Skip to content

Commit

Permalink
add slider to target leverage screen (#213)
Browse files Browse the repository at this point in the history
* create slider text input view

* add target leverage slider

* temp

* fix custom amount switch behavior and fix overwriting bug

* undo changes to platforminput

* fix lines to 1 for leverage buttons

* temp

* fix precision

* add precision to slider

* clean up

* address PR comments

---------

Co-authored-by: Mike <[email protected]>
  • Loading branch information
mike-dydx and mike-dydx authored Jul 25, 2024
1 parent da42677 commit 2caf35d
Show file tree
Hide file tree
Showing 13 changed files with 473 additions and 205 deletions.
31 changes: 0 additions & 31 deletions Utilities/Utilities/_Extensions/Double+String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,37 +35,6 @@ extension Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}

public func round(size: Double) -> Double {
return round(to: places(size: size))
}

private func places(size: Double) -> Int {
switch size {
case 1000.0:
return -3
case 100.0:
return -2
case 10.0:
return -1
case 1.0:
return 0
case 0.1:
return 1
case 0.01:
return 2
case 0.001:
return 3
case 0.0001:
return 4
case 0.00001:
return 5
case 0.000001:
return 6
default:
return 0
}
}

public var stringValue: String? { "\(self)" }
}
Expand Down
4 changes: 4 additions & 0 deletions dydx/dydxFormatter/dydxFormatter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
02841E3A29AD6E5500C0E7CC /* dydxFormatter.h in Headers */ = {isa = PBXBuildFile; fileRef = 02841E2C29AD6E5500C0E7CC /* dydxFormatter.h */; settings = {ATTRIBUTES = (Public, ); }; };
02841ECB29AD6E8D00C0E7CC /* dydxFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02841ECA29AD6E8D00C0E7CC /* dydxFormatter.swift */; };
02841F6529AD6F1100C0E7CC /* Utilities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02841F5A29AD6EF100C0E7CC /* Utilities.framework */; platformFilter = ios; };
27DBF3CB2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */; };
D50ABD228F90F45E822E160E /* Pods_iOS_dydxFormatterTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8B6B79D7CF404CE544872C5 /* Pods_iOS_dydxFormatterTests.framework */; };
E16B15943DC7937C8F9D87D6 /* Pods_iOS_dydxFormatter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2782DAD1615D2E23FD387E79 /* Pods_iOS_dydxFormatter.framework */; };
/* End PBXBuildFile section */
Expand Down Expand Up @@ -131,6 +132,7 @@
02841F5129AD6EF100C0E7CC /* Utilities.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Utilities.xcodeproj; path = ../../Utilities/Utilities.xcodeproj; sourceTree = "<group>"; };
177FCA2765FA7FAE4B4AF56F /* Pods-iOS-dydxFormatterTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatterTests.debug.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatterTests/Pods-iOS-dydxFormatterTests.debug.xcconfig"; sourceTree = "<group>"; };
2782DAD1615D2E23FD387E79 /* Pods_iOS_dydxFormatter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_dydxFormatter.framework; sourceTree = BUILT_PRODUCTS_DIR; };
27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = dydxNumberInputFormatter.swift; sourceTree = "<group>"; };
46DBD879AB8325C51F5F9732 /* Pods-iOS-dydxFormatterTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatterTests.release.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatterTests/Pods-iOS-dydxFormatterTests.release.xcconfig"; sourceTree = "<group>"; };
5172233B016C14B302F6B1C0 /* Pods-iOS-dydxFormatter.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatter.debug.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatter/Pods-iOS-dydxFormatter.debug.xcconfig"; sourceTree = "<group>"; };
CB164F70916C1A8A28D6A263 /* Pods-iOS-dydxFormatter.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-dydxFormatter.release.xcconfig"; path = "Target Support Files/Pods-iOS-dydxFormatter/Pods-iOS-dydxFormatter.release.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -220,6 +222,7 @@
02841ECA29AD6E8D00C0E7CC /* dydxFormatter.swift */,
02841E2C29AD6E5500C0E7CC /* dydxFormatter.h */,
02841E2D29AD6E5500C0E7CC /* dydxFormatter.docc */,
27DBF3CA2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift */,
);
path = dydxFormatter;
sourceTree = "<group>";
Expand Down Expand Up @@ -536,6 +539,7 @@
0262F17A29DB4165009889E2 /* dydxPApi.swift in Sources */,
0262F17B29DB4168009889E2 /* dydxPublicApi.swift in Sources */,
0262F17929DB4163009889E2 /* dydxClientSourceInjection.swift in Sources */,
27DBF3CB2C4AAC8A009EB2D6 /* dydxNumberInputFormatter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
45 changes: 45 additions & 0 deletions dydx/dydxFormatter/dydxFormatter/dydxNumberInputFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// dydxNumberInputFormatter.swift
// dydxFormatter
//
// Created by Michael Maguire on 7/19/24.
//

import Foundation

/// a number formatter that also supports rounding to nearest 10/100/1000/etc
/// formatter is intended for user inputs, so group separator is omitted, i.e. the "," in "1,000"
public class dydxNumberInputFormatter: NumberFormatter, ObservableObject {

/// if greater than 0, numbers will be rounded to nearest 10, 100, 1000, etc. If less than 0 numbers will be rounded to nearest 0.1, 0.01, .001
public var fractionDigits: Int {
get {
maximumFractionDigits
}
set {
if maximumFractionDigits != newValue || minimumFractionDigits != newValue {
maximumFractionDigits = newValue
minimumFractionDigits = newValue
objectWillChange.send()
}
}
}

/// Use this initializer
/// - Parameter fractionDigits: if greater than 0, numbers will be rounded to nearest 10, 100, 1000, etc. If less than 0 numbers will be rounded to nearest 0.1, 0.01, .001
public convenience init(fractionDigits: Int = 2) {
self.init()
self.maximumFractionDigits = fractionDigits
self.minimumFractionDigits = fractionDigits
self.numberStyle = .decimal
self.usesGroupingSeparator = false
}

public override func string(from number: NSNumber) -> String? {
if maximumFractionDigits < 0 {
return String(Int(number.doubleValue.round(to: maximumFractionDigits)))
} else {
return super.string(from: number)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,24 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
super.start()

guard let marketId = marketId else { return }
AbacusStateManager.shared.triggerOrders(input: marketId, type: .marketid)

Publishers
// note we use a zip here intentionally so that the user input is not overwritten unless triggerOrders updates
// which should not really happen unless the active trigger order(s) get triggered or they are placed simultaneously
// on another platform
.Zip(AbacusStateManager.shared.state.selectedSubaccountPositions,
AbacusStateManager.shared.state.selectedSubaccountTriggerOrders)
.sink { [weak self] subaccountPositions, triggerOrders in
self?.update(subaccountPositions: subaccountPositions, triggerOrders: triggerOrders)
}
.store(in: &subscriptions)

Publishers
.CombineLatest3(AbacusStateManager.shared.state.selectedSubaccountPositions,
AbacusStateManager.shared.state.selectedSubaccountTriggerOrders,
.CombineLatest(AbacusStateManager.shared.state.selectedSubaccountPositions,
AbacusStateManager.shared.state.configsAndAssetMap)
.sink { [weak self] subaccountPositions, triggerOrders, configsMap in
self?.update(subaccountPositions: subaccountPositions, triggerOrders: triggerOrders, configsMap: configsMap)
.sink { [weak self] subaccountPositions, configsMap in
self?.update(subaccountPositions: subaccountPositions, configsMap: configsMap)
}
.store(in: &subscriptions)

Expand Down Expand Up @@ -113,9 +124,12 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP

private func update(market: PerpetualMarket?) {
viewModel?.oraclePrice = dydxFormatter.shared.dollar(number: market?.oraclePrice?.doubleValue, digits: market?.configs?.displayTickSizeDecimals?.intValue ?? 2)
viewModel?.customAmountViewModel?.assetId = market?.assetId
viewModel?.customAmountViewModel?.stepSize = market?.configs?.stepSize?.stringValue
viewModel?.customAmountViewModel?.minimumValue = market?.configs?.minOrderSize?.floatValue
viewModel?.customAmountViewModel?.sliderTextInput.accessoryTitle = market?.assetId
viewModel?.customAmountViewModel?.sliderTextInput.minValue = market?.configs?.minOrderSize?.doubleValue.magnitude ?? 0
// abacus stepSizeDecimals is not accurate for 10/100/1000 precision
if let stepSize = market?.configs?.stepSize?.doubleValue, stepSize > 0 {
viewModel?.customAmountViewModel?.sliderTextInput.numberFormatter.fractionDigits = Int(-log10(stepSize))
}
}

private func update(subaccountPositions: [SubaccountPosition], triggerOrdersInput: TriggerOrdersInput?, errors: [ValidationError], configsMap: [String: MarketConfigsAndAsset]) {
Expand Down Expand Up @@ -169,8 +183,8 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
// logic primarily to pre-populate custom amount.
// we do not want to turn on custom amount if it is not already on and the order size is the same amount as the position size. The custom amount may already be on if user manually turned it on, or a pre-existing custom amount exists that is less than the position size
if let customSize = triggerOrdersInput?.size?.doubleValue.magnitude, customSize != position?.size.current?.doubleValue.magnitude || viewModel?.customAmountViewModel?.isOn == true {
let formattedSize = dydxFormatter.shared.raw(number: customSize, digits: marketConfig.displayStepSizeDecimals?.intValue ?? 2)
viewModel?.customAmountViewModel?.programmaticallySet(newValue: formattedSize)
viewModel?.customAmountViewModel?.isOn = true
viewModel?.customAmountViewModel?.sliderTextInput.value = customSize
}

viewModel?.customLimitPriceViewModel?.takeProfitPriceInputViewModel?.value = triggerOrdersInput?.takeProfitOrder?.price?.limitPrice?.stringValue
Expand All @@ -189,7 +203,11 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
}

if let error = errors.first {
viewModel?.submissionReadiness = .fixErrors(cta: error.resources.action?.localizedString)
if let actionText = error.resources.action?.localizedString {
viewModel?.submissionReadiness = .fixErrors(cta: actionText)
} else {
viewModel?.submissionReadiness = .needsInput
}
} else if triggerOrdersInput?.takeProfitOrder?.price?.triggerPrice?.doubleValue == nil
&& triggerOrdersInput?.takeProfitOrder?.orderId == nil
&& triggerOrdersInput?.stopLossOrder?.price?.triggerPrice?.doubleValue == nil
Expand All @@ -202,8 +220,27 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
}
}

private func update(subaccountPositions: [SubaccountPosition], triggerOrders: [SubaccountOrder], configsMap: [String: MarketConfigsAndAsset]) {
private func update(subaccountPositions: [SubaccountPosition], configsMap: [String: MarketConfigsAndAsset]) {
guard let marketConfig = configsMap[marketId ?? ""]?.configs else { return }
let position = subaccountPositions.first { subaccountPosition in
subaccountPosition.id == marketId
}
viewModel?.entryPrice = dydxFormatter.shared.dollar(number: position?.entryPrice.current?.doubleValue,
digits: marketConfig.displayTickSizeDecimals?.intValue ?? 2)
viewModel?.customAmountViewModel?.sliderTextInput.maxValue = position?.size.current?.doubleValue.magnitude ?? 0

// update toggle interaction, must do it within position listener update method since it depends on market config min order size
viewModel?.customAmountViewModel?.toggleAction = { isOn in
if isOn {
// start at min amount
AbacusStateManager.shared.triggerOrders(input: marketConfig.minOrderSize?.stringValue, type: .size)
} else {
AbacusStateManager.shared.triggerOrders(input: position?.size.current?.doubleValue.magnitude.stringValue, type: .size)
}
}
}

private func update(subaccountPositions: [SubaccountPosition], triggerOrders: [SubaccountOrder]) {
let position = subaccountPositions.first { subaccountPosition in
subaccountPosition.id == marketId
}
Expand All @@ -218,14 +255,9 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
&& order.side.opposite == position?.side.current
}

viewModel?.entryPrice = dydxFormatter.shared.dollar(number: position?.entryPrice.current?.doubleValue,
digits: marketConfig.displayTickSizeDecimals?.intValue ?? 2)

viewModel?.takeProfitStopLossInputAreaViewModel?.numOpenTakeProfitOrders = takeProfitOrders.count
viewModel?.takeProfitStopLossInputAreaViewModel?.numOpenStopLossOrders = stopLossOrders.count

viewModel?.customAmountViewModel?.maximumValue = position?.size.current?.floatValue.magnitude

if takeProfitOrders.count == 1, stopLossOrders.count == 1,
let takeProfitOrder = takeProfitOrders.first, let stopLossOrder = stopLossOrders.first {
updateAbacusTriggerOrdersState(order: takeProfitOrder)
Expand All @@ -250,18 +282,6 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
AbacusStateManager.shared.triggerOrders(input: position?.size.current?.doubleValue.magnitude.stringValue, type: .takeprofitordersize)
AbacusStateManager.shared.triggerOrders(input: position?.size.current?.doubleValue.magnitude.stringValue, type: .stoplossordersize)
}

AbacusStateManager.shared.triggerOrders(input: marketId, type: .marketid)

// update toggle interaction, must do it within position listener update method since it depends on market config min order size
viewModel?.customAmountViewModel?.toggleAction = { isOn in
if isOn {
// start at min amount
AbacusStateManager.shared.triggerOrders(input: marketConfig.minOrderSize?.stringValue, type: .size)
} else {
AbacusStateManager.shared.triggerOrders(input: position?.size.current?.doubleValue.magnitude.stringValue, type: .size)
}
}
}

private func updateAbacusTriggerOrdersState(order: SubaccountOrder) {
Expand Down Expand Up @@ -318,9 +338,13 @@ private class dydxTakeProfitStopLossViewPresenter: HostedViewPresenter<dydxTakeP
viewModel.takeProfitStopLossInputAreaViewModel?.stopLossPriceInputViewModel?.onEdited = {
AbacusStateManager.shared.triggerOrders(input: $0, type: .stoplossprice)
}
viewModel.customAmountViewModel?.onEdited = {
AbacusStateManager.shared.triggerOrders(input: $0, type: .size)
}
viewModel.customAmountViewModel?.valuePublisher
.removeDuplicates()
.sink(receiveValue: { value in
AbacusStateManager.shared.triggerOrders(input: value, type: .size)
})
.store(in: &subscriptions)

viewModel.customLimitPriceViewModel?.takeProfitPriceInputViewModel?.onEdited = {
AbacusStateManager.shared.triggerOrders(input: $0, type: .takeprofitlimitprice)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,31 @@ private class dydxTargetLeverageViewPresenter: HostedViewPresenter<dydxTargetLev
self.viewModel = viewModel

self.viewModel?.optionSelectedAction = {[weak self] value in
self?.update(value: "\(value.value)")
self?.update(value: value.value)
}
self.ctaButtonPresenter.viewModel?.ctaAction = {
guard let value = viewModel.leverageInput?.value else { return }
AbacusStateManager.shared.trade(input: "\(value)", type: .targetleverage)
AbacusStateManager.shared.trade(input: viewModel.sliderTextInput.valueAsString, type: .targetleverage)
Router.shared?.navigate(to: RoutingRequest(path: "/action/dismiss"), animated: true, completion: nil)
}
self.viewModel?.leverageInput?.onEdited = {[weak self] value in
self?.update(value: value)
}

self.viewModel?.sliderTextInput.numberFormatter.fractionDigits = 2
self.viewModel?.sliderTextInput.$value
.removeDuplicates()
.sink(receiveValue: { [weak self] value in
self?.update(value: value)
})
.store(in: &subscriptions)

attachChildren(workers: childPresenters)
}

private func update(value: String?) {
let valueAsDouble = Double(value ?? "") ?? 0
viewModel?.leverageInput?.value = value
private func update(value: Double?) {
let value = value ?? 0
viewModel?.sliderTextInput.value = value
viewModel?.selectedOptionIndex = viewModel?.leverageOptions.firstIndex(where: { option in
option.value == valueAsDouble
option.value == value
})
if valueAsDouble > 0 {
if value > 0 {
viewModel?.ctaButton?.ctaButtonState = .enabled(DataLocalizer.localize(path: "APP.TRADE.CONFIRM_LEVERAGE"))
} else {
viewModel?.ctaButton?.ctaButtonState = .disabled(DataLocalizer.localize(path: "APP.TRADE.CONFIRM_LEVERAGE"))
Expand All @@ -93,14 +97,14 @@ private class dydxTargetLeverageViewPresenter: HostedViewPresenter<dydxTargetLev
guard let viewModel = self?.viewModel, let marketId = tradeInput?.marketId, let market = configsAndAssetMap[marketId] else { return }
if let effectiveInitialMarginFraction = market.configs?.effectiveInitialMarginFraction?.doubleValue, effectiveInitialMarginFraction > 0 {
let maxLeverage = 1.0 / effectiveInitialMarginFraction
viewModel.sliderTextInput.maxValue = maxLeverage
viewModel.leverageOptions = [1, 2, 3, 5, 10]
.filter { $0 < maxLeverage }
.map { dydxTargetLeverageViewModel.LeverageTextAndValue(text: dydxFormatter.shared.multiplier(number: Double($0)) ?? "", value: $0) }
viewModel.leverageOptions.append(dydxTargetLeverageViewModel.LeverageTextAndValue(text: DataLocalizer.localize(path: "APP.GENERAL.MAX"), value: maxLeverage))
}

let value = dydxFormatter.shared.localFormatted(number: tradeInput?.targetLeverage ?? 1, digits: 1)
self?.update(value: value)
self?.update(value: tradeInput?.targetLeverage ?? 1)
}
.store(in: &subscriptions)
}
Expand Down
Loading

0 comments on commit 2caf35d

Please sign in to comment.