Skip to content

Commit 2d3cc40

Browse files
sahar-fehribergarcesPrithpal-Sooriya
authored
chore: add ohlcv websocket streaming (#29739)
## **Description** Related to: https://www.notion.so/metamask-consensys/OHLCV-WebSocket-Integration-UI-Implementation-Guide-346f86d67d6880b6a70fc3be0f0c34b9 Wires `OHLCVService` from `@metamask/core-backend` into the Engine and creates a `useOHLCVRealtime` hook that streams live candlestick updates to the advanced chart via the existing `realtimeBar` prop on `AdvancedChart`. **Why:** The advanced chart currently only renders historical data fetched via the REST OHLCV API. Users see stale candles until they navigate away and back. Real-time streaming via WebSocket keeps the chart live with 5-second heartbeat updates. **How:** Follows the exact same Engine wiring pattern as `AccountActivityService` — messenger, init function, Engine registration. The new `useOHLCVRealtime` hook subscribes to `OHLCVService:barUpdated` events, filters by channel, and converts the WS bar format (timestamp in Unix seconds) to the chart's expected format (time in milliseconds). ## Manual Test Plan ### Prerequisites - MetaMask Mobile connected to a wallet with tokens - `backendWebSocketConnection` feature flag enabled ### Adding console.log statements to the mobile hook **1. Inside `handleBarUpdated`** — after the channel guard: ```ts const handleBarUpdated = (payload: { channel: string; bar: WSOHLCVBar }) => { if (payload.channel === channelRef.current) { console.log( // ← ADD `[OHLCV-WS] Bar received — channel=${payload.channel}, close=${payload.bar.close}, ts=${payload.bar.timestamp}`, ); lastMessageTimeRef.current = Date.now(); ... ``` **2. Inside `handleSubscriptionError`** — first line of the callback: ```ts const handleSubscriptionError = (payload: { channel: string; error: string; operation: string }) => { console.log( // ← ADD `[OHLCV-WS] Subscription error on ${payload.channel}: ${payload.error} (${payload.operation})`, ); }; ``` **3. Inside `handleChainStatusChanged`** — after the `chainIds.includes` guard: ```ts if (payload.chainIds.includes(chainId)) { console.log( // ← ADD `[OHLCV-WS] Chain status changed — chainId=${chainId}, status=${payload.status}`, ); chainDownRef.current = payload.status === 'down'; } ``` **4. Inside `pollLatest`** — first line of the function: ```ts const pollLatest = async () => { pollingAbortRef.current?.abort(); const controller = new AbortController(); pollingAbortRef.current = controller; console.log('[OHLCV-WS] Polling /latest via REST fallback'); // ← ADD ... ``` **5. Inside the staleness `setInterval`** — when `isStale || chainDown`: ```ts if (isStale || chainDownRef.current) { console.log( // ← ADD `[OHLCV-WS] Stream stale or chain down — isStale=${isStale}, chainDown=${chainDownRef.current}, elapsed=${elapsed}ms`, ); pollLatest(); } ``` **6. Inside the debounce `setTimeout`** — first line: ```ts debounceTimerRef.current = setTimeout(async () => { console.log( // ← ADD `[OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for ${channel}`, ); try { await Engine.controllerMessenger.call('OHLCVService:subscribe', { ... }); ... ``` **7. In the cleanup `return` function** — first line: ```ts return () => { console.log( // ← ADD `[OHLCV-WS] Cleanup — channel=${channel}, wasSubscribed=${subscribedRef.current}`, ); cancelledRef.current = true; ... ``` ### Enabling core logs in the debugger By default, core `OHLCVService` logs use `projectLogger` (the `debug` package) and won't appear in the React Native debugger. To make them visible, open: ``` node_modules/@metamask/core-backend/dist/ws/ohlcv/OHLCVService.cjs ``` Find this line (near the top, around line 30): ```js const log = (0, logger_1.createModuleLogger)(logger_1.projectLogger, SERVICE_NAME); ``` Replace with: ```js const log = (...args) => console.log('[OHLCV-WS]', ...args); ``` Now all core logs will appear in the debugger with the `[OHLCV-WS]` prefix, alongside the mobile hook logs. Revert with `yarn install` when done. --- ## Group A — No Code Changes (Just Tap and Observe) --- ### Scenario 1: Basic WebSocket Subscription **Steps:** 1. Open Token Details for a supported token (e.g., ETH on Base) 2. Wait for historical chart to load 3. Observe logs **Expected logs:** ``` [OHLCV-WS] OHLCV-WS: Initializing — registering system-notifications callback [OHLCV-WS] OHLCV-WS: Resubscribing active channels after reconnect {count: 0} [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1986.69, ts=1778538600 ``` > **Note:** `Resubscribing active channels after reconnect {count: 0}` appears at app boot because `AccountActivityService` opened the shared WebSocket first. OHLCVService hears the `CONNECTED` event and checks for channels to restore — finds zero since no subscription exists yet. This is normal. **Verify:** Bars continue arriving every ~5s with updating `close` prices. --- ### Scenario 2: Navigate Away (Unsubscribe + Grace Period) **Steps:** 1. From Scenario 1, press back to leave Token Details 2. Wait 3+ seconds 3. Observe logs **Expected logs:** ``` [OHLCV-WS] Cleanup — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, wasSubscribed=true [OHLCV-WS] OHLCV-WS: Grace period expired — performing actual WS unsubscribe {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] OHLCV-WS: WS unsubscribe completed {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} ``` **Verify:** No more bar updates after grace period expires. --- ### Scenario 3: Rapid Navigation (Grace Period Cancel) **Steps:** 1. Open Token Details for Token A, wait for subscription 2. Navigate back 3. Immediately re-open Token A (within 3 seconds) **Expected logs:** ``` [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Cleanup — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, wasSubscribed=true [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Cancelled grace-period unsubscribe, bumped refCount {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur', refCount: 1} ``` **Verify:** `Cancelled grace-period unsubscribe, bumped refCount` appears — subscription was reused without a server roundtrip. --- ### Scenario 4: Switch Between Tokens **Steps:** 1. Open Token Details for Token A (e.g. ETH on Base), wait for subscription 2. Navigate back to token list 3. Open Token B (e.g. MNT on Ethereum) **Expected logs:** ``` [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1988.32, ts=1778539500 [OHLCV-WS] Cleanup — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, wasSubscribed=true [OHLCV-WS] OHLCV-WS: Grace period expired — performing actual WS unsubscribe {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] OHLCV-WS: WS unsubscribe completed {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:1/erc20:0x3c3a...15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:1/erc20:0x3c3a...15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:1/erc20:0x3c3a...15m.eur, close=0.59, ts=1778539500 ``` **Verify:** Token A fully unsubscribes (grace period expires). Token B gets its own subscription and bars flow. --- ### Scenario 5: Rapid Time Range Switching **Steps:** 1. Open Token Details, wait for bars on default time range (15m) 2. Rapidly switch between time ranges (e.g. 1H → 1D → 1W → 1H) **Expected logs (showing one switch cycle: 15m → 1h):** ``` [OHLCV-WS] Cleanup — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, wasSubscribed=true [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.1h.eur [OHLCV-WS] OHLCV-WS: Flushing grace-period channel before new subscribe {flushedChannel: '...15m.eur', newChannel: '...1h.eur'} [OHLCV-WS] OHLCV-WS: Grace period expired — performing actual WS unsubscribe {channel: '...15m.eur'} [OHLCV-WS] OHLCV-WS: WS unsubscribe completed {channel: '...15m.eur'} [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: '...1h.eur'} ``` This pattern repeats for each switch (1h → 1d → 1h → 15m → 1m). Each time, the old channel is flushed immediately before the new subscribe — no accumulation, no server rejections. **Verify:** Every subscribe succeeds (`Subscribe succeeded`). `Flushing grace-period channel` appears before each new subscribe. Bars flow on the final time range. --- ### Scenario 6: App Background / Foreground **Steps:** 1. Open Token Details for a supported token, wait for bars to flow 2. Press home button (send app to background) 3. Wait ~10 seconds 4. Bring app back to foreground **Expected logs:** ``` [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1987.42, ts=1778540400 [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1987.37, ts=1778540400 — app sent to background, then brought back — [OHLCV-WS] OHLCV-WS: Resubscribing active channels after reconnect {count: 1} [OHLCV-WS] OHLCV-WS: Resubscription succeeded {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1987.18, ts=1778540400 ``` **Verify:** `Resubscribing active channels after reconnect {count: 1}` appears after foregrounding. Bars resume automatically without user interaction. --- ### Scenario 7: Unsupported Token (No OHLCV Data) **Steps:** 1. Open Token Details for a token with no OHLCV API data **Expected:** No WS subscription, falls back to legacy line chart. --- ## Group B — Requires Changing DEV Constants in `useOHLCVRealtime.ts` > After testing, set both constants back to `0` before committing. --- ### Scenario 8: WebSocket Disconnect → REST Polling Fallback **What this tests:** The WebSocket connection drops and stays disconnected. After the staleness threshold (30s) is exceeded, the hook falls back to polling REST. #### Code to add In `useOHLCVRealtime.ts`, set the DEV constant: ```ts const DEV_SIMULATE_WS_DISCONNECT_AFTER_MS = 10000; // ← ACTIVE ``` The simulation code in the hook must call `disconnect` (clean shutdown, **not** `forceReconnection`): ```ts if (DEV_SIMULATE_WS_DISCONNECT_AFTER_MS > 0) { setTimeout(() => { console.log( `[OHLCV-WS] DEV: Simulating WS disconnect (no reconnect) after ${DEV_SIMULATE_WS_DISCONNECT_AFTER_MS}ms`, ); Engine.controllerMessenger.call( 'BackendWebSocketService:disconnect' as never, ); }, DEV_SIMULATE_WS_DISCONNECT_AFTER_MS); } ``` #### How it works After 10s, calls `BackendWebSocketService:disconnect` (clean shutdown, no auto-reconnect). The WS stays dead. After 30s with no bars, staleness triggers REST polling every 15s. #### Steps to test 1. Set the constants as shown above 2. Rebuild / hot-reload the app 3. Open Token Details for a supported token 4. Wait for bars to start flowing (~5s) 5. At 10s, the simulated disconnect fires automatically 6. Wait ~30s for the staleness threshold 7. Observe REST fallback polling in logs #### Expected logs: ``` [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscribe succeeded — new WS subscription created {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur'} [OHLCV-WS] Bar received — channel=market-data.v1.eip155:8453/slip44:60.15m.eur, close=1985.08, ts=1778540400 [OHLCV-WS] DEV: Simulating WS disconnect (no reconnect) after 10000ms [OHLCV-WS] Stream stale or chain down — isStale=true, chainDown=false, elapsed=38975ms [OHLCV-WS] Polling /latest via REST fallback [OHLCV-WS] Stream stale or chain down — isStale=true, chainDown=false, elapsed=44886ms [OHLCV-WS] Polling /latest via REST fallback ``` **Verify:** After the simulated disconnect, no more `Bar received` logs. REST polling kicks in every 15s once staleness threshold (30s) is exceeded. --- ## Group C — Requires Editing `.cjs` in node_modules > After testing, run `yarn install` in the mobile repo to restore the original file. --- ### Scenario 10: Subscribe Failure / Error Recovery **What this tests:** `OHLCVService.subscribe()` fails. The service catches the error, publishes `OHLCVService:subscriptionError`, forces reconnection, and REST fallback keeps the chart alive. #### Code to add **1. Disable dev simulation constant** in `useOHLCVRealtime.ts`: ```ts const DEV_SIMULATE_WS_DISCONNECT_AFTER_MS = 0; ``` **2. Simulate subscribe failure** — open `node_modules/@metamask/core-backend/dist/ws/ohlcv/OHLCVService.cjs`. Find the subscribe call (look for `BackendWebSocketService:subscribe`) and comment it out, then add a throw: ```js // await __classPrivateFieldGet(this, _OHLCVService_messenger, "f").call('BackendWebSocketService:subscribe', { // channels: [channel], // channelType: SUBSCRIPTION_NAMESPACE, // callback: (notification) => { // __classPrivateFieldGet(this, _OHLCVService_instances, "m", _OHLCVService_handleBarUpdate).call(this, channel, notification); // }, // }); throw new Error('DEV: Simulated subscribe failure — invalid channel'); ``` #### Steps to test 1. Apply both code changes above 3. Reload the app 4. Open Token Details for a supported token 5. Observe logs #### Expected — look for these key logs: ``` [OHLCV-WS] OHLCV-WS: Resubscribing active channels after reconnect {count: 0} [OHLCV-WS] Debounce fired — calling OHLCVService:subscribe for market-data.v1.eip155:8453/slip44:60.15m.eur [OHLCV-WS] OHLCV-WS: Subscription failed, forcing reconnection {channel: 'market-data.v1.eip155:8453/slip44:60.15m.eur', error: Error: Test error ...} [OHLCV-WS] Subscription error on market-data.v1.eip155:8453/slip44:60.15m.eur: Error: Test error (subscribe) [OHLCV-WS] OHLCV-WS: Forcing WebSocket reconnection [OHLCV-WS] OHLCV-WS: Resubscribing active channels after reconnect {count: 0} [OHLCV-WS] Stream stale or chain down — isStale=true, chainDown=false, elapsed=44219ms [OHLCV-WS] Polling /latest via REST fallback ``` **Verify:** Error is caught, reconnection attempted (`Forcing WebSocket reconnection`), and REST fallback keeps chart alive after staleness is detected. --- ## Log Reference All logs use the **`OHLCV-WS`** prefix. Filter by `OHLCV-WS` in Flipper / debugger. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Adds websocket streaming integration for ohlcv data ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3194?atlOrigin=eyJpIjoiYmQ4N2E3MTlmZTFlNGYyNGFiODUxNzA2YThmM2FkYTkiLCJwIjoiaiJ9 Related: MetaMask/core#8695 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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] > **Medium Risk** > Introduces a new Engine-integrated WebSocket service and a real-time data path for price charts, which could impact app lifecycle, network subscriptions, and chart correctness if misconfigured. Includes a REST polling fallback and feature-flag gating, reducing blast radius but still touching core infrastructure. > > **Overview** > Adds real-time OHLCV candlestick streaming to the token details advanced chart by wiring `OHLCVService` (from `@metamask/core-backend`) into the Engine/messenger layer and upgrading `@metamask/core-backend` to `^6.3.0`. > > Introduces `useOHLCVRealtime`, which subscribes (debounced) to `OHLCVService` bar updates and provides a staleness/chain-down HTTP `/latest` fallback, then feeds updates into `AdvancedChart` via its existing `realtimeBar` prop. > > Gates the behavior behind a new remote, version-gated feature flag `tokenDetailsOhlcvWsIntegration` (registry + selector + CI constant mapping) and updates related unit tests/mocks to account for the new hook and selector. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fe6f560. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Bernardo Garces Chapero <bernardo.chapero@consensys.net> Co-authored-by: Prithpal Sooriya <prithpal.sooriya@consensys.net>
1 parent 2c34efa commit 2d3cc40

19 files changed

Lines changed: 1059 additions & 12 deletions

File tree

.github/scripts/known-feature-flag-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const FILE_SOURCES: Array<{ key: string; file: string; exportName: string }> = [
2020
{ key: 'FULL_PAGE_ACCOUNT_LIST_FLAG_NAME', file: sel('fullPageAccountList'), exportName: 'FULL_PAGE_ACCOUNT_LIST_FLAG_NAME' },
2121
{ key: 'IMPORT_SRP_WORD_SUGGESTION_FLAG_NAME', file: sel('importSrpWordSuggestion'), exportName: 'IMPORT_SRP_WORD_SUGGESTION_FLAG_NAME' },
2222
{ key: 'ASSETS_UNIFY_STATE_FLAG', file: sel('assetsUnifyState'), exportName: 'ASSETS_UNIFY_STATE_FLAG' },
23+
{ key: 'TOKEN_DETAILS_OHLCV_WS_INTEGRATION_FLAG_KEY', file: sel('tokenDetailsOhlcvWsIntegration'), exportName: 'TOKEN_DETAILS_OHLCV_WS_INTEGRATION_FLAG_KEY' },
2324
];
2425

2526
function resolveConstantFromFile(filePath: string, constantName: string): string | undefined {

app/components/UI/AssetOverview/Price/Price.advanced.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ jest.mock('../PriceChart/PriceChart.context', () => ({
2828
}),
2929
}));
3030

