Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public struct PerpetualsNavigationView: View {
public init(
wallet: Wallet,
perpetualService: PerpetualServiceable,
observerService: any PerpetualObservable<HyperliquidSubscription>,
activityService: ActivityService,
onSelectAssetType: @escaping (SelectAssetType) -> Void,
onSelectAsset: @escaping (Asset) -> Void
Expand All @@ -20,6 +21,7 @@ public struct PerpetualsNavigationView: View {
initialValue: PerpetualsSceneViewModel(
wallet: wallet,
perpetualService: perpetualService,
observerService: observerService,
activityService: activityService,
onSelectAssetType: onSelectAssetType,
onSelectAsset: onSelectAsset
Expand Down
7 changes: 7 additions & 0 deletions Features/Perpetuals/Sources/Scenes/PerpetualScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,12 @@ public struct PerpetualScene: View {
.taskOnce {
model.fetch()
}
.onAppear {
Task { await model.onAppear() }
}
.onDisappear {
Task { await model.onDisappear() }
}
.onChange(of: model.currentPeriod, initial: true, model.onPeriodChange)
}
}
6 changes: 6 additions & 0 deletions Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ struct PerpetualsScene: View {
await model.fetch()
}
}
.onAppear {
Task { await model.onAppear() }
}
.onDisappear {
Task { await model.onDisappear() }
}
.refreshable {
await model.fetch()
}
Expand Down
17 changes: 17 additions & 0 deletions Features/Perpetuals/Sources/Types/ChartBounds.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation
import Primitives

struct ChartBounds {
static let desiredTickCount = 5

let minPrice: Double
let maxPrice: Double
let visibleLines: [ChartLineViewModel]
Expand All @@ -25,4 +28,18 @@ struct ChartBounds {
self.maxPrice = max(candleMax, overlayMax) + padding
self.visibleLines = visibleLines.sorted { $0.price < $1.price }
}

var axisFractionLength: Int {
switch maxPrice {
case _ where maxPrice >= 1000: 0
case _ where maxPrice >= 0.01: 2
case _ where maxPrice >= 0.001: 3
case _ where maxPrice >= 0.0001: 4
default: 5
}
}

var axisFormat: FloatingPointFormatStyle<Double> {
.number.precision(.fractionLength(axisFractionLength))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ final class PerpetualPortfolioSceneViewModel {
}

func fetch() async {
guard let address = wallet.perpetualAddress else { return }
guard let address = wallet.hyperliquidAccount?.address else { return }
state = .loading
do {
let data = try await perpetualService.portfolio(address: address)
Expand Down
126 changes: 89 additions & 37 deletions Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import GemstonePrimitives
@MainActor
public final class PerpetualSceneViewModel {
private let perpetualService: PerpetualServiceable
private let observerService: any PerpetualObservable<HyperliquidSubscription>
private let onTransferData: TransferDataAction
private let onPerpetualRecipientData: ((PerpetualRecipientData) -> Void)?
private let perpetualOrderFactory = PerpetualOrderFactory()
Expand All @@ -39,30 +40,28 @@ public final class PerpetualSceneViewModel {
public var transactions: [TransactionExtended] = []

public var state: StateViewType<[ChartCandleStick]> = .loading
public var currentPeriod: ChartPeriod = .day {
didSet {
Task {
await fetchCandlesticks()
}
}
}
public var currentPeriod: ChartPeriod = .day

public var isPresentingInfoSheet: InfoSheetType?
public var isPresentingModifyAlert: Bool?
public var isPresentingAutoclose: Bool = false

let preference = Preferences.standard

private var observeTask: Task<Void, Never>?

public init(
wallet: Wallet,
asset: Asset,
perpetualService: PerpetualServiceable,
observerService: any PerpetualObservable<HyperliquidSubscription>,
onTransferData: TransferDataAction = nil,
onPerpetualRecipientData: ((PerpetualRecipientData) -> Void)? = nil
) {
self.wallet = wallet
self.asset = asset
self.perpetualService = perpetualService
self.observerService = observerService
self.onTransferData = onTransferData
self.onPerpetualRecipientData = onPerpetualRecipientData

Expand Down Expand Up @@ -117,43 +116,42 @@ public final class PerpetualSceneViewModel {
}
}
}
}

public func fetch() {
Task {
await fetchCandlesticks()
}
// MARK: - Actions

public extension PerpetualSceneViewModel {
func fetch() {
Task {
try await perpetualService.updateMarket(symbol: perpetual.name)
}
Task {
do {
if let address = wallet.perpetualAddress {
try await perpetualService.updatePositions(address: address, walletId: wallet.walletId)
}
} catch {
debugLog("Failed to load data: \(error)")
}
}

func onAppear() async {
await subscribeCandles(period: currentPeriod)
observeTask = Task {
await observeCandles()
}
}

public func fetchCandlesticks() async {
state = .loading
func onDisappear() async {
observeTask?.cancel()
observeTask = nil
await unsubscribeCandles(period: currentPeriod)
}

do {
let candlesticks = try await perpetualService.candlesticks(
symbol: perpetual.name,
period: currentPeriod
)
state = .data(candlesticks)
} catch {
state = .error(error)
func onPeriodChange(_ oldPeriod: ChartPeriod, _ newPeriod: ChartPeriod) {
Task {
do {
await unsubscribeCandles(period: oldPeriod)
await subscribeCandles(period: newPeriod)
try await fetchCandlesticks()
} catch {
state = .error(error)
}
}
}
}

// MARK: - Actions

public extension PerpetualSceneViewModel {
func onSelectFundingRateInfo() {
isPresentingInfoSheet = .fundingRate
}
Expand Down Expand Up @@ -262,15 +260,69 @@ public extension PerpetualSceneViewModel {
)
)
}

func onAutocloseComplete() {
isPresentingAutoclose = false
fetch()
}
}

// MARK: - Private

private extension PerpetualSceneViewModel {
func fetchCandlesticks() async throws {
state = .loading
let candlesticks = try await perpetualService.candlesticks(
symbol: perpetual.name,
period: currentPeriod
)
state = .data(candlesticks)
}

// MARK: - Private
func subscribeCandles(period: ChartPeriod) async {
let subscription = ChartSubscription(coin: perpetual.name, period: period)
do {
try await observerService.subscribe(.candle(coin: subscription.coin, interval: subscription.interval))
} catch {
debugLog("Chart subscription failed: \(error)")
}
}

func unsubscribeCandles(period: ChartPeriod) async {
let subscription = ChartSubscription(coin: perpetual.name, period: period)
do {
try await observerService.unsubscribe(.candle(coin: subscription.coin, interval: subscription.interval))
} catch {
debugLog("Chart unsubscribe failed: \(error)")
}
}

func observeCandles() async {
for await candle in await observerService.chartService.makeStream() {
if Task.isCancelled { break }
handleChartUpdate(candle)
}
}

func handleChartUpdate(_ candle: ChartCandleStick) {
guard candle.interval == ChartSubscription(coin: perpetual.name, period: currentPeriod).interval,
Comment thread
DRadmir marked this conversation as resolved.
case .data(var candlesticks) = state,
let lastCandle = candlesticks.last
else {
return
}

if lastCandle.date == candle.date {
candlesticks[candlesticks.count - 1] = candle
} else if candle.date > lastCandle.date {
candlesticks.removeFirst()
candlesticks.append(candle)
}

state = .data(candlesticks)
}

private func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
func createTransferData(direction: PerpetualDirection, leverage: UInt8) -> PerpetualTransferData? {
guard let assetIndex = Int(perpetual.identifier) else {
return nil
}
Expand All @@ -286,7 +338,7 @@ public extension PerpetualSceneViewModel {
)
}

private func onPositionAction(_ positionAction: PerpetualPositionAction) {
func onPositionAction(_ positionAction: PerpetualPositionAction) {
let recipientData = PerpetualRecipientData(
recipient: .hyperliquid(),
positionAction: positionAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ActivityService
@Observable
@MainActor
final class PerpetualsSceneViewModel {
private let observerService: any PerpetualObservable<HyperliquidSubscription>
let perpetualService: PerpetualServiceable
let activityService: ActivityService

Expand Down Expand Up @@ -43,12 +44,14 @@ final class PerpetualsSceneViewModel {
init(
wallet: Wallet,
perpetualService: PerpetualServiceable,
observerService: any PerpetualObservable<HyperliquidSubscription>,
activityService: ActivityService,
onSelectAssetType: ((SelectAssetType) -> Void)? = nil,
onSelectAsset: ((Asset) -> Void)? = nil
) {
self.wallet = wallet
self.perpetualService = perpetualService
self.observerService = observerService
self.activityService = activityService
self.onSelectAssetType = onSelectAssetType
self.onSelectAsset = onSelectAsset
Expand Down Expand Up @@ -86,22 +89,29 @@ final class PerpetualsSceneViewModel {
balance: walletBalance
)
}

}

// MARK: - Businesss Logic

extension PerpetualsSceneViewModel {
func fetch() async {
await updateMarkets()
await updatePositions()
}

private func updatePositions() async {
guard let address = wallet.perpetualAddress else { return }
func onAppear() async {
do {
try await perpetualService.updatePositions(address: address, walletId: wallet.walletId)
try await observerService.subscribe(.allMids)
} catch {
debugLog("Failed to update positions: \(error)")
debugLog("AllMids subscribe failed: \(error)")
}
}

func onDisappear() async {
do {
try await observerService.unsubscribe(.allMids)
} catch {
debugLog("AllMids unsubscribe failed: \(error)")
}
}

Expand Down
33 changes: 29 additions & 4 deletions Features/Perpetuals/Sources/Views/CandlestickChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,45 @@ struct CandlestickChartView: View {
}
}
.chartYAxis {
AxisMarks(position: .trailing, values: .automatic(desiredCount: 5)) { _ in
AxisMarks(position: .trailing, values: .automatic(desiredCount: ChartBounds.desiredTickCount)) { value in
AxisGridLine(stroke: ChartGridStyle.strokeStyle)
.foregroundStyle(ChartGridStyle.color)
AxisTick(stroke: StrokeStyle(lineWidth: ChartGridStyle.lineWidth))
.foregroundStyle(ChartGridStyle.color)
AxisValueLabel()
.foregroundStyle(Colors.gray)
.font(.caption2)
AxisValueLabel {
if let price = value.as(Double.self) {
Text(price, format: bounds.axisFormat)
.font(.caption2)
.foregroundStyle(Colors.gray)
.padding(.horizontal, .extraSmall)
}
}
}
if let currentPrice = data.last?.close {
AxisMarks(position: .trailing, values: [currentPrice]) { value in
AxisValueLabel {
if let price = value.as(Double.self) {
Text(price, format: bounds.axisFormat)
.font(.caption2)
.foregroundStyle(Colors.whiteSolid)
.padding(.horizontal, .extraSmall)
.padding(.vertical, 1)
.background(currentPriceColor)
.clipShape(RoundedRectangle(cornerRadius: Spacing.tiny))
}
}
}
}
}
.chartXScale(domain: dateRange)
.chartYScale(domain: bounds.minPrice...bounds.maxPrice)
}

private var currentPriceColor: Color {
guard let lastCandle = data.last else { return Colors.gray }
return lastCandle.close >= lastCandle.open ? Colors.green : Colors.red
}

@ChartContentBuilder
private var candlestickMarks: some ChartContent {
ForEach(data, id: \.date) { candle in
Expand Down
2 changes: 2 additions & 0 deletions Features/Perpetuals/Tests/PerpetualsSceneViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ extension PerpetualsSceneViewModel {
static func mock(
wallet: Wallet = .mock(),
perpetualService: PerpetualServiceable = PerpetualService.mock(),
observerService: any PerpetualObservable<HyperliquidSubscription> = PerpetualObserverMock(),
activityService: ActivityService = .mock()
) -> PerpetualsSceneViewModel {
PerpetualsSceneViewModel(
wallet: wallet,
perpetualService: perpetualService,
observerService: observerService,
activityService: activityService
)
}
Expand Down
Loading