Skip to content

Commit 0986b6f

Browse files
chore(runway): cherry-pick fix(perps): add missing returnOnEquity calculation in HyperLiquidSubscriptionService cp-7.60.0 (#22983)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR fixes a missing `returnOnEquity` calculation in the `HyperLiquidSubscriptionService.aggregateAccountStates()` method. ## **Changelog** CHANGELOG entry: Fixed a bug where the PnL % would not show ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TAT-2086 ## **Manual testing steps** ```gherkin Feature: Return on Equity calculation in Perps account aggregation Scenario: user views account state with open HIP3 positions Given user has open perpetual positions with unrealized PnL Then the returnOnEquity field should be correctly calculated as (unrealizedPnl / marginUsed) * 100 And the value should be formatted to one decimal place ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ![IMG_954DF8480A63-1](https://github.com/user-attachments/assets/166b53a5-5afd-4f24-9424-d9d9fef269a6) ### **After** <!-- [screenshots/recordings] --> <img width="1170" height="2532" alt="Simulator Screenshot - iPhone 16e - 2025-11-20 at 09 47 15" src="https://github.com/user-attachments/assets/435d4952-8b54-4c4e-ae30-c7b8a11bcd36" /> ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Computes `returnOnEquity` in `HyperLiquidSubscriptionService.aggregateAccountStates` and adds unit tests covering multiple scenarios and rounding. > > - **Service**: > - Compute `returnOnEquity` in `HyperLiquidSubscriptionService.aggregateAccountStates` as `((totalUnrealizedPnl / totalMarginUsed) * 100).toFixed(1)` and include it in the aggregated `AccountState`. > - **Tests**: > - Add ROE test suite in `HyperLiquidSubscriptionService.test.ts` validating positive, negative, zero-margin, mixed PnL, large gains, and one-decimal rounding; mock adapter accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 316fc46. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ce9cf4f commit 0986b6f

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed

app/components/UI/Perps/services/HyperLiquidSubscriptionService.test.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
import type { HyperLiquidClientService } from './HyperLiquidClientService';
1414
import { HyperLiquidSubscriptionService } from './HyperLiquidSubscriptionService';
1515
import type { HyperLiquidWalletService } from './HyperLiquidWalletService';
16+
import { adaptAccountStateFromSDK } from '../utils/hyperLiquidAdapter';
1617

1718
// Mock HyperLiquid SDK types
1819
interface MockSubscription {
@@ -2738,4 +2739,277 @@ describe('HyperLiquidSubscriptionService', () => {
27382739
unsubscribe2();
27392740
});
27402741
});
2742+
2743+
describe('aggregateAccountStates - returnOnEquity calculation', () => {
2744+
it('calculates positive ROE when unrealizedPnl is positive', async () => {
2745+
// Override the adapter mock
2746+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2747+
availableBalance: '100',
2748+
totalBalance: '1100',
2749+
marginUsed: '1000',
2750+
unrealizedPnl: '100',
2751+
returnOnEquity: '10.0',
2752+
}));
2753+
2754+
const mockCallback = jest.fn();
2755+
2756+
// Mock webData3
2757+
mockSubscriptionClient.webData3.mockImplementation(
2758+
(_params: any, callback: any) => {
2759+
const mockData = {
2760+
perpDexStates: [
2761+
{
2762+
clearinghouseState: { assetPositions: [] },
2763+
openOrders: [],
2764+
perpsAtOpenInterestCap: [],
2765+
},
2766+
],
2767+
};
2768+
2769+
setTimeout(() => callback(mockData), 10);
2770+
return { unsubscribe: jest.fn() };
2771+
},
2772+
);
2773+
2774+
const unsubscribe = service.subscribeToAccount({
2775+
callback: mockCallback,
2776+
});
2777+
2778+
await new Promise((resolve) => setTimeout(resolve, 50));
2779+
2780+
expect(mockCallback).toHaveBeenCalled();
2781+
const accountState = mockCallback.mock.calls[0][0];
2782+
expect(accountState.marginUsed).toBe('1000');
2783+
expect(accountState.unrealizedPnl).toBe('100');
2784+
expect(accountState.returnOnEquity).toBe('10.0');
2785+
2786+
unsubscribe();
2787+
});
2788+
2789+
it('calculates negative ROE when unrealizedPnl is negative', async () => {
2790+
// Override the adapter mock
2791+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2792+
availableBalance: '0',
2793+
totalBalance: '950',
2794+
marginUsed: '1000',
2795+
unrealizedPnl: '-50',
2796+
returnOnEquity: '-5.0',
2797+
}));
2798+
2799+
const mockCallback = jest.fn();
2800+
2801+
// Mock webData3
2802+
mockSubscriptionClient.webData3.mockImplementation(
2803+
(_params: any, callback: any) => {
2804+
const mockData = {
2805+
perpDexStates: [
2806+
{
2807+
clearinghouseState: { assetPositions: [] },
2808+
openOrders: [],
2809+
perpsAtOpenInterestCap: [],
2810+
},
2811+
],
2812+
};
2813+
2814+
setTimeout(() => callback(mockData), 10);
2815+
return { unsubscribe: jest.fn() };
2816+
},
2817+
);
2818+
2819+
const unsubscribe = service.subscribeToAccount({
2820+
callback: mockCallback,
2821+
});
2822+
2823+
await new Promise((resolve) => setTimeout(resolve, 50));
2824+
2825+
expect(mockCallback).toHaveBeenCalled();
2826+
const accountState = mockCallback.mock.calls[0][0];
2827+
expect(accountState.marginUsed).toBe('1000');
2828+
expect(accountState.unrealizedPnl).toBe('-50');
2829+
expect(accountState.returnOnEquity).toBe('-5.0');
2830+
2831+
unsubscribe();
2832+
});
2833+
2834+
it('returns zero ROE when marginUsed is zero', async () => {
2835+
// Override the adapter mock
2836+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2837+
availableBalance: '1000',
2838+
totalBalance: '1000',
2839+
marginUsed: '0',
2840+
unrealizedPnl: '0',
2841+
returnOnEquity: '0',
2842+
}));
2843+
2844+
const mockCallback = jest.fn();
2845+
2846+
// Mock webData3
2847+
mockSubscriptionClient.webData3.mockImplementation(
2848+
(_params: any, callback: any) => {
2849+
const mockData = {
2850+
perpDexStates: [
2851+
{
2852+
clearinghouseState: { assetPositions: [] },
2853+
openOrders: [],
2854+
perpsAtOpenInterestCap: [],
2855+
},
2856+
],
2857+
};
2858+
2859+
setTimeout(() => callback(mockData), 10);
2860+
return { unsubscribe: jest.fn() };
2861+
},
2862+
);
2863+
2864+
const unsubscribe = service.subscribeToAccount({
2865+
callback: mockCallback,
2866+
});
2867+
2868+
await new Promise((resolve) => setTimeout(resolve, 50));
2869+
2870+
expect(mockCallback).toHaveBeenCalled();
2871+
const accountState = mockCallback.mock.calls[0][0];
2872+
expect(accountState.marginUsed).toBe('0');
2873+
expect(accountState.unrealizedPnl).toBe('0');
2874+
expect(accountState.returnOnEquity).toBe('0');
2875+
2876+
unsubscribe();
2877+
});
2878+
2879+
it('calculates correct ROE with mixed profit and loss positions', async () => {
2880+
// Override the adapter mock
2881+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2882+
availableBalance: '75',
2883+
totalBalance: '1575',
2884+
marginUsed: '1500',
2885+
unrealizedPnl: '75',
2886+
returnOnEquity: '5.0',
2887+
}));
2888+
2889+
const mockCallback = jest.fn();
2890+
2891+
// Mock webData3 - simulates account with multiple positions
2892+
// marginUsed=1500, unrealizedPnl=75 → ROE=5.0%
2893+
mockSubscriptionClient.webData3.mockImplementation(
2894+
(_params: any, callback: any) => {
2895+
const mockData = {
2896+
perpDexStates: [
2897+
{
2898+
clearinghouseState: { assetPositions: [] },
2899+
openOrders: [],
2900+
perpsAtOpenInterestCap: [],
2901+
},
2902+
],
2903+
};
2904+
2905+
setTimeout(() => callback(mockData), 10);
2906+
return { unsubscribe: jest.fn() };
2907+
},
2908+
);
2909+
2910+
const unsubscribe = service.subscribeToAccount({
2911+
callback: mockCallback,
2912+
});
2913+
2914+
await new Promise((resolve) => setTimeout(resolve, 50));
2915+
2916+
expect(mockCallback).toHaveBeenCalled();
2917+
const accountState = mockCallback.mock.calls[0][0];
2918+
expect(accountState.marginUsed).toBe('1500');
2919+
expect(accountState.unrealizedPnl).toBe('75');
2920+
expect(accountState.returnOnEquity).toBe('5.0');
2921+
2922+
unsubscribe();
2923+
});
2924+
2925+
it('calculates high ROE with large percentage gains', async () => {
2926+
// Override the adapter mock
2927+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2928+
availableBalance: '200',
2929+
totalBalance: '300',
2930+
marginUsed: '100',
2931+
unrealizedPnl: '200',
2932+
returnOnEquity: '200.0',
2933+
}));
2934+
2935+
const mockCallback = jest.fn();
2936+
2937+
// Mock webData3
2938+
mockSubscriptionClient.webData3.mockImplementation(
2939+
(_params: any, callback: any) => {
2940+
const mockData = {
2941+
perpDexStates: [
2942+
{
2943+
clearinghouseState: { assetPositions: [] },
2944+
openOrders: [],
2945+
perpsAtOpenInterestCap: [],
2946+
},
2947+
],
2948+
};
2949+
2950+
setTimeout(() => callback(mockData), 10);
2951+
return { unsubscribe: jest.fn() };
2952+
},
2953+
);
2954+
2955+
const unsubscribe = service.subscribeToAccount({
2956+
callback: mockCallback,
2957+
});
2958+
2959+
await new Promise((resolve) => setTimeout(resolve, 50));
2960+
2961+
expect(mockCallback).toHaveBeenCalled();
2962+
const accountState = mockCallback.mock.calls[0][0];
2963+
expect(accountState.marginUsed).toBe('100');
2964+
expect(accountState.unrealizedPnl).toBe('200');
2965+
expect(accountState.returnOnEquity).toBe('200.0');
2966+
2967+
unsubscribe();
2968+
});
2969+
2970+
it('rounds ROE to one decimal place', async () => {
2971+
// Override the adapter mock
2972+
jest.mocked(adaptAccountStateFromSDK).mockImplementation(() => ({
2973+
availableBalance: '100',
2974+
totalBalance: '433',
2975+
marginUsed: '333',
2976+
unrealizedPnl: '100',
2977+
returnOnEquity: '30.0',
2978+
}));
2979+
2980+
const mockCallback = jest.fn();
2981+
2982+
// Mock webData3
2983+
mockSubscriptionClient.webData3.mockImplementation(
2984+
(_params: any, callback: any) => {
2985+
const mockData = {
2986+
perpDexStates: [
2987+
{
2988+
clearinghouseState: { assetPositions: [] },
2989+
openOrders: [],
2990+
perpsAtOpenInterestCap: [],
2991+
},
2992+
],
2993+
};
2994+
2995+
setTimeout(() => callback(mockData), 10);
2996+
return { unsubscribe: jest.fn() };
2997+
},
2998+
);
2999+
3000+
const unsubscribe = service.subscribeToAccount({
3001+
callback: mockCallback,
3002+
});
3003+
3004+
await new Promise((resolve) => setTimeout(resolve, 50));
3005+
3006+
expect(mockCallback).toHaveBeenCalled();
3007+
const accountState = mockCallback.mock.calls[0][0];
3008+
expect(accountState.marginUsed).toBe('333');
3009+
expect(accountState.unrealizedPnl).toBe('100');
3010+
expect(accountState.returnOnEquity).toBe('30.0');
3011+
3012+
unsubscribe();
3013+
});
3014+
});
27413015
});

app/components/UI/Perps/services/HyperLiquidSubscriptionService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,13 +495,22 @@ export class HyperLiquidSubscriptionService {
495495
const firstDexAccount =
496496
this.dexAccountCache.values().next().value || ({} as AccountState);
497497

498+
// Calculate returnOnEquity across all DEXs (same formula as HyperLiquidProvider.getAccountState)
499+
let returnOnEquity = '0';
500+
if (totalMarginUsed > 0) {
501+
returnOnEquity = ((totalUnrealizedPnl / totalMarginUsed) * 100).toFixed(
502+
1,
503+
);
504+
}
505+
498506
return {
499507
...firstDexAccount,
500508
availableBalance: totalAvailableBalance.toString(),
501509
totalBalance: totalBalance.toString(),
502510
marginUsed: totalMarginUsed.toString(),
503511
unrealizedPnl: totalUnrealizedPnl.toString(),
504512
subAccountBreakdown,
513+
returnOnEquity,
505514
};
506515
}
507516

0 commit comments

Comments
 (0)