mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
init
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
---
|
||||
description: Use Bun instead of Node.js, npm, pnpm, or vite.
|
||||
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
@@ -0,0 +1,19 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
The Bun-based entry point (`index.ts`) lives at the root for quick smoke checks, alongside `tsconfig.json` and the Bun lockfile. The production-ready trading agents reside in `legacy/`, which is a pnpm-managed workspace. Inside `legacy/`, strategy scripts (`trendV2.ts`, `maker.ts`, `bot.ts`) and the CLI live at the top level, shared helpers are under `legacy/utils/`, and exchange adapters plus tests sit in `legacy/exchanges/`. Reference environment samples are provided in `legacy/env.example`, and longer-form docs are kept in `legacy/docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run `bun install` at the root to satisfy the lightweight Bun demo. Change into `legacy/` for real work: `pnpm install` bootstraps dependencies, `pnpm start` launches the default trend strategy, `pnpm maker` starts the market-making loop, `pnpm cli:start` triggers the dual-exchange hedging flow, and `pnpm test` executes the full Vitest suite. Use `pnpm aster:test` when you only need the Aster adapter checks.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
All code is modern TypeScript using native ES modules. Follow the existing two-space indentation, keep imports sorted from external to local, and prefer `camelCase` for variables/functions with `PascalCase` for classes and enums. Strategy files stay in the project root with descriptive verbs (for example `maker.ts`), while shared utilities belong under `legacy/utils/`. Comments should stay concise and explain non-obvious trading logic.
|
||||
|
||||
## Testing Guidelines
|
||||
Vitest powers unit and integration checks. Place new suites beside their subjects using the `<feature>.test.ts` pattern (e.g., `legacy/exchanges/aster.test.ts`). Ensure strategies ship with coverage for order flow, risk guardrails, and websocket edge cases before opening a pull request. Run `pnpm test --watch` during development to keep feedback tight.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
The repository history is empty, so adopt a lightweight Conventional Commits style (for example `feat: add hedging status panel`) to keep future changelogs clear. Commits should stay scoped to one strategy or module. Pull requests need a concise summary, reproduction or validation notes (commands run, environments touched), and screenshots or log excerpts when behavior changes. Link tracking issues or tasks to help downstream coordination.
|
||||
|
||||
## Environment & Secrets
|
||||
Copy `legacy/env.example` to `.env` and populate API keys for Bitget and AsterDex before running live strategies. Never commit secrets; rely on local dotenv files or your deployment platform's secret manager. Rotate keys immediately if logs or configs leave the sandbox.
|
||||
@@ -0,0 +1,15 @@
|
||||
# ritmex-bot
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.21. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ritmex-bot",
|
||||
"dependencies": {
|
||||
"ccxt": "^4.5.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"ink": "^6.3.1",
|
||||
"ink-table": "^3.1.0",
|
||||
"react": "^19.1.1",
|
||||
"ws": "^8.18.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"vitest": "^3.2.4",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@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=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@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/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/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.1.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-YdhtCd19sKRKfAAUsrcC1wzm4JuzJoiX4pOJqIoW2qmKj5WzG/dL8uUJ0361zaXtHqK7gEhOwtAtz7t3Yq3X5g=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"ccxt": ["ccxt@4.5.5", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-AyhwTFLkx4sO985ImIOfumEBox7AHD/iqk5tPGICObUSZG6wTXg0aRzU8Hjz974aCMG4msFwLk3A/iXPKAU4wA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.5.0", "", {}, "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"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-table": ["ink-table@3.1.0", "", { "dependencies": { "object-hash": "^2.0.3" }, "peerDependencies": { "ink": ">=3.0.0", "react": ">=16.8.0" } }, "sha512-qxVb4DIaEaJryvF9uZGydnmP9Hkmas3DCKVpEcBYC0E4eJd3qNgNe+PZKuzgCERFe9LfAS1TNWxCr9+AU4v3YA=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="],
|
||||
|
||||
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
|
||||
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
|
||||
|
||||
"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-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"cli-truncate/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||
}
|
||||
}
|
||||
+4540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ritmex-bot",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"ccxt": "^4.5.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"ink": "^6.3.1",
|
||||
"react": "^19.1.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface TradingConfig {
|
||||
symbol: string;
|
||||
tradeAmount: number;
|
||||
lossLimit: number;
|
||||
trailingProfit: number;
|
||||
trailingCallbackRate: number;
|
||||
profitLockTriggerUsd: number;
|
||||
profitLockOffsetUsd: number;
|
||||
pollIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
klineInterval: string;
|
||||
}
|
||||
|
||||
function parseNumber(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const next = Number(value);
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
}
|
||||
|
||||
export const tradingConfig: TradingConfig = {
|
||||
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
|
||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||
lossLimit: parseNumber(process.env.LOSS_LIMIT, 0.03),
|
||||
trailingProfit: parseNumber(process.env.TRAILING_PROFIT, 0.2),
|
||||
trailingCallbackRate: parseNumber(process.env.TRAILING_CALLBACK_RATE, 0.2),
|
||||
profitLockTriggerUsd: parseNumber(process.env.PROFIT_LOCK_TRIGGER_USD, 0.1),
|
||||
profitLockOffsetUsd: parseNumber(process.env.PROFIT_LOCK_OFFSET_USD, 0.05),
|
||||
pollIntervalMs: parseNumber(process.env.POLL_INTERVAL_MS, 500),
|
||||
maxLogEntries: parseNumber(process.env.MAX_LOG_ENTRIES, 200),
|
||||
klineInterval: process.env.KLINE_INTERVAL ?? "1m",
|
||||
};
|
||||
|
||||
export interface MakerConfig {
|
||||
symbol: string;
|
||||
tradeAmount: number;
|
||||
lossLimit: number;
|
||||
priceChaseThreshold: number;
|
||||
bidOffset: number;
|
||||
askOffset: number;
|
||||
refreshIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
}
|
||||
|
||||
export const makerConfig: MakerConfig = {
|
||||
symbol: process.env.TRADE_SYMBOL ?? "BTCUSDT",
|
||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||
lossLimit: parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.5)),
|
||||
priceChaseThreshold: parseNumber(process.env.MAKER_PRICE_CHASE, 0.5),
|
||||
bidOffset: parseNumber(process.env.MAKER_BID_OFFSET, 0),
|
||||
askOffset: parseNumber(process.env.MAKER_ASK_OFFSET, 0),
|
||||
refreshIntervalMs: parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 1500),
|
||||
maxLogEntries: parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200),
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { MakerConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
} from "../exchanges/types";
|
||||
import { toPrice1Decimal } from "../utils/math";
|
||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||
import {
|
||||
marketClose,
|
||||
OrderLockMap,
|
||||
OrderPendingMap,
|
||||
OrderTimerMap,
|
||||
placeOrder,
|
||||
unlockOperating,
|
||||
} from "./order-coordinator";
|
||||
|
||||
interface DesiredOrder {
|
||||
side: "BUY" | "SELL";
|
||||
price: number;
|
||||
amount: number;
|
||||
reduceOnly: boolean;
|
||||
}
|
||||
|
||||
export interface MakerEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
topBid: number | null;
|
||||
topAsk: number | null;
|
||||
spread: number | null;
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
accountUnrealized: number;
|
||||
openOrders: AsterOrder[];
|
||||
desiredOrders: DesiredOrder[];
|
||||
tradeLog: TradeLogEntry[];
|
||||
lastUpdated: number | null;
|
||||
}
|
||||
|
||||
type MakerEvent = "update";
|
||||
type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
||||
|
||||
const EPS = 1e-5;
|
||||
|
||||
export class MakerEngine {
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
private tickerSnapshot: AsterTicker | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
|
||||
private readonly locks: OrderLockMap = {};
|
||||
private readonly timers: OrderTimerMap = {};
|
||||
private readonly pending: OrderPendingMap = {};
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private desiredOrders: DesiredOrder[] = [];
|
||||
private accountUnrealized = 0;
|
||||
|
||||
constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, this.config.refreshIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: MakerEvent, handler: MakerListener): void {
|
||||
const handlers = this.listeners.get(event) ?? new Set<MakerListener>();
|
||||
handlers.add(handler);
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
|
||||
off(event: MakerEvent, handler: MakerListener): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): MakerEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
this.exchange.watchAccount((snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0");
|
||||
if (Number.isFinite(totalUnrealized)) {
|
||||
this.accountUnrealized = totalUnrealized;
|
||||
}
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchOrders((orders) => {
|
||||
this.syncLocksWithOrders(orders);
|
||||
this.openOrders = Array.isArray(orders) ? orders.filter((order) => order.type !== "MARKET") : [];
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
this.exchange.watchTicker(this.config.symbol, (ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
});
|
||||
|
||||
// Maker strategy does not consume klines, but subscribe to keep parity with other modules
|
||||
this.exchange.watchKlines(this.config.symbol, "1m", () => {
|
||||
/* no-op */
|
||||
});
|
||||
}
|
||||
|
||||
private syncLocksWithOrders(orders: AsterOrder[]): void {
|
||||
Object.keys(this.pending).forEach((type) => {
|
||||
const pendingId = this.pending[type];
|
||||
if (!pendingId) return;
|
||||
const match = orders.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.depthSnapshot);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = this.depthSnapshot!;
|
||||
const bidLevel = depth.bids?.[0];
|
||||
const askLevel = depth.asks?.[0];
|
||||
const topBid = bidLevel ? Number(bidLevel[0]) : undefined;
|
||||
const topAsk = askLevel ? Number(askLevel[0]) : undefined;
|
||||
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset);
|
||||
const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset);
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
const desired: DesiredOrder[] = [];
|
||||
|
||||
if (absPosition < EPS) {
|
||||
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
} else {
|
||||
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||
const closePrice = closeSide === "SELL" ? askPrice : bidPrice;
|
||||
desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true });
|
||||
}
|
||||
|
||||
this.desiredOrders = desired;
|
||||
await this.syncOrders(desired);
|
||||
await this.checkRisk(position, bidPrice, askPrice);
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `做市循环异常: ${String(error)}`);
|
||||
this.emitUpdate();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncOrders(targets: DesiredOrder[]): Promise<void> {
|
||||
const tolerance = this.config.priceChaseThreshold;
|
||||
const unmatched = new Set(targets.map((_, idx) => idx));
|
||||
const toCancel: AsterOrder[] = [];
|
||||
|
||||
for (const order of this.openOrders) {
|
||||
const price = Number(order.price);
|
||||
if (!Number.isFinite(price)) {
|
||||
toCancel.push(order);
|
||||
continue;
|
||||
}
|
||||
const reduceOnly = order.reduceOnly === true;
|
||||
const matchedIndex = targets.findIndex((target, index) => {
|
||||
if (!unmatched.has(index)) return false;
|
||||
if (target.side !== order.side) return false;
|
||||
if (target.reduceOnly !== reduceOnly) return false;
|
||||
return Math.abs(price - target.price) <= tolerance;
|
||||
});
|
||||
if (matchedIndex >= 0) {
|
||||
unmatched.delete(matchedIndex);
|
||||
continue;
|
||||
}
|
||||
toCancel.push(order);
|
||||
}
|
||||
|
||||
for (const order of toCancel) {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||
this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const index of unmatched) {
|
||||
const target = targets[index];
|
||||
if (target.amount < EPS) continue;
|
||||
try {
|
||||
await placeOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
target.side,
|
||||
target.price,
|
||||
target.amount,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
target.reduceOnly
|
||||
);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise<void> {
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
if (absPosition < EPS) return;
|
||||
|
||||
const pnl = position.positionAmt > 0
|
||||
? (bidPrice - position.entryPrice) * absPosition
|
||||
: (position.entryPrice - askPrice) * absPosition;
|
||||
|
||||
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
|
||||
this.tradeLog.push(
|
||||
"stop",
|
||||
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
||||
);
|
||||
try {
|
||||
await this.flushOrders();
|
||||
await marketClose(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||
absPosition,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async flushOrders(): Promise<void> {
|
||||
if (!this.openOrders.length) return;
|
||||
for (const order of this.openOrders) {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId });
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
const snapshot = this.buildSnapshot();
|
||||
const handlers = this.listeners.get("update");
|
||||
if (!handlers) return;
|
||||
handlers.forEach((handler) => handler(snapshot));
|
||||
}
|
||||
|
||||
private buildSnapshot(): MakerEngineSnapshot {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const bid = this.depthSnapshot?.bids?.[0]?.[0];
|
||||
const ask = this.depthSnapshot?.asks?.[0]?.[0];
|
||||
const bidNum = Number(bid);
|
||||
const askNum = Number(ask);
|
||||
const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null;
|
||||
const priceForPnl = position.positionAmt > 0 ? bidNum : askNum;
|
||||
const pnl = Number.isFinite(priceForPnl)
|
||||
? (position.positionAmt > 0
|
||||
? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt)
|
||||
: (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
topBid: Number.isFinite(bidNum) ? bidNum : null,
|
||||
topAsk: Number.isFinite(askNum) ? askNum : null,
|
||||
spread,
|
||||
position,
|
||||
pnl,
|
||||
accountUnrealized: this.accountUnrealized,
|
||||
openOrders: this.openOrders,
|
||||
desiredOrders: this.desiredOrders,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
||||
import { toPrice1Decimal, toQty3Decimal } from "../utils/math";
|
||||
|
||||
export type OrderLockMap = Record<string, boolean>;
|
||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||
export type OrderPendingMap = Record<string, string | null>;
|
||||
export type LogHandler = (type: string, detail: string) => void;
|
||||
|
||||
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
||||
return Boolean(locks[type]);
|
||||
}
|
||||
|
||||
export function lockOperating(
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string,
|
||||
log: LogHandler,
|
||||
timeout = 3000
|
||||
): void {
|
||||
locks[type] = true;
|
||||
if (timers[type]) {
|
||||
clearTimeout(timers[type]!);
|
||||
}
|
||||
timers[type] = setTimeout(() => {
|
||||
locks[type] = false;
|
||||
pendings[type] = null;
|
||||
log("error", `${type} 操作超时自动解锁`);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
export function unlockOperating(
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string
|
||||
): void {
|
||||
locks[type] = false;
|
||||
pendings[type] = null;
|
||||
if (timers[type]) {
|
||||
clearTimeout(timers[type]!);
|
||||
}
|
||||
timers[type] = null;
|
||||
}
|
||||
|
||||
export async function deduplicateOrders(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
type: string,
|
||||
side: string,
|
||||
log: LogHandler
|
||||
): Promise<void> {
|
||||
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side);
|
||||
if (sameTypeOrders.length <= 1) return;
|
||||
sameTypeOrders.sort((a, b) => {
|
||||
const ta = b.updateTime || b.time || 0;
|
||||
const tb = a.updateTime || a.time || 0;
|
||||
return ta - tb;
|
||||
});
|
||||
const toCancel = sameTypeOrders.slice(1);
|
||||
const orderIdList = toCancel.map((o) => o.orderId);
|
||||
if (!orderIdList.length) return;
|
||||
try {
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
await adapter.cancelOrders({ symbol, orderIdList });
|
||||
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
||||
} catch (err) {
|
||||
log("error", `去重撤单失败: ${String(err)}`);
|
||||
} finally {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
price: number,
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "LIMIT";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(amount),
|
||||
price: toPrice1Decimal(price),
|
||||
timeInForce: "GTX",
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeMarketOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
amount: number,
|
||||
log: LogHandler,
|
||||
reduceOnly = false
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(amount),
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeStopLossOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
stopPrice: number,
|
||||
quantity: number,
|
||||
lastPrice: number | null,
|
||||
log: LogHandler
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (lastPrice != null) {
|
||||
if (side === "SELL" && stopPrice >= lastPrice) {
|
||||
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
|
||||
return;
|
||||
}
|
||||
if (side === "BUY" && stopPrice <= lastPrice) {
|
||||
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
stopPrice: toPrice1Decimal(stopPrice),
|
||||
closePosition: "true",
|
||||
timeInForce: "GTC",
|
||||
quantity: toQty3Decimal(quantity),
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function placeTrailingStopOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
activationPrice: number,
|
||||
quantity: number,
|
||||
callbackRate: number,
|
||||
log: LogHandler
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "TRAILING_STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(quantity),
|
||||
reduceOnly: "true",
|
||||
activationPrice: toPrice1Decimal(activationPrice),
|
||||
callbackRate,
|
||||
timeInForce: "GTC",
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log(
|
||||
"order",
|
||||
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}`
|
||||
);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function marketClose(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
openOrders: AsterOrder[],
|
||||
locks: OrderLockMap,
|
||||
timers: OrderTimerMap,
|
||||
pendings: OrderPendingMap,
|
||||
side: "BUY" | "SELL",
|
||||
quantity: number,
|
||||
log: LogHandler
|
||||
): Promise<void> {
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: toQty3Decimal(quantity),
|
||||
reduceOnly: "true",
|
||||
};
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
pendings[type] = String(order.orderId);
|
||||
log("close", `市价平仓: ${side}`);
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import type { TradingConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
} from "../exchanges/types";
|
||||
import {
|
||||
calcStopLossPrice,
|
||||
calcTrailingActivationPrice,
|
||||
getPosition,
|
||||
getSMA,
|
||||
type PositionSnapshot,
|
||||
} from "../utils/strategy";
|
||||
import {
|
||||
marketClose,
|
||||
OrderLockMap,
|
||||
OrderPendingMap,
|
||||
OrderTimerMap,
|
||||
placeMarketOrder,
|
||||
placeStopLossOrder,
|
||||
placeTrailingStopOrder,
|
||||
unlockOperating,
|
||||
} from "./order-coordinator";
|
||||
import { toPrice1Decimal } from "../utils/math";
|
||||
import { createTradeLog, type TradeLogEntry } from "../state/trade-log";
|
||||
|
||||
export interface TrendEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
lastPrice: number | null;
|
||||
sma30: number | null;
|
||||
trend: "做多" | "做空" | "无信号";
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
unrealized: number;
|
||||
totalProfit: number;
|
||||
totalTrades: number;
|
||||
tradeLog: TradeLogEntry[];
|
||||
openOrders: AsterOrder[];
|
||||
depth: AsterDepth | null;
|
||||
ticker: AsterTicker | null;
|
||||
lastUpdated: number | null;
|
||||
lastOpenSignal: OpenOrderPlan;
|
||||
}
|
||||
|
||||
export interface OpenOrderPlan {
|
||||
side: "BUY" | "SELL" | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
type TrendEngineEvent = "update";
|
||||
|
||||
type TrendEngineListener = (snapshot: TrendEngineSnapshot) => void;
|
||||
|
||||
export class TrendEngine {
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
private tickerSnapshot: AsterTicker | null = null;
|
||||
private klineSnapshot: AsterKline[] = [];
|
||||
|
||||
private readonly locks: OrderLockMap = {};
|
||||
private readonly timers: OrderTimerMap = {};
|
||||
private readonly pending: OrderPendingMap = {};
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private lastPrice: number | null = null;
|
||||
private lastSma30: number | null = null;
|
||||
private totalProfit = 0;
|
||||
private totalTrades = 0;
|
||||
private lastOpenPlan: OpenOrderPlan = { side: null, price: null };
|
||||
|
||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||
|
||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||
const handlers = this.listeners.get(event) ?? new Set<TrendEngineListener>();
|
||||
handlers.add(handler);
|
||||
this.listeners.set(event, handlers);
|
||||
}
|
||||
|
||||
off(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||
const handlers = this.listeners.get(event);
|
||||
if (!handlers) return;
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(): TrendEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
this.exchange.watchAccount((snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchOrders((orders) => {
|
||||
this.synchronizeLocks(orders);
|
||||
this.openOrders = Array.isArray(orders)
|
||||
? orders.filter((order) => order.type !== "MARKET")
|
||||
: [];
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchDepth(this.config.symbol, (depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchTicker(this.config.symbol, (ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
});
|
||||
this.exchange.watchKlines(this.config.symbol, this.config.klineInterval, (klines) => {
|
||||
this.klineSnapshot = klines;
|
||||
this.emitUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
private synchronizeLocks(orders: AsterOrder[]): void {
|
||||
Object.keys(this.pending).forEach((type) => {
|
||||
const pendingId = this.pending[type];
|
||||
if (!pendingId) return;
|
||||
const match = orders.find((order) => String(order.orderId) === pendingId);
|
||||
if (!match || (match.status && match.status !== "NEW")) {
|
||||
unlockOperating(this.locks, this.timers, this.pending, type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isReady(): boolean {
|
||||
return Boolean(
|
||||
this.accountSnapshot &&
|
||||
this.tickerSnapshot &&
|
||||
this.depthSnapshot &&
|
||||
this.klineSnapshot.length >= 30
|
||||
);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
const sma30 = getSMA(this.klineSnapshot, 30);
|
||||
if (sma30 == null) {
|
||||
return;
|
||||
}
|
||||
const ticker = this.tickerSnapshot!;
|
||||
const price = Number(ticker.lastPrice);
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
|
||||
if (Math.abs(position.positionAmt) < 1e-5) {
|
||||
await this.handleOpenPosition(price, sma30);
|
||||
} else {
|
||||
const result = await this.handlePositionManagement(position, price);
|
||||
if (result.closed) {
|
||||
this.totalTrades += 1;
|
||||
this.totalProfit += result.pnl;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSma30 = sma30;
|
||||
this.lastPrice = price;
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `策略循环异常: ${String(error)}`);
|
||||
this.emitUpdate();
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOpenPosition(currentPrice: number, currentSma: number): Promise<void> {
|
||||
if (this.lastPrice == null) {
|
||||
this.lastPrice = currentPrice;
|
||||
return;
|
||||
}
|
||||
if (this.openOrders.length > 0) {
|
||||
try {
|
||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `撤销挂单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
if (this.lastPrice > currentSma && currentPrice < currentSma) {
|
||||
await this.submitMarketOrder("SELL", currentPrice, "下穿SMA30,市价开空");
|
||||
} else if (this.lastPrice < currentSma && currentPrice > currentSma) {
|
||||
await this.submitMarketOrder("BUY", currentPrice, "上穿SMA30,市价开多");
|
||||
}
|
||||
}
|
||||
|
||||
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
||||
try {
|
||||
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)
|
||||
);
|
||||
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
||||
this.lastOpenPlan = { side, price };
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `市价下单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePositionManagement(
|
||||
position: PositionSnapshot,
|
||||
price: number
|
||||
): Promise<{ closed: boolean; pnl: number }> {
|
||||
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||
const pnl =
|
||||
(direction === "long"
|
||||
? price - position.entryPrice
|
||||
: position.entryPrice - price) * Math.abs(position.positionAmt);
|
||||
const stopSide = direction === "long" ? "SELL" : "BUY";
|
||||
const stopPrice = calcStopLossPrice(
|
||||
position.entryPrice,
|
||||
Math.abs(position.positionAmt),
|
||||
direction,
|
||||
this.config.lossLimit
|
||||
);
|
||||
const activationPrice = calcTrailingActivationPrice(
|
||||
position.entryPrice,
|
||||
Math.abs(position.positionAmt),
|
||||
direction,
|
||||
this.config.trailingProfit
|
||||
);
|
||||
|
||||
const currentStop = this.openOrders.find(
|
||||
(o) => o.type === "STOP_MARKET" && o.side === stopSide
|
||||
);
|
||||
const currentTrailing = this.openOrders.find(
|
||||
(o) => o.type === "TRAILING_STOP_MARKET" && o.side === stopSide
|
||||
);
|
||||
|
||||
const profitLockStopPrice = direction === "long"
|
||||
? toPrice1Decimal(
|
||||
position.entryPrice + this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
||||
)
|
||||
: toPrice1Decimal(
|
||||
position.entryPrice - this.config.profitLockOffsetUsd / Math.abs(position.positionAmt)
|
||||
);
|
||||
|
||||
if (pnl > this.config.profitLockTriggerUsd || position.unrealizedProfit > this.config.profitLockTriggerUsd) {
|
||||
if (!currentStop) {
|
||||
await this.tryPlaceStopLoss(stopSide, profitLockStopPrice, price);
|
||||
} else {
|
||||
const existingPrice = Number(currentStop.stopPrice);
|
||||
if (Math.abs(existingPrice - profitLockStopPrice) > 0.01) {
|
||||
await this.tryReplaceStop(stopSide, currentStop, profitLockStopPrice, price);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentStop) {
|
||||
await this.tryPlaceStopLoss(stopSide, toPrice1Decimal(stopPrice), price);
|
||||
}
|
||||
|
||||
if (!currentTrailing) {
|
||||
await this.tryPlaceTrailingStop(
|
||||
stopSide,
|
||||
toPrice1Decimal(activationPrice),
|
||||
Math.abs(position.positionAmt)
|
||||
);
|
||||
}
|
||||
|
||||
if (pnl < -this.config.lossLimit || position.unrealizedProfit < -this.config.lossLimit) {
|
||||
try {
|
||||
if (this.openOrders.length > 0) {
|
||||
const orderIdList = this.openOrders.map((order) => order.orderId);
|
||||
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
||||
}
|
||||
await marketClose(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
direction === "long" ? "SELL" : "BUY",
|
||||
this.config.tradeAmount,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `止损平仓失败: ${String(err)}`);
|
||||
}
|
||||
return { closed: true, pnl };
|
||||
}
|
||||
|
||||
return { closed: false, pnl };
|
||||
}
|
||||
|
||||
private async tryPlaceStopLoss(
|
||||
side: "BUY" | "SELL",
|
||||
stopPrice: number,
|
||||
lastPrice: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
stopPrice,
|
||||
this.config.tradeAmount,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryReplaceStop(
|
||||
side: "BUY" | "SELL",
|
||||
currentOrder: AsterOrder,
|
||||
nextStopPrice: number,
|
||||
lastPrice: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
||||
}
|
||||
await this.tryPlaceStopLoss(side, nextStopPrice, lastPrice);
|
||||
this.tradeLog.push("stop", `移动止损到 ${nextStopPrice}`);
|
||||
}
|
||||
|
||||
private async tryPlaceTrailingStop(
|
||||
side: "BUY" | "SELL",
|
||||
activationPrice: number,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await placeTrailingStopOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
activationPrice,
|
||||
quantity,
|
||||
this.config.trailingCallbackRate,
|
||||
(type, detail) => this.tradeLog.push(type, detail)
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
const snapshot = this.buildSnapshot();
|
||||
const handlers = this.listeners.get("update");
|
||||
if (!handlers) return;
|
||||
handlers.forEach((handler) => handler(snapshot));
|
||||
}
|
||||
|
||||
private buildSnapshot(): TrendEngineSnapshot {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||
const sma30 = this.lastSma30;
|
||||
const trend = price == null || sma30 == null
|
||||
? "无信号"
|
||||
: price > sma30
|
||||
? "做多"
|
||||
: price < sma30
|
||||
? "做空"
|
||||
: "无信号";
|
||||
const pnl = price != null && position
|
||||
? (position.positionAmt > 0
|
||||
? (price - position.entryPrice) * Math.abs(position.positionAmt)
|
||||
: (position.entryPrice - price) * Math.abs(position.positionAmt))
|
||||
: 0;
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
lastPrice: price,
|
||||
sma30,
|
||||
trend,
|
||||
position,
|
||||
pnl,
|
||||
unrealized: position.unrealizedProfit,
|
||||
totalProfit: this.totalProfit,
|
||||
totalTrades: this.totalTrades,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
openOrders: this.openOrders,
|
||||
depth: this.depthSnapshot,
|
||||
ticker: this.tickerSnapshot,
|
||||
lastUpdated: Date.now(),
|
||||
lastOpenSignal: this.lastOpenPlan,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterOrder,
|
||||
AsterDepth,
|
||||
AsterTicker,
|
||||
AsterKline,
|
||||
CreateOrderParams,
|
||||
} from "./types";
|
||||
|
||||
export interface AccountListener {
|
||||
(snapshot: AsterAccountSnapshot): void;
|
||||
}
|
||||
|
||||
export interface OrderListener {
|
||||
(orders: AsterOrder[]): void;
|
||||
}
|
||||
|
||||
export interface DepthListener {
|
||||
(depth: AsterDepth): void;
|
||||
}
|
||||
|
||||
export interface TickerListener {
|
||||
(ticker: AsterTicker): void;
|
||||
}
|
||||
|
||||
export interface KlineListener {
|
||||
(klines: AsterKline[]): void;
|
||||
}
|
||||
|
||||
export interface ExchangeAdapter {
|
||||
readonly id: string;
|
||||
watchAccount(cb: AccountListener): void;
|
||||
watchOrders(cb: OrderListener): void;
|
||||
watchDepth(symbol: string, cb: DepthListener): void;
|
||||
watchTicker(symbol: string, cb: TickerListener): void;
|
||||
watchKlines(symbol: string, interval: string, cb: KlineListener): void;
|
||||
createOrder(params: CreateOrderParams): Promise<AsterOrder>;
|
||||
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
|
||||
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
||||
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
AccountListener,
|
||||
DepthListener,
|
||||
ExchangeAdapter,
|
||||
KlineListener,
|
||||
OrderListener,
|
||||
TickerListener,
|
||||
} from "./adapter";
|
||||
import type { AsterOrder, CreateOrderParams, AsterDepth, AsterTicker, AsterKline } from "./types";
|
||||
import { AsterGateway } from "./aster/client";
|
||||
|
||||
export interface AsterCredentials {
|
||||
apiKey?: string;
|
||||
apiSecret?: string;
|
||||
symbol?: string;
|
||||
}
|
||||
|
||||
export class AsterExchangeAdapter implements ExchangeAdapter {
|
||||
readonly id = "aster";
|
||||
private readonly gateway: AsterGateway;
|
||||
private readonly symbol: string;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(credentials: AsterCredentials = {}) {
|
||||
this.gateway = new AsterGateway({ apiKey: credentials.apiKey, apiSecret: credentials.apiSecret });
|
||||
this.symbol = (credentials.symbol ?? process.env.TRADE_SYMBOL ?? "BTCUSDT").toUpperCase();
|
||||
}
|
||||
|
||||
private ensureInitialized(): Promise<void> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.gateway.ensureInitialized(this.symbol);
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
watchAccount(cb: AccountListener): void {
|
||||
void this.ensureInitialized();
|
||||
this.gateway.onAccount((snapshot) => {
|
||||
cb(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
watchOrders(cb: OrderListener): void {
|
||||
void this.ensureInitialized();
|
||||
this.gateway.onOrders((orders) => {
|
||||
cb(orders);
|
||||
});
|
||||
}
|
||||
|
||||
watchDepth(symbol: string, cb: DepthListener): void {
|
||||
void this.ensureInitialized();
|
||||
this.gateway.onDepth(symbol, (depth: AsterDepth) => {
|
||||
cb(depth);
|
||||
});
|
||||
}
|
||||
|
||||
watchTicker(symbol: string, cb: TickerListener): void {
|
||||
void this.ensureInitialized();
|
||||
this.gateway.onTicker(symbol, (ticker: AsterTicker) => {
|
||||
cb(ticker);
|
||||
});
|
||||
}
|
||||
|
||||
watchKlines(symbol: string, interval: string, cb: KlineListener): void {
|
||||
void this.ensureInitialized();
|
||||
this.gateway.onKlines(symbol, interval, (klines: AsterKline[]) => {
|
||||
cb(klines);
|
||||
});
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
await this.ensureInitialized();
|
||||
return this.gateway.createOrder(params);
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.gateway.cancelOrder({ symbol: params.symbol, orderId: Number(params.orderId) });
|
||||
}
|
||||
|
||||
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.gateway.cancelOrders({ symbol: params.symbol, orderIdList: params.orderIdList });
|
||||
}
|
||||
|
||||
async cancelAllOrders(params: { symbol: string }): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.gateway.cancelAllOrders(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
import crypto from "crypto";
|
||||
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
CreateOrderParams,
|
||||
} from "../types";
|
||||
|
||||
const REST_BASE = "https://fapi.asterdex.com";
|
||||
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
|
||||
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
|
||||
|
||||
const FINAL_ORDER_STATUSES = new Set(["FILLED", "CANCELED", "REJECTED", "EXPIRED"]);
|
||||
const DEFAULT_DEPTH_LEVEL = 20;
|
||||
const DEFAULT_DEPTH_SPEED = "100ms";
|
||||
const DEFAULT_KLINE_LIMIT = 120;
|
||||
const KLINE_REFRESH_INTERVAL_MS = 60_000;
|
||||
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
|
||||
function requireEnv(value: string | undefined, key: string): string {
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toDepth(streamSymbol: string, data: any): AsterDepth {
|
||||
return {
|
||||
eventType: data.e,
|
||||
eventTime: data.E,
|
||||
tradeTime: data.T,
|
||||
symbol: streamSymbol,
|
||||
lastUpdateId: data.u,
|
||||
bids: (data.b ?? []).map(([price, qty]: [string, string]) => [price, qty]),
|
||||
asks: (data.a ?? []).map(([price, qty]: [string, string]) => [price, qty]),
|
||||
};
|
||||
}
|
||||
|
||||
function toTicker(data: any): AsterTicker {
|
||||
return {
|
||||
eventType: data.e,
|
||||
eventTime: data.E,
|
||||
symbol: data.s,
|
||||
lastPrice: data.c,
|
||||
openPrice: data.o,
|
||||
highPrice: data.h,
|
||||
lowPrice: data.l,
|
||||
volume: data.q ?? data.v ?? "0",
|
||||
quoteVolume: data.Q ?? data.V ?? "0",
|
||||
priceChange: data.p,
|
||||
priceChangePercent: data.P,
|
||||
weightedAvgPrice: data.w,
|
||||
lastQty: data.l ?? data.L,
|
||||
openTime: data.O,
|
||||
closeTime: data.C,
|
||||
firstId: data.F,
|
||||
lastId: data.L,
|
||||
count: data.n,
|
||||
};
|
||||
}
|
||||
|
||||
function toKline(data: any): AsterKline {
|
||||
return {
|
||||
eventType: data.e,
|
||||
eventTime: data.E,
|
||||
symbol: data.s,
|
||||
interval: data.k.i,
|
||||
openTime: data.k.t,
|
||||
closeTime: data.k.T,
|
||||
firstTradeId: data.k.f,
|
||||
lastTradeId: data.k.L,
|
||||
open: data.k.o,
|
||||
high: data.k.h,
|
||||
low: data.k.l,
|
||||
close: data.k.c,
|
||||
volume: data.k.v,
|
||||
numberOfTrades: data.k.n,
|
||||
quoteAssetVolume: data.k.q,
|
||||
takerBuyBaseAssetVolume: data.k.V,
|
||||
takerBuyQuoteAssetVolume: data.k.Q,
|
||||
isClosed: Boolean(data.k.x),
|
||||
};
|
||||
}
|
||||
|
||||
function fromRestKline(entry: any[], interval: string, symbol: string): AsterKline {
|
||||
return {
|
||||
eventType: undefined,
|
||||
eventTime: undefined,
|
||||
symbol,
|
||||
interval,
|
||||
openTime: entry[0],
|
||||
open: entry[1],
|
||||
high: entry[2],
|
||||
low: entry[3],
|
||||
close: entry[4],
|
||||
volume: entry[5],
|
||||
closeTime: entry[6],
|
||||
quoteAssetVolume: entry[7],
|
||||
numberOfTrades: entry[8],
|
||||
takerBuyBaseAssetVolume: entry[9],
|
||||
takerBuyQuoteAssetVolume: entry[10],
|
||||
isClosed: Boolean(entry[11]),
|
||||
} as AsterKline;
|
||||
}
|
||||
|
||||
function toOrderFromRest(raw: any): AsterOrder {
|
||||
return {
|
||||
avgPrice: raw.avgPrice ?? "0",
|
||||
clientOrderId: raw.clientOrderId ?? "",
|
||||
cumQuote: raw.cumQuote ?? "0",
|
||||
executedQty: raw.executedQty ?? "0",
|
||||
orderId: raw.orderId,
|
||||
origQty: raw.origQty ?? raw.quantity ?? "0",
|
||||
origType: raw.origType ?? raw.type ?? "",
|
||||
price: raw.price ?? "0",
|
||||
reduceOnly: Boolean(raw.reduceOnly),
|
||||
side: raw.side ?? "",
|
||||
positionSide: raw.positionSide ?? "BOTH",
|
||||
status: raw.status ?? "NEW",
|
||||
stopPrice: raw.stopPrice ?? raw.triggerPrice ?? "0",
|
||||
closePosition: Boolean(raw.closePosition),
|
||||
symbol: raw.symbol ?? "",
|
||||
time: raw.time ?? raw.updateTime ?? Date.now(),
|
||||
timeInForce: raw.timeInForce ?? "GTC",
|
||||
type: raw.type ?? "LIMIT",
|
||||
activatePrice: raw.activatePrice,
|
||||
priceRate: raw.priceRate,
|
||||
updateTime: raw.updateTime ?? Date.now(),
|
||||
workingType: raw.workingType ?? "CONTRACT_PRICE",
|
||||
priceProtect: Boolean(raw.priceProtect),
|
||||
};
|
||||
}
|
||||
|
||||
function toOrderFromEvent(event: any): AsterOrder {
|
||||
return {
|
||||
avgPrice: event.ap ?? "0",
|
||||
clientOrderId: event.c ?? "",
|
||||
cumQuote: event.z ?? "0",
|
||||
executedQty: event.z ?? "0",
|
||||
orderId: event.i,
|
||||
origQty: event.q ?? "0",
|
||||
origType: event.ot ?? event.o ?? "",
|
||||
price: event.p ?? "0",
|
||||
reduceOnly: Boolean(event.R),
|
||||
side: event.S,
|
||||
positionSide: event.ps ?? "BOTH",
|
||||
status: event.X,
|
||||
stopPrice: event.sp ?? "0",
|
||||
closePosition: Boolean(event.cp),
|
||||
symbol: event.s,
|
||||
time: event.T ?? Date.now(),
|
||||
timeInForce: event.f ?? "GTC",
|
||||
type: event.o ?? "LIMIT",
|
||||
activatePrice: event.AP,
|
||||
priceRate: event.cr,
|
||||
updateTime: event.T ?? Date.now(),
|
||||
workingType: event.wt ?? "CONTRACT_PRICE",
|
||||
priceProtect: Boolean(event.PP),
|
||||
};
|
||||
}
|
||||
|
||||
function deepCloneAccount(snapshot: AsterAccountSnapshot | null): AsterAccountSnapshot | null {
|
||||
return snapshot ? JSON.parse(JSON.stringify(snapshot)) : null;
|
||||
}
|
||||
|
||||
class SimpleEvent<T> {
|
||||
private readonly listeners = new Set<(payload: T) => void>();
|
||||
|
||||
add(listener: (payload: T) => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
remove(listener: (payload: T) => void): void {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(payload: T): void {
|
||||
for (const listener of Array.from(this.listeners)) {
|
||||
try {
|
||||
listener(payload);
|
||||
} catch (error) {
|
||||
console.error("[SimpleEvent] listener failure", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listenerCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListenKeyResponse {
|
||||
listenKey: string;
|
||||
}
|
||||
|
||||
export class AsterRestClient {
|
||||
private readonly apiKey: string;
|
||||
private readonly apiSecret: string;
|
||||
|
||||
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
|
||||
this.apiKey = requireEnv(options.apiKey ?? process.env.ASTER_API_KEY, "ASTER_API_KEY");
|
||||
this.apiSecret = requireEnv(options.apiSecret ?? process.env.ASTER_API_SECRET, "ASTER_API_SECRET");
|
||||
}
|
||||
|
||||
async getAccount(): Promise<AsterAccountSnapshot> {
|
||||
return this.signedRequest<AsterAccountSnapshot>({ path: "/fapi/v2/account", method: "GET", params: {} });
|
||||
}
|
||||
|
||||
async getOpenOrders(symbol?: string): Promise<AsterOrder[]> {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (symbol) params.symbol = symbol;
|
||||
const raw = await this.signedRequest<any[]>({ path: "/fapi/v1/openOrders", method: "GET", params });
|
||||
return raw.map(toOrderFromRest);
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
const payload: Record<string, unknown> = { ...params };
|
||||
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<AsterOrder> {
|
||||
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "DELETE", params });
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
|
||||
async cancelOrders(params: { symbol: string; orderIdList?: Array<number | string>; origClientOrderIdList?: string[] }): Promise<AsterOrder[]> {
|
||||
const payload: Record<string, unknown> = { symbol: params.symbol };
|
||||
if (params.orderIdList) payload.orderIdList = JSON.stringify(params.orderIdList.map((id) => Number(id)));
|
||||
if (params.origClientOrderIdList) payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList);
|
||||
const response = await this.signedRequest<any[]>({ path: "/fapi/v1/batchOrders", method: "DELETE", params: payload });
|
||||
return response.map(toOrderFromRest);
|
||||
}
|
||||
|
||||
async cancelAllOrders(params: { symbol: string }): Promise<void> {
|
||||
await this.signedRequest({ path: "/fapi/v1/allOpenOrders", method: "DELETE", params });
|
||||
}
|
||||
|
||||
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
||||
const upper = symbol.toUpperCase();
|
||||
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status} ${text}`);
|
||||
}
|
||||
const payload = (await response.json()) as any[];
|
||||
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
||||
}
|
||||
|
||||
async getListenKey(): Promise<string> {
|
||||
const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} });
|
||||
return response.listenKey;
|
||||
}
|
||||
|
||||
async keepAliveListenKey(listenKey: string): Promise<void> {
|
||||
await this.signedRequest({ path: "/fapi/v1/listenKey", method: "PUT", params: { listenKey } });
|
||||
}
|
||||
|
||||
async closeListenKey(listenKey: string): Promise<void> {
|
||||
await this.signedRequest({ path: "/fapi/v1/listenKey", method: "DELETE", params: { listenKey } });
|
||||
}
|
||||
|
||||
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
|
||||
const timestamp = Date.now();
|
||||
const payload = { ...params, timestamp, recvWindow: 5000 };
|
||||
const query = this.serialize(payload);
|
||||
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
|
||||
const url = `${REST_BASE}${path}?${query}&signature=${signature}`;
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
"X-MBX-APIKEY": this.apiKey,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
};
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status} ${text}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private serialize(params: Record<string, unknown>): string {
|
||||
return Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
|
||||
.join("&");
|
||||
}
|
||||
}
|
||||
|
||||
type DepthHandler = (depth: AsterDepth) => void;
|
||||
type TickerHandler = (ticker: AsterTicker) => void;
|
||||
type KlineHandler = (kline: AsterKline) => void;
|
||||
|
||||
type StreamKind = "depth" | "ticker" | "kline";
|
||||
|
||||
interface StreamState {
|
||||
stream: string;
|
||||
kind: StreamKind;
|
||||
symbol: string;
|
||||
interval?: string;
|
||||
}
|
||||
|
||||
export class AsterPublicStreams {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly streams = new Map<string, StreamState>();
|
||||
private readonly depthHandlers = new Map<string, Set<DepthHandler>>();
|
||||
private readonly tickerHandlers = new Map<string, Set<TickerHandler>>();
|
||||
private readonly klineHandlers = new Map<string, Set<KlineHandler>>();
|
||||
private nextRequestId = 1;
|
||||
|
||||
subscribeDepth(symbol: string, handler: DepthHandler): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
const stream = `${upper.toLowerCase()}@depth${DEFAULT_DEPTH_LEVEL}@${DEFAULT_DEPTH_SPEED}`;
|
||||
this.addHandler(this.depthHandlers, upper, handler);
|
||||
this.registerStream(stream, { stream, kind: "depth", symbol: upper });
|
||||
}
|
||||
|
||||
subscribeTicker(symbol: string, handler: TickerHandler): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
const stream = `${upper.toLowerCase()}@miniTicker`;
|
||||
this.addHandler(this.tickerHandlers, upper, handler);
|
||||
this.registerStream(stream, { stream, kind: "ticker", symbol: upper });
|
||||
}
|
||||
|
||||
subscribeKline(symbol: string, interval: string, handler: KlineHandler): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
const stream = `${upper.toLowerCase()}@kline_${interval}`;
|
||||
this.addHandler(this.klineHandlers, `${upper}:${interval}`, handler);
|
||||
this.registerStream(stream, { stream, kind: "kline", symbol: upper, interval });
|
||||
}
|
||||
|
||||
private addHandler<T>(map: Map<string, Set<T>>, key: string, handler: T): void {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
set.add(handler);
|
||||
this.ensureConnection();
|
||||
}
|
||||
|
||||
private registerStream(stream: string, state: StreamState): void {
|
||||
if (!this.streams.has(stream)) {
|
||||
this.streams.set(stream, state);
|
||||
this.send({ method: "SUBSCRIBE", params: [stream], id: this.nextRequestId++ });
|
||||
}
|
||||
}
|
||||
|
||||
private ensureConnection(): void {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.ws = new WebSocket(WS_PUBLIC_URL);
|
||||
this.ws.onopen = () => {
|
||||
const streams = Array.from(this.streams.keys());
|
||||
if (streams.length) {
|
||||
this.send({ method: "SUBSCRIBE", params: streams, id: this.nextRequestId++ });
|
||||
}
|
||||
};
|
||||
this.ws.onmessage = (event) => {
|
||||
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
||||
if (!payload) return;
|
||||
if (payload.result !== undefined) return; // subscription ack
|
||||
const data = payload.data ?? payload;
|
||||
if (!data.e) return;
|
||||
switch (data.e) {
|
||||
case "depthUpdate":
|
||||
this.dispatchDepth(data);
|
||||
break;
|
||||
case "24hrMiniTicker":
|
||||
this.dispatchTicker(data);
|
||||
break;
|
||||
case "kline":
|
||||
this.dispatchKline(data);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimeout) return;
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.connect();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
|
||||
private send(message: Record<string, unknown>): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchDepth(data: any): void {
|
||||
const symbol = String(data.s ?? "").toUpperCase();
|
||||
const handlers = this.depthHandlers.get(symbol);
|
||||
if (!handlers || !handlers.size) return;
|
||||
const depth = toDepth(symbol, data);
|
||||
handlers.forEach((handler) => handler(depth));
|
||||
}
|
||||
|
||||
private dispatchTicker(data: any): void {
|
||||
const symbol = String(data.s ?? "").toUpperCase();
|
||||
const handlers = this.tickerHandlers.get(symbol);
|
||||
if (!handlers || !handlers.size) return;
|
||||
const ticker = toTicker(data);
|
||||
handlers.forEach((handler) => handler(ticker));
|
||||
}
|
||||
|
||||
private dispatchKline(data: any): void {
|
||||
const symbol = String(data.s ?? "").toUpperCase();
|
||||
const interval = data.k?.i ?? "";
|
||||
const key = `${symbol}:${interval}`;
|
||||
const handlers = this.klineHandlers.get(key);
|
||||
if (!handlers || !handlers.size) return;
|
||||
const kline = toKline(data);
|
||||
handlers.forEach((handler) => handler(kline));
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountUpdatePayload {
|
||||
B: Array<{ a: string; wb: string; cw: string; bc: string; wbBalance?: string; } & Record<string, string>>;
|
||||
P: Array<{ s: string; pa: string; ep: string; cr: string; up: string; mt: string; iw?: string; ps: string; pc?: string; } & Record<string, string>>;
|
||||
}
|
||||
|
||||
interface OrderUpdatePayload extends Record<string, any> {}
|
||||
|
||||
export class AsterUserStream {
|
||||
private readonly rest: AsterRestClient;
|
||||
private listenKey: string | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private keepAliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly accountEvent = new SimpleEvent<{ eventTime: number; payload: AccountUpdatePayload }>();
|
||||
private readonly orderEvent = new SimpleEvent<{ eventTime: number; payload: OrderUpdatePayload }>();
|
||||
private isRunning = false;
|
||||
|
||||
constructor(rest: AsterRestClient) {
|
||||
this.rest = rest;
|
||||
}
|
||||
|
||||
onAccount(listener: (payload: { eventTime: number; payload: AccountUpdatePayload }) => void): void {
|
||||
this.accountEvent.add(listener);
|
||||
}
|
||||
|
||||
onOrder(listener: (payload: { eventTime: number; payload: OrderUpdatePayload }) => void): void {
|
||||
this.orderEvent.add(listener);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
await this.ensureListenKey();
|
||||
this.openSocket();
|
||||
this.scheduleKeepAlive();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isRunning = false;
|
||||
if (this.keepAliveTimer) {
|
||||
clearInterval(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
if (this.listenKey) {
|
||||
void this.rest.closeListenKey(this.listenKey).catch(() => undefined);
|
||||
this.listenKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureListenKey(): Promise<void> {
|
||||
if (this.listenKey) return;
|
||||
this.listenKey = await this.rest.getListenKey();
|
||||
}
|
||||
|
||||
private scheduleKeepAlive(): void {
|
||||
if (this.keepAliveTimer) return;
|
||||
this.keepAliveTimer = setInterval(() => {
|
||||
if (!this.listenKey) return;
|
||||
void this.rest.keepAliveListenKey(this.listenKey).catch((error) => {
|
||||
console.error("[AsterUserStream] keepAlive error", error);
|
||||
});
|
||||
}, LISTEN_KEY_KEEPALIVE_MS / 2);
|
||||
}
|
||||
|
||||
private openSocket(): void {
|
||||
if (!this.listenKey) return;
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const url = `${WS_LISTEN_KEY_URL}${this.listenKey}`;
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = () => {
|
||||
// no-op
|
||||
};
|
||||
this.ws.onmessage = (event) => {
|
||||
const payload = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
||||
if (!payload) return;
|
||||
if (payload === "ping") {
|
||||
this.ws?.send("pong");
|
||||
return;
|
||||
}
|
||||
switch (payload.e) {
|
||||
case "ACCOUNT_UPDATE":
|
||||
this.accountEvent.emit({ eventTime: payload.E, payload: payload.a });
|
||||
break;
|
||||
case "ORDER_TRADE_UPDATE":
|
||||
this.orderEvent.emit({ eventTime: payload.E, payload: payload.o });
|
||||
break;
|
||||
case "listenKeyExpired":
|
||||
this.handleListenKeyExpired();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
private async handleListenKeyExpired(): Promise<void> {
|
||||
this.listenKey = null;
|
||||
await this.ensureListenKey();
|
||||
this.openSocket();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.isRunning) return;
|
||||
if (this.reconnectTimeout) return;
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectTimeout = null;
|
||||
this.openSocket();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccountSnapshot(snapshot: AsterAccountSnapshot | null, event: { eventTime: number; payload: AccountUpdatePayload }): AsterAccountSnapshot | null {
|
||||
if (!snapshot) return snapshot;
|
||||
const next = deepCloneAccount(snapshot);
|
||||
if (!next) return snapshot;
|
||||
next.updateTime = event.eventTime;
|
||||
const balances = event.payload.B ?? [];
|
||||
for (const balance of balances) {
|
||||
const asset = balance.a;
|
||||
let existing = next.assets.find((item) => item.asset === asset);
|
||||
if (!existing) {
|
||||
existing = {
|
||||
asset,
|
||||
walletBalance: "0",
|
||||
unrealizedProfit: "0",
|
||||
marginBalance: "0",
|
||||
maintMargin: "0",
|
||||
initialMargin: "0",
|
||||
positionInitialMargin: "0",
|
||||
openOrderInitialMargin: "0",
|
||||
crossWalletBalance: "0",
|
||||
crossUnPnl: "0",
|
||||
availableBalance: "0",
|
||||
maxWithdrawAmount: "0",
|
||||
marginAvailable: true,
|
||||
updateTime: event.eventTime,
|
||||
} as any;
|
||||
next.assets.push(existing);
|
||||
}
|
||||
if (balance.wb !== undefined) existing.walletBalance = balance.wb;
|
||||
if (balance.cw !== undefined) existing.crossWalletBalance = balance.cw;
|
||||
if (balance.bc !== undefined) existing.availableBalance = balance.bc;
|
||||
existing.updateTime = event.eventTime;
|
||||
}
|
||||
|
||||
const positions = event.payload.P ?? [];
|
||||
const unrealizedTotals = positions.reduce((acc, item) => acc + parseFloat(item.up ?? "0"), 0);
|
||||
next.totalUnrealizedProfit = unrealizedTotals.toFixed(8);
|
||||
|
||||
for (const position of positions) {
|
||||
const symbol = position.s;
|
||||
let existing = next.positions.find((item) => item.symbol === symbol && item.positionSide === position.ps);
|
||||
if (!existing) {
|
||||
existing = {
|
||||
symbol,
|
||||
positionAmt: "0",
|
||||
entryPrice: "0",
|
||||
unrealizedProfit: "0",
|
||||
positionSide: position.ps,
|
||||
updateTime: event.eventTime,
|
||||
initialMargin: "0",
|
||||
maintMargin: "0",
|
||||
positionInitialMargin: "0",
|
||||
openOrderInitialMargin: "0",
|
||||
leverage: "",
|
||||
isolated: position.mt === "isolated",
|
||||
maxNotional: "0",
|
||||
} as any;
|
||||
next.positions.push(existing);
|
||||
}
|
||||
existing.positionAmt = position.pa ?? existing.positionAmt;
|
||||
existing.entryPrice = position.ep ?? existing.entryPrice;
|
||||
existing.unrealizedProfit = position.up ?? existing.unrealizedProfit;
|
||||
existing.updateTime = event.eventTime;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeOrderSnapshot(map: Map<number, AsterOrder>, order: AsterOrder): void {
|
||||
if (FINAL_ORDER_STATUSES.has(order.status)) {
|
||||
map.delete(order.orderId);
|
||||
} else {
|
||||
map.set(order.orderId, order);
|
||||
}
|
||||
}
|
||||
|
||||
export class AsterGateway {
|
||||
private readonly rest: AsterRestClient;
|
||||
private readonly publicStreams: AsterPublicStreams;
|
||||
private readonly userStream: AsterUserStream;
|
||||
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private readonly openOrders = new Map<number, AsterOrder>();
|
||||
|
||||
private readonly accountEvent = new SimpleEvent<AsterAccountSnapshot>();
|
||||
private readonly ordersEvent = new SimpleEvent<AsterOrder[]>();
|
||||
private readonly depthEvents = new Map<string, SimpleEvent<AsterDepth>>();
|
||||
private readonly tickerEvents = new Map<string, SimpleEvent<AsterTicker>>();
|
||||
private readonly klineEvents = new Map<string, SimpleEvent<AsterKline[]>>();
|
||||
|
||||
private readonly klineStores = new Map<string, AsterKline[]>();
|
||||
private readonly klineRefreshTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
private readonly klineInitialFetches = new Map<string, Promise<void>>();
|
||||
private initialized = false;
|
||||
private initializing: Promise<void> | null = null;
|
||||
|
||||
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
|
||||
this.rest = new AsterRestClient(options);
|
||||
this.publicStreams = new AsterPublicStreams();
|
||||
this.userStream = new AsterUserStream(this.rest);
|
||||
this.userStream.onAccount((event) => {
|
||||
const updated = updateAccountSnapshot(this.accountSnapshot, event);
|
||||
if (updated) {
|
||||
this.accountSnapshot = updated;
|
||||
this.accountEvent.emit(updated);
|
||||
}
|
||||
});
|
||||
this.userStream.onOrder((event) => {
|
||||
const order = toOrderFromEvent(event.payload);
|
||||
mergeOrderSnapshot(this.openOrders, order);
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
});
|
||||
}
|
||||
|
||||
async ensureInitialized(symbol: string): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
if (this.initializing) return this.initializing;
|
||||
this.initializing = (async () => {
|
||||
this.accountSnapshot = await this.rest.getAccount();
|
||||
const orders = await this.rest.getOpenOrders();
|
||||
this.openOrders.clear();
|
||||
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
||||
this.initialized = true;
|
||||
await this.userStream.start();
|
||||
this.accountEvent.emit(this.accountSnapshot!);
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
})().catch((error) => {
|
||||
this.initializing = null;
|
||||
throw error;
|
||||
});
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
onAccount(listener: (snapshot: AsterAccountSnapshot) => void): void {
|
||||
this.accountEvent.add(listener);
|
||||
if (this.accountSnapshot) listener(this.accountSnapshot);
|
||||
}
|
||||
|
||||
onOrders(listener: (orders: AsterOrder[]) => void): void {
|
||||
this.ordersEvent.add(listener);
|
||||
listener(Array.from(this.openOrders.values()));
|
||||
}
|
||||
|
||||
onDepth(symbol: string, listener: (depth: AsterDepth) => void): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
let event = this.depthEvents.get(upper);
|
||||
if (!event) {
|
||||
event = new SimpleEvent<AsterDepth>();
|
||||
this.depthEvents.set(upper, event);
|
||||
this.publicStreams.subscribeDepth(upper, (depth) => {
|
||||
event?.emit(depth);
|
||||
});
|
||||
}
|
||||
event.add(listener);
|
||||
}
|
||||
|
||||
onTicker(symbol: string, listener: (ticker: AsterTicker) => void): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
let event = this.tickerEvents.get(upper);
|
||||
if (!event) {
|
||||
event = new SimpleEvent<AsterTicker>();
|
||||
this.tickerEvents.set(upper, event);
|
||||
this.publicStreams.subscribeTicker(upper, (ticker) => {
|
||||
event?.emit(ticker);
|
||||
});
|
||||
}
|
||||
event.add(listener);
|
||||
}
|
||||
|
||||
onKlines(symbol: string, interval: string, listener: (klines: AsterKline[]) => void): void {
|
||||
const upper = symbol.toUpperCase();
|
||||
const key = `${upper}:${interval}`;
|
||||
let event = this.klineEvents.get(key);
|
||||
if (!event) {
|
||||
event = new SimpleEvent<AsterKline[]>();
|
||||
this.klineEvents.set(key, event);
|
||||
this.publicStreams.subscribeKline(symbol, interval, (kline) => {
|
||||
const storeKey = `${upper}:${interval}`;
|
||||
let store = this.klineStores.get(storeKey);
|
||||
if (!store) {
|
||||
store = [];
|
||||
this.klineStores.set(storeKey, store);
|
||||
}
|
||||
const index = store.findIndex((item) => item.openTime === kline.openTime);
|
||||
if (index >= 0) {
|
||||
store[index] = kline;
|
||||
} else {
|
||||
store.push(kline);
|
||||
store.sort((a, b) => a.openTime - b.openTime);
|
||||
if (store.length > DEFAULT_KLINE_LIMIT) {
|
||||
store.shift();
|
||||
}
|
||||
}
|
||||
event?.emit([...store]);
|
||||
});
|
||||
void this.ensureKlineSeed(upper, interval);
|
||||
}
|
||||
event.add(listener);
|
||||
const existing = this.klineStores.get(key);
|
||||
if (existing && existing.length) {
|
||||
listener([...existing]);
|
||||
} else {
|
||||
void this.ensureKlineSeed(upper, interval);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureKlineSeed(symbol: string, interval: string): Promise<void> {
|
||||
const key = `${symbol}:${interval}`;
|
||||
const existing = this.klineInitialFetches.get(key);
|
||||
if (existing) return existing;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT);
|
||||
klines.sort((a, b) => a.openTime - b.openTime);
|
||||
this.klineStores.set(key, klines);
|
||||
const event = this.klineEvents.get(key);
|
||||
if (event) {
|
||||
event.emit([...klines]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AsterGateway] seed klines failed", error);
|
||||
} finally {
|
||||
this.startKlineRefresh(symbol, interval);
|
||||
}
|
||||
})();
|
||||
this.klineInitialFetches.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private startKlineRefresh(symbol: string, interval: string): void {
|
||||
const key = `${symbol}:${interval}`;
|
||||
if (this.klineRefreshTimers.has(key)) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const klines = await this.rest.getKlines(symbol, interval, DEFAULT_KLINE_LIMIT);
|
||||
klines.sort((a, b) => a.openTime - b.openTime);
|
||||
this.klineStores.set(key, klines);
|
||||
const event = this.klineEvents.get(key);
|
||||
if (event) {
|
||||
event.emit([...klines]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AsterGateway] refresh klines failed", error);
|
||||
}
|
||||
}, KLINE_REFRESH_INTERVAL_MS);
|
||||
this.klineRefreshTimers.set(key, timer);
|
||||
}
|
||||
|
||||
getAccountSnapshot(): AsterAccountSnapshot | null {
|
||||
return this.accountSnapshot;
|
||||
}
|
||||
|
||||
getOpenOrdersSnapshot(): AsterOrder[] {
|
||||
return Array.from(this.openOrders.values());
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
const order = await this.rest.createOrder(params);
|
||||
mergeOrderSnapshot(this.openOrders, order);
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
return order;
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<void> {
|
||||
const result = await this.rest.cancelOrder(params);
|
||||
mergeOrderSnapshot(this.openOrders, result);
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
}
|
||||
|
||||
async cancelOrders(params: { symbol: string; orderIdList?: Array<number | string>; origClientOrderIdList?: string[] }): Promise<void> {
|
||||
const results = await this.rest.cancelOrders(params);
|
||||
results.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
}
|
||||
|
||||
async cancelAllOrders(params: { symbol: string }): Promise<void> {
|
||||
await this.rest.cancelAllOrders(params);
|
||||
for (const order of Array.from(this.openOrders.values())) {
|
||||
if (order.symbol === params.symbol) {
|
||||
this.openOrders.delete(order.orderId);
|
||||
}
|
||||
}
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export type StringBoolean = "true" | "false";
|
||||
|
||||
export type OrderSide = "BUY" | "SELL";
|
||||
export type OrderType =
|
||||
| "LIMIT"
|
||||
| "MARKET"
|
||||
| "STOP_MARKET"
|
||||
| "TRAILING_STOP_MARKET";
|
||||
export type PositionSide = "BOTH" | "LONG" | "SHORT";
|
||||
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
|
||||
|
||||
export interface CreateOrderParams {
|
||||
symbol: string;
|
||||
side: OrderSide;
|
||||
type: OrderType;
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
stopPrice?: number;
|
||||
activationPrice?: number;
|
||||
callbackRate?: number;
|
||||
timeInForce?: TimeInForce;
|
||||
reduceOnly?: StringBoolean;
|
||||
closePosition?: StringBoolean;
|
||||
}
|
||||
|
||||
export interface AsterAccountPosition {
|
||||
symbol: string;
|
||||
positionAmt: string;
|
||||
entryPrice: string;
|
||||
unrealizedProfit: string;
|
||||
positionSide: PositionSide;
|
||||
updateTime: number;
|
||||
}
|
||||
|
||||
export interface AsterAccountAsset {
|
||||
asset: string;
|
||||
walletBalance: string;
|
||||
availableBalance: string;
|
||||
updateTime: number;
|
||||
}
|
||||
|
||||
export interface AsterAccountSnapshot {
|
||||
canTrade: boolean;
|
||||
canDeposit: boolean;
|
||||
canWithdraw: boolean;
|
||||
updateTime: number;
|
||||
totalWalletBalance: string;
|
||||
totalUnrealizedProfit: string;
|
||||
positions: AsterAccountPosition[];
|
||||
assets: AsterAccountAsset[];
|
||||
}
|
||||
|
||||
export interface AsterDepthLevel extends Array<string> {
|
||||
0: string; // price
|
||||
1: string; // quantity
|
||||
}
|
||||
|
||||
export interface AsterDepth {
|
||||
lastUpdateId: number;
|
||||
bids: AsterDepthLevel[];
|
||||
asks: AsterDepthLevel[];
|
||||
eventTime?: number;
|
||||
}
|
||||
|
||||
export interface AsterTicker {
|
||||
symbol: string;
|
||||
lastPrice: string;
|
||||
openPrice: string;
|
||||
highPrice: string;
|
||||
lowPrice: string;
|
||||
volume: string;
|
||||
quoteVolume: string;
|
||||
eventTime?: number;
|
||||
}
|
||||
|
||||
export interface AsterKline {
|
||||
eventType?: string;
|
||||
eventTime?: number;
|
||||
symbol?: string;
|
||||
interval?: string;
|
||||
openTime: number;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
closeTime: number;
|
||||
firstTradeId?: number;
|
||||
lastTradeId?: number;
|
||||
quoteAssetVolume?: string;
|
||||
numberOfTrades: number;
|
||||
takerBuyBaseAssetVolume?: string;
|
||||
takerBuyQuoteAssetVolume?: string;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export interface AsterOrder {
|
||||
orderId: number;
|
||||
clientOrderId: string;
|
||||
symbol: string;
|
||||
side: OrderSide;
|
||||
type: OrderType;
|
||||
status: string;
|
||||
price: string;
|
||||
origQty: string;
|
||||
executedQty: string;
|
||||
stopPrice: string;
|
||||
time: number;
|
||||
updateTime: number;
|
||||
reduceOnly: boolean;
|
||||
closePosition: boolean;
|
||||
workingType?: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import "dotenv/config";
|
||||
import React from "react";
|
||||
import { render } from "ink";
|
||||
import { App } from "./ui/App";
|
||||
|
||||
render(<App />);
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface TradeLogEntry {
|
||||
time: string;
|
||||
type: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export function createTradeLog(maxEntries: number) {
|
||||
const entries: TradeLogEntry[] = [];
|
||||
function push(type: string, detail: string) {
|
||||
entries.push({ time: new Date().toLocaleString(), type, detail });
|
||||
if (entries.length > maxEntries) {
|
||||
entries.shift();
|
||||
}
|
||||
}
|
||||
function all() {
|
||||
return entries;
|
||||
}
|
||||
return { push, all };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TrendApp } from "./TrendApp";
|
||||
import { MakerApp } from "./MakerApp";
|
||||
|
||||
interface StrategyOption {
|
||||
id: "trend" | "maker";
|
||||
label: string;
|
||||
description: string;
|
||||
component: React.ComponentType<{ onExit: () => void }>;
|
||||
}
|
||||
|
||||
const STRATEGIES: StrategyOption[] = [
|
||||
{
|
||||
id: "trend",
|
||||
label: "趋势跟随策略 (SMA30)",
|
||||
description: "监控均线信号,自动进出场并维护止损/止盈",
|
||||
component: TrendApp,
|
||||
},
|
||||
{
|
||||
id: "maker",
|
||||
label: "做市刷单策略",
|
||||
description: "双边挂单提供流动性,自动追价与风控止损",
|
||||
component: MakerApp,
|
||||
},
|
||||
];
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function App() {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [selected, setSelected] = useState<StrategyOption | null>(null);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (selected) return;
|
||||
if (key.upArrow) {
|
||||
setCursor((prev) => (prev - 1 + STRATEGIES.length) % STRATEGIES.length);
|
||||
} else if (key.downArrow) {
|
||||
setCursor((prev) => (prev + 1) % STRATEGIES.length);
|
||||
} else if (key.return) {
|
||||
const strategy = STRATEGIES[cursor];
|
||||
if (strategy) {
|
||||
setSelected(strategy);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported && !selected }
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
const Selected = selected.component;
|
||||
return <Selected onExit={() => setSelected(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<Text color="cyanBright">请选择要运行的策略</Text>
|
||||
<Text color="gray">使用 ↑/↓ 选择,回车开始,Ctrl+C 退出。</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{STRATEGIES.map((strategy, index) => {
|
||||
const active = index === cursor;
|
||||
return (
|
||||
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
||||
<Text color={active ? "greenBright" : undefined}>
|
||||
{active ? "➤" : " "} {strategy.label}
|
||||
</Text>
|
||||
<Text color="gray"> {strategy.description}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { makerConfig } from "../config";
|
||||
import { AsterExchangeAdapter } from "../exchanges/aster-adapter";
|
||||
import { MakerEngine, type MakerEngineSnapshot } from "../core/maker-engine";
|
||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||
import { formatNumber } from "../utils/format";
|
||||
|
||||
interface MakerAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function MakerApp({ onExit }: MakerAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<MakerEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<MakerEngine | null>(null);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
engineRef.current?.stop();
|
||||
onExit();
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = process.env.ASTER_API_KEY;
|
||||
const apiSecret = process.env.ASTER_API_SECRET;
|
||||
if (!apiKey || !apiSecret) {
|
||||
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const adapter = new AsterExchangeAdapter({
|
||||
apiKey,
|
||||
apiSecret,
|
||||
symbol: makerConfig.symbol,
|
||||
});
|
||||
const engine = new MakerEngine(makerConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: MakerEngineSnapshot) => {
|
||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||
};
|
||||
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)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化做市策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const topBid = snapshot.topBid;
|
||||
const topAsk = snapshot.topAsk;
|
||||
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
|
||||
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||
const openOrderRows = snapshot.openOrders.map((order) => ({
|
||||
id: order.orderId,
|
||||
side: order.side,
|
||||
price: order.price,
|
||||
qty: order.origQty,
|
||||
filled: order.executedQty,
|
||||
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||
status: order.status,
|
||||
}));
|
||||
const openOrderColumns: TableColumn[] = [
|
||||
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ 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: "reduceOnly", header: "RO", minWidth: 4 },
|
||||
{ key: "status", header: "Status", minWidth: 10 },
|
||||
];
|
||||
|
||||
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
|
||||
index: index + 1,
|
||||
side: order.side,
|
||||
price: order.price,
|
||||
amount: order.amount,
|
||||
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||
}));
|
||||
const desiredColumns: TableColumn[] = [
|
||||
{ key: "index", header: "#", align: "right", minWidth: 2 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
|
||||
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||
];
|
||||
|
||||
const lastLogs = snapshot.tradeLog.slice(-10);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Maker Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
||||
</Text>
|
||||
<Text color="gray">状态: {snapshot.ready ? "实时运行" : "等待市场数据"} | 按 Esc 返回策略选择</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">持仓</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, 2)}
|
||||
</Text>
|
||||
<Text>
|
||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">目标挂单</Text>
|
||||
{desiredRows.length > 0 ? (
|
||||
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无目标挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">当前挂单</Text>
|
||||
{openOrderRows.length > 0 ? (
|
||||
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { tradingConfig } from "../config";
|
||||
import { AsterExchangeAdapter } from "../exchanges/aster-adapter";
|
||||
import { TrendEngine, type TrendEngineSnapshot } from "../core/trend-engine";
|
||||
import { formatNumber } from "../utils/format";
|
||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||
|
||||
const READY_MESSAGE = "正在等待交易所推送数据…";
|
||||
|
||||
interface TrendAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function TrendApp({ onExit }: TrendAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<TrendEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<TrendEngine | null>(null);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
engineRef.current?.stop();
|
||||
onExit();
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = process.env.ASTER_API_KEY;
|
||||
const apiSecret = process.env.ASTER_API_SECRET;
|
||||
if (!apiKey || !apiSecret) {
|
||||
setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const adapter = new AsterExchangeAdapter({
|
||||
apiKey,
|
||||
apiSecret,
|
||||
symbol: tradingConfig.symbol,
|
||||
});
|
||||
const engine = new TrendEngine(tradingConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: TrendEngineSnapshot) => {
|
||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||
};
|
||||
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)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化趋势策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { position, tradeLog, openOrders, trend, ready, lastPrice, sma30 } = snapshot;
|
||||
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
||||
const lastLogs = tradeLog.slice(-10);
|
||||
const orderRows = openOrders.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 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Trend Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易对: {snapshot.symbol} | 最近价格: {formatNumber(lastPrice, 2)} | SMA30: {formatNumber(sma30, 2)} | 趋势: {trend}
|
||||
</Text>
|
||||
<Text color="gray">状态: {ready ? "实时运行" : READY_MESSAGE} | 按 Esc 返回策略选择</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">持仓</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 4)} | 开仓价: {formatNumber(position.entryPrice, 2)}
|
||||
</Text>
|
||||
<Text>
|
||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.unrealized, 4)} USDT
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">绩效</Text>
|
||||
<Text>
|
||||
累计交易次数: {snapshot.totalTrades} | 累计收益: {formatNumber(snapshot.totalProfit, 4)} USDT
|
||||
</Text>
|
||||
{snapshot.lastOpenSignal.side ? (
|
||||
<Text color="gray">
|
||||
最近开仓信号: {snapshot.lastOpenSignal.side} @ {formatNumber(snapshot.lastOpenSignal.price, 2)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">当前挂单</Text>
|
||||
{orderRows.length > 0 ? (
|
||||
<DataTable columns={orderColumns} rows={orderRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近交易与事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
type Align = "left" | "right";
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
header: string;
|
||||
align?: Align;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export interface DataTableProps<Row extends Record<string, unknown>> {
|
||||
columns: TableColumn[];
|
||||
rows: Row[];
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "number") {
|
||||
if (Number.isInteger(value)) return value.toString();
|
||||
return value.toFixed(4).replace(/\.0+$/, ".0");
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function pad(text: string, width: number, align: Align): string {
|
||||
if (text.length >= width) return text;
|
||||
const padding = " ".repeat(width - text.length);
|
||||
return align === "right" ? padding + text : text + padding;
|
||||
}
|
||||
|
||||
export function DataTable<Row extends Record<string, unknown>>({ columns, rows }: DataTableProps<Row>) {
|
||||
const widths = columns.map((col) => {
|
||||
const headerLength = col.header.length;
|
||||
const minWidth = col.minWidth ?? 0;
|
||||
const contentLength = rows.reduce((max, row) => {
|
||||
const cell = formatCell(row[col.key]);
|
||||
return Math.max(max, cell.length);
|
||||
}, 0);
|
||||
return Math.max(headerLength, contentLength, minWidth);
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{columns
|
||||
.map((col, index) => pad(col.header, widths[index], col.align ?? "left"))
|
||||
.join(" ")}
|
||||
</Text>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<Text key={rowIndex}>
|
||||
{columns
|
||||
.map((col, index) => {
|
||||
const align = col.align ?? "left";
|
||||
const cell = formatCell(row[col.key]);
|
||||
return pad(cell, widths[index], align);
|
||||
})
|
||||
.join(" ")}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function formatNumber(value: number | null | undefined, digits = 4, fallback = "-"): string {
|
||||
if (value == null || Number.isNaN(value)) return fallback;
|
||||
return Number(value).toFixed(digits);
|
||||
}
|
||||
|
||||
export function formatTrendLabel(trend: "做多" | "做空" | "无信号"): string {
|
||||
return trend;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function toPrice1Decimal(price: number): number {
|
||||
return Math.floor(price * 10) / 10;
|
||||
}
|
||||
|
||||
export function toQty3Decimal(qty: number): number {
|
||||
return Math.floor(qty * 1000) / 1000;
|
||||
}
|
||||
|
||||
export function isNearlyZero(value: number, epsilon = 1e-5): boolean {
|
||||
return Math.abs(value) < epsilon;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AsterAccountSnapshot, AsterKline } from "../exchanges/types";
|
||||
|
||||
export interface PositionSnapshot {
|
||||
positionAmt: number;
|
||||
entryPrice: number;
|
||||
unrealizedProfit: number;
|
||||
}
|
||||
|
||||
export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: string): PositionSnapshot {
|
||||
if (!snapshot) {
|
||||
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 };
|
||||
}
|
||||
const pos = snapshot.positions?.find((p) => p.symbol === symbol);
|
||||
if (!pos) {
|
||||
return { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 };
|
||||
}
|
||||
return {
|
||||
positionAmt: Number(pos.positionAmt),
|
||||
entryPrice: Number(pos.entryPrice),
|
||||
unrealizedProfit: Number(pos.unrealizedProfit),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSMA(values: AsterKline[], length: number): number | null {
|
||||
if (!values || values.length < length) return null;
|
||||
const closes = values.slice(-length).map((k) => Number(k.close));
|
||||
const sum = closes.reduce((acc, current) => acc + current, 0);
|
||||
return sum / closes.length;
|
||||
}
|
||||
|
||||
export function calcStopLossPrice(entryPrice: number, qty: number, side: "long" | "short", loss: number): number {
|
||||
if (side === "long") {
|
||||
return entryPrice - loss / qty;
|
||||
}
|
||||
return entryPrice + loss / Math.abs(qty);
|
||||
}
|
||||
|
||||
export function calcTrailingActivationPrice(entryPrice: number, qty: number, side: "long" | "short", profit: number): number {
|
||||
if (side === "long") {
|
||||
return entryPrice + profit / qty;
|
||||
}
|
||||
return entryPrice - profit / Math.abs(qty);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user