Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit 715451f

Browse files
authored
Refactor Perpetuals to use HyperliquidObserverService (#1635)
* Refactor Perpetuals to use HyperliquidObserverService Replaces PerpetualObserverService with HyperliquidObserverService for perpetuals observation and updates related dependency injection, protocols, and usages throughout the codebase. Removes periodic polling in favor of WebSocket-based updates, adds Hyperliquid-specific protocol and types, and updates wallet/account accessors. Also updates WebSocketConnectable to support sending string messages and adds a PerpetualService mock for testing. * Add real-time chart streaming for perpetuals Introduces real-time candlestick chart updates for perpetuals by integrating HyperliquidChartService and chart subscription management in PerpetualSceneViewModel. Updates the observer service to handle candle messages, adds ChartCandleStick interval support, and wires dependencies through navigation and environment. This enables live chart data streaming and proper subscription lifecycle handling. * Update core submodule to latest commit Advanced the core submodule reference to commit f706ce21a167f0c9b58ae2b8717eebc09d22febf. This pulls in the latest changes from the core repository. * Improve chart axis formatting and current price label Added dynamic axis tick count and price formatting to ChartBounds. Updated CandlestickChartView to use the new axis formatting and display a highlighted label for the current price with color based on price movement. * Add AllMids price subscription and update logic Introduces AllMids price subscription handling in HyperliquidObserverService and PerpetualsSceneViewModel, enabling real-time price updates for perpetuals. PerpetualService and PerpetualStore now support updating prices, with update frequency throttled via Preferences. Updates related protocols, mocks, and dependency injection to support the new functionality. * Refactor lifecycle and subscription handling in scenes Moved subscription logic for candles and mids to async onAppear/onDisappear methods in both PerpetualScene and PerpetualsScene. Updated ViewModels to subscribe/unsubscribe to all periods or mids on appearance/disappearance, and removed redundant or now-unnecessary code. This improves resource management and ensures subscriptions are properly handled with SwiftUI view lifecycle. * update core * Refactor HyperliquidObserverService with protocol abstraction and WebSocket node support - Extract PerpetualObservable protocol with associated type for subscriptions - Extract ChartStreamable protocol and rename HyperliquidChartService to ChartObserverService - Replace specific subscribe/unsubscribe methods with generic subscribe(_:)/unsubscribe(_:) - Subscribe only to current chart period instead of all periods - Add PerpetualNodeService with Preferences-backed node selection - Add WebSocket base URLs to Constants and Chain extension for WS URLs - Remove hardcoded hyperliquidWebSocketURL constant * Connect perpetuals observer if enabled * Add task cancellation for candles observation * Fix unit test * Update core
1 parent 72b2225 commit 715451f

44 files changed

Lines changed: 763 additions & 194 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Features/Perpetuals/Sources/Navigation/PerpetualsNavigationView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct PerpetualsNavigationView: View {
1212
public init(
1313
wallet: Wallet,
1414
perpetualService: PerpetualServiceable,
15+
observerService: any PerpetualObservable<HyperliquidSubscription>,
1516
activityService: ActivityService,
1617
onSelectAssetType: @escaping (SelectAssetType) -> Void,
1718
onSelectAsset: @escaping (Asset) -> Void
@@ -20,6 +21,7 @@ public struct PerpetualsNavigationView: View {
2021
initialValue: PerpetualsSceneViewModel(
2122
wallet: wallet,
2223
perpetualService: perpetualService,
24+
observerService: observerService,
2325
activityService: activityService,
2426
onSelectAssetType: onSelectAssetType,
2527
onSelectAsset: onSelectAsset

Features/Perpetuals/Sources/Scenes/PerpetualScene.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,12 @@ public struct PerpetualScene: View {
169169
.taskOnce {
170170
model.fetch()
171171
}
172+
.onAppear {
173+
Task { await model.onAppear() }
174+
}
175+
.onDisappear {
176+
Task { await model.onDisappear() }
177+
}
178+
.onChange(of: model.currentPeriod, initial: true, model.onPeriodChange)
172179
}
173180
}

Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ struct PerpetualsScene: View {
4444
await model.fetch()
4545
}
4646
}
47+
.onAppear {
48+
Task { await model.onAppear() }
49+
}
50+
.onDisappear {
51+
Task { await model.onDisappear() }
52+
}
4753
.refreshable {
4854
await model.fetch()
4955
}

Features/Perpetuals/Sources/Types/ChartBounds.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

3+
import Foundation
34
import Primitives
45

56
struct ChartBounds {
7+
static let desiredTickCount = 5
8+
69
let minPrice: Double
710
let maxPrice: Double
811
let visibleLines: [ChartLineViewModel]
@@ -25,4 +28,18 @@ struct ChartBounds {
2528
self.maxPrice = max(candleMax, overlayMax) + padding
2629
self.visibleLines = visibleLines.sorted { $0.price < $1.price }
2730
}
31+
32+
var axisFractionLength: Int {
33+
switch maxPrice {
34+
case _ where maxPrice >= 1000: 0
35+
case _ where maxPrice >= 0.01: 2
36+
case _ where maxPrice >= 0.001: 3
37+
case _ where maxPrice >= 0.0001: 4
38+
default: 5
39+
}
40+
}
41+
42+
var axisFormat: FloatingPointFormatStyle<Double> {
43+
.number.precision(.fractionLength(axisFractionLength))
44+
}
2845
}

Features/Perpetuals/Sources/ViewModels/PerpetualPortfolioSceneViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ final class PerpetualPortfolioSceneViewModel {
6666
}
6767

6868
func fetch() async {
69-
guard let address = wallet.perpetualAddress else { return }
69+
guard let address = wallet.hyperliquidAccount?.address else { return }
7070
state = .loading
7171
do {
7272
let data = try await perpetualService.portfolio(address: address)

Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift

Lines changed: 89 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import GemstonePrimitives
1919
@MainActor
2020
public final class PerpetualSceneViewModel {
2121
private let perpetualService: PerpetualServiceable
22+
private let observerService: any PerpetualObservable<HyperliquidSubscription>
2223
private let onTransferData: TransferDataAction
2324
private let onPerpetualRecipientData: ((PerpetualRecipientData) -> Void)?
2425
private let perpetualOrderFactory = PerpetualOrderFactory()
@@ -39,30 +40,28 @@ public final class PerpetualSceneViewModel {
3940
public var transactions: [TransactionExtended] = []
4041

4142
public var state: StateViewType<[ChartCandleStick]> = .loading
42-
public var currentPeriod: ChartPeriod = .day {
43-
didSet {
44-
Task {
45-
await fetchCandlesticks()
46-
}
47-
}
48-
}
43+
public var currentPeriod: ChartPeriod = .day
4944

5045
public var isPresentingInfoSheet: InfoSheetType?
5146
public var isPresentingModifyAlert: Bool?
5247
public var isPresentingAutoclose: Bool = false
5348

5449
let preference = Preferences.standard
5550

51+
private var observeTask: Task<Void, Never>?
52+
5653
public init(
5754
wallet: Wallet,
5855
asset: Asset,
5956
perpetualService: PerpetualServiceable,
57+
observerService: any PerpetualObservable<HyperliquidSubscription>,
6058
onTransferData: TransferDataAction = nil,
6159
onPerpetualRecipientData: ((PerpetualRecipientData) -> Void)? = nil
6260
) {
6361
self.wallet = wallet
6462
self.asset = asset
6563
self.perpetualService = perpetualService
64+
self.observerService = observerService
6665
self.onTransferData = onTransferData
6766
self.onPerpetualRecipientData = onPerpetualRecipientData
6867

@@ -117,43 +116,42 @@ public final class PerpetualSceneViewModel {
117116
}
118117
}
119118
}
119+
}
120120

121-
public func fetch() {
122-
Task {
123-
await fetchCandlesticks()
124-
}
121+
// MARK: - Actions
122+
123+
public extension PerpetualSceneViewModel {
124+
func fetch() {
125125
Task {
126126
try await perpetualService.updateMarket(symbol: perpetual.name)
127127
}
128-
Task {
129-
do {
130-
if let address = wallet.perpetualAddress {
131-
try await perpetualService.updatePositions(address: address, walletId: wallet.walletId)
132-
}
133-
} catch {
134-
debugLog("Failed to load data: \(error)")
135-
}
128+
}
129+
130+
func onAppear() async {
131+
await subscribeCandles(period: currentPeriod)
132+
observeTask = Task {
133+
await observeCandles()
136134
}
137135
}
138136

139-
public func fetchCandlesticks() async {
140-
state = .loading
137+
func onDisappear() async {
138+
observeTask?.cancel()
139+
observeTask = nil
140+
await unsubscribeCandles(period: currentPeriod)
141+
}
141142

142-
do {
143-
let candlesticks = try await perpetualService.candlesticks(
144-
symbol: perpetual.name,
145-
period: currentPeriod
146-
)
147-
state = .data(candlesticks)
148-
} catch {
149-
state = .error(error)
143+
func onPeriodChange(_ oldPeriod: ChartPeriod, _ newPeriod: ChartPeriod) {
144+
Task {
145+
do {
146+
await unsubscribeCandles(period: oldPeriod)
147+
await subscribeCandles(period: newPeriod)
148+
try await fetchCandlesticks()
149+
} catch {
150+
state = .error(error)
151+
}
150152
}
151153
}
152-
}
153-
154-
// MARK: - Actions
155154

156-
public extension PerpetualSceneViewModel {
157155
func onSelectFundingRateInfo() {
158156
isPresentingInfoSheet = .fundingRate
159157
}
@@ -262,15 +260,69 @@ public extension PerpetualSceneViewModel {
262260
)
263261
)
264262
}
265-
263+
266264
func onAutocloseComplete() {
267265
isPresentingAutoclose = false
268266
fetch()
269267
}
268+
}
269+
270+
// MARK: - Private
271+
272+
private extension PerpetualSceneViewModel {
273+
func fetchCandlesticks() async throws {
274+
state = .loading
275+
let candlesticks = try await perpetualService.candlesticks(
276+
symbol: perpetual.name,
277+
period: currentPeriod
278+
)
279+
state = .data(candlesticks)
280+
}
270281

271-
// MARK: - Private
282+
func subscribeCandles(period: ChartPeriod) async {
283+
let subscription = ChartSubscription(coin: perpetual.name, period: period)
284+
do {
285+
try await observerService.subscribe(.candle(coin: subscription.coin, interval: subscription.interval))
286+
} catch {
287+
debugLog("Chart subscription failed: \(error)")
288+
}
289+
}
290+
291+
func unsubscribeCandles(period: ChartPeriod) async {
292+
let subscription = ChartSubscription(coin: perpetual.name, period: period)
293+
do {
294+
try await observerService.unsubscribe(.candle(coin: subscription.coin, interval: subscription.interval))
295+
} catch {
296+
debugLog("Chart unsubscribe failed: \(error)")
297+
}
298+
}
299+
300+
func observeCandles() async {
301+
for await candle in await observerService.chartService.makeStream() {
302+
if Task.isCancelled { break }
303+
handleChartUpdate(candle)
304+
}
305+
}
306+
307+
func handleChartUpdate(_ candle: ChartCandleStick) {
308+
guard candle.interval == ChartSubscription(coin: perpetual.name, period: currentPeriod).interval,
309+
case .data(var candlesticks) = state,
310+
let lastCandle = candlesticks.last
311+
else {
312+
return
313+
}
314+
315+
if lastCandle.date == candle.date {
316+
candlesticks[candlesticks.count - 1] = candle
317+
} else if candle.date > lastCandle.date {
318+
candlesticks.removeFirst()
319+
candlesticks.append(candle)
320+
}
321+
322+
state = .data(candlesticks)
323+
}
272324

273-
private func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
325+
func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
274326
guard let assetIndex = Int(perpetual.identifier) else {
275327
return nil
276328
}
@@ -286,7 +338,7 @@ public extension PerpetualSceneViewModel {
286338
)
287339
}
288340

289-
private func onPositionAction(_ positionAction: PerpetualPositionAction) {
341+
func onPositionAction(_ positionAction: PerpetualPositionAction) {
290342
let recipientData = PerpetualRecipientData(
291343
recipient: .hyperliquid(),
292344
positionAction: positionAction

Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import ActivityService
1515
@Observable
1616
@MainActor
1717
final class PerpetualsSceneViewModel {
18+
private let observerService: any PerpetualObservable<HyperliquidSubscription>
1819
let perpetualService: PerpetualServiceable
1920
let activityService: ActivityService
2021

@@ -43,12 +44,14 @@ final class PerpetualsSceneViewModel {
4344
init(
4445
wallet: Wallet,
4546
perpetualService: PerpetualServiceable,
47+
observerService: any PerpetualObservable<HyperliquidSubscription>,
4648
activityService: ActivityService,
4749
onSelectAssetType: ((SelectAssetType) -> Void)? = nil,
4850
onSelectAsset: ((Asset) -> Void)? = nil
4951
) {
5052
self.wallet = wallet
5153
self.perpetualService = perpetualService
54+
self.observerService = observerService
5255
self.activityService = activityService
5356
self.onSelectAssetType = onSelectAssetType
5457
self.onSelectAsset = onSelectAsset
@@ -86,22 +89,29 @@ final class PerpetualsSceneViewModel {
8689
balance: walletBalance
8790
)
8891
}
92+
8993
}
9094

9195
// MARK: - Businesss Logic
9296

9397
extension PerpetualsSceneViewModel {
9498
func fetch() async {
9599
await updateMarkets()
96-
await updatePositions()
97100
}
98101

99-
private func updatePositions() async {
100-
guard let address = wallet.perpetualAddress else { return }
102+
func onAppear() async {
101103
do {
102-
try await perpetualService.updatePositions(address: address, walletId: wallet.walletId)
104+
try await observerService.subscribe(.allMids)
103105
} catch {
104-
debugLog("Failed to update positions: \(error)")
106+
debugLog("AllMids subscribe failed: \(error)")
107+
}
108+
}
109+
110+
func onDisappear() async {
111+
do {
112+
try await observerService.unsubscribe(.allMids)
113+
} catch {
114+
debugLog("AllMids unsubscribe failed: \(error)")
105115
}
106116
}
107117

Features/Perpetuals/Sources/Views/CandlestickChartView.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,20 +97,45 @@ struct CandlestickChartView: View {
9797
}
9898
}
9999
.chartYAxis {
100-
AxisMarks(position: .trailing, values: .automatic(desiredCount: 5)) { _ in
100+
AxisMarks(position: .trailing, values: .automatic(desiredCount: ChartBounds.desiredTickCount)) { value in
101101
AxisGridLine(stroke: ChartGridStyle.strokeStyle)
102102
.foregroundStyle(ChartGridStyle.color)
103103
AxisTick(stroke: StrokeStyle(lineWidth: ChartGridStyle.lineWidth))
104104
.foregroundStyle(ChartGridStyle.color)
105-
AxisValueLabel()
106-
.foregroundStyle(Colors.gray)
107-
.font(.caption2)
105+
AxisValueLabel {
106+
if let price = value.as(Double.self) {
107+
Text(price, format: bounds.axisFormat)
108+
.font(.caption2)
109+
.foregroundStyle(Colors.gray)
110+
.padding(.horizontal, .extraSmall)
111+
}
112+
}
113+
}
114+
if let currentPrice = data.last?.close {
115+
AxisMarks(position: .trailing, values: [currentPrice]) { value in
116+
AxisValueLabel {
117+
if let price = value.as(Double.self) {
118+
Text(price, format: bounds.axisFormat)
119+
.font(.caption2)
120+
.foregroundStyle(Colors.whiteSolid)
121+
.padding(.horizontal, .extraSmall)
122+
.padding(.vertical, 1)
123+
.background(currentPriceColor)
124+
.clipShape(RoundedRectangle(cornerRadius: Spacing.tiny))
125+
}
126+
}
127+
}
108128
}
109129
}
110130
.chartXScale(domain: dateRange)
111131
.chartYScale(domain: bounds.minPrice...bounds.maxPrice)
112132
}
113133

134+
private var currentPriceColor: Color {
135+
guard let lastCandle = data.last else { return Colors.gray }
136+
return lastCandle.close >= lastCandle.open ? Colors.green : Colors.red
137+
}
138+
114139
@ChartContentBuilder
115140
private var candlestickMarks: some ChartContent {
116141
ForEach(data, id: \.date) { candle in

Features/Perpetuals/Tests/PerpetualsSceneViewModelTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ extension PerpetualsSceneViewModel {
2727
static func mock(
2828
wallet: Wallet = .mock(),
2929
perpetualService: PerpetualServiceable = PerpetualService.mock(),
30+
observerService: any PerpetualObservable<HyperliquidSubscription> = PerpetualObserverMock(),
3031
activityService: ActivityService = .mock()
3132
) -> PerpetualsSceneViewModel {
3233
PerpetualsSceneViewModel(
3334
wallet: wallet,
3435
perpetualService: perpetualService,
36+
observerService: observerService,
3537
activityService: activityService
3638
)
3739
}

0 commit comments

Comments
 (0)