|
33 | 33 | * - getMultiAssetsMargin: Get multi-asset mode status |
34 | 34 | * - setMultiAssetsMargin: Enable/disable multi-asset mode |
35 | 35 | * |
| 36 | + * RPI (Retail Price Improvement) Orders: |
| 37 | + * - futuresRpiDepth: Get RPI order book (public endpoint) |
| 38 | + * - futuresSymbolAdlRisk: Get ADL (Auto-Deleveraging) risk rating |
| 39 | + * - futuresCommissionRate: Get commission rates including RPI commission |
| 40 | + * - RPI Orders: Create and manage orders with timeInForce: 'RPI' |
| 41 | + * |
36 | 42 | * Configuration: |
37 | 43 | * - Uses testnet: true for safe testing |
38 | 44 | * - Uses proxy for connections |
@@ -527,6 +533,284 @@ const main = () => { |
527 | 533 | // Skipped - requires open position and modifies margin |
528 | 534 | t.pass('Skipped - requires open position') |
529 | 535 | }) |
| 536 | + |
| 537 | + // ===== RPI Order Book Tests ===== |
| 538 | + |
| 539 | + test('[FUTURES] futuresRpiDepth - get RPI order book', async t => { |
| 540 | + const rpiDepth = await client.futuresRpiDepth({ |
| 541 | + symbol: 'BTCUSDT', |
| 542 | + limit: 1000, |
| 543 | + }) |
| 544 | + |
| 545 | + t.truthy(rpiDepth) |
| 546 | + checkFields(t, rpiDepth, ['lastUpdateId', 'bids', 'asks']) |
| 547 | + t.true(Array.isArray(rpiDepth.bids), 'Should have bids array') |
| 548 | + t.true(Array.isArray(rpiDepth.asks), 'Should have asks array') |
| 549 | + |
| 550 | + // Check bid/ask structure if data is available |
| 551 | + if (rpiDepth.bids.length > 0) { |
| 552 | + const [firstBid] = rpiDepth.bids |
| 553 | + t.truthy(firstBid.price, 'Bid should have price') |
| 554 | + t.truthy(firstBid.quantity, 'Bid should have quantity') |
| 555 | + } |
| 556 | + if (rpiDepth.asks.length > 0) { |
| 557 | + const [firstAsk] = rpiDepth.asks |
| 558 | + t.truthy(firstAsk.price, 'Ask should have price') |
| 559 | + t.truthy(firstAsk.quantity, 'Ask should have quantity') |
| 560 | + } |
| 561 | + }) |
| 562 | + |
| 563 | + test('[FUTURES] futuresRpiDepth - with default limit', async t => { |
| 564 | + const rpiDepth = await client.futuresRpiDepth({ |
| 565 | + symbol: 'ETHUSDT', |
| 566 | + }) |
| 567 | + |
| 568 | + t.truthy(rpiDepth) |
| 569 | + checkFields(t, rpiDepth, ['lastUpdateId', 'bids', 'asks']) |
| 570 | + t.true(Array.isArray(rpiDepth.bids)) |
| 571 | + t.true(Array.isArray(rpiDepth.asks)) |
| 572 | + }) |
| 573 | + |
| 574 | + // ===== ADL Risk Rating Tests ===== |
| 575 | + |
| 576 | + test('[FUTURES] futuresSymbolAdlRisk - get ADL risk for specific symbol', async t => { |
| 577 | + try { |
| 578 | + const adlRisk = await client.futuresSymbolAdlRisk({ |
| 579 | + symbol: 'BTCUSDT', |
| 580 | + recvWindow: 60000, |
| 581 | + }) |
| 582 | + |
| 583 | + t.truthy(adlRisk) |
| 584 | + |
| 585 | + // Response can be single object or array depending on API |
| 586 | + if (Array.isArray(adlRisk)) { |
| 587 | + if (adlRisk.length > 0) { |
| 588 | + const [risk] = adlRisk |
| 589 | + checkFields(t, risk, ['symbol', 'adlLevel']) |
| 590 | + t.is(risk.symbol, 'BTCUSDT') |
| 591 | + t.true(typeof risk.adlLevel === 'number') |
| 592 | + t.true(risk.adlLevel >= 0 && risk.adlLevel <= 5, 'ADL level should be 0-5') |
| 593 | + } else { |
| 594 | + t.pass('No ADL risk data (no positions on testnet)') |
| 595 | + } |
| 596 | + } else { |
| 597 | + checkFields(t, adlRisk, ['symbol', 'adlLevel']) |
| 598 | + t.is(adlRisk.symbol, 'BTCUSDT') |
| 599 | + t.true(typeof adlRisk.adlLevel === 'number') |
| 600 | + } |
| 601 | + } catch (e) { |
| 602 | + // Testnet may not support ADL risk for all symbols or have no positions |
| 603 | + if (e.code === -1121) { |
| 604 | + t.pass('Symbol not valid or no positions on testnet (expected)') |
| 605 | + } else { |
| 606 | + throw e |
| 607 | + } |
| 608 | + } |
| 609 | + }) |
| 610 | + |
| 611 | + test('[FUTURES] futuresSymbolAdlRisk - get ADL risk for all symbols', async t => { |
| 612 | + const adlRisks = await client.futuresSymbolAdlRisk({ |
| 613 | + recvWindow: 60000, |
| 614 | + }) |
| 615 | + |
| 616 | + t.truthy(adlRisks) |
| 617 | + t.true(Array.isArray(adlRisks), 'Should return an array') |
| 618 | + |
| 619 | + // Should return array for all symbols |
| 620 | + if (adlRisks.length > 0) { |
| 621 | + const [risk] = adlRisks |
| 622 | + checkFields(t, risk, ['symbol', 'adlLevel']) |
| 623 | + t.true(typeof risk.adlLevel === 'number') |
| 624 | + t.true(risk.adlLevel >= 0 && risk.adlLevel <= 5, 'ADL level should be 0-5') |
| 625 | + } else { |
| 626 | + // Empty array is acceptable on testnet with no positions |
| 627 | + t.pass('No ADL risk data (no positions on testnet)') |
| 628 | + } |
| 629 | + }) |
| 630 | + |
| 631 | + // ===== Commission Rate Tests ===== |
| 632 | + |
| 633 | + test('[FUTURES] futuresCommissionRate - get commission rates', async t => { |
| 634 | + const commissionRate = await client.futuresCommissionRate({ |
| 635 | + symbol: 'BTCUSDT', |
| 636 | + recvWindow: 60000, |
| 637 | + }) |
| 638 | + |
| 639 | + t.truthy(commissionRate) |
| 640 | + checkFields(t, commissionRate, ['symbol', 'makerCommissionRate', 'takerCommissionRate']) |
| 641 | + t.is(commissionRate.symbol, 'BTCUSDT') |
| 642 | + |
| 643 | + // Commission rates should be numeric strings |
| 644 | + t.truthy(commissionRate.makerCommissionRate) |
| 645 | + t.truthy(commissionRate.takerCommissionRate) |
| 646 | + t.false( |
| 647 | + isNaN(parseFloat(commissionRate.makerCommissionRate)), |
| 648 | + 'Maker commission should be numeric', |
| 649 | + ) |
| 650 | + t.false( |
| 651 | + isNaN(parseFloat(commissionRate.takerCommissionRate)), |
| 652 | + 'Taker commission should be numeric', |
| 653 | + ) |
| 654 | + |
| 655 | + // RPI commission rate is optional (only present for RPI-supported symbols) |
| 656 | + if (commissionRate.rpiCommissionRate !== undefined) { |
| 657 | + t.false( |
| 658 | + isNaN(parseFloat(commissionRate.rpiCommissionRate)), |
| 659 | + 'RPI commission should be numeric if present', |
| 660 | + ) |
| 661 | + } |
| 662 | + }) |
| 663 | + |
| 664 | + // ===== RPI Order Tests ===== |
| 665 | + |
| 666 | + test('[FUTURES] Integration - create and cancel RPI order', async t => { |
| 667 | + const currentPrice = await getCurrentPrice() |
| 668 | + // Place RPI order well below market (very unlikely to fill) |
| 669 | + const buyPrice = Math.floor(currentPrice * 0.75) |
| 670 | + // Ensure minimum notional of $100 |
| 671 | + const quantity = Math.max(0.002, Math.ceil((100 / buyPrice) * 1000) / 1000) |
| 672 | + |
| 673 | + // Create an RPI order on testnet |
| 674 | + const createResult = await client.futuresOrder({ |
| 675 | + symbol: 'BTCUSDT', |
| 676 | + side: 'BUY', |
| 677 | + type: 'LIMIT', |
| 678 | + quantity: quantity, |
| 679 | + price: buyPrice, |
| 680 | + timeInForce: 'RPI', // RPI time-in-force |
| 681 | + recvWindow: 60000, |
| 682 | + }) |
| 683 | + |
| 684 | + t.truthy(createResult) |
| 685 | + checkFields(t, createResult, ['orderId', 'symbol', 'side', 'type', 'status', 'timeInForce']) |
| 686 | + t.is(createResult.symbol, 'BTCUSDT') |
| 687 | + t.is(createResult.side, 'BUY') |
| 688 | + t.is(createResult.type, 'LIMIT') |
| 689 | + t.is(createResult.timeInForce, 'RPI', 'Should have RPI time-in-force') |
| 690 | + |
| 691 | + const orderId = createResult.orderId |
| 692 | + |
| 693 | + // Query the RPI order |
| 694 | + const queryResult = await client.futuresGetOrder({ |
| 695 | + symbol: 'BTCUSDT', |
| 696 | + orderId, |
| 697 | + recvWindow: 60000, |
| 698 | + }) |
| 699 | + |
| 700 | + t.truthy(queryResult) |
| 701 | + t.is(queryResult.orderId, orderId) |
| 702 | + t.is(queryResult.symbol, 'BTCUSDT') |
| 703 | + t.is(queryResult.timeInForce, 'RPI', 'Queried order should have RPI time-in-force') |
| 704 | + |
| 705 | + // Cancel the RPI order |
| 706 | + try { |
| 707 | + const cancelResult = await client.futuresCancelOrder({ |
| 708 | + symbol: 'BTCUSDT', |
| 709 | + orderId, |
| 710 | + recvWindow: 60000, |
| 711 | + }) |
| 712 | + |
| 713 | + t.truthy(cancelResult) |
| 714 | + t.is(cancelResult.orderId, orderId) |
| 715 | + t.is(cancelResult.status, 'CANCELED') |
| 716 | + } catch (e) { |
| 717 | + // Order might have been filled or already canceled |
| 718 | + if (e.code === -2011) { |
| 719 | + t.pass('RPI order was filled or already canceled (acceptable on testnet)') |
| 720 | + } else { |
| 721 | + throw e |
| 722 | + } |
| 723 | + } |
| 724 | + }) |
| 725 | + |
| 726 | + test('[FUTURES] futuresBatchOrders - create multiple RPI orders', async t => { |
| 727 | + const currentPrice = await getCurrentPrice() |
| 728 | + const buyPrice1 = Math.floor(currentPrice * 0.7) |
| 729 | + const buyPrice2 = Math.floor(currentPrice * 0.65) |
| 730 | + // Ensure minimum notional of $100 |
| 731 | + const quantity1 = Math.max(0.002, Math.ceil((100 / buyPrice1) * 1000) / 1000) |
| 732 | + const quantity2 = Math.max(0.002, Math.ceil((100 / buyPrice2) * 1000) / 1000) |
| 733 | + |
| 734 | + const batchOrders = [ |
| 735 | + { |
| 736 | + symbol: 'BTCUSDT', |
| 737 | + side: 'BUY', |
| 738 | + type: 'LIMIT', |
| 739 | + quantity: quantity1, |
| 740 | + price: buyPrice1, |
| 741 | + timeInForce: 'RPI', // RPI order |
| 742 | + }, |
| 743 | + { |
| 744 | + symbol: 'BTCUSDT', |
| 745 | + side: 'BUY', |
| 746 | + type: 'LIMIT', |
| 747 | + quantity: quantity2, |
| 748 | + price: buyPrice2, |
| 749 | + timeInForce: 'RPI', // RPI order |
| 750 | + }, |
| 751 | + ] |
| 752 | + |
| 753 | + try { |
| 754 | + const result = await client.futuresBatchOrders({ |
| 755 | + batchOrders: JSON.stringify(batchOrders), |
| 756 | + recvWindow: 60000, |
| 757 | + }) |
| 758 | + |
| 759 | + t.true(Array.isArray(result), 'Should return an array') |
| 760 | + t.is(result.length, 2, 'Should have 2 responses') |
| 761 | + |
| 762 | + // Check if RPI orders were created successfully |
| 763 | + const successfulOrders = result.filter(order => order.orderId) |
| 764 | + |
| 765 | + if (successfulOrders.length > 0) { |
| 766 | + // Verify successful RPI orders |
| 767 | + successfulOrders.forEach(order => { |
| 768 | + t.truthy(order.orderId, 'Successful order should have orderId') |
| 769 | + t.is(order.symbol, 'BTCUSDT') |
| 770 | + t.is(order.timeInForce, 'RPI', 'Batch order should have RPI time-in-force') |
| 771 | + }) |
| 772 | + |
| 773 | + // Clean up - cancel the created RPI orders |
| 774 | + const orderIds = successfulOrders.map(order => order.orderId) |
| 775 | + try { |
| 776 | + await client.futuresCancelBatchOrders({ |
| 777 | + symbol: 'BTCUSDT', |
| 778 | + orderIdList: JSON.stringify(orderIds), |
| 779 | + recvWindow: 60000, |
| 780 | + }) |
| 781 | + t.pass('Batch RPI orders created and cancelled successfully') |
| 782 | + } catch (e) { |
| 783 | + if (e.code === -2011) { |
| 784 | + t.pass('RPI orders were filled or already canceled') |
| 785 | + } else { |
| 786 | + throw e |
| 787 | + } |
| 788 | + } |
| 789 | + } else { |
| 790 | + // If no RPI orders succeeded, check if they failed with valid errors |
| 791 | + const failedOrders = result.filter(order => order.code) |
| 792 | + |
| 793 | + // RPI orders might fail with -4188 if symbol doesn't support RPI |
| 794 | + const rpiNotSupported = failedOrders.some(order => order.code === -4188) |
| 795 | + if (rpiNotSupported) { |
| 796 | + t.pass('Symbol may not be in RPI whitelist (expected on testnet)') |
| 797 | + } else { |
| 798 | + t.true( |
| 799 | + failedOrders.length > 0, |
| 800 | + 'Orders should either succeed or fail with error codes', |
| 801 | + ) |
| 802 | + t.pass('Batch RPI orders API works but orders failed validation') |
| 803 | + } |
| 804 | + } |
| 805 | + } catch (e) { |
| 806 | + // RPI orders might not be fully supported on testnet |
| 807 | + if (e.code === -4188) { |
| 808 | + t.pass('Symbol is not in RPI whitelist (expected on testnet)') |
| 809 | + } else { |
| 810 | + t.pass(`Batch RPI orders may not be fully supported on testnet: ${e.message}`) |
| 811 | + } |
| 812 | + } |
| 813 | + }) |
530 | 814 | } |
531 | 815 |
|
532 | 816 | main() |
0 commit comments