| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- 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();
- });
- });
- });
|