Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 18 additions & 50 deletions app/controllers/perps/services/TradingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1982,18 +1982,6 @@ describe('TradingService', () => {
stopLossCount: 0,
};

const mockAccountState = {
availableBalance: '10000',
equity: '15000',
marginUsed: '5000',
};

beforeEach(() => {
mockProvider.getAccountState = jest
.fn()
.mockResolvedValue(mockAccountState);
});

it('places order with 2x position size to flip position', async () => {
const mockResult: OrderResult = {
success: true,
Expand Down Expand Up @@ -2054,31 +2042,7 @@ describe('TradingService', () => {
);
});

it('returns error when insufficient balance for fees', async () => {
// Set very low available balance
mockProvider.getAccountState = jest.fn().mockResolvedValue({
...mockAccountState,
availableBalance: '1', // $1 balance, insufficient for fees
});

await expect(
tradingService.flipPosition({
provider: mockProvider,
position: mockPosition,
context: mockContext,
}),
).rejects.toThrow(/Insufficient balance for flip fees/);
});

it('allows flip when balance covers 1x notional fee estimate', async () => {
// position: size=0.5, entryPrice=50000
// estimatedFees = positionSize * entryPrice * ESTIMATED_FEE_RATE
// = 0.5 * 50000 * 0.0009 = $22.50 (1x notional, correct)
// pre-fix would compute 2x: 1.0 * 50000 * 0.0009 = $45 → would block this user
mockProvider.getAccountState = jest.fn().mockResolvedValue({
...mockAccountState,
availableBalance: '30', // $30 > $22.50, sufficient with 1x
});
it('does not pass entry price as currentPrice to the provider', async () => {
mockProvider.placeOrder.mockResolvedValue({
success: true,
orderId: 'flip-balance-fixed',
Expand All @@ -2093,18 +2057,18 @@ describe('TradingService', () => {
});

expect(result.success).toBe(true);
});

it('throws error when account state cannot be retrieved', async () => {
mockProvider.getAccountState = jest.fn().mockResolvedValue(null);

await expect(
tradingService.flipPosition({
provider: mockProvider,
position: mockPosition,
context: mockContext,
expect(mockProvider.placeOrder).toHaveBeenCalledWith({
symbol: 'BTC',
isBuy: false,
size: '1',
orderType: 'market',
leverage: 10,
});
expect(mockProvider.placeOrder).not.toHaveBeenCalledWith(
expect.objectContaining({
currentPrice: expect.any(Number),
}),
).rejects.toThrow('Failed to get account state');
);
});

it('returns error when order placement fails', async () => {
Expand All @@ -2125,7 +2089,11 @@ describe('TradingService', () => {
});

it('tracks analytics on success', async () => {
const mockResult: OrderResult = { success: true, orderId: 'flip-123' };
const mockResult: OrderResult = {
success: true,
orderId: 'flip-123',
averagePrice: '60000',
};
mockProvider.placeOrder.mockResolvedValue(mockResult);

await tradingService.flipPosition({
Expand All @@ -2138,6 +2106,7 @@ describe('TradingService', () => {
PerpsAnalyticsEvent.TradeTransaction,
expect.objectContaining({
status: 'executed',
order_value: 30000,
}),
);
});
Expand Down Expand Up @@ -2209,7 +2178,6 @@ describe('TradingService', () => {
size: '1',
orderType: 'market',
leverage: 10,
currentPrice: 50000,
});
});
});
Expand Down
33 changes: 8 additions & 25 deletions app/controllers/perps/services/TradingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
PERPS_EVENT_PROPERTY,
PERPS_EVENT_VALUE,
} from '../constants/eventNames';
import { ESTIMATED_FEE_RATE } from '../constants/hyperLiquidConfig';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused export ESTIMATED_FEE_RATE after removing its only consumer

Low Severity

The ESTIMATED_FEE_RATE import was removed from TradingService.ts, but the constant is still exported from hyperLiquidConfig.ts. A codebase-wide grep confirms zero remaining consumers — it is now dead code. Its comment explicitly ties it to "flip operations," which no longer use it.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b78150d. Configure here.

import { isTPSLOrder } from '../constants/orderTypes';
import { PerpsMeasurementName } from '../constants/performanceMetrics';
import { PERPS_CONSTANTS } from '../constants/perpsConfig';
Expand Down Expand Up @@ -1942,44 +1941,29 @@ export class TradingService {
const isCurrentlyLong = parseFloat(position.size) > 0;
const oppositeDirection = !isCurrentlyLong;

// Validate available balance for fees
const accountState = await provider.getAccountState?.();
if (!accountState) {
throw new Error('Failed to get account state');
}

const availableBalance = parseFloat(accountState.availableBalance);

// Estimate fees: ESTIMATED_FEE_RATE (0.09%) already accounts for both legs
// (close at 0.045% + open at 0.045% = 0.09% of position notional).
// Apply to 1x notional (positionSize * entryPrice), not 2x (flipSize * entryPrice).
const entryPrice = parseFloat(position.entryPrice);
const flipSize = positionSize * 2;
const notionalValue = positionSize * entryPrice;
const estimatedFees = notionalValue * ESTIMATED_FEE_RATE;

if (estimatedFees > availableBalance) {
throw new Error(
`Insufficient balance for flip fees. Need $${estimatedFees.toFixed(2)}, have $${availableBalance.toFixed(2)}`,
);
}

// Create order params for flip
// Use 2x position size: 1x to close current position + 1x to open opposite position
// Use 2x position size: 1x to close current position + 1x to open opposite position.
// Do not pass the position entry price as currentPrice: the provider must fetch
// live market data for validation and IOC pricing.
const orderParams: OrderParams = {
symbol: position.symbol,
isBuy: oppositeDirection,
size: flipSize.toString(),
orderType: 'market',
leverage: position.leverage?.value,
currentPrice: entryPrice,
};

// Place flip order (HyperLiquid handles margin transfer automatically)
const result = await provider.placeOrder(orderParams);

const completionDuration = this.#deps.performance.now() - startTime;

const executedPrice = parseFloat(
result.averagePrice ?? position.entryPrice,
);

if (result.success) {
// Update state on success
if (context.stateManager) {
Expand All @@ -2006,8 +1990,7 @@ export class TradingService {
[PERPS_EVENT_PROPERTY.ORDER_SIZE]: positionSize,
[PERPS_EVENT_PROPERTY.COMPLETION_DURATION]: completionDuration,
[PERPS_EVENT_PROPERTY.ACTION]: flipAction,
[PERPS_EVENT_PROPERTY.ORDER_VALUE]:
positionSize * parseFloat(position.entryPrice),
[PERPS_EVENT_PROPERTY.ORDER_VALUE]: positionSize * executedPrice,
},
);

Expand Down
Loading