31+
jest.mock(
32+
'../../../../selectors/featureFlagController/tokenDetailsOhlcvWsIntegration',
33+
() => ({
34+
selectTokenDetailsOhlcvWsEnabled: jest.fn(() => false),
35+
}),
36+
);
37+
3138
jest.mock('react-redux', () => {
3239
const actual = jest.requireActual('react-redux');
3340
return {
@@ -89,6 +96,10 @@ jest.mock('../../Charts/AdvancedChart/useOHLCVChart', () => ({
8996
useOHLCVChart: (...args: unknown[]) => mockUseOHLCVChart(...args),
9097
}));
9198

99+
jest.mock('../../Charts/AdvancedChart/useOHLCVRealtime', () => ({
100+
useOHLCVRealtime: () => ({ latestBar: null }),
101+
}));
102+
92103
jest.mock('../../Charts/AdvancedChart/TimeRangeSelector', () => {
93104
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
94105
const { View, Pressable, Text } = require('react-native');

app/components/UI/AssetOverview/Price/Price.advanced.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import TimeRangeSelector, {
3636
type TimeRange,
3737
} from '../../Charts/AdvancedChart/TimeRangeSelector';
3838
import { useOHLCVChart } from '../../Charts/AdvancedChart/useOHLCVChart';
39+
import { useOHLCVRealtime } from '../../Charts/AdvancedChart/useOHLCVRealtime';
3940
import { OHLCVBar } from '../../Charts/AdvancedChart/OHLCVBar/OHLCVBar';
4041
import {
4142
Box,
@@ -62,9 +63,23 @@ import {
6263
TraceName,
6364
TraceOperation,
6465
} from '../../../../util/trace';
66+
import { selectTokenDetailsOhlcvWsEnabled } from '../../../../selectors/featureFlagController/tokenDetailsOhlcvWsIntegration';
6567

6668
const EMPTY_INDICATORS: IndicatorType[] = [];
6769

70+
/**
71+
* Maps UI time-range selections to the WebSocket candle interval used by
72+
* OHLCVService. These match the default intervals the REST OHLCV API returns
73+
* for each timePeriod.
74+
*/
75+
const WS_INTERVAL_BY_TIME_RANGE: Record<TimeRange, string> = {
76+
'1H': '1m',
77+
'1D': '15m',
78+
'1W': '1h',
79+
'1M': '1d',
80+
'1Y': '1d',
81+
};
82+
6883
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
6984
'1H': 'asset_overview.chart_time_period.1h',
7085
'1D': 'asset_overview.chart_time_period.1d',
@@ -134,6 +149,7 @@ const PriceAdvanced = ({
134149
const { trackEvent, createEventBuilder } = useAnalytics();
135150
const [timeRange, setTimeRange] = useState<TimeRange>('1D');
136151
const chartType = useSelector(selectTokenOverviewChartType);
152+
const isOhlcvWsEnabled = useSelector(selectTokenDetailsOhlcvWsEnabled);
137153
const [crosshairData, setCrosshairData] = useState<CrosshairData | null>(
138154
null,
139155
);
@@ -293,6 +309,37 @@ const PriceAdvanced = ({
293309
vsCurrency: currentCurrency,
294310
});
295311

312+
const wsInterval = WS_INTERVAL_BY_TIME_RANGE[timeRange];
313+
const wsEnabled =
314+
isOhlcvWsEnabled &&
315+
!chartLoading &&
316+
ohlcvData.length >= CHART_DATA_THRESHOLD &&
317+
!hasEmptyData &&
318+
!chartError;
319+
320+
const { latestBar } = useOHLCVRealtime({
321+
assetId,
322+
interval: wsInterval,
323+
currency: currentCurrency,
324+
timePeriod: timeRange.toLowerCase(),
325+
enabled: wsEnabled,
326+
});
327+
328+
// TradingView Advanced Charts Bar.time expects milliseconds
329+
// https://www.tradingview.com/charting-library-docs/latest/api/interfaces/Datafeed.Bar/
330+
// OHLCVService delivers bars with `timestamp` in Unix seconds — multiply by 1000
331+
const realtimeBar = useMemo(() => {
332+
if (!wsEnabled || !latestBar) return undefined;
333+
return {
334+
time: latestBar.timestamp * 1000,
335+
open: latestBar.open,
336+
high: latestBar.high,
337+
low: latestBar.low,
338+
close: latestBar.close,
339+
volume: latestBar.volume,
340+
};
341+
}, [wsEnabled, latestBar]);
342+
296343
const ohlcvPagination = useMemo(
297344
() => ({
298345
nextCursor,
@@ -346,14 +393,22 @@ const PriceAdvanced = ({
346393

347394
// Use last bar's close price for consistent percentage calculation with chart data
348395
const lastBarClose = ohlcvData[ohlcvData.length - 1]?.close;
349-
const displayPrice = crosshairData?.close ?? lastBarClose ?? currentPrice;
396+
const realtimeClose = wsEnabled ? latestBar?.close : undefined;
397+
const displayPrice =
398+
crosshairData?.close ?? realtimeClose ?? lastBarClose ?? currentPrice;
350399
const displayDiff = useMemo(() => {
351400
if (dynamicComparePrice === null) return null;
352401
return (
353-
(crosshairData?.close ?? lastBarClose ?? currentPrice) -
402+
(crosshairData?.close ?? realtimeClose ?? lastBarClose ?? currentPrice) -
354403
dynamicComparePrice
355404
);
356-
}, [crosshairData, lastBarClose, currentPrice, dynamicComparePrice]);
405+
}, [
406+
crosshairData,
407+
realtimeClose,
408+
lastBarClose,
409+
currentPrice,
410+
dynamicComparePrice,
411+
]);
357412

358413
const displayDate = crosshairData
359414
? toDateFormat(crosshairData.time)
@@ -554,6 +609,7 @@ const PriceAdvanced = ({
554609
<AdvancedChart
555610
ohlcvData={ohlcvData}
556611
ohlcvSeriesKey={ohlcvSeriesKey}
612+
realtimeBar={realtimeBar}
557613
height={CHART_HEIGHT}
558614
showVolume={chartType === ChartType.Candles}
559615
volumeOverlay

app/components/UI/AssetOverview/Price/Price.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ jest.mock('../../Charts/AdvancedChart/useOHLCVChart', () => ({
5252
useOHLCVChart: (...args: unknown[]) => mockUseOHLCVChart(...args),
5353
}));
5454

55+
jest.mock('../../Charts/AdvancedChart/useOHLCVRealtime', () => ({
56+
useOHLCVRealtime: () => ({ latestBar: null }),
57+
}));
58+
5559
jest.mock('../PriceChart/PriceChart', () => {
5660
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
5761
const { View } = require('react-native');

0 commit comments

Comments
 (0)