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

Commit 589861c

Browse files
committed
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.
1 parent 2b23d98 commit 589861c

12 files changed

Lines changed: 191 additions & 58 deletions

File tree

Features/Perpetuals/Sources/Scenes/PerpetualScene.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,5 +170,7 @@ public struct PerpetualScene: View {
170170
.taskOnce {
171171
model.fetch()
172172
}
173+
.onChange(of: model.currentPeriod, initial: true, model.onPeriodChange)
174+
.onDisappear(perform: model.onDisappear)
173175
}
174176
}

Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift

Lines changed: 92 additions & 29 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: HyperliquidObserverService
2223
private let onTransferData: TransferDataAction
2324
private let onPerpetualRecipientData: ((PerpetualRecipientData) -> Void)?
2425
private let perpetualOrderFactory = PerpetualOrderFactory()
@@ -39,13 +40,9 @@ 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
44+
45+
private var chartTask: Task<Void, Never>?
4946

5047
public var isPresentingInfoSheet: InfoSheetType?
5148
public var isPresentingModifyAlert: Bool?
@@ -57,12 +54,14 @@ public final class PerpetualSceneViewModel {
5754
wallet: Wallet,
5855
asset: Asset,
5956
perpetualService: PerpetualServiceable,
57+
observerService: HyperliquidObserverService,
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,34 +116,44 @@ 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
}
128128
}
129129

130-
public func fetchCandlesticks() async {
131-
state = .loading
132-
133-
do {
134-
let candlesticks = try await perpetualService.candlesticks(
135-
symbol: perpetual.name,
136-
period: currentPeriod
137-
)
138-
state = .data(candlesticks)
139-
} catch {
140-
state = .error(error)
130+
func onPeriodChange(_ oldPeriod: ChartPeriod, _ newPeriod: ChartPeriod) {
131+
chartTask?.cancel()
132+
chartTask = Task {
133+
do {
134+
try await fetchCandlesticks()
135+
guard !Task.isCancelled else { return }
136+
await subscribeCandle()
137+
if oldPeriod != newPeriod {
138+
await unsubscribeCandle(period: oldPeriod)
139+
}
140+
await observeCandles()
141+
} catch {
142+
if !Task.isCancelled {
143+
state = .error(error)
144+
}
145+
}
141146
}
142147
}
143-
}
144148

145-
// MARK: - Actions
149+
func onDisappear() {
150+
chartTask?.cancel()
151+
chartTask = nil
152+
Task {
153+
await unsubscribeCandle(period: currentPeriod)
154+
}
155+
}
146156

147-
public extension PerpetualSceneViewModel {
148157
func onSelectFundingRateInfo() {
149158
isPresentingInfoSheet = .fundingRate
150159
}
@@ -253,15 +262,69 @@ public extension PerpetualSceneViewModel {
253262
)
254263
)
255264
}
256-
265+
257266
func onAutocloseComplete() {
258267
isPresentingAutoclose = false
259268
fetch()
260269
}
270+
}
271+
272+
// MARK: - Private
261273

262-
// MARK: - Private
274+
private extension PerpetualSceneViewModel {
275+
func unsubscribeCandle(period: ChartPeriod) async {
276+
do {
277+
let subscription = ChartSubscription(coin: perpetual.name, period: period)
278+
try await observerService.unsubscribeCandle(subscription: subscription)
279+
} catch {
280+
debugLog("Chart unsubscribe failed: \(error)")
281+
}
282+
}
283+
284+
func fetchCandlesticks() async throws {
285+
state = .loading
286+
let candlesticks = try await perpetualService.candlesticks(
287+
symbol: perpetual.name,
288+
period: currentPeriod
289+
)
290+
state = .data(candlesticks)
291+
}
292+
293+
func subscribeCandle() async {
294+
do {
295+
try await observerService.subscribeCandle(
296+
subscription: .init(coin: perpetual.name, period: currentPeriod)
297+
)
298+
} catch {
299+
debugLog("Chart subscription failed: \(error)")
300+
}
301+
}
302+
303+
func observeCandles() async {
304+
for await candle in await observerService.chartService.makeStream() {
305+
handleChartUpdate(candle)
306+
}
307+
}
308+
309+
func handleChartUpdate(_ candle: ChartCandleStick) {
310+
guard candle.interval == ChartSubscription(coin: perpetual.name, period: currentPeriod).interval,
311+
case .data(var candlesticks) = state,
312+
let lastCandle = candlesticks.last
313+
else {
314+
return
315+
}
316+
317+
if lastCandle.date == candle.date {
318+
candlesticks[candlesticks.count - 1] = candle
319+
} else if candle.date > lastCandle.date {
320+
candlesticks.removeFirst()
321+
candlesticks.append(candle)
322+
}
323+
324+
state = .data(candlesticks)
325+
}
263326

264-
private func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
327+
func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
265328
guard let assetIndex = Int(perpetual.identifier) else {
266329
return nil
267330
}
@@ -277,7 +340,7 @@ public extension PerpetualSceneViewModel {
277340
)
278341
}
279342

