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
3 changes: 3 additions & 0 deletions src/http-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,9 @@ export default opts => {
futuresIncome: payload => privCall('/fapi/v1/income', payload),
getMultiAssetsMargin: payload => privCall('/fapi/v1/multiAssetsMargin', payload),
setMultiAssetsMargin: payload => privCall('/fapi/v1/multiAssetsMargin', payload, 'POST'),
futuresRpiDepth: payload => book(pubCall, payload, '/fapi/v1/rpiDepth'),
futuresSymbolAdlRisk: payload => pubCall('/fapi/v1/symbolAdlRisk', payload),
futuresCommissionRate: payload => privCall('/fapi/v1/commissionRate', payload),

// Algo Orders (Conditional Orders)
futuresCreateAlgoOrder: payload => {
Expand Down
19 changes: 19 additions & 0 deletions src/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ const depth = (payload, cb, transform = true, variator) => {
)
}

const futuresRpiDepth = (payload, cb, transform = true) => {
const cache = (Array.isArray(payload) ? payload : [payload]).map(symbol => {
const symbolName = symbol.toLowerCase()
const w = openWebSocket(`${endpoints.futures}/${symbolName}@rpiDepth@500ms`)
w.onmessage = msg => {
const obj = JSONbig.parse(msg.data)
cb(transform ? futuresDepthTransform(obj) : obj)
}

return w
})

return options =>
cache.forEach(w =>
w.close(1000, 'Close handle was called', { keepClosed: true, ...options }),
)
}

const partialDepthTransform = (symbol, level, m) => ({
symbol,
level,
Expand Down Expand Up @@ -1017,6 +1035,7 @@ export default opts => {

futuresDepth: (payload, cb, transform) => depth(payload, cb, transform, 'futures'),
deliveryDepth: (payload, cb, transform) => depth(payload, cb, transform, 'delivery'),
futuresRpiDepth,
futuresPartialDepth: (payload, cb, transform) =>
partialDepth(payload, cb, transform, 'futures'),
deliveryPartialDepth: (payload, cb, transform) =>
Expand Down
284 changes: 284 additions & 0 deletions test/futures.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
* - getMultiAssetsMargin: Get multi-asset mode status
* - setMultiAssetsMargin: Enable/disable multi-asset mode
*
* RPI (Retail Price Improvement) Orders:
* - futuresRpiDepth: Get RPI order book (public endpoint)
* - futuresSymbolAdlRisk: Get ADL (Auto-Deleveraging) risk rating
* - futuresCommissionRate: Get commission rates including RPI commission
* - RPI Orders: Create and manage orders with timeInForce: 'RPI'
*
* Configuration:
* - Uses testnet: true for safe testing
* - Uses proxy for connections
Expand Down Expand Up @@ -527,6 +533,284 @@ const main = () => {
// Skipped - requires open position and modifies margin
t.pass('Skipped - requires open position')
})

// ===== RPI Order Book Tests =====

test('[FUTURES] futuresRpiDepth - get RPI order book', async t => {
const rpiDepth = await client.futuresRpiDepth({
symbol: 'BTCUSDT',
limit: 1000,
})

t.truthy(rpiDepth)
checkFields(t, rpiDepth, ['lastUpdateId', 'bids', 'asks'])
t.true(Array.isArray(rpiDepth.bids), 'Should have bids array')
t.true(Array.isArray(rpiDepth.asks), 'Should have asks array')

// Check bid/ask structure if data is available
if (rpiDepth.bids.length > 0) {
const [firstBid] = rpiDepth.bids
t.truthy(firstBid.price, 'Bid should have price')
t.truthy(firstBid.quantity, 'Bid should have quantity')
}
if (rpiDepth.asks.length > 0) {
const [firstAsk] = rpiDepth.asks
t.truthy(firstAsk.price, 'Ask should have price')
t.truthy(firstAsk.quantity, 'Ask should have quantity')
}
})

test('[FUTURES] futuresRpiDepth - with default limit', async t => {
const rpiDepth = await client.futuresRpiDepth({
symbol: 'ETHUSDT',
})

t.truthy(rpiDepth)
checkFields(t, rpiDepth, ['lastUpdateId', 'bids', 'asks'])
t.true(Array.isArray(rpiDepth.bids))
t.true(Array.isArray(rpiDepth.asks))
})

// ===== ADL Risk Rating Tests =====

test('[FUTURES] futuresSymbolAdlRisk - get ADL risk for specific symbol', async t => {
try {
const adlRisk = await client.futuresSymbolAdlRisk({
symbol: 'BTCUSDT',
recvWindow: 60000,
})

t.truthy(adlRisk)

// Response can be single object or array depending on API
if (Array.isArray(adlRisk)) {
if (adlRisk.length > 0) {
const [risk] = adlRisk
checkFields(t, risk, ['symbol', 'adlLevel'])
t.is(risk.symbol, 'BTCUSDT')
t.true(typeof risk.adlLevel === 'number')
t.true(risk.adlLevel >= 0 && risk.adlLevel <= 5, 'ADL level should be 0-5')
} else {
t.pass('No ADL risk data (no positions on testnet)')
}
} else {
checkFields(t, adlRisk, ['symbol', 'adlLevel'])
t.is(adlRisk.symbol, 'BTCUSDT')
t.true(typeof adlRisk.adlLevel === 'number')
}
} catch (e) {
// Testnet may not support ADL risk for all symbols or have no positions
if (e.code === -1121) {
t.pass('Symbol not valid or no positions on testnet (expected)')
} else {
throw e
}
}
})

test('[FUTURES] futuresSymbolAdlRisk - get ADL risk for all symbols', async t => {
const adlRisks = await client.futuresSymbolAdlRisk({
recvWindow: 60000,
})

t.truthy(adlRisks)
t.true(Array.isArray(adlRisks), 'Should return an array')

// Should return array for all symbols
if (adlRisks.length > 0) {
const [risk] = adlRisks
checkFields(t, risk, ['symbol', 'adlLevel'])
t.true(typeof risk.adlLevel === 'number')
t.true(risk.adlLevel >= 0 && risk.adlLevel <= 5, 'ADL level should be 0-5')
} else {
// Empty array is acceptable on testnet with no positions
t.pass('No ADL risk data (no positions on testnet)')
}
})

// ===== Commission Rate Tests =====

test('[FUTURES] futuresCommissionRate - get commission rates', async t => {
const commissionRate = await client.futuresCommissionRate({
symbol: 'BTCUSDT',
recvWindow: 60000,
})

t.truthy(commissionRate)
checkFields(t, commissionRate, ['symbol', 'makerCommissionRate', 'takerCommissionRate'])
t.is(commissionRate.symbol, 'BTCUSDT')

// Commission rates should be numeric strings
t.truthy(commissionRate.makerCommissionRate)
t.truthy(commissionRate.takerCommissionRate)
t.false(
isNaN(parseFloat(commissionRate.makerCommissionRate)),
'Maker commission should be numeric',
)
t.false(
isNaN(parseFloat(commissionRate.takerCommissionRate)),
'Taker commission should be numeric',
)

// RPI commission rate is optional (only present for RPI-supported symbols)
if (commissionRate.rpiCommissionRate !== undefined) {
t.false(
isNaN(parseFloat(commissionRate.rpiCommissionRate)),
'RPI commission should be numeric if present',
)
}
})

// ===== RPI Order Tests =====

test('[FUTURES] Integration - create and cancel RPI order', async t => {
const currentPrice = await getCurrentPrice()
// Place RPI order well below market (very unlikely to fill)
const buyPrice = Math.floor(currentPrice * 0.75)
// Ensure minimum notional of $100
const quantity = Math.max(0.002, Math.ceil((100 / buyPrice) * 1000) / 1000)

// Create an RPI order on testnet
const createResult = await client.futuresOrder({
symbol: 'BTCUSDT',
side: 'BUY',
type: 'LIMIT',
quantity: quantity,
price: buyPrice,
timeInForce: 'RPI', // RPI time-in-force
recvWindow: 60000,
})

t.truthy(createResult)
checkFields(t, createResult, ['orderId', 'symbol', 'side', 'type', 'status', 'timeInForce'])
t.is(createResult.symbol, 'BTCUSDT')
t.is(createResult.side, 'BUY')
t.is(createResult.type, 'LIMIT')
t.is(createResult.timeInForce, 'RPI', 'Should have RPI time-in-force')

const orderId = createResult.orderId

// Query the RPI order
const queryResult = await client.futuresGetOrder({
symbol: 'BTCUSDT',
orderId,
recvWindow: 60000,
})

t.truthy(queryResult)
t.is(queryResult.orderId, orderId)
t.is(queryResult.symbol, 'BTCUSDT')
t.is(queryResult.timeInForce, 'RPI', 'Queried order should have RPI time-in-force')

// Cancel the RPI order
try {
const cancelResult = await client.futuresCancelOrder({
symbol: 'BTCUSDT',
orderId,
recvWindow: 60000,
})

t.truthy(cancelResult)
t.is(cancelResult.orderId, orderId)
t.is(cancelResult.status, 'CANCELED')
} catch (e) {
// Order might have been filled or already canceled
if (e.code === -2011) {
t.pass('RPI order was filled or already canceled (acceptable on testnet)')
} else {
throw e
}
}
})

test('[FUTURES] futuresBatchOrders - create multiple RPI orders', async t => {
const currentPrice = await getCurrentPrice()
const buyPrice1 = Math.floor(currentPrice * 0.7)
const buyPrice2 = Math.floor(currentPrice * 0.65)
// Ensure minimum notional of $100
const quantity1 = Math.max(0.002, Math.ceil((100 / buyPrice1) * 1000) / 1000)
const quantity2 = Math.max(0.002, Math.ceil((100 / buyPrice2) * 1000) / 1000)

const batchOrders = [
{
symbol: 'BTCUSDT',
side: 'BUY',
type: 'LIMIT',
quantity: quantity1,
price: buyPrice1,
timeInForce: 'RPI', // RPI order
},
{
symbol: 'BTCUSDT',
side: 'BUY',
type: 'LIMIT',
quantity: quantity2,
price: buyPrice2,
timeInForce: 'RPI', // RPI order
},
]

try {
const result = await client.futuresBatchOrders({
batchOrders: JSON.stringify(batchOrders),
recvWindow: 60000,
})

t.true(Array.isArray(result), 'Should return an array')
t.is(result.length, 2, 'Should have 2 responses')

// Check if RPI orders were created successfully
const successfulOrders = result.filter(order => order.orderId)

if (successfulOrders.length > 0) {
// Verify successful RPI orders
successfulOrders.forEach(order => {
t.truthy(order.orderId, 'Successful order should have orderId')
t.is(order.symbol, 'BTCUSDT')
t.is(order.timeInForce, 'RPI', 'Batch order should have RPI time-in-force')
})

// Clean up - cancel the created RPI orders
const orderIds = successfulOrders.map(order => order.orderId)
try {
await client.futuresCancelBatchOrders({
symbol: 'BTCUSDT',
orderIdList: JSON.stringify(orderIds),
recvWindow: 60000,
})
t.pass('Batch RPI orders created and cancelled successfully')
} catch (e) {
if (e.code === -2011) {
t.pass('RPI orders were filled or already canceled')
} else {
throw e
}
}
} else {
// If no RPI orders succeeded, check if they failed with valid errors
const failedOrders = result.filter(order => order.code)

// RPI orders might fail with -4188 if symbol doesn't support RPI
const rpiNotSupported = failedOrders.some(order => order.code === -4188)
if (rpiNotSupported) {
t.pass('Symbol may not be in RPI whitelist (expected on testnet)')
} else {
t.true(
failedOrders.length > 0,
'Orders should either succeed or fail with error codes',
)
t.pass('Batch RPI orders API works but orders failed validation')
}
}
} catch (e) {
// RPI orders might not be fully supported on testnet
if (e.code === -4188) {
t.pass('Symbol is not in RPI whitelist (expected on testnet)')
} else {
t.pass(`Batch RPI orders may not be fully supported on testnet: ${e.message}`)
}
}
})
}

main()
Loading
Loading