diff --git a/README.md b/README.md index 1242dfe..84473cc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ ## Table of contents - ๐ŸŽ“ [Crash Course](#crash-course-geniusyield-dex-orders-and-the-smart-order-routers) +- ๐Ÿ” [How bot assesses profitability](#how-bot-assesses-profitability) - ๐Ÿš€ [Building and running](#building-and-running-the-smart-order-router) - ๐Ÿง  [Strategies](#strategies) - ๐Ÿ’ฐ [Yield Accelerator Rewards](#yield-accelerator-rewards) @@ -137,20 +138,17 @@ Using the previous example we could have two cases: If we want our earnings to be in `GENS` then the commodity must be `ADA`. So we can buy from the sell order, `20 ADA` using `8 GENS`, then using these `20 ADA` we can get `10 GENS` from the buy order, earning `2 GENS`. -> [!IMPORTANT] -> -> There is a check in the end which does the following before submitting any transaction: -> -> * In case "currency" is set to ADA for all `scanTokens` then this check guarantees that bot doesn't lose any funds by submitting the built transaction. -> * For other case, since arbitrage isn't guaranteed to be in ADA but as transaction fees must be paid in ADA, this check guarantees that bot doesn't lose any non-ADA token and doesn't lose any ADA besides transaction fees. +## How bot assesses profitability? -## Building and running the Smart Order Router - -> [!NOTE] -> To run a Smart Order Router instance for the [public testnet](https://testnet.geniusyield.co/), please use the preprod testnet. +Our design aims to execute built transactions only if they are "profitable" but how do we define profitability? We use the following rules regarding it: +* If currency of all the pairs (`scanTokens`) is set to ADA, then transaction is profitable if bot doesn't lose balance for any of it's tokens. +* For other case, since arbitrage isn't guaranteed to be in ADA but as transaction fees must be paid in ADA, some ADA loss might be inevitable. Here we require that token balance for any non-ada token does not decrease and that _ADA equivalent_ for arbitraged non-ADA token (along with any arbitraged ADA) compensates loss of ADA due to fees. + * In case `tokenInfos`[^1] misses an entry for an arbitraged token or in case an error is encountered when obtaining price from provider, we make a log with high severity (warning in case of missed entry & error in case of remote price provider failure) and assume it's ADA equivalent value to be zero (to be on safe side) when determining above profitability check. + * Note that in case `priceProvider` field is not provided in bot's configuration, profitability check is slightly modified where we require that bot doesn't lose any ADA besides transaction fees (along with requiring that it doesn't lose any non-ADA token). +## Building and running the Smart Order Router -## Running the SOR: System requirements +### Running the SOR: System requirements Minimum System Requirements: - Memory: 500 MB @@ -319,6 +317,8 @@ console, and some `Debug` level info into the `Debug.log` file. ] ``` +#### Bot configuration + In addition, to configure the **bot**, it is necessary to edit the [bot-config.json](./config-files/bot-config.json) file. The complete bot configuration looks like this: @@ -335,7 +335,24 @@ file. The complete bot configuration looks like this: "commodityAsset": "c6e65ba7878b2f8ea0ad39287d3e2fd256dc5c4160fc19bdf4c4d87e.7447454e53", "currencyAsset": "lovelace" } - ] + ], + "lovelaceWarningThreshold": 5000000, + "priceProvider": { + "tag": "tapTools", + "contents": { + "apiKey": "YOUR_API_KEY" + } + }, + "tokenInfos": { + "dda5fdb1002f7389b33e036b6afee82a8189becb6cba852e8b79b4fb.0014df1047454e53": { + "ticker": "GENS", + "decimals": 6 + }, + "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d": { + "ticker": "USDM", + "decimals": 6 + } + } } ``` - `signingKeyFP`, we need to specify the bot signing key, that must be placed @@ -355,6 +372,32 @@ file. The complete bot configuration looks like this: will arbitrage the orders to get tokens of the `currencyAsset`. Each token must be written with the format policyId.hexTokenName. For convenience, scanning ADAs can be done by writing lovelace or the empty string. The multi-asset order book is built using this list. +- `lovelaceWarningThreshold`, denotes lovelace value. If bot's balance is below this threshold, log with warning severity is made. +- `priceProvider` (optional) is used to configure price provider which is used to obtain ADA price for a token. Currently two price providers are supported. + - To use [Maestro's OHLCV](https://docs.gomaestro.org/cardano/dex-and-pair-ohlc) endpoint, example configuration is provided below. `resolution` & `dex` field correspond directly to underlying Maestro endpoint: + ```json + "priceProvider": { + "tag": "maestro", + "contents": { + "apiKey": "YOUR_API_KEY", + "resolution": "15m", + "dex": "minswap" + } + } + ``` + - To use [TapTools's prices](https://openapi.taptools.io/#tag/Market-Tokens/paths/~1token~1prices/post) endpoint, example configuration is provided below. Note that when using TapTools price provider, SOR does single request to obtain price information for multiple tokens when required as underlying endpoint supports price fetch for multiple assets in a single API call. Whereas for Maestro, request is made per individual token. + ```json + "priceProvider": { + "tag": "tapTools", + "contents": { + "apiKey": "YOUR_API_KEY" + } + } + ``` +- `tokenInfos` (optional) is used to provide token's registered off-chain metadata. Since prices provided by providers utilise "display" units which is independent of underlying blockchain ledger, we require information such as token's registered decimal places to obtain lovelace value per token's indivisible unit. + +> [!NOTE] +> See [_How bot assesses profitability_](#how-bot-assesses-profitability) section on how fields of `tokenInfos` & `priceProvider` are used to assess profitability. #### Creating Signing Key @@ -583,3 +626,5 @@ Cloud service providers like AWS, Google Cloud Platform or Azure offer monitorin ## License [Apache-2.0](./LICENSE) ยฉ [GYELD GMBH](https://www.geniusyield.co). + +[^1]: See ["Bot configuration"](#bot-configuration) section for elaboration on these fields. \ No newline at end of file diff --git a/cabal.project b/cabal.project index d83186b..68706ed 100644 --- a/cabal.project +++ b/cabal.project @@ -38,10 +38,11 @@ source-repository-package source-repository-package type: git location: https://github.com/geniusyield/dex-contracts-api - tag: v0.11.0 - --sha256: sha256-fV6jQVxoPfv1DdssmuHDmyvKcFpFCReiSeZ3n76zC9M= + tag: b6ab4d8d722d22c3fc9eae9c8689de00ba8d37c2 subdir: geniusyield-dex-api + geniusyield-server-lib + geniusyield-orderbot-lib geniusyield-onchain/geniusyield-common -------- Begin contents from @atlas@'s @cabal.project@ file. -------- diff --git a/config-files/bot-config.json b/config-files/bot-config.json index 32ea631..32a46bf 100644 --- a/config-files/bot-config.json +++ b/config-files/bot-config.json @@ -18,5 +18,22 @@ "commodityAsset": "c6e65ba7878b2f8ea0ad39287d3e2fd256dc5c4160fc19bdf4c4d87e.7447454e53", "currencyAsset": "lovelace" } - ] -} + ], + "lovelaceWarningThreshold": 5000000, + "priceProvider": { + "tag": "tapTools", + "contents": { + "apiKey": "YOUR_API_KEY" + } + }, + "tokenInfos": { + "dda5fdb1002f7389b33e036b6afee82a8189becb6cba852e8b79b4fb.0014df1047454e53": { + "ticker": "GENS", + "decimals": 6 + }, + "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d": { + "ticker": "USDM", + "decimals": 6 + } + } +} \ No newline at end of file diff --git a/geniusyield-orderbot-framework/CHANGELOG.md b/geniusyield-orderbot-framework/CHANGELOG.md index b3b3bae..96c71b8 100644 --- a/geniusyield-orderbot-framework/CHANGELOG.md +++ b/geniusyield-orderbot-framework/CHANGELOG.md @@ -1,5 +1,10 @@ # Revision history for geniusyield-orderbot-framework +## 0.5.1 + +* Additional configuration parameter, `lovelaceWarningThreshold` which denotes lovelace value. If bot's balance is below this threshold, log with warning severity is made. +* Support for price providers to assess profitability, see details in updated README file. + ## 0.5.0 Adds strategy signature, utilities to different orderbook, etc. signatures and more modules related to order bot configuration and command line parsing. diff --git a/geniusyield-orderbot-framework/geniusyield-orderbot-framework.cabal b/geniusyield-orderbot-framework/geniusyield-orderbot-framework.cabal index c28bc5c..e05de9c 100644 --- a/geniusyield-orderbot-framework/geniusyield-orderbot-framework.cabal +++ b/geniusyield-orderbot-framework/geniusyield-orderbot-framework.cabal @@ -1,7 +1,7 @@ -cabal-version: 3.4 +cabal-version: 3.12 name: geniusyield-orderbot-framework synopsis: Smart Order Router framework -version: 0.5.0 +version: 0.5.1 build-type: Simple license: Apache-2.0 copyright: (c) 2023 GYELD GMBH @@ -178,12 +178,16 @@ library hs-source-dirs: src build-depends: cardano-api, + deriving-aeson, envy, geniusyield-dex-api, geniusyield-orderbot-framework:common, geniusyield-orderbot-framework:datasource, geniusyield-orderbot-framework:orderbook, geniusyield-orderbot-framework:strategies, + geniusyield-server-lib, + http-api-data, + maestro-sdk, vector, exposed-modules: diff --git a/geniusyield-orderbot-framework/src/GeniusYield/OrderBot.hs b/geniusyield-orderbot-framework/src/GeniusYield/OrderBot.hs index 3c3eaea..6ba0281 100644 --- a/geniusyield-orderbot-framework/src/GeniusYield/OrderBot.hs +++ b/geniusyield-orderbot-framework/src/GeniusYield/OrderBot.hs @@ -6,6 +6,8 @@ Maintainer : support@geniusyield.co Stability : develop -} module GeniusYield.OrderBot ( + AssetInfo (..), + PriceProviderConfig (..), OrderBot (..), ExecutionStrategy (..), runOrderBot, @@ -15,32 +17,44 @@ import Control.Arrow (second, (&&&)) import Control.Concurrent (threadDelay) import Control.Exception ( AsyncException (UserInterrupt), + Exception, SomeException, bracket, + displayException, fromException, handle, + throwIO, + try, ) import Control.Monad ( filterM, forever, unless, + when, + (<=<), ) import Control.Monad.Reader (runReaderT) -import Data.Aeson (ToJSON, encode) +import Data.Aeson (encode) import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as BL -import Data.Foldable (foldl', toList) +import Data.Foldable (foldl', foldlM, toList) import Data.Functor ((<&>)) import Data.List (find) import qualified Data.List.NonEmpty as NE (toList) -import qualified Data.Map as M +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (mapMaybe) +import Data.Text (Text) import qualified Data.Text as Txt +import Data.Word (Word64) +import Deriving.Aeson import GeniusYield.Api.Dex.Constants (DEXInfo (..)) import GeniusYield.GYConfig ( + Confidential (..), GYCoreConfig (cfgNetworkId), withCfgProviders, ) +import GeniusYield.Imports (coerce) import GeniusYield.OrderBot.DataSource (closeDB, connectDB) import GeniusYield.OrderBot.MatchingStrategy ( IndependentStrategy, @@ -63,6 +77,9 @@ import GeniusYield.OrderBot.Types ( assetInfo, ) import GeniusYield.Providers.Common (SubmitTxException) +import GeniusYield.Providers.Maestro (networkIdToMaestroEnv) +import GeniusYield.Server.Ctx (TapToolsEnv (..)) +import GeniusYield.Server.Dex.HistoricalPrices.TapTools.Client hiding (handleTapToolsError) import GeniusYield.Transaction (GYCoinSelectionStrategy (GYLegacy)) import GeniusYield.TxBuilder ( GYTxBuildResult (..), @@ -71,11 +88,80 @@ import GeniusYield.TxBuilder ( buildTxBodyParallelWithStrategy, runGYTxBuilderMonadIO, runGYTxQueryMonadIO, + utxosAtAddresses, utxosAtTxOutRefs, ) import GeniusYield.TxBuilder.Errors (GYTxMonadException) import GeniusYield.Types +import qualified Maestro.Client.V1 as Maestro +import qualified Maestro.Types.V1 as Maestro import System.Exit (exitSuccess) +import Web.HttpApiData (ToHttpApiData (..)) + +data AssetInfo = AssetInfo + { assetTicker :: !Text + , assetDecimals :: !Word64 + } + deriving stock (Show, Generic) + deriving (FromJSON, ToJSON) via CustomJSON '[FieldLabelModifier '[StripPrefix "asset", Maestro.LowerFirst]] AssetInfo + +data MaestroConfig = MaestroConfig + { mcApiKey :: !(Confidential Text) + , mcResolution :: !Maestro.Resolution + , mcDex :: !Maestro.Dex + } + deriving stock (Show, Generic) + deriving (FromJSON, ToJSON) via CustomJSON '[FieldLabelModifier '[StripPrefix "mc", Maestro.LowerFirst]] MaestroConfig + +newtype TapToolsConfig = TapToolsConfig + { ttcApiKey :: Confidential Text + } + deriving stock (Show, Generic) + deriving (FromJSON, ToJSON) via CustomJSON '[FieldLabelModifier '[StripPrefix "ttc", Maestro.LowerFirst]] TapToolsConfig + +-- | Price provider to get ADA price of a token. +data PriceProviderConfig + = TapToolsPriceProviderConfig !TapToolsConfig + | MaestroPriceProviderConfig !MaestroConfig + deriving stock (Show, Generic) + deriving (FromJSON, ToJSON) via CustomJSON '[ConstructorTagModifier '[Rename "TapToolsPriceProviderConfig" "tapTools", Rename "MaestroPriceProviderConfig" "maestro"]] PriceProviderConfig + +data MaestroPP = MaestroPP + { mppEnv :: !(Maestro.MaestroEnv 'Maestro.V1) + , mppResolution :: !Maestro.Resolution + , mppDex :: !Maestro.Dex + } + +newtype TapToolsPP = TapToolsPP + { ttppEnv :: TapToolsEnv + } + +data PriceProvider + = TapToolsPriceProvider !TapToolsPP + | MaestroPriceProvider !MaestroPP + +buildTapToolsPP :: TapToolsConfig -> IO TapToolsPP +buildTapToolsPP TapToolsConfig {..} = do + tcenv <- tapToolsClientEnv + let tenv = TapToolsEnv tcenv (coerce ttcApiKey) + pure + TapToolsPP + { ttppEnv = tenv + } + +buildMaestroPP :: MaestroConfig -> IO MaestroPP +buildMaestroPP MaestroConfig {..} = do + env <- networkIdToMaestroEnv (coerce mcApiKey) GYMainnet + pure + MaestroPP + { mppEnv = env + , mppResolution = mcResolution + , mppDex = mcDex + } + +buildPP :: PriceProviderConfig -> IO PriceProvider +buildPP (TapToolsPriceProviderConfig ttpc) = TapToolsPriceProvider <$> buildTapToolsPP ttpc +buildPP (MaestroPriceProviderConfig mpc) = MaestroPriceProvider <$> buildMaestroPP mpc -- | The order bot is product type between bot info and "execution strategies". data OrderBot = OrderBot @@ -101,6 +187,11 @@ data OrderBot = OrderBot , botTakeMatches :: [MatchResult] -> IO [MatchResult] -- ^ How and how many matching results do the bot takes to build, sign and -- submit every iteration. + , botLovelaceWarningThreshold :: Maybe Natural + -- ^ See 'botCLovelaceWarningThreshold'. + , botPriceProvider :: Maybe PriceProviderConfig + -- ^ The price provider for the bot, used in case arbitrage is in non-ada token & we need to decide if the arbitraged tokens compensate the ada lost due to transaction fees. + , botTokenInfos :: Map GYAssetClass AssetInfo } {- | Currently, we only have the parallel execution strategy: @MultiAssetTraverse@, @@ -109,6 +200,9 @@ data OrderBot = OrderBot -} newtype ExecutionStrategy = MultiAssetTraverse IndependentStrategy +sorNS :: GYLogNamespace +sorNS = "SOR" + runOrderBot :: -- | Path to the config file for the GY framework. GYCoreConfig -> @@ -128,16 +222,19 @@ runOrderBot , botAssetPairFilter , botRescanDelay , botTakeMatches + , botLovelaceWarningThreshold + , botPriceProvider + , botTokenInfos } = do withCfgProviders cfg "" $ \providers -> do - let logInfo = gyLogInfo providers "SOR" - logDebug = gyLogDebug providers "SOR" + let logInfo = gyLogInfo providers sorNS + logDebug = gyLogDebug providers sorNS + logWarn = gyLogWarning providers sorNS netId = cfgNetworkId cfg botPkh = paymentKeyHash $ paymentVerificationKey botSkey botChangeAddr = addressFromCredential netId (GYPaymentCredentialByKey botPkh) (stakeAddressToCredential . stakeAddressFromBech32 <$> botStakeAddress) botAddrs = [botChangeAddr] - logInfo $ unlines [ "" @@ -146,16 +243,34 @@ runOrderBot , " Wallet Addresses: " ++ show (Txt.unpack . addressToText <$> botAddrs) , " Change Address: " ++ (Txt.unpack . addressToText $ botChangeAddr) , " Collateral: " ++ show botCollateral + , " Lovelace balance warning threshold: " ++ show botLovelaceWarningThreshold , " Scan delay (ยตs): " ++ show botRescanDelay + , " Bot price configuration: " ++ show botPriceProvider + , " Bot token infos: " ++ show botTokenInfos , " Token Pairs to scan:" , unlines (map (("\t - " ++) . show) botAssetPairFilter) , "" ] - + mpp <- maybe (pure Nothing) (fmap Just . buildPP) botPriceProvider bracket (connectDB netId providers) closeDB $ \conn -> forever $ handle (handleAnyException providers) $ do logInfo "Rescanning for orders..." - + botUtxos <- runGYTxQueryMonadIO netId providers $ utxosAtAddresses botAddrs + let botBalance = foldMapUTxOs utxoValue botUtxos + botLovelaceBalance = valueAssetClass botBalance GYLovelace + logInfo $ + unwords + [ "Bot balance:" + , show botBalance + ] + when (botLovelaceBalance < maybe 0 fromIntegral botLovelaceWarningThreshold) $ + logWarn $ + unwords + [ "Bot lovelace balance is below the warning threshold. Threshold:" + , show botLovelaceWarningThreshold + , ", bot lovelace balance:" + , show botLovelaceBalance + ] -- First we populate the multi asset orderbook, using the provided -- @populateOrderBook@. book <- populateOrderBook conn di botAssetPairFilter @@ -217,7 +332,7 @@ runOrderBot -- We filter the txs that are not losing tokens profitableTxs <- filterM - (notLosingTokensCheck netId providers botAddrs botAssetPairFilter) + (notLosingTokensCheck netId providers botAddrs botAssetPairFilter mpp botTokenInfos) txs logInfo $ @@ -243,7 +358,7 @@ runOrderBot handleAnyException _ (fromException -> Just UserInterrupt) = putStrLn "Gracefully stopping..." >> exitSuccess handleAnyException providers err = - let logErr = gyLogError providers "SOR" + let logErr = gyLogError providers sorNS in logErr (show err) >> threadDelay botRescanDelay signAndSubmitTx :: GYTxBody -> GYProviders -> GYPaymentSigningKey -> IO () @@ -254,9 +369,9 @@ signAndSubmitTx txBody providers botSkey = handle handlerSubmit $ do logInfo $ unwords ["Submitted order matching transaction with id:", show tid] where logInfo, logDebug, logWarn :: String -> IO () - logInfo = gyLogInfo providers "SOR" - logDebug = gyLogDebug providers "SOR" - logWarn = gyLogWarning providers "SOR" + logInfo = gyLogInfo providers sorNS + logDebug = gyLogDebug providers sorNS + logWarn = gyLogWarning providers sorNS handlerSubmit :: SubmitTxException -> IO () handlerSubmit ex = logWarn $ unwords ["SubmitTxException:", show ex] @@ -301,7 +416,7 @@ buildTransactions GYTxBuildNoInputs -> logWarn "No Inputs" >> return [] where logWarn :: String -> IO () - logWarn = gyLogWarning providers "SOR" + logWarn = gyLogWarning providers sorNS findBody :: [GYTxBody] -> MatchResult -> Maybe (GYTxBody, MatchResult) findBody bs mr = @@ -323,11 +438,14 @@ notLosingTokensCheck :: GYProviders -> [GYAddress] -> [OrderAssetPair] -> + Maybe PriceProvider -> + Map GYAssetClass AssetInfo -> (GYTxBody, MatchResult) -> IO Bool -notLosingTokensCheck netId providers botAddrs oapFilter (txBody, matchesToExecute) = do - let logDebug = gyLogDebug providers "SOR" - logWarn = gyLogWarning providers "SOR" +notLosingTokensCheck netId providers botAddrs oapFilter mpp assetInfos (txBody, matchesToExecute) = do + let logDebug = gyLogDebug providers sorNS + logWarn = gyLogWarning providers sorNS + logErr = gyLogError providers sorNS matchesRefs = map matchExecutionInfoUtxoRef matchesToExecute botInputs = filter (`notElem` matchesRefs) $ txBodyTxIns txBody @@ -337,21 +455,61 @@ notLosingTokensCheck netId providers botAddrs oapFilter (txBody, matchesToExecut utxosLovelaceAndFilteredValueAtAddr inputs (outputLovelace, filteredACOutput) = utxosLovelaceAndFilteredValueAtAddr $ txBodyUTxOs txBody - + assetsToConsider = valueAssets filteredACInput <> valueAssets filteredACOutput fees = txBodyFee txBody - lovelaceCheck = if all currencyIsLovelace oapFilter then outputLovelace >= inputLovelace else inputLovelace - outputLovelace <= fees - - filteredACCheck = - all - ( \ac -> - valueAssetClass filteredACInput ac - <= valueAssetClass filteredACOutput ac - ) - $ toList - $ valueAssets filteredACInput - - completeCheck = lovelaceCheck && filteredACCheck - + nonAdaTokenArbitrage = M.fromList $ filter ((/= 0) . snd) $ map (\ac -> (ac, valueAssetClass filteredACOutput ac - valueAssetClass filteredACInput ac)) $ toList assetsToConsider + filteredACCheck = all (> 0) $ M.elems nonAdaTokenArbitrage -- Note that we have already filtered for zero values. + logDebug "Inside notLosingTokensCheck" + lovelaceCheck <- + if all currencyIsLovelace oapFilter + then do + logDebug "Currency of all order asset pairs is lovelace." + pure (outputLovelace >= inputLovelace) + else case mpp of + Nothing -> do + logDebug "No price provider found." + pure $ inputLovelace - outputLovelace <= fees -- Ideally, we should be including flat taker fee here as well since matching algorithm is agnostic of current DEX requirement. + Just pp -> do + logDebug $ "AssetInfos: " ++ show assetInfos + logDebug $ "nonAdaTokenArbitrage: " ++ show nonAdaTokenArbitrage + let tokensWithInfos = M.restrictKeys assetInfos (M.keysSet nonAdaTokenArbitrage) + logDebug $ "TokensWithInfos: " ++ show tokensWithInfos + accLovelace <- do + priceInfos <- case pp of + MaestroPriceProvider mpp -> do + foldlM' + ( \acc (ac, assetInfo) -> do + res <- getLovelacePriceOfAssetMaestro mpp ac assetInfo + pure $! M.insert ac res acc + ) + (M.empty :: Map GYAssetClass (Either PricesProviderException Rational)) + $ M.toList tokensWithInfos + TapToolsPriceProvider tpp -> do + getLovelacePriceOfAssetsTapTools tpp tokensWithInfos + logDebug $ "PriceInfos: " ++ show priceInfos + foldlM' + ( \accLovelace (ac, amt) -> do + if not (M.member ac assetInfos) + then do + logWarn $ "AssetInfo not found for: " ++ show ac + pure accLovelace + else do + let lovelacePriceOfAssetE = priceInfos M.! ac + case lovelacePriceOfAssetE of + Left e -> do + logErr $ "Failed to get lovelace price of asset: " ++ show ac ++ ", with error: " ++ show e + pure accLovelace + Right lovelacePriceOfAsset -> do + pure $ accLovelace + floor (lovelacePriceOfAsset * fromIntegral amt) + ) + 0 + $ M.toList nonAdaTokenArbitrage + logDebug $ "AccLovelace: " ++ show accLovelace + logDebug $ "Fees: " ++ show fees + logDebug $ "InputLovelace: " ++ show inputLovelace + logDebug $ "OutputLovelace: " ++ show outputLovelace + pure $ outputLovelace + accLovelace >= inputLovelace + let completeCheck = lovelaceCheck && filteredACCheck unless lovelaceCheck $ logWarn $ unwords @@ -416,12 +574,96 @@ totalSellOrders = foldrOrders (const (+ 1)) 0 . sellOrders totalBuyOrders :: OrderBook -> Int totalBuyOrders = foldrOrders (const (+ 1)) 0 . buyOrders -matchingsPerOrderAssetPair :: [OrderAssetPair] -> [MatchResult] -> M.Map OrderAssetPair Int +matchingsPerOrderAssetPair :: [OrderAssetPair] -> [MatchResult] -> Map OrderAssetPair Int matchingsPerOrderAssetPair oaps = foldl' succOAP (M.fromList $ map (,0) oaps) where - succOAP :: M.Map OrderAssetPair Int -> MatchResult -> M.Map OrderAssetPair Int + succOAP :: Map OrderAssetPair Int -> MatchResult -> Map OrderAssetPair Int succOAP m (OrderExecutionInfo _ oi : _) = M.insertWith (+) (assetInfo oi) 1 m succOAP m _ = m runGYTxMonadNodeParallelWithStrategy :: GYCoinSelectionStrategy -> GYNetworkId -> GYProviders -> [GYAddress] -> GYAddress -> Maybe (GYTxOutRef, Bool) -> GYTxBuilderMonadIO [GYTxSkeleton v] -> IO GYTxBuildResult runGYTxMonadNodeParallelWithStrategy strat nid providers addrs change collateral act = runGYTxBuilderMonadIO nid providers addrs change collateral $ act >>= buildTxBodyParallelWithStrategy strat + +foldlM' :: (Foldable t, Monad m) => (b -> a -> m b) -> b -> t a -> m b +foldlM' f = foldlM (\ !acc -> f acc) + +data MaestroPriceException = MaestroApiError !Text !Maestro.MaestroError + deriving stock Show + deriving anyclass Exception + +data TapToolsPriceException + = TapToolsApiError !Text !TapToolsException + | TapToolsOtherError !Text !Text + deriving stock (Eq, Show) + deriving anyclass Exception + +data PricesProviderException + = PPMaestroErr MaestroPriceException + | PPTapToolsErr TapToolsPriceException + deriving stock Show + +instance Exception PricesProviderException where + displayException (PPMaestroErr err) = "Maestro fail: " ++ displayException err + displayException (PPTapToolsErr err) = "TapTools fail: " ++ displayException err + +throwMspvApiError :: Text -> Maestro.MaestroError -> IO a +throwMspvApiError locationInfo = + throwIO . MaestroApiError locationInfo + +handleMaestroError :: Text -> Either Maestro.MaestroError a -> IO a +handleMaestroError locationInfo = either (throwMspvApiError locationInfo) pure + +handleMaestroSourceFail :: MaestroPriceException -> IO (Either PricesProviderException a) +handleMaestroSourceFail = pure . Left . PPMaestroErr + +-- | Assumption: None of the tokens is ADA. +getLovelacePriceOfAssetsTapTools :: TapToolsPP -> Map GYAssetClass AssetInfo -> IO (Map GYAssetClass (Either PricesProviderException Rational)) +getLovelacePriceOfAssetsTapTools (TapToolsPP {..}) assetInfos = do + let units :: [TapToolsUnit] = coerce $ M.keys assetInfos + adaPrecision :: Int = 6 -- We cast to @Int@ so as to handle overflows when performing subtraction later. + priceInfosE <- try $ tapToolsPrices ttppEnv units + case priceInfosE of + Left (e :: TapToolsException) -> + pure $ M.map (\_ -> Left (PPTapToolsErr $ TapToolsApiError functionLocationIdent e)) assetInfos + Right priceInfos -> + pure $ + M.mapWithKey + ( \ac _ -> do + let unit :: TapToolsUnit = coerce ac + case M.lookup unit priceInfos of + Nothing -> Left $ PPTapToolsErr $ TapToolsOtherError functionLocationIdent ("Price not found for given unit: " <> toUrlPiece unit) + Just price -> do + let AssetInfo {..} = assetInfos M.! ac + tokenPrecision :: Int = fromIntegral assetDecimals + precisionDiff = 10 ** fromIntegral (adaPrecision - tokenPrecision) + adjustedPrice = price * precisionDiff + Right . toRational $ adjustedPrice + ) + assetInfos + where + functionLocationIdent = "getLovelacePriceOfAssetsTapTools" + +-- | Assumption: Provided token is not ADA. +getLovelacePriceOfAssetMaestro :: MaestroPP -> GYAssetClass -> AssetInfo -> IO (Either PricesProviderException Rational) +getLovelacePriceOfAssetMaestro MaestroPP {..} _ac AssetInfo {..} = do + handle handleMaestroSourceFail $ do + let pairName = "ADA-" <> assetTicker + pair = Maestro.TaggedText pairName + + ohlInfo <- + handleMaestroError (functionLocationIdent <> " - fetching price from pair") <=< try $ + -- NOTE: Should set limit parameter to 1? + Maestro.pricesFromDex mppEnv mppDex pair (Just mppResolution) Nothing Nothing Nothing (Just Maestro.Descending) + + let info = head ohlInfo + adaPrecision :: Int = 6 -- We cast to @Int@ so as to handle overflows when performing subtraction later. + tokenPrecision :: Int = fromIntegral assetDecimals + precisionDiff = 10 ** fromIntegral (adaPrecision - tokenPrecision) + + price = Maestro.ohlcCandleInfoCoinAClose info + + adjustedPrice = price * precisionDiff + + return . Right . toRational $ adjustedPrice + where + functionLocationIdent = "getLovelacePriceOfAssetMaestro" diff --git a/geniusyield-orderbot-framework/src/GeniusYield/OrderBot/OrderBotConfig.hs b/geniusyield-orderbot-framework/src/GeniusYield/OrderBot/OrderBotConfig.hs index 288d1d6..ec49766 100644 --- a/geniusyield-orderbot-framework/src/GeniusYield/OrderBot/OrderBotConfig.hs +++ b/geniusyield-orderbot-framework/src/GeniusYield/OrderBot/OrderBotConfig.hs @@ -29,9 +29,12 @@ import Data.Aeson ( import qualified Data.Aeson.Types as Aeson import Data.Bifunctor (first) import Data.List (nub) +import Data.Map.Strict (Map) +import Data.Maybe (fromMaybe) import Data.Random (sample, shuffle) import Data.String (IsString (..)) import qualified Data.Vector as V +import Data.Word (Word64) import GHC.Generics (Generic) import GeniusYield.OrderBot import GeniusYield.OrderBot.MatchingStrategy (MatchResult) @@ -79,13 +82,18 @@ data OrderBotConfig , botCRandomizeMatchesFound :: Bool -- ^ A boolean that dictates whether the bot chooses the tx to submit at -- random (to decrease collisions), or not (to maximize profit) + , botCLovelaceWarningThreshold :: Maybe Natural + -- ^ If bot's lovelace balance falls below this value, bot would log warning logs. + , botCPriceProvider :: Maybe PriceProviderConfig + -- ^ Price provider used to get ADA value of a token + , botCTokenInfos :: Maybe (Map GYAssetClass AssetInfo) + -- ^ Token registry information. Since prices given by provider are usually in display units, we need information such as registered decimal places to know lovelace value per indivisible token unit. } - deriving stock (Show, Eq, Generic) + deriving stock (Show, Generic) instance FromEnv OrderBotConfig where fromEnv _ = - OrderBotConfig - <$> (Right . parseCBORSKey <$> env "BOTC_SKEY") + (OrderBotConfig . Right . parseCBORSKey <$> env "BOTC_SKEY") <*> (fmap fromString <$> envMaybe "BOTC_STAKE_ADDRESS") <*> (fmap fromString <$> envMaybe "BOTC_COLLATERAL") <*> envWithMsg ("Invalid Strategy. Must be one of: " ++ show allStrategies) "BOTC_EXECUTION_STRAT" @@ -94,6 +102,10 @@ instance FromEnv OrderBotConfig where <*> envIntWithMsg "BOTC_MAX_ORDERS_MATCHES" <*> envIntWithMsg "BOTC_MAX_TXS_PER_ITERATION" <*> envWithMsg "Must be either 'True' or 'False'" "BOTC_RANDOMIZE_MATCHES_FOUND" + -- Apparently, there is no `Var` instance for `Natural` in `System.Envy`. + <*> (fmap (fromIntegral @Word64 @Natural) <$> envMaybe "BOTC_LOVELACE_WARNING_THRESHOLD") + <*> (fmap forceFromJson <$> envMaybe "BOTC_PRICE_PROVIDER") + <*> (fmap forceFromJson <$> envMaybe "BOTC_TOKEN_INFOS") where parseCBORSKey :: String -> GYPaymentSigningKey parseCBORSKey s = @@ -107,6 +119,9 @@ instance FromEnv OrderBotConfig where eitherDecodeStrict (fromString s) >>= Aeson.parseEither parseScanTokenPairs + forceFromJson :: FromJSON a => String -> a + forceFromJson = either error id . eitherDecodeStrict . fromString + envIntWithMsg :: Var a => String -> Parser a envIntWithMsg = envWithMsg "Not a number" @@ -115,8 +130,7 @@ envWithMsg msg name = maybe (throwError $ unwords ["Error parsing enviroment var instance FromJSON OrderBotConfig where parseJSON (Object obj) = - OrderBotConfig - <$> (Left <$> obj .: "signingKeyFP") + (OrderBotConfig . Left <$> (obj .: "signingKeyFP")) <*> obj .:? "stakeAddress" <*> obj .:? "collateral" <*> obj .: "strategy" @@ -125,6 +139,9 @@ instance FromJSON OrderBotConfig where <*> obj .: "maxOrderMatches" <*> obj .: "maxTxsPerIteration" <*> obj .: "randomizeMatchesFound" + <*> obj .:? "lovelaceWarningThreshold" + <*> obj .:? "priceProvider" + <*> obj .:? "tokenInfos" parseJSON _ = fail "Expecting object value" parseScanTokenPairs :: Value -> Aeson.Parser [OrderAssetPair] @@ -141,45 +158,37 @@ parseObjectTokenPair = withObject "OrderAssetPair" $ \v -> -- | Given a vanilla order bot configuration, builds a complete order bot setup. buildOrderBot :: OrderBotConfig -> IO OrderBot -buildOrderBot - OrderBotConfig - { botCSkey - , botCStakeAddress - , botCCollateral - , botCExecutionStrat - , botCAssetFilter - , botCRescanDelay - , botCMaxOrderMatches - , botCMaxTxsPerIteration - , botCRandomizeMatchesFound - } = do - skey <- either readPaymentSigningKey return botCSkey - maxOrderMatch <- intToNatural "Max Order matches amount" botCMaxOrderMatches - maxTxPerIter <- intToNatural "Max Tx per iteration" botCMaxTxsPerIteration - oneEquivalentAssetPair <- - if hasNoneEquivalentAssetPair botCAssetFilter - then return $ nub botCAssetFilter - else throwIO $ userError "Can't have equivalent order asset pairs scanTokens" - return $ - OrderBot - { botSkey = skey - , botStakeAddress = botCStakeAddress - , botCollateral = buildCollateral - , botExecutionStrat = - MultiAssetTraverse $ mkIndependentStrategy botCExecutionStrat maxOrderMatch - , botAssetPairFilter = nub oneEquivalentAssetPair - , botRescanDelay = botCRescanDelay - , botTakeMatches = takeMatches botCRandomizeMatchesFound maxTxPerIter - } - where - buildCollateral :: Maybe (GYTxOutRef, Bool) - buildCollateral = (,False) <$> botCCollateral - - hasNoneEquivalentAssetPair :: [OrderAssetPair] -> Bool - hasNoneEquivalentAssetPair [] = True - hasNoneEquivalentAssetPair (oap : oaps) = - not (any (equivalentAssetPair oap) oaps) - && hasNoneEquivalentAssetPair oaps +buildOrderBot OrderBotConfig {..} = do + skey <- either readPaymentSigningKey return botCSkey + maxOrderMatch <- intToNatural "Max Order matches amount" botCMaxOrderMatches + maxTxPerIter <- intToNatural "Max Tx per iteration" botCMaxTxsPerIteration + oneEquivalentAssetPair <- + if hasNoneEquivalentAssetPair botCAssetFilter + then return $ nub botCAssetFilter + else throwIO $ userError "Can't have equivalent order asset pairs scanTokens" + return $ + OrderBot + { botSkey = skey + , botStakeAddress = botCStakeAddress + , botCollateral = buildCollateral + , botExecutionStrat = + MultiAssetTraverse $ mkIndependentStrategy botCExecutionStrat maxOrderMatch + , botAssetPairFilter = nub oneEquivalentAssetPair + , botRescanDelay = botCRescanDelay + , botTakeMatches = takeMatches botCRandomizeMatchesFound maxTxPerIter + , botLovelaceWarningThreshold = botCLovelaceWarningThreshold + , botPriceProvider = botCPriceProvider + , botTokenInfos = fromMaybe mempty botCTokenInfos + } + where + buildCollateral :: Maybe (GYTxOutRef, Bool) + buildCollateral = (,False) <$> botCCollateral + + hasNoneEquivalentAssetPair :: [OrderAssetPair] -> Bool + hasNoneEquivalentAssetPair [] = True + hasNoneEquivalentAssetPair (oap : oaps) = + not (any (equivalentAssetPair oap) oaps) + && hasNoneEquivalentAssetPair oaps readBotConfig :: Maybe FilePath -> IO OrderBotConfig readBotConfig = diff --git a/geniusyield-orderbot.cabal b/geniusyield-orderbot.cabal index 790c38f..bbfef07 100644 --- a/geniusyield-orderbot.cabal +++ b/geniusyield-orderbot.cabal @@ -1,4 +1,4 @@ -cabal-version: 3.4 +cabal-version: 3.12 name: geniusyield-orderbot version: 0.2.0 synopsis: Smart Order Router