280-
private func onPositionAction(_ positionAction: PerpetualPositionAction) {
343+
func onPositionAction(_ positionAction: PerpetualPositionAction) {
281344
let recipientData = PerpetualRecipientData(
282345
recipient: .hyperliquid(),
283346
positionAction: positionAction

Gem/Navigation/Perpetuals/PerpetualNavigationView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public struct PerpetualNavigationView: View {
1515
asset: Asset,
1616
wallet: Wallet,
1717
perpetualService: any PerpetualServiceable,
18+
observerService: HyperliquidObserverService,
1819
isPresentingTransferData: Binding<TransferData?>,
1920
isPresentingPerpetualRecipientData: Binding<PerpetualRecipientData?>
2021
) {
@@ -24,6 +25,7 @@ public struct PerpetualNavigationView: View {
2425
wallet: wallet,
2526
asset: asset,
2627
perpetualService: perpetualService,
28+
observerService: observerService,
2729
onTransferData: { isPresentingTransferData.wrappedValue = $0 },
2830
onPerpetualRecipientData: { isPresentingPerpetualRecipientData.wrappedValue = $0 }
2931
))

Gem/Navigation/Wallet/WalletNavigationStack.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct WalletNavigationStack: View {
2626
@Environment(\.priceObserverService) private var priceObserverService
2727
@Environment(\.stakeService) private var stakeService
2828
@Environment(\.perpetualService) private var perpetualService
29+
@Environment(\.hyperliquidObserverService) private var hyperliquidObserverService
2930
@Environment(\.balanceService) private var balanceService
3031
@Environment(\.activityService) private var activityService
3132

@@ -144,6 +145,7 @@ struct WalletNavigationStack: View {
144145
asset: $0.asset,
145146
wallet: model.wallet,
146147
perpetualService: perpetualService,
148+
observerService: hyperliquidObserverService,
147149
isPresentingTransferData: $model.isPresentingTransferData,
148150
isPresentingPerpetualRecipientData: $model.isPresentingPerpetualRecipientData
149151
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c). Gem Wallet. All rights reserved.
2+
3+
import Foundation
4+
import Primitives
5+
6+
public actor HyperliquidChartService: Sendable {
7+
private var continuation: AsyncStream<ChartCandleStick>.Continuation?
8+
9+
public init() {}
10+
11+
public func makeStream() -> AsyncStream<ChartCandleStick> {
12+
continuation?.finish()
13+
let (stream, newContinuation) = AsyncStream.makeStream(of: ChartCandleStick.self)
14+
continuation = newContinuation
15+
return stream
16+
}
17+
18+
func yield(_ candle: ChartCandleStick) {
19+
continuation?.yield(candle)
20+
}
21+
}

Packages/FeatureServices/PerpetualService/HyperliquidObserverService.swift

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import class Gemstone.Hyperliquid
55
import struct Gemstone.GemPerpetualBalance
66
import struct Gemstone.GemPerpetualPosition
77
import struct Gemstone.GemHyperliquidOpenOrder
8+
import struct Gemstone.GemChartCandleStick
89
import Primitives
910
import WebSocketClient
1011

@@ -18,6 +19,8 @@ public actor HyperliquidObserverService: Sendable {
1819
private var observeTask: Task<Void, Never>?
1920
private var currentWallet: Wallet?
2021

22+
public let chartService = HyperliquidChartService()
23+
2124
public init(
2225
webSocket: any WebSocketConnectable = WebSocketConnection(url: Constants.hyperliquidWebSocketURL),
2326
perpetualService: HyperliquidPerpetualServiceable
@@ -66,7 +69,7 @@ public actor HyperliquidObserverService: Sendable {
6669
case .connected:
6770
await handleConnected()
6871
case .message(let data):
69-
handleMessage(data)
72+
await handleMessage(data)
7073
case .disconnected:
7174
break
7275
}
@@ -77,37 +80,38 @@ public actor HyperliquidObserverService: Sendable {
7780
guard let address = currentWallet?.hyperliquidAccount?.address else { return }
7881
do {
7982
try await perpetualService.updateMarkets()
80-
try await subscribeClearinghouseState(user: address)
81-
try await subscribeOpenOrders(user: address)
83+
try await send(HyperliquidRequest(method: .subscribe, subscription: .clearinghouseState(user: address)))
84+
try await send(HyperliquidRequest(method: .subscribe, subscription: .openOrders(user: address)))
8285
} catch {
8386
debugLog("HyperliquidObserver: subscribe failed: \(error)")
8487
}
8588
}
8689

87-
private func handleMessage(_ data: Data) {
88-
guard let walletId = currentWallet?.walletId else { return }
89-
90+
private func handleMessage(_ data: Data) async {
9091
do {
9192
switch try hyperliquid.parseWebsocketData(data: data) {
9293
case .clearinghouseState(let balance, let newPositions):
93-
try handleClearinghouseState(walletId: walletId, balance: balance, newPositions: newPositions)
94+
try handleClearinghouseState(balance: balance, newPositions: newPositions)
9495
case .openOrders(let orders):
95-
try handleOpenOrders(walletId: walletId, orders: orders)
96+
try handleOpenOrders(orders: orders)
97+
case .candle(let candle):
98+
try await handleCandle(candle: candle)
9699
case .subscriptionResponse(let subscriptionType):
97-
debugLog("HyperliquidObserver: subscribed to \(subscriptionType)")
100+
debugLog("HyperliquidObserver: subscription response - \(subscriptionType)")
98101
case .unknown:
99-
debugLog("HyperliquidObserver: unknown message")
102+
debugLog("HyperliquidObserver: unknown message: \(String(data: data, encoding: .utf8) ?? "nil")")
100103
}
101104
} catch {
102105
debugLog("HyperliquidObserver: handle message error: \(error)")
103106
}
104107
}
105108

106109
private func handleClearinghouseState(
107-
walletId: WalletId,
108110
balance: GemPerpetualBalance,
109111
newPositions: [GemPerpetualPosition]
110112
) throws {
113+
guard let walletId = currentWallet?.walletId else { return }
114+
111115
let diff = hyperliquid.diffClearinghousePositions(
112116
newPositions: newPositions,
113117
existingPositions: try perpetualService.getHypercorePositions(walletId: walletId)
@@ -124,7 +128,9 @@ public actor HyperliquidObserverService: Sendable {
124128
)
125129
}
126130

127-
private func handleOpenOrders(walletId: WalletId, orders: [GemHyperliquidOpenOrder]) throws {
131+
private func handleOpenOrders(orders: [GemHyperliquidOpenOrder]) throws {
132+
guard let walletId = currentWallet?.walletId else { return }
133+
128134
let diff = hyperliquid.diffOpenOrdersPositions(
129135
orders: orders,
130136
existingPositions: try perpetualService.getHypercorePositions(walletId: walletId)
@@ -136,21 +142,23 @@ public actor HyperliquidObserverService: Sendable {
136142
)
137143
}
138144

139-
// MARK: - Subscriptions
145+
private func handleCandle(candle: GemChartCandleStick) async throws {
146+
await chartService.yield(try candle.map())
147+
}
140148

141-
private func subscribeClearinghouseState(user: String) async throws {
142-
let request = HyperliquidRequest(
143-
method: .subscribe,
144-
subscription: .clearinghouseState(user: user)
145-
)
149+
private func send(_ request: HyperliquidRequest) async throws {
146150
try await webSocket.send(try encoder.encode(request).encodeString())
147151
}
152+
}
148153

149-
private func subscribeOpenOrders(user: String) async throws {
150-
let request = HyperliquidRequest(
151-
method: .subscribe,
152-
subscription: .openOrders(user: user)
153-
)
154-
try await webSocket.send(try encoder.encode(request).encodeString())
154+
// MARK: - Chart Subscriptions
155+
156+
extension HyperliquidObserverService {
157+
public func subscribeCandle(subscription: ChartSubscription) async throws {
158+
try await send(HyperliquidRequest(method: .subscribe, subscription: .candle(coin: subscription.coin, interval: subscription.interval)))
159+
}
160+
161+
public func unsubscribeCandle(subscription: ChartSubscription) async throws {
162+
try await send(HyperliquidRequest(method: .unsubscribe, subscription: .candle(coin: subscription.coin, interval: subscription.interval)))
155163
}
156164
}

Packages/FeatureServices/PerpetualService/TestKit/PerpetualServiceMock.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public struct PerpetualServiceMock: PerpetualServiceable {
1212
[]
1313
}
1414

15-
public func getMarkets() async throws -> [Perpetual] {
15+
public func getMarkets() async throws -> [Primitives.Perpetual] {
1616
[]
1717
}
1818

@@ -25,7 +25,7 @@ public struct PerpetualServiceMock: PerpetualServiceable {
2525
}
2626

2727
public func portfolio(address: String) async throws -> PerpetualPortfolio {
28-
PerpetualPortfolio(totalValue: 0, totalPnl: 0, totalMargin: 0)
28+
PerpetualPortfolio(day: nil, week: nil, month: nil, allTime: nil, accountSummary: nil)
2929
}
3030

3131
public func setPinned(_ isPinned: Bool, perpetualId: String) throws {}

0 commit comments

Comments
 (0)