From c93d731382426deb551a1d84da1e0794ff6fce64 Mon Sep 17 00:00:00 2001 From: discountry Date: Tue, 23 Sep 2025 02:10:27 +0800 Subject: [PATCH] add test --- package.json | 6 + tests/order-coordinator.test.ts | 188 ++++++++++++++++++++++++++++++++ tests/strategy-utils.test.ts | 58 ++++++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/order-coordinator.test.ts create mode 100644 tests/strategy-utils.test.ts diff --git a/package.json b/package.json index 82a8705..bf7fcf1 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,12 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "dev": "bun run index.ts", + "start": "bun run index.ts", + "test": "bun x vitest run", + "test:watch": "bun x vitest" + }, "devDependencies": { "@types/bun": "latest", "vitest": "^3.2.4" diff --git a/tests/order-coordinator.test.ts b/tests/order-coordinator.test.ts new file mode 100644 index 0000000..680f876 --- /dev/null +++ b/tests/order-coordinator.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { AsterOrder } from "../src/exchanges/types"; +import { + deduplicateOrders, + placeOrder, + placeMarketOrder, + placeStopLossOrder, + placeTrailingStopOrder, + marketClose, + unlockOperating, + OrderLockMap, + OrderTimerMap, + OrderPendingMap, +} from "../src/core/order-coordinator"; + +const baseOrder: AsterOrder = { + orderId: 1, + clientOrderId: "client", + symbol: "BTCUSDT", + side: "BUY", + type: "LIMIT", + status: "NEW", + price: "100", + origQty: "1", + executedQty: "0", + stopPrice: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: false, + closePosition: false, +}; + +function createMockExchange(overrides: Partial = {}): ExchangeAdapter { + return { + id: "mock", + watchAccount: () => undefined, + watchOrders: () => undefined, + watchDepth: () => undefined, + watchTicker: () => undefined, + watchKlines: () => undefined, + createOrder: vi.fn(async () => baseOrder), + cancelOrder: vi.fn(async () => undefined), + cancelOrders: vi.fn(async () => undefined), + cancelAllOrders: vi.fn(async () => undefined), + ...overrides, + }; +} + +describe("order-coordinator", () => { + it("deduplicates orders by type and side", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + const openOrders: AsterOrder[] = [ + { ...baseOrder, orderId: 1 }, + { ...baseOrder, orderId: 2 }, + ]; + await deduplicateOrders(adapter, "BTCUSDT", openOrders, locks, timers, pending, "LIMIT", "BUY", log); + expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] }); + expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复")); + }); + + it("places limit orders and records pending id", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + await placeOrder( + adapter, + "BTCUSDT", + [], + locks, + timers, + pending, + "BUY", + 100, + 1, + log, + false + ); + expect(adapter.createOrder).toHaveBeenCalled(); + expect(pending.MARKET).toBeUndefined(); + expect(pending.LIMIT).toBe(String(baseOrder.orderId)); + }); + + it("places market order and unlocks after completion", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + await placeMarketOrder( + adapter, + "BTCUSDT", + [], + locks, + timers, + pending, + "SELL", + 1, + log, + true + ); + expect(adapter.createOrder).toHaveBeenCalled(); + expect(pending.MARKET).toBe(String(baseOrder.orderId)); + }); + + it("places stop loss order only when valid", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + await placeStopLossOrder( + adapter, + "BTCUSDT", + [], + locks, + timers, + pending, + "SELL", + 99, + 1, + 100, + log + ); + expect(adapter.createOrder).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET")); + }); + + it("places trailing stop order", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + await placeTrailingStopOrder( + adapter, + "BTCUSDT", + [], + locks, + timers, + pending, + "SELL", + 101, + 1, + 0.2, + log + ); + expect(adapter.createOrder).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单")); + }); + + it("market close cancels open orders before placing close order", async () => { + const adapter = createMockExchange(); + const locks: OrderLockMap = {}; + const timers: OrderTimerMap = {}; + const pending: OrderPendingMap = {}; + const log = vi.fn(); + await marketClose( + adapter, + "BTCUSDT", + [{ ...baseOrder, orderId: 2 }], + locks, + timers, + pending, + "SELL", + "BUY", + log + ); + expect(adapter.createOrder).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓")); + }); + + it("unlockOperating clears timers and pending", () => { + const locks: OrderLockMap = { LIMIT: true }; + const timers: OrderTimerMap = { LIMIT: setTimeout(() => undefined, 0) }; + const pending: OrderPendingMap = { LIMIT: "123" }; + unlockOperating(locks, timers, pending, "LIMIT"); + expect(locks.LIMIT).toBe(false); + expect(pending.LIMIT).toBeNull(); + expect(timers.LIMIT).toBeNull(); + }); +}); diff --git a/tests/strategy-utils.test.ts b/tests/strategy-utils.test.ts new file mode 100644 index 0000000..d5ecf4b --- /dev/null +++ b/tests/strategy-utils.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getPosition, getSMA } from "../src/utils/strategy"; +import type { AsterAccountSnapshot, AsterKline } from "../src/exchanges/types"; + +const mockSnapshot = (positions: Array<{ symbol: string; amt: number; entry: number; pnl: number }> = []): AsterAccountSnapshot => ({ + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + positions: positions.map((p) => ({ + symbol: p.symbol, + positionAmt: String(p.amt), + entryPrice: String(p.entry), + unrealizedProfit: String(p.pnl), + positionSide: "BOTH", + updateTime: Date.now(), + })), + assets: [], +}); + +const mockKlines = (values: number[]): AsterKline[] => + values.map((value, index) => ({ + openTime: index, + open: String(value), + high: String(value), + low: String(value), + close: String(value), + volume: "0", + closeTime: index + 1, + numberOfTrades: 0, + })); + +describe("strategy utils", () => { + it("returns default position when snapshot missing", () => { + expect(getPosition(null, "BTCUSDT")).toEqual({ positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 }); + }); + + it("extracts position for symbol", () => { + const snapshot = mockSnapshot([{ symbol: "BTCUSDT", amt: 1, entry: 100, pnl: 5 }]); + expect(getPosition(snapshot, "BTCUSDT")).toEqual({ positionAmt: 1, entryPrice: 100, unrealizedProfit: 5 }); + }); + + it("returns zero position when symbol not found", () => { + const snapshot = mockSnapshot([{ symbol: "ETHUSDT", amt: 2, entry: 200, pnl: 10 }]); + expect(getPosition(snapshot, "BTCUSDT")).toEqual({ positionAmt: 0, entryPrice: 0, unrealizedProfit: 0 }); + }); + + it("returns null when not enough klines", () => { + expect(getSMA(mockKlines([1, 2, 3]), 5)).toBeNull(); + }); + + it("computes SMA for latest closes", () => { + const data = mockKlines(Array.from({ length: 30 }, (_, i) => i + 1)); + expect(getSMA(data, 30)).toBe(15.5); + }); +});