diff --git a/client/webserver/api.go b/client/webserver/api.go
index 0afec8a7c7..95eb631b95 100644
--- a/client/webserver/api.go
+++ b/client/webserver/api.go
@@ -2008,7 +2008,7 @@ func (s *WebServer) apiStartMarketMakingBot(w http.ResponseWriter, r *http.Reque
return
}
if err = s.mm.StartBot(form.Config, nil, appPW, true); err != nil {
- s.writeAPIError(w, fmt.Errorf("error starting market making: %v", err))
+ s.writeAPIError(w, err)
return
}
diff --git a/client/webserver/jsintl.go b/client/webserver/jsintl.go
index 1a595603c9..49d9f5a6ff 100644
--- a/client/webserver/jsintl.go
+++ b/client/webserver/jsintl.go
@@ -160,71 +160,188 @@ const (
tradingTierUpdateddID = "TRADING_TIER_UPDATED"
invalidTierValueID = "INVALID_TIER_VALUE"
invalidCompsValueID = "INVALID_COMPS_VALUE"
- txTypeUnknownID = "TX_TYPE_UNKNOWN"
- txTypeSendID = "TX_TYPE_SEND"
- txTypeReceiveID = "TX_TYPE_RECEIVE"
- txTypeSwapID = "TX_TYPE_SWAP"
- txTypeRedeemID = "TX_TYPE_REDEEM"
- txTypeRefundID = "TX_TYPE_REFUND"
- txTypeSplitID = "TX_TYPE_SPLIT"
- txTypeCreateBondID = "TX_TYPE_CREATE_BOND"
- txTypeRedeemBondID = "TX_TYPE_REDEEM_BOND"
- txTypeApproveTokenID = "TX_TYPE_APPROVE_TOKEN"
- txTypeAccelerationID = "TX_TYPE_ACCELERATION"
- txTypeSelfTransferID = "TX_TYPE_SELF_TRANSFER"
- txTypeRevokeTokenApprovalID = "TX_TYPE_REVOKE_TOKEN_APPROVAL"
- txTypeTicketPurchaseID = "TX_TYPE_TICKET_PURCHASE"
- txTypeTicketVoteID = "TX_TYPE_TICKET_VOTE"
- txTypeTicketRevokeID = "TX_TYPE_TICKET_REVOCATION"
- txTypeSwapOrSendID = "TX_TYPE_SWAP_OR_SEND"
- txTypeMixID = "TX_TYPE_MIX"
- txTypeBridgeInitiationID = "TX_TYPE_BRIDGE_INITIATION"
- txTypeBridgeCompletionID = "TX_TYPE_BRIDGE_COMPLETION"
- swapOrSendTooltipID = "SWAP_OR_SEND_TOOLTIP"
- missingCexCredsID = "MISSING_CEX_CREDS"
- matchBufferID = "MATCH_BUFFER"
- noPlacementsID = "NO_PLACEMENTS"
- invalidValueID = "INVALID_VALUE"
- noZeroID = "NO_ZERO"
- botTypeBasicMMID = "BOTTYPE_BASIC_MM"
- botTypeArbMMID = "BOTTYPE_ARB_MM"
- botTypeSimpleArbID = "BOTTYPE_SIMPLE_ARB"
- botTypeNoneID = "NO_BOTTYPE"
- noCexID = "NO_CEX"
- cexBalanceErrID = "CEXBALANCE_ERR"
- pendingID = "PENDING"
- completeID = "COMPLETE"
- archivedSettingsID = "ARCHIVED_SETTINGS"
- idTransparent = "TRANSPARENT"
- idNoCodeProvided = "NO_CODE_PROVIDED"
- enableAccount = "ENABLE_ACCOUNT"
- disableAccount = "DISABLE_ACCOUNT"
- accountDisabledMsg = "ACCOUNT_DISABLED_MSG"
- dexDisabledMsg = "DEX_DISABLED_MSG"
- idWalletNotSynced = "WALLET_NOT_SYNCED"
- idWalletNoPeers = "WALLET_NO_PEERS"
- idDepositError = "DEPOSIT_ERROR"
- idWithdrawError = "WITHDRAW_ERROR"
- idDEXUnderfunded = "DEX_UNDERFUNDED"
- idCEXUnderfunded = "CEX_UNDERFUNDED"
- idCEXTooShallow = "CEX_TOO_SHALLOW"
- idAccountSuspended = "ACCOUNT_SUSPENDED"
- idUserLimitTooLow = "USER_LIMIT_TOO_LOW"
- idNoPriceSource = "NO_PRICE_SOURCE"
- idCEXOrderbookUnsynced = "CEX_ORDERBOOK_UNSYNCED"
- idDeterminePlacementsError = "DETERMINE_PLACEMENTS_ERROR"
- idPlaceBuyOrdersError = "PLACE_BUY_ORDERS_ERROR"
- idPlaceSellOrdersError = "PLACE_SELL_ORDERS_ERROR"
- idCEXTradeError = "CEX_TRADE_ERROR"
- idOrderReportTitle = "ORDER_REPORT_TITLE"
- idCEXBalances = "CEX_BALANCES"
- idCausesSelfMatch = "CAUSES_SELF_MATCH"
- idCexNotConnected = "CEX_NOT_CONNECTED"
- idDeleteBot = "DELETE_BOT"
- idMarketOrderCapitalize = "MARKET_ORDER_CAPITALIZE"
- idLimitOrderCapitalize = "LIMIT_ORDER_CAPITALIZE"
- idInsuffRedeemFundsErrMsg = "INSUFFICIENT_REDEEM_FUNDS_ERR_MSG"
- idInsuffRedeemFundsBundErrMsg = "INSUFFICIENT_REDEEM_FUNDS_BUNDLER_ERR_MSG"
+
+ // MM Settings translations
+ mmStartBotID = "MM_START_BOT"
+ mmSaveSettingsID = "MM_SAVE_SETTINGS"
+ mmDeleteBotID = "MM_DELETE_BOT"
+ mmUpdateRunningBotID = "MM_UPDATE_RUNNING_BOT"
+ mmConfirmDeleteID = "MM_CONFIRM_DELETE"
+ mmCancelID = "MM_CANCEL"
+ mmDeleteID = "MM_DELETE"
+ mmCloseID = "MM_CLOSE"
+ mmErrorID = "MM_ERROR"
+ mmPlacementsID = "MM_PLACEMENTS"
+ mmAllocationsID = "MM_ALLOCATIONS"
+ mmSettingsID = "MM_SETTINGS"
+ mmRebalanceSettingsID = "MM_REBALANCE_SETTINGS"
+ mmBasicMarketMakerID = "MM_BASIC_MARKET_MAKER"
+ mmMMPlusArbID = "MM_MM_PLUS_ARB"
+ mmBasicArbitrageID = "MM_BASIC_ARBITRAGE"
+ mmUnknownID = "MM_UNKNOWN"
+ mmConfigureID = "MM_CONFIGURE"
+ mmFixErrorsID = "MM_FIX_ERRORS"
+ mmMarketNotAvailableID = "MM_MARKET_NOT_AVAILABLE"
+ mmSelectMarketID = "MM_SELECT_MARKET"
+ mmSearchMarketsID = "MM_SEARCH_MARKETS"
+ mmMarketHeaderID = "MM_MARKET_HEADER"
+ mmHostHeaderID = "MM_HOST_HEADER"
+ mmArbHeaderID = "MM_ARB_HEADER"
+ mmChooseBotID = "MM_CHOOSE_BOT"
+ mmNextID = "MM_NEXT"
+ mmSubmitID = "MM_SUBMIT"
+ mmWalletSettingsID = "MM_WALLET_SETTINGS"
+ mmBaseWalletID = "MM_BASE_WALLET"
+ mmQuoteWalletID = "MM_QUOTE_WALLET"
+ mmKnobsID = "MM_KNOBS"
+ mmDriftToleranceID = "MM_DRIFT_TOLERANCE"
+ mmDriftToleranceTooltipID = "MM_DRIFT_TOLERANCE_TOOLTIP"
+ mmOrderPersistenceID = "MM_ORDER_PERSISTENCE"
+ mmOrderPersistenceTooltipID = "MM_ORDER_PERSISTENCE_TOOLTIP"
+ mmEpochsID = "MM_EPOCHS"
+ mmMultiHopArbID = "MM_MULTI_HOP_ARB"
+ mmIntermediateAssetID = "MM_INTERMEDIATE_ASSET"
+ mmIntermediateAssetTooltipID = "MM_INTERMEDIATE_ASSET_TOOLTIP"
+ mmCompletionOrderTypeID = "MM_COMPLETION_ORDER_TYPE"
+ mmCompletionOrderTypeTooltipID = "MM_COMPLETION_ORDER_TYPE_TOOLTIP"
+ mmMarketOrderCapitalizeID = "MM_MARKET_ORDER_CAPITALIZE"
+ mmLimitOrderCapitalizeID = "MM_LIMIT_ORDER_CAPITALIZE"
+ mmLimitBufferID = "MM_LIMIT_BUFFER"
+ mmLimitBufferTooltipID = "MM_LIMIT_BUFFER_TOOLTIP"
+ mmGapStrategyID = "MM_GAP_STRATEGY"
+ mmPercentPlusID = "MM_PERCENT_PLUS"
+ mmPercentID = "MM_PERCENT"
+ mmAbsolutePlusID = "MM_ABSOLUTE_PLUS"
+ mmAbsoluteID = "MM_ABSOLUTE"
+ mmMultiplierID = "MM_MULTIPLIER"
+ mmFactorLabelPercentID = "MM_FACTOR_LABEL_PERCENT"
+ mmFactorLabelRateID = "MM_FACTOR_LABEL_RATE"
+ mmFactorLabelMultiplierID = "MM_FACTOR_LABEL_MULTIPLIER"
+ mmProfitThresholdID = "MM_PROFIT_THRESHOLD"
+ mmProfitThresholdDescID = "MM_PROFIT_THRESHOLD_DESC"
+ mmPriceLevelsPerSideID = "MM_PRICE_LEVELS_PER_SIDE"
+ mmLotsPerLevelID = "MM_LOTS_PER_LEVEL"
+ mmUSDPerSideID = "MM_USD_PER_SIDE"
+ mmPriceIncrementID = "MM_PRICE_INCREMENT"
+ mmMatchBufferID = "MM_MATCH_BUFFER"
+ mmQuickPlacementsID = "MM_QUICK_PLACEMENTS"
+ mmAdvancedPlacementsID = "MM_ADVANCED_PLACEMENTS"
+ mmQuickConfigID = "MM_QUICK_CONFIG"
+ mmAdvancedConfigID = "MM_ADVANCED_CONFIG"
+ mmPlacementsDescriptionID = "MM_PLACEMENTS_DESCRIPTION"
+ mmQuickAllocationID = "MM_QUICK_ALLOCATION"
+ mmManualAllocationID = "MM_MANUAL_ALLOCATION"
+ mmTradedAmountID = "MM_TRADED_AMOUNT"
+ mmSwapFeesID = "MM_SWAP_FEES"
+ mmRedeemFeesID = "MM_REDEEM_FEES"
+ mmRefundFeesID = "MM_REFUND_FEES"
+ mmFundingFeesID = "MM_FUNDING_FEES"
+ mmSlippageBufferID = "MM_SLIPPAGE_BUFFER"
+ mmMultiSplitBufferID = "MM_MULTI_SPLIT_BUFFER"
+ mmInitialBuyFundingFeesID = "MM_INITIAL_BUY_FUNDING_FEES"
+ mmInitialSellFundingFeesID = "MM_INITIAL_SELL_FUNDING_FEES"
+ mmBridgeFeeReservesID = "MM_BRIDGE_FEE_RESERVES"
+ mmTotalRequiredID = "MM_TOTAL_REQUIRED"
+ mmAlreadyAllocatedID = "MM_ALREADY_ALLOCATED"
+ mmAvailableToUnallocateID = "MM_AVAILABLE_TO_UNALLOCATE"
+ mmTotalAvailableID = "MM_TOTAL_AVAILABLE"
+ mmAmountAllocatedID = "MM_AMOUNT_ALLOCATED"
+ mmBuyBufferID = "MM_BUY_BUFFER"
+ mmSellBufferID = "MM_SELL_BUFFER"
+ mmBuyFeeReserveID = "MM_BUY_FEE_RESERVE"
+ mmSellFeeReserveID = "MM_SELL_FEE_RESERVE"
+ mmBridgeFeeReserveID = "MM_BRIDGE_FEE_RESERVE"
+ mmWithdrawalID = "MM_WITHDRAWAL"
+ mmDepositID = "MM_DEPOSIT"
+ mmFailedSaveBotConfigID = "MM_FAILED_SAVE_BOT_CONFIG"
+ mmMinTransferID = "MM_MIN_TRANSFER"
+ mmMinTransferTooltipID = "MM_MIN_TRANSFER_TOOLTIP"
+ mmFailedFetchBridgeFeesID = "MM_FAILED_FETCH_BRIDGE_FEES"
+ mmBridgeConfigurationID = "MM_BRIDGE_CONFIGURATION"
+ mmBridgeConfigTooltipID = "MM_BRIDGE_CONFIG_TOOLTIP"
+ mmBridgeToAssetID = "MM_BRIDGE_TO_ASSET"
+ mmSelectCEXAssetID = "MM_SELECT_CEX_ASSET"
+ mmBridgeID = "MM_BRIDGE"
+ mmSelectBridgeID = "MM_SELECT_BRIDGE"
+ mmBridgeFeesID = "MM_BRIDGE_FEES"
+ mmRebalanceMethodID = "MM_REBALANCE_METHOD"
+ mmCEXRebalanceID = "MM_CEX_REBALANCE"
+ mmCEXRebalanceDescID = "MM_CEX_REBALANCE_DESC"
+ mmInternalTransfersOnlyID = "MM_INTERNAL_TRANSFERS_ONLY"
+ mmInternalTransfersDescID = "MM_INTERNAL_TRANSFERS_DESC"
+ mmRebalanceDescriptionID = "MM_REBALANCE_DESCRIPTION"
+ mmFailedStartBotID = "MM_FAILED_START_BOT"
+ mmLoadingID = "MM_LOADING"
+ mmRemovePlacementID = "MM_REMOVE_PLACEMENT"
+ mmMoveUpID = "MM_MOVE_UP"
+ mmMoveDownID = "MM_MOVE_DOWN"
+ mmAddPlacementID = "MM_ADD_PLACEMENT"
+
+ txTypeUnknownID = "TX_TYPE_UNKNOWN"
+ txTypeSendID = "TX_TYPE_SEND"
+ txTypeReceiveID = "TX_TYPE_RECEIVE"
+ txTypeSwapID = "TX_TYPE_SWAP"
+ txTypeRedeemID = "TX_TYPE_REDEEM"
+ txTypeRefundID = "TX_TYPE_REFUND"
+ txTypeSplitID = "TX_TYPE_SPLIT"
+ txTypeCreateBondID = "TX_TYPE_CREATE_BOND"
+ txTypeRedeemBondID = "TX_TYPE_REDEEM_BOND"
+ txTypeApproveTokenID = "TX_TYPE_APPROVE_TOKEN"
+ txTypeAccelerationID = "TX_TYPE_ACCELERATION"
+ txTypeSelfTransferID = "TX_TYPE_SELF_TRANSFER"
+ txTypeRevokeTokenApprovalID = "TX_TYPE_REVOKE_TOKEN_APPROVAL"
+ txTypeTicketPurchaseID = "TX_TYPE_TICKET_PURCHASE"
+ txTypeTicketVoteID = "TX_TYPE_TICKET_VOTE"
+ txTypeTicketRevokeID = "TX_TYPE_TICKET_REVOCATION"
+ txTypeSwapOrSendID = "TX_TYPE_SWAP_OR_SEND"
+ txTypeMixID = "TX_TYPE_MIX"
+ txTypeBridgeInitiationID = "TX_TYPE_BRIDGE_INITIATION"
+ txTypeBridgeCompletionID = "TX_TYPE_BRIDGE_COMPLETION"
+ swapOrSendTooltipID = "SWAP_OR_SEND_TOOLTIP"
+ missingCexCredsID = "MISSING_CEX_CREDS"
+ matchBufferID = "MATCH_BUFFER"
+ noPlacementsID = "NO_PLACEMENTS"
+ invalidValueID = "INVALID_VALUE"
+ noZeroID = "NO_ZERO"
+ botTypeBasicMMID = "BOTTYPE_BASIC_MM"
+ botTypeArbMMID = "BOTTYPE_ARB_MM"
+ botTypeSimpleArbID = "BOTTYPE_SIMPLE_ARB"
+ botTypeNoneID = "NO_BOTTYPE"
+ noCexID = "NO_CEX"
+ cexBalanceErrID = "CEXBALANCE_ERR"
+ pendingID = "PENDING"
+ completeID = "COMPLETE"
+ archivedSettingsID = "ARCHIVED_SETTINGS"
+ idTransparent = "TRANSPARENT"
+ idNoCodeProvided = "NO_CODE_PROVIDED"
+ enableAccount = "ENABLE_ACCOUNT"
+ disableAccount = "DISABLE_ACCOUNT"
+ accountDisabledMsg = "ACCOUNT_DISABLED_MSG"
+ dexDisabledMsg = "DEX_DISABLED_MSG"
+ idWalletNotSynced = "WALLET_NOT_SYNCED"
+ idWalletNoPeers = "WALLET_NO_PEERS"
+ idDepositError = "DEPOSIT_ERROR"
+ idWithdrawError = "WITHDRAW_ERROR"
+ idDEXUnderfunded = "DEX_UNDERFUNDED"
+ idCEXUnderfunded = "CEX_UNDERFUNDED"
+ idCEXTooShallow = "CEX_TOO_SHALLOW"
+ idAccountSuspended = "ACCOUNT_SUSPENDED"
+ idUserLimitTooLow = "USER_LIMIT_TOO_LOW"
+ idNoPriceSource = "NO_PRICE_SOURCE"
+ idCEXOrderbookUnsynced = "CEX_ORDERBOOK_UNSYNCED"
+ idDeterminePlacementsError = "DETERMINE_PLACEMENTS_ERROR"
+ idPlaceBuyOrdersError = "PLACE_BUY_ORDERS_ERROR"
+ idPlaceSellOrdersError = "PLACE_SELL_ORDERS_ERROR"
+ idCEXTradeError = "CEX_TRADE_ERROR"
+ idOrderReportTitle = "ORDER_REPORT_TITLE"
+ idCEXBalances = "CEX_BALANCES"
+ idCausesSelfMatch = "CAUSES_SELF_MATCH"
+ idCexNotConnected = "CEX_NOT_CONNECTED"
+ idDeleteBot = "DELETE_BOT"
+ idMarketOrderCapitalize = "MARKET_ORDER_CAPITALIZE"
+ idLimitOrderCapitalize = "LIMIT_ORDER_CAPITALIZE"
+ idInsuffRedeemFundsErrMsg = "INSUFFICIENT_REDEEM_FUNDS_ERR_MSG"
+ idInsuffRedeemFundsBundErrMsg = "INSUFFICIENT_REDEEM_FUNDS_BUNDLER_ERR_MSG"
)
var enUS = map[string]*intl.Translation{
@@ -383,72 +500,189 @@ var enUS = map[string]*intl.Translation{
tradingTierUpdateddID: {T: "Trading Tier Updated"},
invalidTierValueID: {T: "Invalid tier value"},
invalidCompsValueID: {T: "Invalid comps value"},
- apiErrorID: {T: "api error: {{ msg }}"},
- txTypeUnknownID: {T: "Unknown"},
- txTypeSendID: {T: "Send"},
- txTypeReceiveID: {T: "Receive"},
- txTypeSwapID: {T: "Swap"},
- txTypeRedeemID: {T: "Redeem"},
- txTypeRefundID: {T: "Refund"},
- txTypeSplitID: {T: "Split"},
- txTypeCreateBondID: {T: "Create bond"},
- txTypeRedeemBondID: {T: "Redeem bond"},
- txTypeApproveTokenID: {T: "Approve token"},
- txTypeAccelerationID: {T: "Acceleration"},
- txTypeSelfTransferID: {T: "Self transfer"},
- txTypeRevokeTokenApprovalID: {T: "Revoke token approval"},
- txTypeTicketPurchaseID: {T: "Ticket purchase"},
- txTypeTicketVoteID: {T: "Ticket vote"},
- txTypeTicketRevokeID: {T: "Ticket revocation"},
- txTypeSwapOrSendID: {T: "Swap / Send"},
- txTypeMixID: {T: "Mix"},
- txTypeBridgeInitiationID: {T: "Bridge initiation"},
- txTypeBridgeCompletionID: {T: "Bridge completion"},
- swapOrSendTooltipID: {T: "The wallet was unable to determine if this transaction was a swap or a send."},
- missingCexCredsID: {T: "specify both key and secret"},
- matchBufferID: {T: "Match buffer"},
- noPlacementsID: {T: "must specify 1 or more placements"},
- invalidValueID: {T: "invalid value"},
- noZeroID: {T: "zero not allowed"},
- botTypeBasicMMID: {T: "Market Maker"},
- botTypeArbMMID: {T: "Market Maker + Arbitrage"},
- botTypeSimpleArbID: {Version: 1, T: "Arbitrage"},
- botTypeNoneID: {T: "choose a bot type"},
- noCexID: {T: "choose an exchange for arbitrage"},
- cexBalanceErrID: {T: "error fetching {{ cexName }} balance for {{ assetID }}: {{ err }}"},
- pendingID: {T: "Pending"},
- completeID: {T: "Complete"},
- archivedSettingsID: {T: "Archived Settings"},
- idTransparent: {T: "Transparent"},
- idNoCodeProvided: {T: "no code provided"},
- enableAccount: {T: "Enable Account"},
- disableAccount: {T: "Disable Account"},
- accountDisabledMsg: {T: "account disabled - re-enable to update settings"},
- dexDisabledMsg: {T: "DEX server is disabled. Visit the settings page to enable and connect to this server."},
- idWalletNotSynced: {T: "{{ assetSymbol }} wallet not synced."},
- idWalletNoPeers: {T: "{{ assetSymbol }} wallet has no peers."},
- idDepositError: {T: "The last attempted deposit of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"},
- idWithdrawError: {T: "The last attempted withdrawal of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"},
- idDEXUnderfunded: {T: "The {{ assetSymbol }} wallet is underfunded by {{ amount }}"},
- idCEXUnderfunded: {T: "The {{ cexName }} {{ assetSymbol }} wallet is underfunded by {{ amount }}"},
- idCEXTooShallow: {T: "The {{ cexName }} market on the {{ side }} side is too shallow for arbitrages as specified by the configuration."},
- idAccountSuspended: {T: "Your account at {{ dexHost }} is suspended."},
- idUserLimitTooLow: {T: "Your account at {{ dexHost }} has a limit too low to place all the orders required by the configuration."},
- idNoPriceSource: {T: "No oracle or fiat rate sources are available for this market."},
- idCEXOrderbookUnsynced: {T: "The {{ cexName }} orderbook is not synced."},
- idDeterminePlacementsError: {T: "Error determining placements: {{ error }}"},
- idPlaceBuyOrdersError: {T: "Error placing buy orders: {{ error }}"},
- idPlaceSellOrdersError: {T: "Error placing sell orders: {{ error }}"},
- idCEXTradeError: {T: "The last attempted CEX trade at {{ time }} failed with the following error: {{ error }}"},
- idOrderReportTitle: {T: "{{ side }} orders report for epoch #{{ epochNum }}"},
- idCEXBalances: {T: "{{ cexName }} Balances"},
- idCausesSelfMatch: {T: "This order would cause a self-match"},
- idCexNotConnected: {T: "{{ cexName }} not connected"},
- idDeleteBot: {T: "Are you sure you want to delete this bot for the {{ baseTicker }}-{{ quoteTicker }} market on {{ host }}?"},
- idMarketOrderCapitalize: {T: "Market"},
- idLimitOrderCapitalize: {T: "Limit"},
- idInsuffRedeemFundsErrMsg: {T: "Insufficient gas for redemption. Configure an ERC-4337 bundler to do a gasless redemption."},
- idInsuffRedeemFundsBundErrMsg: {T: "Redemption lot size is too small to cover the gas fees in a gasless redemption."},
+
+ // MM Settings translations
+ mmStartBotID: {T: "Start Bot"},
+ mmSaveSettingsID: {T: "Save Settings"},
+ mmDeleteBotID: {T: "Delete Bot"},
+ mmUpdateRunningBotID: {T: "Update Running Bot"},
+ mmConfirmDeleteID: {T: "Are you sure you want to delete this bot?"},
+ mmCancelID: {T: "Cancel"},
+ mmDeleteID: {T: "Delete"},
+ mmCloseID: {T: "Close"},
+ mmErrorID: {T: "Error"},
+ mmPlacementsID: {T: "Placements"},
+ mmAllocationsID: {T: "Allocations"},
+ mmSettingsID: {T: "Settings"},
+ mmRebalanceSettingsID: {T: "Rebalance Settings"},
+ mmBasicMarketMakerID: {T: "Basic Market Maker"},
+ mmMMPlusArbID: {T: "MM + Arbitrage"},
+ mmBasicArbitrageID: {T: "Basic Arbitrage"},
+ mmUnknownID: {T: "Unknown"},
+ mmConfigureID: {T: "Configure"},
+ mmFixErrorsID: {T: "Fix errors"},
+ mmMarketNotAvailableID: {T: "Market not available"},
+ mmSelectMarketID: {T: "Select a Market"},
+ mmSearchMarketsID: {T: "Search markets..."},
+ mmMarketHeaderID: {T: "Market"},
+ mmHostHeaderID: {T: "Host"},
+ mmArbHeaderID: {T: "Arb"},
+ mmChooseBotID: {T: "Choose Your Bot"},
+ mmNextID: {T: "Next"},
+ mmSubmitID: {T: "Submit"},
+ mmWalletSettingsID: {T: "Wallet Settings"},
+ mmBaseWalletID: {T: "Base Wallet"},
+ mmQuoteWalletID: {T: "Quote Wallet"},
+ mmKnobsID: {T: "Knobs"},
+ mmDriftToleranceID: {T: "Drift Tolerance"},
+ mmDriftToleranceTooltipID: {T: "Maximum allowed price deviation before repositioning orders"},
+ mmOrderPersistenceID: {T: "Order Persistence"},
+ mmOrderPersistenceTooltipID: {T: "Number of epochs to keep unfilled orders active"},
+ mmEpochsID: {T: "epochs"},
+ mmMultiHopArbID: {T: "Multi-Hop Arbitrage"},
+ mmIntermediateAssetID: {T: "Intermediate Asset"},
+ mmIntermediateAssetTooltipID: {T: "Asset to use for multi-hop arbitrage"},
+ mmCompletionOrderTypeID: {T: "Completion Order Type"},
+ mmCompletionOrderTypeTooltipID: {T: "This specifies the type of order to execute on the second leg of a multi-hop arb. Market orders will always be filled, ensuring that the bot never has any funds stuck in the intermediate asset, but may result in losses if the price suddenly moves against the bot."},
+ mmMarketOrderCapitalizeID: {T: "Market"},
+ mmLimitOrderCapitalizeID: {T: "Limit"},
+ mmLimitBufferID: {T: "Limit Buffer"},
+ mmLimitBufferTooltipID: {T: "This specifies the buffer to apply to the limit order rate for the second leg of a multi-hop arb. The buffer will make the rate 'worse' (lower for sell orders, higher for buy orders) resulting in a higher probability of the trade being filled in order to avoid having funds stuck in the intermediate asset."},
+ mmGapStrategyID: {T: "Gap Strategy"},
+ mmPercentPlusID: {T: "Percent Plus"},
+ mmPercentID: {T: "Percent"},
+ mmAbsolutePlusID: {T: "Absolute Plus"},
+ mmAbsoluteID: {T: "Absolute"},
+ mmMultiplierID: {T: "Multiplier"},
+ mmFactorLabelPercentID: {T: "Percent"},
+ mmFactorLabelRateID: {T: "Rate"},
+ mmFactorLabelMultiplierID: {T: "Multiplier"},
+ mmProfitThresholdID: {T: "Profit Threshold"},
+ mmProfitThresholdDescID: {T: "Minimum profit required for arbitrage opportunities."},
+ mmPriceLevelsPerSideID: {T: "Price levels per side"},
+ mmLotsPerLevelID: {T: "Lots per level"},
+ mmUSDPerSideID: {T: "USD per side"},
+ mmPriceIncrementID: {T: "Price increment"},
+ mmMatchBufferID: {T: "Match buffer"},
+ mmQuickPlacementsID: {T: "Quick Placements"},
+ mmAdvancedPlacementsID: {T: "Advanced Placements"},
+ mmQuickConfigID: {T: "Quick config"},
+ mmAdvancedConfigID: {T: "Advanced config"},
+ mmPlacementsDescriptionID: {T: "Configure the price levels of the placements on both sides of the order book."},
+ mmQuickAllocationID: {T: "Quick Allocation"},
+ mmManualAllocationID: {T: "Manual Allocation"},
+ mmTradedAmountID: {T: "Traded Amount"},
+ mmSwapFeesID: {T: "Swap Fees"},
+ mmRedeemFeesID: {T: "Redeem Fees"},
+ mmRefundFeesID: {T: "Refund Fees"},
+ mmFundingFeesID: {T: "Funding Fees"},
+ mmSlippageBufferID: {T: "Slippage Buffer"},
+ mmMultiSplitBufferID: {T: "Multi-Split Buffer"},
+ mmInitialBuyFundingFeesID: {T: "Initial Buy Funding Fees"},
+ mmInitialSellFundingFeesID: {T: "Initial Sell Funding Fees"},
+ mmBridgeFeeReservesID: {T: "Bridge Fee Reserves"},
+ mmTotalRequiredID: {T: "Total Required"},
+ mmAlreadyAllocatedID: {T: "Already Allocated"},
+ mmAvailableToUnallocateID: {T: "Available To Unallocate"},
+ mmTotalAvailableID: {T: "Total Available"},
+ mmAmountAllocatedID: {T: "Amount Allocated"},
+ mmBuyBufferID: {T: "Buy Buffer"},
+ mmSellBufferID: {T: "Sell Buffer"},
+ mmBuyFeeReserveID: {T: "Buy Fee Reserve"},
+ mmSellFeeReserveID: {T: "Sell Fee Reserve"},
+ mmBridgeFeeReserveID: {T: "Bridge Fee Reserve"},
+ mmWithdrawalID: {T: "Withdrawal"},
+ mmDepositID: {T: "Deposit"},
+ mmFailedSaveBotConfigID: {T: "Failed to save bot config:"},
+ mmMinTransferID: {T: "Min Transfer"},
+ mmMinTransferTooltipID: {T: "Minimum {{ asset }} asset amount for transfers"},
+ mmFailedFetchBridgeFeesID: {T: "Failed to fetch bridge fees and limits"},
+ mmBridgeConfigurationID: {T: "{{ asset }} Bridge Configuration"},
+ mmBridgeConfigTooltipID: {T: "The {{ asset }} asset cannot be directly transferred between Bison Wallet and the CEX. It must be bridged before deposits and after withdrawals."},
+ mmBridgeToAssetID: {T: "Bridge to Asset:"},
+ mmSelectCEXAssetID: {T: "Select CEX Asset"},
+ mmBridgeID: {T: "Bridge:"},
+ mmSelectBridgeID: {T: "Select Bridge"},
+ mmBridgeFeesID: {T: "Bridge Fees"},
+ mmRebalanceMethodID: {T: "Rebalance Method"},
+ mmCEXRebalanceID: {T: "CEX Rebalance"},
+ mmCEXRebalanceDescID: {T: "Automatically rebalance funds between DEX and CEX"},
+ mmInternalTransfersOnlyID: {T: "Internal Transfers Only"},
+ mmInternalTransfersDescID: {T: "Only use internal wallet transfers for rebalancing"},
+ mmRebalanceDescriptionID: {T: "Configure settings related to rebalancing between Bison Wallet and a CEX. If all of the required placements cannot be made, the bot will automatically transfer funds in order to be able to make the maximum amount of placements."},
+ mmFailedStartBotID: {T: "Failed to start bot:"},
+ mmLoadingID: {T: "Loading..."},
+ mmRemovePlacementID: {T: "Remove placement"},
+ mmMoveUpID: {T: "Move up"},
+ mmMoveDownID: {T: "Move down"},
+ mmAddPlacementID: {T: "Add placement"},
+
+ apiErrorID: {T: "api error: {{ msg }}"},
+ txTypeUnknownID: {T: "Unknown"},
+ txTypeSendID: {T: "Send"},
+ txTypeReceiveID: {T: "Receive"},
+ txTypeSwapID: {T: "Swap"},
+ txTypeRedeemID: {T: "Redeem"},
+ txTypeRefundID: {T: "Refund"},
+ txTypeSplitID: {T: "Split"},
+ txTypeCreateBondID: {T: "Create bond"},
+ txTypeRedeemBondID: {T: "Redeem bond"},
+ txTypeApproveTokenID: {T: "Approve token"},
+ txTypeAccelerationID: {T: "Acceleration"},
+ txTypeSelfTransferID: {T: "Self transfer"},
+ txTypeRevokeTokenApprovalID: {T: "Revoke token approval"},
+ txTypeTicketPurchaseID: {T: "Ticket purchase"},
+ txTypeTicketVoteID: {T: "Ticket vote"},
+ txTypeTicketRevokeID: {T: "Ticket revocation"},
+ txTypeSwapOrSendID: {T: "Swap / Send"},
+ txTypeMixID: {T: "Mix"},
+ txTypeBridgeInitiationID: {T: "Bridge initiation"},
+ txTypeBridgeCompletionID: {T: "Bridge completion"},
+ swapOrSendTooltipID: {T: "The wallet was unable to determine if this transaction was a swap or a send."},
+ missingCexCredsID: {T: "specify both key and secret"},
+ matchBufferID: {T: "Match buffer"},
+ noPlacementsID: {T: "must specify 1 or more placements"},
+ invalidValueID: {T: "invalid value"},
+ noZeroID: {T: "zero not allowed"},
+ botTypeBasicMMID: {T: "Market Maker"},
+ botTypeArbMMID: {T: "Market Maker + Arbitrage"},
+ botTypeSimpleArbID: {Version: 1, T: "Arbitrage"},
+ botTypeNoneID: {T: "choose a bot type"},
+ noCexID: {T: "choose an exchange for arbitrage"},
+ cexBalanceErrID: {T: "error fetching {{ cexName }} balance for {{ assetID }}: {{ err }}"},
+ pendingID: {T: "Pending"},
+ completeID: {T: "Complete"},
+ archivedSettingsID: {T: "Archived Settings"},
+ idTransparent: {T: "Transparent"},
+ idNoCodeProvided: {T: "no code provided"},
+ enableAccount: {T: "Enable Account"},
+ disableAccount: {T: "Disable Account"},
+ accountDisabledMsg: {T: "account disabled - re-enable to update settings"},
+ dexDisabledMsg: {T: "DEX server is disabled. Visit the settings page to enable and connect to this server."},
+ idWalletNotSynced: {T: "{{ assetSymbol }} wallet not synced."},
+ idWalletNoPeers: {T: "{{ assetSymbol }} wallet has no peers."},
+ idDepositError: {T: "The last attempted deposit of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"},
+ idWithdrawError: {T: "The last attempted withdrawal of {{ assetSymbol }} at {{ time }} failed with the following error: {{ error }}"},
+ idDEXUnderfunded: {T: "The {{ assetSymbol }} wallet is underfunded by {{ amount }}"},
+ idCEXUnderfunded: {T: "The {{ cexName }} {{ assetSymbol }} wallet is underfunded by {{ amount }}"},
+ idCEXTooShallow: {T: "The {{ cexName }} market on the {{ side }} side is too shallow for arbitrages as specified by the configuration."},
+ idAccountSuspended: {T: "Your account at {{ dexHost }} is suspended."},
+ idUserLimitTooLow: {T: "Your account at {{ dexHost }} has a limit too low to place all the orders required by the configuration."},
+ idNoPriceSource: {T: "No oracle or fiat rate sources are available for this market."},
+ idCEXOrderbookUnsynced: {T: "The {{ cexName }} orderbook is not synced."},
+ idDeterminePlacementsError: {T: "Error determining placements: {{ error }}"},
+ idPlaceBuyOrdersError: {T: "Error placing buy orders: {{ error }}"},
+ idPlaceSellOrdersError: {T: "Error placing sell orders: {{ error }}"},
+ idCEXTradeError: {T: "The last attempted CEX trade at {{ time }} failed with the following error: {{ error }}"},
+ idOrderReportTitle: {T: "{{ side }} orders report for epoch #{{ epochNum }}"},
+ idCEXBalances: {T: "{{ cexName }} Balances"},
+ idCausesSelfMatch: {T: "This order would cause a self-match"},
+ idCexNotConnected: {T: "{{ cexName }} not connected"},
+ idDeleteBot: {T: "Are you sure you want to delete this bot for the {{ baseTicker }}-{{ quoteTicker }} market on {{ host }}?"},
+ idMarketOrderCapitalize: {T: "Market"},
+ idLimitOrderCapitalize: {T: "Limit"},
+ idInsuffRedeemFundsErrMsg: {T: "Insufficient gas for redemption. Configure an ERC-4337 bundler to do a gasless redemption."},
+ idInsuffRedeemFundsBundErrMsg: {T: "Redemption lot size is too small to cover the gas fees in a gasless redemption."},
}
var ptBR = map[string]*intl.Translation{
@@ -503,6 +737,8 @@ var ptBR = map[string]*intl.Translation{
availableID: {T: "disponível"},
immatureID: {T: "imaturo"},
maxID: {T: "ma"},
+ mmWalletSettingsID: {T: "Configurações da Carteira"},
+ mmDepositID: {T: "Depositar"},
}
var zhCN = map[string]*intl.Translation{
@@ -719,6 +955,25 @@ var zhCN = map[string]*intl.Translation{
idOrderReportTitle: {T: "{{ side }}订单报告,纪元 #{{ epochNum }}"},
idCEXBalances: {T: "{{ cexName }}余额"},
idCausesSelfMatch: {T: "此订单会导致自匹配"},
+ mmBasicMarketMakerID: {T: "基础做市商"},
+ mmBasicArbitrageID: {T: "基础套利"},
+ mmFixErrorsID: {T: "修复错误"},
+ mmChooseBotID: {T: "选择您的机器人"},
+ mmPlacementsID: {T: "投放"},
+ mmConfigureID: {T: "配置"},
+ mmMarketNotAvailableID: {T: "市场不可用"},
+ mmSelectMarketID: {T: "选择一个市场"},
+ mmWalletSettingsID: {T: "钱包设置"},
+ mmMMPlusArbID: {T: "市场做市 + 套利"},
+ mmRebalanceSettingsID: {T: "再平衡设置"},
+ mmSettingsID: {T: "设置"},
+ mmGapStrategyID: {T: "差距策略"},
+ mmProfitThresholdID: {T: "利润阈值"},
+ mmPriceLevelsPerSideID: {T: "每边价格等级"},
+ mmPriceIncrementID: {T: "价格增量"},
+ mmMatchBufferID: {T: "匹配缓冲区"},
+ mmWithdrawalID: {T: "提取"},
+ mmDepositID: {T: "存款"},
}
var plPL = map[string]*intl.Translation{
@@ -903,6 +1158,13 @@ var plPL = map[string]*intl.Translation{
txTypeUnknownID: {T: "Nieznany"},
walletSyncFinishingID: {T: "na ukończeniu"},
txTypeReceiveID: {T: "Odbiór"},
+ mmConfigureID: {T: "Skonfiguruj"},
+ mmMarketNotAvailableID: {T: "Rynek niedostępny"},
+ mmSelectMarketID: {T: "Wybierz rynek"},
+ mmWalletSettingsID: {T: "Ustawienia portfela"},
+ mmRebalanceSettingsID: {T: "Ustawienia rebalancingu"},
+ mmSettingsID: {T: "Ustawienia"},
+ mmDepositID: {T: "Zdeponuj"},
}
var deDE = map[string]*intl.Translation{
@@ -1121,6 +1383,25 @@ var deDE = map[string]*intl.Translation{
idCausesSelfMatch: {T: "Dieser Auftrag würde ein Self-Match auslösen"},
idCexNotConnected: {T: "{{ cexName }} nicht verbunden"},
idDeleteBot: {T: "Bist du sicher das du den Bot für den {{ baseTicker }}-{{ quoteTicker }} Markt bei {{ host }} löschen möchtest?"},
+ mmChooseBotID: {T: "Wähle deinen Bot"},
+ mmBasicMarketMakerID: {T: "Basic Market Maker"},
+ mmMMPlusArbID: {T: "Market Maker + Arbitrage"},
+ mmBasicArbitrageID: {T: "Basic Arbitrage"},
+ mmFixErrorsID: {T: "Fehler beheben"},
+ mmConfigureID: {T: "Konfigurieren"},
+ mmMarketNotAvailableID: {T: "Markt nicht verfügbar"},
+ mmSelectMarketID: {T: "Wähle einen Markt"},
+ mmPlacementsID: {T: "Platzierungen"},
+ mmWalletSettingsID: {T: "Wallet Einstellungen"},
+ mmRebalanceSettingsID: {T: "Rebalance Einstellungen"},
+ mmSettingsID: {T: "Einstellungen"},
+ mmGapStrategyID: {T: "Gap Strategy"},
+ mmProfitThresholdID: {T: "Profit-Schwelle"},
+ mmPriceLevelsPerSideID: {T: "Preislevels pro Seite"},
+ mmPriceIncrementID: {T: "Preis Steigerung"},
+ mmMatchBufferID: {T: "Match buffer"},
+ mmWithdrawalID: {T: "Auszahlen"},
+ mmDepositID: {T: "Einzahlen"},
}
var ar = map[string]*intl.Translation{
@@ -1305,6 +1586,13 @@ var ar = map[string]*intl.Translation{
ticketStatusMissedID: {T: "مفوتة"},
txFeeSupportedID: {T: "تقدير الرسوم غير مدعوم لهذا النوع من المحفظة"},
orderBttnSellBalErrID: {T: "الرصيد غير كافي للبيع."},
+ mmConfigureID: {T: "التهيئة"},
+ mmMarketNotAvailableID: {T: "السوق غير متوفر"},
+ mmSelectMarketID: {T: "اختر سوقًا"},
+ mmWalletSettingsID: {T: "اعدادات المحفظة"},
+ mmRebalanceSettingsID: {T: "إعدادات إعادة الرصيد لـ"},
+ mmSettingsID: {T: "الإعدادات"},
+ mmDepositID: {T: "إيداع"},
}
var localesMap = map[string]map[string]*intl.Translation{
diff --git a/client/webserver/site/package-lock.json b/client/webserver/site/package-lock.json
index 1990e22a4e..d1cb58ce0c 100644
--- a/client/webserver/site/package-lock.json
+++ b/client/webserver/site/package-lock.json
@@ -8,12 +8,19 @@
"name": "bisonw",
"version": "1.2.0",
"license": "Blue Oak 1.0.0",
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
"devDependencies": {
"@babel/core": "^7.21.8",
"@babel/plugin-transform-runtime": "^7.21.4",
"@babel/preset-env": "^7.21.5",
+ "@babel/preset-react": "^7.22.5",
"@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.21.5",
+ "@types/react": "^18.2.12",
+ "@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"babel-loader": "^9.1.2",
@@ -65,15 +72,14 @@
}
},
"node_modules/@babel/code-frame": {
- "version": "7.26.2",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
- "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
- "picocolors": "^1.0.0"
+ "picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
@@ -119,41 +125,38 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.23.0",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
- "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"dependencies": {
- "@babel/types": "^7.23.0",
- "@jridgewell/gen-mapping": "^0.3.2",
- "@jridgewell/trace-mapping": "^0.3.17",
- "jsesc": "^2.5.1"
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
- "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"dependencies": {
- "@jridgewell/set-array": "^1.0.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.9"
- },
- "engines": {
- "node": ">=6.0.0"
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
- "version": "7.18.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
- "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"dependencies": {
- "@babel/types": "^7.18.6"
+ "@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
@@ -280,6 +283,15 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-hoist-variables": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
@@ -305,12 +317,13 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.21.4",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz",
- "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"dependencies": {
- "@babel/types": "^7.21.4"
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -348,9 +361,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.21.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz",
- "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -428,29 +441,27 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
- "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-option": {
- "version": "7.21.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
- "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -486,13 +497,12 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.26.10",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
- "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+ "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/types": "^7.26.10"
+ "@babel/types": "^7.28.4"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -885,12 +895,12 @@
}
},
"node_modules/@babel/plugin-syntax-jsx": {
- "version": "7.21.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz",
- "integrity": "sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
+ "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"dependencies": {
- "@babel/helper-plugin-utils": "^7.20.2"
+ "@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1385,6 +1395,71 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-react-display-name": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
+ "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz",
+ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
+ "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz",
+ "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-regenerator": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz",
@@ -1667,6 +1742,26 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/preset-react": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz",
+ "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-transform-react-display-name": "^7.27.1",
+ "@babel/plugin-transform-react-jsx": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-development": "^7.27.1",
+ "@babel/plugin-transform-react-pure-annotations": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/preset-typescript": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.21.5.tgz",
@@ -1700,50 +1795,45 @@
}
},
"node_modules/@babel/template": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
- "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.26.2",
- "@babel/parser": "^7.26.9",
- "@babel/types": "^7.26.9"
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.23.2",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
- "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
- "dev": true,
- "dependencies": {
- "@babel/code-frame": "^7.22.13",
- "@babel/generator": "^7.23.0",
- "@babel/helper-environment-visitor": "^7.22.20",
- "@babel/helper-function-name": "^7.23.0",
- "@babel/helper-hoist-variables": "^7.22.5",
- "@babel/helper-split-export-declaration": "^7.22.6",
- "@babel/parser": "^7.23.0",
- "@babel/types": "^7.23.0",
- "debug": "^4.1.0",
- "globals": "^11.1.0"
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
+ "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4",
+ "debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
- "version": "7.26.10",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
- "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
- "license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.25.9",
- "@babel/helper-validator-identifier": "^7.25.9"
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -2091,15 +2181,15 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.14",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
- "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.30",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
+ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -2269,6 +2359,31 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true
},
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.24",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
+ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
+ "dev": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
"node_modules/@types/semver": {
"version": "7.3.13",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
@@ -3748,6 +3863,12 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true
},
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -5837,7 +5958,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5853,15 +5973,15 @@
}
},
"node_modules/jsesc": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
- "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
- "node": ">=4"
+ "node": ">=6"
}
},
"node_modules/json-parse-even-better-errors": {
@@ -6006,6 +6126,17 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"dev": true
},
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -7274,6 +7405,29 @@
"safe-buffer": "^5.1.0"
}
},
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
"node_modules/read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -7737,6 +7891,14 @@
}
}
},
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
"node_modules/schema-utils": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz",
@@ -9248,14 +9410,14 @@
}
},
"@babel/code-frame": {
- "version": "7.26.2",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
- "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"requires": {
- "@babel/helper-validator-identifier": "^7.25.9",
+ "@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
- "picocolors": "^1.0.0"
+ "picocolors": "^1.1.1"
}
},
"@babel/compat-data": {
@@ -9288,37 +9450,37 @@
}
},
"@babel/generator": {
- "version": "7.23.0",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
- "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
+ "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"requires": {
- "@babel/types": "^7.23.0",
- "@jridgewell/gen-mapping": "^0.3.2",
- "@jridgewell/trace-mapping": "^0.3.17",
- "jsesc": "^2.5.1"
+ "@babel/parser": "^7.28.3",
+ "@babel/types": "^7.28.2",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
},
"dependencies": {
"@jridgewell/gen-mapping": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
- "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"requires": {
- "@jridgewell/set-array": "^1.0.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.9"
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
}
}
}
},
"@babel/helper-annotate-as-pure": {
- "version": "7.18.6",
- "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
- "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"requires": {
- "@babel/types": "^7.18.6"
+ "@babel/types": "^7.27.3"
}
},
"@babel/helper-builder-binary-assignment-operator-visitor": {
@@ -9409,6 +9571,12 @@
"@babel/types": "^7.23.0"
}
},
+ "@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true
+ },
"@babel/helper-hoist-variables": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
@@ -9428,12 +9596,13 @@
}
},
"@babel/helper-module-imports": {
- "version": "7.21.4",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz",
- "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"requires": {
- "@babel/types": "^7.21.4"
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
}
},
"@babel/helper-module-transforms": {
@@ -9462,9 +9631,9 @@
}
},
"@babel/helper-plugin-utils": {
- "version": "7.21.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz",
- "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"dev": true
},
"@babel/helper-remap-async-to-generator": {
@@ -9521,21 +9690,21 @@
}
},
"@babel/helper-string-parser": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
- "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true
},
"@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true
},
"@babel/helper-validator-option": {
- "version": "7.21.0",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz",
- "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true
},
"@babel/helper-wrap-function": {
@@ -9561,12 +9730,12 @@
}
},
"@babel/parser": {
- "version": "7.26.10",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
- "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
+ "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"dev": true,
"requires": {
- "@babel/types": "^7.26.10"
+ "@babel/types": "^7.28.4"
}
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
@@ -9821,12 +9990,12 @@
}
},
"@babel/plugin-syntax-jsx": {
- "version": "7.21.4",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz",
- "integrity": "sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==",
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
+ "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dev": true,
"requires": {
- "@babel/helper-plugin-utils": "^7.20.2"
+ "@babel/helper-plugin-utils": "^7.27.1"
}
},
"@babel/plugin-syntax-logical-assignment-operators": {
@@ -10141,6 +10310,47 @@
"@babel/helper-plugin-utils": "^7.18.6"
}
},
+ "@babel/plugin-transform-react-display-name": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
+ "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ }
+ },
+ "@babel/plugin-transform-react-jsx": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz",
+ "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ }
+ },
+ "@babel/plugin-transform-react-jsx-development": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
+ "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
+ "dev": true,
+ "requires": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1"
+ }
+ },
+ "@babel/plugin-transform-react-pure-annotations": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz",
+ "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.27.1",
+ "@babel/helper-plugin-utils": "^7.27.1"
+ }
+ },
"@babel/plugin-transform-regenerator": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz",
@@ -10348,6 +10558,20 @@
"esutils": "^2.0.2"
}
},
+ "@babel/preset-react": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz",
+ "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==",
+ "dev": true,
+ "requires": {
+ "@babel/helper-plugin-utils": "^7.27.1",
+ "@babel/helper-validator-option": "^7.27.1",
+ "@babel/plugin-transform-react-display-name": "^7.27.1",
+ "@babel/plugin-transform-react-jsx": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-development": "^7.27.1",
+ "@babel/plugin-transform-react-pure-annotations": "^7.27.1"
+ }
+ },
"@babel/preset-typescript": {
"version": "7.21.5",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.21.5.tgz",
@@ -10371,42 +10595,39 @@
}
},
"@babel/template": {
- "version": "7.26.9",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
- "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"requires": {
- "@babel/code-frame": "^7.26.2",
- "@babel/parser": "^7.26.9",
- "@babel/types": "^7.26.9"
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
}
},
"@babel/traverse": {
- "version": "7.23.2",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
- "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
- "dev": true,
- "requires": {
- "@babel/code-frame": "^7.22.13",
- "@babel/generator": "^7.23.0",
- "@babel/helper-environment-visitor": "^7.22.20",
- "@babel/helper-function-name": "^7.23.0",
- "@babel/helper-hoist-variables": "^7.22.5",
- "@babel/helper-split-export-declaration": "^7.22.6",
- "@babel/parser": "^7.23.0",
- "@babel/types": "^7.23.0",
- "debug": "^4.1.0",
- "globals": "^11.1.0"
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
+ "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.3",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.4",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4",
+ "debug": "^4.3.1"
}
},
"@babel/types": {
- "version": "7.26.10",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
- "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
+ "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
"requires": {
- "@babel/helper-string-parser": "^7.25.9",
- "@babel/helper-validator-identifier": "^7.25.9"
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
}
},
"@csstools/css-parser-algorithms": {
@@ -10640,15 +10861,15 @@
}
},
"@jridgewell/sourcemap-codec": {
- "version": "1.4.14",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
- "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
"@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.30",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz",
+ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"requires": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -10802,6 +11023,29 @@
"integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
"dev": true
},
+ "@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true
+ },
+ "@types/react": {
+ "version": "18.3.24",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz",
+ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
+ "dev": true,
+ "requires": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "requires": {}
+ },
"@types/semver": {
"version": "7.3.13",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz",
@@ -11844,6 +12088,12 @@
}
}
},
+ "csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
+ },
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -13353,8 +13603,7 @@
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
"version": "4.1.0",
@@ -13366,9 +13615,9 @@
}
},
"jsesc": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
- "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true
},
"json-parse-even-better-errors": {
@@ -13486,6 +13735,14 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"dev": true
},
+ "loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "requires": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ }
+ },
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -14325,6 +14582,23 @@
"safe-buffer": "^5.1.0"
}
},
+ "react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ }
+ },
"read-pkg": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@@ -14639,6 +14913,14 @@
"neo-async": "^2.6.2"
}
},
+ "scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
"schema-utils": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz",
diff --git a/client/webserver/site/package.json b/client/webserver/site/package.json
index 3534525cba..2201e5ae92 100644
--- a/client/webserver/site/package.json
+++ b/client/webserver/site/package.json
@@ -19,8 +19,11 @@
"@babel/core": "^7.21.8",
"@babel/plugin-transform-runtime": "^7.21.4",
"@babel/preset-env": "^7.21.5",
+ "@babel/preset-react": "^7.22.5",
"@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.21.5",
+ "@types/react": "^18.2.12",
+ "@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"babel-loader": "^9.1.2",
@@ -47,5 +50,9 @@
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.0.2",
"webpack-merge": "^5.8.0"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
}
}
diff --git a/client/webserver/site/src/css/components.scss b/client/webserver/site/src/css/components.scss
index af2a3b3d3e..0709506ba5 100644
--- a/client/webserver/site/src/css/components.scss
+++ b/client/webserver/site/src/css/components.scss
@@ -42,6 +42,15 @@ button {
background-color: var(--btn-feature-hover-bg);
border-color: var(--btn-featur-hover-border-color);
}
+
+ &:disabled {
+ opacity: 0.4;
+
+ &:hover {
+ background-color: var(--btn-feature-bg);
+ border-color: var(--btn-feature-border-color);
+ }
+ }
}
&.danger {
diff --git a/client/webserver/site/src/css/mm.scss b/client/webserver/site/src/css/mm.scss
index 9a2fc7c9b7..f987b98c02 100644
--- a/client/webserver/site/src/css/mm.scss
+++ b/client/webserver/site/src/css/mm.scss
@@ -64,12 +64,17 @@ div[data-handler=mm] {
border-radius: 5px;
}
+ #marketFilterInput {
+ padding-right: 35px;
+ }
+
#marketFilterIcon {
position: absolute;
- left: 10px;
+ right: 10px;
top: 50%;
transform: translateY(-50%);
opacity: 0.5;
+ pointer-events: none;
}
#botTypeForm {
@@ -196,4 +201,81 @@ div[data-handler=mm] {
#limitOrderBufferSection {
display: none;
}
+
+ .configure-bot-content {
+ width: 100%;
+ background-color: var(--section-bg);
+ margin: 20px 0;
+ display: flex;
+ flex-direction: column;
+ // border: 1px solid #dee2e6;
+ border-radius: 0.375rem;
+ box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 7.5%);
+ }
+
+ .configure-bot-market-box {
+ padding: 1rem;
+ border-bottom: 1px solid #dee2e6;
+ }
+
+ .configure-bot-market-display {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease-in-out;
+ }
+
+ .configure-bot-market-display:hover {
+ background-color: rgb(0 123 255 / 10%);
+ }
+
+ .configure-bot-bot-type-display {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease-in-out;
+ }
+
+ .configure-bot-bot-type-display:hover {
+ background-color: rgb(0 123 255 / 10%);
+ }
+
+ .configure-bot-tab-section {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 0.75rem 1rem;
+ margin: 0 0.25rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ transition: all 0.15s ease-in-out;
+ background-color: transparent;
+ color: #6c757d;
+ border: none;
+ }
+
+ .configure-bot-tab-section:hover {
+ background-color: rgb(0 123 255 / 10%);
+ color: #0056b3;
+ }
+
+ .configure-bot-tab-section.active {
+ background-color: rgb(0 123 255 / 15%);
+ color: white;
+ }
+
+ .configure-bot-tab-section.active:hover {
+ background-color: rgb(0 123 255 / 20%);
+ color: white;
+ }
}
\ No newline at end of file
diff --git a/client/webserver/site/src/html/mmsettings.tmpl b/client/webserver/site/src/html/mmsettings.tmpl
index 55c4d051c8..a62c77b59a 100644
--- a/client/webserver/site/src/html/mmsettings.tmpl
+++ b/client/webserver/site/src/html/mmsettings.tmpl
@@ -1,892 +1,6 @@
{{define "mmsettings"}}
{{template "top" .}}
-
[[[Market Maker Settings]]]
-
-
-
-
-
-
[[[Loading market data]]]
-
-
-
- !
- Missing fiat exchange rates
-
-
- Enable external fiat rate sources in
-
settings
-
-
-
-
- {{- /* PLACEMENTS */ -}}
-
-
-
-
[[[Market Maker Settings]]]
-
-
- {{- /* MARKET NAME DISPLAY */ -}}
-
-
-
![]()
![]()
-
–
-
-
- @
-
-
-
- {{- /* BOT TYPE DISPLAY */ -}}
-
-
-
-
![]()
-
-
-
-
-
-
-
-
- [[[bot_running]]]
-
-
-
-
- {{- /* MANUAL CONFIG */ -}}
-
- {{- /* STRATEGY SELECTION */ -}}
-
-
-
-
-
- {{- /* STRATEGY DESCRIPTIONS */ -}}
-
- [[[strategy_percent_plus]]]
-
-
- [[[strategy_percent]]]
-
-
- [[[strategy_absolute_plus]]]
-
-
- [[[strategy_absolute]]]
-
-
- [[[strategy_multiplier]]]
-
-
-
-
-
- [[[bot_profit_title]]]
-
- [[[bot_profit_explainer]]]
-
-
-
-
-
-
-
- [[[buy_placements]]]
-
-
-
-
-
-
-
- [[[sell_placements]]]
-
-
-
-
-
-
- [[[Quick Placements]]]
-
-
-
- {{- /* QUICK CONFIG */ -}}
-
-
- [[[Quick Placements]]]
-
-
-
-
-
- {{- /* LEVELS PER SIDE */ -}}
-
-
[[[Price levels per side]]]
-
-
-
- {{- /* LOTS PER LEVEL */ -}}
-
-
-
[[[Lots per level]]]
-
[[[Lots per side]]]
-
-
-
-
-
-
-
- ~
-
- USD per side
-
-
-
- {{- /* USD PER SIDE */ -}}
-
-
-
-
- ~
-
- lots per level
-
-
-
- {{- /* PROFIT */ -}}
-
-
[[[Profit threshold]]]
-
-
-
- {{- /* RATE INCREMENT */ -}}
-
-
[[[Price increment]]]
-
-
- %
-
-
-
-
- {{- /* MATCH MULTIPLIER */ -}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
δ = [[[remote exchange gap]]]
-
-
- {{- /* ORACLES */ -}}
-
-
- [[[loading_oracles]]]
-
-
-
-
-
-
- [[[no_oracles]]]
-
-
-
-
-
- | [[[Oracles]]] |
- avg: |
-
-
-
-
- ![]() |
- USD |
- |
-
-
-
-
-
-
-
- | [[[fiat_rates]]] |
-
-
-
-
- ![]() |
- USD |
-
-
- ![]() |
- USD |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- [[[Asset Allocations]]]
-
-
-
- [[[running_bot_allocation_note]]]
-
-
-
-
-
-
Sufficient Funds
-
-
Insufficient Funds
-
-
Sufficient With Rebalance
-
-
-
-
-
-
-
-
- ![]() |
-
-
- ![]() |
-
-
-
-
-
-
-
- Buy Buffer
-
-
-
- Number of Buys
-
-
-
-
-
-
-
- Sell Buffer
-
-
-
- Number of Sells
-
-
-
-
-
-
-
- Slippage Buffer
-
-
-
-
-
-
-
- Buy Fee Reserve
-
-
-
-
-
-
-
- Sell Fee Reserve
-
-
-
-
-
-
-
- Bridge Fee Reserve
-
-
-
-
-
-
-
-
-
- {{- /* DEX BALANCES */ -}}
-
-
![]()
-
-
-
-
-
-
-
-
- {{- /* CEX BALANCES */ -}}
-
-
![]()
-
-
-
-
-
-
-
- {{- /* ASSET SETTINGS */ -}}
-
-
- {{- /* WALLET SETTINGS */ -}}
-
-
-
- [[[Wallet Options]]]
-
-
-
-
-
-
-
-
no settings available
-
-
-
-
-
- {{- /* KNOBS */ -}}
-
-
-
- Knobs
-
-
- {{- /* DRIFT TOLERANCE */ -}}
-
-
-
- [[[Drift tolerance]]]
-
-
-
-
-
-
- {{- /* ORDER PERSISTENCE */ -}}
-
-
- [[[Order persistence]]]
-
-
-
-
-
- {{- /* MULTI-HOP COMPLETION */ -}}
-
-
- [[[multi_hop_completion_order]]]
-
-
-
-
-
-
-
-
-
-
-
[[[limit_order_buffer]]]
-
-
-
%
-
-
-
-
-
-
-
- {{- /* AUTO REBALANCE */ -}}
-
-
-
- Auto Rebalance
-
-
-
-
-
-
-
-
-
- {{- /* ALLOW EXTERNAL TRANSFERS */ -}}
-
-
- {{- /* INTERNAL TRANSFERS ONLY */ -}}
-
-
-
-
-
![]()
-
Minimum Transfer
-
-
-
-
-
-
-
-
![]()
-
Minimum Transfer
-
-
-
-
-
-
-
-
![]()
-
Bridge Configuration
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
![]()
-
Bridge Configuration
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{template "orderOptionTemplates"}}
-{{template "bottom"}} {{end}}
+{{template "bottom" .}}
+{{end}}
\ No newline at end of file
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts
index 9c675c7d45..f3ffd0a6ea 100644
--- a/client/webserver/site/src/js/app.ts
+++ b/client/webserver/site/src/js/app.ts
@@ -1457,6 +1457,16 @@ export default class Application {
return this.assets[asset.token.parentID]
}
+ prettyPrintAssetID (assetID: number) : string {
+ const asset = this.assets[assetID]
+ if (!asset) return `Unknown Asset ${assetID}`
+ if (asset.token) {
+ const parentAsset = this.assets[asset.token.parentID]
+ return `${asset.name} on ${parentAsset.name}`
+ }
+ return asset.name
+ }
+
/*
* baseChainSymbol returns the symbol for the asset's parent if the asset is a
* token, otherwise the symbol for the asset itself.
@@ -1495,7 +1505,8 @@ export default class Application {
if (asset.token) {
return asset.token.definition
}
- return this.walletDefinition(assetID, this.assets[assetID].wallet.type)
+ console.log('asset', asset)
+ return this.walletDefinition(assetID, asset.wallet.type)
}
/*
diff --git a/client/webserver/site/src/js/charts.ts b/client/webserver/site/src/js/charts.ts
index 9fe033ff1e..07d9585bfc 100644
--- a/client/webserver/site/src/js/charts.ts
+++ b/client/webserver/site/src/js/charts.ts
@@ -162,6 +162,11 @@ export class Chart {
this.report = reporters
this.theme = State.isDark() ? darkTheme : lightTheme
this.canvas = document.createElement('canvas')
+ this.canvas.style.position = 'absolute'
+ this.canvas.style.left = '0'
+ this.canvas.style.top = '0'
+ this.canvas.style.width = '100%'
+ this.canvas.style.height = '100%'
this.visible = true
parent.appendChild(this.canvas)
const ctx = this.canvas.getContext('2d')
@@ -237,8 +242,9 @@ export class Chart {
* but before the clientHeight has been updated.
*/
resize () {
- this.canvas.width = this.parent.clientWidth
- this.canvas.height = this.parent.clientHeight
+ const rect = this.parent.getBoundingClientRect()
+ this.canvas.width = rect.width
+ this.canvas.height = rect.height
const xLblHeight = 30
const yGuess = 40 // y label width guess. Will be adjusted when drawn.
const plotExtents = new Extents(0, this.canvas.width, 0, this.canvas.height - xLblHeight)
diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts
index b214b24c1c..46c95969b4 100644
--- a/client/webserver/site/src/js/locales.ts
+++ b/client/webserver/site/src/js/locales.ts
@@ -225,6 +225,111 @@ export const ID_MARKET_ORDER_CAPITALIZE = 'MARKET_ORDER_CAPITALIZE'
export const ID_LIMIT_ORDER_CAPITALIZE = 'LIMIT_ORDER_CAPITALIZE'
export const ID_INSUFFICIENT_REDEEM_FUNDS_ERR_MSG = 'INSUFFICIENT_REDEEM_FUNDS_ERR_MSG'
export const ID_INSUFFICIENT_REDEEM_FUNDS_BUNDLER_ERR_MSG = 'INSUFFICIENT_REDEEM_FUNDS_BUNDLER_ERR_MSG'
+export const ID_MM_START_BOT = 'MM_START_BOT'
+export const ID_MM_SAVE_SETTINGS = 'MM_SAVE_SETTINGS'
+export const ID_MM_DELETE_BOT = 'MM_DELETE_BOT'
+export const ID_MM_UPDATE_RUNNING_BOT = 'MM_UPDATE_RUNNING_BOT'
+export const ID_MM_CONFIRM_DELETE = 'MM_CONFIRM_DELETE'
+export const ID_MM_CANCEL = 'MM_CANCEL'
+export const ID_MM_DELETE = 'MM_DELETE'
+export const ID_MM_CLOSE = 'MM_CLOSE'
+export const ID_MM_ERROR = 'MM_ERROR'
+export const ID_MM_PLACEMENTS = 'MM_PLACEMENTS'
+export const ID_MM_ALLOCATIONS = 'MM_ALLOCATIONS'
+export const ID_MM_SETTINGS = 'MM_SETTINGS'
+export const ID_MM_REBALANCE_SETTINGS = 'MM_REBALANCE_SETTINGS'
+export const ID_MM_BASIC_MARKET_MAKER = 'MM_BASIC_MARKET_MAKER'
+export const ID_MM_MM_PLUS_ARB = 'MM_MM_PLUS_ARB'
+export const ID_MM_BASIC_ARBITRAGE = 'MM_BASIC_ARBITRAGE'
+export const ID_MM_UNKNOWN = 'MM_UNKNOWN'
+export const ID_MM_CONFIGURE = 'MM_CONFIGURE'
+export const ID_MM_FIX_ERRORS = 'MM_FIX_ERRORS'
+export const ID_MM_MARKET_NOT_AVAILABLE = 'MM_MARKET_NOT_AVAILABLE'
+export const ID_MM_SELECT_MARKET = 'MM_SELECT_MARKET'
+export const ID_MM_SEARCH_MARKETS = 'MM_SEARCH_MARKETS'
+export const ID_MM_MARKET_HEADER = 'MM_MARKET_HEADER'
+export const ID_MM_HOST_HEADER = 'MM_HOST_HEADER'
+export const ID_MM_ARB_HEADER = 'MM_ARB_HEADER'
+export const ID_MM_CHOOSE_BOT = 'MM_CHOOSE_BOT'
+export const ID_MM_NEXT = 'MM_NEXT'
+export const ID_MM_SUBMIT = 'MM_SUBMIT'
+export const ID_MM_WALLET_SETTINGS = 'MM_WALLET_SETTINGS'
+export const ID_MM_BASE_WALLET = 'MM_BASE_WALLET'
+export const ID_MM_QUOTE_WALLET = 'MM_QUOTE_WALLET'
+export const ID_MM_KNOBS = 'MM_KNOBS'
+export const ID_MM_DRIFT_TOLERANCE = 'MM_DRIFT_TOLERANCE'
+export const ID_MM_DRIFT_TOLERANCE_TOOLTIP = 'MM_DRIFT_TOLERANCE_TOOLTIP'
+export const ID_MM_ORDER_PERSISTENCE = 'MM_ORDER_PERSISTENCE'
+export const ID_MM_ORDER_PERSISTENCE_TOOLTIP = 'MM_ORDER_PERSISTENCE_TOOLTIP'
+export const ID_MM_EPOCHS = 'MM_EPOCHS'
+export const ID_MM_MULTI_HOP_ARB = 'MM_MULTI_HOP_ARB'
+export const ID_MM_INTERMEDIATE_ASSET = 'MM_INTERMEDIATE_ASSET'
+export const ID_MM_INTERMEDIATE_ASSET_TOOLTIP = 'MM_INTERMEDIATE_ASSET_TOOLTIP'
+export const ID_MM_COMPLETION_ORDER_TYPE = 'MM_COMPLETION_ORDER_TYPE'
+export const ID_MM_COMPLETION_ORDER_TYPE_TOOLTIP = 'MM_COMPLETION_ORDER_TYPE_TOOLTIP'
+export const ID_MM_MARKET_ORDER_CAPITALIZE = 'MM_MARKET_ORDER_CAPITALIZE'
+export const ID_MM_LIMIT_ORDER_CAPITALIZE = 'MM_LIMIT_ORDER_CAPITALIZE'
+export const ID_MM_LIMIT_BUFFER = 'MM_LIMIT_BUFFER'
+export const ID_MM_LIMIT_BUFFER_TOOLTIP = 'MM_LIMIT_BUFFER_TOOLTIP'
+export const ID_MM_GAP_STRATEGY = 'MM_GAP_STRATEGY'
+export const ID_MM_PERCENT_PLUS = 'MM_PERCENT_PLUS'
+export const ID_MM_PERCENT = 'MM_PERCENT'
+export const ID_MM_ABSOLUTE_PLUS = 'MM_ABSOLUTE_PLUS'
+export const ID_MM_ABSOLUTE = 'MM_ABSOLUTE'
+export const ID_MM_MULTIPLIER = 'MM_MULTIPLIER'
+export const ID_MM_FACTOR_LABEL_PERCENT = 'MM_FACTOR_LABEL_PERCENT'
+export const ID_MM_FACTOR_LABEL_RATE = 'MM_FACTOR_LABEL_RATE'
+export const ID_MM_FACTOR_LABEL_MULTIPLIER = 'MM_FACTOR_LABEL_MULTIPLIER'
+export const ID_MM_PROFIT_THRESHOLD = 'MM_PROFIT_THRESHOLD'
+export const ID_MM_PROFIT_THRESHOLD_DESC = 'MM_PROFIT_THRESHOLD_DESC'
+export const ID_MM_PRICE_LEVELS_PER_SIDE = 'MM_PRICE_LEVELS_PER_SIDE'
+export const ID_MM_PRICE_INCREMENT = 'MM_PRICE_INCREMENT'
+export const ID_MM_MATCH_BUFFER = 'MM_MATCH_BUFFER'
+export const ID_MM_TRADED_AMOUNT = 'MM_TRADED_AMOUNT'
+export const ID_MM_SWAP_FEES = 'MM_SWAP_FEES'
+export const ID_MM_REDEEM_FEES = 'MM_REDEEM_FEES'
+export const ID_MM_REFUND_FEES = 'MM_REFUND_FEES'
+export const ID_MM_FUNDING_FEES = 'MM_FUNDING_FEES'
+export const ID_MM_SLIPPAGE_BUFFER = 'MM_SLIPPAGE_BUFFER'
+export const ID_MM_MULTI_SPLIT_BUFFER = 'MM_MULTI_SPLIT_BUFFER'
+export const ID_MM_INITIAL_BUY_FUNDING_FEES = 'MM_INITIAL_BUY_FUNDING_FEES'
+export const ID_MM_INITIAL_SELL_FUNDING_FEES = 'MM_INITIAL_SELL_FUNDING_FEES'
+export const ID_MM_BRIDGE_FEE_RESERVES = 'MM_BRIDGE_FEE_RESERVES'
+export const ID_MM_TOTAL_REQUIRED = 'MM_TOTAL_REQUIRED'
+export const ID_MM_ALREADY_ALLOCATED = 'MM_ALREADY_ALLOCATED'
+export const ID_MM_AVAILABLE_TO_UNALLOCATE = 'MM_AVAILABLE_TO_UNALLOCATE'
+export const ID_MM_TOTAL_AVAILABLE = 'MM_TOTAL_AVAILABLE'
+export const ID_MM_AMOUNT_ALLOCATED = 'MM_AMOUNT_ALLOCATED'
+export const ID_MM_BUY_BUFFER = 'MM_BUY_BUFFER'
+export const ID_MM_SELL_BUFFER = 'MM_SELL_BUFFER'
+export const ID_MM_BUY_FEE_RESERVE = 'MM_BUY_FEE_RESERVE'
+export const ID_MM_SELL_FEE_RESERVE = 'MM_SELL_FEE_RESERVE'
+export const ID_MM_BRIDGE_FEE_RESERVE = 'MM_BRIDGE_FEE_RESERVE'
+export const ID_MM_WITHDRAWAL = 'MM_WITHDRAWAL'
+export const ID_MM_DEPOSIT = 'MM_DEPOSIT'
+export const ID_MM_FAILED_SAVE_BOT_CONFIG = 'MM_FAILED_SAVE_BOT_CONFIG'
+export const ID_MM_MIN_TRANSFER = 'MM_MIN_TRANSFER'
+export const ID_MM_MIN_TRANSFER_TOOLTIP = 'MM_MIN_TRANSFER_TOOLTIP'
+export const ID_MM_FAILED_FETCH_BRIDGE_FEES = 'MM_FAILED_FETCH_BRIDGE_FEES'
+export const ID_MM_BRIDGE_CONFIGURATION = 'MM_BRIDGE_CONFIGURATION'
+export const ID_MM_BRIDGE_CONFIG_TOOLTIP = 'MM_BRIDGE_CONFIG_TOOLTIP'
+export const ID_MM_BRIDGE_TO_ASSET = 'MM_BRIDGE_TO_ASSET'
+export const ID_MM_SELECT_CEX_ASSET = 'MM_SELECT_CEX_ASSET'
+export const ID_MM_BRIDGE = 'MM_BRIDGE'
+export const ID_MM_SELECT_BRIDGE = 'MM_SELECT_BRIDGE'
+export const ID_MM_BRIDGE_FEES = 'MM_BRIDGE_FEES'
+export const ID_MM_REBALANCE_METHOD = 'MM_REBALANCE_METHOD'
+export const ID_MM_CEX_REBALANCE = 'MM_CEX_REBALANCE'
+export const ID_MM_CEX_REBALANCE_DESC = 'MM_CEX_REBALANCE_DESC'
+export const ID_MM_INTERNAL_TRANSFERS_ONLY = 'MM_INTERNAL_TRANSFERS_ONLY'
+export const ID_MM_INTERNAL_TRANSFERS_DESC = 'MM_INTERNAL_TRANSFERS_DESC'
+export const ID_MM_REBALANCE_DESCRIPTION = 'MM_REBALANCE_DESCRIPTION'
+export const ID_MM_FAILED_START_BOT = 'MM_FAILED_START_BOT'
+export const ID_MM_LOADING = 'MM_LOADING'
+export const ID_MM_REMOVE_PLACEMENT = 'MM_REMOVE_PLACEMENT'
+export const ID_MM_MOVE_UP = 'MM_MOVE_UP'
+export const ID_MM_MOVE_DOWN = 'MM_MOVE_DOWN'
+export const ID_MM_ADD_PLACEMENT = 'MM_ADD_PLACEMENT'
let locale: Locale
diff --git a/client/webserver/site/src/js/mmsettings.ts b/client/webserver/site/src/js/mmsettings.ts
index 3c67537063..9c4f42eabe 100644
--- a/client/webserver/site/src/js/mmsettings.ts
+++ b/client/webserver/site/src/js/mmsettings.ts
@@ -1,3910 +1,190 @@
-import {
- PageElement,
- BotConfig,
- OrderPlacement,
- app,
- Spot,
- MarketReport,
- OrderOption,
- CEXConfig,
- BasicMarketMakingConfig,
- ArbMarketMakingConfig,
- SimpleArbConfig,
- ArbMarketMakingPlacement,
- ExchangeBalance,
- MarketMakingStatus,
- MMCEXStatus,
- BalanceNote,
- ApprovalStatus,
- SupportedAsset,
- StartConfig,
- MMBotStatus,
- RunStats,
- UIConfig,
- UnitInfo,
- AutoRebalanceConfig,
- BotBalanceAllocation,
- MultiHopCfg,
- BridgeFeesAndLimits
-} from './registry'
-import Doc, {
- NumberInput,
- MiniSlider,
- IncrementalInput,
- toFourSigFigs,
- toPrecision,
- parseFloatDefault
-} from './doc'
-import State from './state'
import BasePage from './basepage'
-import { setOptionTemplates } from './opts'
-import {
- MM,
- CEXDisplayInfos,
- botTypeBasicArb,
- botTypeArbMM,
- botTypeBasicMM,
- runningBotInventory,
- setMarketElements,
- setCexElements,
- calculateQuoteLot,
- PlacementsChart,
- liveBotConfig,
- GapStrategyMultiplier,
- GapStrategyAbsolute,
- GapStrategyAbsolutePlus,
- GapStrategyPercent,
- GapStrategyPercentPlus
-} from './mmutil'
-import { Forms, bind as bindForm, NewWalletForm, TokenApprovalForm, DepositAddress, CEXConfigurationForm } from './forms'
-import * as intl from './locales'
-import * as OrderUtil from './orderutil'
-
-const specLK = 'lastMMSpecs'
-const lastBotsLK = 'lastBots'
-const lastArbExchangeLK = 'lastArbExchange'
-const arbMMRowCacheKey = 'arbmm'
-
-const defaultDriftTolerance = {
- value: 0.002,
- minV: 0,
- maxV: 0.02,
- range: 0.02,
- prec: 5
-}
-const defaultOrderPersistence = {
- value: 20,
- minV: 0,
- maxV: 40, // 10 minutes @ 15 second epochs
- range: 40,
- prec: 0
-}
-const defaultProfit = {
- prec: 3,
- value: 0.01,
- minV: 0.001,
- maxV: 0.1,
- range: 0.1 - 0.001
-}
-const defaultLevelSpacing = {
- prec: 3,
- value: 0.005,
- minV: 0.001,
- maxV: 0.02,
- range: 0.02 - 0.0001
-}
-const defaultMatchBuffer = {
- value: 0,
- prec: 3,
- minV: 0,
- maxV: 1,
- range: 1
-}
-const defaultLevelsPerSide = {
- prec: 0,
- inc: 1,
- value: 1,
- minV: 1
-}
-const defaultLotsPerLevel = {
- prec: 0,
- value: 1,
- minV: 1,
- usdIncrement: 100
-}
-const defaultUSDPerSide = {
- prec: 2
-}
-const defaultLimitOrderBuffer = {
- prec: 2,
- value: 1,
- minV: 0,
- maxV: 20,
- range: 20
-}
-
-function defaultUIConfig (baseMinWithdraw: number, quoteMinWithdraw: number, botType: string) : UIConfig {
- const buffer = botType === botTypeBasicArb ? 1 : 0
- return {
- allocation: {
- dex: {},
- cex: {}
- },
- quickBalance: {
- buysBuffer: buffer,
- sellsBuffer: buffer,
- buyFeeReserve: 0,
- sellFeeReserve: 0,
- bridgeFeeReserve: 1,
- slippageBuffer: 5
- },
- usingQuickBalance: true,
- internalTransfers: true,
- baseMinTransfer: baseMinWithdraw,
- quoteMinTransfer: quoteMinWithdraw,
- cexRebalance: false
- }
-}
-
-const defaultMarketMakingConfig: ConfigState = {
- gapStrategy: GapStrategyPercentPlus,
- sellPlacements: [],
- buyPlacements: [],
- driftTolerance: defaultDriftTolerance.value,
- profit: 0.02,
- orderPersistence: defaultOrderPersistence.value
-} as any as ConfigState
-
-// cexButton stores parts of a CEX selection button.
-interface cexButton {
- name: string
- div: PageElement
- tmpl: Record
-}
-
-/*
- * ConfigState is an amalgamation of BotConfig, ArbMarketMakingCfg, and
- * BasicMarketMakingCfg. ConfigState tracks the global state of the options
- * presented on the page, with a single field for each option / control element.
- * ConfigState is necessary because there are duplicate fields in the various
- * config structs, and the placement types are not identical.
- */
-interface ConfigState {
- gapStrategy: string
- profit: number
- driftTolerance: number
- orderPersistence: number // epochs
- buyPlacements: OrderPlacement[]
- sellPlacements: OrderPlacement[]
- baseOptions: Record
- quoteOptions: Record
- uiConfig: UIConfig
- multiHop?: MultiHopCfg
- cexBaseID: number
- cexQuoteID: number
- intermediateAsset: number
- baseBridgeName: string
- quoteBridgeName: string
-}
+import React from 'react'
+import { createRoot, Root } from 'react-dom/client'
+import MMSettings, { cexSupportsArbOnMarket, MMSettingsHandle } from './mmsettings/components/MMSettings'
+import { app, BalanceNote, CEXNotification, MMCEXStatus, CEXBalanceUpdate } from './registry'
+import { CEXDisplayInfos, liveBotConfig } from './mmutil'
+import type { AvailableMarkets, AvailableMarket } from './mmsettings/components/MMSettings'
+import { initialBotConfigState, botConfigStateFromSavedConfig, BotConfigState } from './mmsettings/utils/BotConfig'
+import State from './state'
-interface BotSpecs {
+export interface BotSpecs {
host: string
baseID: number
quoteID: number
- botType: string
+ botType: 'basicMM' | 'arbMM' | 'basicArb'
cexName?: string
}
-interface MarketRow {
- tr: PageElement
- tmpl: Record
- host: string
- name: string
- baseID: number
- quoteID: number
- arbs: string[]
- spot: Spot
-}
-
-interface UIOpts {
- usingUSDPerSide?: boolean
-}
-
-interface RoundTripFeesAndLimits {
- withdrawal: BridgeFeesAndLimits | null
- deposit: BridgeFeesAndLimits | null
- // cexAsset and bridgeName are stored to avoid unnecessarily refetching.
- cexAsset: number
- bridgeName: string
-}
+export const specLK = 'lastMMSpecs'
export default class MarketMakerSettingsPage extends BasePage {
- page: Record
- forms: Forms
- opts: UIOpts
- runningBot: boolean
- newWalletForm: NewWalletForm
- approveTokenForm: TokenApprovalForm
- walletAddrForm: DepositAddress
- cexConfigForm: CEXConfigurationForm
- currentMarket: string
- originalConfig: ConfigState
- updatedConfig: ConfigState
- creatingNewBot: boolean
- marketReport: MarketReport
- qcProfit: NumberInput
- qcProfitSlider: MiniSlider
- qcLevelSpacing: NumberInput
- qcLevelSpacingSlider: MiniSlider
- qcMatchBuffer: NumberInput
- qcMatchBufferSlider: MiniSlider
- qcLevelsPerSide: IncrementalInput
- qcLotsPerLevel: IncrementalInput
- qcUSDPerSide: IncrementalInput
- cexBaseBalance: ExchangeBalance
- cexQuoteBalance: ExchangeBalance
- specs: BotSpecs
- dexMktID: string
- baseBridges: Record | undefined
- quoteBridges: Record | undefined
- intermediateAssets: number[] | undefined
- formSpecs: BotSpecs
- formCexes: Record
- placementsCache: Record
- botTypeSelectors: PageElement[]
- marketRows: MarketRow[]
- lotsPerLevelIncrement: number
- placementsChart: PlacementsChart
- baseSettings: WalletSettings
- quoteSettings: WalletSettings
- driftTolerance: NumberInput
- driftToleranceSlider: MiniSlider
- orderPersistence: NumberInput
- orderPersistenceSlider: MiniSlider
- limitOrderBuffer: NumberInput
- limitOrderBufferSlider: MiniSlider
- availableDEXBalances: Record
- availableCEXBalances: Record
- buyBufferSlider: MiniSlider
- buyBufferInput: NumberInput
- sellBufferSlider: MiniSlider
- sellBufferInput: NumberInput
- slippageBufferSlider: MiniSlider
- slippageBufferInput: NumberInput
- buyFeeReserveSlider: MiniSlider
- buyFeeReserveInput: NumberInput
- sellFeeReserveSlider: MiniSlider
- sellFeeReserveInput: NumberInput
- bridgeFeeReserveSlider: MiniSlider
- bridgeFeeReserveInput: NumberInput
- baseMinTransferSlider: MiniSlider
- baseMinTransferInput: NumberInput
- quoteMinTransferSlider: MiniSlider
- quoteMinTransferInput: NumberInput
- manualBalanceInputs: Record<'dex' | 'cex', Record>
- fundingFeesCache: Record
- bridgePaths: Record>
- buyFundingFees: number
- sellFundingFees: number
- oneTradeBuyFundingFees: number
- oneTradeSellFundingFees: number
- baseBridgeFeesAndLimits: RoundTripFeesAndLimits | null
- quoteBridgeFeesAndLimits: RoundTripFeesAndLimits | null
+ private reactRoot: Root | null = null
+ private mainElement: HTMLElement
+ private mmSettingsRef: React.RefObject
- constructor (main: HTMLElement, specs: BotSpecs) {
+ constructor (main: HTMLElement, specs?: BotSpecs) {
super()
+ this.mainElement = main
+ this.mmSettingsRef = React.createRef()
+ this.renderReact(specs)
- this.placementsCache = {}
- this.fundingFeesCache = {}
- this.opts = {}
- this.buyFundingFees = 0
- this.sellFundingFees = 0
- this.oneTradeBuyFundingFees = 0
- this.oneTradeSellFundingFees = 0
- this.baseBridgeFeesAndLimits = null
- this.quoteBridgeFeesAndLimits = null
-
- const page = this.page = Doc.idDescendants(main)
-
- this.forms = new Forms(page.forms, {
- closed: () => {
- if (!this.specs?.host || !this.specs?.botType) app().loadPage('mm')
- }
+ app().registerNoteFeeder({
+ balance: (note: BalanceNote) => { this.handleBalanceNote(note) },
+ cexnote: (note: CEXNotification) => { this.handleCEXNote(note) }
})
+ }
- this.placementsChart = new PlacementsChart(page.placementsChart)
- this.approveTokenForm = new TokenApprovalForm(page.approveTokenForm, () => { this.submitBotType() })
- this.walletAddrForm = new DepositAddress(page.walletAddrForm)
- this.cexConfigForm = new CEXConfigurationForm(page.cexConfigForm, (cexName: string) => this.cexConfigured(cexName))
- page.quoteSettings = page.baseSettings.cloneNode(true) as PageElement
- page.walletSettingsBox.appendChild(page.quoteSettings)
- this.baseSettings = new WalletSettings(this, page.baseSettings, () => { this.updateAllocations() })
- this.quoteSettings = new WalletSettings(this, page.quoteSettings, () => { this.updateAllocations() })
-
- app().headerSpace.appendChild(page.mmTitle)
-
- setOptionTemplates(page)
- Doc.cleanTemplates(
- page.orderOptTmpl, page.booleanOptTmpl, page.rangeOptTmpl, page.placementRowTmpl,
- page.oracleTmpl, page.cexOptTmpl, page.arbBttnTmpl, page.marketRowTmpl, page.needRegTmpl,
- page.manualBalanceEntryTmpl)
- page.baseSettings.removeAttribute('id') // don't remove from layout
-
- Doc.bind(page.resetButton, 'click', () => { this.setOriginalValues() })
- Doc.bind(page.updateButton, 'click', () => { this.updateSettings() })
- Doc.bind(page.updateStartButton, 'click', () => { this.saveSettingsAndStart() })
- Doc.bind(page.updateRunningButton, 'click', () => { this.updateSettings() })
- Doc.bind(page.deleteBttn, 'click', () => { this.delete() })
- bindForm(page.botTypeForm, page.botTypeSubmit, () => { this.submitBotType() })
- Doc.bind(page.noMarketBttn, 'click', () => { this.showMarketSelectForm() })
- Doc.bind(page.botTypeHeader, 'click', () => { this.reshowBotTypeForm() })
- Doc.bind(page.botTypeChangeMarket, 'click', () => { this.showMarketSelectForm() })
- Doc.bind(page.marketHeader, 'click', () => { this.showMarketSelectForm() })
- Doc.bind(page.marketFilterInput, 'input', () => { this.sortMarketRows() })
- Doc.bind(page.internalOnlyRadio, 'change', () => { this.internalOnlyChanged() })
- Doc.bind(page.externalTransfersRadio, 'change', () => { this.externalTransfersChanged() })
- Doc.bind(page.switchToAdvanced, 'click', () => { this.showAdvancedConfig() })
- Doc.bind(page.switchToQuickConfig, 'click', () => { this.switchToQuickConfig() })
- Doc.bind(page.qcMatchBuffer, 'change', () => { this.matchBufferChanged() })
- Doc.bind(page.switchToUSDPerSide, 'click', () => { this.changeSideCommitmentDialog() })
- Doc.bind(page.switchToLotsPerLevel, 'click', () => { this.changeSideCommitmentDialog() })
- Doc.bind(page.manuallyAllocateBttn, 'click', () => { this.setAllocationTechnique(false) })
- Doc.bind(page.quickConfigBttn, 'click', () => { this.setAllocationTechnique(true) })
- Doc.bind(page.enableRebalance, 'change', () => { this.autoRebalanceChanged() })
- Doc.bind(page.baseBridgeAsset, 'change', () => { this.bridgeAssetUpdated(true) })
- Doc.bind(page.quoteBridgeAsset, 'change', () => { this.bridgeAssetUpdated(false) })
- Doc.bind(page.baseBridge, 'change', () => { this.bridgeTypeUpdated(true) })
- Doc.bind(page.quoteBridge, 'change', () => { this.bridgeTypeUpdated(false) })
-
- // Gap Strategy
- Doc.bind(page.gapStrategySelect, 'change', () => {
- if (!page.gapStrategySelect.value) return
- const gapStrategy = page.gapStrategySelect.value
- this.clearPlacements(this.updatedConfig.gapStrategy)
- this.loadCachedPlacements(gapStrategy)
- this.updatedConfig.gapStrategy = gapStrategy
- this.setGapFactorLabels(gapStrategy)
- this.updateModifiedMarkers()
- })
- Doc.bind(page.intermediateAssetSelect, 'change', () => {
- if (!page.intermediateAssetSelect.value) return
- this.updatedConfig.intermediateAsset = Number(page.intermediateAssetSelect.value)
- console.log('intermediateAsset', this.updatedConfig.intermediateAsset)
- })
+ private initializeMarketRows (): AvailableMarkets {
+ const markets: AvailableMarket[] = []
+ const exchangesRequiringRegistration: string[] = []
- // Buy/Sell placements
- Doc.bind(page.addBuyPlacementBtn, 'click', () => {
- this.addPlacement(true, null)
- page.addBuyPlacementLots.value = ''
- page.addBuyPlacementGapFactor.value = ''
- this.updateModifiedMarkers()
- this.placementsChart.render()
- this.updateAllocations()
- })
- Doc.bind(page.addSellPlacementBtn, 'click', () => {
- this.addPlacement(false, null)
- page.addSellPlacementLots.value = ''
- page.addSellPlacementGapFactor.value = ''
- this.updateModifiedMarkers()
- this.placementsChart.render()
- this.updateAllocations()
- })
+ for (const [host, exchange] of Object.entries(app().exchanges)) {
+ const { markets: exchangeMarkets, auth: { effectiveTier, pendingStrength } } = exchange
- this.driftTolerance = new NumberInput(page.driftToleranceInput, {
- prec: defaultDriftTolerance.prec - 2, // converting to percent for display
- sigFigs: true,
- min: 0,
- changed: (rawV: number) => {
- const { minV, range, prec } = defaultDriftTolerance
- const [v] = toFourSigFigs(rawV / 100, prec)
- this.driftToleranceSlider.setValue((v - minV) / range)
- this.updatedConfig.driftTolerance = v
+ // Check if user has sufficient tier for this exchange
+ if (effectiveTier + pendingStrength === 0) {
+ exchangesRequiringRegistration.push(host)
+ continue // Skip exchanges where user needs to register
}
- })
- this.driftToleranceSlider = new MiniSlider(page.driftToleranceSlider, (r: number) => {
- const { minV, range, prec } = defaultDriftTolerance
- const [v] = toFourSigFigs(minV + r * range, prec)
- this.updatedConfig.driftTolerance = v
- this.driftTolerance.setValue(v * 100)
- })
-
- this.orderPersistence = new NumberInput(page.orderPersistence, {
- changed: (v: number) => {
- const { minV, range } = defaultOrderPersistence
- this.updatedConfig.orderPersistence = v
- this.orderPersistenceSlider.setValue((v - minV) / range)
- }
- })
+ // Process each market in this exchange
+ for (const [marketName, market] of Object.entries(exchangeMarkets)) {
+ const { baseid: baseID, quoteid: quoteID, basesymbol: baseSymbol, quotesymbol: quoteSymbol, spot } = market
- this.orderPersistenceSlider = new MiniSlider(page.orderPersistenceSlider, (r: number) => {
- const { minV, range, prec } = defaultOrderPersistence
- const rawV = minV + r * range
- const [v] = toPrecision(rawV, prec)
- this.updatedConfig.orderPersistence = v
- this.orderPersistence.setValue(v)
- })
+ // Skip markets with missing assets
+ if (!app().assets[baseID] || !app().assets[quoteID]) continue
- this.limitOrderBuffer = new NumberInput(page.limitOrderBufferInput, {
- prec: defaultLimitOrderBuffer.prec,
- min: defaultLimitOrderBuffer.minV * 100,
- changed: (bufferPct: number) => {
- const { minV, range } = defaultLimitOrderBuffer
- const pct = Math.max(minV, Math.min(range, bufferPct))
- this.limitOrderBuffer.setValue(pct)
- if (this.updatedConfig.multiHop && typeof this.updatedConfig.multiHop === 'object') {
- this.updatedConfig.multiHop.limitOrdersBuffer = pct / 100
+ // Check arbitrage support
+ const arbs: string[] = []
+ const hasCexSupport = () => {
+ // Simplified arbitrage check - in real implementation this would check CEX market support
+ return true // For now, assume all CEXes support this market
}
- this.limitOrderBufferSlider.setValue((pct - minV) / range)
- this.updateModifiedMarkers()
- }
- })
- this.limitOrderBufferSlider = new MiniSlider(page.limitOrderBufferSlider, (r: number) => {
- const { minV, range } = defaultLimitOrderBuffer
- const pct = minV + r * range
- this.limitOrderBuffer.setValue(pct)
- if (this.updatedConfig.multiHop && typeof this.updatedConfig.multiHop === 'object') {
- this.updatedConfig.multiHop.limitOrdersBuffer = pct / 100
- }
- this.updateModifiedMarkers()
- })
-
- Doc.bind(page.multiHopMarketOrder, 'change', () => {
- if (page.multiHopMarketOrder.checked) {
- if (this.updatedConfig.multiHop && typeof this.updatedConfig.multiHop === 'object') {
- this.updatedConfig.multiHop.marketOrders = true
+ for (const cexName of Object.keys(CEXDisplayInfos)) {
+ if (hasCexSupport()) {
+ arbs.push(cexName)
+ }
}
- Doc.hide(page.limitOrderBufferSection)
- this.updateModifiedMarkers()
- }
- })
- Doc.bind(page.multiHopLimitOrder, 'change', () => {
- if (page.multiHopLimitOrder.checked) {
- if (this.updatedConfig.multiHop && typeof this.updatedConfig.multiHop === 'object') {
- this.updatedConfig.multiHop.marketOrders = false
+ const availableMarket: AvailableMarket = {
+ host,
+ name: marketName,
+ baseID,
+ quoteID,
+ baseSymbol,
+ quoteSymbol,
+ hasArb: arbs.length > 0,
+ arbs,
+ spot
}
- Doc.show(page.limitOrderBufferSection)
- this.updateModifiedMarkers()
- }
- })
-
- this.qcProfit = new NumberInput(page.qcProfit, {
- prec: defaultProfit.prec - 2, // converting to percent
- sigFigs: true,
- min: defaultProfit.minV * 100,
- changed: (vPct: number) => {
- const { minV, range } = defaultProfit
- const v = vPct / 100
- this.updatedConfig.profit = v
- page.profitInput.value = this.qcProfit.input.value
- this.qcProfitSlider.setValue((v - minV) / range)
- this.quickConfigUpdated()
- }
- })
-
- this.qcProfitSlider = new MiniSlider(page.qcProfitSlider, (r: number) => {
- const { minV, range, prec } = defaultProfit
- const [v] = toFourSigFigs((minV + r * range) * 100, prec)
- this.updatedConfig.profit = v / 100
- this.qcProfit.setValue(v)
- page.profitInput.value = this.qcProfit.input.value
- this.quickConfigUpdated()
- })
-
- this.qcLevelSpacing = new NumberInput(page.qcLevelSpacing, {
- prec: defaultLevelSpacing.prec - 2, // converting to percent
- sigFigs: true,
- min: defaultLevelSpacing.minV * 100,
- changed: (vPct: number) => {
- const { minV, range } = defaultLevelSpacing
- this.qcLevelSpacingSlider.setValue((vPct / 100 - minV) / range)
- this.quickConfigUpdated()
- }
- })
-
- this.qcLevelSpacingSlider = new MiniSlider(page.qcLevelSpacingSlider, (r: number) => {
- const { minV, range } = defaultLevelSpacing
- this.qcLevelSpacing.setValue(minV + r * range * 100)
- this.quickConfigUpdated()
- })
-
- this.qcMatchBuffer = new NumberInput(page.qcMatchBuffer, {
- prec: defaultMatchBuffer.prec - 2, // converting to percent
- sigFigs: true,
- min: defaultMatchBuffer.minV * 100,
- changed: (vPct: number) => {
- const { minV, range } = defaultMatchBuffer
- this.qcMatchBufferSlider.setValue((vPct / 100 - minV) / range)
- this.quickConfigUpdated()
- }
- })
-
- this.qcMatchBufferSlider = new MiniSlider(page.qcMatchBufferSlider, (r: number) => {
- const { minV, range } = defaultMatchBuffer
- this.qcMatchBuffer.setValue(minV + r * range * 100)
- this.quickConfigUpdated()
- })
-
- this.qcLevelsPerSide = new IncrementalInput(page.qcLevelsPerSide, {
- prec: defaultLevelsPerSide.prec,
- min: defaultLevelsPerSide.minV,
- inc: defaultLevelsPerSide.inc,
- changed: (v: number) => {
- this.qcUSDPerSide.setValue(this.lotSizeUSD() * v * this.qcLotsPerLevel.value())
- this.quickConfigUpdated()
- }
- })
-
- this.qcLotsPerLevel = new IncrementalInput(page.qcLotsPerLevel, {
- prec: defaultLotsPerLevel.prec,
- min: defaultLotsPerLevel.minV,
- inc: 1, // set showQuickConfig
- changed: (v: number) => {
- this.qcUSDPerSide.setValue(this.lotSizeUSD() * v * this.qcLevelsPerSide.value())
- page.qcUSDPerSideEcho.textContent = this.qcUSDPerSide.input.value as string
- this.quickConfigUpdated()
- },
- set: (v: number) => {
- const [, s] = toFourSigFigs(v * this.qcLevelsPerSide.value() * this.lotSizeUSD(), 2)
- page.qcUSDPerSideEcho.textContent = s
- page.qcLotsPerLevelEcho.textContent = s
- }
- })
-
- this.qcUSDPerSide = new IncrementalInput(page.qcUSDPerSide, {
- prec: defaultUSDPerSide.prec,
- min: 1, // changed by showQuickConfig
- inc: 1, // changed by showQuickConfig
- changed: (v: number) => {
- this.qcLotsPerLevel.setValue(v / this.qcLevelsPerSide.value() / this.lotSizeUSD())
- page.qcLotsPerLevelEcho.textContent = this.qcLotsPerLevel.input.value as string
- this.quickConfigUpdated()
- },
- set: (v: number, s: string) => {
- page.qcUSDPerSideEcho.textContent = s
- page.qcLotsPerLevelEcho.textContent = String(Math.round(v / this.lotSizeUSD()))
- }
- })
-
- this.buyBufferSlider = new MiniSlider(page.buyBufferSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'buyBuffer'))
- this.buyBufferInput = new NumberInput(page.buyBuffer, { prec: 0, min: 0, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'buyBuffer') })
- this.sellBufferSlider = new MiniSlider(page.sellBufferSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'sellBuffer'))
- this.sellBufferInput = new NumberInput(page.sellBuffer, { prec: 0, min: 0, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'sellBuffer') })
- this.slippageBufferSlider = new MiniSlider(page.slippageBufferSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'slippageBuffer'))
- this.slippageBufferInput = new NumberInput(page.slippageBuffer, { prec: 3, min: 0, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'slippageBuffer') })
- this.buyFeeReserveSlider = new MiniSlider(page.buyFeeReserveSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'buyFeeReserve'))
- this.buyFeeReserveInput = new NumberInput(page.buyFeeReserve, { prec: 0, min: 0, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'buyFeeReserve') })
- this.sellFeeReserveSlider = new MiniSlider(page.sellFeeReserveSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'sellFeeReserve'))
- this.sellFeeReserveInput = new NumberInput(page.sellFeeReserve, { prec: 0, min: 0, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'sellFeeReserve') })
- this.bridgeFeeReserveSlider = new MiniSlider(page.bridgeFeeReserveSlider, (amt: number) => this.quickBalanceSliderChanged(amt, 'bridgeFeeReserve'))
- this.bridgeFeeReserveInput = new NumberInput(page.bridgeFeeReserve, { prec: 0, min: 1, changed: (amt: number) => this.quickBalanceInputChanged(amt, 'bridgeFeeReserve') })
- this.baseMinTransferSlider = new MiniSlider(page.baseMinTransferSlider, (amt: number) => this.minTransferSliderChanged(amt, 'base'))
- this.baseMinTransferInput = new NumberInput(page.baseMinTransfer, { prec: 0, min: 0, changed: (amt: number) => this.minTransferInputChanged(amt, 'base') })
- this.quoteMinTransferSlider = new MiniSlider(page.quoteMinTransferSlider, (amt: number) => this.minTransferSliderChanged(amt, 'quote'))
- this.quoteMinTransferInput = new NumberInput(page.quoteMinTransfer, { prec: 0, min: 0, changed: (amt: number) => this.minTransferInputChanged(amt, 'quote') })
-
- const maybeSubmitBuyRow = (e: KeyboardEvent) => {
- if (e.key !== 'Enter') return
- if (
- !isNaN(parseFloat(page.addBuyPlacementGapFactor.value || '')) &&
- !isNaN(parseFloat(page.addBuyPlacementLots.value || ''))
- ) {
- page.addBuyPlacementBtn.click()
- }
- }
- Doc.bind(page.addBuyPlacementGapFactor, 'keyup', (e: KeyboardEvent) => { maybeSubmitBuyRow(e) })
- Doc.bind(page.addBuyPlacementLots, 'keyup', (e: KeyboardEvent) => { maybeSubmitBuyRow(e) })
- const maybeSubmitSellRow = (e: KeyboardEvent) => {
- if (e.key !== 'Enter') return
- if (
- !isNaN(parseFloat(page.addSellPlacementGapFactor.value || '')) &&
- !isNaN(parseFloat(page.addSellPlacementLots.value || ''))
- ) {
- page.addSellPlacementBtn.click()
+ markets.push(availableMarket)
}
}
- Doc.bind(page.addSellPlacementGapFactor, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) })
- Doc.bind(page.addSellPlacementLots, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) })
- Doc.bind(page.profitInput, 'change', () => {
- Doc.hide(page.profitInputErr)
- const showError = (errID: string) => {
- Doc.show(page.profitInputErr)
- page.profitInputErr.textContent = intl.prep(errID)
+ // Sort markets by volume (simplified - would need fiat rates in real implementation)
+ const fiatRates = app().fiatRatesMap
+ markets.sort((a: AvailableMarket, b: AvailableMarket) => {
+ let [volA, volB] = [a.spot?.vol24 ?? 0, b.spot?.vol24 ?? 0]
+ if (fiatRates[a.baseID] && fiatRates[b.baseID]) {
+ volA *= fiatRates[a.baseID]
+ volB *= fiatRates[b.baseID]
}
- const profit = parseFloat(page.profitInput.value || '') / 100
- if (isNaN(profit)) return showError(intl.ID_INVALID_VALUE)
- if (profit === 0) return showError(intl.ID_NO_ZERO)
- this.updatedConfig.profit = profit
- this.updateModifiedMarkers()
+ return volB - volA
})
- this.botTypeSelectors = Doc.applySelector(page.botTypeForm, '[data-bot-type]')
- for (const div of this.botTypeSelectors) {
- Doc.bind(div, 'click', () => {
- if (div.classList.contains('disabled')) return
- Doc.hide(page.botTypeErr)
- page.cexSelection.classList.toggle('disabled', div.dataset.botType === botTypeBasicMM)
- this.setBotTypeSelected(div.dataset.botType as string)
- })
+ return {
+ markets,
+ exchangesRequiringRegistration
}
-
- this.newWalletForm = new NewWalletForm(
- page.newWalletForm,
- async () => {
- await app().fetchUser()
- this.submitBotType()
- }
- )
-
- app().registerNoteFeeder({
- balance: (note: BalanceNote) => { this.handleBalanceNote(note) }
- })
-
- this.initialize(specs)
}
unload () {
- this.forms.exit()
- }
-
- // updateAllocationAssetIDs updates the assetIDs that are used in the config allocations map.
- updateAllocationAssetIDs () {
- const dexAssetIDs = this.requiredDexAssets(this.specs.baseID, this.specs.quoteID, this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID)
-
- const updateAllocations = (assetIDs: number[], allocations: Record) => {
- // Remove assets that are no longer required
- for (const assetID of Object.keys(allocations).map(Number)) {
- if (!assetIDs.includes(assetID)) {
- delete allocations[assetID]
- }
- }
- // Add new assets with 0 allocation
- for (const assetID of assetIDs) {
- if (allocations[assetID] === undefined) {
- allocations[assetID] = 0
- }
- }
- }
-
- updateAllocations(dexAssetIDs, this.updatedConfig.uiConfig.allocation.dex)
-
- if (this.specs.cexName) {
- const cexAssetIDs = [this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID]
- updateAllocations(cexAssetIDs, this.updatedConfig.uiConfig.allocation.cex)
- }
- }
-
- // assetsUpdated handles all necessary updates when the required assets change
- // This includes updating balances, UI elements, and allocations
- async assetsUpdated () {
- await this.setAvailableBalances()
- this.setupManualBalanceEntries()
- this.setupAllocationTable()
- if (this.updatedConfig.uiConfig.usingQuickBalance) {
- await this.updateAllocations()
- } else {
- this.updateAllocationAssetIDs()
- this.updateManualBalanceEntries()
+ if (this.reactRoot) {
+ this.reactRoot.unmount()
+ this.reactRoot = null
}
+ super.unload()
}
- async bridgeAssetUpdated (isBase: boolean) {
- const { page, originalConfig: oldCfg, updatedConfig: cfg } = this
- const assetSelect = isBase ? page.baseBridgeAsset : page.quoteBridgeAsset
- const bridgeSelect = isBase ? page.baseBridge : page.quoteBridge
- const bridges = isBase ? this.baseBridges : this.quoteBridges
- const savedBridgeName = isBase ? oldCfg.baseBridgeName : oldCfg.quoteBridgeName
- const selectedAssetID = parseInt(assetSelect.value || '0')
-
- Doc.empty(bridgeSelect)
-
- // Update the config with the selected asset
- if (isBase) {
- cfg.cexBaseID = selectedAssetID || cfg.cexBaseID
- } else {
- cfg.cexQuoteID = selectedAssetID || cfg.cexQuoteID
+ handleBalanceNote (note: BalanceNote) {
+ if (this.mmSettingsRef.current) {
+ this.mmSettingsRef.current.handleBalanceNote(note)
}
-
- if (selectedAssetID && bridges && bridges[selectedAssetID]) {
- for (const bridgeName of bridges[selectedAssetID]) {
- const opt = document.createElement('option')
- opt.value = bridgeName
- opt.textContent = bridgeName
- if (savedBridgeName === bridgeName) opt.selected = true
- bridgeSelect.appendChild(opt)
- }
- }
-
- await this.assetsUpdated()
- await this.populateBridgeFeesAndLimits()
- this.updateModifiedMarkers()
}
- async bridgeTypeUpdated (isBase: boolean) {
- const { page, updatedConfig: cfg } = this
- const bridgeSelect = isBase ? page.baseBridge : page.quoteBridge
- const bridgeName = bridgeSelect.value || ''
+ handleCEXNote (note: any) {
+ if (!this.mmSettingsRef.current) return
- if (isBase) {
- cfg.baseBridgeName = bridgeName
- } else {
- cfg.quoteBridgeName = bridgeName
+ // Handle different CEX note topics
+ if (note.topic === 'BalanceUpdate') {
+ const update = note.note as CEXBalanceUpdate
+ this.mmSettingsRef.current.handleCEXBalanceUpdate(note.cexName, update)
}
-
- await this.populateBridgeFeesAndLimits()
- this.updateModifiedMarkers()
}
- setupBridgeUI () {
- const { page, updatedConfig: cfg } = this
-
- const setupAssetBridge = (isBase: boolean) => {
- const bridges = (isBase ? this.baseBridges : this.quoteBridges) || {}
- const assetSelect = isBase ? page.baseBridgeAsset : page.quoteBridgeAsset
- const bridgeSelect = isBase ? page.baseBridge : page.quoteBridge
- const section = isBase ? page.baseBridgeSection : page.quoteBridgeSection
- const savedAssetID = isBase ? cfg.cexBaseID : cfg.cexQuoteID
- const bridgeAssets = Object.keys(bridges).map(Number)
- const requiresBridge = bridgeAssets.length > 0
-
- if (requiresBridge) {
- Doc.empty(assetSelect)
- Doc.empty(bridgeSelect)
-
- // Populate bridge asset options
- for (const bridgeAssetID of bridgeAssets) {
- const bridgeAssetSymbol = app().assets[bridgeAssetID].symbol.toUpperCase()
- const opt = document.createElement('option')
- opt.value = String(bridgeAssetID)
- opt.textContent = bridgeAssetSymbol
- if (savedAssetID === bridgeAssetID) opt.selected = true
- assetSelect.appendChild(opt)
- }
-
- this.bridgeAssetUpdated(isBase)
- }
-
- Doc.setVis(requiresBridge, section)
+ private async renderReact (specs?: BotSpecs) {
+ // Clear existing content. TODO: remove
+ while (this.mainElement.firstChild) {
+ this.mainElement.removeChild(this.mainElement.firstChild)
}
- setupAssetBridge(true)
- setupAssetBridge(false)
- }
-
- async initialize (specs?: BotSpecs) {
- this.bridgePaths = await app().allBridgePaths()
- this.baseBridgeFeesAndLimits = null
- this.quoteBridgeFeesAndLimits = null
- this.setupCEXes()
- this.initializeMarketRows()
+ // Create a container div for React
+ const reactContainer = document.createElement('div')
+ reactContainer.id = 'react-mmsettings-container'
+ this.mainElement.appendChild(reactContainer)
+ // If this is a refresh, load the latest config that was being edited.
const isRefresh = specs && Object.keys(specs).length === 0
if (isRefresh) specs = State.fetchLocal(specLK)
- if (!specs || !app().walletMap[specs.baseID] || !app().walletMap[specs.quoteID]) {
- this.showMarketSelectForm()
- return
- }
-
- // If we have specs specifying only a market, make sure the cex name and
- // bot type are set.
- if (specs && !specs.botType) {
- const botCfg = liveBotConfig(specs.host, specs.baseID, specs.quoteID)
- specs.cexName = botCfg?.cexName ?? ''
- specs.botType = botTypeBasicMM
- if (botCfg?.arbMarketMakingConfig) specs.botType = botTypeArbMM
- else if (botCfg?.simpleArbConfig) specs.botType = botTypeBasicArb
- }
-
- // Must be a reconfig.
- this.specs = specs
- await this.fetchCEXBalances(specs)
- this.configureUI()
- }
-
- // clampOriginalAllocations sets the allocations to be within the valid range
- // based on the available balances.
- clampOriginalAllocations (uiConfig: UIConfig) {
- const { baseID, quoteID } = this.walletStuff()
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
-
- for (const assetID of dexAssetIDs) {
- const [dexMin, dexMax] = this.validManualBalanceRange(assetID, 'dex', false)
- uiConfig.allocation.dex[assetID] = Math.min(Math.max(uiConfig.allocation.dex[assetID], dexMin), dexMax)
- }
-
- if (this.specs.cexName) {
- const cexAssetIDs = [cexBaseID, cexQuoteID]
- for (const assetID of cexAssetIDs) {
- const [cexMin, cexMax] = this.validManualBalanceRange(assetID, 'cex', false)
- uiConfig.allocation.cex[assetID] = Math.min(Math.max(uiConfig.allocation.cex[assetID], cexMin), cexMax)
- }
- }
- }
-
- async setAvailableBalances () {
- const { specs } = this
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const availableBalances = await MM.availableBalances({ host: specs.host, baseID: specs.baseID, quoteID: specs.quoteID }, cexBaseID, cexQuoteID, specs.cexName)
- this.availableDEXBalances = availableBalances.dexBalances
- this.availableCEXBalances = availableBalances.cexBalances
- }
-
- minWithdrawals (cexBaseID: number, cexQuoteID: number, cexName: string | undefined): { minBaseWithdraw: number, minQuoteWithdraw: number } {
- if (!cexName) return { minBaseWithdraw: 0, minQuoteWithdraw: 0 }
- const cex = app().mmStatus.cexes[cexName]
- if (!cex) return { minBaseWithdraw: 0, minQuoteWithdraw: 0 }
- let minBaseWithdraw = 0
- let minQuoteWithdraw = 0
- for (const market of Object.values(cex.markets)) {
- if (market.baseID === cexBaseID) {
- minBaseWithdraw = market.baseMinWithdraw
- }
- if (market.quoteID === cexBaseID) {
- minBaseWithdraw = market.quoteMinWithdraw
- }
- if (market.baseID === cexQuoteID) {
- minQuoteWithdraw = market.baseMinWithdraw
- }
- if (market.quoteID === cexQuoteID) {
- minQuoteWithdraw = market.quoteMinWithdraw
- }
- if (minBaseWithdraw > 0 && minQuoteWithdraw > 0) {
- break
- }
- }
-
- return { minBaseWithdraw, minQuoteWithdraw }
- }
-
- findMultiHopMarkets (cexName: string, baseID: number, quoteID: number, intermediateAsset: number) : [[number, number], [number, number]] | undefined {
- const cex = app().mmStatus.cexes[cexName]
- if (!cex) return undefined
- const mktsEqual = (mkt1: [number, number], mkt2: [number, number]) => {
- return mkt1[0] === mkt2[0] && mkt1[1] === mkt2[1]
- }
- let baseMkt: [number, number] = [0, 0]
- let quoteMkt: [number, number] = [0, 0]
- let foundBaseMkt = false
- let foundQuoteMkt = false
- for (const id of Object.keys(cex.markets)) {
- const market = cex.markets[id]
- const marketAssetIDs: [number, number] = [market.baseID, market.quoteID]
- if (mktsEqual([baseID, intermediateAsset], marketAssetIDs) ||
- mktsEqual([intermediateAsset, baseID], marketAssetIDs)) {
- foundBaseMkt = true
- baseMkt = marketAssetIDs
- }
- if (mktsEqual([quoteID, intermediateAsset], marketAssetIDs) ||
- mktsEqual([intermediateAsset, quoteID], marketAssetIDs)) {
- foundQuoteMkt = true
- quoteMkt = marketAssetIDs
- }
- }
- if (foundBaseMkt && foundQuoteMkt) {
- return [baseMkt, quoteMkt]
- }
- return undefined
- }
-
- defaultMultiHopCfg (cexName: string | undefined, baseID: number, quoteID: number, intermediateAsset: number | undefined) : MultiHopCfg | undefined {
- if (!cexName) return undefined
- if (intermediateAsset === undefined) return undefined
-
- const cex = app().mmStatus.cexes[cexName]
- if (!cex) return undefined
- const multiHopMkts = this.findMultiHopMarkets(cexName, baseID, quoteID, intermediateAsset)
- if (!multiHopMkts) return undefined
- return {
- baseAssetMarket: multiHopMkts[0],
- quoteAssetMarket: multiHopMkts[1],
- marketOrders: false,
- limitOrdersBuffer: 0.01
- }
- }
-
- originalMultiHopCfg (savedBotCfg: BotConfig) : MultiHopCfg | undefined {
- if (!savedBotCfg.cexName || !this.intermediateAssets || this.intermediateAssets.length === 0) return undefined
-
- const baseBridgeAssets = Object.keys(this.baseBridges ?? {}).map(Number)
- const quoteBridgeAssets = Object.keys(this.quoteBridges ?? {}).map(Number)
- const defaultCEXBaseID = baseBridgeAssets.length > 0 ? baseBridgeAssets[0] : this.specs.baseID
- const defaultCEXQuoteID = quoteBridgeAssets.length > 0 ? quoteBridgeAssets[0] : this.specs.quoteID
-
- // If there is no saved multi-hop config, use the default.
- if (!savedBotCfg || !savedBotCfg.arbMarketMakingConfig || !savedBotCfg.arbMarketMakingConfig.multiHop) {
- return this.defaultMultiHopCfg(savedBotCfg.cexName, defaultCEXBaseID, defaultCEXQuoteID, this.intermediateAssets ? this.intermediateAssets[0] : undefined)
- }
-
- // Check that the markets in the multi-hop config are still
- // available to trade on the CEX.
- const savedMultiHopCfg = savedBotCfg.arbMarketMakingConfig.multiHop
- const cex = app().mmStatus.cexes[savedBotCfg.cexName]
- if (!cex) return undefined
- let foundSavedBaseMkt = false
- let foundSavedQuoteMkt = false
- const mktsEqual = (mkt1: [number, number], mkt2: [number, number]) => {
- return mkt1[0] === mkt2[0] && mkt1[1] === mkt2[1]
- }
- for (const id of Object.keys(cex.markets)) {
- const market = cex.markets[id]
- const marketAssetIDs: [number, number] = [market.baseID, market.quoteID]
- if (mktsEqual(savedMultiHopCfg.baseAssetMarket, marketAssetIDs)) {
- foundSavedBaseMkt = true
- }
- if (mktsEqual(savedMultiHopCfg.quoteAssetMarket, marketAssetIDs)) {
- foundSavedQuoteMkt = true
- }
- }
-
- // If either of the markets are no longer available, use the default.
- if (!foundSavedBaseMkt || !foundSavedQuoteMkt) {
- const res = this.findMultiHopMarkets(savedBotCfg.cexName, defaultCEXBaseID, defaultCEXQuoteID, this.intermediateAssets[0])
- if (!res) return undefined
- return {
- baseAssetMarket: res[0],
- quoteAssetMarket: res[1],
- marketOrders: savedMultiHopCfg.marketOrders,
- limitOrdersBuffer: savedMultiHopCfg.limitOrdersBuffer
- }
- }
-
- return savedMultiHopCfg
- }
-
- // setOriginalConfigValues sets the initial values of the page's original config
- // based on the savedBotCfg. This should be called after the originalConfig
- // has been initialized with the default values.
- setOriginalConfigValues (savedBotCfg: BotConfig | undefined) {
- if (!savedBotCfg) return
- const { basicMarketMakingConfig: mmCfg, arbMarketMakingConfig: arbMMCfg, simpleArbConfig: arbCfg } = savedBotCfg
- const oldCfg = this.originalConfig
-
- // This is kinda sloppy, but we'll copy any relevant issues from the
- // old config into the originalConfig.
- const idx = savedBotCfg as { [k: string]: any } // typescript
- for (const [k, v] of Object.entries(savedBotCfg)) if (idx[k] !== undefined) idx[k] = v
-
- oldCfg.baseOptions = savedBotCfg.baseWalletOptions || {}
- oldCfg.quoteOptions = savedBotCfg.quoteWalletOptions || {}
- oldCfg.cexBaseID = savedBotCfg.cexBaseID
- oldCfg.cexQuoteID = savedBotCfg.cexQuoteID
- if (savedBotCfg.uiConfig) oldCfg.uiConfig = savedBotCfg.uiConfig
- if (this.runningBot && !savedBotCfg.uiConfig.usingQuickBalance) {
- // If the bot is running and we are allocating manually, initialize
- // the allocations to 0.
- oldCfg.uiConfig.allocation = { dex: {}, cex: {} }
- }
-
- if (mmCfg) {
- oldCfg.buyPlacements = mmCfg.buyPlacements
- oldCfg.sellPlacements = mmCfg.sellPlacements
- oldCfg.driftTolerance = mmCfg.driftTolerance
- oldCfg.gapStrategy = mmCfg.gapStrategy
- } else if (arbMMCfg) {
- const { buyPlacements, sellPlacements } = arbMMCfg
- oldCfg.buyPlacements = Array.from(buyPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } })
- oldCfg.sellPlacements = Array.from(sellPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } })
- oldCfg.profit = arbMMCfg.profit
- oldCfg.driftTolerance = arbMMCfg.driftTolerance
- oldCfg.orderPersistence = arbMMCfg.orderPersistence
- oldCfg.multiHop = this.originalMultiHopCfg(savedBotCfg)
- oldCfg.baseBridgeName = savedBotCfg.baseBridgeName || ''
- oldCfg.quoteBridgeName = savedBotCfg.quoteBridgeName || ''
- } else if (arbCfg) {
- // TODO: expose maxActiveArbs
- oldCfg.profit = arbCfg.profitTrigger
- oldCfg.orderPersistence = arbCfg.numEpochsLeaveOpen
- }
- }
-
- async configureUI () {
- const { page, specs } = this
- const { host, baseID, quoteID, cexName, botType } = specs
-
- // Set the visibility of fee asset sections.
- this.fundingFeesCache = {}
- const { baseFeeAssetID, quoteFeeAssetID } = this.walletStuff()
- const baseFeeNotTraded = baseFeeAssetID !== baseID && baseFeeAssetID !== quoteID
- const quoteFeeNotTraded = quoteFeeAssetID !== baseID && quoteFeeAssetID !== quoteID
- Doc.setVis(baseFeeNotTraded || quoteFeeNotTraded, page.buyFeeReserveSection, page.sellFeeReserveSection)
-
- // Get all assets, and hide page if any fiat rates are missing.
- const [{ symbol: baseSymbol, token: baseToken }, { symbol: quoteSymbol, token: quoteToken }] = [app().assets[baseID], app().assets[quoteID]]
- this.dexMktID = `${baseSymbol}_${quoteSymbol}`
- Doc.hide(page.botSettingsContainer, page.marketBox, page.resetButton, page.noMarket, page.missingFiatRates, page.intermediateAssetBox)
- if ([baseID, quoteID, baseToken?.parentID ?? baseID, quoteToken?.parentID ?? quoteID].some((assetID: number) => !app().fiatRatesMap[assetID])) {
- Doc.show(page.missingFiatRates)
- return
- }
-
- Doc.show(page.marketLoading)
- State.storeLocal(specLK, specs)
-
- // Allow deletion of bot if it is not running or we are not switching bot types.
- const mmStatus = app().mmStatus
- this.runningBot = botIsRunning(specs, mmStatus)
- Doc.setVis(this.runningBot, page.runningBotAllocationNote)
- let savedBotCfg = liveBotConfig(host, baseID, quoteID)
- if (savedBotCfg) {
- const oldBotType = savedBotCfg.arbMarketMakingConfig ? botTypeArbMM : savedBotCfg.basicMarketMakingConfig ? botTypeBasicMM : botTypeBasicArb
- if (oldBotType !== botType) savedBotCfg = undefined
- }
- Doc.setVis(savedBotCfg && !this.runningBot, page.deleteBttnBox)
-
- // Only allow changing the bot type if the bot is not running.
- page.marketHeader.classList.remove('hoverbg', 'pointer')
- page.botTypeHeader.classList.remove('hoverbg', 'pointer')
- if (!this.runningBot) {
- page.botTypeHeader.classList.add('hoverbg', 'pointer')
- page.marketHeader.classList.add('hoverbg', 'pointer')
- }
-
- // Determine all default bridging and multi-hop related values
- let cexBaseID = baseID
- let cexQuoteID = quoteID
- let intermediateAssets: number[] | undefined
- let baseBridgeName = ''
- let quoteBridgeName = ''
- if (cexName) {
- let supportsDirectArb : boolean
- [supportsDirectArb, this.intermediateAssets, this.baseBridges, this.quoteBridges] = this.cexSupportsArbOnMarket(baseID, quoteID, mmStatus.cexes[cexName])
- if (!supportsDirectArb && this.intermediateAssets.length === 0) {
- console.error(`CEX does not support arb on market: ${cexName} ${baseID} ${quoteID}`)
- Doc.show(page.missingFiatRates)
- return
- }
- if (!supportsDirectArb && botType !== botTypeArbMM) {
- console.error(`Only arbMM bots can use multi-hop arb: ${cexName} ${baseID} ${quoteID}`)
- Doc.show(page.missingFiatRates)
- return
- }
- const baseBridgeAssets = Object.keys(this.baseBridges).map(Number)
- if (baseBridgeAssets.length > 0) {
- cexBaseID = baseBridgeAssets[0]
- baseBridgeName = this.baseBridges[cexBaseID][0]
- } else {
- cexBaseID = baseID
- }
- const quoteBridgeAssets = Object.keys(this.quoteBridges).map(Number)
- if (quoteBridgeAssets.length > 0) {
- cexQuoteID = quoteBridgeAssets[0]
- quoteBridgeName = this.quoteBridges[cexQuoteID][0]
- } else {
- cexQuoteID = quoteID
- }
- } else {
- this.baseBridges = undefined
- this.quoteBridges = undefined
- this.intermediateAssets = undefined
- }
-
- const { minBaseWithdraw, minQuoteWithdraw } = this.minWithdrawals(cexBaseID, cexQuoteID, cexName)
- const oldCfg = this.originalConfig = Object.assign({}, defaultMarketMakingConfig, {
- baseOptions: this.defaultWalletOptions(baseID),
- quoteOptions: this.defaultWalletOptions(quoteID),
- buyPlacements: [],
- sellPlacements: [],
- cexBaseID: cexBaseID,
- cexQuoteID: cexQuoteID,
- baseBridgeName: baseBridgeName,
- quoteBridgeName: quoteBridgeName,
- multiHop: this.defaultMultiHopCfg(cexName, cexBaseID, cexQuoteID, intermediateAssets ? intermediateAssets[0] : undefined),
- uiConfig: defaultUIConfig(minBaseWithdraw, minQuoteWithdraw, botType)
- }) as ConfigState
-
- // Update original config values based on saved bot config
- this.creatingNewBot = !savedBotCfg
- this.setOriginalConfigValues(savedBotCfg)
- this.updatedConfig = JSON.parse(JSON.stringify(oldCfg))
- await this.setAvailableBalances()
- await this.fetchMarketReport()
- this.clampOriginalAllocations(oldCfg.uiConfig)
- this.updatedConfig = JSON.parse(JSON.stringify(oldCfg))
-
- this.setupAllocationTable()
- this.setupManualBalanceEntries()
- this.updateManualBalanceEntries()
-
- // Setup the multi-hop market selection UI
- if (intermediateAssets !== undefined && intermediateAssets.length > 0) { // MultiHopArbMarket[]
- Doc.empty(page.intermediateAssetSelect)
- for (const intermediateAsset of intermediateAssets) {
- const intermediateAssetSymbol = app().assets[intermediateAsset].symbol.toUpperCase()
- const opt = document.createElement('option')
- opt.value = String(intermediateAsset)
- opt.textContent = intermediateAssetSymbol
- if (oldCfg.intermediateAsset === intermediateAsset) opt.selected = true
- page.intermediateAssetSelect.appendChild(opt)
- }
- Doc.show(page.bridgeAssetBox)
- }
-
- // Show/hide multi-hop completion section based on whether multi-hop is required
- const requiresMultiHop = intermediateAssets !== undefined && intermediateAssets.length > 0
- Doc.setVis(requiresMultiHop, page.multiHopCompletionBox)
-
- Doc.setVis(this.runningBot, page.updateRunningButton)
- Doc.setVis(!this.runningBot, page.updateStartButton, page.updateButton)
-
- switch (botType) {
- case botTypeBasicMM:
- page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_BASIC_MM)
- break
- case botTypeArbMM:
- page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_ARB_MM)
- break
- case botTypeBasicArb:
- page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_SIMPLE_ARB)
- }
-
- setMarketElements(document.body, baseID, quoteID, host)
- Doc.setVis(botType === botTypeBasicArb, page.numBuysLabel, page.numSellsLabel)
- Doc.setVis(botType !== botTypeBasicArb, page.driftToleranceBox, page.switchToAdvanced, page.qcTitle,
- page.buyBufferLabel, page.sellBufferLabel)
- Doc.setVis(Boolean(cexName), ...Doc.applySelector(document.body, '[data-cex-show]'))
-
- Doc.setVis(this.runningBot, page.botRunningMsg)
-
- await this.updateAllocations()
-
- if (cexName) {
- setCexElements(document.body, cexName)
- this.setupMinTransferInputs()
- }
-
- Doc.setVis(cexName, page.rebalanceSection, page.adjustManuallyCexBalances)
-
- const lotSizeUSD = this.lotSizeUSD()
- this.lotsPerLevelIncrement = Math.round(Math.max(1, defaultLotsPerLevel.usdIncrement / lotSizeUSD))
- this.qcLotsPerLevel.inc = this.lotsPerLevelIncrement
- this.qcUSDPerSide.inc = this.lotsPerLevelIncrement * lotSizeUSD
- this.qcUSDPerSide.min = lotSizeUSD
-
- const { marketReport: { baseFiatRate } } = this
- this.placementsChart.setMarket({ cexName: cexName as string, botType, baseFiatRate, dict: this.updatedConfig })
-
- // If this is a new bot, show the quick config form.
- const isQuickPlacements = !savedBotCfg || this.isQuickPlacements(this.updatedConfig.buyPlacements, this.updatedConfig.sellPlacements)
- const gapStrategy = savedBotCfg?.basicMarketMakingConfig?.gapStrategy ?? GapStrategyPercentPlus
- page.gapStrategySelect.value = gapStrategy
- if (botType === botTypeBasicArb || (isQuickPlacements && gapStrategy === GapStrategyPercentPlus)) this.showQuickConfig()
- else this.showAdvancedConfig()
-
- this.setOriginalValues()
-
- // Initialize multi-hop order type radio buttons and limit order buffer section visibility
- if (this.updatedConfig.multiHop && typeof this.updatedConfig.multiHop === 'object' && Doc.isDisplayed(page.multiHopCompletionBox)) {
- if (this.updatedConfig.multiHop.marketOrders) {
- page.multiHopMarketOrder.checked = true
- page.multiHopLimitOrder.checked = false
- Doc.hide(page.limitOrderBufferSection)
+ if (specs) State.storeLocal(specLK, specs)
+
+ // If this is a refresh, or we are loading a saved config, get the initial state
+ // of the react component.
+ const bridgePaths = await app().allBridgePaths()
+ let botConfigStateOnLoad: BotConfigState | string | undefined
+ let intermediateAssets: number[] | null = null
+ let cexStatus: MMCEXStatus | null = null
+
+ if (specs) {
+ let baseBridges: Record | null = null
+ let quoteBridges: Record | null = null
+
+ if (specs.cexName) {
+ let supportsDirectArb: boolean
+ cexStatus = app().mmStatus.cexes[specs.cexName] ?? null;
+ [supportsDirectArb, intermediateAssets, baseBridges, quoteBridges] = cexSupportsArbOnMarket(
+ specs.baseID,
+ specs.quoteID,
+ cexStatus,
+ bridgePaths)
+ if (!supportsDirectArb && (!intermediateAssets || intermediateAssets.length === 0)) {
+ throw new Error(`CEX does not support arb on market: ${specs.cexName} ${specs.baseID} ${specs.quoteID}`)
+ }
+ }
+
+ const savedBotConfig = liveBotConfig(specs.host, specs.baseID, specs.quoteID)
+ if (savedBotConfig) {
+ botConfigStateOnLoad = await botConfigStateFromSavedConfig(
+ savedBotConfig, cexStatus, intermediateAssets, baseBridges, quoteBridges)
} else {
- page.multiHopMarketOrder.checked = false
- page.multiHopLimitOrder.checked = true
- Doc.show(page.limitOrderBufferSection)
+ botConfigStateOnLoad = await initialBotConfigState(specs.host,
+ specs.baseID, specs.quoteID, specs.botType, intermediateAssets,
+ baseBridges, quoteBridges, cexStatus, specs.cexName)
}
}
- this.setupBridgeUI()
- await this.populateBridgeFeesAndLimits()
-
- // Set the visibility of bridge fee reserve section.
- const cfg = this.updatedConfig
- const baseBridgingRequired = cfg.baseBridgeName && cfg.cexBaseID
- const quoteBridgingRequired = cfg.quoteBridgeName && cfg.cexQuoteID
- Doc.setVis(baseBridgingRequired || quoteBridgingRequired, page.bridgeFeeReserveSection)
-
- Doc.hide(page.marketLoading)
- Doc.show(page.botSettingsContainer, page.marketBox)
- }
-
- requiredDexAssets (baseID: number, quoteID: number, cexBaseID: number, cexQuoteID: number) : number[] {
- const assetIDs = [baseID, quoteID]
- const addAssetID = (assetID: number) => {
- if (!assetIDs.includes(assetID)) assetIDs.push(assetID)
- }
-
- const baseAsset = app().assets[baseID]
- const baseAssetFeeID = baseAsset.token ? baseAsset.token.parentID : baseID
- addAssetID(baseAssetFeeID)
-
- const quoteAsset = app().assets[quoteID]
- const quoteAssetFeeID = quoteAsset.token ? quoteAsset.token.parentID : quoteID
- addAssetID(quoteAssetFeeID)
-
- if (baseID !== cexBaseID && this.updatedConfig.uiConfig.cexRebalance) {
- const cexBaseAsset = app().assets[cexBaseID]
- const cexBaseAssetFeeID = cexBaseAsset.token ? cexBaseAsset.token.parentID : cexBaseID
- addAssetID(cexBaseAssetFeeID)
- }
-
- if (quoteID !== cexQuoteID && this.updatedConfig.uiConfig.cexRebalance) {
- const cexQuoteAsset = app().assets[cexQuoteID]
- const cexQuoteAssetFeeID = cexQuoteAsset.token ? cexQuoteAsset.token.parentID : cexQuoteID
- addAssetID(cexQuoteAssetFeeID)
- }
-
- return assetIDs
- }
-
- setupMinTransferInputs () {
- const { bui, qui } = this.walletStuff()
- const { cexName } = this.specs
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const { minBaseWithdraw, minQuoteWithdraw } = this.minWithdrawals(cexBaseID, cexQuoteID, cexName)
- console.log({ minBaseWithdraw, minQuoteWithdraw })
- this.baseMinTransferInput.min = minBaseWithdraw / bui.conventional.conversionFactor
- this.quoteMinTransferInput.min = minQuoteWithdraw / qui.conventional.conversionFactor
- this.baseMinTransferInput.prec = Math.log10(bui.conventional.conversionFactor)
- this.quoteMinTransferInput.prec = Math.log10(qui.conventional.conversionFactor)
- }
-
- setupManualBalanceEntries () {
- const createEntrySection = (assetID: number, location: 'dex' | 'cex') : [PageElement, NumberInput, MiniSlider] => {
- const asset = app().assets[assetID]
- const tmpl = this.page.manualBalanceEntryTmpl.cloneNode(true) as PageElement
- const tmplEl = Doc.parseTemplate(tmpl)
- tmplEl.logo.src = Doc.logoPath(asset.symbol)
- tmplEl.ticker.textContent = asset.unitInfo.conventional.unit
-
- let inputHandler: (amt: number) => void = () => { /* implemented below */ }
- let sliderHandler: (amt: number) => void = () => { /* implemented below */ }
-
- const prec = Math.log10(asset.unitInfo.conventional.conversionFactor)
- const input = new NumberInput(tmplEl.input, { prec, min: 0, changed: (amt) => inputHandler(amt) })
- const slider = new MiniSlider(tmplEl.slider, (amt) => sliderHandler(amt))
-
- inputHandler = (amt: number) => {
- const [min, max] = this.validManualBalanceRange(assetID, location, false)
- const ui = app().assets[assetID].unitInfo
- amt = amt * ui.conventional.conversionFactor
- if (amt > max || amt < min) {
- if (amt > max) amt = max
- else amt = min
- input.setValue(amt / ui.conventional.conversionFactor)
- }
- slider.setValue((amt - min) / (max - min))
- this.setConfigAllocation(amt, assetID, location)
- }
-
- sliderHandler = (amt: number) => {
- const [min, max] = this.validManualBalanceRange(assetID, location, false)
- const ui = app().assets[assetID].unitInfo
- amt = (max - min) * amt + min
- if (amt < 0) amt = Math.ceil(amt)
- else amt = Math.floor(amt)
- input.setValue(amt / ui.conventional.conversionFactor)
- this.setConfigAllocation(amt, assetID, location)
- }
-
- return [tmpl, input, slider]
- }
-
- const { baseID, quoteID } = this.walletStuff()
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
- const { dexManualBalanceEntrySection: dexSection, cexManualBalanceEntrySection: cexSection } = this.page
- Doc.empty(dexSection, cexSection)
- this.manualBalanceInputs = { dex: {}, cex: {} }
-
- for (let i = 0; i < dexAssetIDs.length; i++) {
- const [entry, input, slider] = createEntrySection(dexAssetIDs[i], 'dex')
- dexSection.appendChild(entry)
- this.manualBalanceInputs.dex[dexAssetIDs[i]] = [input, slider]
- }
-
- for (const assetID of [cexBaseID, cexQuoteID]) {
- const [entry, input, slider] = createEntrySection(assetID, 'cex')
- cexSection.appendChild(entry)
- this.manualBalanceInputs.cex[assetID] = [input, slider]
- }
+ // Create React root and render component with market data
+ this.reactRoot = createRoot(reactContainer)
+ this.reactRoot.render(React.createElement(MMSettings, {
+ ref: this.mmSettingsRef,
+ availableMarkets: this.initializeMarketRows(),
+ initialCexes: app().mmStatus.cexes,
+ bridgePaths: bridgePaths,
+ initialSpecs: specs,
+ botConfigStateOnLoad
+ }))
}
-
- setupAllocationTable () {
- const { page } = this
- const { baseID, quoteID } = this.walletStuff()
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
-
- // Get table elements
- const headerRow = page.minAllocationTableHeader
- const dexRow = page.minAllocationTableDexRow
- const cexRow = page.minAllocationTableCexRow
-
- // Clear existing columns
- while (headerRow.children.length > 1 && headerRow.lastChild) {
- headerRow.removeChild(headerRow.lastChild)
- }
- while (dexRow.children.length > 1 && dexRow.lastChild) {
- dexRow.removeChild(dexRow.lastChild)
- }
-
- Doc.setVis(this.specs.cexName, cexRow)
- if (this.specs.cexName) {
- while (cexRow.children.length > 1 && cexRow.lastChild) {
- cexRow.removeChild(cexRow.lastChild)
- }
- }
-
- // Add columns for all assets. DEX row requires all asset,
- for (let i = 0; i < dexAssetIDs.length; i++) {
- const assetID = dexAssetIDs[i]
- const asset = app().assets[assetID]
-
- // Add header
- const th = document.createElement('th')
- th.className = 'text-center'
- const img = document.createElement('img')
- img.className = 'mini-icon'
- img.src = Doc.logoPath(asset.symbol)
- const span = document.createElement('span')
- span.textContent = asset.unitInfo.conventional.unit
- th.appendChild(img)
- th.appendChild(span)
- headerRow.appendChild(th)
-
- // Add DEX data cell
- const dexTd = document.createElement('td')
- dexTd.className = 'text-center border'
- dexRow.appendChild(dexTd)
-
- // Add CEX data cell
- if (i < 2 && this.specs.cexName) {
- const cexTd = document.createElement('td')
- cexTd.className = 'text-center border'
- cexRow.appendChild(cexTd)
- }
- }
-
- this.updateAllocations()
- }
-
- initializeMarketRows () {
- this.marketRows = []
- Doc.empty(this.page.marketSelect)
- for (const { host, markets, assets, auth: { effectiveTier, pendingStrength } } of Object.values(app().exchanges)) {
- if (effectiveTier + pendingStrength === 0) {
- const { needRegTmpl, needRegBox } = this.page
- const bttn = needRegTmpl.cloneNode(true) as PageElement
- const tmpl = Doc.parseTemplate(bttn)
- Doc.bind(bttn, 'click', () => { app().loadPage('register', { host, backTo: 'mmsettings' }) })
- tmpl.host.textContent = host
- needRegBox.appendChild(bttn)
- Doc.show(needRegBox)
- continue
- }
- for (const { name, baseid: baseID, quoteid: quoteID, spot, basesymbol: baseSymbol, quotesymbol: quoteSymbol } of Object.values(markets)) {
- if (!app().assets[baseID] || !app().assets[quoteID]) continue
- const tr = this.page.marketRowTmpl.cloneNode(true) as PageElement
- const tmpl = Doc.parseTemplate(tr)
- const mr = { tr, tmpl, host: host, name, baseID, quoteID, spot: spot, arbs: [] } as MarketRow
- this.marketRows.push(mr)
- this.page.marketSelect.appendChild(tr)
- tmpl.baseIcon.src = Doc.logoPath(baseSymbol)
- tmpl.quoteIcon.src = Doc.logoPath(quoteSymbol)
- tmpl.baseSymbol.appendChild(Doc.symbolize(assets[baseID], true))
- tmpl.quoteSymbol.appendChild(Doc.symbolize(assets[quoteID], true))
- tmpl.host.textContent = host
- const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
- for (const [cexName, dinfo] of Object.entries(CEXDisplayInfos)) {
- if (cexHasMarket(cexName)) {
- const img = this.page.arbBttnTmpl.cloneNode(true) as PageElement
- img.src = dinfo.logo
- tmpl.arbs.appendChild(img)
- mr.arbs.push(cexName)
- }
- }
- Doc.bind(tr, 'click', () => { this.showBotTypeForm(host, baseID, quoteID) })
- }
- }
- if (this.marketRows.length === 0) {
- const { marketSelectionTable, marketFilterBox, noMarkets } = this.page
- Doc.hide(marketSelectionTable, marketFilterBox)
- Doc.show(noMarkets)
- } else Doc.hide(this.page.noMarkets)
- const fiatRates = app().fiatRatesMap
- this.marketRows.sort((a: MarketRow, b: MarketRow) => {
- let [volA, volB] = [a.spot?.vol24 ?? 0, b.spot?.vol24 ?? 0]
- if (fiatRates[a.baseID] && fiatRates[b.baseID]) {
- volA *= fiatRates[a.baseID]
- volB *= fiatRates[b.baseID]
- }
- return volB - volA
- })
- }
-
- runningBotInventory (assetID: number) {
- return runningBotInventory(assetID)
- }
-
- setAllocationTechnique (quick: boolean) {
- const { page, updatedConfig } = this
- updatedConfig.uiConfig.usingQuickBalance = quick
- this.updateAllocations()
- Doc.setVis(quick, page.quickAllocateSection)
- Doc.setVis(!quick, page.manuallyAllocateSection)
- }
-
- quickBalanceMin (config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') : number {
- const { botType } = this.marketStuff()
- switch (config) {
- case 'buyBuffer': return botType === botTypeBasicArb ? 1 : 0
- case 'sellBuffer': return botType === botTypeBasicArb ? 1 : 0
- case 'slippageBuffer': return 0
- case 'buyFeeReserve': return botType === botTypeBasicArb ? 1 : 0
- case 'sellFeeReserve': return botType === botTypeBasicArb ? 1 : 0
- case 'bridgeFeeReserve': return 1
- }
- }
-
- quickBalanceMax (config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') : number {
- const { buyLots, sellLots, botType } = this.marketStuff()
- switch (config) {
- case 'buyBuffer': return botType === botTypeBasicArb ? 20 : 3 * buyLots
- case 'sellBuffer': return botType === botTypeBasicArb ? 20 : 3 * sellLots
- case 'slippageBuffer': return 100
- case 'buyFeeReserve': return 1000
- case 'sellFeeReserve': return 1000
- case 'bridgeFeeReserve': return 100
- }
- }
-
- quickBalanceInput (config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') : NumberInput {
- switch (config) {
- case 'buyBuffer': return this.buyBufferInput
- case 'sellBuffer': return this.sellBufferInput
- case 'slippageBuffer': return this.slippageBufferInput
- case 'buyFeeReserve': return this.buyFeeReserveInput
- case 'sellFeeReserve': return this.sellFeeReserveInput
- case 'bridgeFeeReserve': return this.bridgeFeeReserveInput
- }
- }
-
- quickBalanceSlider (config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') : MiniSlider {
- switch (config) {
- case 'buyBuffer': return this.buyBufferSlider
- case 'sellBuffer': return this.sellBufferSlider
- case 'slippageBuffer': return this.slippageBufferSlider
- case 'buyFeeReserve': return this.buyFeeReserveSlider
- case 'sellFeeReserve': return this.sellFeeReserveSlider
- case 'bridgeFeeReserve': return this.bridgeFeeReserveSlider
- }
- }
-
- // fundingFees fetches the funding fees (fees for split transactions) required
- // for a given number of buys and sells. To avoid excessive calls, the results
- // are cached.
- async fundingFees (numBuys: number, numSells: number) : Promise<[number, number]> {
- const { updatedConfig: { baseOptions, quoteOptions }, fundingFeesCache, specs: { host, baseID, quoteID } } = this
- const cacheKey = `${numBuys}-${numSells}-${JSON.stringify(baseOptions)}-${JSON.stringify(quoteOptions)}`
- if (fundingFeesCache[cacheKey] !== undefined) return fundingFeesCache[cacheKey]
- const res = await MM.maxFundingFees({ host, baseID, quoteID }, numBuys, numSells, baseOptions, quoteOptions)
- fundingFeesCache[cacheKey] = [res.buyFees, res.sellFees]
- return [res.buyFees, res.sellFees]
- }
-
- updateManualBalanceEntries () {
- const { baseID, quoteID } = this.walletStuff()
- const { allocation } = this.updatedConfig.uiConfig
-
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID)
- let cexAssetIDs : number[] = []
- if (this.specs.cexName) {
- cexAssetIDs = [this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID]
- }
-
- const updateBalanceInputs = (assetIDs: number[], location: 'dex' | 'cex', allocations: Record) => {
- for (const assetID of assetIDs) {
- const asset = app().assets[assetID]
- const allocation = allocations[assetID] ?? 0
- const [input, slider] = this.manualBalanceInputs[location][assetID]
- const [min, max] = this.validManualBalanceRange(assetID, location, false)
- input.min = min / asset.unitInfo.conventional.conversionFactor
- input.setValue(allocation / asset.unitInfo.conventional.conversionFactor)
- slider.setValue((allocation - min) / (max - min))
- }
- }
-
- updateBalanceInputs(dexAssetIDs, 'dex', allocation.dex)
- updateBalanceInputs(cexAssetIDs, 'cex', allocation.cex)
- }
-
- populateAllocationTable (allocationResult: AllocationResult) {
- const setColor = (el: PageElement, status: AllocationStatus) => {
- el.classList.remove('text-buycolor', 'text-danger', 'text-warning')
- switch (status) {
- case 'sufficient': el.classList.add('text-buycolor'); break
- case 'insufficient': el.classList.add('text-danger'); break
- case 'sufficient-with-rebalance': el.classList.add('text-warning'); break
- }
- }
-
- const format = (v: number, unitInfo: UnitInfo) => v ? Doc.formatCoinValue(v, unitInfo) : '0'
-
- const populateAllocationRow = (assetIDs: number[], row: PageElement, allocations: Record) => {
- for (let i = 0; i < assetIDs.length; i++) {
- const td = row.children[i + 1] as PageElement
- const assetID = assetIDs[i]
- const asset = app().assets[assetID]
- const alloc = allocations[assetID] ? allocations[assetID].amount : 0
- td.textContent = format(alloc, asset.unitInfo)
- setColor(td, allocations[assetID]?.status ?? 'insufficient')
- }
- }
-
- const { minAllocationTableDexRow: dexRow, minAllocationTableCexRow: cexRow } = this.page
-
- const { baseID, quoteID } = this.walletStuff()
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID)
- populateAllocationRow(dexAssetIDs, dexRow, allocationResult.dex)
-
- if (this.specs.cexName) {
- const cexAssetIDs = [this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID]
- populateAllocationRow(cexAssetIDs, cexRow, allocationResult.cex)
- }
- }
-
- feeAsset (assetID: number) : number {
- const asset = app().assets[assetID]
- if (asset.token) return asset.token.parentID
- return assetID
- }
-
- // perLotRequirements calculates the funding requirements for a single buy and sell lot.
- perLotRequirements () : { perSellLot: PerLot, perBuyLot: PerLot } {
- const {
- baseID, quoteID, baseFeeAssetID, quoteFeeAssetID,
- baseIsAccountLocker, quoteIsAccountLocker, lotSize, quoteLot
- } = this.marketStuff()
-
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const { slippageBuffer } = this.updatedConfig.uiConfig.quickBalance
-
- const perSellLot: PerLot = { cex: {}, dex: {} }
- const perBuyLot: PerLot = { cex: {}, dex: {} }
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
- const cexAssetIDs = [cexBaseID, cexQuoteID]
-
- for (const assetID of dexAssetIDs) {
- perSellLot.dex[assetID] = newPerLotBreakdown()
- perBuyLot.dex[assetID] = newPerLotBreakdown()
- }
- for (const assetID of cexAssetIDs) {
- perSellLot.cex[assetID] = newPerLotBreakdown()
- perBuyLot.cex[assetID] = newPerLotBreakdown()
- }
-
- perSellLot.dex[baseID].tradedAmount = lotSize
- perSellLot.dex[baseFeeAssetID].fees.swap = this.marketReport.baseFees.max.swap
- perSellLot.cex[cexQuoteID].tradedAmount = quoteLot
- perSellLot.cex[cexQuoteID].slippageBuffer = slippageBuffer
- perSellLot.dex[baseFeeAssetID].fees.funding = this.oneTradeSellFundingFees
- if (baseIsAccountLocker) perSellLot.dex[baseFeeAssetID].fees.refund = this.marketReport.baseFees.max.refund
- if (quoteIsAccountLocker) perSellLot.dex[quoteFeeAssetID].fees.redeem = this.marketReport.quoteFees.max.redeem
-
- perBuyLot.dex[quoteID].tradedAmount = quoteLot
- perBuyLot.dex[quoteID].multiSplitBuffer = this.quoteMultiSplitBuffer()
- perBuyLot.dex[quoteID].slippageBuffer = slippageBuffer
- perBuyLot.cex[cexBaseID].tradedAmount = lotSize
- perBuyLot.dex[quoteFeeAssetID].fees.swap = this.marketReport.quoteFees.max.swap
- perBuyLot.dex[quoteFeeAssetID].fees.funding = this.oneTradeBuyFundingFees
- if (baseIsAccountLocker) perBuyLot.dex[baseFeeAssetID].fees.redeem = this.marketReport.baseFees.max.redeem
- if (quoteIsAccountLocker) perBuyLot.dex[quoteFeeAssetID].fees.refund = this.marketReport.quoteFees.max.refund
-
- const calculateTotalAmount = (perLot: PerLotBreakdown) : number => {
- let total = perLot.tradedAmount
- const slippagePercentage = perLot.slippageBuffer / 100
- const multiSplitPercentage = perLot.multiSplitBuffer / 100
- total *= (1 + slippagePercentage + multiSplitPercentage)
- total = Math.floor(total)
- total += perLot.fees.swap + perLot.fees.redeem + perLot.fees.refund + perLot.fees.funding
- return total
- }
-
- for (const assetID of dexAssetIDs) {
- perSellLot.dex[assetID].totalAmount = calculateTotalAmount(perSellLot.dex[assetID])
- perBuyLot.dex[assetID].totalAmount = calculateTotalAmount(perBuyLot.dex[assetID])
- }
- for (const assetID of cexAssetIDs) {
- perSellLot.cex[assetID].totalAmount = calculateTotalAmount(perSellLot.cex[assetID])
- perBuyLot.cex[assetID].totalAmount = calculateTotalAmount(perBuyLot.cex[assetID])
- }
-
- return { perSellLot, perBuyLot }
- }
-
- // requiredFunds calculates the total funds required for a bot based on the quick
- // allocation settings.
- requiredFunds () : AllocationResult {
- const {
- sellLots, buyLots, baseID, quoteID, baseFeeAssetID, quoteFeeAssetID,
- baseIsAccountLocker, quoteIsAccountLocker
- } = this.marketStuff()
-
- const { cexBaseID, cexQuoteID } = this.updatedConfig
- const {
- buysBuffer, sellsBuffer, buyFeeReserve, sellFeeReserve, bridgeFeeReserve
- } = this.updatedConfig.uiConfig.quickBalance
-
- const numBuyLots = buysBuffer + buyLots
- const numSellLots = sellsBuffer + sellLots
-
- const toAllocate: AllocationResult = { dex: {}, cex: {} }
-
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
- const cexAssetIDs = [cexBaseID, cexQuoteID]
-
- for (const assetID of dexAssetIDs) {
- toAllocate.dex[assetID] = newAllocationDetail()
- }
-
- if (this.specs.cexName) {
- for (const assetID of cexAssetIDs) {
- toAllocate.cex[assetID] = newAllocationDetail()
- }
- }
-
- const { perBuyLot, perSellLot } = this.perLotRequirements()
-
- if (this.specs.cexName) {
- for (const assetID of cexAssetIDs) {
- toAllocate.cex[assetID].calculation.buyLot = perBuyLot.cex[assetID]
- toAllocate.cex[assetID].calculation.sellLot = perSellLot.cex[assetID]
- toAllocate.cex[assetID].calculation.numBuyLots = numBuyLots
- toAllocate.cex[assetID].calculation.numSellLots = numSellLots
- }
- }
-
- for (const assetID of dexAssetIDs) {
- toAllocate.dex[assetID].calculation.buyLot = perBuyLot.dex[assetID]
- toAllocate.dex[assetID].calculation.sellLot = perSellLot.dex[assetID]
- toAllocate.dex[assetID].calculation.numBuyLots = numBuyLots
- toAllocate.dex[assetID].calculation.numSellLots = numSellLots
-
- if (assetID === baseFeeAssetID) {
- toAllocate.dex[assetID].calculation.feeReserves.sellReserves.swap = this.marketReport.baseFees.estimated.swap
- if (baseIsAccountLocker) {
- toAllocate.dex[assetID].calculation.feeReserves.buyReserves.redeem = this.marketReport.baseFees.estimated.redeem
- toAllocate.dex[assetID].calculation.feeReserves.sellReserves.refund = this.marketReport.baseFees.estimated.refund
- }
- toAllocate.dex[assetID].calculation.initialSellFundingFees = this.sellFundingFees
- }
-
- if (assetID === quoteFeeAssetID) {
- toAllocate.dex[assetID].calculation.feeReserves.buyReserves.swap = this.marketReport.quoteFees.estimated.swap
- if (quoteIsAccountLocker) {
- toAllocate.dex[assetID].calculation.feeReserves.sellReserves.redeem = this.marketReport.quoteFees.estimated.redeem
- toAllocate.dex[assetID].calculation.feeReserves.buyReserves.refund = this.marketReport.quoteFees.estimated.refund
- }
- toAllocate.dex[assetID].calculation.initialBuyFundingFees = this.buyFundingFees
- toAllocate.dex[assetID].calculation.initialSellFundingFees = this.sellFundingFees
- }
-
- toAllocate.dex[assetID].calculation.bridgeFeeReserves = bridgeFeeReserve
- if (this.baseBridgeFeesAndLimits) {
- toAllocate.dex[assetID].calculation.bridgeFees += this.baseBridgeFeesAndLimits.withdrawal?.fees[assetID] ?? 0
- toAllocate.dex[assetID].calculation.bridgeFees += this.baseBridgeFeesAndLimits.deposit?.fees[assetID] ?? 0
- }
- if (this.quoteBridgeFeesAndLimits) {
- toAllocate.dex[assetID].calculation.bridgeFees += this.quoteBridgeFeesAndLimits.withdrawal?.fees[assetID] ?? 0
- toAllocate.dex[assetID].calculation.bridgeFees += this.quoteBridgeFeesAndLimits.deposit?.fees[assetID] ?? 0
- }
-
- toAllocate.dex[assetID].calculation.numBuyFeeReserves = buyFeeReserve
- toAllocate.dex[assetID].calculation.numSellFeeReserves = sellFeeReserve
- }
-
- const totalFees = (fees: Fees) : number => {
- return fees.swap + fees.redeem + fees.refund + fees.funding
- }
-
- const calculateTotalRequired = (breakdown: CalculationBreakdown) : number => {
- let total = 0
- total += breakdown.buyLot.totalAmount * breakdown.numBuyLots
- total += breakdown.sellLot.totalAmount * breakdown.numSellLots
- total += totalFees(breakdown.feeReserves.buyReserves) * breakdown.numBuyFeeReserves
- total += totalFees(breakdown.feeReserves.sellReserves) * breakdown.numSellFeeReserves
- total += breakdown.initialBuyFundingFees
- total += breakdown.initialSellFundingFees
- total += breakdown.bridgeFeeReserves * breakdown.bridgeFees
- return total
- }
-
- for (const assetID of dexAssetIDs) {
- toAllocate.dex[assetID].calculation.totalRequired = calculateTotalRequired(toAllocate.dex[assetID].calculation)
- }
-
- if (this.specs.cexName) {
- for (const assetID of cexAssetIDs) {
- toAllocate.cex[assetID].calculation.totalRequired = calculateTotalRequired(toAllocate.cex[assetID].calculation)
- }
- }
-
- return toAllocate
- }
-
- // toAllocate calculates the quick allocations for a bot that is not running.
- toAllocate () : AllocationResult {
- const { specs, updatedConfig } = this
- const availableFunds = { dex: this.availableDEXBalances, cex: this.availableCEXBalances }
- const canRebalance = !!specs.cexName && updatedConfig.uiConfig.cexRebalance
- const result = this.requiredFunds()
-
- const { baseID, quoteID } = this.marketStuff()
- const { cexBaseID, cexQuoteID } = this.updatedConfig
-
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
- const cexAssetIDs = [cexBaseID, cexQuoteID]
-
- let dexBaseSurplus = 0
- let dexQuoteSurplus = 0
- let cexBaseSurplus = 0
- let cexQuoteSurplus = 0
-
- for (const assetID of dexAssetIDs) {
- const allocationDetail = result.dex[assetID]
- allocationDetail.calculation.available = availableFunds.dex[assetID] ?? 0
- const surplus = allocationDetail.calculation.available - allocationDetail.calculation.totalRequired
- if (surplus < 0) {
- allocationDetail.status = 'insufficient'
- allocationDetail.amount = allocationDetail.calculation.available
- } else {
- allocationDetail.amount = allocationDetail.calculation.totalRequired
- }
- if (assetID === baseID) dexBaseSurplus = surplus
- if (assetID === quoteID) dexQuoteSurplus = surplus
- }
-
- if (this.specs.cexName) {
- for (const assetID of cexAssetIDs) {
- const allocationDetail = result.cex[assetID]
- allocationDetail.calculation.available = availableFunds.cex?.[assetID] ?? 0
- const surplus = allocationDetail.calculation.available - allocationDetail.calculation.totalRequired
- if (surplus < 0) {
- allocationDetail.status = 'insufficient'
- allocationDetail.amount = allocationDetail.calculation.available
- } else {
- allocationDetail.amount = allocationDetail.calculation.totalRequired
- }
- if (assetID === cexBaseID) cexBaseSurplus = surplus
- if (assetID === cexQuoteID) cexQuoteSurplus = surplus
- }
- }
-
- const rebalance = (dexAssetID: number, cexAssetID: number, dexSurplus: number, cexSurplus: number) => {
- if (canRebalance && dexSurplus < 0 && cexSurplus > 0) {
- const dexDeficit = -dexSurplus
- const additionalCEX = Math.min(dexDeficit, cexSurplus)
- result.cex[cexAssetID].calculation.rebalanceAdjustment = additionalCEX
- result.cex[cexAssetID].amount += additionalCEX
- if (cexSurplus >= dexDeficit) result.dex[dexAssetID].status = 'sufficient-with-rebalance'
- }
-
- if (canRebalance && cexSurplus < 0 && dexSurplus > 0) {
- const cexDeficit = -cexSurplus
- const additionalDEX = Math.min(cexDeficit, dexSurplus)
- result.dex[dexAssetID].calculation.rebalanceAdjustment = additionalDEX
- result.dex[dexAssetID].amount += additionalDEX
- if (dexSurplus >= cexDeficit) result.cex[cexAssetID].status = 'sufficient-with-rebalance'
- }
- }
-
- rebalance(baseID, cexBaseID, dexBaseSurplus, cexBaseSurplus)
- rebalance(quoteID, cexQuoteID, dexQuoteSurplus, cexQuoteSurplus)
-
- return result
- }
-
- // toAllocateRunning calculates the quick allocations for a running bot.
- toAllocateRunning (runStats: RunStats) : AllocationResult {
- const { specs, updatedConfig } = this
- const availableFunds = { dex: this.availableDEXBalances, cex: this.availableCEXBalances }
- const canRebalance = !!specs.cexName && updatedConfig.uiConfig.cexRebalance
- const result = this.requiredFunds()
-
- const { baseID, quoteID } = this.marketStuff()
- const { cexBaseID, cexQuoteID } = this.updatedConfig
-
- const dexAssetIDs = this.requiredDexAssets(baseID, quoteID, cexBaseID, cexQuoteID)
- const cexAssetIDs = [cexBaseID, cexQuoteID]
-
- const totalBotBalance = (source: 'cex' | 'dex', assetID: number) => {
- let bals
- if (source === 'dex') {
- bals = runStats.dexBalances[assetID] ?? { available: 0, locked: 0, pending: 0, reserved: 0 }
- } else {
- bals = runStats.cexBalances[assetID] ?? { available: 0, locked: 0, pending: 0, reserved: 0 }
- }
- return bals.available + bals.locked + bals.pending + bals.reserved
- }
-
- let dexBaseSurplus = 0
- let dexQuoteSurplus = 0
- let cexBaseSurplus = 0
- let cexQuoteSurplus = 0
-
- for (const assetID of dexAssetIDs) {
- result.dex[assetID].calculation.runningBotTotal = totalBotBalance('dex', assetID)
- result.dex[assetID].calculation.runningBotAvailable = runStats.dexBalances[assetID]?.available ?? 0
- result.dex[assetID].calculation.available = availableFunds.dex[assetID] ?? 0
-
- const dexTotalAvailable = result.dex[assetID].calculation.runningBotTotal + result.dex[assetID].calculation.available
- const surplus = dexTotalAvailable - result.dex[assetID].calculation.totalRequired
-
- if (surplus >= 0) {
- result.dex[assetID].amount = result.dex[assetID].calculation.totalRequired - result.dex[assetID].calculation.runningBotTotal
- if (result.dex[assetID].amount < 0) result.dex[assetID].amount = -Math.min(-result.dex[assetID].amount, result.dex[assetID].calculation.runningBotAvailable)
- } else {
- result.dex[assetID].status = 'insufficient'
- result.dex[assetID].amount = result.dex[assetID].calculation.available
- }
-
- if (assetID === baseID) dexBaseSurplus = surplus
- if (assetID === quoteID) dexQuoteSurplus = surplus
- }
-
- if (this.specs.cexName) {
- for (const assetID of cexAssetIDs) {
- result.cex[assetID].calculation.runningBotTotal = totalBotBalance('cex', assetID)
- result.cex[assetID].calculation.runningBotAvailable = runStats.cexBalances[assetID]?.available ?? 0
- result.cex[assetID].calculation.available = availableFunds.cex?.[assetID] ?? 0
-
- const cexTotalAvailable = result.cex[assetID].calculation.runningBotTotal + result.cex[assetID].calculation.available
- const surplus = cexTotalAvailable - result.cex[assetID].calculation.totalRequired
-
- if (surplus >= 0) {
- result.cex[assetID].amount = result.cex[assetID].calculation.totalRequired - result.cex[assetID].calculation.runningBotTotal
- if (result.cex[assetID].amount < 0) result.cex[assetID].amount = -Math.min(-result.cex[assetID].amount, result.cex[assetID].calculation.runningBotAvailable)
- } else {
- result.cex[assetID].status = 'insufficient'
- result.cex[assetID].amount = result.cex[assetID].calculation.available
- }
-
- if (assetID === cexBaseID) cexBaseSurplus = surplus
- if (assetID === cexQuoteID) cexQuoteSurplus = surplus
- }
- }
-
- const rebalance = (dexAssetID: number, cexAssetID: number, dexSurplus: number, cexSurplus: number) => {
- if (canRebalance && dexSurplus < 0 && cexSurplus > 0) {
- const dexDeficit = -dexSurplus
- const additionalCEX = Math.min(dexDeficit, cexSurplus)
- result.cex[cexAssetID].calculation.rebalanceAdjustment = additionalCEX
- result.cex[cexAssetID].amount += additionalCEX
- if (cexSurplus >= dexDeficit) result.dex[dexAssetID].status = 'sufficient-with-rebalance'
- }
-
- if (canRebalance && cexSurplus < 0 && dexSurplus > 0) {
- const cexDeficit = -cexSurplus
- const additionalDEX = Math.min(cexDeficit, dexSurplus)
- result.dex[dexAssetID].calculation.rebalanceAdjustment = additionalDEX
- result.dex[dexAssetID].amount += additionalDEX
- if (dexSurplus >= cexDeficit) result.cex[cexAssetID].status = 'sufficient-with-rebalance'
- }
- }
-
- if (this.specs.cexName) {
- rebalance(baseID, cexBaseID, dexBaseSurplus, cexBaseSurplus)
- rebalance(quoteID, cexQuoteID, dexQuoteSurplus, cexQuoteSurplus)
- }
-
- return result
- }
-
- // updateAllocates updates the required allocations if quick balance config is
- // being used.
- async updateAllocations () {
- const { updatedConfig } = this
- if (!updatedConfig.uiConfig.usingQuickBalance) return
-
- const {
- numBuys, numSells
- } = this.marketStuff()
-
- const [oneTradeBuyFundingFees, oneTradeSellFundingFees] = await this.fundingFees(1, 1)
- const [buyFundingFees, sellFundingFees] = await this.fundingFees(numBuys, numSells)
-
- this.oneTradeBuyFundingFees = oneTradeBuyFundingFees
- this.oneTradeSellFundingFees = oneTradeSellFundingFees
- this.buyFundingFees = buyFundingFees
- this.sellFundingFees = sellFundingFees
-
- let toAlloc : AllocationResult
- if (this.runningBot) {
- const { runStats } = this.status()
- if (!runStats) {
- console.error('cannot find run stats for running bot')
- return
- }
- toAlloc = this.toAllocateRunning(runStats)
- } else {
- toAlloc = this.toAllocate()
- }
-
- const botBalanceAllocation = allocationResultToBotBalanceAllocation(toAlloc)
- this.updatedConfig.uiConfig.allocation = botBalanceAllocation
- this.populateAllocationTable(toAlloc)
- this.updateManualBalanceEntries()
- this.updateRebalanceSection()
- }
-
- updateRebalanceSection () {
- const { page, updatedConfig } = this
- const { cexBaseID, cexQuoteID } = updatedConfig
- const { bui, qui } = this.walletStuff()
- const cexRebalance = this.specs.cexName && updatedConfig.uiConfig.cexRebalance
- Doc.setVis(cexRebalance, page.baseMinTransferSection, page.quoteMinTransferSection)
- Doc.setVis(cexRebalance && this.specs.baseID !== cexBaseID, page.baseBridgeSection)
- Doc.setVis(cexRebalance && this.specs.quoteID !== cexQuoteID, page.quoteBridgeSection)
- if (cexRebalance) {
- this.minTransferInputChanged(updatedConfig.uiConfig.baseMinTransfer / bui.conventional.conversionFactor, 'base')
- this.minTransferInputChanged(updatedConfig.uiConfig.quoteMinTransfer / qui.conventional.conversionFactor, 'quote')
- }
- }
-
- setQuickBalanceConfig (config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve', amt: number) {
- switch (config) {
- case 'buyBuffer': this.updatedConfig.uiConfig.quickBalance.buysBuffer = amt; break
- case 'sellBuffer': this.updatedConfig.uiConfig.quickBalance.sellsBuffer = amt; break
- case 'slippageBuffer': this.updatedConfig.uiConfig.quickBalance.slippageBuffer = amt; break
- case 'buyFeeReserve': this.updatedConfig.uiConfig.quickBalance.buyFeeReserve = amt; break
- case 'sellFeeReserve': this.updatedConfig.uiConfig.quickBalance.sellFeeReserve = amt; break
- case 'bridgeFeeReserve': this.updatedConfig.uiConfig.quickBalance.bridgeFeeReserve = amt; break
- }
- }
-
- quickBalanceSliderChanged (amt: number, config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') {
- const [min, max] = [this.quickBalanceMin(config), this.quickBalanceMax(config)]
- const input = this.quickBalanceInput(config)
- const val = Math.floor((max - min) * amt + min)
- input.setValue(val)
- this.setQuickBalanceConfig(config, val)
- this.updateAllocations()
- }
-
- setQuickBalanceSliderValue (amt: number, config: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') {
- const slider = this.quickBalanceSlider(config)
- const [min, max] = [this.quickBalanceMin(config), this.quickBalanceMax(config)]
- const val = (max - min) === 0 ? 0 : (amt - min) / (max - min)
- slider.setValue(val)
- }
-
- quickBalanceInputChanged (amt: number, sliderName: 'buyBuffer' | 'sellBuffer' | 'slippageBuffer' | 'buyFeeReserve' | 'sellFeeReserve' | 'bridgeFeeReserve') {
- this.setQuickBalanceSliderValue(amt, sliderName)
- this.setQuickBalanceConfig(sliderName, amt)
- this.updateAllocations()
- }
-
- // runningBotAllocations returns the total amount allocated to a running bot.
- runningBotAllocations () : BotBalanceAllocation | undefined {
- const botStatus = app().mmStatus.bots.find((s: MMBotStatus) =>
- s.config.baseID === this.specs.baseID && s.config.quoteID === this.specs.quoteID
- )
- if (!botStatus || !botStatus.runStats) {
- console.error('cannot find run stats for running bot')
- return undefined
- }
-
- const result : BotBalanceAllocation = { dex: {}, cex: {} }
-
- const dexAssetIDs = this.requiredDexAssets(this.specs.baseID, this.specs.quoteID, this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID)
- const cexAssetIDs = [this.updatedConfig.cexBaseID, this.updatedConfig.cexQuoteID]
- const assetIDs = [...dexAssetIDs, ...cexAssetIDs]
-
- for (const assetID of assetIDs) {
- const { dexBalances, cexBalances } = botStatus.runStats
- let totalDEX = 0
- totalDEX += dexBalances[assetID]?.available ?? 0
- totalDEX += dexBalances[assetID]?.locked ?? 0
- totalDEX += dexBalances[assetID]?.pending ?? 0
- totalDEX += dexBalances[assetID]?.reserved ?? 0
- result.dex[assetID] = totalDEX
-
- if (cexBalances) {
- let totalCEX = 0
- totalCEX += cexBalances[assetID]?.available ?? 0
- totalCEX += cexBalances[assetID]?.locked ?? 0
- totalCEX += cexBalances[assetID]?.pending ?? 0
- totalCEX += cexBalances[assetID]?.reserved ?? 0
- result.cex[assetID] = totalCEX
- }
- }
-
- return result
- }
-
- minTransferValidRange (asset: 'base' | 'quote') : [number, number] {
- const totalAlloc : number = (() => {
- const { bui, qui } = this.walletStuff()
- const ui = asset === 'base' ? bui : qui
- const dexAssetID = asset === 'base' ? this.specs.baseID : this.specs.quoteID
- const cexAssetID = asset === 'base' ? this.updatedConfig.cexBaseID : this.updatedConfig.cexQuoteID
-
- console.log('minTransferValidRange', asset, dexAssetID, cexAssetID)
-
- const { dex, cex } = this.updatedConfig.uiConfig.allocation
-
- console.log('allocation', dex, cex)
-
- let total = (dex[dexAssetID] ?? 0) + (cex[cexAssetID] ?? 0)
-
- if (!this.runningBot) return total / ui.conventional.conversionFactor
-
- const botAlloc = this.runningBotAllocations()
- if (botAlloc) {
- total += botAlloc.dex[dexAssetID] ?? 0
- total += botAlloc.cex[cexAssetID] ?? 0
- }
-
- return total / ui.conventional.conversionFactor
- })()
-
- const min = asset === 'base' ? this.baseMinTransferInput.min : this.quoteMinTransferInput.min
- const max = Math.max(min * 2, totalAlloc)
-
- console.log('minTransferValidRange', asset, min, max, totalAlloc)
-
- return [min, max]
- }
-
- setMinTransferCfg (asset: 'base' | 'quote', amt: number) {
- const { updatedConfig: cfg } = this
- const { bui, qui } = this.walletStuff()
- const ui = asset === 'base' ? bui : qui
- const msgAmt = Math.floor(amt * ui.conventional.conversionFactor)
- if (asset === 'base') cfg.uiConfig.baseMinTransfer = msgAmt
- else cfg.uiConfig.quoteMinTransfer = msgAmt
- }
-
- minTransferSliderChanged (r: number, asset: 'base' | 'quote') {
- const input = asset === 'base' ? this.baseMinTransferInput : this.quoteMinTransferInput
- const [min, max] = this.minTransferValidRange(asset)
- const amt = min + (max - min) * r
- input.setValue(amt)
- this.setMinTransferCfg(asset, amt)
- }
-
- minTransferInputChanged (amt: number, asset: 'base' | 'quote') {
- const [min, max] = this.minTransferValidRange(asset)
- amt = Math.min(Math.max(amt, min), max) // clamp
- const slider = asset === 'base' ? this.baseMinTransferSlider : this.quoteMinTransferSlider
- const input = asset === 'base' ? this.baseMinTransferInput : this.quoteMinTransferInput
- slider.setValue((amt - min) / (max - min))
- input.setValue(amt)
- this.setMinTransferCfg(asset, amt)
- }
-
- // validManualBalanceRange returns the valid range for a manual balance slider.
- // For running bots, this ranges from the negative the bot's unused balance to
- // the available balance, and for non-running bots, it ranges from 0 to the
- // available balance.
- validManualBalanceRange (assetID: number, location: 'dex' | 'cex', conventional: boolean) : [number, number] {
- const conventionalRange = (min: number, max: number): [number, number] => {
- if (!conventional) return [min, max]
- const ui = app().assets[assetID].unitInfo
- return [min / ui.conventional.conversionFactor, max / ui.conventional.conversionFactor]
- }
-
- const max = location === 'cex'
- ? this.availableCEXBalances[assetID] ?? 0
- : this.availableDEXBalances[assetID] ?? 0
-
- if (!this.runningBot) return conventionalRange(0, max)
-
- const botStatus = app().mmStatus.bots.find((s: MMBotStatus) =>
- s.config.baseID === this.specs.baseID && s.config.quoteID === this.specs.quoteID
- )
-
- if (!botStatus?.runStats) return conventionalRange(0, max)
-
- const min = location === 'cex'
- ? -(botStatus.runStats.cexBalances?.[assetID]?.available ?? 0)
- : -(botStatus.runStats.dexBalances?.[assetID]?.available ?? 0)
-
- return conventionalRange(min, max)
- }
-
- setConfigAllocation (amt: number, assetID: number, location: 'dex' | 'cex') {
- const { updatedConfig: cfg } = this
- if (location === 'dex') {
- cfg.uiConfig.allocation.dex[assetID] = amt
- } else {
- cfg.uiConfig.allocation.cex[assetID] = amt
- }
- }
-
- status () {
- const { specs: { baseID, quoteID } } = this
- const botStatus = app().mmStatus.bots.find((s: MMBotStatus) => s.config.baseID === baseID && s.config.quoteID === quoteID)
- if (!botStatus) return { botCfg: {} as BotConfig, running: false, runStats: {} as RunStats }
- const { config: botCfg, running, runStats, latestEpoch, cexProblems } = botStatus
- return { botCfg, running, runStats, latestEpoch, cexProblems }
- }
-
- lotSizeUSD () {
- const { specs: { host, baseID }, dexMktID, marketReport: { baseFiatRate } } = this
- const xc = app().exchanges[host]
- const market = xc.markets[dexMktID]
- const { lotsize: lotSize } = market
- const { unitInfo: ui } = app().assets[baseID]
- return lotSize / ui.conventional.conversionFactor * baseFiatRate
- }
-
- quoteMultiSplitBuffer () : number {
- if (!this.updatedConfig.quoteOptions) return 0
- if (this.updatedConfig.quoteOptions.multisplit !== 'true') return 0
- return Number(this.updatedConfig.quoteOptions.multisplitbuffer || '0')
- }
-
- /*
- * marketStuff is just a bunch of useful properties for the current specs
- * gathered in one place and with preferable names.
- */
- marketStuff () {
- const {
- page, specs: { host, baseID, quoteID, cexName, botType },
- marketReport: { baseFiatRate, quoteFiatRate, baseFees, quoteFees },
- lotsPerLevelIncrement, updatedConfig: cfg, originalConfig: oldCfg, dexMktID
- } = this
- const { symbol: baseSymbol, unitInfo: bui } = app().assets[baseID]
- const { symbol: quoteSymbol, unitInfo: qui } = app().assets[quoteID]
- const xc = app().exchanges[host]
- const market = xc.markets[dexMktID]
- const { lotsize: lotSize, spot } = market
- const lotSizeUSD = lotSize / bui.conventional.conversionFactor * baseFiatRate
- const atomicRate = 1 / bui.conventional.conversionFactor * baseFiatRate / quoteFiatRate * qui.conventional.conversionFactor
- const xcRate = {
- conv: quoteFiatRate / baseFiatRate,
- atomic: atomicRate,
- msg: Math.round(atomicRate * OrderUtil.RateEncodingFactor), // unadjusted
- spot
- }
-
- let [sellLots, buyLots, numBuys, numSells] = [0, 0, 0, 0]
- if (botType !== botTypeBasicArb) {
- sellLots = this.updatedConfig.sellPlacements.reduce((lots: number, p: OrderPlacement) => lots + p.lots, 0)
- buyLots = this.updatedConfig.buyPlacements.reduce((lots: number, p: OrderPlacement) => lots + p.lots, 0)
- numBuys = this.updatedConfig.buyPlacements.length
- numSells = this.updatedConfig.sellPlacements.length
- }
- const quoteLot = calculateQuoteLot(lotSize, baseID, quoteID, spot)
-
- return {
- page, cfg, oldCfg, host, xc, botType, cexName, baseFiatRate, quoteFiatRate,
- xcRate, baseSymbol, quoteSymbol, dexMktID, lotSize, lotSizeUSD, lotsPerLevelIncrement,
- quoteLot, baseFees, quoteFees, sellLots, buyLots, numBuys, numSells, ...this.walletStuff()
- }
- }
-
- walletStuff () {
- const { specs: { baseID, quoteID } } = this
- const [baseWallet, quoteWallet] = [app().walletMap[baseID], app().walletMap[quoteID]]
- const [{ token: baseToken, unitInfo: bui }, { token: quoteToken, unitInfo: qui }] = [app().assets[baseID], app().assets[quoteID]]
- const baseFeeAssetID = baseToken ? baseToken.parentID : baseID
- const quoteFeeAssetID = quoteToken ? quoteToken.parentID : quoteID
- const [baseFeeUI, quoteFeeUI] = [app().assets[baseFeeAssetID].unitInfo, app().assets[quoteFeeAssetID].unitInfo]
- const traitAccountLocker = 1 << 14
- const baseIsAccountLocker = (baseWallet.traits & traitAccountLocker) > 0
- const quoteIsAccountLocker = (quoteWallet.traits & traitAccountLocker) > 0
- return {
- baseWallet, quoteWallet, baseFeeUI, quoteFeeUI, baseToken, quoteToken,
- bui, qui, baseFeeAssetID, quoteFeeAssetID, baseIsAccountLocker, quoteIsAccountLocker,
- baseID, quoteID
- }
- }
-
- showAdvancedConfig () {
- const { page } = this
- Doc.show(page.advancedConfig)
- Doc.hide(page.quickConfig)
- this.placementsChart.render()
- }
-
- isQuickPlacements (buyPlacements: OrderPlacement[], sellPlacements: OrderPlacement[]) {
- if (buyPlacements.length === 0 || buyPlacements.length !== sellPlacements.length) return false
- for (let i = 0; i < buyPlacements.length; i++) {
- if (buyPlacements[i].gapFactor !== sellPlacements[i].gapFactor) return false
- if (buyPlacements[i].lots !== sellPlacements[i].lots) return false
- }
- return true
- }
-
- switchToQuickConfig () {
- const { cfg, botType, lotSizeUSD } = this.marketStuff()
- const { buyPlacements: buys, sellPlacements: sells } = cfg
- // If we have both buys and sells, get the best approximation quick config
- // approximation.
- if (buys.length > 0 && sells.length > 0) {
- const bestBuy = buys.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
- const bestSell = sells.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
- const placementCount = buys.length + sells.length
- const levelsPerSide = Math.max(1, Math.floor((placementCount) / 2))
- if (botType === botTypeBasicMM) {
- cfg.profit = (bestBuy.gapFactor + bestSell.gapFactor) / 2
- const worstBuy = buys.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor > prev.gapFactor ? curr : prev)
- const worstSell = sells.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor > prev.gapFactor ? curr : prev)
- const range = ((worstBuy.gapFactor - bestBuy.gapFactor) + (worstSell.gapFactor - bestSell.gapFactor)) / 2
- const inc = range / (levelsPerSide - 1)
- this.qcProfit.setValue(cfg.profit * 100)
- this.qcProfitSlider.setValue((cfg.profit - defaultProfit.minV) / defaultProfit.range)
- this.qcLevelSpacing.setValue(inc * 100)
- this.qcLevelSpacingSlider.setValue((inc - defaultLevelSpacing.minV) / defaultLevelSpacing.range)
- } else if (botType === botTypeArbMM) {
- const multSum = buys.reduce((v: number, p: OrderPlacement) => v + p.gapFactor, 0) + sells.reduce((v: number, p: OrderPlacement) => v + p.gapFactor, 0)
- const buffer = ((multSum / placementCount) - 1) || defaultMatchBuffer.value
- this.qcMatchBuffer.setValue(buffer * 100)
- this.qcMatchBufferSlider.setValue((buffer - defaultMatchBuffer.minV) / defaultMatchBuffer.range)
- }
- const lots = buys.reduce((v: number, p: OrderPlacement) => v + p.lots, 0) + sells.reduce((v: number, p: OrderPlacement) => v + p.lots, 0)
- const lotsPerLevel = Math.max(1, Math.round(lots / 2 / levelsPerSide))
- this.qcLotsPerLevel.setValue(lotsPerLevel)
- this.qcUSDPerSide.setValue(lotsPerLevel * levelsPerSide * lotSizeUSD)
- this.qcLevelsPerSide.setValue(levelsPerSide)
- } else if (botType === botTypeBasicArb) {
- this.qcLotsPerLevel.setValue(1)
- }
- this.showQuickConfig()
- this.quickConfigUpdated()
- }
-
- showQuickConfig () {
- const { page, lotSizeUSD, botType, lotsPerLevelIncrement } = this.marketStuff()
-
- if (!this.qcLevelsPerSide.input.value) {
- this.qcLevelsPerSide.setValue(defaultLevelsPerSide.value)
- this.qcUSDPerSide.setValue(defaultLevelsPerSide.value * (this.qcLotsPerLevel.value() || lotsPerLevelIncrement) * lotSizeUSD)
- }
- if (!this.qcLotsPerLevel.input.value) {
- this.qcLotsPerLevel.setValue(lotsPerLevelIncrement)
- this.qcUSDPerSide.setValue(lotSizeUSD * lotsPerLevelIncrement * this.qcLevelsPerSide.value())
- }
- if (!page.qcLevelSpacing.value) {
- this.qcLevelSpacing.setValue(defaultLevelSpacing.value * 100)
- this.qcLevelSpacingSlider.setValue((defaultLevelSpacing.value - defaultLevelSpacing.minV) / defaultLevelSpacing.range)
- }
- if (!page.qcMatchBuffer.value) page.qcMatchBuffer.value = String(defaultMatchBuffer.value * 100)
-
- Doc.hide(page.advancedConfig)
- Doc.show(page.quickConfig)
-
- this.showInputsForBot(botType)
- }
-
- showInputsForBot (botType: string) {
- const { page, opts: { usingUSDPerSide } } = this
- Doc.hide(
- page.matchMultiplierBox, page.placementsChartBox, page.placementChartLegend,
- page.lotsPerLevelLabel, page.levelSpacingBox, page.arbLotsLabel, page.qcLevelPerSideBox,
- page.qcUSDPerSideBox, page.qcLotsBox
- )
- switch (botType) {
- case botTypeArbMM:
- Doc.show(
- page.qcLevelPerSideBox, page.matchMultiplierBox, page.placementsChartBox,
- page.placementChartLegend, page.lotsPerLevelLabel
- )
- Doc.setVis(usingUSDPerSide, page.qcUSDPerSideBox)
- Doc.setVis(!usingUSDPerSide, page.qcLotsBox)
- break
- case botTypeBasicMM:
- Doc.show(
- page.qcLevelPerSideBox, page.levelSpacingBox, page.placementsChartBox,
- page.lotsPerLevelLabel
- )
- Doc.setVis(usingUSDPerSide, page.qcUSDPerSideBox)
- Doc.setVis(!usingUSDPerSide, page.qcLotsBox)
- break
- }
- }
-
- async quickConfigUpdated () {
- const { page, cfg, botType, cexName } = this.marketStuff()
-
- Doc.hide(page.qcError)
- const setError = (msg: string) => {
- page.qcError.textContent = msg
- Doc.show(page.qcError)
- }
-
- const levelsPerSide = botType === botTypeBasicArb ? 1 : this.qcLevelsPerSide.value()
- if (isNaN(levelsPerSide)) {
- setError('invalid value for levels per side')
- }
-
- const lotsPerLevel = this.qcLotsPerLevel.value()
- if (isNaN(lotsPerLevel)) {
- setError('invalid value for lots per level')
- }
-
- const profit = parseFloat(page.qcProfit.value ?? '') / 100
- if (isNaN(profit)) {
- setError('invalid value for profit')
- }
-
- const levelSpacing = botType === botTypeBasicMM ? parseFloat(page.qcLevelSpacing.value ?? '') / 100 : 0
- if (isNaN(levelSpacing)) {
- setError('invalid value for level spacing')
- }
-
- const matchBuffer = botType === botTypeArbMM ? parseFloat(page.qcMatchBuffer.value ?? '') / 100 : 0
- if (isNaN(matchBuffer)) {
- setError('invalid value for match buffer')
- }
- const multiplier = matchBuffer + 1
-
- const levelSpacingDisabled = levelsPerSide === 1
- page.levelSpacingBox.classList.toggle('disabled', levelSpacingDisabled)
- page.qcLevelSpacing.disabled = levelSpacingDisabled
-
- if (botType !== botTypeBasicArb) {
- this.clearPlacements(cexName ? arbMMRowCacheKey : cfg.gapStrategy)
- for (let levelN = 0; levelN < levelsPerSide; levelN++) {
- const placement = { lots: lotsPerLevel } as OrderPlacement
- placement.gapFactor = botType === botTypeBasicMM ? profit + levelSpacing * levelN : multiplier
- cfg.buyPlacements.push(placement)
- cfg.sellPlacements.push(placement)
- // Add rows in the advanced config table.
- this.addPlacement(true, placement)
- this.addPlacement(false, placement)
- }
-
- this.placementsChart.render()
- }
-
- await this.updateAllocations()
- }
-
- matchBufferChanged () {
- const { page } = this
- page.qcMatchBuffer.value = Math.max(0, parseFloat(page.qcMatchBuffer.value ?? '') || defaultMatchBuffer.value * 100).toFixed(2)
- this.quickConfigUpdated()
- }
-
- showAddress (assetID: number) {
- this.walletAddrForm.setAsset(assetID)
- this.forms.show(this.page.walletAddrForm)
- }
-
- changeSideCommitmentDialog () {
- const { page, opts } = this
- opts.usingUSDPerSide = !opts.usingUSDPerSide
- Doc.setVis(opts.usingUSDPerSide, page.qcUSDPerSideBox)
- Doc.setVis(!opts.usingUSDPerSide, page.qcLotsBox)
- }
-
- async showBotTypeForm (host: string, baseID: number, quoteID: number, botType?: string, configuredCEX?: string) {
- const { page } = this
- this.formSpecs = { host, baseID, quoteID, botType: '' }
- const botRunning = botIsRunning(this.formSpecs, app().mmStatus)
- if (botRunning) {
- const botCfg = liveBotConfig(host, baseID, quoteID)
- const specs = this.specs = this.formSpecs
- switch (true) {
- case Boolean(botCfg?.simpleArbConfig):
- specs.botType = botTypeBasicArb
- break
- case Boolean(botCfg?.arbMarketMakingConfig):
- specs.botType = botTypeArbMM
- break
- default:
- specs.botType = botTypeBasicMM
- }
- specs.cexName = botCfg?.cexName
- await this.fetchCEXBalances(this.formSpecs)
- await this.configureUI()
- this.forms.close()
- return
- }
- setMarketElements(page.botTypeForm, baseID, quoteID, host)
- Doc.empty(page.botTypeBaseSymbol, page.botTypeQuoteSymbol)
- const [b, q] = [app().assets[baseID], app().assets[quoteID]]
- page.botTypeBaseSymbol.appendChild(Doc.symbolize(b, true))
- page.botTypeQuoteSymbol.appendChild(Doc.symbolize(q, true))
- for (const div of this.botTypeSelectors) div.classList.remove('selected')
- for (const { div } of Object.values(this.formCexes)) div.classList.remove('selected')
- this.setCEXAvailability(baseID, quoteID)
- Doc.hide(page.noCexesConfigured, page.noCexMarket, page.noCexMarketConfigureMore, page.botTypeErr)
- const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
- const supportingCexes: Record = {}
- for (const cex of Object.values(app().mmStatus.cexes)) {
- if (cexHasMarket(cex.config.name)) supportingCexes[cex.config.name] = cex.config
- }
- const nCexes = Object.keys(supportingCexes).length
- const arbEnabled = nCexes > 0
- for (const div of this.botTypeSelectors) div.classList.toggle('disabled', div.dataset.botType !== botTypeBasicMM && !arbEnabled)
- if (Object.keys(app().mmStatus.cexes).length === 0) {
- Doc.show(page.noCexesConfigured)
- this.setBotTypeSelected(botTypeBasicMM)
- } else {
- const lastBots = (State.fetchLocal(lastBotsLK) || {}) as Record
- const lastBot = lastBots[`${baseID}_${quoteID}_${host}`]
- let cex: CEXConfig | undefined
- botType = botType ?? (lastBot ? lastBot.botType : botTypeArbMM)
- if (botType !== botTypeBasicMM) {
- // Four ways to auto-select a cex.
- // 1. Coming back from the cex configuration form.
- if (configuredCEX) cex = supportingCexes[configuredCEX]
- // 2. We have a saved configuration.
- if (!cex && lastBot) cex = supportingCexes[lastBot.cexName ?? '']
- // 3. The last exchange that the user selected.
- if (!cex) {
- const lastCEX = State.fetchLocal(lastArbExchangeLK)
- if (lastCEX) cex = supportingCexes[lastCEX]
- }
- // 4. Any supporting cex.
- if (!cex && nCexes > 0) cex = Object.values(supportingCexes)[0]
- }
- if (cex) {
- page.cexSelection.classList.remove('disabled')
- this.setBotTypeSelected(botType ?? (lastBot ? lastBot.botType : botTypeArbMM))
- this.selectFormCEX(cex.name)
- } else {
- page.cexSelection.classList.add('disabled')
- Doc.show(page.noCexMarket)
- this.setBotTypeSelected(botTypeBasicMM)
- // If there are unconfigured cexes, show configureMore message.
- const unconfigured = Object.keys(CEXDisplayInfos).filter((cexName: string) => !app().mmStatus.cexes[cexName])
- const allConfigured = unconfigured.length === 0 || (unconfigured.length === 1 && (unconfigured[0] === 'Binance' || unconfigured[0] === 'BinanceUS'))
- if (!allConfigured) Doc.show(page.noCexMarketConfigureMore)
- }
- }
-
- Doc.show(page.cexSelection)
- // Check if we have any cexes configured.
- this.forms.show(page.botTypeForm)
- }
-
- reshowBotTypeForm () {
- if (this.runningBot) return
- const { baseID, quoteID, host, cexName, botType } = this.specs
- this.showBotTypeForm(host, baseID, quoteID, botType, cexName)
- }
-
- setBotTypeSelected (selectedType: string) {
- const { formSpecs: { baseID, quoteID, host }, botTypeSelectors, formCexes } = this
- for (const { classList, dataset: { botType } } of botTypeSelectors) classList.toggle('selected', botType === selectedType)
- // If we don't have a cex selected, attempt to select one
- if (selectedType === botTypeBasicMM) return
- const mmStatus = app().mmStatus
- if (Object.keys(mmStatus.cexes).length === 0) return
- const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
- // If there is one currently selected and it supports this market, leave it.
- const selecteds = Object.values(formCexes).filter((cex: cexButton) => cex.div.classList.contains('selected'))
- if (selecteds.length && cexHasMarket(selecteds[0].name)) return
- // See if we have a saved configuration.
- const lastBots = (State.fetchLocal(lastBotsLK) || {}) as Record
- const lastBot = lastBots[`${baseID}_${quoteID}_${host}`]
- if (lastBot) {
- const cex = mmStatus.cexes[lastBot.cexName ?? '']
- if (cex && cexHasMarket(cex.config.name)) {
- this.selectFormCEX(cex.config.name)
- return
- }
- }
- // 2. The last exchange that the user selected.
- const lastCEX = State.fetchLocal(lastArbExchangeLK)
- if (lastCEX) {
- const cex = mmStatus.cexes[lastCEX]
- if (cex && cexHasMarket(cex.config.name)) {
- this.selectFormCEX(cex.config.name)
- return
- }
- }
- // 3. Any supporting cex.
- const cexes = Object.values(mmStatus.cexes).filter((cex: MMCEXStatus) => cexHasMarket(cex.config.name))
- if (cexes.length) this.selectFormCEX(cexes[0].config.name)
- }
-
- showMarketSelectForm () {
- if (this.runningBot) return
- this.page.marketFilterInput.value = ''
- this.sortMarketRows()
- this.forms.show(this.page.marketSelectForm)
- }
-
- sortMarketRows () {
- const page = this.page
- const filter = page.marketFilterInput.value?.toLowerCase()
- Doc.empty(page.marketSelect)
- for (const mr of this.marketRows) {
- mr.tr.classList.remove('selected')
- if (filter && !mr.name.includes(filter)) continue
- page.marketSelect.appendChild(mr.tr)
- }
- }
-
- async handleBalanceNote (note: BalanceNote) {
- if (!this.marketReport) return
- const { assetID } = note
- const { baseID, quoteID, baseFeeAssetID, quoteFeeAssetID } = this.walletStuff()
- if ([baseID, quoteID, baseFeeAssetID, quoteFeeAssetID].indexOf(assetID) >= 0) {
- await this.setAvailableBalances()
- this.updateAllocations()
- }
- }
-
- async internalOnlyChanged () {
- const checked = Boolean(this.page.internalOnlyRadio.checked)
- this.page.externalTransfersRadio.checked = !checked
- const oldCexRebalance = this.updatedConfig.uiConfig.cexRebalance
- this.updatedConfig.uiConfig.cexRebalance = !checked
- this.updatedConfig.uiConfig.internalTransfers = checked
-
- if (this.bridgingRequired() && (oldCexRebalance !== this.updatedConfig.uiConfig.cexRebalance)) {
- await this.assetsUpdated()
- this.updateRebalanceSection()
- } else {
- await this.updateAllocations()
- }
- }
-
- async externalTransfersChanged () {
- const checked = Boolean(this.page.externalTransfersRadio.checked)
- this.page.internalOnlyRadio.checked = !checked
- const oldCexRebalance = this.updatedConfig.uiConfig.cexRebalance
- this.updatedConfig.uiConfig.cexRebalance = checked
- this.updatedConfig.uiConfig.internalTransfers = !checked
-
- if (this.bridgingRequired() && oldCexRebalance !== this.updatedConfig.uiConfig.cexRebalance) {
- await this.assetsUpdated()
- this.updateRebalanceSection()
- } else {
- await this.updateAllocations()
- }
- }
-
- async autoRebalanceChanged () {
- const { page, updatedConfig: cfg } = this
- const checked = page.enableRebalance.checked
- Doc.setVis(checked, page.internalOnlySettings, page.externalTransfersSettings)
-
- const oldCexRebalance = cfg.uiConfig.cexRebalance
-
- if (checked && !cfg.uiConfig.cexRebalance && !cfg.uiConfig.internalTransfers) {
- // default to external transfers
- cfg.uiConfig.cexRebalance = true
- page.externalTransfersRadio.checked = true
- page.internalOnlyRadio.checked = false
- } else if (!checked) {
- cfg.uiConfig.cexRebalance = false
- cfg.uiConfig.internalTransfers = false
- page.externalTransfersRadio.checked = false
- page.internalOnlyRadio.checked = false
- } else if (cfg.uiConfig.cexRebalance && cfg.uiConfig.internalTransfers) {
- // should not happen.. set to default
- cfg.uiConfig.internalTransfers = false
- page.externalTransfersRadio.checked = true
- page.internalOnlyRadio.checked = false
- } else {
- // set to current values. This case should only be called when the form
- // is loaded.
- page.externalTransfersRadio.checked = cfg.uiConfig.cexRebalance
- page.internalOnlyRadio.checked = cfg.uiConfig.internalTransfers
- }
-
- // Only update assets if rebalancing setting changed AND bridging is actually required
- const rebalanceSettingChanged = (oldCexRebalance !== cfg.uiConfig.cexRebalance)
- if (rebalanceSettingChanged && this.bridgingRequired()) {
- await this.assetsUpdated()
- } else {
- await this.updateAllocations()
- }
- }
-
- // bridgingRequired checks if bridging is required for either base or quote asset
- // AND rebalancing is enabled (which affects required assets)
- bridgingRequired (): boolean {
- const { specs, updatedConfig: cfg } = this
- // if (!cfg.uiConfig.cexRebalance) return false
- return (specs.baseID !== cfg.cexBaseID) || (specs.quoteID !== cfg.cexQuoteID)
- }
-
- async submitBotType () {
- const loaded = app().loading(this.page.botTypeForm)
- try {
- await this.submitBotWithValidation()
- } finally {
- loaded()
- }
- }
-
- async submitBotWithValidation () {
- // check for wallets
- const { page, forms, formSpecs: { baseID, quoteID, host } } = this
-
- if (!app().walletMap[baseID]) {
- this.newWalletForm.setAsset(baseID)
- forms.show(this.page.newWalletForm)
- return
- }
- if (!app().walletMap[quoteID]) {
- this.newWalletForm.setAsset(quoteID)
- forms.show(this.page.newWalletForm)
- return
- }
- // Are tokens approved?
- const [bApproval, qApproval] = tokenAssetApprovalStatuses(host, app().assets[baseID], app().assets[quoteID])
- if (bApproval === ApprovalStatus.NotApproved) {
- this.approveTokenForm.setAsset(baseID, host)
- forms.show(page.approveTokenForm)
- return
- }
- if (qApproval === ApprovalStatus.NotApproved) {
- this.approveTokenForm.setAsset(quoteID, host)
- forms.show(page.approveTokenForm)
- return
- }
-
- const { botTypeSelectors } = this
- const selecteds = botTypeSelectors.filter((div: PageElement) => div.classList.contains('selected'))
- if (selecteds.length < 1) {
- page.botTypeErr.textContent = intl.prep(intl.ID_NO_BOTTYPE)
- Doc.show(page.botTypeErr)
- return
- }
- const botType = this.formSpecs.botType = selecteds[0].dataset.botType ?? ''
- if (botType !== botTypeBasicMM) {
- const selecteds = Object.values(this.formCexes).filter((cex: cexButton) => cex.div.classList.contains('selected'))
- if (selecteds.length < 1) {
- page.botTypeErr.textContent = intl.prep(intl.ID_NO_CEX)
- Doc.show(page.botTypeErr)
- return
- }
- const cexName = selecteds[0].name
- this.formSpecs.cexName = cexName
- await this.fetchCEXBalances(this.formSpecs)
- }
-
- this.specs = this.formSpecs
-
- this.configureUI()
- this.forms.close()
- }
-
- async fetchCEXBalances (specs: BotSpecs) {
- const { page } = this
- const { baseID, quoteID, cexName, botType } = specs
- if (botType === botTypeBasicMM || !cexName) return
-
- try {
- // This won't work if we implement live reconfiguration, because locked
- // funds would need to be considered.
- this.cexBaseBalance = await MM.cexBalance(cexName, baseID)
- } catch (e) {
- page.botTypeErr.textContent = intl.prep(intl.ID_CEXBALANCE_ERR, { cexName, assetID: String(baseID), err: String(e) })
- Doc.show(page.botTypeErr)
- throw e
- }
-
- try {
- this.cexQuoteBalance = await MM.cexBalance(cexName, quoteID)
- } catch (e) {
- page.botTypeErr.textContent = intl.prep(intl.ID_CEXBALANCE_ERR, { cexName, assetID: String(quoteID), err: String(e) })
- Doc.show(page.botTypeErr)
- throw e
- }
- }
-
- defaultWalletOptions (assetID: number): Record {
- const walletDef = app().currentWalletDefinition(assetID)
- if (!walletDef.multifundingopts) {
- return {}
- }
- const options: Record = {}
- for (const opt of walletDef.multifundingopts) {
- if (opt.quoteAssetOnly && assetID !== this.specs.quoteID) {
- continue
- }
- options[opt.key] = `${opt.default}`
- }
- return options
- }
-
- /*
- * updateModifiedMarkers checks each of the input elements on the page and
- * if the current value does not match the original value (since the last
- * save), then the input will have a colored border.
- */
- updateModifiedMarkers () {
- if (this.creatingNewBot) return
- const { page, originalConfig: oldCfg, updatedConfig: newCfg } = this
-
- // Gap strategy input
- const gapStrategyModified = oldCfg.gapStrategy !== newCfg.gapStrategy
- page.gapStrategySelect.classList.toggle('modified', gapStrategyModified)
-
- const profitModified = oldCfg.profit !== newCfg.profit
- page.profitInput.classList.toggle('modified', profitModified)
-
- // Buy placements Input
- let buyPlacementsModified = false
- if (oldCfg.buyPlacements.length !== newCfg.buyPlacements.length) {
- buyPlacementsModified = true
- } else {
- for (let i = 0; i < oldCfg.buyPlacements.length; i++) {
- if (oldCfg.buyPlacements[i].lots !== newCfg.buyPlacements[i].lots ||
- oldCfg.buyPlacements[i].gapFactor !== newCfg.buyPlacements[i].gapFactor) {
- buyPlacementsModified = true
- break
- }
- }
- }
- page.buyPlacementsTableWrapper.classList.toggle('modified', buyPlacementsModified)
-
- // Sell placements input
- let sellPlacementsModified = false
- if (oldCfg.sellPlacements.length !== newCfg.sellPlacements.length) {
- sellPlacementsModified = true
- } else {
- for (let i = 0; i < oldCfg.sellPlacements.length; i++) {
- if (oldCfg.sellPlacements[i].lots !== newCfg.sellPlacements[i].lots ||
- oldCfg.sellPlacements[i].gapFactor !== newCfg.sellPlacements[i].gapFactor) {
- sellPlacementsModified = true
- break
- }
- }
- }
- page.sellPlacementsTableWrapper.classList.toggle('modified', sellPlacementsModified)
- }
-
- /*
- * gapFactorHeaderUnit returns the header on the placements table and the
- * units in the gap factor rows needed for each gap strategy.
- */
- gapFactorHeaderUnit (gapStrategy: string): [string, string] {
- switch (gapStrategy) {
- case GapStrategyMultiplier:
- return ['Multiplier', 'x']
- case GapStrategyAbsolute:
- case GapStrategyAbsolutePlus: {
- const rateUnit = `${app().assets[this.specs.quoteID].symbol}/${app().assets[this.specs.baseID].symbol}`
- return ['Rate', rateUnit]
- }
- case GapStrategyPercent:
- case GapStrategyPercentPlus:
- return ['Percent', '%']
- default:
- throw new Error(`Unknown gap strategy ${gapStrategy}`)
- }
- }
-
- /*
- * checkGapFactorRange returns an error string if the value input for a
- * gap factor is valid for the currently selected gap strategy.
- */
- checkGapFactorRange (gapFactor: string, value: number): (string | null) {
- switch (gapFactor) {
- case GapStrategyMultiplier:
- if (value < 1 || value > 100) {
- return 'Multiplier must be between 1 and 100'
- }
- return null
- case GapStrategyAbsolute:
- case GapStrategyAbsolutePlus:
- if (value <= 0) {
- return 'Rate must be greater than 0'
- }
- return null
- case GapStrategyPercent:
- case GapStrategyPercentPlus:
- if (value <= 0 || value > 10) {
- return 'Percent must be between 0 and 10'
- }
- return null
- default: {
- throw new Error(`Unknown gap factor ${gapFactor}`)
- }
- }
- }
-
- /*
- * convertGapFactor converts between the displayed gap factor in the
- * placement tables and the number that is passed to the market maker.
- * For gap strategies that involve a percentage it converts between the
- * decimal value required by the backend and a percentage displayed to
- * the user.
- */
- convertGapFactor (gapFactor: number, gapStrategy: string, toDisplay: boolean): number {
- switch (gapStrategy) {
- case GapStrategyMultiplier:
- case GapStrategyAbsolute:
- case GapStrategyAbsolutePlus:
- return gapFactor
- case GapStrategyPercent:
- case GapStrategyPercentPlus:
- if (toDisplay) {
- return gapFactor * 100
- }
- return gapFactor / 100
- default:
- throw new Error(`Unknown gap factor ${gapStrategy}`)
- }
- }
-
- /*
- * addPlacement adds a row to a placement table. This is called both when
- * the page is initially loaded, and when the "add" button is pressed on
- * the placement table. initialLoadPlacement is non-nil if this is being
- * called on the initial load.
- */
- addPlacement (isBuy: boolean, initialLoadPlacement: OrderPlacement | null, gapStrategy?: string) {
- const { page, updatedConfig: cfg } = this
-
- let tableBody: PageElement = page.sellPlacementsTableBody
- let addPlacementRow: PageElement = page.addSellPlacementRow
- let lotsElement: PageElement = page.addSellPlacementLots
- let gapFactorElement: PageElement = page.addSellPlacementGapFactor
- let errElement: PageElement = page.sellPlacementsErr
- if (isBuy) {
- tableBody = page.buyPlacementsTableBody
- addPlacementRow = page.addBuyPlacementRow
- lotsElement = page.addBuyPlacementLots
- gapFactorElement = page.addBuyPlacementGapFactor
- errElement = page.buyPlacementsErr
- }
-
- Doc.hide(errElement)
-
- // updateArrowVis updates the visibility of the move up/down arrows in
- // each row of the placement table. The up arrow is not shown on the
- // top row, and the down arrow is not shown on the bottom row. They
- // are all hidden if market making is running.
- const updateArrowVis = () => {
- for (let i = 0; i < tableBody.children.length - 1; i++) {
- const row = Doc.parseTemplate(tableBody.children[i] as HTMLElement)
- Doc.setVis(i !== 0, row.upBtn)
- Doc.setVis(i !== tableBody.children.length - 2, row.downBtn)
- }
- }
-
- Doc.hide(errElement)
- const setErr = (err: string) => {
- errElement.textContent = err
- Doc.show(errElement)
- }
-
- let lots: number
- let actualGapFactor: number
- let displayedGapFactor: number
- if (!gapStrategy) gapStrategy = this.specs.cexName ? GapStrategyMultiplier : cfg.gapStrategy
- const placements = isBuy ? cfg.buyPlacements : cfg.sellPlacements
- const unit = this.gapFactorHeaderUnit(gapStrategy)[1]
- if (initialLoadPlacement) {
- lots = initialLoadPlacement.lots
- actualGapFactor = initialLoadPlacement.gapFactor
- displayedGapFactor = this.convertGapFactor(actualGapFactor, gapStrategy, true)
- } else {
- lots = parseInt(lotsElement.value || '0')
- displayedGapFactor = parseFloat(gapFactorElement.value || '0')
- actualGapFactor = this.convertGapFactor(displayedGapFactor, gapStrategy, false)
- if (lots === 0) {
- setErr('Lots must be greater than 0')
- return
- }
-
- const gapFactorErr = this.checkGapFactorRange(gapStrategy, displayedGapFactor)
- if (gapFactorErr) {
- setErr(gapFactorErr)
- return
- }
-
- if (placements.find((placement) => placement.gapFactor === actualGapFactor)
- ) {
- setErr('Duplicate placement')
- return
- }
-
- placements.push({ lots, gapFactor: actualGapFactor })
- }
-
- const newRow = page.placementRowTmpl.cloneNode(true) as PageElement
- const newRowTmpl = Doc.parseTemplate(newRow)
- newRowTmpl.priority.textContent = `${tableBody.children.length}`
- newRowTmpl.lots.textContent = `${lots}`
- newRowTmpl.gapFactor.textContent = `${displayedGapFactor} ${unit}`
- Doc.bind(newRowTmpl.removeBtn, 'click', () => {
- const index = placements.findIndex((placement) => {
- return placement.lots === lots && placement.gapFactor === actualGapFactor
- })
- if (index === -1) return
- placements.splice(index, 1)
- newRow.remove()
- updateArrowVis()
- this.updateModifiedMarkers()
- this.placementsChart.render()
- this.updateAllocations()
- })
-
- Doc.bind(newRowTmpl.upBtn, 'click', () => {
- const index = placements.findIndex((p: OrderPlacement) => p.lots === lots && p.gapFactor === actualGapFactor)
- if (index === 0) return
- const prevPlacement = placements[index - 1]
- placements[index - 1] = placements[index]
- placements[index] = prevPlacement
- newRowTmpl.priority.textContent = `${index}`
- newRow.remove()
- tableBody.insertBefore(newRow, tableBody.children[index - 1])
- const movedDownTmpl = Doc.parseTemplate(
- tableBody.children[index] as HTMLElement
- )
- movedDownTmpl.priority.textContent = `${index + 1}`
- updateArrowVis()
- this.updateModifiedMarkers()
- })
-
- Doc.bind(newRowTmpl.downBtn, 'click', () => {
- const index = placements.findIndex((p) => p.lots === lots && p.gapFactor === actualGapFactor)
- if (index === placements.length - 1) return
- const nextPlacement = placements[index + 1]
- placements[index + 1] = placements[index]
- placements[index] = nextPlacement
- newRowTmpl.priority.textContent = `${index + 2}`
- newRow.remove()
- tableBody.insertBefore(newRow, tableBody.children[index + 1])
- const movedUpTmpl = Doc.parseTemplate(
- tableBody.children[index] as HTMLElement
- )
- movedUpTmpl.priority.textContent = `${index + 1}`
- updateArrowVis()
- this.updateModifiedMarkers()
- })
-
- tableBody.insertBefore(newRow, addPlacementRow)
- updateArrowVis()
- }
-
- setArbMMLabels () {
- this.page.buyGapFactorHdr.textContent = intl.prep(intl.ID_MATCH_BUFFER)
- this.page.sellGapFactorHdr.textContent = intl.prep(intl.ID_MATCH_BUFFER)
- }
-
- /*
- * setGapFactorLabels sets the headers on the gap factor column of each
- * placement table.
- */
- setGapFactorLabels (gapStrategy: string) {
- const page = this.page
- const header = this.gapFactorHeaderUnit(gapStrategy)[0]
- page.buyGapFactorHdr.textContent = header
- page.sellGapFactorHdr.textContent = header
- Doc.hide(page.percentPlusInfo, page.percentInfo, page.absolutePlusInfo, page.absoluteInfo, page.multiplierInfo)
- switch (gapStrategy) {
- case 'percent-plus':
- return Doc.show(page.percentPlusInfo)
- case 'percent':
- return Doc.show(page.percentInfo)
- case 'absolute-plus':
- return Doc.show(page.absolutePlusInfo)
- case 'absolute':
- return Doc.show(page.absoluteInfo)
- case 'multiplier':
- return Doc.show(page.multiplierInfo)
- }
- }
-
- clearPlacements (cacheKey: string) {
- const { page, updatedConfig: cfg } = this
- while (page.buyPlacementsTableBody.children.length > 1) {
- page.buyPlacementsTableBody.children[0].remove()
- }
- while (page.sellPlacementsTableBody.children.length > 1) {
- page.sellPlacementsTableBody.children[0].remove()
- }
- this.placementsCache[cacheKey] = [cfg.buyPlacements, cfg.sellPlacements]
- cfg.buyPlacements.splice(0, cfg.buyPlacements.length)
- cfg.sellPlacements.splice(0, cfg.sellPlacements.length)
- }
-
- loadCachedPlacements (cacheKey: string) {
- const c = this.placementsCache[cacheKey]
- if (!c) return
- const { updatedConfig: cfg } = this
- cfg.buyPlacements.splice(0, cfg.buyPlacements.length)
- cfg.sellPlacements.splice(0, cfg.sellPlacements.length)
- cfg.buyPlacements.push(...c[0])
- cfg.sellPlacements.push(...c[1])
- const gapStrategy = cacheKey === arbMMRowCacheKey ? GapStrategyMultiplier : cacheKey
- for (const p of cfg.buyPlacements) this.addPlacement(true, p, gapStrategy)
- for (const p of cfg.sellPlacements) this.addPlacement(false, p, gapStrategy)
- }
-
- /*
- * setOriginalValues sets the updatedConfig field to be equal to the
- * and sets the values displayed buy each field input to be equal
- * to the values since the last save.
- */
- setOriginalValues () {
- const {
- page, originalConfig: oldCfg, updatedConfig: cfg, specs: { cexName, botType }
- } = this
-
- this.clearPlacements(cexName ? arbMMRowCacheKey : cfg.gapStrategy)
-
- const assign = (to: any, from: any) => { // not recursive
- for (const [k, v] of Object.entries(from)) {
- if (Array.isArray(v)) {
- to[k].splice(0, to[k].length)
- for (const i of v) to[k].push(i)
- } else if (typeof v === 'object') Object.assign(to[k], v)
- else to[k] = from[k]
- }
- }
- assign(cfg, JSON.parse(JSON.stringify(oldCfg)))
-
- const tol = cfg.driftTolerance ?? defaultDriftTolerance.value
- this.driftTolerance.setValue(tol * 100)
- this.driftToleranceSlider.setValue(tol / defaultDriftTolerance.maxV)
-
- const persist = cfg.orderPersistence ?? defaultOrderPersistence.value
- this.orderPersistence.setValue(persist)
- this.orderPersistenceSlider.setValue(persist / defaultOrderPersistence.maxV)
-
- const profit = cfg.profit ?? defaultProfit.value
- page.profitInput.value = String(profit * 100)
- this.qcProfit.setValue(profit * 100)
- this.qcProfitSlider.setValue((profit - defaultProfit.minV) / defaultProfit.range)
-
- if (cexName) {
- page.enableRebalance.checked = cfg.uiConfig.cexRebalance || cfg.uiConfig.internalTransfers
- page.internalOnlyRadio.checked = cfg.uiConfig.internalTransfers
- page.externalTransfersRadio.checked = cfg.uiConfig.cexRebalance
- // Call autoRebalanceChanged without await since this is part of initialization
- // and we don't want to block the UI setup. The assets should already be properly set up.
- this.autoRebalanceChanged().catch(console.error)
- }
-
- // Gap strategy
- if (!page.gapStrategySelect.options) return
- Array.from(page.gapStrategySelect.options).forEach((opt: HTMLOptionElement) => { opt.selected = opt.value === cfg.gapStrategy })
- this.setGapFactorLabels(cfg.gapStrategy)
-
- if (botType === botTypeBasicMM) {
- Doc.show(page.gapStrategyBox)
- Doc.hide(page.profitSelectorBox, page.orderPersistenceBox)
- this.setGapFactorLabels(page.gapStrategySelect.value || '')
- } else if (cexName && app().mmStatus.cexes[cexName]) {
- Doc.hide(page.gapStrategyBox)
- Doc.show(page.profitSelectorBox, page.orderPersistenceBox)
- this.setArbMMLabels()
- }
-
- // Buy/Sell placements
- oldCfg.buyPlacements.forEach((p) => { this.addPlacement(true, p) })
- oldCfg.sellPlacements.forEach((p) => { this.addPlacement(false, p) })
-
- // Quick balance
- this.buyBufferInput.setValue(cfg.uiConfig.quickBalance.buysBuffer)
- this.sellBufferInput.setValue(cfg.uiConfig.quickBalance.sellsBuffer)
- this.buyFeeReserveInput.setValue(cfg.uiConfig.quickBalance.buyFeeReserve)
- this.sellFeeReserveInput.setValue(cfg.uiConfig.quickBalance.sellFeeReserve)
- this.bridgeFeeReserveInput.setValue(cfg.uiConfig.quickBalance.bridgeFeeReserve)
- this.slippageBufferInput.setValue(cfg.uiConfig.quickBalance.slippageBuffer)
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.buysBuffer, 'buyBuffer')
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.sellsBuffer, 'sellBuffer')
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.buyFeeReserve, 'buyFeeReserve')
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.sellFeeReserve, 'sellFeeReserve')
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.bridgeFeeReserve, 'bridgeFeeReserve')
- this.setQuickBalanceSliderValue(cfg.uiConfig.quickBalance.slippageBuffer, 'slippageBuffer')
-
- this.setAllocationTechnique(cfg.uiConfig.usingQuickBalance)
-
- if (cfg.uiConfig.cexRebalance) {
- const { bui, qui } = this.walletStuff()
- this.minTransferInputChanged(cfg.uiConfig.baseMinTransfer / bui.conventional.conversionFactor, 'base')
- this.minTransferInputChanged(cfg.uiConfig.quoteMinTransfer / qui.conventional.conversionFactor, 'quote')
- }
-
- this.baseSettings.clear()
- this.quoteSettings.clear()
- this.baseSettings.init(cfg.baseOptions, this.specs.baseID, false)
- this.quoteSettings.init(cfg.quoteOptions, this.specs.quoteID, true)
-
- // Initialize multi-hop order type radio buttons and limit order buffer section visibility
- if (cfg.multiHop && typeof cfg.multiHop === 'object' && Doc.isDisplayed(page.multiHopCompletionBox)) {
- if (cfg.multiHop.marketOrders) {
- page.multiHopMarketOrder.checked = true
- page.multiHopLimitOrder.checked = false
- Doc.hide(page.limitOrderBufferSection)
- } else {
- page.multiHopMarketOrder.checked = false
- page.multiHopLimitOrder.checked = true
- Doc.show(page.limitOrderBufferSection)
- }
-
- // Set the limit order buffer value
- const bufferPct = cfg.multiHop.limitOrdersBuffer * 100
- this.limitOrderBuffer.setValue(bufferPct)
- this.limitOrderBufferSlider.setValue(bufferPct / 20)
- }
-
- this.updateModifiedMarkers()
- if (Doc.isDisplayed(page.quickConfig)) this.switchToQuickConfig()
- }
-
- /*
- * validateFields validates configuration values and optionally shows error
- * messages.
- */
- validateFields (showErrors: boolean): boolean {
- let ok = true
- const {
- page, specs: { botType },
- updatedConfig: { sellPlacements, buyPlacements, profit }
- } = this
- const setError = (errEl: PageElement, errID: string) => {
- ok = false
- if (!showErrors) return
- errEl.textContent = intl.prep(errID)
- Doc.show(errEl)
- }
- if (showErrors) {
- Doc.hide(
- page.buyPlacementsErr, page.sellPlacementsErr, page.profitInputErr
- )
- }
- if (botType !== botTypeBasicArb && buyPlacements.length + sellPlacements.length === 0) {
- setError(page.buyPlacementsErr, intl.ID_NO_PLACEMENTS)
- setError(page.sellPlacementsErr, intl.ID_NO_PLACEMENTS)
- }
- if (botType !== botTypeBasicMM) {
- if (isNaN(profit)) setError(page.profitInputErr, intl.ID_INVALID_VALUE)
- else if (profit === 0) setError(page.profitInputErr, intl.ID_NO_ZERO)
- }
- return ok
- }
-
- autoRebalanceSettings () : AutoRebalanceConfig | undefined {
- const { updatedConfig: cfg } = this
- if (!cfg.uiConfig.cexRebalance && !cfg.uiConfig.internalTransfers) return
- return {
- minBaseTransfer: cfg.uiConfig.baseMinTransfer,
- minQuoteTransfer: cfg.uiConfig.quoteMinTransfer,
- internalOnly: !cfg.uiConfig.cexRebalance
- }
- }
-
- async doSave () {
- // Make a copy and delete either the basic mm config or the arb-mm config,
- // depending on whether a cex is selected.
- if (!this.validateFields(true)) return
- const { cfg, baseID, quoteID, host, botType, cexName } = this.marketStuff()
-
- const botCfg: BotConfig = {
- host: host,
- baseID: baseID,
- quoteID: quoteID,
- cexBaseID: cfg.cexBaseID,
- cexQuoteID: cfg.cexQuoteID,
- baseBridgeName: cfg.baseBridgeName,
- quoteBridgeName: cfg.quoteBridgeName,
- cexName: cexName ?? '',
- uiConfig: cfg.uiConfig,
- baseWalletOptions: cfg.baseOptions,
- quoteWalletOptions: cfg.quoteOptions
- }
-
- console.log({ cfg, botCfg })
-
- switch (botType) {
- case botTypeBasicMM:
- botCfg.basicMarketMakingConfig = this.basicMMConfig()
- break
- case botTypeArbMM:
- botCfg.arbMarketMakingConfig = this.arbMMConfig()
- break
- case botTypeBasicArb:
- botCfg.simpleArbConfig = this.basicArbConfig()
- }
-
- app().log('mm', 'saving bot config', botCfg)
-
- // When loading a running bot with balances configured manually, we set
- // all the diffs initially to 0. However, we save the UI with the total
- // allocations for each asset, so that if the bot is stopped and then the
- // settings are reloaded, the total allocations will be shown.
- const updatedAllocation = cfg.uiConfig.allocation
- if (!botCfg.uiConfig.usingQuickBalance && this.runningBot) {
- const botAlloc = this.runningBotAllocations()
- if (botAlloc) {
- botCfg.uiConfig.allocation = combineBotAllocations(botAlloc, updatedAllocation)
- }
- }
-
- if (this.runningBot) await MM.updateRunningBot(botCfg, updatedAllocation, this.autoRebalanceSettings())
- else await MM.updateBotConfig(botCfg)
-
- await app().fetchMMStatus()
- this.originalConfig = JSON.parse(JSON.stringify(cfg))
- this.updateModifiedMarkers()
- const lastBots = State.fetchLocal(lastBotsLK) || {}
- lastBots[`${baseID}_${quoteID}_${host}`] = this.specs
- State.storeLocal(lastBotsLK, lastBots)
- if (cexName) State.storeLocal(lastArbExchangeLK, cexName)
- }
-
- async updateSettings () {
- await this.doSave()
- app().loadPage('mm')
- }
-
- async saveSettingsAndStart () {
- const { specs: { host, baseID, quoteID }, updatedConfig: cfg } = this
- await this.doSave()
-
- const startConfig: StartConfig = {
- baseID: baseID,
- quoteID: quoteID,
- host: host,
- alloc: cfg.uiConfig.allocation,
- autoRebalance: this.autoRebalanceSettings()
- }
-
- await MM.startBot(startConfig)
- app().loadPage('mm')
- }
-
- async delete () {
- const { page, specs: { host, baseID, quoteID } } = this
- Doc.hide(page.deleteErr)
- const loaded = app().loading(page.botSettingsContainer)
- const resp = await MM.removeBotConfig(host, baseID, quoteID)
- loaded()
- if (!app().checkResponse(resp)) {
- page.deleteErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: resp.msg })
- Doc.show(page.deleteErr)
- return
- }
- await app().fetchMMStatus()
- app().loadPage('mm')
- }
-
- /*
- * arbMMConfig parses the configuration for the arb-mm bot. Only one of
- * arbMMConfig or basicMMConfig should be used when updating the bot
- * configuration. Which is used depends on if the user has configured and
- * selected a CEX or not.
- */
- arbMMConfig (): ArbMarketMakingConfig {
- const { updatedConfig: cfg } = this
- const arbCfg: ArbMarketMakingConfig = {
- buyPlacements: [],
- sellPlacements: [],
- profit: cfg.profit,
- driftTolerance: cfg.driftTolerance,
- orderPersistence: cfg.orderPersistence,
- multiHop: cfg.multiHop
- }
- for (const p of cfg.buyPlacements) arbCfg.buyPlacements.push({ lots: p.lots, multiplier: p.gapFactor })
- for (const p of cfg.sellPlacements) arbCfg.sellPlacements.push({ lots: p.lots, multiplier: p.gapFactor })
- return arbCfg
- }
-
- basicArbConfig (): SimpleArbConfig {
- const { updatedConfig: cfg } = this
- const arbCfg: SimpleArbConfig = {
- profitTrigger: cfg.profit,
- maxActiveArbs: 100, // TODO
- numEpochsLeaveOpen: cfg.orderPersistence
- }
- return arbCfg
- }
-
- /*
- * basicMMConfig parses the configuration for the basic marketmaker. Only of
- * of basidMMConfig or arbMMConfig should be used when updating the bot
- * configuration.
- */
- basicMMConfig (): BasicMarketMakingConfig {
- const { updatedConfig: cfg } = this
- const mmCfg: BasicMarketMakingConfig = {
- gapStrategy: cfg.gapStrategy,
- sellPlacements: cfg.sellPlacements,
- buyPlacements: cfg.buyPlacements,
- driftTolerance: cfg.driftTolerance
- }
- return mmCfg
- }
-
- /*
- * fetchOracles fetches the current oracle rates and fiat rates, and displays
- * them on the screen.
- */
- async fetchMarketReport (): Promise {
- const { page, specs: { host, baseID, quoteID } } = this
-
- const res = await MM.report(host, baseID, quoteID)
-
- Doc.hide(page.oraclesLoading, page.oraclesTable, page.noOracles)
-
- if (!app().checkResponse(res)) {
- page.oraclesErrMsg.textContent = res.msg
- Doc.show(page.oraclesErr)
- return
- }
-
- const r = this.marketReport = res.report as MarketReport
- if (!r.oracles || r.oracles.length === 0) {
- Doc.show(page.noOracles)
- } else {
- Doc.hide(page.noOracles)
- Doc.empty(page.oracles)
- for (const o of r.oracles ?? []) {
- const tr = page.oracleTmpl.cloneNode(true) as PageElement
- page.oracles.appendChild(tr)
- const tmpl = Doc.parseTemplate(tr)
- tmpl.logo.src = 'img/' + o.host + '.png'
- tmpl.host.textContent = ExchangeNames[o.host]
- tmpl.volume.textContent = Doc.formatFourSigFigs(o.usdVol)
- tmpl.price.textContent = Doc.formatFourSigFigs((o.bestBuy + o.bestSell) / 2)
- }
- page.avgPrice.textContent = r.price ? Doc.formatFourSigFigs(r.price) : '0'
- Doc.show(page.oraclesTable)
- }
-
- if (r.baseFiatRate > 0) {
- page.baseFiatRate.textContent = Doc.formatFourSigFigs(r.baseFiatRate)
- } else {
- page.baseFiatRate.textContent = 'N/A'
- }
-
- if (r.quoteFiatRate > 0) {
- page.quoteFiatRate.textContent = Doc.formatFourSigFigs(r.quoteFiatRate)
- } else {
- page.quoteFiatRate.textContent = 'N/A'
- }
- Doc.show(page.fiatRates)
- }
-
- /*
- * handleCEXSubmit handles clicks on the CEX configuration submission button.
- */
- async cexConfigured (cexName: string) {
- const { page, formSpecs: { host, baseID, quoteID } } = this
- const dinfo = CEXDisplayInfos[cexName]
- for (const { baseID, quoteID, tmpl, arbs } of this.marketRows) {
- if (arbs.indexOf(cexName) !== -1) continue
- const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
- if (cexHasMarket(cexName)) {
- const img = page.arbBttnTmpl.cloneNode(true) as PageElement
- img.src = dinfo.logo
- tmpl.arbs.appendChild(img)
- arbs.push(cexName)
- }
- }
- this.setCEXAvailability(baseID, quoteID, cexName)
- this.showBotTypeForm(host, baseID, quoteID, botTypeArbMM, cexName)
- }
-
- /*
- * setupCEXes should be called during initialization.
- */
- setupCEXes () {
- this.formCexes = {}
- for (const name of Object.keys(CEXDisplayInfos)) this.addCEX(name)
- }
-
- /*
- * setCEXAvailability sets the coloring and messaging of the CEX selection
- * buttons.
- */
- setCEXAvailability (baseID: number, quoteID: number, selectedCEX?: string) {
- const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
- for (const { name, div, tmpl } of Object.values(this.formCexes)) {
- const has = cexHasMarket(name)
- const cexStatus = app().mmStatus.cexes[name]
- Doc.hide(tmpl.unavailable, tmpl.needsconfig, tmpl.disconnected)
- Doc.setVis(Boolean(cexStatus), tmpl.reconfig)
- tmpl.logo.classList.remove('greyscal')
- div.classList.toggle('configured', Boolean(cexStatus) && !cexStatus.connectErr)
- if (!cexStatus) {
- Doc.show(tmpl.needsconfig)
- } else if (cexStatus.connectErr) {
- Doc.show(tmpl.disconnected)
- } else if (!has) {
- Doc.show(tmpl.unavailable)
- tmpl.logo.classList.add('greyscal')
- } else if (name === selectedCEX) this.selectFormCEX(name)
- }
- }
-
- selectFormCEX (cexName: string) {
- for (const { name, div } of Object.values(this.formCexes)) {
- div.classList.toggle('selected', name === cexName)
- }
- }
-
- addCEX (cexName: string) {
- const dinfo = CEXDisplayInfos[cexName]
- const div = this.page.cexOptTmpl.cloneNode(true) as PageElement
- const tmpl = Doc.parseTemplate(div)
- tmpl.name.textContent = dinfo.name
- tmpl.logo.src = dinfo.logo
- this.page.cexSelection.appendChild(div)
- this.formCexes[cexName] = { name: cexName, div, tmpl }
- Doc.bind(div, 'click', () => {
- const cexStatus = app().mmStatus.cexes[cexName]
- if (!cexStatus || cexStatus.connectErr) {
- this.showCEXConfigForm(cexName)
- return
- }
- const cex = this.formCexes[cexName]
- if (cex.div.classList.contains('selected')) { // unselect
- for (const cex of Object.values(this.formCexes)) cex.div.classList.remove('selected')
- const { baseID, quoteID } = this.formSpecs
- this.setCEXAvailability(baseID, quoteID)
- return
- }
- for (const cex of Object.values(this.formCexes)) cex.div.classList.toggle('selected', cex.name === cexName)
- })
- Doc.bind(tmpl.reconfig, 'click', (e: MouseEvent) => {
- e.stopPropagation()
- this.showCEXConfigForm(cexName)
- })
- }
-
- showCEXConfigForm (cexName: string) {
- const page = this.page
- this.cexConfigForm.setCEX(cexName)
- this.forms.show(page.cexConfigForm)
- }
-
- // populateBridgeFeesAndLimits fetches and caches bridge fees and limits for
- // the current base and quote assets based on their bridge configurations.
- async populateBridgeFeesAndLimits () {
- const { baseID, quoteID } = this.specs
- const cfg = this.updatedConfig
-
- // Check if base bridge fees need to be refetched
- if (cfg.baseBridgeName) {
- const needsRefetch = !this.baseBridgeFeesAndLimits ||
- this.baseBridgeFeesAndLimits.cexAsset !== cfg.cexBaseID ||
- this.baseBridgeFeesAndLimits.bridgeName !== cfg.baseBridgeName
-
- if (needsRefetch) {
- const withdrawal = await app().bridgeFeesAndLimits(baseID, cfg.cexBaseID, cfg.baseBridgeName)
- const deposit = await app().bridgeFeesAndLimits(cfg.cexBaseID, baseID, cfg.baseBridgeName)
- this.baseBridgeFeesAndLimits = {
- withdrawal,
- deposit,
- cexAsset: cfg.cexBaseID,
- bridgeName: cfg.baseBridgeName
- }
- }
- } else {
- this.baseBridgeFeesAndLimits = null
- }
-
- // Check if quote bridge fees need to be refetched
- if (cfg.quoteBridgeName) {
- const needsRefetch = !this.quoteBridgeFeesAndLimits ||
- this.quoteBridgeFeesAndLimits.cexAsset !== cfg.cexQuoteID ||
- this.quoteBridgeFeesAndLimits.bridgeName !== cfg.quoteBridgeName
-
- if (needsRefetch) {
- const withdrawal = await app().bridgeFeesAndLimits(quoteID, cfg.cexQuoteID, cfg.quoteBridgeName)
- const deposit = await app().bridgeFeesAndLimits(cfg.cexQuoteID, quoteID, cfg.quoteBridgeName)
- this.quoteBridgeFeesAndLimits = {
- withdrawal,
- deposit,
- cexAsset: cfg.cexQuoteID,
- bridgeName: cfg.quoteBridgeName
- }
- }
- } else {
- this.quoteBridgeFeesAndLimits = null
- }
- }
-
- // cexSupportsArbOnMarket checks whether the CEX supports arbitrage market
- // making on the given market. It returns a tuple of:
- //
- // - whether the CEX supports direct arbitrage on the market
- // - the intermedate assets that can be used for multi-hop arbitrage
- // - the CEX assetIDs that the base asset can be bridged to
- // - the CEX assetIDs that the quote asset can be bridged to
- //
- // If the CEX does not support direct arb and there are no intermediate assets,
- // the CEX does not support arbitrage market making on the market.
- // The bridge destination assets will be empty if the CEX supports the same
- // asset that is used on the DEX market.
- cexSupportsArbOnMarket (baseID: number, quoteID: number, cexStatus: MMCEXStatus): [boolean, number[], Record, Record] {
- const supportedBridgePath = (dexAssetID: number, cexAssetID: number) => {
- if (!this.bridgePaths[dexAssetID]) return false
- const dests = this.bridgePaths[dexAssetID]
- return dests[cexAssetID] !== undefined
- }
-
- const getBridgeNames = (dexAssetID: number, cexAssetID: number): string[] => {
- if (!this.bridgePaths[dexAssetID]) return []
- const dests = this.bridgePaths[dexAssetID]
- return dests[cexAssetID] || []
- }
-
- const supportedMarkets = (dexBaseID: number, dexQuoteID: number, cexBaseID: number, cexQuoteID: number) => {
- if (dexBaseID !== cexBaseID) {
- if (!supportedBridgePath(dexBaseID, cexBaseID)) return false
- }
- if (dexQuoteID !== cexQuoteID) {
- if (!supportedBridgePath(dexQuoteID, cexQuoteID)) return false
- }
- return true
- }
-
- // baseBridges and quoteBridges are all the assets that the base and quote
- // asset can be bridged to that are supported by the CEX, mapping to available bridge names.
- // If the CEX supports the base or quote assets directly, the bridge map will be empty.
- let baseBridges: Record = {}
- let quoteBridges: Record = {}
- let baseSupported = false
- let quoteSupported = false
- for (const { baseID: cexBaseID, quoteID: cexQuoteID } of Object.values(cexStatus.markets ?? [])) {
- if (cexBaseID === baseID) {
- baseSupported = true
- baseBridges = {}
- }
- if (cexQuoteID === quoteID) {
- quoteSupported = true
- quoteBridges = {}
- }
- if (!baseSupported && supportedBridgePath(baseID, cexBaseID)) {
- baseBridges[cexBaseID] = getBridgeNames(baseID, cexBaseID)
- continue
- }
- if (!baseSupported && supportedBridgePath(baseID, cexQuoteID)) {
- baseBridges[cexQuoteID] = getBridgeNames(baseID, cexQuoteID)
- continue
- }
- if (!quoteSupported && supportedBridgePath(quoteID, cexQuoteID)) {
- quoteBridges[cexQuoteID] = getBridgeNames(quoteID, cexQuoteID)
- continue
- }
- if (!quoteSupported && supportedBridgePath(quoteID, cexBaseID)) {
- quoteBridges[cexBaseID] = getBridgeNames(quoteID, cexBaseID)
- continue
- }
- }
-
- // Find all markets that trade either base or quote assets trade on. If there
- // is an exact match, we can return early.
- const baseMarkets = new Set()
- const quoteMarkets = new Set()
- for (const { baseID: cexBaseID, quoteID: cexQuoteID } of Object.values(cexStatus.markets ?? [])) {
- if (supportedMarkets(cexBaseID, cexQuoteID, baseID, quoteID)) {
- return [true, [], baseBridges, quoteBridges]
- }
-
- if (cexBaseID === baseID || baseBridges[cexBaseID]) baseMarkets.add(cexQuoteID)
- if (cexQuoteID === baseID || quoteBridges[cexQuoteID]) baseMarkets.add(cexBaseID)
- if (cexBaseID === quoteID || baseBridges[cexBaseID]) quoteMarkets.add(cexQuoteID)
- if (cexQuoteID === quoteID || quoteBridges[cexQuoteID]) quoteMarkets.add(cexBaseID)
- }
-
- // If there was no exact match, find all the intermediate assets that can
- // be used for a multi-hop arb.
- const intermediateAssets: Record = {}
- for (const intermediateAsset of baseMarkets) {
- if (quoteMarkets.has(intermediateAsset)) {
- intermediateAssets[intermediateAsset] = true
- }
- }
-
- return [false, Object.keys(intermediateAssets).map(Number), baseBridges, quoteBridges]
- }
-
- /*
- * cexMarketSupportFilter returns a lookup CEXes that have a matching market
- * for the currently selected base and quote assets.
- */
- cexMarketSupportFilter (baseID: number, quoteID: number) {
- const cexes: Record = {}
- for (const [cexName, cexStatus] of Object.entries(app().mmStatus.cexes)) {
- const [supportsDirectArb, intermediateAssets] = this.cexSupportsArbOnMarket(baseID, quoteID, cexStatus)
- if (supportsDirectArb || intermediateAssets.length > 0) {
- cexes[cexName] = true
- }
- }
- return (cexName: string) => Boolean(cexes[cexName])
- }
-}
-
-function botIsRunning (specs: BotSpecs, mmStatus: MarketMakingStatus): boolean {
- const botStatus = mmStatus.bots.find(({ config: cfg }) => cfg.host === specs.host && cfg.baseID === specs.baseID && cfg.quoteID === specs.quoteID)
- return Boolean(botStatus?.running)
-}
-
-const ExchangeNames: Record = {
- 'binance.com': 'Binance',
- 'coinbase.com': 'Coinbase',
- 'bittrex.com': 'Bittrex',
- 'hitbtc.com': 'HitBTC',
- 'exmo.com': 'EXMO'
-}
-
-function tokenAssetApprovalStatuses (host: string, b: SupportedAsset, q: SupportedAsset) {
- let baseAssetApprovalStatus = ApprovalStatus.Approved
- let quoteAssetApprovalStatus = ApprovalStatus.Approved
-
- if (b?.token) {
- const baseAsset = app().assets[b.id]
- const baseVersion = app().exchanges[host].assets[b.id].version
- if (baseAsset?.wallet?.approved && baseAsset.wallet.approved[baseVersion] !== undefined) {
- baseAssetApprovalStatus = baseAsset.wallet.approved[baseVersion]
- }
- }
- if (q?.token) {
- const quoteAsset = app().assets[q.id]
- const quoteVersion = app().exchanges[host].assets[q.id].version
- if (quoteAsset?.wallet?.approved && quoteAsset.wallet.approved[quoteVersion] !== undefined) {
- quoteAssetApprovalStatus = quoteAsset.wallet.approved[quoteVersion]
- }
- }
-
- return [
- baseAssetApprovalStatus,
- quoteAssetApprovalStatus
- ]
-}
-
-class WalletSettings {
- pg: MarketMakerSettingsPage
- div: PageElement
- page: Record
- updated: () => void
- optElements: Record
-
- constructor (pg: MarketMakerSettingsPage, div: PageElement, updated: () => void) {
- this.pg = pg
- this.div = div
- this.page = Doc.parseTemplate(div)
- this.updated = updated
- }
-
- clear () {
- Doc.empty(this.page.walletSettings)
- }
-
- init (walletConfig: Record, assetID: number, isQuote: boolean) {
- const { page } = this
- const walletSettings = app().currentWalletDefinition(assetID)
- Doc.empty(page.walletSettings)
- Doc.setVis(!walletSettings.multifundingopts, page.walletSettingsNone)
- const { symbol } = app().assets[assetID]
- page.ticker.textContent = symbol.toUpperCase()
- page.logo.src = Doc.logoPath(symbol)
- if (!walletSettings.multifundingopts) return
- const optToDiv: Record = {}
- const dependentOpts: Record = {}
- const addDependentOpt = (optKey: string, optSetting: PageElement, dependentOn: string) => {
- if (!dependentOpts[dependentOn]) dependentOpts[dependentOn] = []
- dependentOpts[dependentOn].push(optKey)
- optToDiv[optKey] = optSetting
- }
- const setDependentOptsVis = (parentOptKey: string, vis: boolean) => {
- const optKeys = dependentOpts[parentOptKey]
- if (!optKeys) return
- for (const optKey of optKeys) Doc.setVis(vis, optToDiv[optKey])
- }
- this.optElements = {}
- const addOpt = (opt: OrderOption) => {
- if (opt.quoteAssetOnly && !isQuote) return
- const currVal = walletConfig[opt.key]
- let div: PageElement | undefined
- if (opt.isboolean) {
- div = page.boolSettingTmpl.cloneNode(true) as PageElement
- const tmpl = Doc.parseTemplate(div)
- tmpl.name.textContent = opt.displayname
- tmpl.input.checked = currVal === 'true'
- Doc.bind(tmpl.input, 'change', () => {
- walletConfig[opt.key] = tmpl.input.checked ? 'true' : 'false'
- setDependentOptsVis(opt.key, Boolean(tmpl.input.checked))
- this.updated()
- })
- if (opt.description) tmpl.tooltip.dataset.tooltip = opt.description
- this.optElements[opt.key] = tmpl.input
- } else if (opt.xyRange) {
- const { start, end, xUnit } = opt.xyRange
- const range = end.x - start.x
- div = page.rangeSettingTmpl.cloneNode(true) as PageElement
- const tmpl = Doc.parseTemplate(div)
- tmpl.name.textContent = opt.displayname
- if (opt.description) tmpl.tooltip.dataset.tooltip = opt.description
- if (xUnit) tmpl.unit.textContent = xUnit
- else Doc.hide(tmpl.unit)
-
- const input = new NumberInput(tmpl.value, {
- prec: 1,
- changed: (rawV: number) => {
- const [v, s] = toFourSigFigs(rawV, 1)
- walletConfig[opt.key] = s
- slider.setValue((v - start.x) / range)
- this.updated()
- }
- })
- const slider = new MiniSlider(tmpl.slider, (r: number) => {
- const rawV = start.x + r * range
- const [v, s] = toFourSigFigs(rawV, 1)
- walletConfig[opt.key] = s
- input.setValue(v)
- this.updated()
- })
- // TODO: default value should be smaller or none for base asset.
- const [v, s] = toFourSigFigs(parseFloatDefault(currVal, start.x), 3)
- walletConfig[opt.key] = s
- slider.setValue((v - start.x) / range)
- input.setValue(v)
- tmpl.value.textContent = s
- this.optElements[opt.key] = input
- }
- if (!div) return console.error("don't know how to handle opt", opt)
- page.walletSettings.appendChild(div)
- if (opt.dependsOn) {
- addDependentOpt(opt.key, div, opt.dependsOn)
- const parentOptVal = walletConfig[opt.dependsOn]
- Doc.setVis(parentOptVal === 'true', div)
- }
- }
- if (walletSettings.multifundingopts && walletSettings.multifundingopts.length > 0) {
- for (const opt of walletSettings.multifundingopts) addOpt(opt)
- }
- app().bindTooltips(page.walletSettings)
- }
-}
-
-type Fees = {
- swap: number
- redeem: number
- refund: number
- funding: number
-}
-
-interface PerLotBreakdown {
- totalAmount: number
- tradedAmount: number
- fees: Fees
- slippageBuffer: number
- multiSplitBuffer: number
-}
-
-function newPerLotBreakdown () : PerLotBreakdown {
- return {
- totalAmount: 0,
- tradedAmount: 0,
- fees: { swap: 0, redeem: 0, refund: 0, funding: 0 },
- slippageBuffer: 0,
- multiSplitBuffer: 0
- }
-}
-
-interface PerLot {
- cex: Record
- dex: Record
-}
-
-interface FeeReserveBreakdown {
- buyReserves: Fees
- sellReserves: Fees
-}
-
-type AllocationStatus = 'sufficient' | 'insufficient' | 'sufficient-with-rebalance'
-
-interface CalculationBreakdown {
- totalRequired: number
-
- feeReserves: FeeReserveBreakdown
- numBuyFeeReserves: number
- numSellFeeReserves: number
-
- numBuyLots: number
- buyLot: PerLotBreakdown
- numSellLots: number
- sellLot: PerLotBreakdown
-
- // initialFundingFees are the fees to initially place
- // every buy and sell lot.
- initialBuyFundingFees: number
- initialSellFundingFees: number
-
- // bridgeFeeReserves is the amount of round trip bridges for which the user
- // wants to reserve funds.
- bridgeFeeReserves: number
- // bridgeFees is the fees in this asset required to perform a round trip
- // bridge.
- bridgeFees: number
-
- available: number
- allocated: number
- rebalanceAdjustment: number
-
- // For running bots only
- runningBotAvailable: number
- runningBotTotal: number
-}
-
-function newCalculationBreakdown () : CalculationBreakdown {
- return {
- buyLot: newPerLotBreakdown(),
- sellLot: newPerLotBreakdown(),
- feeReserves: {
- buyReserves: { swap: 0, redeem: 0, refund: 0, funding: 0 },
- sellReserves: { swap: 0, redeem: 0, refund: 0, funding: 0 }
- },
- numBuyFeeReserves: 0,
- numSellFeeReserves: 0,
- numBuyLots: 0,
- numSellLots: 0,
- initialBuyFundingFees: 0,
- initialSellFundingFees: 0,
- bridgeFees: 0,
- bridgeFeeReserves: 0,
- totalRequired: 0,
- available: 0,
- allocated: 0,
- rebalanceAdjustment: 0,
- runningBotAvailable: 0,
- runningBotTotal: 0
- }
-}
-
-interface AllocationDetail {
- amount: number
- status: AllocationStatus
- calculation: CalculationBreakdown
-}
-
-function newAllocationDetail () : AllocationDetail {
- return {
- amount: 0,
- status: 'sufficient',
- calculation: newCalculationBreakdown()
- }
-}
-
-type AllocationResult = {
- dex: Record
- cex: Record
-}
-
-export type AvailableFunds = {
- dex: Record
- cex?: Record
-}
-
-function allocationResultToBotBalanceAllocation (allocationResult: AllocationResult) : BotBalanceAllocation {
- const result: BotBalanceAllocation = { dex: {}, cex: {} }
- for (const assetID of Object.keys(allocationResult.dex)) {
- result.dex[Number(assetID)] = allocationResult.dex[Number(assetID)].amount
- }
- for (const assetID of Object.keys(allocationResult.cex)) {
- result.cex[Number(assetID)] = allocationResult.cex[Number(assetID)].amount
- }
- return result
-}
-
-// combineBotAllocations combines two allocations. If the result of an allocation
-// is negative, it is set to 0.
-function combineBotAllocations (alloc1: BotBalanceAllocation, alloc2: BotBalanceAllocation) : BotBalanceAllocation {
- const result: BotBalanceAllocation = { dex: {}, cex: {} }
-
- for (const assetIDStr of Object.keys(alloc1.dex)) {
- const assetID = Number(assetIDStr)
- result.dex[assetID] = (alloc1.dex?.[assetID] ?? 0) + (alloc2.dex?.[assetID] ?? 0)
- if (result.dex[assetID] < 0) {
- result.dex[assetID] = 0
- }
- }
-
- for (const assetIDStr of Object.keys(alloc1.cex)) {
- const assetID = Number(assetIDStr)
- result.cex[assetID] = (alloc1.cex?.[assetID] ?? 0) + (alloc2.cex?.[assetID] ?? 0)
- if (result.cex[assetID] < 0) {
- result.cex[assetID] = 0
- }
- }
-
- return result
}
diff --git a/client/webserver/site/src/js/mmsettings/components/AdvancedPlacements.tsx b/client/webserver/site/src/js/mmsettings/components/AdvancedPlacements.tsx
new file mode 100644
index 0000000000..2c5eedb6f1
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/AdvancedPlacements.tsx
@@ -0,0 +1,362 @@
+import React from 'react'
+import { GapStrategy, OrderPlacement, ArbMarketMakingPlacement } from '../../registry'
+import { useBotConfigState, useBotConfigDispatch } from '../utils/BotConfig'
+import PlacementsChartWrapper from './PlacementsChartWrapper'
+import { PlacementsPanelHeader } from './QuickPlacements'
+import { FormLabel, NumberInput, IconButton, ErrorMessage } from './FormComponents'
+import { useBootstrapBreakpoints } from '../hooks/PageSizeBreakpoints'
+
+type UnifiedPlacement = OrderPlacement | ArbMarketMakingPlacement;
+
+const gapStrategies = {
+ 'percent-plus': {
+ label: 'Percent Plus',
+ description: 'Places orders at percentages above and below the market price.',
+ factorLabel: 'Percent',
+ checkRange: (value: number) => (value <= 0 || value > 10) ? 'Percent must be between 0 and 10' : null,
+ convert: (value: number) => value / 100,
+ },
+ 'percent': {
+ label: 'Percent',
+ description: 'Places orders at fixed percentages from the market price.',
+ factorLabel: 'Percent',
+ checkRange: (value: number) => (value <= 0 || value > 10) ? 'Percent must be between 0 and 10' : null,
+ convert: (value: number) => value / 100,
+ },
+ 'absolute-plus': {
+ label: 'Absolute Plus',
+ description: 'Places orders at fixed amounts above and below the market price.',
+ factorLabel: 'Rate',
+ checkRange: (value: number) => (value <= 0) ? 'Rate must be greater than 0' : null,
+ convert: (value: number) => value,
+ },
+ 'absolute': {
+ label: 'Absolute',
+ description: 'Places orders at fixed amounts from the market price.',
+ factorLabel: 'Rate',
+ checkRange: (value: number) => (value <= 0) ? 'Rate must be greater than 0' : null,
+ convert: (value: number) => value,
+ },
+ 'multiplier': {
+ label: 'Multiplier',
+ description: 'Uses a multiplier to determine order placement.',
+ factorLabel: 'Multiplier',
+ checkRange: (value: number) => (value < 1 || value > 100) ? 'Multiplier must be between 1 and 100' : null,
+ convert: (value: number) => value,
+ }
+} as const
+
+const GapStrategySelector: React.FC = () => {
+ const { botConfig } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ // Only render for basic market making bots
+ if (!botConfig.basicMarketMakingConfig) return null
+
+ const gapStrategy = botConfig.basicMarketMakingConfig.gapStrategy
+
+ const handleGapStrategyChange = (e: React.ChangeEvent) => {
+ dispatch({ type: 'SET_GAP_STRATEGY', payload: e.target.value as GapStrategy })
+ }
+
+ return (
+
+
+
+
+ {gapStrategy && (
+
+ Strategy: {gapStrategies[gapStrategy].description}
+
+ )}
+
+ )
+}
+
+const ProfitSelector: React.FC = () => {
+ const { botConfig } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ const [errorMessage, setErrorMessage] = React.useState('')
+
+ // Only render for arbitrage market making bots
+ if (!botConfig.arbMarketMakingConfig) return null
+
+ const profit = botConfig.arbMarketMakingConfig.profit
+ const handleProfitChange = (value: number) => {
+ setErrorMessage('')
+ if (value <= 0) {
+ setErrorMessage('Profit must be greater than 0')
+ return
+ }
+ dispatch({ type: 'SET_PROFIT', payload: value / 100 })
+ }
+
+ return (
+
+
+
+ {errorMessage && setErrorMessage('')} />}
+
+
+
+ )
+}
+
+interface PlacementRowProps {
+ index: number
+ isFirst: boolean
+ isLast: boolean
+ lots: number
+ gapFactor: number
+ gapStrategy: GapStrategy
+ onMoveUp: () => void
+ onMoveDown: () => void
+ onRemove: () => void
+}
+
+const PlacementRow: React.FC = ({
+ index,
+ isFirst,
+ isLast,
+ lots,
+ gapFactor,
+ gapStrategy,
+ onMoveUp,
+ onMoveDown,
+ onRemove
+}) => {
+ const { botConfig } = useBotConfigState()
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+
+ const isPercent = gapStrategy === 'percent' || gapStrategy === 'percent-plus'
+ const displayFactor = isPercent ? gapFactor * 100 : gapFactor
+
+ return (
+
+ { isBasicMM ? | {index + 1} | : null }
+ {lots} |
+
+ {displayFactor}
+ {isPercent && '%'}
+ |
+
+
+ {!isFirst && }
+ {!isLast && }
+ |
+
+ )
+}
+
+interface PlacementsProps {
+ isSell: boolean
+}
+
+const Placements: React.FC = ({ isSell }) => {
+ const { botConfig } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ const [lots, setLots] = React.useState(undefined)
+ const [gapFactor, setGapFactor] = React.useState(undefined)
+ const [errorMessage, setErrorMessage] = React.useState('')
+
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+ const gapStrategy: GapStrategy = botConfig.basicMarketMakingConfig?.gapStrategy ?? 'multiplier'
+ const title = isSell ? "Sell Placements" : "Buy Placements"
+ const placements: UnifiedPlacement[] = isSell
+ ? (botConfig.basicMarketMakingConfig?.sellPlacements || botConfig.arbMarketMakingConfig?.sellPlacements || [])
+ : (botConfig.basicMarketMakingConfig?.buyPlacements || botConfig.arbMarketMakingConfig?.buyPlacements || [])
+
+ const getFactor = (placement: UnifiedPlacement) => 'gapFactor' in placement ? placement.gapFactor : placement.multiplier
+
+ const validateAndAddPlacement = () => {
+ // Validate lots
+ if (!lots || lots <= 0 || !Number.isInteger(lots)) {
+ setErrorMessage('Lots must be a whole number greater than 0')
+ return
+ }
+
+ // Validate gap factor
+ if (!gapFactor) {
+ setErrorMessage('Gap factor must be a valid number')
+ return
+ }
+
+ const rangeError = gapStrategies[gapStrategy].checkRange(gapFactor)
+ if (rangeError) {
+ setErrorMessage(rangeError)
+ return
+ }
+
+ // Convert gap factor for storage if needed
+ const storageGapFactor = gapStrategies[gapStrategy].convert(gapFactor)
+
+ // Check for duplicate gap factors
+ const duplicateExists = placements.some(placement => getFactor(placement) === storageGapFactor)
+
+ if (duplicateExists) {
+ setErrorMessage(`A placement with ${gapFactor}${(gapStrategy === 'percent' || gapStrategy === 'percent-plus') ? '%' : ''} already exists`)
+ return
+ }
+
+ dispatch({
+ type: 'ADD_PLACEMENT',
+ payload: { sell: isSell, lots, gapFactor: storageGapFactor }
+ })
+
+ setLots(undefined)
+ setGapFactor(undefined)
+ setErrorMessage('')
+ }
+
+ const handleMoveUp = (index: number) => {
+ if (index > 0) {
+ dispatch({
+ type: 'REORDER_PLACEMENTS',
+ payload: { sell: isSell, fromIndex: index, toIndex: index - 1 }
+ })
+ }
+ }
+
+ const handleMoveDown = (index: number) => {
+ if (index < placements.length - 1) {
+ dispatch({
+ type: 'REORDER_PLACEMENTS',
+ payload: { sell: isSell, fromIndex: index, toIndex: index + 1 }
+ })
+ }
+ }
+
+ const handleRemovePlacement = (index: number) => {
+ dispatch({
+ type: 'REMOVE_PLACEMENT',
+ payload: { sell: isSell, index }
+ })
+ }
+
+ return (
+
+
+ {title}
+
+
+
+
+
+ { isBasicMM ? | Priority | : null }
+ Lots |
+ {gapStrategies[gapStrategy].factorLabel} |
+ |
+
+
+
+ {placements.map((placement, index) => (
+ handleMoveUp(index)}
+ onMoveDown={() => handleMoveDown(index)}
+ onRemove={() => handleRemovePlacement(index)}
+ />
+ ))}
+
+ { isBasicMM ? | : null }
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {errorMessage && (
+
+ |
+ setErrorMessage('')} />
+ |
+
+ )}
+
+
+
+
+ )
+}
+
+const headerDescription = "Manually configure each order placement."
+
+export const AdvancedPlacements: React.FC = () => {
+
+ const pageSize = useBootstrapBreakpoints(['lg'])
+
+ const header =
+
+ // One column layout for small screens
+ if (pageSize === 'xs') {
+ return (
+
+ )
+ }
+
+ // Two column layout for large screens
+ return (
+
+ {header}
+
+ {/* LEFT COLUMN */}
+
+
+ {/* RIGHT COLUMN */}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/webserver/site/src/js/mmsettings/components/BotAllocationsTab.tsx b/client/webserver/site/src/js/mmsettings/components/BotAllocationsTab.tsx
new file mode 100644
index 0000000000..eac37cfc90
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/BotAllocationsTab.tsx
@@ -0,0 +1,20 @@
+import React from 'react'
+import { useBotConfigState } from '../utils/BotConfig'
+import QuickAllocationView from './QuickAllocation'
+import ManualAllocationView from './ManualAllocation'
+
+
+const BotAllocationsTab: React.FC = () => {
+ const { botConfig } = useBotConfigState()
+
+ return (
+
+ {botConfig.uiConfig.usingQuickBalance
+ ?
+ :
+ }
+
+ )
+}
+
+export default BotAllocationsTab
diff --git a/client/webserver/site/src/js/mmsettings/components/BotPlacementsTab.tsx b/client/webserver/site/src/js/mmsettings/components/BotPlacementsTab.tsx
new file mode 100644
index 0000000000..97c806eafc
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/BotPlacementsTab.tsx
@@ -0,0 +1,19 @@
+import React from 'react'
+import { useBotConfigState } from '../utils/BotConfig'
+import { AdvancedPlacements } from './AdvancedPlacements'
+import { QuickPlacements } from './QuickPlacements'
+
+const BotPlacementsTab: React.FC = () => {
+ const { quickPlacements } = useBotConfigState()
+
+ return (
+
+ {quickPlacements
+ ?
+ :
+ }
+
+ )
+}
+
+export default BotPlacementsTab
diff --git a/client/webserver/site/src/js/mmsettings/components/BotSettingsTab.tsx b/client/webserver/site/src/js/mmsettings/components/BotSettingsTab.tsx
new file mode 100644
index 0000000000..0c3b3ca96a
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/BotSettingsTab.tsx
@@ -0,0 +1,425 @@
+import React from 'react'
+import { useBotConfigState, useBotConfigDispatch } from '../utils/BotConfig'
+import Tooltip from './Tooltip'
+import { app, OrderOption } from '../../registry'
+import Doc from '../../doc'
+import { PanelHeader, NumberInput } from './FormComponents'
+
+export const SettingsPanelHeader: React.FC = () => {
+ return (
+
+ )
+}
+
+const MultiHopSettings: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ const { intermediateAssets, intermediateAsset, botConfig } = botConfigState
+ if (!intermediateAssets || intermediateAssets.length === 0) {
+ return null
+ }
+
+ // Get the current multi-hop config
+ const multiHopConfig = botConfig.arbMarketMakingConfig?.multiHop
+ const marketOrders = multiHopConfig?.marketOrders ?? false
+ const limitOrdersBuffer = multiHopConfig?.limitOrdersBuffer ?? 0.01
+
+ const handleIntermediateAssetChange = (assetID: number) => {
+ dispatch({
+ type: 'UPDATE_INTERMEDIATE_ASSET',
+ payload: assetID
+ })
+ }
+
+ const handleOrderTypeChange = (isMarketOrder: boolean) => {
+ dispatch({
+ type: 'UPDATE_MULTI_HOP_MARKET_COMPLETION',
+ payload: isMarketOrder
+ })
+ }
+
+ const handleLimitBufferChange = (value: number) => {
+ dispatch({
+ type: 'UPDATE_MULTI_HOP_LIMIT_BUFFER',
+ payload: value / 100 // Convert from percentage to decimal
+ })
+ }
+
+ return (
+
+ {/* Intermediate Asset */}
+
+ Intermediate Asset
+
+
+
+
+
+
+
+
+ {/* Completion Radio Buttons */}
+
+
+ Completion Order Type
+
+
+
+
+
+
+
+
+ {/* Limit Orders Buffer Slider - Only show when limit orders are selected */}
+ {!marketOrders && (
+
+
+ Limit Buffer
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+interface IndividualWalletSettingsProps {
+ asset: 'base' | 'quote'
+ options: OrderOption[] | null
+ optionsState: Record | null
+ onSettingChange: (asset: 'base' | 'quote', key: string, value: string) => void
+}
+
+const IndividualWalletSettings: React.FC = ({
+ asset,
+ options,
+ optionsState,
+ onSettingChange
+}) => {
+ if (!options || options.length === 0) {
+ return (
+
+ No settings available for {asset} wallet
+
+ )
+ }
+
+ return (
+
+ {options.map((opt: OrderOption) => {
+ // Skip options that are quote-only for base asset
+ if (opt.quoteAssetOnly && asset === 'base') return null
+
+ if (opt.dependsOn && ((optionsState?.[opt.dependsOn] || 'false') !== 'true')) return null
+
+ const currentValue = optionsState?.[opt.key] || opt.default?.toString() || ''
+
+ if (opt.isboolean) {
+ return (
+
+
+ onSettingChange(asset, opt.key, e.target.checked ? 'true' : 'false')}
+ />
+
+ {opt.description && (
+
+
+
+ )}
+
+
+ )
+ }
+
+ if (opt.xyRange) {
+ const { start, end, xUnit } = opt.xyRange
+ const numericValue = parseFloat(currentValue) || start.x
+
+ return (
+
+
+ {opt.displayname}
+ {opt.description && (
+
+
+
+ )}
+
+
+
+ onSettingChange(asset, opt.key, value.toString())}
+ withSlider={true}
+ />
+
+ {xUnit &&
{xUnit}}
+
+
+ )
+ }
+
+ return null
+ })}
+
+ )
+}
+
+const WalletSettings: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { botConfig, dexMarket, baseMultiFundingOpts, quoteMultiFundingOpts } = botConfigState
+ const dispatch = useBotConfigDispatch()
+
+ const handleWalletSettingChange = (asset: 'base' | 'quote', key: string, value: string) => {
+ dispatch({
+ type: 'UPDATE_WALLET_SETTING',
+ payload: { asset, key, value }
+ })
+ }
+
+ const baseAsset = app().assets[dexMarket.baseID]
+ const quoteAsset = app().assets[dexMarket.quoteID]
+
+ return (
+
+
+
+ Wallet Settings
+
+
+
+ {/* Base Wallet Settings */}
+
+
+
+
})
+
Base Wallet ({baseAsset.unitInfo.conventional.unit})
+
+
+
+
+
+
+ {/* Quote Wallet Settings */}
+
+
+
+
})
+
Quote Wallet ({quoteAsset.unitInfo.conventional.unit})
+
+
+
+
+
+
+
+ )
+}
+
+const Knobs: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { botConfig } = botConfigState
+ const dispatch = useBotConfigDispatch()
+
+ const driftTolerance = botConfig.basicMarketMakingConfig?.driftTolerance ||
+ botConfig.arbMarketMakingConfig?.driftTolerance || 0.001
+
+ const orderPersistence = botConfig.arbMarketMakingConfig?.orderPersistence ||
+ botConfig.simpleArbConfig?.numEpochsLeaveOpen || 2
+
+ return (
+
+
+
+ Knobs
+
+
+
+ {/* Drift Tolerance (ArbMM or BasicMM) */}
+ {(botConfig.arbMarketMakingConfig || botConfig.basicMarketMakingConfig) && (
+
+
+ Drift Tolerance
+
+
+
+
+
+
+ dispatch({
+ type: 'UPDATE_DRIFT_TOLERANCE',
+ payload: value / 100
+ })}
+ withSlider={true}
+ />
+
+
%
+
+
+ )}
+
+ {/* Order Persistence (ArbMM or BasicArb) */}
+ {(botConfig.arbMarketMakingConfig || botConfig.simpleArbConfig) && (
+
+
+ Order Persistence
+
+
+
+
+
+
+ dispatch({
+ type: 'UPDATE_ORDER_PERSISTENCE',
+ payload: value
+ })}
+ withSlider={true}
+ />
+
+
epochs
+
+
+ )}
+
+
+ )
+}
+
+const MultiHopSection: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { intermediateAssets } = botConfigState
+ if (!intermediateAssets || intermediateAssets.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ Multi-Hop Arbitrage
+
+
+
+
+
+
+ )
+}
+
+const BotSettingsTab: React.FC = () => {
+ return (
+
+ )
+}
+
+export default BotSettingsTab
diff --git a/client/webserver/site/src/js/mmsettings/components/BotTypeSelector.tsx b/client/webserver/site/src/js/mmsettings/components/BotTypeSelector.tsx
new file mode 100644
index 0000000000..cb5d4b4d35
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/BotTypeSelector.tsx
@@ -0,0 +1,311 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import Doc from '../../doc';
+import { app, MarketWithHost, MMCEXStatus } from '../../registry';
+import { CEXDisplayInfos } from '../../mmutil';
+import { renderSymbol } from './MMSettings';
+import CEXConfigForm from './CEXConfigForm';
+import {
+ prep,
+ ID_MM_CONFIGURE,
+ ID_MM_FIX_ERRORS,
+ ID_MM_MARKET_NOT_AVAILABLE,
+ ID_MM_CHOOSE_BOT,
+ ID_MM_BASIC_MARKET_MAKER,
+ ID_MM_MM_PLUS_ARB,
+ ID_MM_BASIC_ARBITRAGE,
+ ID_MM_SUBMIT
+} from '../../locales';
+
+interface CexMarketSupportChecker {
+ (baseID: number, quoteID: number, cexName: string, directOnly: boolean): boolean;
+}
+
+interface BotTypeSelectorProps {
+ selectedMarket: MarketWithHost;
+ cexes?: Record;
+ checkCexMarketSupport?: CexMarketSupportChecker;
+ onClose?: () => void;
+ onBotTypeSelected: (botType: 'basicMM' | 'arbMM' | 'basicArb', cexName?: string) => void;
+ onChangeMarket?: () => void;
+ handleCEXesUpdated: () => void;
+}
+
+interface CEXIconProps {
+ cexName: string;
+ isSelected: boolean;
+ cexStatus: MMCEXStatus | null;
+ supportsArbitrage: boolean;
+ onSelect: () => void;
+ onConfigure: () => void;
+ onReconfigure: (e: React.MouseEvent) => void;
+}
+
+const CEXIcon: React.FC = ({
+ cexName,
+ isSelected,
+ cexStatus,
+ supportsArbitrage,
+ onSelect,
+ onConfigure,
+ onReconfigure
+}) => {
+ const cexInfo = CEXDisplayInfos[cexName];
+ const isConfigured = cexStatus !== null;
+
+ const handleClick = () => {
+ if (isConfigured && !cexStatus?.connectErr) {
+ onSelect();
+ } else {
+ onConfigure();
+ }
+ };
+
+ const statusIndicator = () : React.ReactNode => {
+ if (!isConfigured) return (
+
+
+ {prep(ID_MM_CONFIGURE)}
+
+ );
+
+ if (cexStatus.connectErr) return (
+
+
+ {prep(ID_MM_FIX_ERRORS)}
+
+ );
+
+ if (!supportsArbitrage) return (
+ {prep(ID_MM_MARKET_NOT_AVAILABLE)}
+ );
+
+ return null;
+ };
+
+ return (
+
+ {isConfigured && !cexStatus?.connectErr && (
+
+
+ )}
+
+
+

+
{cexName}
+
+
+ {statusIndicator()}
+
+ );
+};
+
+const BotTypeSelector: React.FC = ({
+ selectedMarket,
+ cexes = {},
+ checkCexMarketSupport,
+ onClose,
+ onBotTypeSelected,
+ onChangeMarket,
+ handleCEXesUpdated
+}) => {
+ const checkCexArbitrageSupport = useCallback((cexName: string, directOnly: boolean): boolean => {
+ if (!checkCexMarketSupport) return false;
+ return checkCexMarketSupport(selectedMarket.baseID, selectedMarket.quoteID, cexName, directOnly);
+ }, [checkCexMarketSupport, selectedMarket.baseID, selectedMarket.quoteID]);
+
+ const findSupportedCex = useCallback((directOnly: boolean): string => {
+ for (const cexName of Object.keys(cexes || {})) {
+ if (checkCexArbitrageSupport(cexName, directOnly)) {
+ return cexName;
+ }
+ }
+ return '';
+ }, [cexes, checkCexArbitrageSupport]);
+
+ const [selectedBotType, setSelectedBotType] = useState<'basicMM' | 'arbMM' | 'basicArb'>('basicMM');
+ const [selectedCex, setSelectedCex] = useState('');
+ const [configuringCex, setConfiguringCex] = useState(null);
+
+ useEffect(() => {
+ const directOnly = selectedBotType === 'basicArb';
+ const supportedCex = findSupportedCex(directOnly);
+ if (!selectedCex || !checkCexArbitrageSupport(selectedCex, directOnly)) {
+ setSelectedCex(supportedCex);
+ }
+ }, [findSupportedCex, selectedCex, checkCexArbitrageSupport]);
+
+ const handleBotTypeSelect = (botType: 'basicMM' | 'arbMM' | 'basicArb') => {
+ setSelectedBotType(botType);
+
+ if (botType === 'basicMM') {
+ setSelectedCex('');
+ return;
+ }
+
+ if (botType === 'arbMM' || botType === 'basicArb') {
+ const directOnly = botType === 'basicArb';
+ const supportedCex = findSupportedCex(directOnly);
+ setSelectedCex(supportedCex);
+ }
+ };
+
+ const handleCexSelect = (cexName: string) => {
+ setSelectedCex(cexName);
+ };
+
+ const handleConfigureCex = (cexName: string) => {
+ setConfiguringCex(cexName);
+ };
+
+ const handleCexConfigClose = () => {
+ setConfiguringCex(null);
+ };
+
+ const handleSubmit = () => {
+ if (selectedBotType) {
+ let cex : string | undefined = selectedCex;
+ if (selectedBotType === 'basicMM' || cex === '') cex = undefined;
+ onBotTypeSelected(selectedBotType, cex);
+ }
+ };
+
+ const baseSymbol = app().assets[selectedMarket.baseID].symbol;
+ const quoteSymbol = app().assets[selectedMarket.quoteID].symbol;
+ const availableCexes = Object.keys(CEXDisplayInfos);
+ const isArbBotSelected = selectedBotType === 'arbMM' || selectedBotType === 'basicArb';
+ const isSubmitDisabled = !selectedBotType || (isArbBotSelected && !selectedCex);
+
+ return (
+
+ );
+};
+
+export default BotTypeSelector;
\ No newline at end of file
diff --git a/client/webserver/site/src/js/mmsettings/components/CEXConfigForm.tsx b/client/webserver/site/src/js/mmsettings/components/CEXConfigForm.tsx
new file mode 100644
index 0000000000..fb2299e852
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/CEXConfigForm.tsx
@@ -0,0 +1,125 @@
+import React, { useState } from 'react';
+import { CEXDisplayInfos, MM } from '../../mmutil';
+import { MMCEXStatus } from '../../registry';
+import { app } from '../../registry';
+
+interface CEXConfigFormProps {
+ cexName: string;
+ cexStatus: MMCEXStatus | null;
+ onClose: () => void;
+ onCEXUpdated: () => void;
+}
+
+const CEXConfigForm: React.FC = ({
+ cexName,
+ onClose,
+ cexStatus,
+ onCEXUpdated,
+}) => {
+ const [apiKey, setApiKey] = useState(cexStatus && cexStatus.connectErr ? cexStatus.config.apiKey : '');
+ const [apiSecret, setApiSecret] = useState(cexStatus && cexStatus.connectErr ? cexStatus.config.apiSecret : '');
+ const [error, setError] = useState('');
+
+ const cexInfo = CEXDisplayInfos[cexName];
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!apiKey.trim() || !apiSecret.trim()) {
+ setError('API Key and Secret are required');
+ return;
+ }
+
+ const res = await MM.updateCEXConfig({ name: cexName, apiKey: apiKey.trim(), apiSecret: apiSecret.trim() });
+ await app().fetchMMStatus();
+ // Update the CEX statuses even if we fail, because if the CEX has a
+ // connection error, we still want the pre-populated incorrect credentials
+ // to be updated.
+ onCEXUpdated?.();
+
+ if (!app().checkResponse(res)) {
+ setError(`Failed to update CEX configuration: ${res.msg}`);
+ } else {
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default CEXConfigForm;
diff --git a/client/webserver/site/src/js/mmsettings/components/ConfigureBot.tsx b/client/webserver/site/src/js/mmsettings/components/ConfigureBot.tsx
new file mode 100644
index 0000000000..2d48a67b25
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/ConfigureBot.tsx
@@ -0,0 +1,411 @@
+import React, { useState, useEffect } from 'react'
+import Doc from '../../doc'
+import { app } from '../../registry'
+import { MM } from '../../mmutil'
+import { useBotConfigState } from '../utils/BotConfig'
+import { renderSymbol, useMMSettingsSetError } from './MMSettings'
+import BotPlacementsTab from './BotPlacementsTab'
+import BotAllocationsTab from './BotAllocationsTab'
+import BotSettingsTab from './BotSettingsTab'
+import RebalanceSettingsTab from './RebalanceSettingsTab'
+import Popup from './Popup'
+import { useBootstrapBreakpoints } from '../hooks/PageSizeBreakpoints'
+import {
+ prep,
+ ID_MM_START_BOT,
+ ID_MM_SAVE_SETTINGS,
+ ID_MM_DELETE_BOT,
+ ID_MM_UPDATE_RUNNING_BOT,
+ ID_MM_CONFIRM_DELETE,
+ ID_MM_CANCEL,
+ ID_MM_DELETE,
+ ID_MM_PLACEMENTS,
+ ID_MM_ALLOCATIONS,
+ ID_MM_SETTINGS,
+ ID_MM_REBALANCE_SETTINGS,
+ ID_MM_BASIC_MARKET_MAKER,
+ ID_MM_MM_PLUS_ARB,
+ ID_MM_BASIC_ARBITRAGE,
+ ID_MM_UNKNOWN,
+ ID_MM_FAILED_SAVE_BOT_CONFIG,
+ ID_MM_FAILED_START_BOT
+} from '../../locales'
+
+// Market Button component
+const MarketButton: React.FC<{ onChangeMarket?: () => void }> = ({
+ onChangeMarket
+}) => {
+ const botConfigState = useBotConfigState()
+ const mkt = botConfigState.dexMarket
+
+ return (
+
+
+
})
+
})
+ {renderSymbol(mkt.baseID, mkt.baseAsset.symbol)}–{renderSymbol(mkt.quoteID, mkt.quoteAsset.symbol)}
+
+
+
+ @
+ {mkt.host}
+
+
+ )
+}
+
+// Bot Type Button component
+const BotTypeButton: React.FC<{
+ onChangeBotType: () => void
+ alignRight?: boolean
+}> = ({
+ onChangeBotType,
+ alignRight = false
+}) => {
+ const botConfigState = useBotConfigState()
+ const cfg = botConfigState.botConfig
+
+ // Determine bot type from config
+ const getBotType = () => {
+ if (cfg.basicMarketMakingConfig) return prep(ID_MM_BASIC_MARKET_MAKER)
+ if (cfg.arbMarketMakingConfig) return prep(ID_MM_MM_PLUS_ARB)
+ if (cfg.simpleArbConfig) return prep(ID_MM_BASIC_ARBITRAGE)
+ return prep(ID_MM_UNKNOWN)
+ }
+
+ const botType = getBotType()
+
+ return (
+
+ )
+}
+
+// BotActionButtons component
+const BotActionButtons: React.FC<{
+ layout?: 'column' | 'row'
+}> = ({
+ layout = 'column'
+}) => {
+ const botConfigState = useBotConfigState()
+ const setError = useMMSettingsSetError()
+ const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false)
+
+ const handleSaveSettings = async () => {
+ try {
+ await MM.updateBotConfig(botConfigState.botConfig)
+ await app().fetchMMStatus()
+ app().loadPage('mm')
+ } catch (error) {
+ // TODO: show error message
+ console.error('Failed to save bot config:', error)
+ }
+ }
+
+ const handleStart = async () => {
+ let res = await MM.updateBotConfig(botConfigState.botConfig)
+ if (!app().checkResponse(res)) {
+ setError({
+ message: prep(ID_MM_FAILED_SAVE_BOT_CONFIG) + res.msg
+ })
+ return
+ }
+
+ res = await MM.startBot({
+ baseID: botConfigState.dexMarket.baseID,
+ quoteID: botConfigState.dexMarket.quoteID,
+ host: botConfigState.dexMarket.host,
+ alloc: botConfigState.botConfig.uiConfig.allocation,
+ autoRebalance: {
+ minBaseTransfer: botConfigState.botConfig.uiConfig.baseMinTransfer,
+ minQuoteTransfer: botConfigState.botConfig.uiConfig.quoteMinTransfer,
+ internalOnly: !botConfigState.botConfig.uiConfig.cexRebalance
+ }
+ })
+ if (!app().checkResponse(res)) {
+ setError({
+ message: prep(ID_MM_FAILED_START_BOT) + res.msg
+ })
+ return
+ }
+
+ await app().fetchMMStatus()
+
+ app().loadPage('mm')
+ }
+
+ const handleDeleteBotClick = () => {
+ setShowDeleteConfirmation(true)
+ }
+
+ const confirmDeleteBot = async () => {
+ setShowDeleteConfirmation(false)
+ try {
+ await MM.removeBotConfig(botConfigState.botConfig.host, botConfigState.botConfig.baseID, botConfigState.botConfig.quoteID)
+ await app().fetchMMStatus()
+ app().loadPage('mm')
+ } catch (error) {
+ console.error('Failed to delete bot:', error)
+ }
+ }
+
+ const cancelDeleteBot = () => {
+ setShowDeleteConfirmation(false)
+ }
+
+ const handleUpdateRunningBot = async () => {
+ try {
+ await MM.updateRunningBot(botConfigState.botConfig, botConfigState.botConfig.uiConfig.allocation, {
+ minBaseTransfer: botConfigState.botConfig.uiConfig.baseMinTransfer,
+ minQuoteTransfer: botConfigState.botConfig.uiConfig.quoteMinTransfer,
+ internalOnly: !botConfigState.botConfig.uiConfig.cexRebalance
+ })
+ await app().fetchMMStatus()
+ app().loadPage('mm')
+ } catch (error) {
+ // TODO: show error message
+ console.error('Failed to update running bot:', error)
+ }
+ }
+
+ let containerClass = `d-flex flex-row gap-2 p-2 mb-1`
+ let buttonClass = `m-2 flex-fill`
+ if (layout == 'column') {
+ buttonClass = `my-1 w-100`
+ containerClass = `d-flex flex-column gap-2 p-2 mb-3`
+ }
+
+ if (botConfigState.runStats) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {showDeleteConfirmation && (
+
+ )}
+ >
+ )
+}
+
+// BotTabNavigation component
+interface BotTabNavigationProps {
+ activeTab: 'placements' | 'allocations' | 'settings' | 'rebalanceSettings'
+ onTabChange: (tab: 'placements' | 'allocations' | 'settings' | 'rebalanceSettings') => void
+ layout?: 'column' | 'row'
+}
+
+const BotTabNavigation: React.FC = ({
+ activeTab,
+ onTabChange,
+ layout = 'column'
+}) => {
+ const cfg = useBotConfigState().botConfig
+ const flexDirection = layout === 'row' ? 'flex-row' : 'flex-column'
+ const spacing = layout === 'row' ? 'me-3' : 'mb-2'
+
+ return (
+
+
+ {!cfg.simpleArbConfig && (
+
onTabChange('placements')}
+ >
+
{prep(ID_MM_PLACEMENTS)}
+
+ )}
+
onTabChange('allocations')}
+ >
+
{prep(ID_MM_ALLOCATIONS)}
+
+
onTabChange('settings')}
+ >
+
{prep(ID_MM_SETTINGS)}
+
+ { cfg.cexName &&
onTabChange('rebalanceSettings')}
+ >
+
{prep(ID_MM_REBALANCE_SETTINGS)}
+
}
+
+
+ )
+}
+
+interface ConfigureBotProps {
+ onChangeMarket: () => void
+ onChangeBotType: () => void
+}
+
+const ConfigureBot: React.FC = ({
+ onChangeMarket,
+ onChangeBotType,
+}) => {
+ const botConfigState = useBotConfigState()
+ const cfg = botConfigState.botConfig
+ const initialTab = cfg.simpleArbConfig ? 'settings' : 'placements'
+ const [activeTab, setActiveTab] = useState<'placements' | 'allocations' | 'settings' | 'rebalanceSettings'>(initialTab)
+ const [availableHeight, setAvailableHeight] = useState(0)
+ const pageSize = useBootstrapBreakpoints(['lg', 'xl'])
+
+ // Calculate available height dynamically and prevent page scrolling
+ useEffect(() => {
+ // Store original overflow values
+ const originalHtmlOverflow = document.documentElement.style.overflow
+ const originalBodyOverflow = document.body.style.overflow
+
+ // Prevent page scrolling
+ document.documentElement.style.overflow = 'hidden'
+ document.body.style.overflow = 'hidden'
+
+ const calculateHeight = () => {
+ // Get viewport height
+ const viewportHeight = window.innerHeight
+
+ // Account for top/bottom margins (5% each) and leave some buffer
+ const topMargin = viewportHeight * 0.05
+ const bottomMargin = viewportHeight * 0.05
+ const buffer = 20 // Small buffer for any additional spacing
+
+ const available = viewportHeight - topMargin - bottomMargin - buffer
+ setAvailableHeight(Math.max(available, 400)) // Minimum height of 400px
+ }
+
+ // Calculate initial height
+ calculateHeight()
+
+ // Add resize listener
+ window.addEventListener('resize', calculateHeight)
+
+ // Cleanup function
+ return () => {
+ window.removeEventListener('resize', calculateHeight)
+ document.documentElement.style.overflow = originalHtmlOverflow
+ document.body.style.overflow = originalBodyOverflow
+ }
+ }, [])
+
+ const handleTabChange = (tab: 'placements' | 'allocations' | 'settings' | 'rebalanceSettings') => {
+ setActiveTab(tab)
+ }
+
+ const currentTabContent = () => {
+ if (activeTab === 'placements') return
+ if (activeTab === 'allocations') return
+ if (activeTab === 'settings') return
+ if (activeTab === 'rebalanceSettings') return
+ }
+
+ // Check if screen is large or larger (lg or xl)
+ const isLargeScreen = pageSize === 'lg' || pageSize === 'xl'
+
+ if (isLargeScreen) {
+ return (
+ 0 ? `${availableHeight}px` : '100vh'
+ }}>
+
+
+ {/* LEFT PANEL */}
+
+
+ {/* RIGHT PANEL */}
+
+ { currentTabContent() }
+
+
+
+ )
+ }
+
+ // Small screen layout - stacked vertically
+ return (
+
+ {/* TOP ROW - Market and Bot Type buttons */}
+
+
+
+
+
+ {/* MIDDLE ROW - Action buttons */}
+
+
+ {/* BOTTOM ROW - Tab navigation */}
+
+
+ {/* TAB CONTENT */}
+
+ { currentTabContent() }
+
+
+ )
+}
+
+export default ConfigureBot
diff --git a/client/webserver/site/src/js/mmsettings/components/ErrorPopup.tsx b/client/webserver/site/src/js/mmsettings/components/ErrorPopup.tsx
new file mode 100644
index 0000000000..5bfdc98c84
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/ErrorPopup.tsx
@@ -0,0 +1,37 @@
+import React, { useContext } from 'react'
+import { MMSettingsSetErrorContext } from './MMSettings'
+import Popup from './Popup'
+import { prep, ID_MM_ERROR, ID_MM_CLOSE } from '../../locales'
+
+interface ErrorPopupProps {
+ error: { message: string; onClose?: () => void } | null
+}
+
+const ErrorPopup: React.FC = ({ error }) => {
+ const setError = useContext(MMSettingsSetErrorContext)
+
+ if (!error || !setError) {
+ return null
+ }
+
+ const handleClose = () => {
+ setError(null)
+ if (error.onClose) {
+ error.onClose()
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default ErrorPopup
diff --git a/client/webserver/site/src/js/mmsettings/components/FormComponents.tsx b/client/webserver/site/src/js/mmsettings/components/FormComponents.tsx
new file mode 100644
index 0000000000..2b38c7568e
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/FormComponents.tsx
@@ -0,0 +1,319 @@
+import React from 'react'
+import { prep, ID_MM_LOADING } from '../../locales'
+
+interface PanelHeaderProps {
+ title: string
+ description?: string
+ buttonText?: string
+ onClick?: () => void
+}
+
+export const PanelHeader: React.FC = ({ title, description, buttonText, onClick }) => {
+ return (
+
+
+ {title}
+ {buttonText && onClick && (
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ )
+}
+
+interface FormLabelProps {
+ text: string
+ description?: string
+ className?: string
+ isBold?: boolean
+}
+
+export const FormLabel: React.FC = ({ text, description, className = '', isBold = true }) => {
+ return (
+
+ {text}
+ {description && {description}}
+
+ )
+}
+
+interface NumberInputProps {
+ value?: number;
+ onChange: (num: number) => void;
+ min?: number;
+ max?: number;
+ precision?: number;
+ className?: string;
+ // If onIncrement and onDecrement are provided, the component will show up
+ // and down arrows
+ onIncrement?: () => void;
+ onDecrement?: () => void;
+ header?: React.ReactNode;
+ bottomContent?: React.ReactNode;
+ suffix?: string;
+ disabled?: boolean;
+ withSlider?: boolean;
+}
+
+export const NumberInput: React.FC = ({
+ value,
+ onChange,
+ min,
+ max,
+ precision = 0,
+ className = 'p-2 text-center fs20',
+ suffix,
+ onIncrement,
+ onDecrement,
+ header,
+ bottomContent,
+ withSlider = false,
+ disabled = false,
+}) => {
+ const [inputValue, setInputValue] = React.useState(value !== undefined ? value.toFixed(precision) : '');
+ const [isDragging, setIsDragging] = React.useState(false);
+ const sliderRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const formattedValue = value !== undefined ? value.toFixed(precision) : '';
+ if (inputValue !== formattedValue) {
+ setInputValue(formattedValue);
+ }
+ }, [value, precision]);
+
+ if (withSlider && (min === undefined || max === undefined)) {
+ console.error('The props `min` and `max` must be provided when `withSlider` is true.');
+ return null;
+ }
+
+ const commitInputValue = React.useCallback(() => {
+ if (inputValue === '') {
+ if (value !== undefined) {
+ setInputValue(value.toFixed(precision));
+ }
+ return;
+ }
+
+ let numericValue = parseFloat(inputValue);
+ if (isNaN(numericValue)) {
+ const formattedValue = value !== undefined ? value.toFixed(precision) : '';
+ setInputValue(formattedValue);
+ return;
+ }
+ let clampedValue = numericValue;
+ if (min !== undefined) clampedValue = Math.max(min, clampedValue);
+ if (max !== undefined) clampedValue = Math.min(max, clampedValue);
+ const roundedValue = parseFloat(clampedValue.toFixed(precision));
+ setInputValue(roundedValue.toFixed(precision));
+ if (roundedValue !== value) {
+ onChange(roundedValue);
+ }
+ }, [inputValue, min, max, precision, value, onChange]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ commitInputValue();
+ (e.target as HTMLInputElement).blur();
+ }
+ };
+
+ const getPercentage = React.useCallback(() => {
+ if (value === undefined || min === undefined || max === undefined || max === min) return 0;
+ return ((value - min) / (max - min)) * 100;
+ }, [value, min, max]);
+
+ const getValueFromPercentage = React.useCallback((percentage: number) => {
+ if (min === undefined || max === undefined) return 0;
+ const clampedPercentage = Math.max(0, Math.min(100, percentage));
+ const rawValue = min + (clampedPercentage / 100) * (max - min);
+ return parseFloat(rawValue.toFixed(precision));
+ }, [min, max, precision]);
+
+ const handleSliderInteraction = React.useCallback((clientX: number) => {
+ if (disabled || !sliderRef.current) return;
+ const rect = sliderRef.current.getBoundingClientRect();
+ const percentage = ((clientX - rect.left) / rect.width) * 100;
+ const newValue = getValueFromPercentage(percentage);
+ setInputValue(newValue.toFixed(precision));
+ onChange(newValue);
+ }, [disabled, getValueFromPercentage, onChange, precision]);
+
+ const handleSliderClick = (e: React.MouseEvent) => {
+ handleSliderInteraction(e.clientX);
+ };
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (disabled) return;
+ setIsDragging(true);
+ e.preventDefault();
+ };
+
+ const handleMouseMove = React.useCallback((e: MouseEvent) => {
+ if (!isDragging) return;
+ handleSliderInteraction(e.clientX);
+ }, [isDragging, handleSliderInteraction]);
+
+ const handleMouseUp = React.useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ React.useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }
+ }, [isDragging, handleMouseMove, handleMouseUp]);
+
+ const hasArrows = onIncrement && onDecrement;
+
+ return (
+
+ {header}
+
+
+
+ {withSlider && (
+
+ )}
+
+
+ {hasArrows && (
+
+ )}
+ {suffix &&
{suffix}}
+
+
+ {bottomContent}
+
+ );
+};
+
+interface IconButtonProps {
+ iconClass: string
+ onClick: () => void
+ size?: string
+ ariaLabel: string
+ className?: string
+}
+
+
+export const IconButton: React.FC = ({ iconClass, onClick, size = 'fs15', ariaLabel, className = 'pointer px-2' }) => {
+ return (
+
+ )
+}
+
+interface ErrorMessageProps {
+ message: string
+ onClear?: () => void
+ timeout?: number
+}
+
+export const ErrorMessage: React.FC = ({ message, onClear, timeout = 5000 }) => {
+ React.useEffect(() => {
+ if (message && onClear) {
+ const timer = setTimeout(onClear, timeout)
+ return () => clearTimeout(timer)
+ }
+ }, [message, onClear, timeout])
+
+ if (!message) return null
+ return {message}
+}
+
+interface LoadingSpinnerProps {
+ isLoading: boolean
+}
+
+export const LoadingSpinner: React.FC = ({ isLoading }) => {
+ if (!isLoading) return null
+
+ return (
+
+
+ {prep(ID_MM_LOADING)}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/webserver/site/src/js/mmsettings/components/MMSettings.tsx b/client/webserver/site/src/js/mmsettings/components/MMSettings.tsx
new file mode 100644
index 0000000000..9479c21f2f
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/MMSettings.tsx
@@ -0,0 +1,600 @@
+import React, { useReducer, useState, createContext, useContext, forwardRef, useImperativeHandle } from 'react'
+import { MMCEXStatus, app, BalanceNote, CEXBalanceUpdate, SupportedAsset, ApprovalStatus } from '../../registry'
+import { MM } from '../../mmutil'
+import { BotSpecs, specLK } from '../../mmsettings'
+import Doc from '../../doc'
+import MarketSelector from './MarketSelector'
+import BotTypeSelector from './BotTypeSelector'
+import ConfigureBot from './ConfigureBot'
+import ErrorPopup from './ErrorPopup'
+import { LoadingSpinner } from './FormComponents'
+import { botConfigStateReducer, initialBotConfigState, BotConfigStateContext, BotConfigDispatchContext, BotConfigState } from '../utils/BotConfig'
+import State from '../../state'
+import { requiredDexAssets } from '../utils/AllocationUtil'
+
+export interface AvailableMarket {
+ host: string
+ name: string
+ baseID: number
+ quoteID: number
+ baseSymbol: string
+ quoteSymbol: string
+ hasArb: boolean
+ arbs: string[]
+ spot?: any
+}
+
+export interface AvailableMarkets {
+ markets: AvailableMarket[]
+ exchangesRequiringRegistration: string[]
+}
+
+// Helper function to render symbols with proper capitalization and token parent logos
+export const renderSymbol = (assetID: number, symbol: string): JSX.Element => {
+ const asset = app().assets[assetID]
+ if (!asset) {
+ return {symbol.toUpperCase()}
+ }
+
+ const parts = symbol.split('.')
+ const isToken = parts.length === 2
+
+ if (!isToken) {
+ return {symbol.toUpperCase()}
+ }
+
+ const tokenSymbol = parts[0]
+ const parentSymbol = parts[1]
+
+ return (
+
+ {tokenSymbol.toUpperCase()}
+
+
+ )
+}
+
+// cexSupportsArbOnMarket checks whether the CEX supports arbitrage market
+// making on the given market. It returns a tuple of:
+//
+// - whether the CEX supports direct arbitrage on the market
+// - the intermediate assets that can be used for multi-hop arbitrage
+// - the CEX assetIDs that the base asset can be bridged to
+// - the CEX assetIDs that the quote asset can be bridged to
+//
+// If the CEX does not support direct arb and there are no intermediate assets,
+// the CEX does not support arbitrage market making on the market.
+// The bridge destination assets will be empty if the CEX supports the same
+// asset that is used on the DEX market.
+export const cexSupportsArbOnMarket = (
+ baseID: number,
+ quoteID: number,
+ cexStatus: MMCEXStatus,
+ bridgePaths: Record>
+): [boolean, number[] | null, Record | null, Record | null] => {
+ const supportedBridgePath = (dexAssetID: number, cexAssetID: number) => {
+ if (!bridgePaths[dexAssetID]) return false
+ const dests = bridgePaths[dexAssetID]
+ return dests[cexAssetID] !== undefined
+ }
+
+ const getBridgeNames = (dexAssetID: number, cexAssetID: number): string[] => {
+ if (!bridgePaths[dexAssetID]) return []
+ const dests = bridgePaths[dexAssetID]
+ return dests[cexAssetID] || []
+ }
+
+ const supportedMarkets = (dexBaseID: number, dexQuoteID: number, cexBaseID: number, cexQuoteID: number) => {
+ if (dexBaseID !== cexBaseID) {
+ if (!supportedBridgePath(dexBaseID, cexBaseID)) return false
+ }
+ if (dexQuoteID !== cexQuoteID) {
+ if (!supportedBridgePath(dexQuoteID, cexQuoteID)) return false
+ }
+ return true
+ }
+
+ // baseBridges and quoteBridges are all the assets that the base and quote
+ // asset can be bridged to that are supported by the CEX, mapping to available bridge names.
+ // If the CEX supports the base or quote assets directly, the bridge map will be empty.
+ let baseBridges: Record | null = {}
+ let quoteBridges: Record | null = {}
+ for (const { baseID: cexBaseID, quoteID: cexQuoteID } of Object.values(cexStatus.markets ?? [])) {
+ if (cexBaseID === baseID) {
+ baseBridges = null
+ }
+ if (cexQuoteID === quoteID) {
+ quoteBridges = null
+ }
+ if (baseBridges && supportedBridgePath(baseID, cexBaseID)) {
+ baseBridges[cexBaseID] = getBridgeNames(baseID, cexBaseID)
+ continue
+ }
+ if (baseBridges && supportedBridgePath(baseID, cexQuoteID)) {
+ baseBridges[cexQuoteID] = getBridgeNames(baseID, cexQuoteID)
+ continue
+ }
+ if (quoteBridges && supportedBridgePath(quoteID, cexQuoteID)) {
+ quoteBridges[cexQuoteID] = getBridgeNames(quoteID, cexQuoteID)
+ continue
+ }
+ if (quoteBridges && supportedBridgePath(quoteID, cexBaseID)) {
+ quoteBridges[cexBaseID] = getBridgeNames(quoteID, cexBaseID)
+ continue
+ }
+ }
+
+ // Find all markets that trade either base or quote assets trade on. If there
+ // is an exact match, we can return early.
+ const baseMarkets = new Set()
+ const quoteMarkets = new Set()
+ for (const { baseID: cexBaseID, quoteID: cexQuoteID } of Object.values(cexStatus.markets ?? [])) {
+ if (supportedMarkets(cexBaseID, cexQuoteID, baseID, quoteID)) {
+ return [true, null, baseBridges, quoteBridges]
+ }
+
+ if (cexBaseID === baseID || (baseBridges && baseBridges[cexBaseID])) baseMarkets.add(cexQuoteID)
+ if (cexQuoteID === baseID || (baseBridges && baseBridges[cexQuoteID])) baseMarkets.add(cexBaseID)
+ if (cexBaseID === quoteID || (quoteBridges && quoteBridges[cexBaseID])) quoteMarkets.add(cexQuoteID)
+ if (cexQuoteID === quoteID || (quoteBridges && quoteBridges[cexQuoteID])) quoteMarkets.add(cexBaseID)
+ }
+
+ // If there was no exact match, find all the intermediate assets that can
+ // be used for a multi-hop arb.
+ const intermediateAssets: Record = {}
+ for (const intermediateAsset of baseMarkets) {
+ if (quoteMarkets.has(intermediateAsset)) {
+ intermediateAssets[intermediateAsset] = true
+ }
+ }
+
+ // Filter out duplicate intermediate assets. If two intermediate assets
+ // share the same symbol, only one of them is required. WETH is also
+ // ignored.
+ const intermediateAssetSymbols : Set = new Set()
+ const filteredIntermediateAssets: number[] = []
+ for (const intermediateAsset of Object.keys(intermediateAssets).map(Number)) {
+ const asset = app().assets[intermediateAsset]
+ if (!asset) {
+ continue
+ }
+ const assetSymbol = asset.symbol.split('.')[0]
+ if (assetSymbol === "weth") {
+ continue
+ }
+ console.log('asset', assetSymbol)
+ if (intermediateAssetSymbols.has(assetSymbol)) {
+ continue
+ }
+ intermediateAssetSymbols.add(assetSymbol)
+ filteredIntermediateAssets.push(intermediateAsset)
+ }
+
+ return [false, filteredIntermediateAssets, baseBridges, quoteBridges]
+}
+
+// Function to check if a specific CEX supports arbitrage on a market
+const createCexMarketSupportChecker = (
+ bridgePaths: Record>,
+ cexes: Record
+) => {
+ return (baseID: number, quoteID: number, cexName: string, directOnly: boolean): boolean => {
+ const cexStatus = cexes[cexName]
+ if (!cexStatus) return false
+
+ const [supportsDirectArb, intermediateAssets] = cexSupportsArbOnMarket(
+ baseID,
+ quoteID,
+ cexStatus,
+ bridgePaths
+ )
+
+ if (directOnly) {
+ return supportsDirectArb
+ }
+
+ return supportsDirectArb || (!!intermediateAssets && intermediateAssets.length > 0)
+ }
+}
+
+export const MMSettingsSetErrorContext = createContext | undefined>(undefined)
+
+export const MMSettingsSetLoadingContext = createContext | undefined>(undefined)
+
+export const useMMSettingsSetError = () => {
+ const context = useContext(MMSettingsSetErrorContext)
+ if (context === undefined) {
+ throw new Error('useMMSettingsSetError must be used within a MMSettingsSetErrorProvider')
+ }
+ return context
+}
+
+export const useMMSettingsSetLoading = () => {
+ const context = useContext(MMSettingsSetLoadingContext)
+ if (context === undefined) {
+ throw new Error('useMMSettingsSetLoading must be used within a MMSettingsSetLoadingProvider')
+ }
+ return context
+}
+
+export interface MMSettingsError {
+ message: string
+ onClose?: () => void
+}
+
+const checkFiatRates = (_: BotConfigState): boolean => {
+ // TODO: check fiat rates for the required assets
+ // const assetIDs = requiredDexAssets(botConfigState)
+ return true
+}
+
+function initialErrorState(botConfigState: BotConfigState | string | undefined, returnToMM?: boolean): [BotConfigState | null, MMSettingsError | null] {
+ if (!botConfigState) {
+ return [null, null]
+ }
+
+ if (typeof botConfigState === 'string') {
+ return [null, {
+ message: botConfigState,
+ onClose: () => {
+ if (returnToMM) app().loadPage('mm')
+ }
+ }]
+ }
+
+ if (!checkFiatRates(botConfigState)) {
+ return [null, {
+ message: 'Fiat rates are not available for the selected market',
+ onClose: () => {
+ if (returnToMM) app().loadPage('mm')
+ }
+ }]
+ }
+
+ return [botConfigState, null]
+}
+
+interface MMSettingsProps {
+ availableMarkets?: AvailableMarkets
+ initialCexes?: Record
+ bridgePaths?: Record>
+ initialSpecs?: BotSpecs
+ // botConfigStateOnLoad may be a string, which means an error should be displayed.
+ botConfigStateOnLoad?: BotConfigState | string
+}
+
+export interface MMSettingsHandle {
+ handleBalanceNote: (note: BalanceNote) => void
+ handleCEXBalanceUpdate: (cexName: string, update: CEXBalanceUpdate) => void
+}
+
+function tokenAssetApprovalStatuses (host: string, b: SupportedAsset, q: SupportedAsset) {
+ let baseApprovalStatus = ApprovalStatus.Approved
+ let quoteApprovalStatus = ApprovalStatus.Approved
+
+ if (b?.token) {
+ const baseAsset = app().assets[b.id]
+ const baseVersion = app().exchanges[host].assets[b.id].version
+ if (baseAsset?.wallet?.approved && baseAsset.wallet.approved[baseVersion] !== undefined) {
+ baseApprovalStatus = baseAsset.wallet.approved[baseVersion]
+ }
+ }
+ if (q?.token) {
+ const quoteAsset = app().assets[q.id]
+ const quoteVersion = app().exchanges[host].assets[q.id].version
+ if (quoteAsset?.wallet?.approved && quoteAsset.wallet.approved[quoteVersion] !== undefined) {
+ quoteApprovalStatus = quoteAsset.wallet.approved[quoteVersion]
+ }
+ }
+
+ return {
+ baseApprovalStatus,
+ quoteApprovalStatus
+ }
+}
+
+const MMSettings = forwardRef(({
+ availableMarkets = { markets: [], exchangesRequiringRegistration: [] },
+ initialCexes = {},
+ bridgePaths = {},
+ botConfigStateOnLoad = undefined
+}, ref) => {
+ const [initialState, initialError] = initialErrorState(botConfigStateOnLoad)
+ const [error, setError] = useState(initialError)
+ const [botConfigState, dispatch] = useReducer(botConfigStateReducer, initialState)
+ const [isLoading, setIsLoading] = useState(false)
+ const [updatingMarketOrType, setUpdatingMarketOrType] = useState(false)
+ const [cexes, setCexes] = useState>(initialCexes)
+ const [selectedMarket, setSelectedMarket] = useState<{
+ host: string;
+ baseID: number;
+ quoteID: number;
+ } | null>(null)
+
+ const handleCEXesUpdated = async () => {
+ try {
+ const status = await MM.status()
+ setCexes(status.cexes)
+ } catch (error) {
+ console.error('Failed to update CEX status:', error)
+ }
+ }
+
+ // Expose handleBalanceNote and handleCEXBalanceUpdate to parent via ref
+ useImperativeHandle(ref, () => ({
+ handleBalanceNote: async (note: BalanceNote) => {
+ // Only update if we have a bot config state
+ if (!botConfigState) return
+
+ // Check if the updated asset is required for the bot
+ const requiredAssets = requiredDexAssets(botConfigState)
+ if (!requiredAssets.includes(note.assetID)) return
+
+ try {
+ // Fetch updated available balances
+ const { dexBalances, cexBalances } = await MM.availableBalances(
+ { host: botConfigState.botConfig.host, baseID: botConfigState.dexMarket.baseID, quoteID: botConfigState.dexMarket.quoteID },
+ botConfigState.botConfig.cexBaseID,
+ botConfigState.botConfig.cexQuoteID,
+ botConfigState.botConfig.cexName
+ )
+
+ // Dispatch action to update balances
+ dispatch({
+ type: 'UPDATE_AVAILABLE_BALANCES',
+ payload: { dexBalances, cexBalances }
+ })
+ } catch (error) {
+ console.error('Failed to update available balances:', error)
+ }
+ },
+
+ handleCEXBalanceUpdate: async (cexName: string, update: CEXBalanceUpdate) => {
+ // Only update if we have a bot config state
+ if (!botConfigState) return
+
+ // Only update if this is the CEX we're using for this bot
+ if (botConfigState.botConfig.cexName !== cexName) return
+
+ // Check if the updated asset is required for the bot (CEX side)
+ const { cexBaseID, cexQuoteID } = botConfigState.botConfig
+ if (update.assetID !== cexBaseID && update.assetID !== cexQuoteID) return
+
+ try {
+ // Fetch updated available balances
+ const { dexBalances, cexBalances } = await MM.availableBalances(
+ { host: botConfigState.botConfig.host, baseID: botConfigState.dexMarket.baseID, quoteID: botConfigState.dexMarket.quoteID },
+ cexBaseID,
+ cexQuoteID,
+ cexName
+ )
+
+ // Dispatch action to update balances
+ dispatch({
+ type: 'UPDATE_AVAILABLE_BALANCES',
+ payload: { dexBalances, cexBalances }
+ })
+ } catch (error) {
+ console.error('Failed to update CEX available balances:', error)
+ }
+ }
+ }))
+
+ // Create the CEX market support checker function
+ const checkCexMarketSupport = createCexMarketSupportChecker(bridgePaths, cexes)
+
+ const handleBotTypeSelected = async (botType: 'basicMM' | 'arbMM' | 'basicArb', cexName?: string) => {
+ if (!selectedMarket) {
+ console.error('No market selected')
+ return
+ }
+
+ // Check if the market and bot type are the same as currently selected
+ if (initialState) {
+ const currentConfig = initialState.botConfig
+ const marketMatches = (
+ currentConfig.host === selectedMarket.host &&
+ currentConfig.baseID === selectedMarket.baseID &&
+ currentConfig.quoteID === selectedMarket.quoteID
+ )
+
+ // Determine current bot type from config
+ let currentBotType: 'basicMM' | 'arbMM' | 'basicArb'
+ if (currentConfig.basicMarketMakingConfig) {
+ currentBotType = 'basicMM'
+ } else if (currentConfig.arbMarketMakingConfig) {
+ currentBotType = 'arbMM'
+ } else if (currentConfig.simpleArbConfig) {
+ currentBotType = 'basicArb'
+ } else {
+ throw new Error('Invalid bot type in current config')
+ }
+
+ const botTypeMatches = currentBotType === botType
+ const cexMatches = currentConfig.cexName === (cexName || '')
+
+ // If everything matches, just set updatingMarketOrType to false
+ if (marketMatches && botTypeMatches && cexMatches) {
+ setUpdatingMarketOrType(false)
+ return
+ }
+ }
+
+ let baseBridges: Record | null = null
+ let quoteBridges: Record | null = null
+ let intermediateAssets: number[] | null = null
+ let cexStatus: MMCEXStatus | null = null
+
+ if (cexName) {
+ cexStatus = cexes[cexName] ?? null;
+ [, intermediateAssets, baseBridges, quoteBridges] = cexSupportsArbOnMarket(
+ selectedMarket.baseID,
+ selectedMarket.quoteID,
+ cexes[cexName],
+ bridgePaths
+ )
+ }
+
+ // Create the default BotConfig based on the selected market and bot type
+ const newBotConfigState = await initialBotConfigState(
+ selectedMarket.host,
+ selectedMarket.baseID,
+ selectedMarket.quoteID,
+ botType,
+ intermediateAssets,
+ baseBridges,
+ quoteBridges,
+ cexStatus,
+ cexName
+ )
+
+ const [botConfigState, errorState] = initialErrorState(newBotConfigState)
+ if (errorState != null) {
+ setError(errorState)
+ return
+ }
+
+ const botSpecs : BotSpecs = {
+ host: selectedMarket.host,
+ baseID: selectedMarket.baseID,
+ quoteID: selectedMarket.quoteID,
+ botType: botType,
+ cexName: cexName
+ }
+
+ State.storeLocal(specLK, botSpecs)
+
+ dispatch({ type: 'SET_INITIAL_CONFIG', payload: botConfigState })
+ setUpdatingMarketOrType(false)
+ }
+
+ const handleChangeMarket = () => {
+ setSelectedMarket(null)
+ setUpdatingMarketOrType(true)
+ }
+
+ const handleChangeBotType = () => {
+ setUpdatingMarketOrType(true)
+ }
+
+ let mainComponent
+
+ if (botConfigState && !updatingMarketOrType) {
+ mainComponent = (
+
+
+
+
+
+ )
+ } else if (selectedMarket) {
+ mainComponent = (
+ {
+ if (updatingMarketOrType) {
+ setUpdatingMarketOrType(false)
+ } else {
+ setSelectedMarket(null)
+ }
+ }}
+ onBotTypeSelected={handleBotTypeSelected}
+ onChangeMarket={handleChangeMarket}
+ handleCEXesUpdated={handleCEXesUpdated}
+ />
+ )
+ } else {
+ mainComponent = (
+ {
+ if (botConfigState) {
+ setSelectedMarket({
+ host: botConfigState.botConfig.host,
+ baseID: botConfigState.botConfig.baseID,
+ quoteID: botConfigState.botConfig.quoteID,
+ })
+ setUpdatingMarketOrType(false)
+ } else {
+ app().loadPage('mm')
+ }
+ }}
+ checkCexMarketSupport={checkCexMarketSupport}
+ handleMarketSelected={(host: string, baseID: number, quoteID: number) => {
+ const baseWallet = app().walletMap[baseID]
+ const quoteWallet = app().walletMap[quoteID]
+
+ if (!baseWallet) {
+ setError({
+ message: `You must create a ${app().assets[baseID].symbol} wallet to market make on this market.`
+ })
+ return
+ }
+
+ if (baseWallet.disabled) {
+ setError({
+ message: `The ${app().assets[baseID].symbol} wallet is disabled. Please enable it to market make on this market.`
+ })
+ return
+ }
+
+ if (!quoteWallet) {
+ setError({
+ message: `You must create a ${app().assets[quoteID].symbol} wallet to market make on this market.`
+ })
+ return
+ }
+
+ if (quoteWallet.disabled) {
+ setError({
+ message: `The ${app().assets[quoteID].symbol} wallet is disabled. Please enable it to market make on this market.`
+ })
+ return
+ }
+
+ const { baseApprovalStatus, quoteApprovalStatus } = tokenAssetApprovalStatuses(host, app().assets[baseID], app().assets[quoteID])
+
+ if (baseApprovalStatus === ApprovalStatus.NotApproved) {
+ setError({
+ message: `You must approve the ${app().assets[baseID].symbol} asset to market make on this market.`
+ })
+ return
+ }
+
+ if (quoteApprovalStatus === ApprovalStatus.NotApproved) {
+ setError({
+ message: `You must approve the ${app().assets[quoteID].symbol} asset to market make on this market.`
+ })
+ return
+ }
+
+ setSelectedMarket({ host, baseID, quoteID })}}
+ />
+ )
+ }
+
+ return (
+
+
+
+ { mainComponent }
+
+
+
+
+
+ )
+})
+
+export default MMSettings
diff --git a/client/webserver/site/src/js/mmsettings/components/ManualAllocation.tsx b/client/webserver/site/src/js/mmsettings/components/ManualAllocation.tsx
new file mode 100644
index 0000000000..f3f761949c
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/ManualAllocation.tsx
@@ -0,0 +1,122 @@
+import React from 'react'
+import { useBotConfigState, useBotConfigDispatch } from '../utils/BotConfig'
+import { app } from '../../registry'
+import Doc from '../../doc'
+import { requiredDexAssets } from '../utils/AllocationUtil'
+import { CEXDisplayInfos } from '../../mmutil'
+import { AllocationPanelHeader } from './QuickAllocation'
+import { NumberInput } from './FormComponents'
+
+interface ManualBalanceEntryProps {
+ assetID: number
+ location: 'dex' | 'cex',
+ columnClass: string
+}
+
+const ManualBalanceEntry: React.FC = ({
+ assetID,
+ location,
+ columnClass
+}) => {
+ const { runStats, availableCEXBalances, availableDEXBalances,
+ botConfig: { uiConfig: { allocation } }} = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ const asset = app().assets[assetID]
+ const availableBalance = location === 'dex'
+ ? (availableDEXBalances[assetID] || 0)
+ : (availableCEXBalances?.[assetID] || 0)
+ let runningBotAvailable = 0
+ if (runStats) {
+ runningBotAvailable = location === 'dex'
+ ? (runStats.dexBalances[assetID].available || 0)
+ : (runStats.cexBalances[assetID].available || 0)
+ }
+ const currentValue = allocation[location][assetID] || 0
+
+ return (
+
+
+
+ dispatch({
+ type: 'UPDATE_MANUAL_ALLOCATION',
+ payload: { assetID, amount: amount * asset.unitInfo.conventional.conversionFactor, source: location }
+ })}
+ withSlider={true}
+ />
+
+
+ )
+}
+
+const ManualAllocationView: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { botConfig } = botConfigState
+
+ const dexAssetIDs = requiredDexAssets(botConfigState)
+
+ // Calculate column class based on number of items
+ const getColumnClass = (itemCount: number) => {
+ return `col-${Math.floor(24 / itemCount)}`
+ }
+
+ return (
+
+
+
+ {/* DEX Balances */}
+
+
})
+
+
+ {dexAssetIDs.map(assetID => {
+ return (
+
+ )
+ })}
+
+
+
+
+ {/* CEX Balances - only show if CEX is configured */}
+ {botConfig.cexName && (
+
+

+
+
+ {dexAssetIDs.slice(0, 2).map(assetID => {
+ return (
+
+ )
+ })}
+
+
+
+ )}
+
+ )
+}
+
+export default ManualAllocationView
diff --git a/client/webserver/site/src/js/mmsettings/components/MarketSelector.tsx b/client/webserver/site/src/js/mmsettings/components/MarketSelector.tsx
new file mode 100644
index 0000000000..958ffde881
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/MarketSelector.tsx
@@ -0,0 +1,188 @@
+import React, { useState } from 'react';
+import Doc from '../../doc';
+import { CEXDisplayInfos } from '../../mmutil';
+import { renderSymbol } from './MMSettings';
+import { AvailableMarket } from './MMSettings';
+import { prep, ID_MM_SELECT_MARKET, ID_MM_SEARCH_MARKETS, ID_MM_MARKET_HEADER, ID_MM_HOST_HEADER, ID_MM_ARB_HEADER } from '../../locales';
+
+interface MarketData {
+ baseSymbol: string;
+ quoteSymbol: string;
+ baseID: number;
+ quoteID: number;
+ host: string;
+ hasArb: boolean;
+ supportedCexes: string[];
+}
+
+interface CexMarketSupportChecker {
+ (baseID: number, quoteID: number, cexName: string, directOnly: boolean): boolean;
+}
+
+interface MarketSelectorProps {
+ markets?: AvailableMarket[];
+ exchangesRequiringRegistration?: string[];
+ cexes?: Record;
+ checkCexMarketSupport?: CexMarketSupportChecker;
+ onClose: () => void;
+ handleMarketSelected: (host: string, baseID: number, quoteID: number, baseSymbol: string, quoteSymbol: string) => void;
+}
+
+
+const MarketSelector: React.FC = ({
+ markets = [],
+ exchangesRequiringRegistration = [],
+ cexes = {},
+ checkCexMarketSupport,
+ onClose,
+ handleMarketSelected
+}) => {
+ const [filterText, setFilterText] = useState('');
+
+ // Check which CEXes support arbitrage for each market
+ const getSupportedCexes = (market: AvailableMarket) => {
+ const supportedCexes: string[] = []
+ if (checkCexMarketSupport) {
+ for (const cexName of Object.keys(cexes)) {
+ if (checkCexMarketSupport(market.baseID, market.quoteID, cexName, false)) {
+ supportedCexes.push(cexName)
+ }
+ }
+ }
+ return supportedCexes
+ }
+
+ // Convert AvailableMarket data to MarketData format for the component
+ const marketData: MarketData[] = markets.map(market => ({
+ baseSymbol: market.baseSymbol,
+ quoteSymbol: market.quoteSymbol,
+ host: market.host,
+ hasArb: market.hasArb,
+ baseID: market.baseID,
+ quoteID: market.quoteID,
+ supportedCexes: getSupportedCexes(market)
+ }));
+
+ // Filter markets based on search text
+ const filteredMarkets = marketData.filter(market =>
+ market.baseSymbol.toLowerCase().includes(filterText.toLowerCase()) ||
+ market.quoteSymbol.toLowerCase().includes(filterText.toLowerCase()) ||
+ market.host.toLowerCase().includes(filterText.toLowerCase())
+ );
+
+ const handleMarketClick = (market: MarketData) => {
+ handleMarketSelected(market.host, market.baseID, market.quoteID, market.baseSymbol, market.quoteSymbol);
+ };
+
+ return (
+
+ );
+};
+
+export default MarketSelector;
diff --git a/client/webserver/site/src/js/mmsettings/components/PlacementsChartWrapper.tsx b/client/webserver/site/src/js/mmsettings/components/PlacementsChartWrapper.tsx
new file mode 100644
index 0000000000..bfa362bae5
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/PlacementsChartWrapper.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import { useBotConfigState } from '../utils/BotConfig'
+import { PlacementsChart, PlacementChartConfig } from '../../mmutil'
+import { OrderPlacement } from '../../registry'
+
+const PlacementsChartWrapper: React.FC = () => {
+ const chartRef = React.useRef(null)
+ const chartInstanceRef = React.useRef(null)
+ const { botConfig, dexMarket, quickPlacements } = useBotConfigState()
+
+ React.useEffect(() => {
+ if (chartRef.current && !chartInstanceRef.current) {
+ chartInstanceRef.current = new PlacementsChart(chartRef.current)
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (chartInstanceRef.current && botConfig && dexMarket) {
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+ const isArbMM = !!botConfig.arbMarketMakingConfig
+
+ let buyPlacements: OrderPlacement[] = []
+ let sellPlacements: OrderPlacement[] = []
+ let profit = 0
+
+ if (isBasicMM && botConfig.basicMarketMakingConfig) {
+ buyPlacements = [...botConfig.basicMarketMakingConfig.buyPlacements]
+ sellPlacements = [...botConfig.basicMarketMakingConfig.sellPlacements]
+ // For basic MM, profit comes from quick config or defaults to 0
+ profit = quickPlacements?.profitThreshold || 0
+ } else if (isArbMM && botConfig.arbMarketMakingConfig) {
+ buyPlacements = botConfig.arbMarketMakingConfig.buyPlacements.map(placement => ({
+ lots: placement.lots,
+ gapFactor: placement.multiplier
+ }))
+ sellPlacements = botConfig.arbMarketMakingConfig.sellPlacements.map(placement => ({
+ lots: placement.lots,
+ gapFactor: placement.multiplier
+ }))
+ profit = botConfig.arbMarketMakingConfig.profit || 0
+ }
+
+ buyPlacements.sort((a, b) => a.gapFactor - b.gapFactor)
+ sellPlacements.sort((a, b) => a.gapFactor - b.gapFactor)
+
+ const chartConfig: PlacementChartConfig = {
+ cexName: botConfig.cexName,
+ botType: isBasicMM ? 'basicMM' : isArbMM ? 'arbMM' : 'basicArb',
+ baseFiatRate: 1, // TODO: We'll use 1 for now, could be enhanced to use actual fiat rate
+ dict: {
+ profit,
+ buyPlacements,
+ sellPlacements
+ }
+ }
+
+ chartInstanceRef.current.setMarket(chartConfig)
+ }
+ }, [botConfig, dexMarket, quickPlacements])
+
+ return (
+
+ )
+}
+
+export default PlacementsChartWrapper
diff --git a/client/webserver/site/src/js/mmsettings/components/Popup.tsx b/client/webserver/site/src/js/mmsettings/components/Popup.tsx
new file mode 100644
index 0000000000..9fb912b656
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/Popup.tsx
@@ -0,0 +1,71 @@
+import React, { useRef } from 'react'
+
+interface PopupButton {
+ text: string
+ onClick: () => void
+ className?: string
+}
+
+interface PopupProps {
+ title?: string
+ message: string
+ messageClass?: string
+ buttons: PopupButton[]
+ onClose: () => void
+ closeOnBackdropClick?: boolean
+}
+
+const Popup: React.FC = ({
+ title,
+ message,
+ messageClass,
+ buttons,
+ onClose,
+ closeOnBackdropClick = true
+}) => {
+ const formRef = useRef(null)
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (closeOnBackdropClick && formRef.current && !formRef.current.contains(e.target as Node)) {
+ onClose()
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default Popup
+
diff --git a/client/webserver/site/src/js/mmsettings/components/QuickAllocation.tsx b/client/webserver/site/src/js/mmsettings/components/QuickAllocation.tsx
new file mode 100644
index 0000000000..199b6b0cb1
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/QuickAllocation.tsx
@@ -0,0 +1,722 @@
+import React from 'react'
+import { useBotConfigState, useBotConfigDispatch } from '../utils/BotConfig'
+import Tooltip from './Tooltip'
+import { app } from '../../registry'
+import Doc from '../../doc'
+import { requiredDexAssets } from '../utils/AllocationUtil'
+import { PanelHeader, NumberInput } from './FormComponents'
+import State from '../../state'
+import { CEXDisplayInfos } from '../../mmutil'
+import { AllocationDetail } from '../utils/AllocationUtil'
+
+interface QuickConfigInputProps {
+ label: string
+ tooltip: string
+ showBorder?: boolean
+ suffix?: string
+ min: number
+ max: number
+ precision: number
+ value: number
+ onChange: (value: number) => void
+}
+
+const QuickConfigInput: React.FC = ({
+ label,
+ tooltip,
+ showBorder = true,
+ suffix,
+ min,
+ max,
+ precision,
+ value,
+ onChange
+}) => (
+
+
+ {label}
+
+
+
+
+
+
+
+
+ {suffix && {suffix}}
+
+
+
+)
+
+interface CalculationBreakdownRowProps {
+ text: string
+ value: number
+ assetID: number
+ level: 1 | 2
+ isPercentage?: boolean
+ displayZero?: boolean
+}
+
+const CalculationBreakdownRow: React.FC = ({
+ text,
+ value,
+ assetID,
+ level,
+ isPercentage = false,
+ displayZero = false
+}) => {
+ // If displayZero is not true and value is zero, don't render the row
+ if (!displayZero && value === 0) {
+ return null
+ }
+
+ // Helper function to format values
+ const formatValue = (value: number, assetID: number) => {
+ const asset = app().assets[assetID]
+ if (isPercentage) {
+ return `${(value * 100).toFixed(2)}%`
+ }
+ return value ? Doc.formatCoinValue(value, asset.unitInfo) : '0'
+ }
+
+ const textSizeClass = level === 1 ? 'fs14' : 'fs11'
+ const textColorClass = State.isDark() ? 'text-white' : 'text-dark'
+
+ return (
+
+ {text}
+
+ {formatValue(value, assetID)}
+
+
+ )
+}
+
+interface PerLotBreakdownProps {
+ type: 'buy' | 'sell'
+ perLotBreakdown: any
+ numLots: number
+ assetID: number
+}
+
+const PerLotBreakdown: React.FC = ({
+ type,
+ perLotBreakdown,
+ numLots,
+ assetID
+}) => {
+ if (numLots === 0 || perLotBreakdown.totalAmount === 0) {
+ return null
+ }
+
+ const title = `Per ${type === 'buy' ? 'Buy' : 'Sell'} Lot Total`
+
+ return (
+
+ )
+}
+
+interface FeeReservesBreakdownProps {
+ type: 'buy' | 'sell'
+ feeReserves: any
+ numFeeReserves: number
+ assetID: number
+}
+
+const FeeReservesBreakdown: React.FC = ({
+ type,
+ feeReserves,
+ numFeeReserves,
+ assetID
+}) => {
+ if (numFeeReserves === 0) {
+ return null
+ }
+
+ const title = `${type === 'buy' ? 'Buy' : 'Sell'} Fee Reserves`
+ const totalFees = feeReserves.swap + feeReserves.redeem + feeReserves.refund + feeReserves.funding
+
+ return (
+
+ )
+}
+
+interface AllocationBreakdownProps {
+ allocationDetail: AllocationDetail | undefined
+ assetID: number
+}
+
+const AllocationBreakdown: React.FC = ({
+ allocationDetail,
+ assetID
+}) => {
+ if (!allocationDetail) {
+ return null
+ }
+
+ return (
+
+ {/* Per Lot Section */}
+ {(allocationDetail.calculation.numBuyLots > 0 || allocationDetail.calculation.numSellLots > 0) && (
+
+ )}
+
+ {/* Fee Reserves Section */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Aggregation Section */}
+
+ {allocationDetail.calculation.numBuyLots > 0 && (
+
+ )}
+ {allocationDetail.calculation.numSellLots > 0 && (
+
+ )}
+
+
+
+
+ {/* Totals Section */}
+
+
+
+
+
+ {allocationDetail.amount < 0 && }
+
+ {allocationDetail.amount >= 0 && }
+
+
+
+
+ {/* Allocated Amount */}
+
+
+
+
+ )
+}
+
+const BalanceItem: React.FC<{
+ assetID: number;
+ amount: number;
+ status?: string;
+ allocationDetail?: AllocationDetail;
+ isExpanded: boolean;
+ onToggle: () => void;
+}> = ({
+ assetID,
+ amount,
+ status,
+ allocationDetail,
+ isExpanded,
+ onToggle
+}) => {
+ const asset = app().assets[assetID]
+
+ // Helper function to format values
+ const formatValue = (value: number, assetID: number) => {
+ const asset = app().assets[assetID]
+ return value ? Doc.formatCoinValue(value, asset.unitInfo) : '0'
+ }
+
+ // Helper function to get status color class
+ const getStatusClass = (status?: string) => {
+ switch (status) {
+ case 'sufficient': return 'text-buycolor'
+ case 'insufficient': return 'text-danger'
+ case 'sufficient-with-rebalance': return 'text-warning'
+ default: return 'text-danger'
+ }
+ }
+
+ return (
+
+
+
+
})
+
{asset.unitInfo.conventional.unit}
+
+
+
+ {formatValue(amount, assetID)}
+
+
+
+
+
+ {isExpanded && (
+
+ )}
+
+ )
+}
+
+const AllocationTable: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { botConfig, allocationResult } = botConfigState
+ const [expandedItem, setExpandedItem] = React.useState(null)
+
+ if (!allocationResult) return null
+
+ const dexAssetIDs = requiredDexAssets(botConfigState)
+
+ const toggleExpanded = (itemKey: string) => {
+ setExpandedItem(expandedItem === itemKey ? null : itemKey)
+ }
+
+ const cexDisplayInfo = CEXDisplayInfos[botConfig.cexName]
+ const cexLogoPath = cexDisplayInfo?.logo || ''
+ const cexDisplayName = cexDisplayInfo?.name || botConfig.cexName || 'CEX'
+
+ return (
+
+ {/* Two Column Layout */}
+
+ {/* DEX Column */}
+
+ {/* DEX Header */}
+
+
})
+
Bison Wallet Balances
+
+
+ {/* DEX Balances */}
+
+ {dexAssetIDs.map(assetID => {
+ const allocation = allocationResult.dex[assetID]
+ const amount = allocation ? allocation.amount : 0
+ const status = allocation?.status
+ const itemKey = `dex-${assetID}`
+
+ return (
+ toggleExpanded(itemKey)}
+ />
+ )
+ })}
+
+
+
+ {/* CEX Column (only if CEX is configured) */}
+ {botConfig.cexName && (
+
+ {/* CEX Header */}
+
+

+
{cexDisplayName} Balances
+
+
+ {/* CEX Balances */}
+
+ {[botConfig.cexBaseID, botConfig.cexQuoteID].map(assetID => {
+ const allocation = allocationResult.cex[assetID]
+ const amount = allocation ? allocation.amount : 0
+ const status = allocation?.status
+ const itemKey = `cex-${assetID}`
+
+ return (
+ toggleExpanded(itemKey)}
+ />
+ )
+ })}
+
+
+ )}
+
+
+ )
+}
+
+const StatusLabels: React.FC = () => {
+ return
+
+ Sufficient Funds
+
+
+ Insufficient Funds
+
+
+
+ Sufficient With Rebalance
+
+
+
+
+
+
+}
+
+interface AllocationPanelHeaderProps {
+ description: string
+}
+
+export const AllocationPanelHeader: React.FC = ({
+ description
+}) => {
+ const { botConfig } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ const isUsingQuickAllocation = !!botConfig.uiConfig.usingQuickBalance
+
+ const title = isUsingQuickAllocation ? "Quick Allocation" : "Manual Allocation"
+ const buttonText = isUsingQuickAllocation ? "Configure manually" : "Quick config"
+
+ const handleSwitch = () => {
+ dispatch({ type: 'TOGGLE_QUICK_BALANCE', payload: !isUsingQuickAllocation })
+ }
+
+ return (
+
+ )
+}
+
+const QuickAllocationView: React.FC = () => {
+ const { botConfig, baseBridgeFeesAndLimits, quoteBridgeFeesAndLimits, dexMarket } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ if (!botConfig.uiConfig.quickBalance) return null
+
+ const quickBalance = botConfig.uiConfig.quickBalance
+
+ const marketHasAToken = botConfig.baseID !== dexMarket.baseFeeAssetID || botConfig.quoteID !== dexMarket.quoteFeeAssetID
+
+ const handleQuickConfigChange = (field: keyof typeof quickBalance, value: number) => {
+ dispatch({
+ type: 'UPDATE_QUICK_BALANCE',
+ payload: { field, value }
+ })
+ }
+
+ const numPlacementLots = (sells: boolean) : number => {
+ if (botConfig.arbMarketMakingConfig) {
+ const cfg = botConfig.arbMarketMakingConfig
+ const placements = sells ? cfg.sellPlacements : cfg.buyPlacements
+ return placements.reduce((prev, curr) => {
+ return prev + curr.lots
+ }, 0)
+ }
+
+ if (botConfig.basicMarketMakingConfig) {
+ const cfg = botConfig.basicMarketMakingConfig
+ const placements = sells ? cfg.sellPlacements : cfg.buyPlacements
+ return placements.reduce((prev, curr) => {
+ return prev + curr.lots
+ }, 0)
+ }
+
+ return 1
+ }
+
+ return (
+
+
+
+ {/* Two Column Layout */}
+
+ {/* Left Column - Configuration Inputs */}
+
+ {/* Buy Buffer */}
+ handleQuickConfigChange('buysBuffer', value)}
+ />
+
+ {/* Sell Buffer */}
+ handleQuickConfigChange('sellsBuffer', value)}
+ />
+
+ {/* Slippage Buffer */}
+ handleQuickConfigChange('slippageBuffer', value / 100)}
+ />
+
+ {/* Buy Fee Reserve */}
+ { marketHasAToken && handleQuickConfigChange('buyFeeReserve', value)}
+ />}
+
+ {/* Sell Fee Reserve */}
+ { marketHasAToken && handleQuickConfigChange('sellFeeReserve', value)}
+ />}
+
+ {/* Bridge Fee Reserve */}
+ { (baseBridgeFeesAndLimits || quoteBridgeFeesAndLimits) && handleQuickConfigChange('bridgeFeeReserve', value)}
+ />}
+
+
+ {/* Right Column - Balances */}
+
+
+
+ )
+}
+
+export default QuickAllocationView
diff --git a/client/webserver/site/src/js/mmsettings/components/QuickPlacements.tsx b/client/webserver/site/src/js/mmsettings/components/QuickPlacements.tsx
new file mode 100644
index 0000000000..eab4ea2535
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/QuickPlacements.tsx
@@ -0,0 +1,300 @@
+import React from 'react'
+import { useBotConfigState, useBotConfigDispatch } from '../utils/BotConfig'
+import PlacementsChartWrapper from './PlacementsChartWrapper'
+import { FormLabel, NumberInput, PanelHeader } from './FormComponents'
+import { useBootstrapBreakpoints } from '../hooks/PageSizeBreakpoints'
+
+const LevelsPerSideSelector: React.FC = () => {
+ const { quickPlacements } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ if (!quickPlacements) return null
+
+ const handleChange = (value: number) => {
+ dispatch({ type: 'UPDATE_QUICK_CONFIG', payload: { field: 'priceLevelsPerSide', value } })
+ }
+
+ const handleIncrement = () => {
+ handleChange(quickPlacements.priceLevelsPerSide + 1)
+ }
+
+ const handleDecrement = () => {
+ handleChange(Math.max(1, quickPlacements.priceLevelsPerSide - 1))
+ }
+
+ return (
+ }
+ onChange={handleChange}
+ value={quickPlacements.priceLevelsPerSide}
+ precision={0}
+ className="fs18 text-center p-2 fs20 flex-grow-1"
+ onIncrement={handleIncrement}
+ onDecrement={handleDecrement}
+ />
+ )
+}
+
+const LotsOrUsdSelector: React.FC = () => {
+ const { quickPlacements, dexMarket: { lotSize, baseAsset }, fiatRatesMap } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ if (!quickPlacements) return null
+
+ const USD_RATE = fiatRatesMap[baseAsset.id] || 17
+ const [isLotsMode, setIsLotsMode] = React.useState(true)
+
+ const formatUSD = (value: number): string => {
+ return value.toFixed(2)
+ }
+
+ const lotsToUSD = (): number => {
+ const convFactor = baseAsset.unitInfo.conventional.conversionFactor
+ const totalLots = quickPlacements.lotsPerLevel * quickPlacements.priceLevelsPerSide
+ const convLotSize = lotSize / convFactor
+ return totalLots * USD_RATE * convLotSize
+ }
+
+ const usdToLots = (usd: number) => {
+ const convFactor = baseAsset.unitInfo.conventional.conversionFactor
+ const convLotSize = lotSize / convFactor
+ const usdPerLot = USD_RATE * convLotSize
+ return Math.floor(usd / usdPerLot / quickPlacements.priceLevelsPerSide)
+ }
+
+ const handleQuickConfigChange = (value: number) => {
+ dispatch({ type: 'UPDATE_QUICK_CONFIG', payload: { field: 'lotsPerLevel', value } })
+ }
+
+ const handleChange = (value: number) => {
+ if (!isLotsMode) handleQuickConfigChange(usdToLots(value))
+ else handleQuickConfigChange(value)
+ }
+
+ const handleIncrement = () => {
+ handleQuickConfigChange(quickPlacements.lotsPerLevel + 1)
+ }
+
+ const handleDecrement = () => {
+ handleQuickConfigChange(quickPlacements.lotsPerLevel - 1)
+ }
+
+ return (
+
+
+ {setIsLotsMode(!isLotsMode)}}
+ >
+
+
+
+ }
+ value={isLotsMode ? quickPlacements.lotsPerLevel : lotsToUSD()}
+ onChange={handleChange}
+ precision={isLotsMode ? 0 : 2}
+ className="fs18 text-center p-2 fs20 flex-grow-1"
+ onIncrement={handleIncrement}
+ onDecrement={handleDecrement}
+ bottomContent={
+
+ ~
+ {isLotsMode ? formatUSD(lotsToUSD()) : String(quickPlacements.lotsPerLevel)}
+ {isLotsMode ? 'USD per side' : 'Lots per level'}
+
+ }
+ />
+ )
+}
+
+const ProfitThresholdEntry: React.FC = () => {
+ const { quickPlacements } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ if (!quickPlacements) return null
+
+ const handleQuickConfigChange = (value: number) => {
+ dispatch({ type: 'UPDATE_QUICK_CONFIG', payload: { field: 'profitThreshold', value } })
+ }
+
+ return (
+ }
+ min={0.1}
+ max={10}
+ precision={2}
+ value={quickPlacements.profitThreshold * 100}
+ onChange={(value) => handleQuickConfigChange(value / 100)}
+ suffix='%'
+ withSlider={true}
+ />
+ )
+}
+
+const PriceIncrementEntry: React.FC = () => {
+ const { quickPlacements } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ if (!quickPlacements) return null
+
+ const handleQuickConfigChange = (value: number) => {
+ dispatch({ type: 'UPDATE_QUICK_CONFIG', payload: { field: 'priceIncrement', value } })
+ }
+
+ return (
+ }
+ min={0.1}
+ max={2}
+ precision={2}
+ value={quickPlacements.priceIncrement * 100}
+ onChange={(value) => handleQuickConfigChange(value / 100)}
+ disabled={quickPlacements.priceLevelsPerSide === 1}
+ suffix='%'
+ withSlider={true}
+ />
+ )
+}
+
+const MatchBufferEntry: React.FC = () => {
+ const { quickPlacements } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+ if (!quickPlacements) return null
+
+ const handleQuickConfigChange = (value: number) => {
+ dispatch({ type: 'UPDATE_QUICK_CONFIG', payload: { field: 'matchBuffer', value } })
+ }
+
+ return (
+ }
+ min={0}
+ max={100}
+ precision={2}
+ value={quickPlacements.matchBuffer * 100}
+ onChange={(value) => handleQuickConfigChange(value / 100)}
+ suffix='%'
+ withSlider={true}
+ />
+ )
+}
+
+interface PlacementsPanelHeaderProps {
+ description: string
+}
+
+export const PlacementsPanelHeader: React.FC = ({
+ description
+}) => {
+ const { quickPlacements } = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ const isUsingQuickPlacements = !!quickPlacements
+
+ const title = isUsingQuickPlacements ? "Quick Placements" : "Advanced Placements"
+ const buttonText = isUsingQuickPlacements ? "Advanced config" : "Quick config"
+
+ const handleSwitch = () => {
+ dispatch({ type: 'USE_QUICK_PLACEMENTS', payload: !isUsingQuickPlacements })
+ }
+
+ return (
+
+ )
+}
+
+const headerDescription = "Configure the price levels of the placements on both sides of the order book."
+
+export const QuickPlacements: React.FC = () => {
+ const pageSize = useBootstrapBreakpoints(['lg'])
+ const { quickPlacements, botConfig } = useBotConfigState()
+ if (!quickPlacements) return null
+
+ // Determine bot type
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+ const isArbMM = !!botConfig.arbMarketMakingConfig
+
+ const header =
+
+ if (pageSize === 'xs') {
+ return (
+ {header}
+
+
+
+
+
+
+ {isBasicMM && (
+
+ )}
+
+ {isArbMM && (
+
+
+
+ )}
+
+
+
+
)
+ }
+
+ return (
+
+ {header}
+
+
+
+
+
+
+
+
+
+
+ {isBasicMM && (
+
+ )}
+
+ {isArbMM && (
+
+
+
+ )}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/webserver/site/src/js/mmsettings/components/RebalanceSettingsTab.tsx b/client/webserver/site/src/js/mmsettings/components/RebalanceSettingsTab.tsx
new file mode 100644
index 0000000000..6f9148a1e5
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/RebalanceSettingsTab.tsx
@@ -0,0 +1,350 @@
+import { useBotConfigState, useBotConfigDispatch, fetchRoundTripFeesAndLimits } from '../utils/BotConfig'
+import { app } from '../../registry'
+import Tooltip from './Tooltip'
+import Doc from '../../doc'
+import { PanelHeader, NumberInput } from './FormComponents'
+import React from 'react'
+import { useMMSettingsSetError, useMMSettingsSetLoading } from './MMSettings'
+import {
+ prep,
+ ID_MM_MIN_TRANSFER,
+ ID_MM_MIN_TRANSFER_TOOLTIP,
+ ID_MM_FAILED_FETCH_BRIDGE_FEES,
+ ID_MM_BRIDGE_CONFIGURATION,
+ ID_MM_BRIDGE_CONFIG_TOOLTIP,
+ ID_MM_BRIDGE_TO_ASSET,
+ ID_MM_SELECT_CEX_ASSET,
+ ID_MM_BRIDGE,
+ ID_MM_SELECT_BRIDGE,
+ ID_MM_BRIDGE_FEES,
+ ID_MM_WITHDRAWAL,
+ ID_MM_DEPOSIT,
+ ID_MM_REBALANCE_METHOD,
+ ID_MM_CEX_REBALANCE,
+ ID_MM_CEX_REBALANCE_DESC,
+ ID_MM_INTERNAL_TRANSFERS_ONLY,
+ ID_MM_INTERNAL_TRANSFERS_DESC,
+ ID_MM_REBALANCE_SETTINGS,
+ ID_MM_REBALANCE_DESCRIPTION
+} from '../../locales'
+
+interface MinTransferControlProps {
+ asset: 'base' | 'quote'
+ }
+
+ const MinTransferControl: React.FC = ({
+ asset
+ }) => {
+ const botConfigState = useBotConfigState()
+ const dispatch = useBotConfigDispatch()
+
+ const { botConfig, dexMarket } = botConfigState
+
+ const assetInfo = asset === 'base' ? dexMarket.baseAsset : dexMarket.quoteAsset
+ let assetSymbol = assetInfo?.symbol || (asset === 'base' ? 'Base' : 'Quote')
+ assetSymbol = assetSymbol.split('.')[0].toUpperCase()
+ const tooltip = prep(ID_MM_MIN_TRANSFER_TOOLTIP, { asset: assetSymbol })
+ const value = asset === 'base' ? botConfig.uiConfig.baseMinTransfer : botConfig.uiConfig.quoteMinTransfer
+
+ // Use min withdrawal from CEX
+ const min = asset === 'base' ? botConfigState.baseMinWithdraw : botConfigState.quoteMinWithdraw
+
+ // Get asset ID and calculate precision from conversion factor
+ const assetID = asset === 'base' ? dexMarket.baseID : dexMarket.quoteID
+ const conversionFactor = app().unitInfo(assetID).conventional.conversionFactor
+ const precision = Math.log10(conversionFactor)
+
+ // Calculate max as total allocated amount on DEX + CEX
+ const cexAssetID = asset === 'base' ? botConfig.cexBaseID : botConfig.cexQuoteID
+ const dexAmount = botConfig.uiConfig.allocation.dex[assetID] || 0
+ const cexAmount = botConfig.uiConfig.allocation.cex[cexAssetID] || 0
+ const max = Math.max(dexAmount + cexAmount, min)
+
+ const onChange = (value: number) => {
+ const actionType = asset === 'base' ? 'BASE_MIN_TRANSFER' : 'QUOTE_MIN_TRANSFER'
+ dispatch({
+ type: 'UPDATE_REBALANCE_SETTINGS',
+ payload: { type: actionType, payload: value }
+ })
+ }
+
+ return (
+
+
+
+
})
+
{assetSymbol}
+
{prep(ID_MM_MIN_TRANSFER)}
+
+
+
+
+
+
onChange(Math.floor(value * conversionFactor))}
+ withSlider={true}
+ />
+
+ )
+ }
+
+ const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
+
+ interface BridgeFeesDisplayProps {
+ fees: Record
+ title: string
+ titleColor: string
+ }
+
+ const BridgeFeesDisplay: React.FC = ({ fees, title, titleColor }) => {
+ return (
+
+
{title}
+
+ {Object.entries(fees).map(([assetID, fee]) => {
+ const asset = app().assets[parseInt(assetID)]
+ return (
+
+
{fee ? Doc.formatCoinValue(fee, asset?.unitInfo) : '0'}
+
{asset?.name}
+ {asset &&
})
}
+
+ )
+ })}
+
+
+ )
+ }
+
+ interface BridgeControlProps {
+ asset: 'base' | 'quote'
+ }
+
+ const BridgeControl: React.FC = ({
+ asset
+ }) => {
+ const botConfigState = useBotConfigState()
+ const { dexMarket } = botConfigState
+ const dispatch = useBotConfigDispatch()
+ const setIsLoading = useMMSettingsSetLoading()
+ const setError = useMMSettingsSetError()
+
+ const { botConfig } = botConfigState
+ const bridges = asset === 'base' ? botConfigState.baseBridges : botConfigState.quoteBridges
+ const currentFeesAndLimits = asset === 'base' ? botConfigState.baseBridgeFeesAndLimits : botConfigState.quoteBridgeFeesAndLimits
+ const dexAssetID = asset === 'base' ? dexMarket.baseID : dexMarket.quoteID
+
+ // Only show bridge controls if CEX rebalance is selected
+ if (!botConfig.uiConfig.cexRebalance || !bridges || !currentFeesAndLimits) return null
+
+ const handleCexAssetChange = async (cexAssetID: number) => {
+ if (!bridges || !bridges[cexAssetID] || bridges[cexAssetID].length === 0) return
+
+ const bridgeName = bridges[cexAssetID][0] // Default to first bridge
+ try {
+ setIsLoading(true)
+ const feesAndLimits = await fetchRoundTripFeesAndLimits(dexAssetID, cexAssetID, bridgeName)
+ dispatch({
+ type: 'UPDATE_BRIDGE_SELECTION',
+ payload: { asset, feesAndLimits }
+ })
+ } catch (error) {
+ setError({
+ message: prep(ID_MM_FAILED_FETCH_BRIDGE_FEES)
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleBridgeChange = async (bridgeName: string) => {
+ try {
+ const feesAndLimits = await fetchRoundTripFeesAndLimits(dexAssetID, currentFeesAndLimits.cexAsset, bridgeName)
+ dispatch({
+ type: 'UPDATE_BRIDGE_SELECTION',
+ payload: { asset, feesAndLimits }
+ })
+ } catch (error) {
+ console.error('Failed to fetch bridge fees and limits:', error)
+ }
+ }
+
+ const assetName = asset === 'base' ? 'Base' : 'Quote'
+ const availableCexAssets = Object.keys(bridges).map(id => parseInt(id))
+ const currentCexAsset = currentFeesAndLimits.cexAsset
+ const availableBridges = bridges[currentCexAsset]
+ const currentBridge = currentFeesAndLimits.bridgeName
+
+ return (
+
+
+
+ {prep(ID_MM_BRIDGE_CONFIGURATION, { asset: assetName })}
+
+
+
+
+
+
+
+
+
+
+
+ {availableBridges.length > 0 && (
+
+
+
+
+ )}
+
+ {/* Bridge Fees Display */}
+
+
{prep(ID_MM_BRIDGE_FEES)}
+
+
+
+
+
+ )
+ }
+
+ export const RebalanceSettingsPanelHeader: React.FC = () => {
+ return (
+
+ )
+}
+
+ const RebalanceSettingsTab: React.FC = () => {
+ const botConfigState = useBotConfigState()
+ const { botConfig } = botConfigState
+ const dispatch = useBotConfigDispatch()
+
+ const handleRebalanceTypeChange = (cexRebalance: boolean) => {
+ dispatch({
+ type: 'UPDATE_REBALANCE_SETTINGS',
+ payload: { type: 'CEX_REBALANCE', payload: cexRebalance }
+ })
+ }
+
+ return (
+
+
+
+
+ {/* Rebalance Method Selection */}
+
+
{prep(ID_MM_REBALANCE_METHOD)}
+
+ {/* CEX Rebalance */}
+
+ handleRebalanceTypeChange(true)}
+ />
+
+
+
+ {prep(ID_MM_CEX_REBALANCE_DESC)}
+
+
+ {/* Internal Transfers Only */}
+
+ handleRebalanceTypeChange(false)}
+ />
+
+
+
+ {prep(ID_MM_INTERNAL_TRANSFERS_DESC)}
+
+
+
+ {/* Minimum Transfer Amounts - Only show if CEX Rebalance is selected */}
+ {botConfig.uiConfig.cexRebalance && (
+
+
+
+
+
+ )}
+
+ {/* Bridge Configuration */}
+
+ {botConfigState.baseBridges && (
+
+
)}
+ {botConfigState.quoteBridges && (
+
+
)}
+
+
+
+ )
+ }
+
+export default RebalanceSettingsTab;
\ No newline at end of file
diff --git a/client/webserver/site/src/js/mmsettings/components/Tooltip.tsx b/client/webserver/site/src/js/mmsettings/components/Tooltip.tsx
new file mode 100644
index 0000000000..06f5bf07f8
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/components/Tooltip.tsx
@@ -0,0 +1,77 @@
+import React from 'react'
+import { createPortal } from 'react-dom'
+
+interface TooltipProps {
+ content: string
+ children: React.ReactElement
+}
+
+const Tooltip: React.FC = ({ content, children }) => {
+ const [isVisible, setIsVisible] = React.useState(false)
+ const [position, setPosition] = React.useState({ top: 0, left: 0 })
+ const triggerRef = React.useRef(null)
+
+ const handleMouseEnter = () => {
+ if (triggerRef.current) {
+ const rect = triggerRef.current.getBoundingClientRect()
+ const tooltipWidth = 200 // Approximate tooltip width
+ let left = rect.left + rect.width / 2 - tooltipWidth / 2
+
+ // Ensure tooltip doesn't go off screen
+ if (left < 5) left = 5
+ if (left + tooltipWidth > window.innerWidth) {
+ left = window.innerWidth - tooltipWidth - 5
+ }
+
+ setPosition({
+ top: rect.top - 35, // Position above the element
+ left: left
+ })
+ setIsVisible(true)
+ }
+ }
+
+ const handleMouseLeave = () => {
+ setIsVisible(false)
+ }
+
+ // Clone the child element and add event handlers and ref
+ const childWithHandlers = React.cloneElement(children as React.ReactElement, {
+ ref: triggerRef,
+ onMouseEnter: handleMouseEnter,
+ onMouseLeave: handleMouseLeave,
+ style: { cursor: 'help', ...(children.props?.style || {}) }
+ })
+
+ return (
+ <>
+ {childWithHandlers}
+ {isVisible && createPortal(
+
+ {content}
+
,
+ document.body
+ )}
+ >
+ )
+}
+
+export default Tooltip
diff --git a/client/webserver/site/src/js/mmsettings/hooks/PageSizeBreakpoints.ts b/client/webserver/site/src/js/mmsettings/hooks/PageSizeBreakpoints.ts
new file mode 100644
index 0000000000..b08fe815e8
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/hooks/PageSizeBreakpoints.ts
@@ -0,0 +1,105 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * Enum representing Bootstrap breakpoint sizes with their min-width values.
+ * - XS: 0 (default/smallest)
+ * - SM: 576px
+ * - MD: 768px
+ * - LG: 992px
+ * - XL: 1200px
+ * - XXL: 1400px
+ */
+export enum BootstrapBreakpoint {
+ XS = 0,
+ SM = 576,
+ MD = 768,
+ LG = 992,
+ XL = 1200,
+ XXL = 1400,
+}
+
+/**
+ * Map of breakpoint labels to their enum values for easy lookup.
+ */
+const BREAKPOINT_VALUES: Record = {
+ XS: BootstrapBreakpoint.XS,
+ SM: BootstrapBreakpoint.SM,
+ MD: BootstrapBreakpoint.MD,
+ LG: BootstrapBreakpoint.LG,
+ XL: BootstrapBreakpoint.XL,
+ XXL: BootstrapBreakpoint.XXL
+}
+
+/**
+ * A hook to get the current window width.
+ * @returns The current window width.
+ */
+const useWindowWidth = () => {
+ const [width, setWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 0)
+
+ useEffect(() => {
+ const handleResize = () => {
+ setWidth(window.innerWidth)
+ }
+
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ return width
+}
+
+/**
+ * A React hook that determines the current Bootstrap-like breakpoint based on a list of passed breakpoints.
+ * - Passed breakpoints (e.g., ['md', 'xl']) define the lower bounds for ranges (min-width).
+ * - Returns 'xs' if below the first passed breakpoint (or if 'xs' is passed/derived).
+ * - Returns the label of the range the current width falls into.
+ * - Assumes passed breakpoints are valid (e.g., 'xs', 'md') and in ascending order; sorts if needed.
+ * - If 'xs' is passed, it's treated as the base (0px).
+ * @param passedBreakpoints Array of breakpoint labels (e.g., ['md', 'xl'])
+ * @returns The current breakpoint label as a string (e.g., 'xs', 'md', 'xl')
+ */
+export const useBootstrapBreakpoints = (passedBreakpoints: Array<'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>): string => {
+ const width = useWindowWidth()
+
+ // Validate and map passed breakpoints to {label, value} array, sorted by value
+ const breakpointMap = passedBreakpoints
+ .map(bp => {
+ const key = bp.toUpperCase() as keyof typeof BootstrapBreakpoint
+ if (!(key in BREAKPOINT_VALUES)) {
+ console.warn(`Invalid breakpoint: ${bp}. Ignoring.`)
+ return null
+ }
+ return { label: bp.toLowerCase(), value: BREAKPOINT_VALUES[key] }
+ })
+ .filter((bp): bp is { label: string; value: number } => bp !== null)
+ .sort((a, b) => a.value - b.value)
+
+ if (breakpointMap.length === 0) {
+ return 'xs' // Default if no breakpoints passed
+ }
+
+ // The number of possible return values is length + 1 (for 'xs' fallback below first)
+ // Find the smallest breakpoint where width >= value
+ for (const bp of breakpointMap) {
+ if (width >= bp.value) {
+ continue
+ } else {
+ break
+ }
+ }
+
+ // Find the range
+ let currentBp = 'xs'
+ for (let i = 0; i < breakpointMap.length; i++) {
+ const current = breakpointMap[i]
+ const next = breakpointMap[i + 1]
+
+ if (width >= current.value && (!next || width < next.value)) {
+ currentBp = current.label
+ break
+ }
+ }
+
+ return currentBp
+}
diff --git a/client/webserver/site/src/js/mmsettings/utils/AllocationUtil.ts b/client/webserver/site/src/js/mmsettings/utils/AllocationUtil.ts
new file mode 100644
index 0000000000..cc9a56d111
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/utils/AllocationUtil.ts
@@ -0,0 +1,484 @@
+import { app, RunStats } from '../../registry'
+import { BotConfigState } from './BotConfig'
+
+// Interfaces for allocation calculations
+interface PerLotBreakdown {
+ totalAmount: number
+ tradedAmount: number
+ fees: Fees
+ slippageBuffer: number
+ multiSplitBuffer: number
+}
+
+interface PerLot {
+ dex: Record
+ cex: Record
+}
+
+interface Fees {
+ swap: number
+ redeem: number
+ refund: number
+ funding: number
+}
+
+interface CalculationBreakdown {
+ buyLot: PerLotBreakdown
+ sellLot: PerLotBreakdown
+
+ numBuyLots: number
+ numSellLots: number
+
+ feeReserves: {
+ buyReserves: Fees
+ sellReserves: Fees
+ }
+ numBuyFeeReserves: number
+ numSellFeeReserves: number
+
+ initialBuyFundingFees: number
+ initialSellFundingFees: number
+
+ bridgeFeeReserves: number
+
+ bridgeFees: number
+ available: number
+ totalRequired: number
+ runningBotTotal?: number
+ runningBotAvailable?: number
+ rebalanceAdjustment?: number
+}
+
+export interface AllocationDetail {
+ amount: number
+ status: string
+ calculation: CalculationBreakdown
+}
+
+export interface AllocationResult {
+ dex: Record
+ cex: Record
+}
+
+function newPerLotBreakdown () : PerLotBreakdown {
+ return {
+ totalAmount: 0,
+ tradedAmount: 0,
+ fees: { swap: 0, redeem: 0, refund: 0, funding: 0 },
+ slippageBuffer: 0,
+ multiSplitBuffer: 0
+ }
+}
+
+function newAllocationDetail () : AllocationDetail {
+ return {
+ amount: 0,
+ status: 'sufficient',
+ calculation: newCalculationBreakdown()
+ }
+}
+
+function newCalculationBreakdown () : CalculationBreakdown {
+ return {
+ buyLot: newPerLotBreakdown(),
+ sellLot: newPerLotBreakdown(),
+ numBuyLots: 0,
+ numSellLots: 0,
+ feeReserves: {
+ buyReserves: { swap: 0, redeem: 0, refund: 0, funding: 0 },
+ sellReserves: { swap: 0, redeem: 0, refund: 0, funding: 0 }
+ },
+ numBuyFeeReserves: 0,
+ numSellFeeReserves: 0,
+ initialBuyFundingFees: 0,
+ initialSellFundingFees: 0,
+ bridgeFeeReserves: 0,
+ bridgeFees: 0,
+ available: 0,
+ totalRequired: 0
+ }
+}
+
+// perLotRequirements calculates the funding requirements for a single buy and sell lot.
+function perLotRequirements (state: BotConfigState) : { perSellLot: PerLot, perBuyLot: PerLot } {
+ const perSellLot: PerLot = { cex: {}, dex: {} }
+ const perBuyLot: PerLot = { cex: {}, dex: {} }
+ const dexAssetIDs = requiredDexAssets(state)
+ const cexAssetIDs = [state.botConfig.cexBaseID, state.botConfig.cexQuoteID]
+
+ for (const assetID of dexAssetIDs) {
+ perSellLot.dex[assetID] = newPerLotBreakdown()
+ perBuyLot.dex[assetID] = newPerLotBreakdown()
+ }
+ for (const assetID of cexAssetIDs) {
+ perSellLot.cex[assetID] = newPerLotBreakdown()
+ perBuyLot.cex[assetID] = newPerLotBreakdown()
+ }
+
+ const {
+ dexMarket: { baseID, lotSize, quoteLot, baseFeeAssetID, quoteID, quoteFeeAssetID, baseIsAccountLocker, quoteIsAccountLocker },
+ botConfig: { cexBaseID, cexQuoteID, uiConfig: { quickBalance: { slippageBuffer } } }, marketReport
+ } = state
+
+ perSellLot.dex[baseID].tradedAmount = lotSize
+ perSellLot.dex[baseFeeAssetID].fees.swap = marketReport.baseFees.max.swap
+ perSellLot.cex[cexQuoteID].tradedAmount = quoteLot
+ perSellLot.cex[cexQuoteID].slippageBuffer = slippageBuffer
+ perSellLot.dex[baseFeeAssetID].fees.funding = 0 // TODO: update this
+ if (baseIsAccountLocker) perSellLot.dex[baseFeeAssetID].fees.refund = marketReport.baseFees.max.refund
+ if (quoteIsAccountLocker) perSellLot.dex[quoteFeeAssetID].fees.redeem = marketReport.quoteFees.max.redeem
+
+ perBuyLot.dex[quoteID].tradedAmount = quoteLot
+ perBuyLot.dex[quoteID].slippageBuffer = slippageBuffer
+ perBuyLot.cex[cexBaseID].tradedAmount = lotSize
+ perBuyLot.dex[quoteFeeAssetID].fees.swap = marketReport.quoteFees.max.swap
+ perBuyLot.dex[quoteFeeAssetID].fees.funding = 0 // TODO: update this
+ if (baseIsAccountLocker) perBuyLot.dex[baseFeeAssetID].fees.redeem = marketReport.baseFees.max.redeem
+ if (quoteIsAccountLocker) perBuyLot.dex[quoteFeeAssetID].fees.refund = marketReport.quoteFees.max.refund
+
+ const calculateTotalAmount = (perLot: PerLotBreakdown) : number => {
+ let total = perLot.tradedAmount
+ total *= (1 + perLot.slippageBuffer + perLot.multiSplitBuffer)
+ total = Math.floor(total)
+ total += perLot.fees.swap + perLot.fees.redeem + perLot.fees.refund + perLot.fees.funding
+ return total
+ }
+
+ for (const assetID of dexAssetIDs) {
+ perSellLot.dex[assetID].totalAmount = calculateTotalAmount(perSellLot.dex[assetID])
+ perBuyLot.dex[assetID].totalAmount = calculateTotalAmount(perBuyLot.dex[assetID])
+ }
+ for (const assetID of cexAssetIDs) {
+ perSellLot.cex[assetID].totalAmount = calculateTotalAmount(perSellLot.cex[assetID])
+ perBuyLot.cex[assetID].totalAmount = calculateTotalAmount(perBuyLot.cex[assetID])
+ }
+
+ return { perSellLot, perBuyLot }
+}
+
+function calcNumLots (state: BotConfigState) : { numBuyLots: number, numSellLots: number } {
+ if (state.botConfig.basicMarketMakingConfig) {
+ const numBuyLots = state.botConfig.basicMarketMakingConfig.buyPlacements.reduce((acc, placement) => acc + placement.lots, 0)
+ const numSellLots = state.botConfig.basicMarketMakingConfig.sellPlacements.reduce((acc, placement) => acc + placement.lots, 0)
+ return { numBuyLots, numSellLots }
+ } else if (state.botConfig.arbMarketMakingConfig) {
+ const numBuyLots = state.botConfig.arbMarketMakingConfig.buyPlacements.reduce((acc, placement) => acc + placement.lots, 0)
+ const numSellLots = state.botConfig.arbMarketMakingConfig.sellPlacements.reduce((acc, placement) => acc + placement.lots, 0)
+ return { numBuyLots, numSellLots }
+ } else if (state.botConfig.simpleArbConfig) {
+ return { numBuyLots: 1, numSellLots: 1 }
+ } else {
+ throw new Error('Invalid bot config')
+ }
+}
+
+// requiredFunds calculates the total funds required for a bot based on the quick allocation settings.
+function requiredFunds (state: BotConfigState) : AllocationResult {
+ const { numBuyLots, numSellLots } = calcNumLots(state)
+ const totalBuyLots = numBuyLots + state.botConfig.uiConfig.quickBalance.buysBuffer
+ const totalSellLots = numSellLots + state.botConfig.uiConfig.quickBalance.sellsBuffer
+
+ const { dexMarket: { baseIsAccountLocker, quoteIsAccountLocker }, marketReport } = state
+
+ const dexAssetIDs = requiredDexAssets(state)
+ const cexAssetIDs = [state.botConfig.cexBaseID, state.botConfig.cexQuoteID]
+
+ const toAllocate: AllocationResult = { dex: {}, cex: {} }
+
+ for (const assetID of dexAssetIDs) {
+ toAllocate.dex[assetID] = newAllocationDetail()
+ }
+ if (state.botConfig.cexName) {
+ for (const assetID of cexAssetIDs) {
+ toAllocate.cex[assetID] = newAllocationDetail()
+ }
+ }
+
+ const { perBuyLot, perSellLot } = perLotRequirements(state)
+
+ if (state.botConfig.cexName) {
+ for (const assetID of cexAssetIDs) {
+ toAllocate.cex[assetID].calculation.buyLot = perBuyLot.cex[assetID]
+ toAllocate.cex[assetID].calculation.sellLot = perSellLot.cex[assetID]
+ toAllocate.cex[assetID].calculation.numBuyLots = totalBuyLots
+ toAllocate.cex[assetID].calculation.numSellLots = totalSellLots
+ }
+ }
+
+ for (const assetID of dexAssetIDs) {
+ toAllocate.dex[assetID].calculation.buyLot = perBuyLot.dex[assetID]
+ toAllocate.dex[assetID].calculation.sellLot = perSellLot.dex[assetID]
+ toAllocate.dex[assetID].calculation.numBuyLots = totalBuyLots
+ toAllocate.dex[assetID].calculation.numSellLots = totalSellLots
+
+ if (assetID === state.dexMarket.baseFeeAssetID) {
+ toAllocate.dex[assetID].calculation.feeReserves.sellReserves.swap = marketReport.baseFees.estimated.swap
+ if (baseIsAccountLocker) {
+ toAllocate.dex[assetID].calculation.feeReserves.buyReserves.redeem = marketReport.baseFees.estimated.redeem
+ toAllocate.dex[assetID].calculation.feeReserves.sellReserves.refund = marketReport.baseFees.estimated.refund
+ }
+ toAllocate.dex[assetID].calculation.initialSellFundingFees = 0 // TODO: update this
+ }
+
+ if (assetID === state.dexMarket.quoteFeeAssetID) {
+ toAllocate.dex[assetID].calculation.feeReserves.buyReserves.swap = marketReport.quoteFees.estimated.swap
+ if (quoteIsAccountLocker) {
+ toAllocate.dex[assetID].calculation.feeReserves.sellReserves.redeem = marketReport.quoteFees.estimated.redeem
+ toAllocate.dex[assetID].calculation.feeReserves.buyReserves.refund = marketReport.quoteFees.estimated.refund
+ }
+ toAllocate.dex[assetID].calculation.initialBuyFundingFees = 0 // TODO: update this
+ toAllocate.dex[assetID].calculation.initialSellFundingFees = 0 // TODO: update this
+ }
+
+ toAllocate.dex[assetID].calculation.bridgeFeeReserves = state.botConfig.uiConfig.quickBalance.bridgeFeeReserve
+
+ if (state.baseBridgeFeesAndLimits) {
+ toAllocate.dex[assetID].calculation.bridgeFees += state.baseBridgeFeesAndLimits.withdrawal?.fees[assetID] ?? 0
+ toAllocate.dex[assetID].calculation.bridgeFees += state.baseBridgeFeesAndLimits.deposit?.fees[assetID] ?? 0
+ }
+ if (state.quoteBridgeFeesAndLimits) {
+ toAllocate.dex[assetID].calculation.bridgeFees += state.quoteBridgeFeesAndLimits.withdrawal?.fees[assetID] ?? 0
+ toAllocate.dex[assetID].calculation.bridgeFees += state.quoteBridgeFeesAndLimits.deposit?.fees[assetID] ?? 0
+ }
+
+ toAllocate.dex[assetID].calculation.numBuyFeeReserves = state.botConfig.uiConfig.quickBalance.buyFeeReserve
+ toAllocate.dex[assetID].calculation.numSellFeeReserves = state.botConfig.uiConfig.quickBalance.sellFeeReserve
+ }
+
+ const totalFees = (fees: Fees) : number => {
+ return fees.swap + fees.redeem + fees.refund + fees.funding
+ }
+
+ const calculateTotalRequired = (breakdown: CalculationBreakdown) : number => {
+ let total = 0
+ total += breakdown.buyLot.totalAmount * breakdown.numBuyLots
+ total += breakdown.sellLot.totalAmount * breakdown.numSellLots
+ total += totalFees(breakdown.feeReserves.buyReserves) * breakdown.numBuyFeeReserves
+ total += totalFees(breakdown.feeReserves.sellReserves) * breakdown.numSellFeeReserves
+ total += breakdown.initialBuyFundingFees
+ total += breakdown.initialSellFundingFees
+ total += breakdown.bridgeFeeReserves * breakdown.bridgeFees
+ return total
+ }
+
+ for (const assetID of dexAssetIDs) {
+ toAllocate.dex[assetID].calculation.totalRequired = calculateTotalRequired(toAllocate.dex[assetID].calculation)
+ }
+
+ if (state.botConfig.cexName) {
+ for (const assetID of cexAssetIDs) {
+ toAllocate.cex[assetID].calculation.totalRequired = calculateTotalRequired(toAllocate.cex[assetID].calculation)
+ }
+ }
+
+ return toAllocate
+}
+
+export function allocationResultAmounts (result: AllocationResult) : { dex: Record, cex: Record } {
+ const amounts: { dex: Record, cex: Record } = { dex: {}, cex: {} }
+ for (const [assetID, allocationDetail] of Object.entries(result.dex)) {
+ amounts.dex[Number(assetID)] = allocationDetail.amount
+ }
+ for (const [assetID, allocationDetail] of Object.entries(result.cex)) {
+ amounts.cex[Number(assetID)] = allocationDetail.amount
+ }
+ return amounts
+}
+
+// toAllocate calculates the quick allocations for a bot that is not running.
+export function toAllocate (state: BotConfigState) : AllocationResult {
+ const availableFunds = { dex: state.availableDEXBalances, cex: state.availableCEXBalances }
+ const canRebalance = !!state.botConfig.cexName && state.botConfig.uiConfig.cexRebalance
+
+ const result = requiredFunds(state)
+
+ const dexAssetIDs = requiredDexAssets(state)
+ const { cexBaseID, cexQuoteID } = state.botConfig
+ const cexAssetIDs = [cexBaseID, cexQuoteID]
+
+ let dexBaseSurplus = 0
+ let dexQuoteSurplus = 0
+ let cexBaseSurplus = 0
+ let cexQuoteSurplus = 0
+
+ for (const assetID of dexAssetIDs) {
+ const allocationDetail = result.dex[assetID]
+ allocationDetail.calculation.available = availableFunds.dex[assetID] ?? 0
+ const surplus = allocationDetail.calculation.available - allocationDetail.calculation.totalRequired
+ if (surplus < 0) {
+ allocationDetail.status = 'insufficient'
+ allocationDetail.amount = allocationDetail.calculation.available
+ } else {
+ allocationDetail.amount = allocationDetail.calculation.totalRequired
+ }
+ if (assetID === state.dexMarket.baseID) dexBaseSurplus = surplus
+ if (assetID === state.dexMarket.quoteID) dexQuoteSurplus = surplus
+ }
+
+ if (state.botConfig.cexName) {
+ for (const assetID of cexAssetIDs) {
+ const allocationDetail = result.cex[assetID]
+ allocationDetail.calculation.available = availableFunds.cex?.[assetID] ?? 0
+ const surplus = allocationDetail.calculation.available - allocationDetail.calculation.totalRequired
+ if (surplus < 0) {
+ allocationDetail.status = 'insufficient'
+ allocationDetail.amount = allocationDetail.calculation.available
+ } else {
+ allocationDetail.amount = allocationDetail.calculation.totalRequired
+ }
+ if (assetID === cexBaseID) cexBaseSurplus = surplus
+ if (assetID === cexQuoteID) cexQuoteSurplus = surplus
+ }
+ }
+
+ const rebalance = (dexAssetID: number, cexAssetID: number, dexSurplus: number, cexSurplus: number) => {
+ if (canRebalance && dexSurplus < 0 && cexSurplus > 0) {
+ const dexDeficit = -dexSurplus
+ const additionalCEX = Math.min(dexDeficit, cexSurplus)
+ result.cex[cexAssetID].calculation.rebalanceAdjustment = additionalCEX
+ result.cex[cexAssetID].amount += additionalCEX
+ if (cexSurplus >= dexDeficit) result.dex[dexAssetID].status = 'sufficient-with-rebalance'
+ }
+
+ if (canRebalance && cexSurplus < 0 && dexSurplus > 0) {
+ const cexDeficit = -cexSurplus
+ const additionalDEX = Math.min(cexDeficit, dexSurplus)
+ result.dex[dexAssetID].calculation.rebalanceAdjustment = additionalDEX
+ result.dex[dexAssetID].amount += additionalDEX
+ if (dexSurplus >= cexDeficit) result.cex[cexAssetID].status = 'sufficient-with-rebalance'
+ }
+ }
+
+ rebalance(state.dexMarket.baseID, cexBaseID, dexBaseSurplus, cexBaseSurplus)
+ rebalance(state.dexMarket.quoteID, cexQuoteID, dexQuoteSurplus, cexQuoteSurplus)
+
+ return result
+}
+
+export function requiredDexAssets (botConfigState: BotConfigState) : number[] {
+ const { dexMarket: { baseID, quoteID }, botConfig: { cexBaseID, cexQuoteID, uiConfig: { cexRebalance } } } = botConfigState
+
+ const assetIDs = [baseID, quoteID]
+ const addAssetID = (assetID: number) => {
+ if (!assetIDs.includes(assetID)) assetIDs.push(assetID)
+ }
+
+ const baseAsset = app().assets[baseID]
+ const baseAssetFeeID = baseAsset.token ? baseAsset.token.parentID : baseID
+ addAssetID(baseAssetFeeID)
+
+ const quoteAsset = app().assets[quoteID]
+ const quoteAssetFeeID = quoteAsset.token ? quoteAsset.token.parentID : quoteID
+ addAssetID(quoteAssetFeeID)
+
+ if (baseID !== cexBaseID && cexRebalance) {
+ const cexBaseAsset = app().assets[cexBaseID]
+ const cexBaseAssetFeeID = cexBaseAsset.token ? cexBaseAsset.token.parentID : cexBaseID
+ addAssetID(cexBaseAssetFeeID)
+ }
+
+ if (quoteID !== cexQuoteID && cexRebalance) {
+ const cexQuoteAsset = app().assets[cexQuoteID]
+ const cexQuoteAssetFeeID = cexQuoteAsset.token ? cexQuoteAsset.token.parentID : cexQuoteID
+ addAssetID(cexQuoteAssetFeeID)
+ }
+
+ return assetIDs
+}
+
+// toAllocateRunning calculates the quick allocations for a running bot.
+export function toAllocateRunning (botConfigState: BotConfigState, runStats: RunStats) : AllocationResult {
+ const result = requiredFunds(botConfigState)
+
+ const dexAssetIDs = requiredDexAssets(botConfigState)
+ const cexAssetIDs = [botConfigState.botConfig.cexBaseID, botConfigState.botConfig.cexQuoteID]
+ // const { availableDEXBalances, availableCEXBalances } = botConfigState.runStats
+
+ const totalBotBalance = (source: 'cex' | 'dex', assetID: number) => {
+ let bals
+ if (source === 'dex') {
+ bals = runStats.dexBalances[assetID] ?? { available: 0, locked: 0, pending: 0, reserved: 0 }
+ } else {
+ bals = runStats.cexBalances[assetID] ?? { available: 0, locked: 0, pending: 0, reserved: 0 }
+ }
+ return bals.available + bals.locked + bals.pending + bals.reserved
+ }
+
+ let dexBaseSurplus = 0
+ let dexQuoteSurplus = 0
+ let cexBaseSurplus = 0
+ let cexQuoteSurplus = 0
+
+ for (const assetID of dexAssetIDs) {
+ const runningBotTotal = totalBotBalance('dex', assetID)
+ const runningBotAvailable = runStats.dexBalances[assetID]?.available ?? 0
+ result.dex[assetID].calculation.runningBotTotal = runningBotTotal
+ result.dex[assetID].calculation.runningBotAvailable = runningBotAvailable
+ result.dex[assetID].calculation.available = botConfigState.availableDEXBalances[assetID] ?? 0
+
+ const dexTotalAvailable = runningBotTotal + result.dex[assetID].calculation.available
+ const surplus = dexTotalAvailable - result.dex[assetID].calculation.totalRequired
+
+ if (surplus >= 0) {
+ result.dex[assetID].amount = result.dex[assetID].calculation.totalRequired - runningBotTotal
+ if (result.dex[assetID].amount < 0) result.dex[assetID].amount = -Math.min(-result.dex[assetID].amount, runningBotAvailable)
+ } else {
+ result.dex[assetID].status = 'insufficient'
+ result.dex[assetID].amount = result.dex[assetID].calculation.available
+ }
+
+ if (assetID === botConfigState.dexMarket.baseID) dexBaseSurplus = surplus
+ if (assetID === botConfigState.dexMarket.quoteID) dexQuoteSurplus = surplus
+ }
+
+ if (botConfigState.botConfig.cexName) {
+ for (const assetID of cexAssetIDs) {
+ const runningBotTotal = totalBotBalance('cex', assetID)
+ const runningBotAvailable = runStats.cexBalances[assetID]?.available ?? 0
+ result.cex[assetID].calculation.runningBotTotal = runningBotTotal
+ result.cex[assetID].calculation.runningBotAvailable = runningBotAvailable
+ result.cex[assetID].calculation.available = botConfigState.availableCEXBalances?.[assetID] ?? 0
+
+ const cexTotalAvailable = runningBotTotal + result.cex[assetID].calculation.available
+ const surplus = cexTotalAvailable - result.cex[assetID].calculation.totalRequired
+
+ if (surplus >= 0) {
+ result.cex[assetID].amount = result.cex[assetID].calculation.totalRequired - runningBotTotal
+ if (result.cex[assetID].amount < 0) result.cex[assetID].amount = -Math.min(-result.cex[assetID].amount, runningBotAvailable)
+ } else {
+ result.cex[assetID].status = 'insufficient'
+ result.cex[assetID].amount = result.cex[assetID].calculation.available
+ }
+
+ if (assetID === botConfigState.botConfig.cexBaseID) cexBaseSurplus = surplus
+ if (assetID === botConfigState.botConfig.cexQuoteID) cexQuoteSurplus = surplus
+ }
+ }
+
+ const canRebalance = !!botConfigState.botConfig.cexName && botConfigState.botConfig.uiConfig.cexRebalance
+
+ const rebalance = (dexAssetID: number, cexAssetID: number, dexSurplus: number, cexSurplus: number) => {
+ if (canRebalance && dexSurplus < 0 && cexSurplus > 0) {
+ const dexDeficit = -dexSurplus
+ const additionalCEX = Math.min(dexDeficit, cexSurplus)
+ result.cex[cexAssetID].calculation.rebalanceAdjustment = additionalCEX
+ result.cex[cexAssetID].amount += additionalCEX
+ if (cexSurplus >= dexDeficit) result.dex[dexAssetID].status = 'sufficient-with-rebalance'
+ }
+
+ if (canRebalance && cexSurplus < 0 && dexSurplus > 0) {
+ const cexDeficit = -cexSurplus
+ const additionalDEX = Math.min(cexDeficit, dexSurplus)
+ result.dex[dexAssetID].calculation.rebalanceAdjustment = additionalDEX
+ result.dex[dexAssetID].amount += additionalDEX
+ if (dexSurplus >= cexDeficit) result.cex[cexAssetID].status = 'sufficient-with-rebalance'
+ }
+ }
+
+ if (botConfigState.botConfig.cexName) {
+ rebalance(botConfigState.dexMarket.baseID, botConfigState.botConfig.cexBaseID, dexBaseSurplus, cexBaseSurplus)
+ rebalance(botConfigState.dexMarket.quoteID, botConfigState.botConfig.cexQuoteID, dexQuoteSurplus, cexQuoteSurplus)
+ }
+
+ return result
+}
diff --git a/client/webserver/site/src/js/mmsettings/utils/BotConfig.ts b/client/webserver/site/src/js/mmsettings/utils/BotConfig.ts
new file mode 100644
index 0000000000..b8feef9023
--- /dev/null
+++ b/client/webserver/site/src/js/mmsettings/utils/BotConfig.ts
@@ -0,0 +1,1091 @@
+import {
+ BotConfig,
+ MMBotStatus,
+ MarketReport,
+ SupportedAsset,
+ GapStrategy,
+ OrderPlacement,
+ ArbMarketMakingPlacement,
+ QuickBalanceConfig,
+ app,
+ BridgeFeesAndLimits,
+ RunStats,
+ OrderOption,
+ UIConfig,
+ MMCEXStatus,
+ MultiHopCfg
+} from '../../registry'
+import { MM, calculateQuoteLot } from '../../mmutil'
+import { toAllocate, toAllocateRunning, AllocationResult, allocationResultAmounts } from './AllocationUtil'
+import { createContext, useContext } from 'react'
+
+// Interfaces
+interface MarketInfo {
+ host: string;
+ baseID: number;
+ quoteID: number;
+ baseFeeAssetID: number;
+ quoteFeeAssetID: number;
+ baseAsset: SupportedAsset;
+ quoteAsset: SupportedAsset;
+ lotSize: number;
+ quoteLot: number;
+ baseIsAccountLocker: boolean;
+ quoteIsAccountLocker: boolean;
+}
+
+export interface QuickPlacementsConfig {
+ priceLevelsPerSide: number;
+ lotsPerLevel: number;
+ priceIncrement: number;
+ profitThreshold: number;
+ matchBuffer: number;
+}
+
+export interface BotConfigState {
+ botConfig: BotConfig;
+ dexMarket: MarketInfo;
+ availableDEXBalances: Record;
+ availableCEXBalances: Record | null;
+ baseBridges: Record | null;
+ quoteBridges: Record | null;
+ baseBridgeFeesAndLimits: RoundTripFeesAndLimits | null;
+ quoteBridgeFeesAndLimits: RoundTripFeesAndLimits | null;
+ quickPlacements: QuickPlacementsConfig | null;
+ allocationResult: AllocationResult | null;
+ runStats: RunStats | null;
+ marketReport: MarketReport;
+ baseMultiFundingOpts: OrderOption[] | null;
+ quoteMultiFundingOpts: OrderOption[] | null;
+ baseMinWithdraw: number;
+ quoteMinWithdraw: number;
+ cexStatus: MMCEXStatus | null;
+ intermediateAssets: number[] | null;
+ intermediateAsset: number | null;
+ fiatRatesMap: Record;
+}
+
+export interface RoundTripFeesAndLimits {
+ withdrawal: BridgeFeesAndLimits;
+ deposit: BridgeFeesAndLimits;
+ cexAsset: number;
+ bridgeName: string;
+}
+
+// Constants
+const DEFAULT_QUICK_PLACEMENTS: QuickPlacementsConfig = {
+ priceLevelsPerSide: 1,
+ lotsPerLevel: 1,
+ priceIncrement: 0.005,
+ profitThreshold: 0.02,
+ matchBuffer: 0
+}
+
+const TRAIT_ACCOUNT_LOCKER = 1 << 14
+
+// Utility Functions
+function getWalletMultiFundingOptions (assetID: number): OrderOption[] | null {
+ const walletDef = app().currentWalletDefinition(assetID)
+ return walletDef.multifundingopts ?? null
+}
+
+function orderOptionsToRecord (opts: OrderOption[] | null): Record | null {
+ if (!opts) return null
+ return opts.reduce((acc, opt) => ({
+ ...acc,
+ [opt.key]: opt.default?.toString() || ''
+ }), {})
+}
+
+async function fetchCEXAssetAndBridgeInfo (
+ dexAssetID: number,
+ savedCEXAssetID: number | null,
+ savedCEXBridge: string,
+ bridges: Record | null
+): Promise<{ cexAssetID: number; cexBridge: string; feesAndLimits: RoundTripFeesAndLimits | null }> {
+ if (!bridges || !Object.keys(bridges).length) {
+ return { cexAssetID: dexAssetID, cexBridge: '', feesAndLimits: null }
+ }
+
+ let cexAssetID = parseInt(Object.keys(bridges)[0])
+ let cexBridge = bridges[cexAssetID][0]
+
+ if (savedCEXAssetID && savedCEXBridge) {
+ for (const [assetIDStr, bridgeNames] of Object.entries(bridges)) {
+ const assetID = parseInt(assetIDStr)
+ if (assetID === savedCEXAssetID && bridgeNames.includes(savedCEXBridge)) {
+ cexAssetID = assetID
+ cexBridge = savedCEXBridge
+ break
+ }
+ }
+ }
+
+ const feesAndLimits = await fetchRoundTripFeesAndLimits(dexAssetID, cexAssetID, cexBridge)
+
+ return { cexAssetID, cexBridge, feesAndLimits }
+}
+
+export async function fetchRoundTripFeesAndLimits (dexAssetID: number, cexAssetID: number, bridgeName: string): Promise {
+ const [withdrawal, deposit] = await Promise.all([
+ app().bridgeFeesAndLimits(dexAssetID, cexAssetID, bridgeName),
+ app().bridgeFeesAndLimits(cexAssetID, dexAssetID, bridgeName)
+ ])
+
+ if (!withdrawal || !deposit) {
+ throw new Error(`Failed to fetch round trip fees and limits for ${bridgeName}`)
+ }
+
+ return { withdrawal, deposit, bridgeName, cexAsset: cexAssetID }
+}
+
+function getMinimumTransferAmounts (
+ cexName: string,
+ baseID: number,
+ quoteID: number,
+ baseFeesAndLimits: RoundTripFeesAndLimits | null,
+ quoteFeesAndLimits: RoundTripFeesAndLimits | null
+): { baseMinWithdraw: number; quoteMinWithdraw: number } {
+ if (!cexName) return { baseMinWithdraw: 0, quoteMinWithdraw: 0 }
+
+ const cex = app().mmStatus.cexes[cexName]
+ if (!cex) throw new Error(`CEX ${cexName} not found`)
+
+ let baseMinWithdraw = 0
+ let quoteMinWithdraw = 0
+
+ for (const market of Object.values(cex.markets)) {
+ if (market.baseID === baseID) baseMinWithdraw = market.baseMinWithdraw
+ if (market.quoteID === quoteID) quoteMinWithdraw = market.quoteMinWithdraw
+ if (baseMinWithdraw > 0 && quoteMinWithdraw > 0) break
+ }
+
+ if (baseFeesAndLimits) {
+ baseMinWithdraw = Math.max(
+ baseMinWithdraw,
+ baseFeesAndLimits.deposit.hasLimits ? baseFeesAndLimits.deposit.minLimit : 0,
+ baseFeesAndLimits.withdrawal.hasLimits ? baseFeesAndLimits.withdrawal.minLimit : 0
+ )
+ }
+
+ if (quoteFeesAndLimits) {
+ quoteMinWithdraw = Math.max(
+ quoteMinWithdraw,
+ quoteFeesAndLimits.deposit.hasLimits ? quoteFeesAndLimits.deposit.minLimit : 0,
+ quoteFeesAndLimits.withdrawal.hasLimits ? quoteFeesAndLimits.withdrawal.minLimit : 0
+ )
+ }
+
+ return { baseMinWithdraw, quoteMinWithdraw }
+}
+
+function getDEXMarketInfo (host: string, baseID: number, quoteID: number): MarketInfo {
+ const baseAsset = app().assets[baseID]
+ const quoteAsset = app().assets[quoteID]
+ const baseFeeAssetID = baseAsset.token ? baseAsset.token.parentID : baseID
+ const quoteFeeAssetID = quoteAsset.token ? quoteAsset.token.parentID : quoteID
+
+ const baseFeeWallet = app().walletMap[baseFeeAssetID]
+ const quoteFeeWallet = app().walletMap[quoteFeeAssetID]
+
+ const { markets } = app().exchanges[host]
+ const { lotsize: lotSize } = markets[`${baseAsset.symbol}_${quoteAsset.symbol}`]
+ const quoteLot = calculateQuoteLot(lotSize, baseID, quoteID)
+
+ return {
+ host,
+ baseID,
+ quoteID,
+ baseFeeAssetID,
+ quoteFeeAssetID,
+ baseAsset,
+ quoteAsset,
+ lotSize,
+ quoteLot,
+ baseIsAccountLocker: (baseFeeWallet.traits & TRAIT_ACCOUNT_LOCKER) > 0,
+ quoteIsAccountLocker: (quoteFeeWallet.traits & TRAIT_ACCOUNT_LOCKER) > 0
+ }
+}
+
+function initialMultiHopCfg (
+ cexStatus: MMCEXStatus,
+ intermediateAssets: number[],
+ cexBaseID: number,
+ cexQuoteID: number,
+ savedCfg?: MultiHopCfg
+): MultiHopCfg | undefined {
+ const intermediateAsset = savedCfg
+ ? savedCfg.baseAssetMarket[0] === cexBaseID ? savedCfg.baseAssetMarket[1] : savedCfg.baseAssetMarket[0]
+ : intermediateAssets[0]
+
+ const markets = multiHopMarkets(intermediateAsset, cexBaseID, cexQuoteID, cexStatus) ||
+ multiHopMarkets(intermediateAssets[0], cexBaseID, cexQuoteID, cexStatus)
+
+ if (!markets) return undefined
+
+ return {
+ baseAssetMarket: markets[0],
+ quoteAssetMarket: markets[1],
+ marketOrders: savedCfg?.marketOrders ?? false,
+ limitOrdersBuffer: savedCfg?.limitOrdersBuffer ?? 0.01
+ }
+}
+
+function setBotSpecificDefaultConfig (
+ config: BotConfig,
+ botType: 'basicMM' | 'arbMM' | 'basicArb',
+ intermediateAssets: number[] | null,
+ cexStatus: MMCEXStatus | null
+): void {
+ switch (botType) {
+ case 'basicMM':
+ config.basicMarketMakingConfig = {
+ gapStrategy: 'percent-plus',
+ sellPlacements: [{ lots: 1, gapFactor: 0.01 }],
+ buyPlacements: [{ lots: 1, gapFactor: 0.01 }],
+ driftTolerance: 0.001
+ }
+ break
+ case 'arbMM': {
+ let multiHop : MultiHopCfg | undefined
+ if (intermediateAssets && cexStatus) {
+ multiHop = initialMultiHopCfg(cexStatus, intermediateAssets, config.cexBaseID, config.cexQuoteID)
+ if (!multiHop) throw new Error('Unable to determine initial multi-hop config')
+ }
+ config.arbMarketMakingConfig = {
+ buyPlacements: [{ lots: 1, multiplier: 1 }],
+ sellPlacements: [{ lots: 1, multiplier: 1 }],
+ profit: 0.01,
+ driftTolerance: 0.001,
+ orderPersistence: 2,
+ multiHop
+ }
+ break
+ }
+ case 'basicArb':
+ config.simpleArbConfig = {
+ profitTrigger: 0.01,
+ maxActiveArbs: 5,
+ numEpochsLeaveOpen: 2
+ }
+ break
+ default:
+ throw new Error(`Unknown bot type: ${botType}`)
+ }
+}
+
+// Main Functions
+export async function initialBotConfigState (
+ host: string,
+ baseID: number,
+ quoteID: number,
+ botType: 'basicMM' | 'arbMM' | 'basicArb',
+ intermediateAssets: number[] | null,
+ baseBridges: Record | null,
+ quoteBridges: Record | null,
+ cexStatus: MMCEXStatus | null,
+ cexName?: string
+): Promise {
+ const baseMultiFundingOpts = getWalletMultiFundingOptions(baseID)
+ const quoteMultiFundingOpts = getWalletMultiFundingOptions(quoteID)
+
+ let cexBaseID = 0
+ let cexQuoteID = 0
+ let baseBridgeName = ''
+ let quoteBridgeName = ''
+ let baseBridgeFeesAndLimits = null
+ let quoteBridgeFeesAndLimits = null
+ try {
+ ({ cexAssetID: cexBaseID, cexBridge: baseBridgeName, feesAndLimits: baseBridgeFeesAndLimits } =
+ await fetchCEXAssetAndBridgeInfo(baseID, null, '', baseBridges));
+ ({ cexAssetID: cexQuoteID, cexBridge: quoteBridgeName, feesAndLimits: quoteBridgeFeesAndLimits } =
+ await fetchCEXAssetAndBridgeInfo(quoteID, null, '', quoteBridges))
+ } catch (error) {
+ return `${error}`
+ }
+
+ const { baseMinWithdraw, quoteMinWithdraw } = getMinimumTransferAmounts(
+ cexName || '',
+ cexBaseID,
+ cexQuoteID,
+ baseBridgeFeesAndLimits,
+ quoteBridgeFeesAndLimits
+ )
+
+ const config: BotConfig = {
+ host,
+ baseID,
+ quoteID,
+ cexBaseID,
+ cexQuoteID,
+ baseBridgeName,
+ quoteBridgeName,
+ baseWalletOptions: orderOptionsToRecord(baseMultiFundingOpts),
+ quoteWalletOptions: orderOptionsToRecord(quoteMultiFundingOpts),
+ cexName: cexName || '',
+ uiConfig: {
+ quickBalance: {
+ buysBuffer: 1,
+ sellsBuffer: 1,
+ buyFeeReserve: 0,
+ sellFeeReserve: 0,
+ bridgeFeeReserve: 0,
+ slippageBuffer: 0.05
+ },
+ allocation: { dex: {}, cex: {} },
+ usingQuickBalance: true,
+ baseMinTransfer: baseMinWithdraw,
+ quoteMinTransfer: quoteMinWithdraw,
+ cexRebalance: false,
+ internalTransfers: true
+ }
+ }
+
+ setBotSpecificDefaultConfig(config, botType, intermediateAssets, cexStatus)
+
+ const { dexBalances, cexBalances } = await MM.availableBalances(
+ { host, baseID, quoteID },
+ config.cexBaseID,
+ config.cexQuoteID,
+ config.cexName
+ )
+
+ const marketReportRes = await MM.report(host, baseID, quoteID)
+ if (!app().checkResponse(marketReportRes)) {
+ return `Failed to get market report: ${marketReportRes.msg}`
+ }
+
+ const botConfigState: BotConfigState = {
+ botConfig: config,
+ dexMarket: getDEXMarketInfo(host, baseID, quoteID),
+ availableDEXBalances: dexBalances,
+ availableCEXBalances: cexBalances,
+ baseBridges,
+ quoteBridges,
+ baseBridgeFeesAndLimits,
+ quoteBridgeFeesAndLimits,
+ quickPlacements: DEFAULT_QUICK_PLACEMENTS,
+ allocationResult: null,
+ runStats: null,
+ marketReport: marketReportRes.report as MarketReport,
+ baseMultiFundingOpts,
+ quoteMultiFundingOpts,
+ baseMinWithdraw,
+ quoteMinWithdraw,
+ intermediateAssets,
+ intermediateAsset: intermediateAssets?.[0] ?? null,
+ cexStatus,
+ fiatRatesMap: app().fiatRatesMap
+ }
+
+ return updateAllocationsBasedOnQuickConfig(botConfigState)
+}
+
+export async function botConfigStateFromSavedConfig (
+ savedBotConfig: BotConfig,
+ cexStatus: MMCEXStatus | null,
+ intermediateAssets: number[] | null,
+ baseBridges: Record | null,
+ quoteBridges: Record | null
+): Promise {
+ const { cexAssetID: cexBaseID, cexBridge: baseBridgeName, feesAndLimits: baseBridgeFeesAndLimits } =
+ await fetchCEXAssetAndBridgeInfo(savedBotConfig.baseID, savedBotConfig.cexBaseID, savedBotConfig.baseBridgeName, baseBridges)
+ const { cexAssetID: cexQuoteID, cexBridge: quoteBridgeName, feesAndLimits: quoteBridgeFeesAndLimits } =
+ await fetchCEXAssetAndBridgeInfo(savedBotConfig.quoteID, savedBotConfig.cexQuoteID, savedBotConfig.quoteBridgeName, quoteBridges)
+
+ const { baseMinWithdraw, quoteMinWithdraw } = getMinimumTransferAmounts(
+ savedBotConfig.cexName,
+ cexBaseID,
+ cexQuoteID,
+ baseBridgeFeesAndLimits,
+ quoteBridgeFeesAndLimits
+ )
+
+ const config: BotConfig = {
+ ...savedBotConfig,
+ cexBaseID,
+ cexQuoteID,
+ baseBridgeName,
+ quoteBridgeName,
+ uiConfig: {
+ ...savedBotConfig.uiConfig,
+ baseMinTransfer: Math.max(savedBotConfig.uiConfig.baseMinTransfer, baseMinWithdraw),
+ quoteMinTransfer: Math.max(savedBotConfig.uiConfig.quoteMinTransfer, quoteMinWithdraw)
+ }
+ }
+
+ let intermediateAsset: number | null = null
+ if (config.arbMarketMakingConfig?.multiHop && cexStatus && intermediateAssets) {
+ config.arbMarketMakingConfig.multiHop = initialMultiHopCfg(cexStatus, intermediateAssets, config.cexBaseID, config.cexQuoteID, config.arbMarketMakingConfig.multiHop)
+ if (!config.arbMarketMakingConfig.multiHop) {
+ return 'Unable to determine initial multi-hop config'
+ }
+ const baseAssetMarket = config.arbMarketMakingConfig.multiHop.baseAssetMarket
+ intermediateAsset = baseAssetMarket[0] === config.cexBaseID ? baseAssetMarket[1] : baseAssetMarket[0]
+ }
+
+ const { dexBalances, cexBalances } = await MM.availableBalances(
+ { host: savedBotConfig.host, baseID: savedBotConfig.baseID, quoteID: savedBotConfig.quoteID },
+ config.cexBaseID,
+ config.cexQuoteID,
+ config.cexName
+ )
+
+ const marketReportRes = await MM.report(savedBotConfig.host, savedBotConfig.baseID, savedBotConfig.quoteID)
+ if (!app().checkResponse(marketReportRes)) {
+ throw new Error(`Failed to get market report: ${marketReportRes.msg}`)
+ }
+
+ const status = await MM.status()
+ const botStatus = status.bots.find((b: MMBotStatus) =>
+ b.config.baseID === savedBotConfig.baseID &&
+ b.config.quoteID === savedBotConfig.quoteID &&
+ b.config.host === savedBotConfig.host
+ )
+
+ const botConfigState: BotConfigState = {
+ botConfig: config,
+ dexMarket: getDEXMarketInfo(savedBotConfig.host, savedBotConfig.baseID, savedBotConfig.quoteID),
+ availableDEXBalances: dexBalances,
+ availableCEXBalances: cexBalances,
+ baseBridges,
+ quoteBridges,
+ baseBridgeFeesAndLimits,
+ quoteBridgeFeesAndLimits,
+ quickPlacements: null,
+ allocationResult: null,
+ runStats: botStatus?.runStats ?? null,
+ marketReport: marketReportRes.report as MarketReport,
+ baseMultiFundingOpts: getWalletMultiFundingOptions(savedBotConfig.baseID),
+ quoteMultiFundingOpts: getWalletMultiFundingOptions(savedBotConfig.quoteID),
+ baseMinWithdraw,
+ quoteMinWithdraw,
+ intermediateAssets,
+ intermediateAsset,
+ cexStatus,
+ fiatRatesMap: app().fiatRatesMap
+ }
+
+ return config.uiConfig.usingQuickBalance
+ ? updateAllocationsBasedOnQuickConfig(botConfigState)
+ : clampOriginalAllocations(botConfigState, dexBalances, cexBalances)
+}
+
+// Reducer and Context
+type RebalanceSettingsAction =
+ | { type: 'BASE_MIN_TRANSFER'; payload: number }
+ | { type: 'QUOTE_MIN_TRANSFER'; payload: number }
+ | { type: 'CEX_REBALANCE'; payload: boolean };
+
+type BotConfigAction =
+ | { type: 'SET_INITIAL_CONFIG'; payload: BotConfigState | null }
+ | { type: 'USE_QUICK_PLACEMENTS'; payload: boolean }
+ | { type: 'SET_GAP_STRATEGY'; payload: GapStrategy }
+ | { type: 'SET_PROFIT'; payload: number }
+ | { type: 'ADD_PLACEMENT'; payload: { sell: boolean; lots: number; gapFactor: number } }
+ | { type: 'REMOVE_PLACEMENT'; payload: { sell: boolean; index: number } }
+ | { type: 'REORDER_PLACEMENTS'; payload: { sell: boolean; fromIndex: number; toIndex: number } }
+ | { type: 'UPDATE_QUICK_CONFIG'; payload: { field: keyof QuickPlacementsConfig; value: number } }
+ | { type: 'UPDATE_QUICK_BALANCE'; payload: { field: keyof QuickBalanceConfig; value: number } }
+ | { type: 'TOGGLE_QUICK_BALANCE'; payload: boolean }
+ | { type: 'UPDATE_MANUAL_ALLOCATION'; payload: { assetID: number; amount: number; source: 'dex' | 'cex' } }
+ | { type: 'UPDATE_DRIFT_TOLERANCE'; payload: number }
+ | { type: 'UPDATE_ORDER_PERSISTENCE'; payload: number }
+ | { type: 'UPDATE_REBALANCE_SETTINGS'; payload: RebalanceSettingsAction }
+ | { type: 'UPDATE_WALLET_SETTING'; payload: { asset: 'base' | 'quote'; key: string; value: string } }
+ | { type: 'UPDATE_BRIDGE_SELECTION'; payload: { asset: 'base' | 'quote'; feesAndLimits: RoundTripFeesAndLimits } }
+ | { type: 'UPDATE_INTERMEDIATE_ASSET'; payload: number }
+ | { type: 'UPDATE_MULTI_HOP_MARKET_COMPLETION'; payload: boolean }
+ | { type: 'UPDATE_MULTI_HOP_LIMIT_BUFFER'; payload: number }
+ | { type: 'UPDATE_AVAILABLE_BALANCES'; payload: { dexBalances: Record; cexBalances: Record } };
+
+function rebalanceSettingsReducer (state: UIConfig, action: RebalanceSettingsAction): UIConfig {
+ switch (action.type) {
+ case 'BASE_MIN_TRANSFER':
+ return { ...state, baseMinTransfer: action.payload }
+ case 'QUOTE_MIN_TRANSFER':
+ return { ...state, quoteMinTransfer: action.payload }
+ case 'CEX_REBALANCE':
+ return { ...state, cexRebalance: action.payload, internalTransfers: !action.payload }
+ default:
+ return state
+ }
+}
+
+function deriveQuickConfigFromPlacements (state: BotConfigState): QuickPlacementsConfig {
+ const { botConfig } = state
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+ const isArbMM = !!botConfig.arbMarketMakingConfig
+
+ // Get placements - handle different types
+ let buys: any[] = []
+ let sells: any[] = []
+
+ if (isBasicMM && botConfig.basicMarketMakingConfig) {
+ buys = botConfig.basicMarketMakingConfig.buyPlacements
+ sells = botConfig.basicMarketMakingConfig.sellPlacements
+ } else if (isArbMM && botConfig.arbMarketMakingConfig) {
+ buys = botConfig.arbMarketMakingConfig.buyPlacements
+ sells = botConfig.arbMarketMakingConfig.sellPlacements
+ }
+
+ // Default values
+ let levelsPerSide = 1
+ let lotsPerLevel = 1
+ let priceIncrement = 0.005
+ let profitThreshold = 0.02
+ let matchBuffer = 0
+
+ if (buys.length > 0 && sells.length > 0) {
+ const placementCount = buys.length + sells.length
+ levelsPerSide = Math.max(1, Math.floor(placementCount / 2))
+
+ if (isBasicMM) {
+ // Find best/worst placements by gapFactor
+ const bestBuy = buys.reduce((prev, curr) => curr.gapFactor < prev.gapFactor ? curr : prev)
+ const bestSell = sells.reduce((prev, curr) => curr.gapFactor < prev.gapFactor ? curr : prev)
+ const worstBuy = buys.reduce((prev, curr) => curr.gapFactor > prev.gapFactor ? curr : prev)
+ const worstSell = sells.reduce((prev, curr) => curr.gapFactor > prev.gapFactor ? curr : prev)
+
+ // Calculate profit as average of best buy/sell gap factors
+ profitThreshold = (bestBuy.gapFactor + bestSell.gapFactor) / 2
+
+ // Calculate price increment from range
+ if (levelsPerSide > 1) {
+ const range = ((worstBuy.gapFactor - bestBuy.gapFactor) + (worstSell.gapFactor - bestSell.gapFactor)) / 2
+ priceIncrement = range / (levelsPerSide - 1)
+ }
+ } else if (isArbMM) {
+ const multSum = buys.reduce((v, p) => v + p.multiplier, 0) + sells.reduce((v, p) => v + p.multiplier, 0)
+ matchBuffer = ((multSum / placementCount) - 1) || 0
+ }
+
+ // Calculate lots per level from total placements
+ const lots = buys.reduce((v, p) => v + p.lots, 0) + sells.reduce((v, p) => v + p.lots, 0)
+ lotsPerLevel = Math.max(1, Math.round(lots / 2 / levelsPerSide))
+ }
+
+ return {
+ priceLevelsPerSide: levelsPerSide,
+ lotsPerLevel,
+ priceIncrement,
+ profitThreshold,
+ matchBuffer
+ }
+}
+
+function regeneratePlacementsFromQuickConfig (state: BotConfigState): BotConfigState {
+ if (!state.quickPlacements || state.botConfig.simpleArbConfig) return state
+
+ const { quickPlacements, botConfig } = state
+ const isBasicMM = !!botConfig.basicMarketMakingConfig
+ const isArbMM = !!botConfig.arbMarketMakingConfig
+ const levelsPerSide = quickPlacements.priceLevelsPerSide
+ const { lotsPerLevel, profitThreshold: profit, priceIncrement, matchBuffer } = quickPlacements
+
+ let newState = { ...state }
+
+ if (isBasicMM && botConfig.basicMarketMakingConfig) {
+ newState = {
+ ...newState,
+ botConfig: {
+ ...botConfig,
+ basicMarketMakingConfig: {
+ ...botConfig.basicMarketMakingConfig,
+ buyPlacements: [],
+ sellPlacements: []
+ }
+ }
+ }
+
+ if (!newState.botConfig.basicMarketMakingConfig) return newState
+ for (let levelN = 0; levelN < levelsPerSide; levelN++) {
+ const placement: OrderPlacement = { lots: lotsPerLevel, gapFactor: profit + (priceIncrement * levelN) }
+ newState.botConfig.basicMarketMakingConfig.buyPlacements.push(placement)
+ newState.botConfig.basicMarketMakingConfig.sellPlacements.push(placement)
+ }
+ } else if (isArbMM && botConfig.arbMarketMakingConfig) {
+ newState = {
+ ...newState,
+ botConfig: {
+ ...botConfig,
+ arbMarketMakingConfig: {
+ ...botConfig.arbMarketMakingConfig,
+ profit,
+ buyPlacements: [],
+ sellPlacements: []
+ }
+ }
+ }
+
+ if (!newState.botConfig.arbMarketMakingConfig) return newState
+ for (let levelN = 0; levelN < levelsPerSide; levelN++) {
+ const placement: ArbMarketMakingPlacement = { lots: lotsPerLevel, multiplier: matchBuffer + 1 }
+ newState.botConfig.arbMarketMakingConfig.buyPlacements.push(placement)
+ newState.botConfig.arbMarketMakingConfig.sellPlacements.push(placement)
+ }
+ }
+
+ return newState
+}
+
+function clampOriginalAllocations (state: BotConfigState, dexBalances: Record, cexBalances: Record): BotConfigState {
+ const { allocation } = state.botConfig.uiConfig
+ const clampedAllocation = {
+ dex: { ...allocation.dex },
+ cex: { ...allocation.cex }
+ }
+
+ for (const [assetIDStr, allocatedAmount] of Object.entries(allocation.dex)) {
+ const assetID = parseInt(assetIDStr)
+ clampedAllocation.dex[assetID] = state.runStats ? 0 : Math.min(allocatedAmount, dexBalances[assetID] || 0)
+ }
+
+ for (const [assetIDStr, allocatedAmount] of Object.entries(allocation.cex)) {
+ const assetID = parseInt(assetIDStr)
+ clampedAllocation.cex[assetID] = state.runStats ? 0 : Math.min(allocatedAmount, cexBalances[assetID] || 0)
+ }
+
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ uiConfig: { ...state.botConfig.uiConfig, allocation: clampedAllocation }
+ }
+ }
+}
+
+function updateAllocationsBasedOnQuickConfig (state: BotConfigState): BotConfigState {
+ if (!state.botConfig.uiConfig.usingQuickBalance) return state
+
+ const allocationResult = state.runStats ? toAllocateRunning(state, state.runStats) : toAllocate(state)
+ const updatedUIConfig = {
+ ...state.botConfig.uiConfig,
+ allocation: allocationResultAmounts(allocationResult)
+ }
+
+ return {
+ ...state,
+ botConfig: { ...state.botConfig, uiConfig: updatedUIConfig },
+ allocationResult
+ }
+}
+
+function multiHopMarkets (
+ intermediateAsset: number,
+ cexBaseID: number,
+ cexQuoteID: number,
+ cexStatus: MMCEXStatus
+): [[number, number], [number, number]] | undefined {
+ let baseAssetMarket: [number, number] | undefined
+ let quoteAssetMarket: [number, number] | undefined
+
+ for (const mkt of Object.values(cexStatus.markets)) {
+ if ((mkt.baseID === cexBaseID && mkt.quoteID === intermediateAsset) ||
+ (mkt.baseID === intermediateAsset && mkt.quoteID === cexBaseID)) {
+ baseAssetMarket = [mkt.baseID, mkt.quoteID]
+ }
+ if ((mkt.baseID === cexQuoteID && mkt.quoteID === intermediateAsset) ||
+ (mkt.baseID === intermediateAsset && mkt.quoteID === cexQuoteID)) {
+ quoteAssetMarket = [mkt.baseID, mkt.quoteID]
+ }
+ if (baseAssetMarket && quoteAssetMarket) break
+ }
+
+ return baseAssetMarket && quoteAssetMarket ? [baseAssetMarket, quoteAssetMarket] : undefined
+}
+
+export const botConfigStateReducer = (state: BotConfigState | null, action: BotConfigAction): BotConfigState | null => {
+ if (action.type === 'SET_INITIAL_CONFIG') return action.payload
+ if (!state) return null
+
+ switch (action.type) {
+ case 'USE_QUICK_PLACEMENTS': {
+ let newState = { ...state, quickPlacements: action.payload ? deriveQuickConfigFromPlacements(state) : null }
+ if (action.payload && newState.botConfig.basicMarketMakingConfig) {
+ newState = {
+ ...newState,
+ botConfig: {
+ ...newState.botConfig,
+ basicMarketMakingConfig: {
+ ...newState.botConfig.basicMarketMakingConfig,
+ gapStrategy: 'percent-plus'
+ }
+ }
+ }
+ }
+ return regeneratePlacementsFromQuickConfig(newState)
+ }
+
+ case 'SET_GAP_STRATEGY':
+ if (state.botConfig.basicMarketMakingConfig) {
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ basicMarketMakingConfig: {
+ ...state.botConfig.basicMarketMakingConfig,
+ gapStrategy: action.payload,
+ buyPlacements: [],
+ sellPlacements: []
+ }
+ }
+ }
+ }
+ return state
+
+ case 'SET_PROFIT':
+ if (state.botConfig.arbMarketMakingConfig) {
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ profit: action.payload
+ }
+ }
+ }
+ }
+ if (state.botConfig.simpleArbConfig) {
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ simpleArbConfig: {
+ ...state.botConfig.simpleArbConfig,
+ profitTrigger: action.payload
+ }
+ }
+ }
+ }
+ return state
+
+ case 'ADD_PLACEMENT': {
+ const placementType = action.payload.sell ? 'sellPlacements' : 'buyPlacements'
+ const isBasicMM = !!state.botConfig.basicMarketMakingConfig
+
+ if (isBasicMM && state.botConfig.basicMarketMakingConfig) {
+ const newPlacement: OrderPlacement = { lots: action.payload.lots, gapFactor: action.payload.gapFactor }
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ basicMarketMakingConfig: {
+ ...state.botConfig.basicMarketMakingConfig,
+ [placementType]: [...state.botConfig.basicMarketMakingConfig[placementType], newPlacement]
+ }
+ }
+ }
+ } else if (state.botConfig.arbMarketMakingConfig) {
+ const newPlacement: ArbMarketMakingPlacement = { lots: action.payload.lots, multiplier: action.payload.gapFactor }
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ [placementType]: [...state.botConfig.arbMarketMakingConfig[placementType], newPlacement]
+ }
+ }
+ }
+ }
+ return state
+ }
+
+ case 'REMOVE_PLACEMENT': {
+ const placementType = action.payload.sell ? 'sellPlacements' : 'buyPlacements'
+ const config = state.botConfig.basicMarketMakingConfig ?? state.botConfig.arbMarketMakingConfig
+ if (!config) return state
+
+ const placements = [...config[placementType]]
+ placements.splice(action.payload.index, 1)
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ [state.botConfig.basicMarketMakingConfig ? 'basicMarketMakingConfig' : 'arbMarketMakingConfig']: {
+ ...config,
+ [placementType]: placements
+ }
+ }
+ }
+ }
+
+ case 'REORDER_PLACEMENTS': {
+ const placementType = action.payload.sell ? 'sellPlacements' : 'buyPlacements'
+ const config = state.botConfig.basicMarketMakingConfig ?? state.botConfig.arbMarketMakingConfig
+ if (!config) return state
+
+ const placements = [...config[placementType]];
+ [placements[action.payload.fromIndex], placements[action.payload.toIndex]] =
+ [placements[action.payload.toIndex], placements[action.payload.fromIndex]]
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ [state.botConfig.basicMarketMakingConfig ? 'basicMarketMakingConfig' : 'arbMarketMakingConfig']: {
+ ...config,
+ [placementType]: placements
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_QUICK_CONFIG':
+ if (state.quickPlacements) {
+ const newState = {
+ ...state,
+ quickPlacements: { ...state.quickPlacements, [action.payload.field]: action.payload.value }
+ }
+ return regeneratePlacementsFromQuickConfig(newState)
+ }
+ return state
+
+ case 'UPDATE_QUICK_BALANCE':
+ if (state.botConfig.uiConfig.quickBalance) {
+ const newState = {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ uiConfig: {
+ ...state.botConfig.uiConfig,
+ quickBalance: {
+ ...state.botConfig.uiConfig.quickBalance,
+ [action.payload.field]: action.payload.value
+ }
+ }
+ }
+ }
+ return updateAllocationsBasedOnQuickConfig(newState)
+ }
+ return state
+
+ case 'TOGGLE_QUICK_BALANCE': {
+ const newState = {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ uiConfig: { ...state.botConfig.uiConfig, usingQuickBalance: action.payload }
+ }
+ }
+ return action.payload ? updateAllocationsBasedOnQuickConfig(newState) : newState
+ }
+
+ case 'UPDATE_MANUAL_ALLOCATION':
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ uiConfig: {
+ ...state.botConfig.uiConfig,
+ allocation: {
+ ...state.botConfig.uiConfig.allocation,
+ [action.payload.source]: {
+ ...state.botConfig.uiConfig.allocation[action.payload.source],
+ [action.payload.assetID]: action.payload.amount
+ }
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_DRIFT_TOLERANCE':
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ basicMarketMakingConfig: state.botConfig.basicMarketMakingConfig
+ ? { ...state.botConfig.basicMarketMakingConfig, driftTolerance: action.payload }
+ : undefined,
+ arbMarketMakingConfig: state.botConfig.arbMarketMakingConfig
+ ? { ...state.botConfig.arbMarketMakingConfig, driftTolerance: action.payload }
+ : undefined
+ }
+ }
+
+ case 'UPDATE_ORDER_PERSISTENCE':
+ if (state.botConfig.arbMarketMakingConfig) {
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ orderPersistence: action.payload
+ }
+ }
+ }
+ }
+ if (state.botConfig.simpleArbConfig) {
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ simpleArbConfig: {
+ ...state.botConfig.simpleArbConfig,
+ numEpochsLeaveOpen: action.payload
+ }
+ }
+ }
+ }
+ return state
+
+ case 'UPDATE_REBALANCE_SETTINGS': {
+ const newState = {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ uiConfig: rebalanceSettingsReducer(state.botConfig.uiConfig, action.payload)
+ }
+ }
+ return updateAllocationsBasedOnQuickConfig(newState)
+ }
+
+ case 'UPDATE_WALLET_SETTING': {
+ const optionsKey = action.payload.asset === 'base' ? 'baseWalletOptions' : 'quoteWalletOptions'
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ [optionsKey]: {
+ ...state.botConfig[optionsKey],
+ [action.payload.key]: action.payload.value
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_BRIDGE_SELECTION': {
+ const optionsKey = action.payload.asset === 'base' ? 'baseBridgeFeesAndLimits' : 'quoteBridgeFeesAndLimits'
+ const cexAssetIDKey = action.payload.asset === 'base' ? 'cexBaseID' : 'cexQuoteID'
+ const bridgeNameKey = action.payload.asset === 'base' ? 'baseBridgeName' : 'quoteBridgeName'
+ let newState = {
+ ...state,
+ [optionsKey]: action.payload.feesAndLimits,
+ botConfig: {
+ ...state.botConfig,
+ [cexAssetIDKey]: action.payload.feesAndLimits.cexAsset,
+ [bridgeNameKey]: action.payload.feesAndLimits.bridgeName
+ }
+ }
+
+ const { baseMinWithdraw, quoteMinWithdraw } = getMinimumTransferAmounts(
+ newState.botConfig.cexName,
+ newState.botConfig.cexBaseID,
+ newState.botConfig.cexQuoteID,
+ newState.baseBridgeFeesAndLimits,
+ newState.quoteBridgeFeesAndLimits
+ )
+
+ newState = {
+ ...newState,
+ botConfig: {
+ ...newState.botConfig,
+ uiConfig: {
+ ...newState.botConfig.uiConfig,
+ baseMinTransfer: Math.max(newState.botConfig.uiConfig.baseMinTransfer, baseMinWithdraw),
+ quoteMinTransfer: Math.max(newState.botConfig.uiConfig.quoteMinTransfer, quoteMinWithdraw)
+ }
+ },
+ baseMinWithdraw,
+ quoteMinWithdraw
+ }
+
+ return updateAllocationsBasedOnQuickConfig(newState)
+ }
+
+ case 'UPDATE_INTERMEDIATE_ASSET': {
+ if (!state.botConfig.arbMarketMakingConfig?.multiHop || !state.cexStatus) {
+ console.error(`Unable to update intermediate asset to ${action.payload}`)
+ return state
+ }
+
+ const mkts = multiHopMarkets(action.payload, state.botConfig.cexBaseID, state.botConfig.cexQuoteID, state.cexStatus)
+ if (!mkts) {
+ console.error(`Unable to update intermediate asset to ${action.payload}`)
+ return state
+ }
+
+ return {
+ ...state,
+ intermediateAsset: action.payload,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ multiHop: {
+ ...state.botConfig.arbMarketMakingConfig.multiHop,
+ baseAssetMarket: mkts[0],
+ quoteAssetMarket: mkts[1]
+ }
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_MULTI_HOP_MARKET_COMPLETION':
+ if (!state.botConfig.arbMarketMakingConfig?.multiHop) return state
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ multiHop: {
+ ...state.botConfig.arbMarketMakingConfig.multiHop,
+ marketOrders: action.payload
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_MULTI_HOP_LIMIT_BUFFER':
+ if (!state.botConfig.arbMarketMakingConfig?.multiHop) return state
+ return {
+ ...state,
+ botConfig: {
+ ...state.botConfig,
+ arbMarketMakingConfig: {
+ ...state.botConfig.arbMarketMakingConfig,
+ multiHop: {
+ ...state.botConfig.arbMarketMakingConfig.multiHop,
+ limitOrdersBuffer: action.payload
+ }
+ }
+ }
+ }
+
+ case 'UPDATE_AVAILABLE_BALANCES': {
+ const { dexBalances, cexBalances } = action.payload
+ const updatedState: BotConfigState = {
+ ...state,
+ availableDEXBalances: dexBalances,
+ availableCEXBalances: cexBalances
+ }
+
+ return updatedState.botConfig.uiConfig.usingQuickBalance
+ ? updateAllocationsBasedOnQuickConfig(updatedState)
+ : clampOriginalAllocations(updatedState, dexBalances, cexBalances)
+ }
+
+ default:
+ return state
+ }
+}
+
+// Context and Hooks
+export const BotConfigStateContext = createContext(undefined)
+export const BotConfigDispatchContext = createContext | undefined>(undefined)
+
+export const useBotConfigState = () => {
+ const context = useContext(BotConfigStateContext)
+ if (context === undefined) throw new Error('useBotConfigState must be used within a BotConfigProvider')
+ return context
+}
+
+export const useBotConfigDispatch = () => {
+ const context = useContext(BotConfigDispatchContext)
+ if (context === undefined) throw new Error('useBotConfigDispatch must be used within a BotConfigProvider')
+ return context
+}
diff --git a/client/webserver/site/src/js/mmutil.ts b/client/webserver/site/src/js/mmutil.ts
index ed79e0a1ac..3068d570fb 100644
--- a/client/webserver/site/src/js/mmutil.ts
+++ b/client/webserver/site/src/js/mmutil.ts
@@ -325,23 +325,38 @@ export class PlacementsChart extends Chart {
if (!placements?.length) return
const [xMin, xMax] = isBuy ? [0, cexGapL] : [cexGapR, canvas.width]
const reg = new Region(ctx, new Extents(xMin, xMax, canvas.height * (1 - regionHeight), canvas.height))
- const [l, r] = isBuy ? [-range, 0] : [0, range]
+
+ // Calculate actual range needed for placements to touch the border
+ let actualRange = range
+ if (isBuy && placements.length > 0) {
+ const maxGapFactor = Math.max(...placements.map(p => isBasicMM ? p.gapFactor : profit + (placements.indexOf(p) + 1) * fauxSpacer))
+ actualRange = Math.max(range, maxGapFactor)
+ }
+
+ const [l, r] = isBuy ? [-actualRange, 0] : [0, range]
reg.plot(new Extents(l, r, 0, maxLots), (ctx: CanvasRenderingContext2D, tools: Translator) => {
ctx.lineWidth = 2.5
ctx.strokeStyle = isBuy ? theme.buyLine : theme.sellLine
ctx.fillStyle = isBuy ? theme.buyFill : theme.sellFill
ctx.beginPath()
const sideFactor = isBuy ? -1 : 1
- const firstPt = placements[0]
const y0 = tools.y(0)
- const firstX = tools.x((isBasicMM ? firstPt.gapFactor : profit + fauxSpacer) * sideFactor)
- ctx.moveTo(firstX, y0)
+
+ // For buy side, start from the left border to ensure it touches
+ if (isBuy) {
+ ctx.moveTo(tools.x(-actualRange), y0)
+ }
+
let cumulativeLots = 0
for (let i = 0; i < placements.length; i++) {
const p = placements[i]
// For arb-mm, we don't know exactly
const rawX = isBasicMM ? p.gapFactor : profit + (i + 1) * fauxSpacer
const x = tools.x(rawX * sideFactor)
+ if (i === 0 && !isBuy) {
+ // For sell side, start from first placement
+ ctx.moveTo(x, y0)
+ }
ctx.lineTo(x, tools.y(cumulativeLots))
cumulativeLots += p.lots
ctx.lineTo(x, tools.y(cumulativeLots))
@@ -350,7 +365,13 @@ export class PlacementsChart extends Chart {
ctx.lineTo(xInfinity, tools.y(cumulativeLots))
ctx.stroke()
ctx.lineTo(xInfinity, y0)
- ctx.lineTo(firstX, y0)
+ if (isBuy) {
+ ctx.lineTo(tools.x(-actualRange), y0)
+ } else {
+ const firstPt = placements[0]
+ const firstX = tools.x((isBasicMM ? firstPt.gapFactor : profit + fauxSpacer) * sideFactor)
+ ctx.lineTo(firstX, y0)
+ }
ctx.closePath()
ctx.globalAlpha = 0.25
ctx.fill()
@@ -379,6 +400,7 @@ export function liveBotStatus (host: string, baseID: number, quoteID: number): M
}
export function feeAssetID (assetID: number) {
+ console.log('assetID', assetID)
const asset = app().assets[assetID]
if (asset.token) return asset.token.parentID
return assetID
diff --git a/client/webserver/site/src/js/register.ts b/client/webserver/site/src/js/register.ts
index eefa6905c5..60c4e1e9c2 100644
--- a/client/webserver/site/src/js/register.ts
+++ b/client/webserver/site/src/js/register.ts
@@ -48,7 +48,7 @@ export default class RegistrationPage extends BasePage {
// Hide the form closers for the registration process except for the
// password reset form closer.
- for (const el of body.querySelectorAll('.form-closer')) if (el !== page.resetPassFormCloser) Doc.hide(el)
+ for (const el of Array.from(body.querySelectorAll('.form-closer'))) if (el !== page.resetPassFormCloser) Doc.hide(el)
this.newWalletForm = new NewWalletForm(
page.newWalletForm,
diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts
index f7d4cdae94..35023c046e 100644
--- a/client/webserver/site/src/js/registry.ts
+++ b/client/webserver/site/src/js/registry.ts
@@ -776,8 +776,10 @@ export interface AutoRebalanceConfig {
internalOnly: boolean
}
+export type GapStrategy = 'multiplier' | 'absolute' | 'absolute-plus' | 'percent' | 'percent-plus'
+
export interface BasicMarketMakingConfig {
- gapStrategy: string
+ gapStrategy: GapStrategy
sellPlacements: OrderPlacement[]
buyPlacements: OrderPlacement[]
driftTolerance: number
@@ -831,8 +833,8 @@ export interface QuickBalanceConfig {
export interface UIConfig {
quickBalance: QuickBalanceConfig
- allocation: BotBalanceAllocation
usingQuickBalance: boolean
+ allocation: BotBalanceAllocation
baseMinTransfer: number
quoteMinTransfer: number
cexRebalance: boolean
@@ -852,8 +854,8 @@ export interface BotConfig {
cexQuoteID: number
baseBridgeName: string
quoteBridgeName: string
- baseWalletOptions?: Record
- quoteWalletOptions?: Record
+ baseWalletOptions: Record | null
+ quoteWalletOptions: Record | null
cexName: string
uiConfig: UIConfig
basicMarketMakingConfig?: BasicMarketMakingConfig
@@ -1353,6 +1355,7 @@ export interface Application {
needsCustomProvider (assetID: number): Promise
allBridgePaths (): Promise>>
bridgeFeesAndLimits (fromAssetID: number, toAssetID: number, bridgeName: string): Promise
+ prettyPrintAssetID(assetID: number): string
}
// TODO: Define an interface for Application?
diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts
index c232b8c469..6c94d18b4c 100644
--- a/client/webserver/site/src/js/wallets.ts
+++ b/client/webserver/site/src/js/wallets.ts
@@ -437,7 +437,7 @@ export default class WalletsPage extends BasePage {
this.setSelectedAsset(selectedAsset)
setInterval(() => {
- for (const row of this.page.txHistoryTableBody.children) {
+ for (const row of Array.from(this.page.txHistoryTableBody.children)) {
const age = Doc.tmplElement(row as PageElement, 'age')
age.textContent = Doc.timeSince(parseInt(age.dataset.timestamp as string))
}
@@ -982,7 +982,7 @@ export default class WalletsPage extends BasePage {
async setSelectedAsset (assetID: number) {
const { assetSelect } = this.page
- for (const b of assetSelect.children) b.classList.remove('selected')
+ for (const b of Array.from(assetSelect.children)) b.classList.remove('selected')
this.assetButtons[assetID].bttn.classList.add('selected')
this.selectedAssetID = assetID
this.page.hideMixTxsCheckbox.checked = true
@@ -1889,7 +1889,7 @@ export default class WalletsPage extends BasePage {
}
return
}
- for (const row of this.page.txHistoryTableBody.children) {
+ for (const row of Array.from(this.page.txHistoryTableBody.children)) {
const peRow = row as PageElement
if (peRow.dataset.txid === tx.id) {
this.updateTxHistoryRow(peRow, tx, assetID)
diff --git a/client/webserver/site/tsconfig.json b/client/webserver/site/tsconfig.json
index 4a5c12f243..612e73436c 100644
--- a/client/webserver/site/tsconfig.json
+++ b/client/webserver/site/tsconfig.json
@@ -9,6 +9,8 @@
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "strictNullChecks": true
+ "strictNullChecks": true,
+ "jsx": "react-jsx",
+ "lib": ["es6", "dom"]
}
}
diff --git a/client/webserver/site/webpack/common.js b/client/webserver/site/webpack/common.js
index 738e1d20a7..1b14005bc0 100644
--- a/client/webserver/site/webpack/common.js
+++ b/client/webserver/site/webpack/common.js
@@ -33,6 +33,20 @@ module.exports = {
}
}
]
+ },
+ {
+ test: /\.(ts|tsx|js|jsx)$/,
+ exclude: /node_modules/,
+ use: {
+ loader: 'babel-loader',
+ options: {
+ presets: [
+ '@babel/preset-env',
+ '@babel/preset-typescript',
+ '@babel/preset-react'
+ ]
+ }
+ }
}
]
},
@@ -55,7 +69,7 @@ module.exports = {
publicPath: '/dist/'
},
resolve: {
- extensions: ['.ts', ".js"],
+ extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
// Fixes weird issue with watch script. See
// https://github.com/webpack/webpack/issues/2297#issuecomment-289291324