import { describe, it, expect, beforeEach, vi } from 'vitest'; import pino from 'pino'; import { GridMaker, type AdaptiveGridConfig } from '../src/gridMaker'; import type { Order, Fill, OrderBook } from '../../domain/src/types'; import type { OrderRouter } from '../../execution/src/orderRouter'; import type { HedgeEngine } from '../../hedge/src/hedgeEngine'; import type { ShadowBook } from '../../utils/src/shadowBook'; import pino from 'pino'; describe('GridMaker', () => { let gridMaker: GridMaker; let mockRouter: OrderRouter; let mockHedgeEngine: HedgeEngine; let mockShadowBook: ShadowBook; const testConfig = { symbol: 'BTC', gridStepBps: 100, // 1% gridRangeBps: 400, // 4% baseClipUsd: 500, maxLayers: 4, hedgeThresholdBase: 0.3, accountId: 'maker' }; const mockMidPrice = 50000; beforeEach(() => { // Mock OrderRouter mockRouter = { sendLimitChild: vi.fn(async (order: Order) => { return `order-${order.clientId}`; }) } as any; // Mock HedgeEngine mockHedgeEngine = { maybeHedge: vi.fn(async () => ({ hedged: 0, clientId: undefined, orderId: undefined })) } as any; // Mock ShadowBook mockShadowBook = { mid: vi.fn(() => mockMidPrice), snapshot: vi.fn(() => ({ bids: [{ px: 49900, sz: 1 }], asks: [{ px: 50100, sz: 1 }], ts: Date.now() } as OrderBook)) } as any; const mockLogger = pino({ level: 'silent' }); gridMaker = new GridMaker( testConfig, mockRouter, mockHedgeEngine, mockShadowBook, mockLogger, undefined, async () => {}, () => {} ); }); describe('initialize', () => { it('should initialize grid orders correctly', async () => { await gridMaker.initialize(); // 应该创建 4 层买单 + 4 层卖单 = 8 个订单 expect(mockRouter.sendLimitChild).toHaveBeenCalledTimes(8); const status = gridMaker.getStatus(); expect(status.isInitialized).toBe(true); expect(status.totalGrids).toBe(8); expect(status.activeOrders).toBe(8); }); it('should place buy orders below mid price', async () => { await gridMaker.initialize(); const calls = (mockRouter.sendLimitChild as any).mock.calls; const buyOrders = calls.filter((call: any) => call[0].side === 'buy'); // 所有买单价格应该低于 mid buyOrders.forEach((call: any) => { const order = call[0] as Order; expect(order.px).toBeLessThan(mockMidPrice); expect(order.postOnly).toBe(true); expect(order.tif).toBe('GTC'); }); }); it('should place sell orders above mid price', async () => { await gridMaker.initialize(); const calls = (mockRouter.sendLimitChild as any).mock.calls; const sellOrders = calls.filter((call: any) => call[0].side === 'sell'); // 所有卖单价格应该高于 mid sellOrders.forEach((call: any) => { const order = call[0] as Order; expect(order.px).toBeGreaterThan(mockMidPrice); expect(order.postOnly).toBe(true); }); }); it('should throw error if no mid price available', async () => { (mockShadowBook.mid as any).mockReturnValue(undefined); await expect(gridMaker.initialize()).rejects.toThrow( 'Cannot initialize grid: no mid price available' ); }); }); describe('onFill', () => { beforeEach(async () => { await gridMaker.initialize(); vi.clearAllMocks(); }); it('should place opposite order when buy order filled', async () => { const buyFill: Fill = { orderId: 'order-grid-BTC--1-' + Date.now(), tradeId: 'trade-1', symbol: 'BTC', side: 'buy', px: 49500, // 买单在 -1% 成交 sz: 0.01, fee: 0.00001, liquidity: 'maker', ts: Date.now() }; await gridMaker.onFill(buyFill); // 应该挂一个卖单在更高价 expect(mockRouter.sendLimitChild).toHaveBeenCalledTimes(1); const call = (mockRouter.sendLimitChild as any).mock.calls[0]; const oppositeOrder = call[0] as Order; expect(oppositeOrder.side).toBe('sell'); expect(oppositeOrder.px).toBeGreaterThan(buyFill.px); // 应该在 buyFill.px * (1 + 1%) 附近 expect(oppositeOrder.px).toBeCloseTo(buyFill.px * 1.01, 0); expect(oppositeOrder.accountId).toBe('maker'); }); it('should place opposite order when sell order filled', async () => { const sellFill: Fill = { orderId: 'order-grid-BTC-1-' + Date.now(), tradeId: 'trade-2', symbol: 'BTC', side: 'sell', px: 50500, // 卖单在 +1% 成交 sz: 0.01, fee: 0.00001, liquidity: 'maker', ts: Date.now() }; await gridMaker.onFill(sellFill); // 应该挂一个买单在更低价 expect(mockRouter.sendLimitChild).toHaveBeenCalledTimes(1); const call = (mockRouter.sendLimitChild as any).mock.calls[0]; const oppositeOrder = call[0] as Order; expect(oppositeOrder.side).toBe('buy'); expect(oppositeOrder.px).toBeLessThan(sellFill.px); // 应该在 sellFill.px * (1 - 1%) 附近 expect(oppositeOrder.px).toBeCloseTo(sellFill.px * 0.99, 0); expect(oppositeOrder.accountId).toBe('maker'); }); it('should update delta correctly', async () => { const buyFill: Fill = { orderId: 'order-grid-BTC--1-' + Date.now(), tradeId: 'trade-1', symbol: 'BTC', side: 'buy', px: 49500, sz: 0.1, // 买入 0.1 BTC fee: 0.0001, liquidity: 'maker', ts: Date.now() }; await gridMaker.onFill(buyFill); const status = gridMaker.getStatus(); expect(status.currentDelta).toBe(0.1); // Delta 应该增加 0.1 }); it('should trigger hedge when threshold reached', async () => { // 连续成交 3 笔买单,累积 Delta = 0.3(达到阈值) (mockHedgeEngine.maybeHedge as any).mockResolvedValueOnce({ hedged: 0.3, orderId: 'hedge-order-1', clientId: 'hedge-1' }); for (let i = 0; i < 3; i++) { const buyFill: Fill = { orderId: `order-grid-BTC--${i + 1}-` + (Date.now() + i), tradeId: `trade-${i}`, symbol: 'BTC', side: 'buy', px: 49500 - i * 100, sz: 0.1, fee: 0.0001, liquidity: 'maker', ts: Date.now() + i }; await gridMaker.onFill(buyFill); } const status = gridMaker.getStatus(); expect(status.currentDelta).toBe(0.3); expect(status.pendingHedges).toBe(1); // 应该触发对冲 expect(mockHedgeEngine.maybeHedge).toHaveBeenCalled(); expect(mockHedgeEngine.maybeHedge).toHaveBeenCalledWith('BTC', 0.3); }); it('should ignore fill from non-grid orders', async () => { const externalFill: Fill = { orderId: 'external-order-123', // 非网格订单 tradeId: 'trade-ext', symbol: 'BTC', side: 'buy', px: 50000, sz: 0.5, fee: 0.0005, liquidity: 'taker', ts: Date.now() }; await gridMaker.onFill(externalFill); // 不应该挂对手单 expect(mockRouter.sendLimitChild).not.toHaveBeenCalled(); // Delta 不应该更新 const status = gridMaker.getStatus(); expect(status.currentDelta).toBe(0); }); }); describe('getStatus', () => { it('should return correct status before initialization', () => { const status = gridMaker.getStatus(); expect(status.isInitialized).toBe(false); expect(status.totalGrids).toBe(0); expect(status.activeOrders).toBe(0); expect(status.filledGrids).toBe(0); expect(status.pendingHedges).toBe(0); }); it('should return correct status after initialization', async () => { await gridMaker.initialize(); const status = gridMaker.getStatus(); expect(status.isInitialized).toBe(true); expect(status.gridCenter).toBe(mockMidPrice); expect(status.totalGrids).toBe(8); expect(status.activeOrders).toBe(8); expect(status.filledGrids).toBe(0); expect(status.pendingHedges).toBe(0); }); it('should correct delta when hedge fill arrives', async () => { await gridMaker.initialize(); (mockHedgeEngine.maybeHedge as any).mockResolvedValue({ hedged: 0.3, orderId: 'hedge-order-1', clientId: 'hedge-1' }); // 累积 delta 触发对冲 for (let i = 0; i < 3; i++) { const fill: Fill = { orderId: `order-grid-BTC-${i}-` + (Date.now() + i), tradeId: `trade-${i}`, symbol: 'BTC', side: 'buy', px: 49500, sz: 0.1, fee: 0, liquidity: 'maker', ts: Date.now() + i }; await gridMaker.onFill(fill); } const before = gridMaker.getStatus(); expect(before.currentDelta).toBe(0.3); expect(before.pendingHedges).toBe(1); const hedgeFill: Fill = { orderId: 'hedge-order-1', tradeId: 'hedge-trade-1', symbol: 'BTC', side: 'sell', px: 49600, sz: 0.3, fee: 0, liquidity: 'taker', ts: Date.now() + 10 }; await gridMaker.onHedgeFill(hedgeFill); const after = gridMaker.getStatus(); expect(after.currentDelta).toBeCloseTo(0.0, 5); expect(after.pendingHedges).toBe(0); }); }); describe('adaptive grid behaviour', () => { it('adjusts grid step and triggers reset when volatility jumps', async () => { const adaptiveConfig: AdaptiveGridConfig = { enabled: true, volatilityWindowMinutes: 1, minVolatilityBps: 20, maxVolatilityBps: 400, minGridStepBps: 20, maxGridStepBps: 200, recenterEnabled: true, recenterThresholdBps: 150, recenterCooldownMs: 60_000, minStepChangeRatio: 0.1, minSamples: 2 }; let currentMid = mockMidPrice; (mockShadowBook.mid as any).mockImplementation(() => currentMid); const mockLogger = pino({ level: 'silent' }); const adaptiveMaker = new GridMaker( { ...testConfig }, mockRouter, mockHedgeEngine, mockShadowBook, mockLogger, adaptiveConfig, async () => {}, () => {} ); await adaptiveMaker.initialize(); const resetSpy = vi.spyOn(adaptiveMaker as any, 'reset').mockResolvedValue(undefined); await adaptiveMaker.onTick(); // baseline currentMid = mockMidPrice * 1.12; // ~12% move await adaptiveMaker.onTick(); const status = adaptiveMaker.getStatus(); expect(status.adaptive?.currentGridStepBps).toBeGreaterThan(testConfig.gridStepBps); expect(resetSpy).toHaveBeenCalled(); resetSpy.mockRestore(); }); }); });