From b5703c73b226c274fb540b4c55d85c4632738fa2 Mon Sep 17 00:00:00 2001 From: moo-onthelawn <70078372+moo-onthelawn@users.noreply.github.com> Date: Wed, 1 May 2024 10:24:50 -0400 Subject: [PATCH] v1.6.53: add analytics for SL/TP (#325) * wip * wip clean up * bump v * bump v * midway through fixing tests * revise analytics * review comments * rename fromSlTp to fromSlTpDialog * add notation for frunctions * bumped time (#330) --- .../TriggerOrdersInputCalculator.kt | 4 + .../protocols/PublicProtocols.kt | 5 +- .../state/manager/StateManagerAdaptor.kt | 35 +++-- .../state/manager/V4StateManagerAdaptor.kt | 98 +++++++++---- .../state/manager/utils/Payloads.kt | 5 + .../state/v2/supervisor/NetworkSupervisor.kt | 2 +- .../v2/supervisor/SubaccountSupervisor.kt | 134 ++++++++++++------ .../utils/AnalyticsUtils.kt | 101 ++++++++++++- .../helper/TriggerOrderToastGeneratorTests.kt | 20 +++ .../app/manager/V4TransactionTests.kt | 2 +- .../app/manager/v2/V4TransactionTests.kt | 2 +- 11 files changed, 320 insertions(+), 88 deletions(-) diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TriggerOrdersInputCalculator.kt b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TriggerOrdersInputCalculator.kt index c89329c60..e24cbcb29 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TriggerOrdersInputCalculator.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/calculator/TriggerOrdersInputCalculator.kt @@ -13,6 +13,10 @@ import exchange.dydx.abacus.utils.mutable import exchange.dydx.abacus.utils.safeSet import kotlin.math.abs +internal object TriggerOrdersConstants { + const val TRIGGER_ORDER_DEFAULT_DURATION_DAYS = 90.0 +} + @Suppress("UNCHECKED_CAST") internal class TriggerOrdersInputCalculator(val parser: ParserProtocol) { internal fun calculate( diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/protocols/PublicProtocols.kt b/src/commonMain/kotlin/exchange.dydx.abacus/protocols/PublicProtocols.kt index 1a155ddf9..8b290fc49 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/protocols/PublicProtocols.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/protocols/PublicProtocols.kt @@ -209,7 +209,7 @@ enum class AnalyticsEvent(val rawValue: String) { // App NetworkStatus("NetworkStatus"), - // Trade Events + // Trade TradePlaceOrderClick("TradePlaceOrderClick"), TradeCancelOrderClick("TradeCancelOrderClick"), TradePlaceOrder("TradePlaceOrder"), @@ -221,6 +221,9 @@ enum class AnalyticsEvent(val rawValue: String) { TradeCancelOrderConfirmed("TradeCancelOrderConfirmed"), TradePlaceOrderConfirmed("TradePlaceOrderConfirmed"), + // Trigger Order + TriggerOrderClick("TriggerOrderClick"), + // Transfers TransferFaucetConfirmed("TransferFaucetConfirmed"); diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/StateManagerAdaptor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/StateManagerAdaptor.kt index 6ebc05891..e5b8b27c0 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/StateManagerAdaptor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/StateManagerAdaptor.kt @@ -1,5 +1,6 @@ package exchange.dydx.abacus.state.manager +import exchange.dydx.abacus.calculator.TriggerOrdersConstants.TRIGGER_ORDER_DEFAULT_DURATION_DAYS import exchange.dydx.abacus.output.Compliance import exchange.dydx.abacus.output.ComplianceAction import exchange.dydx.abacus.output.ComplianceStatus @@ -120,9 +121,6 @@ open class StateManagerAdaptor( var stateNotification: StateNotificationProtocol?, var dataNotification: DataNotificationProtocol?, ) { - @Suppress("LocalVariableName", "PropertyName") - private val TRIGGER_ORDER_DEFAULT_DURATION_DAYS = 28.0 - var stateMachine: TradingStateMachine = PerpTradingStateMachine( environment, uiImplementations.localizer, @@ -1878,12 +1876,13 @@ open class StateManagerAdaptor( fun triggerOrdersPayload(): HumanReadableTriggerOrdersPayload { val placeOrderPayloads = mutableListOf() val cancelOrderPayloads = mutableListOf() - val triggerOrders = stateMachine.state?.input?.triggerOrders + val triggerOrders = requireNotNull(stateMachine.state?.input?.triggerOrders) { "triggerOrders input was null" } + val marketId = requireNotNull(triggerOrders.marketId) { "triggerOrders.marektId was null" } val subaccountNumber = connectedSubaccountNumber ?: throw Exception("subaccountNumber is null") val subaccount = stateMachine.state?.subaccount(subaccountNumber) ?: throw Exception("subaccount is null") - - val marketId = triggerOrders?.marketId ?: throw Exception("marketId is null") + val position = subaccount.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current fun updateTriggerOrder(triggerOrder: TriggerOrder) { // Cases @@ -1923,6 +1922,8 @@ open class StateManagerAdaptor( } return HumanReadableTriggerOrdersPayload( + marketId, + positionSize, placeOrderPayloads, cancelOrderPayloads, ) @@ -2152,6 +2153,7 @@ open class StateManagerAdaptor( ?: throw Exception("subaccount is null") val order = subaccount.orders?.firstOrNull { it.id == orderId } ?: throw Exception("order is null") + val type = order.type.rawValue val clientId = order.clientId ?: throw Exception("clientId is null") val orderFlags = order.orderFlags ?: throw Exception("orderFlags is null") val clobPairId = order.clobPairId ?: throw Exception("clobPairId is null") @@ -2160,6 +2162,7 @@ open class StateManagerAdaptor( return HumanReadableCancelOrderPayload( subaccountNumber, + type, orderId, clientId, orderFlags, @@ -2291,13 +2294,19 @@ open class StateManagerAdaptor( } } + internal open fun fromSlTpDialogParams(fromSlTpDialog: Boolean): IMap { + return iMapOf( + "fromSlTpDialog" to fromSlTpDialog, + ) + } + internal open fun trackingParams(interval: Double): IMap { return iMapOf( "roundtripMs" to interval, ) } - internal fun tracking(eventName: String, params: IMap?) { + internal fun tracking(eventName: String, params: IMap?) { val paramsAsString = jsonEncoder.encode(params) ioImplementations.threading?.async(ThreadingType.main) { ioImplementations.tracking?.log(eventName, paramsAsString) @@ -2326,10 +2335,14 @@ open class StateManagerAdaptor( if (placeOrderRecord != null) { val interval = Clock.System.now().toEpochMilliseconds() .toDouble() - placeOrderRecord.timestampInMilliseconds + val extraParams = ParsingHelper.merge( + trackingParams(interval), + fromSlTpDialogParams(placeOrderRecord.fromSlTpDialog), + ) tracking( AnalyticsEvent.TradePlaceOrderConfirmed.rawValue, ParsingHelper.merge( - trackingParams(interval), + extraParams, orderAnalyticsPayload, )?.toIMap(), ) @@ -2342,10 +2355,14 @@ open class StateManagerAdaptor( if (cancelOrderRecord != null) { val interval = Clock.System.now().toEpochMilliseconds() .toDouble() - cancelOrderRecord.timestampInMilliseconds + val extraParams = ParsingHelper.merge( + trackingParams(interval), + fromSlTpDialogParams(cancelOrderRecord.fromSlTpDialog), + ) tracking( AnalyticsEvent.TradeCancelOrderConfirmed.rawValue, ParsingHelper.merge( - trackingParams(interval), + extraParams, orderAnalyticsPayload, )?.toIMap(), ) diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/V4StateManagerAdaptor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/V4StateManagerAdaptor.kt index 35c8d31da..443a0d5f2 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/V4StateManagerAdaptor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/V4StateManagerAdaptor.kt @@ -12,6 +12,8 @@ import exchange.dydx.abacus.protocols.TransactionCallback import exchange.dydx.abacus.protocols.TransactionType import exchange.dydx.abacus.protocols.run import exchange.dydx.abacus.responses.ParsingError +import exchange.dydx.abacus.responses.ParsingErrorType +import exchange.dydx.abacus.responses.ParsingException import exchange.dydx.abacus.state.app.adaptors.V4TransactionErrors import exchange.dydx.abacus.state.manager.configs.V4StateManagerConfigs import exchange.dydx.abacus.state.model.TransferInputField @@ -1000,11 +1002,15 @@ class V4StateManagerAdaptor( callback: TransactionCallback, payload: HumanReadablePlaceOrderPayload, analyticsPayload: IMap?, + uiClickTimeMs: Double, isTriggerOrder: Boolean = false, ): HumanReadablePlaceOrderPayload { val clientId = payload.clientId val string = Json.encodeToString(payload) - val uiClickTimeMs = trackOrderClick(analyticsPayload) + val marketId = payload.marketId + + val position = stateMachine.state?.subaccount(subaccountNumber)?.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current stopWatchingLastOrder() @@ -1019,6 +1025,7 @@ class V4StateManagerAdaptor( subaccountNumber, clientId, submitTimeMs, + fromSlTpDialog = isTriggerOrder, ), ) } @@ -1040,6 +1047,8 @@ class V4StateManagerAdaptor( callback, if (isTriggerOrder) { HumanReadableTriggerOrdersPayload( + marketId, + positionSize, listOf(payload), emptyList(), ) @@ -1056,15 +1065,19 @@ class V4StateManagerAdaptor( private fun submitCancelOrder( orderId: String, + marketId: String, callback: TransactionCallback, payload: HumanReadableCancelOrderPayload, analyticsPayload: IMap?, + uiClickTimeMs: Double, isTriggerOrder: Boolean = false, ) { val clientId = payload.clientId val string = Json.encodeToString(payload) - val uiClickTimeMs = trackOrderClick(analyticsPayload, isCancel = true) + val position = stateMachine.state?.subaccount(subaccountNumber)?.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current + val isShortTermOrder = payload.orderFlags == 0 stopWatchingLastOrder() @@ -1080,6 +1093,7 @@ class V4StateManagerAdaptor( subaccountNumber, clientId, submitTimeMs, + fromSlTpDialog = isTriggerOrder, ), ) } @@ -1101,6 +1115,8 @@ class V4StateManagerAdaptor( callback, if (isTriggerOrder) { HumanReadableTriggerOrdersPayload( + marketId, + positionSize, emptyList(), listOf(payload), ) @@ -1114,12 +1130,12 @@ class V4StateManagerAdaptor( } private fun trackOrderClick( - analyticsPayload: IMap?, - isCancel: Boolean = false + analyticsPayload: IMap?, + analyticsEvent: AnalyticsEvent, ): Double { val uiClickTimeMs = Clock.System.now().toEpochMilliseconds().toDouble() tracking( - if (isCancel) AnalyticsEvent.TradeCancelOrderClick.rawValue else AnalyticsEvent.TradePlaceOrderClick.rawValue, + analyticsEvent.rawValue, analyticsPayload, ) return uiClickTimeMs @@ -1135,8 +1151,7 @@ class V4StateManagerAdaptor( tracking( if (isCancel) AnalyticsEvent.TradeCancelOrder.rawValue else AnalyticsEvent.TradePlaceOrder.rawValue, - ParsingHelper.merge(uiTrackingParams(uiDelayTimeMs), analyticsPayload) - ?.toIMap(), + ParsingHelper.merge(uiTrackingParams(uiDelayTimeMs), analyticsPayload)?.toIMap(), ) return submitTimeMs @@ -1163,59 +1178,86 @@ class V4StateManagerAdaptor( override fun commitPlaceOrder(callback: TransactionCallback): HumanReadablePlaceOrderPayload { val payload = placeOrderPayload() val midMarketPrice = stateMachine.state?.marketOrderbook(payload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice) - return submitPlaceOrder(callback, payload, analyticsPayload) + val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice, fromSlTpDialog = false, isClosePosition = false) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradePlaceOrderClick) + + return submitPlaceOrder(callback, payload, analyticsPayload, uiClickTimeMs) } override fun commitClosePosition(callback: TransactionCallback): HumanReadablePlaceOrderPayload { val payload = closePositionPayload() val midMarketPrice = stateMachine.state?.marketOrderbook(payload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice, true) - return submitPlaceOrder(callback, payload, analyticsPayload) + val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice, fromSlTpDialog = false, isClosePosition = true) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradePlaceOrderClick) + + return submitPlaceOrder(callback, payload, analyticsPayload, uiClickTimeMs) } override fun cancelOrder(orderId: String, callback: TransactionCallback) { val payload = cancelOrderPayload(orderId) val subaccount = stateMachine.state?.subaccount(subaccountNumber) - val existingOrder = subaccount?.orders?.firstOrNull { it.id == orderId } + val existingOrder = subaccount?.orders?.firstOrNull { it.id == orderId } ?: throw ParsingException( + ParsingErrorType.MissingRequiredData, + "no existing order to be cancelled for $orderId", + ) + val marketId = existingOrder.marketId val analyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload( payload, existingOrder, + fromSlTpDialog = false, ) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradeCancelOrderClick) - submitCancelOrder(orderId, callback, payload, analyticsPayload) + submitCancelOrder(orderId, marketId, callback, payload, analyticsPayload, uiClickTimeMs) } override fun commitTriggerOrders(callback: TransactionCallback): HumanReadableTriggerOrdersPayload { - val payloads = triggerOrdersPayload() + val payload = triggerOrdersPayload() + + // this is a diff payload that summarizes the actions to be taken + val analyticsPayload = analyticsUtils.triggerOrdersAnalyticsPayload(payload) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TriggerOrderClick) - payloads.cancelOrderPayloads.forEach { payload -> + payload.cancelOrderPayloads.forEach { cancelPayload -> val subaccount = stateMachine.state?.subaccount(subaccountNumber) - val existingOrder = subaccount?.orders?.firstOrNull { it.id == payload.orderId } - val analyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload( - payload, + val existingOrder = subaccount?.orders?.firstOrNull { it.id == cancelPayload.orderId } + ?: throw ParsingException( + ParsingErrorType.MissingRequiredData, + "no existing order to be cancelled for $cancelPayload.orderId", + ) + val marketId = existingOrder.marketId + val cancelOrderAnalyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload( + cancelPayload, existingOrder, + fromSlTpDialog = true, + ) + submitCancelOrder( + cancelPayload.orderId, + marketId, + callback, + cancelPayload, + cancelOrderAnalyticsPayload, + uiClickTimeMs, true, ) - submitCancelOrder(payload.orderId, callback, payload, analyticsPayload, true) } - payloads.placeOrderPayloads.forEach { payload -> - val midMarketPrice = stateMachine.state?.marketOrderbook(payload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload( - payload, + payload.placeOrderPayloads.forEach { placePayload -> + val midMarketPrice = stateMachine.state?.marketOrderbook(placePayload.marketId)?.midPrice + val placeOrderAnalyticsPayload = analyticsUtils.placeOrderAnalyticsPayload( + placePayload, midMarketPrice, - isClosePosition = false, fromSlTpDialog = true, + isClosePosition = false, ) - submitPlaceOrder(callback, payload, analyticsPayload, true) + submitPlaceOrder(callback, placePayload, placeOrderAnalyticsPayload, uiClickTimeMs, true) } - if (payloads.cancelOrderPayloads.isEmpty() && payloads.placeOrderPayloads.isEmpty()) { - send(null, callback, payloads) + if (payload.cancelOrderPayloads.isEmpty() && payload.placeOrderPayloads.isEmpty()) { + send(null, callback, payload) } - return payloads + return payload } override fun commitTransfer(callback: TransactionCallback) { diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/utils/Payloads.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/utils/Payloads.kt index c13f994ef..5bfac942b 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/utils/Payloads.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/manager/utils/Payloads.kt @@ -18,12 +18,14 @@ data class PlaceOrderRecord( val subaccountNumber: Int, val clientId: Int, val timestampInMilliseconds: Double, + val fromSlTpDialog: Boolean, ) data class CancelOrderRecord( val subaccountNumber: Int, val clientId: Int, val timestampInMilliseconds: Double, + val fromSlTpDialog: Boolean, ) @JsExport @@ -61,6 +63,7 @@ data class HumanReadablePlaceOrderPayload( @Serializable data class HumanReadableCancelOrderPayload( val subaccountNumber: Int, + val type: String, val orderId: String, val clientId: Int, val orderFlags: Int, @@ -72,6 +75,8 @@ data class HumanReadableCancelOrderPayload( @JsExport @Serializable data class HumanReadableTriggerOrdersPayload( + val marketId: String, + val positionSize: Double?, val placeOrderPayloads: List, val cancelOrderPayloads: List, ) diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/NetworkSupervisor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/NetworkSupervisor.kt index 8748551d4..475a8a847 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/NetworkSupervisor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/NetworkSupervisor.kt @@ -81,7 +81,7 @@ internal open class NetworkSupervisor( } } - internal fun tracking(eventName: String, params: IMap?) { + internal fun tracking(eventName: String, params: IMap?) { val paramsAsString = helper.jsonEncoder.encode(params) helper.ioImplementations.threading?.async(ThreadingType.main) { helper.ioImplementations.tracking?.log(eventName, paramsAsString) diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/SubaccountSupervisor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/SubaccountSupervisor.kt index 44416eb40..119926137 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/SubaccountSupervisor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/SubaccountSupervisor.kt @@ -1,5 +1,6 @@ package exchange.dydx.abacus.state.v2.supervisor +import exchange.dydx.abacus.calculator.TriggerOrdersConstants.TRIGGER_ORDER_DEFAULT_DURATION_DAYS import exchange.dydx.abacus.output.Notification import exchange.dydx.abacus.output.SubaccountOrder import exchange.dydx.abacus.output.TransferRecordType @@ -80,9 +81,6 @@ internal class SubaccountSupervisor( private val accountAddress: String, internal val subaccountNumber: Int ) : DynamicNetworkSupervisor(stateMachine, helper, analyticsUtils) { - @Suppress("LocalVariableName", "PropertyName") - private val TRIGGER_ORDER_DEFAULT_DURATION_DAYS = 28.0 - /* Because faucet is done at subaccount level, we need SubaccountSupervisor even before the subaccount is realized on protocol/indexer. @@ -307,10 +305,14 @@ internal class SubaccountSupervisor( if (placeOrderRecord != null) { val interval = Clock.System.now().toEpochMilliseconds() .toDouble() - placeOrderRecord.timestampInMilliseconds + val extraParams = ParsingHelper.merge( + trackingParams(interval), + fromSlTpDialogParams(placeOrderRecord.fromSlTpDialog), + ) tracking( AnalyticsEvent.TradePlaceOrderConfirmed.rawValue, ParsingHelper.merge( - trackingParams(interval), + extraParams, orderAnalyticsPayload, )?.toIMap(), ) @@ -323,10 +325,14 @@ internal class SubaccountSupervisor( if (cancelOrderRecord != null) { val interval = Clock.System.now().toEpochMilliseconds() .toDouble() - cancelOrderRecord.timestampInMilliseconds + val extraParams = ParsingHelper.merge( + trackingParams(interval), + fromSlTpDialogParams(cancelOrderRecord.fromSlTpDialog), + ) tracking( AnalyticsEvent.TradeCancelOrderConfirmed.rawValue, ParsingHelper.merge( - trackingParams(interval), + extraParams, orderAnalyticsPayload, )?.toIMap(), ) @@ -337,6 +343,12 @@ internal class SubaccountSupervisor( } } + private fun fromSlTpDialogParams(fromSlTpDialog: Boolean): IMap { + return iMapOf( + "fromSlTpDialog" to fromSlTpDialog, + ) + } + private fun trackingParams(interval: Double): IMap { return iMapOf( "roundtripMs" to interval, @@ -530,6 +542,7 @@ internal class SubaccountSupervisor( callback: TransactionCallback, payload: HumanReadablePlaceOrderPayload, analyticsPayload: IMap?, + uiClickTimeMs: Double, isTriggerOrder: Boolean = false, transferPayload: HumanReadableSubaccountTransferPayload? = null, ): HumanReadablePlaceOrderPayload { @@ -537,7 +550,10 @@ internal class SubaccountSupervisor( val string = Json.encodeToString(payload) val transferPayloadString = if (transferPayload != null) Json.encodeToString(transferPayload) else null - val uiClickTimeMs = trackOrderClick(analyticsPayload) + + val marketId = payload.marketId + val position = stateMachine.state?.subaccount(subaccountNumber)?.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current val isShortTermOrder = when (payload.type) { "MARKET" -> true @@ -561,6 +577,7 @@ internal class SubaccountSupervisor( subaccountNumber, clientId, submitTimeMs, + fromSlTpDialog = isTriggerOrder, ), ) } @@ -582,6 +599,8 @@ internal class SubaccountSupervisor( callback, if (isTriggerOrder) { HumanReadableTriggerOrdersPayload( + marketId, + positionSize, listOf(payload), emptyList(), ) @@ -632,12 +651,12 @@ internal class SubaccountSupervisor( } private fun trackOrderClick( - analyticsPayload: IMap?, - isCancel: Boolean = false + analyticsPayload: IMap?, + analyticsEvent: AnalyticsEvent, ): Double { val uiClickTimeMs = Clock.System.now().toEpochMilliseconds().toDouble() tracking( - if (isCancel) AnalyticsEvent.TradeCancelOrderClick.rawValue else AnalyticsEvent.TradePlaceOrderClick.rawValue, + analyticsEvent.rawValue, analyticsPayload, ) return uiClickTimeMs @@ -653,8 +672,7 @@ internal class SubaccountSupervisor( tracking( if (isCancel) AnalyticsEvent.TradeCancelOrder.rawValue else AnalyticsEvent.TradePlaceOrder.rawValue, - ParsingHelper.merge(uiTrackingParams(uiDelayTimeMs), analyticsPayload) - ?.toIMap(), + ParsingHelper.merge(uiTrackingParams(uiDelayTimeMs), analyticsPayload)?.toIMap(), ) return submitTimeMs @@ -680,16 +698,18 @@ internal class SubaccountSupervisor( private fun submitCancelOrder( orderId: String, + marketId: String, callback: TransactionCallback, payload: HumanReadableCancelOrderPayload, analyticsPayload: IMap?, + uiClickTimeMs: Double, isTriggerOrder: Boolean = false, ): HumanReadableCancelOrderPayload { val clientId = payload.clientId val string = Json.encodeToString(payload) - val uiClickTimeMs = Clock.System.now().toEpochMilliseconds().toDouble() - tracking(AnalyticsEvent.TradeCancelOrderClick.rawValue, analyticsPayload) + val position = stateMachine.state?.subaccount(subaccountNumber)?.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current stopWatchingLastOrder() @@ -706,6 +726,7 @@ internal class SubaccountSupervisor( subaccountNumber, clientId, submitTimeMs, + fromSlTpDialog = isTriggerOrder, ), ) } @@ -726,6 +747,8 @@ internal class SubaccountSupervisor( callback, if (isTriggerOrder) { HumanReadableTriggerOrdersPayload( + marketId, + positionSize, emptyList(), listOf(payload), ) @@ -746,13 +769,14 @@ internal class SubaccountSupervisor( ): HumanReadablePlaceOrderPayload { val orderPayload = placeOrderPayload(currentHeight) val midMarketPrice = stateMachine.state?.marketOrderbook(orderPayload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(orderPayload, midMarketPrice, false) + val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(orderPayload, midMarketPrice, fromSlTpDialog = false, isClosePosition = false) val isIsolatedMarginOrder = helper.parser.asInt(orderPayload.subaccountNumber) != subaccountNumber val transferPayload = if (isIsolatedMarginOrder) getTransferPayloadForIsolatedMarginTrade(orderPayload) else null + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradePlaceOrderClick) - return submitPlaceOrder(callback, orderPayload, analyticsPayload, false, transferPayload) + return submitPlaceOrder(callback, orderPayload, analyticsPayload, uiClickTimeMs, false, transferPayload) } internal fun commitClosePosition( @@ -761,49 +785,76 @@ internal class SubaccountSupervisor( ): HumanReadablePlaceOrderPayload { val payload = closePositionPayload(currentHeight) val midMarketPrice = stateMachine.state?.marketOrderbook(payload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice, true) + val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload(payload, midMarketPrice, fromSlTpDialog = false, isClosePosition = true) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradePlaceOrderClick) - return submitPlaceOrder(callback, payload, analyticsPayload) + return submitPlaceOrder(callback, payload, analyticsPayload, uiClickTimeMs) } internal fun cancelOrder(orderId: String, callback: TransactionCallback): HumanReadableCancelOrderPayload { val payload = cancelOrderPayload(orderId) val subaccount = stateMachine.state?.subaccount(subaccountNumber) - val existingOrder = subaccount?.orders?.firstOrNull { it.id == orderId } - val analyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload(payload, existingOrder) + val existingOrder = subaccount?.orders?.firstOrNull { it.id == orderId } ?: throw ParsingException( + ParsingErrorType.MissingRequiredData, + "no existing order to be cancelled for $orderId", + ) + val marketId = existingOrder.marketId + val analyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload(payload, existingOrder, fromSlTpDialog = false) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TradeCancelOrderClick) - return submitCancelOrder(orderId, callback, payload, analyticsPayload) + return submitCancelOrder(orderId, marketId, callback, payload, analyticsPayload, uiClickTimeMs) } internal fun commitTriggerOrders( currentHeight: Int?, callback: TransactionCallback ): HumanReadableTriggerOrdersPayload { - val payloads = triggerOrdersPayload(currentHeight) + val payload = triggerOrdersPayload(currentHeight) + + // this is a diff payload that summarizes the actions to be taken + val analyticsPayload = analyticsUtils.triggerOrdersAnalyticsPayload(payload) + val uiClickTimeMs = trackOrderClick(analyticsPayload, AnalyticsEvent.TriggerOrderClick) - payloads.cancelOrderPayloads.forEach { payload -> + payload.cancelOrderPayloads.forEach { cancelPayload -> val subaccount = stateMachine.state?.subaccount(subaccountNumber) - val existingOrder = subaccount?.orders?.firstOrNull { it.id == payload.orderId } - val analyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload(payload, existingOrder, true) - submitCancelOrder(payload.orderId, callback, payload, analyticsPayload, true) + val existingOrder = subaccount?.orders?.firstOrNull { it.id == cancelPayload.orderId } + ?: throw ParsingException( + ParsingErrorType.MissingRequiredData, + "no existing order to be cancelled for $cancelPayload.orderId", + ) + val marketId = existingOrder.marketId + val cancelOrderAnalyticsPayload = analyticsUtils.cancelOrderAnalyticsPayload( + cancelPayload, + existingOrder, + fromSlTpDialog = true, + ) + submitCancelOrder( + cancelPayload.orderId, + marketId, + callback, + cancelPayload, + cancelOrderAnalyticsPayload, + uiClickTimeMs, + true, + ) } - payloads.placeOrderPayloads.forEach { payload -> - val midMarketPrice = stateMachine.state?.marketOrderbook(payload.marketId)?.midPrice - val analyticsPayload = analyticsUtils.placeOrderAnalyticsPayload( - payload, + payload.placeOrderPayloads.forEach { placePayload -> + val midMarketPrice = stateMachine.state?.marketOrderbook(placePayload.marketId)?.midPrice + val placeOrderAnalyticsPayload = analyticsUtils.placeOrderAnalyticsPayload( + placePayload, midMarketPrice, - isClosePosition = false, fromSlTpDialog = true, + isClosePosition = false, ) - submitPlaceOrder(callback, payload, analyticsPayload, true) + submitPlaceOrder(callback, placePayload, placeOrderAnalyticsPayload, uiClickTimeMs, true) } - if (payloads.cancelOrderPayloads.isEmpty() && payloads.placeOrderPayloads.isEmpty()) { - helper.send(null, callback, payloads) + if (payload.cancelOrderPayloads.isEmpty() && payload.placeOrderPayloads.isEmpty()) { + helper.send(null, callback, payload) } - return payloads + return payload } internal fun stopWatchingLastOrder() { @@ -968,11 +1019,12 @@ internal class SubaccountSupervisor( fun triggerOrdersPayload(currentHeight: Int?): HumanReadableTriggerOrdersPayload { val placeOrderPayloads = mutableListOf() val cancelOrderPayloads = mutableListOf() - val triggerOrders = stateMachine.state?.input?.triggerOrders + val triggerOrders = requireNotNull(stateMachine.state?.input?.triggerOrders) { "triggerOrders input was null" } - val subaccount = stateMachine.state?.subaccount(subaccountNumber) ?: throw Exception("subaccount is null") - - val marketId = triggerOrders?.marketId ?: throw Exception("marketId is null") + val marketId = requireNotNull(triggerOrders.marketId) { "triggerOrders.marketId was null" } + val subaccount = stateMachine.state?.subaccount(subaccountNumber) + val position = subaccount?.openPositions?.find { it.id == marketId } + val positionSize = position?.size?.current fun updateTriggerOrder(triggerOrder: TriggerOrder) { // Cases @@ -983,7 +1035,7 @@ internal class SubaccountSupervisor( // 5. No existing order -> nothing should be done if (triggerOrder.orderId != null) { - val existingOrder = subaccount.orders?.firstOrNull { it.id == triggerOrder.orderId } + val existingOrder = subaccount?.orders?.firstOrNull { it.id == triggerOrder.orderId } ?: throw Exception("order is null") if (triggerOrder.price?.triggerPrice != null) { if (!isTriggerOrderEqualToExistingOrder(triggerOrder, existingOrder)) { @@ -1011,7 +1063,7 @@ internal class SubaccountSupervisor( updateTriggerOrder(triggerOrders.takeProfitOrder) } - return HumanReadableTriggerOrdersPayload(placeOrderPayloads, cancelOrderPayloads) + return HumanReadableTriggerOrdersPayload(marketId, positionSize, placeOrderPayloads, cancelOrderPayloads) } @Throws(Exception::class) @@ -1069,6 +1121,7 @@ internal class SubaccountSupervisor( ?: throw Exception("subaccount is null") val order = subaccount.orders?.firstOrNull { it.id == orderId } ?: throw Exception("order is null") + val type = order.type.rawValue val clientId = order.clientId ?: error("clientId is null") val orderFlags = order.orderFlags ?: error("orderFlags is null") val clobPairId = order.clobPairId ?: error("clobPairId is null") @@ -1078,6 +1131,7 @@ internal class SubaccountSupervisor( return HumanReadableCancelOrderPayload( orderSubaccountNumber, + type, orderId, clientId, orderFlags, diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/utils/AnalyticsUtils.kt b/src/commonMain/kotlin/exchange.dydx.abacus/utils/AnalyticsUtils.kt index 0e7227a1a..dba6d6502 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/utils/AnalyticsUtils.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/utils/AnalyticsUtils.kt @@ -1,26 +1,107 @@ package exchange.dydx.abacus.utils import exchange.dydx.abacus.output.SubaccountOrder +import exchange.dydx.abacus.output.input.OrderType import exchange.dydx.abacus.state.manager.HumanReadableCancelOrderPayload import exchange.dydx.abacus.state.manager.HumanReadablePlaceOrderPayload +import exchange.dydx.abacus.state.manager.HumanReadableTriggerOrdersPayload +import kollections.JsExport import kollections.toIMap +import kotlinx.serialization.Serializable + +@JsExport +@Serializable +enum class TriggerOrderAction(val rawValue: String) { + REPLACE("REPLACE"), + CANCEL("CANCEL"), + CREATE("CREATE"), + ; +} class AnalyticsUtils { + /** + * Format Trigger Orders Payload and add additional details for `TriggerOrders` Analytic Events + * @param payload HumanReadableTriggerOrdersPayload + */ + + /** + * Format Place Order Payload for `TriggerOrders` Analytic Event + * @param payload HumanReadableTriggerOrdersPayload + */ + fun triggerOrdersAnalyticsPayload( + payload: HumanReadableTriggerOrdersPayload, + ): IMap? { + val placeOrderPayloads = payload.placeOrderPayloads + val cancelOrderPayloads = payload.cancelOrderPayloads + + val stopLossOrderTypes = listOf(OrderType.stopMarket, OrderType.stopLimit) + val takeProfitOrderTypes = listOf(OrderType.takeProfitMarket, OrderType.takeProfitLimit) + + var stopLossOrderCancelClientId: Int? = null + var stopLossOrderPlaceClientId: Int? = null + var takeProfitOrderCancelClientId: Int? = null + var takeProfitOrderPlaceClientId: Int? = null + + var stopLossOrderAction: TriggerOrderAction? = null + var takeProfitOrderAction: TriggerOrderAction? = null + + placeOrderPayloads.forEach { placePayload -> + val orderType = OrderType(placePayload.type) + if (stopLossOrderTypes.contains(orderType)) { + stopLossOrderPlaceClientId = placePayload.clientId + stopLossOrderAction = TriggerOrderAction.CREATE + } else if (takeProfitOrderTypes.contains(orderType)) { + takeProfitOrderPlaceClientId = placePayload.clientId + takeProfitOrderAction = TriggerOrderAction.CREATE + } + } + + cancelOrderPayloads.forEach { cancelPayload -> + val orderType = OrderType(cancelPayload.type) + if (stopLossOrderTypes.contains(orderType)) { + stopLossOrderCancelClientId = cancelPayload.clientId + stopLossOrderAction = if (stopLossOrderAction == null) { + TriggerOrderAction.CANCEL + } else { + TriggerOrderAction.REPLACE + } + } else if (takeProfitOrderTypes.contains(orderType)) { + takeProfitOrderCancelClientId = cancelPayload.clientId + takeProfitOrderAction = if (takeProfitOrderAction == null) { + TriggerOrderAction.CANCEL + } else { + TriggerOrderAction.REPLACE + } + } + } + + return iMapOf( + "marketId" to payload.marketId, + "positionSize" to payload.positionSize, + "stopLossOrderAction" to stopLossOrderAction?.rawValue, + "stopLossOrderCancelClientId" to stopLossOrderCancelClientId, + "stopLossOrderPlaceClientId" to stopLossOrderPlaceClientId, + "takeProfitOrderAction" to takeProfitOrderAction?.rawValue, + "takeProfitOrderCancelClientId" to takeProfitOrderCancelClientId, + "takeProfitOrderPlaceClientId" to takeProfitOrderPlaceClientId, + ) + } + /** * Format Place Order Payload and add additional details for `TradePlaceOrder` Analytic Events * @param payload HumanReadablePlaceOrderPayload * @param midMarketPrice Double? - * @param isClosePosition Boolean? * @param fromSlTpDialog Boolean? + * @param isClosePosition Boolean? */ fun placeOrderAnalyticsPayload( payload: HumanReadablePlaceOrderPayload, midMarketPrice: Double?, - isClosePosition: Boolean? = false, fromSlTpDialog: Boolean? = false, + isClosePosition: Boolean? = false, ): IMap? { return ParsingHelper.merge( - formatPlaceOrderPayload(payload, isClosePosition, fromSlTpDialog), + formatPlaceOrderPayload(payload, fromSlTpDialog, isClosePosition), iMapOf( "inferredTimeInForce" to calculateOrderTimeInForce(payload), "midMarketPrice" to midMarketPrice, @@ -31,12 +112,13 @@ class AnalyticsUtils { /** * Format Place Order Payload for `TradePlaceOrder` Analytic Event * @param payload HumanReadablePlaceOrderPayload - * @param isClosePosition Boolean + * @param fromSlTpDialog Boolean? + * @param isClosePosition Boolean? */ private fun formatPlaceOrderPayload( payload: HumanReadablePlaceOrderPayload, - isClosePosition: Boolean? = false, fromSlTpDialog: Boolean? = false, + isClosePosition: Boolean? = false, ): IMap? { return iMapOf( "clientId" to payload.clientId, @@ -44,8 +126,8 @@ class AnalyticsUtils { "execution" to payload.execution, "goodTilTimeInSeconds" to payload.goodTilTimeInSeconds, "goodTilBlock" to payload.goodTilBlock, - "isClosePosition" to isClosePosition, "fromSlTpDialog" to fromSlTpDialog, + "isClosePosition" to isClosePosition, "marketId" to payload.marketId, "postOnly" to payload.postOnly, "price" to payload.price, @@ -91,7 +173,7 @@ class AnalyticsUtils { * Format Cancel Order Payload and add order details for `TradeCancelOrder` Analytic Events * @param payload HumanReadableCancelOrderPayload * @param existingOrder SubaccountOrder? - * @param fromSlTpDialog Boolean + * @param fromSlTpDialog Boolean? */ fun cancelOrderAnalyticsPayload( payload: HumanReadableCancelOrderPayload, @@ -104,6 +186,11 @@ class AnalyticsUtils { )?.toIMap() } + /** + * Format Cancel Order Payload for `TradeCancelOrder` Analytic Event + * @param payload HumanReadableCancelOrderPayload + * @param fromSlTpDialog Boolean? + */ private fun formatCancelOrderPayload( payload: HumanReadableCancelOrderPayload, fromSlTpDialog: Boolean? = false, diff --git a/src/commonTest/kotlin/exchange.dydx.abacus/app/helper/TriggerOrderToastGeneratorTests.kt b/src/commonTest/kotlin/exchange.dydx.abacus/app/helper/TriggerOrderToastGeneratorTests.kt index c20738af3..1d3777049 100644 --- a/src/commonTest/kotlin/exchange.dydx.abacus/app/helper/TriggerOrderToastGeneratorTests.kt +++ b/src/commonTest/kotlin/exchange.dydx.abacus/app/helper/TriggerOrderToastGeneratorTests.kt @@ -69,6 +69,9 @@ class TriggerOrderToastGeneratorTests { "side": { "current": "SHORT" }, + "size": { + "current": 94.57 + }, "resources": { "sideString": {}, "sideStringKey": {}, @@ -95,6 +98,8 @@ class TriggerOrderToastGeneratorTests { toastGenerator.onTriggerOrderSubmitted( subaccountNumber = 0, payload = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = emptyList(), placeOrderPayloads = emptyList(), ), @@ -105,10 +110,13 @@ class TriggerOrderToastGeneratorTests { toastGenerator.onTriggerOrderSubmitted( subaccountNumber = 0, payload = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = listOf( HumanReadableCancelOrderPayload( subaccountNumber = 0, clientId = 0, + type = "TAKE_PROFIT", orderId = "existingOrderId", orderFlags = 0, clobPairId = 0, @@ -160,6 +168,8 @@ class TriggerOrderToastGeneratorTests { toastGenerator.onTriggerOrderSubmitted( subaccountNumber = 0, payload = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = emptyList(), placeOrderPayloads = emptyList(), ), @@ -171,10 +181,13 @@ class TriggerOrderToastGeneratorTests { successful = true, error = null, data = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = listOf( HumanReadableCancelOrderPayload( subaccountNumber = 0, clientId = 0, + type = "TAKE_PROFIT", orderId = "existingOrderId", orderFlags = 0, clobPairId = 0, @@ -198,6 +211,8 @@ class TriggerOrderToastGeneratorTests { successful = true, error = null, data = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = emptyList(), placeOrderPayloads = listOf( HumanReadablePlaceOrderPayload( @@ -237,6 +252,8 @@ class TriggerOrderToastGeneratorTests { toastGenerator.onTriggerOrderSubmitted( subaccountNumber = 0, payload = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = emptyList(), placeOrderPayloads = emptyList(), ), @@ -248,9 +265,12 @@ class TriggerOrderToastGeneratorTests { successful = false, error = null, data = HumanReadableTriggerOrdersPayload( + marketId = "ETH-USD", + positionSize = 94.57, cancelOrderPayloads = listOf( HumanReadableCancelOrderPayload( subaccountNumber = 0, + type = "TAKE_PROFIT", clientId = 0, orderId = "existingOrderId", orderFlags = 0, diff --git a/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/V4TransactionTests.kt b/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/V4TransactionTests.kt index 4607f50b4..23f375ade 100644 --- a/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/V4TransactionTests.kt +++ b/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/V4TransactionTests.kt @@ -221,7 +221,7 @@ class V4TransactionTests : NetworkTests() { fun validateLimitOrderDefaults(payload: HumanReadablePlaceOrderPayload) { assertEquals(payload.execution, "DEFAULT") assertEquals(payload.timeInForce, null) - assertEquals(payload.goodTilTimeInSeconds, 2419200) + assertEquals(payload.goodTilTimeInSeconds, 7776000) assertEquals(payload.reduceOnly, true) assertEquals(payload.postOnly, false) } diff --git a/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/v2/V4TransactionTests.kt b/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/v2/V4TransactionTests.kt index 405ca6a56..207ef6ae8 100644 --- a/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/v2/V4TransactionTests.kt +++ b/src/commonTest/kotlin/exchange.dydx.abacus/app/manager/v2/V4TransactionTests.kt @@ -244,7 +244,7 @@ class V4TransactionTests : NetworkTests() { fun validateLimitOrderDefaults(payload: HumanReadablePlaceOrderPayload) { assertEquals(payload.execution, "DEFAULT") assertEquals(payload.timeInForce, null) - assertEquals(payload.goodTilTimeInSeconds, 2419200) + assertEquals(payload.goodTilTimeInSeconds, 7776000) assertEquals(payload.reduceOnly, true) assertEquals(payload.postOnly, false) }