class EtherDeltaMarketMakerKeeper: """Keeper acting as a market maker on EtherDelta, on the ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='etherdelta-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--etherdelta-address", type=str, required=True, help="Ethereum address of the EtherDelta contract") parser.add_argument( "--etherdelta-socket", type=str, required=True, help="Ethereum address of the EtherDelta API socket") parser.add_argument( "--etherdelta-number-of-attempts", type=int, default=3, help= "Number of attempts of running the tool to talk to the EtherDelta API socket" ) parser.add_argument( "--etherdelta-retry-interval", type=int, default=10, help= "Retry interval for sending orders over the EtherDelta API socket") parser.add_argument( "--etherdelta-timeout", type=int, default=120, help="Timeout for sending orders over the EtherDelta API socket") parser.add_argument("--config", type=str, required=True, help="Buy/sell bands configuration file") parser.add_argument( "--price-feed", type=str, help= "Source of price feed. Tub price feed will be used if not specified" ) parser.add_argument( "--price-feed-expiry", type=int, default=120, help="Maximum age of non-Tub price feed (in seconds, default: 120)" ) parser.add_argument("--order-age", type=int, required=True, help="Age of created orders (in blocks)") parser.add_argument( "--order-expiry-threshold", type=int, default=0, help= "Remaining order age (in blocks) at which order is considered already expired, which" " means the keeper will send a new replacement order slightly ahead" ) parser.add_argument( "--order-no-cancel-threshold", type=int, default=0, help= "Remaining order age (in blocks) below which keeper does not try to cancel orders," " assuming that they will probably expire before the cancel transaction gets mined" ) parser.add_argument( "--eth-reserve", type=float, required=True, help= "Amount of ETH which will never be deposited so the keeper can cover gas" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help= "Minimum ETH balance below which keeper with either terminate or not start at all" ) parser.add_argument( "--min-eth-deposit", type=float, required=True, help= "Minimum amount of ETH that can be deposited in one transaction") parser.add_argument( "--min-sai-deposit", type=float, required=True, help= "Minimum amount of SAI that can be deposited in one transaction") parser.add_argument( '--cancel-on-shutdown', dest='cancel_on_shutdown', action='store_true', help= "Whether should cancel all open orders on EtherDelta on keeper shutdown" ) parser.add_argument( '--withdraw-on-shutdown', dest='withdraw_on_shutdown', action='store_true', help= "Whether should withdraw all tokens from EtherDelta on keeper shutdown" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--gas-price-increase", type=int, help="Gas price increase (in Wei) if no confirmation within" " `--gas-price-increase-every` seconds") parser.add_argument( "--gas-price-increase-every", type=int, default=120, help="Gas price increase frequency (in seconds, default: 120)") parser.add_argument("--gas-price-max", type=int, help="Maximum gas price (in Wei)") parser.add_argument("--gas-price-file", type=str, help="Gas price configuration file") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") parser.set_defaults(cancel_on_shutdown=False, withdraw_on_shutdown=False) self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.vox = Vox(web3=self.web3, address=self.tub.vox()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) logging.getLogger('requests.packages.urllib3.connectionpool').setLevel( logging.INFO) self.bands_config = ReloadableConfig(self.arguments.config) self.eth_reserve = Wad.from_number(self.arguments.eth_reserve) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.min_eth_deposit = Wad.from_number(self.arguments.min_eth_deposit) self.min_sai_deposit = Wad.from_number(self.arguments.min_sai_deposit) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments.price_feed, self.arguments.price_feed_expiry, self.tub, self.vox) if self.eth_reserve <= self.min_eth_balance: raise Exception( "--eth-reserve must be higher than --min-eth-balance") assert (self.arguments.order_expiry_threshold >= 0) assert (self.arguments.order_no_cancel_threshold >= self.arguments.order_expiry_threshold) self.etherdelta = EtherDelta(web3=self.web3, address=Address( self.arguments.etherdelta_address)) self.etherdelta_api = EtherDeltaApi( client_tool_directory="lib/pymaker/utils/etherdelta-client", client_tool_command="node main.js", api_server=self.arguments.etherdelta_socket, number_of_attempts=self.arguments.etherdelta_number_of_attempts, retry_interval=self.arguments.etherdelta_retry_interval, timeout=self.arguments.etherdelta_timeout) self.our_orders = list() def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): if self.arguments.cancel_on_shutdown: self.cancel_all_orders() if self.arguments.withdraw_on_shutdown: self.withdraw_everything() def approve(self): """Approve EtherDelta to access our tokens, so we can deposit them with the exchange""" token_addresses = filter( lambda address: address != EtherDelta.ETH_TOKEN, [self.token_sell(), self.token_buy()]) tokens = list( map(lambda address: ERC20Token(web3=self.web3, address=address), token_addresses)) self.etherdelta.approve(tokens, directly(gas_price=self.gas_price)) def place_order(self, order: Order): self.our_orders.append(order) self.etherdelta_api.publish_order(order) def price(self) -> Wad: return self.price_feed.get_price() def token_sell(self) -> Address: return EtherDelta.ETH_TOKEN def token_buy(self) -> Address: return self.sai.address def our_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return self.etherdelta.balance_of(self.our_address) else: return self.etherdelta.balance_of_token(token, self.our_address) def our_sell_orders(self): return list( filter( lambda order: order.buy_token == self.token_buy() and order. pay_token == self.token_sell(), self.our_orders)) def our_buy_orders(self): return list( filter( lambda order: order.buy_token == self.token_sell() and order. pay_token == self.token_buy(), self.our_orders)) def synchronize_orders(self): # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning( "Keeper ETH balance below minimum. Cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config) block_number = self.web3.eth.blockNumber target_price = self.price() # If the is no target price feed, cancel all orders but do not terminate the keeper. # The moment the price feed comes back, the keeper will resume placing orders. if target_price is None: self.logger.warning( "Cancelling all orders as no price feed available.") self.cancel_all_orders() return # Remove expired orders from the local order list self.remove_expired_orders(block_number) # Cancel orders cancellable_orders = bands.cancellable_orders(self.our_buy_orders(), self.our_sell_orders(), target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders, block_number) return # Place new orders self.top_up_bands(bands.buy_bands, bands.sell_bands, target_price) @staticmethod def is_order_age_above_threshold(order: Order, block_number: int, threshold: int): return block_number >= order.expires - threshold # we do >= 0, which makes us effectively detect an order # as expired one block earlier than the contract, but # this is desirable from the keeper point of view def is_expired(self, order: Order, block_number: int): return self.is_order_age_above_threshold( order, block_number, self.arguments.order_expiry_threshold) def is_non_cancellable(self, order: Order, block_number: int): return self.is_order_age_above_threshold( order, block_number, self.arguments.order_no_cancel_threshold) def remove_expired_orders(self, block_number: int): self.our_orders = list( filter(lambda order: not self.is_expired(order, block_number), self.our_orders)) def cancel_orders(self, orders: Iterable, block_number: int): """Cancel orders asynchronously.""" cancellable_orders = list( filter( lambda order: not self.is_non_cancellable(order, block_number), orders)) synchronize([ self.etherdelta.cancel_order(order).transact_async( gas_price=self.gas_price) for order in cancellable_orders ]) self.our_orders = list(set(self.our_orders) - set(cancellable_orders)) def cancel_all_orders(self): """Cancel all our orders.""" self.cancel_orders(self.our_orders, self.web3.eth.blockNumber) def withdraw_everything(self): eth_balance = self.etherdelta.balance_of(self.our_address) if eth_balance > Wad(0): self.etherdelta.withdraw(eth_balance).transact( gas_price=self.gas_price) sai_balance = self.etherdelta.balance_of_token(self.sai.address, self.our_address) if sai_balance > Wad(0): self.etherdelta.withdraw_token(self.sai.address, sai_balance).transact() def top_up_bands(self, buy_bands: list, sell_bands: list, target_price: Wad): """Create new buy and sell orders in all send and buy bands if necessary.""" self.top_up_buy_bands(buy_bands, target_price) self.top_up_sell_bands(sell_bands, target_price) def top_up_sell_bands(self, sell_bands: list, target_price: Wad): """Ensure our sell engagement is not below minimum in all sell bands. Place new orders if necessary.""" our_balance = self.our_balance(self.token_sell()) for band in sell_bands: orders = [ order for order in self.our_sell_orders() if band.includes(order, target_price) ] total_amount = self.total_amount(orders) if total_amount < band.min_amount: if self.deposit_for_sell_order_if_needed(band.avg_amount - total_amount): return price = band.avg_price(target_price) pay_amount = self.fix_amount( Wad.min( band.avg_amount - total_amount, our_balance - self.total_amount(self.our_sell_orders()))) buy_amount = self.fix_amount(pay_amount * price) if (pay_amount >= band.dust_cutoff) and ( pay_amount > Wad(0)) and (buy_amount > Wad(0)): self.logger.debug( f"Using price {price} for new sell order") order = self.etherdelta.create_order( pay_token=self.token_sell(), pay_amount=pay_amount, buy_token=self.token_buy(), buy_amount=buy_amount, expires=self.web3.eth.blockNumber + self.arguments.order_age) self.place_order(order) def top_up_buy_bands(self, buy_bands: list, target_price: Wad): """Ensure our buy engagement is not below minimum in all buy bands. Place new orders if necessary.""" our_balance = self.our_balance(self.token_buy()) for band in buy_bands: orders = [ order for order in self.our_buy_orders() if band.includes(order, target_price) ] total_amount = self.total_amount(orders) if total_amount < band.min_amount: if self.deposit_for_buy_order_if_needed(band.avg_amount - total_amount): return price = band.avg_price(target_price) pay_amount = self.fix_amount( Wad.min( band.avg_amount - total_amount, our_balance - self.total_amount(self.our_buy_orders()))) buy_amount = self.fix_amount(pay_amount / price) if (pay_amount >= band.dust_cutoff) and ( pay_amount > Wad(0)) and (buy_amount > Wad(0)): self.logger.debug(f"Using price {price} for new buy order") order = self.etherdelta.create_order( pay_token=self.token_buy(), pay_amount=pay_amount, buy_token=self.token_sell(), buy_amount=buy_amount, expires=self.web3.eth.blockNumber + self.arguments.order_age) self.place_order(order) def depositable_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return Wad.max( eth_balance(self.web3, self.our_address) - self.eth_reserve, Wad(0)) else: return ERC20Token(web3=self.web3, address=token).balance_of(self.our_address) def deposit_for_sell_order_if_needed(self, desired_order_pay_amount: Wad): if self.our_balance(self.token_sell()) < desired_order_pay_amount: return self.deposit_for_sell_order() else: return False def deposit_for_sell_order(self): depositable_eth = self.depositable_balance(self.token_sell()) if depositable_eth > self.min_eth_deposit: return self.etherdelta.deposit(depositable_eth).transact( gas_price=self.gas_price).successful else: return False def deposit_for_buy_order_if_needed(self, desired_order_pay_amount: Wad): if self.our_balance(self.token_buy()) < desired_order_pay_amount: return self.deposit_for_buy_order() else: return False def deposit_for_buy_order(self): depositable_sai = self.depositable_balance(self.token_buy()) if depositable_sai > self.min_sai_deposit: return self.etherdelta.deposit_token( self.sai.address, depositable_sai).transact(gas_price=self.gas_price).successful else: return False def total_amount(self, orders): return reduce(operator.add, map(lambda order: order.remaining_sell_amount, orders), Wad(0)) @staticmethod def fix_amount(amount: Wad) -> Wad: # for some reason, the EtherDelta backend rejects offchain orders with some amounts # for example, the following order: # self.etherdelta.place_order_offchain(self.sai.address, Wad(93033469375510291122), # EtherDelta.ETH_TOKEN, Wad(400000000000000000), # self.web3.eth.blockNumber + 50) # will get placed correctly, but if we substitute 93033469375510291122 for 93033469375510237227 # the backend will not accept it. this is 100% reproductible with above amounts, # although I wasn't able to figure out the actual reason # # what I have noticed is that rounding the amount seems to help, # so this is what this particular method does return Wad(int(amount.value / 10**9) * 10**9)
class OasisMarketMakerKeeper: """Keeper acting as a market maker on OasisDEX, on the W-ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='oasis-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--oasis-address", type=str, required=True, help="Ethereum address of the OasisDEX contract") parser.add_argument("--config", type=str, required=True, help="Bands configuration file") parser.add_argument("--price-feed", type=str, required=True, help="Source of price feed") parser.add_argument( "--price-feed-expiry", type=int, default=120, help="Maximum age of the price feed (in seconds, default: 120)") parser.add_argument( "--round-places", type=int, default=2, help="Number of decimal places to round order prices to (default=2)" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help="Minimum ETH balance below which keeper will cease operation") parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) setup_logging(self.arguments) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.otc = MatchingMarket(web3=self.web3, address=Address( self.arguments.oasis_address)) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.bands_config = ReloadableConfig(self.arguments.config) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments.price_feed, self.arguments.price_feed_expiry, self.tub) self.order_book_manager = OrderBookManager(refresh_frequency=3) self.order_book_manager.get_orders_with(lambda: self.our_orders()) self.order_book_manager.start() def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.on_block) lifecycle.every(3, self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): self.cancel_all_orders() def on_block(self): # This method is present only so the lifecycle binds the new block listener, which makes # it then terminate the keeper if no new blocks have been arriving for 300 seconds. pass def approve(self): """Approve OasisDEX to access our balances, so we can place orders.""" self.otc.approve( [self.token_sell(), self.token_buy()], directly(gas_price=self.gas_price)) def price(self) -> Wad: return self.price_feed.get_price() def token_sell(self) -> ERC20Token: return self.gem def token_buy(self) -> ERC20Token: return self.sai def our_available_balance(self, token: ERC20Token) -> Wad: return token.balance_of(self.our_address) def our_orders(self): return list( filter( lambda order: order.maker == self.our_address, self.otc.get_orders(self.token_sell().address, self.token_buy().address) + self.otc.get_orders(self.token_buy().address, self.token_sell().address))) def our_sell_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.token_buy().address and order.pay_token == self.token_sell().address, our_orders)) def our_buy_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.token_sell().address and order.pay_token == self.token_buy().address, our_orders)) def synchronize_orders(self): # If market is closed, cancel all orders but do not terminate the keeper. if self.otc.is_closed(): self.logger.warning("Market is closed. Cancelling all orders.") self.cancel_all_orders() return # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning( "Keeper ETH balance below minimum. Cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config) order_book = self.order_book_manager.get_order_book() target_price = self.price() # If the is no target price feed, cancel all orders but do not terminate the keeper. # The moment the price feed comes back, the keeper will resume placing orders. if target_price is None: self.logger.warning( "No price feed available. Cancelling all orders.") self.cancel_all_orders() return # If there are any orders to be cancelled, cancel them. It is deliberate that we wait with topping-up # bands until the next block. This way we would create new orders based on the most recent price and # order book state. We could theoretically retrieve both (`target_price` and `our_orders`) again here, # but it just seems cleaner to do it in one place instead of in two. cancellable_orders = bands.cancellable_orders( our_buy_orders=self.our_buy_orders(order_book.orders), our_sell_orders=self.our_sell_orders(order_book.orders), target_price=target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders) return # Do not place new orders if order book state is not confirmed if order_book.orders_being_placed or order_book.orders_being_cancelled: self.logger.debug( "Order book is in progress, not placing new orders") return # Place new orders self.create_orders( bands.new_orders( our_buy_orders=self.our_buy_orders(order_book.orders), our_sell_orders=self.our_sell_orders(order_book.orders), our_buy_balance=self.our_available_balance(self.token_buy()), our_sell_balance=self.our_available_balance(self.token_sell()), target_price=target_price)[0]) def cancel_all_orders(self): # Wait for the order book to stabilize while True: order_book = self.order_book_manager.get_order_book() if not order_book.orders_being_cancelled and not order_book.orders_being_placed: break # Cancel all open orders self.cancel_orders(self.order_book_manager.get_order_book().orders) self.order_book_manager.wait_for_order_cancellation() def cancel_orders(self, orders): for order in orders: self.order_book_manager.cancel_order( order.order_id, lambda: self.otc.kill(order.order_id).transact( gas_price=self.gas_price).successful) def create_orders(self, new_orders): def place_order_function(new_order: NewOrder): assert (isinstance(new_order, NewOrder)) if new_order.is_sell: pay_token = self.token_sell().address buy_token = self.token_buy().address else: pay_token = self.token_buy().address buy_token = self.token_sell().address transact = self.otc.make(pay_token=pay_token, pay_amount=new_order.pay_amount, buy_token=buy_token, buy_amount=new_order.buy_amount).transact( gas_price=self.gas_price) if transact is not None and transact.successful and transact.result is not None: return Order(market=self.otc, order_id=transact.result, maker=self.our_address, pay_token=pay_token, pay_amount=new_order.pay_amount, buy_token=buy_token, buy_amount=new_order.buy_amount, timestamp=0) else: return None for new_order in new_orders: self.order_book_manager.place_order( lambda: place_order_function(new_order))
class ArbitrageKeeper: """Keeper to arbitrage on OasisDEX, `join`, `exit`, `boom` and `bust`.""" logger = logging.getLogger('arbitrage-keeper') def __init__(self, args, **kwargs): parser = argparse.ArgumentParser("arbitrage-keeper") parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument( "--eth-key", type=str, nargs='*', help= "Ethereum private key(s) to use (e.g. 'key_file=aaa.json,pass_file=aaa.pass')" ) parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--tap-address", type=str, required=True, help="Ethereum address of the Tap contract") parser.add_argument( "--exchange-address", type=str, help="Ethereum address of the 0x Exchange contract") parser.add_argument("--oasis-address", type=str, required=True, help="Ethereum address of the OasisDEX contract") parser.add_argument( "--oasis-support-address", type=str, required=False, help="Ethereum address of the OasisDEX support contract") parser.add_argument("--relayer-api-server", type=str, help="Address of the 0x Relayer API") parser.add_argument( "--relayer-per-page", type=int, default=100, help= "Number of orders to fetch per one page from the 0x Relayer API (default: 100)" ) parser.add_argument( "--tx-manager", type=str, help= "Ethereum address of the TxManager contract to use for multi-step arbitrage" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price in Wei (default: node default)") parser.add_argument( "--base-token", type=str, required=True, help="The token all arbitrage sequences will start and end with") parser.add_argument( "--min-profit", type=float, required=True, help="Minimum profit (in base token) from one arbitrage operation") parser.add_argument( "--max-engagement", type=float, required=True, help="Maximum engagement (in base token) in one arbitrage operation" ) parser.add_argument( "--max-errors", type=int, default=100, help= "Maximum number of allowed errors before the keeper terminates (default: 100)" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from register_keys(self.web3, self.arguments.eth_key) self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.tap = Tap(web3=self.web3, address=Address(self.arguments.tap_address)) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.skr = ERC20Token(web3=self.web3, address=self.tub.skr()) self.zrx_exchange = ZrxExchange(web3=self.web3, address=Address(self.arguments.exchange_address)) \ if self.arguments.exchange_address is not None else None self.zrx_relayer_api = ZrxRelayerApi(exchange=self.zrx_exchange, api_server=self.arguments.relayer_api_server) \ if self.arguments.relayer_api_server is not None else None self.otc = MatchingMarket( web3=self.web3, address=Address(self.arguments.oasis_address), support_address=Address(self.arguments.oasis_support_address) if self.arguments.oasis_support_address is not None else None) self.base_token = ERC20Token(web3=self.web3, address=Address( self.arguments.base_token)) self.min_profit = Wad.from_number(self.arguments.min_profit) self.max_engagement = Wad.from_number(self.arguments.max_engagement) self.max_errors = self.arguments.max_errors self.errors = 0 if self.arguments.tx_manager: self.tx_manager = TxManager(web3=self.web3, address=Address( self.arguments.tx_manager)) if self.tx_manager.owner() != self.our_address: raise Exception( f"The TxManager has to be owned by the address the keeper is operating from." ) else: self.tx_manager = None logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) def main(self): with Lifecycle(self.web3) as lifecycle: self.lifecycle = lifecycle lifecycle.on_startup(self.startup) lifecycle.on_block(self.process_block) def startup(self): self.approve() def approve(self): """Approve all components that need to access our balances""" approval_method = via_tx_manager(self.tx_manager, gas_price=self.gas_price()) if self.tx_manager \ else directly(gas_price=self.gas_price()) self.tub.approve(approval_method) self.tap.approve(approval_method) self.otc.approve([self.gem, self.sai, self.skr], approval_method) if self.zrx_exchange: self.zrx_exchange.approve([self.gem, self.sai], approval_method) if self.tx_manager: self.tx_manager.approve([self.gem, self.sai, self.skr], directly(gas_price=self.gas_price())) def token_name(self, address: Address) -> str: if address == self.sai.address: return "DAI" elif address == self.gem.address: return "WETH" elif address == self.skr.address: return "PETH" else: return str(address) def tub_conversions(self) -> List[Conversion]: return [ TubJoinConversion(self.tub), TubExitConversion(self.tub), TubBoomConversion(self.tub, self.tap), TubBustConversion(self.tub, self.tap) ] def otc_orders(self, tokens): orders = [] for token1 in tokens: for token2 in tokens: if token1 != token2: orders = orders + self.otc.get_orders(token1, token2) return orders def otc_conversions(self, tokens) -> List[Conversion]: return list( map(lambda order: OasisTakeConversion(self.otc, order), self.otc_orders(tokens))) def zrx_orders(self, tokens): if self.zrx_exchange is None or self.zrx_relayer_api is None: return [] orders = [] for token1 in tokens: for token2 in tokens: if token1 != token2: orders = orders + self.zrx_relayer_api.get_orders( token1, token2) return list( filter(lambda order: order.expiration <= time.time(), orders)) def zrx_conversions(self, tokens) -> List[Conversion]: return list( map(lambda order: ZrxFillOrderConversion(self.zrx_exchange, order), self.zrx_orders(tokens))) def all_conversions(self): return self.tub_conversions() + \ self.otc_conversions([self.sai.address, self.skr.address, self.gem.address]) + \ self.zrx_conversions([self.sai.address, self.gem.address]) def process_block(self): """Callback called on each new block. If too many errors, terminate the keeper to minimize potential damage.""" if self.errors >= self.max_errors: self.lifecycle.terminate() else: self.execute_best_opportunity_available() def execute_best_opportunity_available(self): """Find the best arbitrage opportunity present and execute it.""" opportunity = self.best_opportunity(self.profitable_opportunities()) if opportunity: self.print_opportunity(opportunity) self.execute_opportunity(opportunity) def profitable_opportunities(self): """Identify all profitable arbitrage opportunities within given limits.""" entry_amount = Wad.min(self.base_token.balance_of(self.our_address), self.max_engagement) opportunity_finder = OpportunityFinder( conversions=self.all_conversions()) opportunities = opportunity_finder.find_opportunities( self.base_token.address, entry_amount) opportunities = filter( lambda op: op.total_rate() > Ray.from_number(1.000001), opportunities) opportunities = filter( lambda op: op.profit(self.base_token.address) > self.min_profit, opportunities) opportunities = sorted( opportunities, key=lambda op: op.profit(self.base_token.address), reverse=True) return opportunities def best_opportunity(self, opportunities: List[Sequence]): """Pick the best opportunity, or return None if no profitable opportunities.""" return opportunities[0] if len(opportunities) > 0 else None def print_opportunity(self, opportunity: Sequence): """Print the details of the opportunity.""" self.logger.info( f"Opportunity with id={opportunity.id()}," f" profit={opportunity.profit(self.base_token.address)} {self.token_name(self.base_token.address)}" ) for index, conversion in enumerate(opportunity.steps, start=1): self.logger.info( f"Step {index}/{len(opportunity.steps)}: {conversion.name()}" f" (from {conversion.source_amount} {self.token_name(conversion.source_token)}" f" to {conversion.target_amount} {self.token_name(conversion.target_token)})" ) def execute_opportunity(self, opportunity: Sequence): """Execute the opportunity either in one Ethereum transaction or step-by-step. Depending on whether `tx_manager` is available.""" if self.tx_manager: self.execute_opportunity_in_one_transaction(opportunity) else: self.execute_opportunity_step_by_step(opportunity) def execute_opportunity_step_by_step(self, opportunity: Sequence): """Execute the opportunity step-by-step.""" def incoming_transfer(our_address: Address): return lambda transfer: transfer.to_address == our_address def outgoing_transfer(our_address: Address): return lambda transfer: transfer.from_address == our_address all_transfers = [] for step in opportunity.steps: receipt = step.transact().transact(gas_price=self.gas_price()) if receipt: all_transfers += receipt.transfers outgoing = TransferFormatter().format( filter(outgoing_transfer(self.our_address), receipt.transfers), self.token_name) incoming = TransferFormatter().format( filter(incoming_transfer(self.our_address), receipt.transfers), self.token_name) self.logger.info(f"Exchanged {outgoing} to {incoming}") else: self.errors += 1 return self.logger.info( f"The profit we made is {TransferFormatter().format_net(all_transfers, self.our_address, self.token_name)}" ) def execute_opportunity_in_one_transaction(self, opportunity: Sequence): """Execute the opportunity in one transaction, using the `tx_manager`.""" tokens = [self.sai.address, self.skr.address, self.gem.address] invocations = list( map(lambda step: step.transact().invocation(), opportunity.steps)) receipt = self.tx_manager.execute( tokens, invocations).transact(gas_price=self.gas_price()) if receipt: self.logger.info( f"The profit we made is {TransferFormatter().format_net(receipt.transfers, self.our_address, self.token_name)}" ) else: self.errors += 1 def gas_price(self): if self.arguments.gas_price > 0: return FixedGasPrice(self.arguments.gas_price) else: return DefaultGasPrice()
class IdexMarketMakerKeeper: """Keeper acting as a market maker on IDEX, on the ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='idex-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument("--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--idex-address", type=str, required=True, help="Ethereum address of the IDEX contract") parser.add_argument("--idex-api-server", type=str, default='https://api.idex.market', help="Address of the IDEX API server (default: 'https://api.idex.market')") parser.add_argument("--idex-timeout", type=float, default=9.5, help="Timeout for accessing the IDEX API (in seconds, default: 9.5)") parser.add_argument("--config", type=str, required=True, help="Bands configuration file") parser.add_argument("--price-feed", type=str, required=True, help="Source of price feed") parser.add_argument("--price-feed-expiry", type=int, default=120, help="Maximum age of the price feed (in seconds, default: 120)") parser.add_argument("--eth-reserve", type=float, required=True, help="Amount of ETH which will never be deposited so the keeper can cover gas") parser.add_argument("--min-eth-balance", type=float, default=0, help="Minimum ETH balance below which keeper will cease operation") parser.add_argument("--min-eth-deposit", type=float, required=True, help="Minimum amount of ETH that can be deposited in one transaction") parser.add_argument("--min-sai-deposit", type=float, required=True, help="Minimum amount of SAI that can be deposited in one transaction") parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument("--smart-gas-price", dest='smart_gas_price', action='store_true', help="Use smart gas pricing strategy, based on the ethgasstation.info feed") parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") parser.set_defaults(cancel_on_shutdown=False, withdraw_on_shutdown=False) self.arguments = parser.parse_args(args) setup_logging(self.arguments) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3(HTTPProvider(endpoint_uri=f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.bands_config = ReloadableConfig(self.arguments.config) self.eth_reserve = Wad.from_number(self.arguments.eth_reserve) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.min_eth_deposit = Wad.from_number(self.arguments.min_eth_deposit) self.min_sai_deposit = Wad.from_number(self.arguments.min_sai_deposit) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed(self.arguments.price_feed, self.arguments.price_feed_expiry, self.tub) if self.eth_reserve <= self.min_eth_balance: raise Exception("--eth-reserve must be higher than --min-eth-balance") self.history = History() self.idex = IDEX(self.web3, Address(self.arguments.idex_address)) self.idex_api = IDEXApi(self.idex, self.arguments.idex_api_server, self.arguments.idex_timeout) def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): self.cancel_all_orders() def approve(self): """Approve IEEX to access our tokens, so we can deposit them with the exchange""" token_addresses = filter(lambda address: address != IDEX.ETH_TOKEN, [self.token_sell(), self.token_buy()]) tokens = list(map(lambda address: ERC20Token(web3=self.web3, address=address), token_addresses)) self.idex.approve(tokens, directly(gas_price=self.gas_price)) def pair(self): # IDEX is inconsistent here. They call the pair `DAI_ETH`, but in reality all prices are # calculated like it was an `ETH/DAI` pair. return 'DAI_ETH' def token_sell(self) -> Address: return IDEX.ETH_TOKEN def token_buy(self) -> Address: return self.sai.address def our_balances(self): return self.idex_api.get_balances() def our_available_balance(self, our_balances, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: try: return Wad.from_number(our_balances['ETH']['available']) except KeyError: return Wad(0) elif token == self.sai.address: try: return Wad.from_number(our_balances['DAI']['available']) except KeyError: return Wad(0) else: raise Exception("Unknown token") def our_orders(self) -> list: return self.idex_api.get_orders(self.pair()) def our_sell_orders(self, our_orders: list): return list(filter(lambda order: order.is_sell, our_orders)) def our_buy_orders(self, our_orders: list): return list(filter(lambda order: not order.is_sell, our_orders)) def synchronize_orders(self): # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning(f"Keeper ETH balance below minimum, cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config, self.history) our_balances = self.our_balances() our_orders = self.our_orders() target_price = self.price_feed.get_price() # If the is no target price feed, cancel all orders but do not terminate the keeper. # The moment the price feed comes back, the keeper will resume placing orders. if target_price is None: self.logger.warning("Cancelling all orders as no price feed available.") self.cancel_all_orders() return # Cancel orders cancellable_orders = bands.cancellable_orders(our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), target_price=target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders) return # If we detect that our total balance reported by the API is not equal to the # total balance reported by the Ethereum contract, it probably means that some # deposits are still pending being credited to our account. In this case # we also do not create any new orders, but at the same time existing orders # can still be cancelled. if not self.balances_match(our_balances): self.logger.info("Balances do not match, probably deposits are in progress, waiting.") return # Evaluate if we need to create new orders, and how much do we need to deposit new_orders, missing_buy_amount, missing_sell_amount = bands.new_orders(our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), our_buy_balance=self.our_available_balance(our_balances, self.token_buy()), our_sell_balance=self.our_available_balance(our_balances, self.token_sell()), target_price=target_price) # If deposited amount too low for placing buy orders, try to deposit. # If deposited amount too low for placing sell orders, try to deposit. made_deposit = False if missing_buy_amount > Wad(0): if self.deposit_for_buy_order(missing_buy_amount): made_deposit = True if missing_sell_amount > Wad(0): if missing_sell_amount > Wad(0): if self.deposit_for_sell_order(missing_sell_amount): made_deposit = True # If we managed to deposit something, do not do anything so we can reevaluate new orders to be placed. # Otherwise, place new orders. if not made_deposit: self.place_orders(new_orders) def cancel_orders(self, orders: list): for order in orders: self.idex_api.cancel_order(order) def cancel_all_orders(self): self.cancel_orders(self.our_orders()) def place_orders(self, new_orders): for new_order in new_orders: if new_order.is_sell: self.idex_api.place_order(pay_token=self.token_sell(), pay_amount=new_order.pay_amount, buy_token=self.token_buy(), buy_amount=new_order.buy_amount) else: self.idex_api.place_order(pay_token=self.token_buy(), pay_amount=new_order.pay_amount, buy_token=self.token_sell(), buy_amount=new_order.buy_amount) def deposit_for_sell_order(self, missing_sell_amount: Wad): # We always want to deposit at least `min_eth_deposit`. If `missing_sell_amount` is less # than that, we deposit `min_eth_deposit` anyway. if Wad(0) < missing_sell_amount < self.min_eth_deposit: missing_sell_amount = self.min_eth_deposit # We can never deposit more than our available ETH balance minus `eth_reserve` (reserve for gas). depositable_eth = Wad.max(eth_balance(self.web3, self.our_address) - self.eth_reserve, Wad(0)) missing_sell_amount = Wad.min(missing_sell_amount, depositable_eth) # If we still can deposit something, and it's at least `min_eth_deposit`, then we do deposit. if missing_sell_amount > Wad(0) and missing_sell_amount >= self.min_eth_deposit: receipt = self.idex.deposit(missing_sell_amount).transact(gas_price=self.gas_price) return receipt is not None and receipt.successful else: return False def deposit_for_buy_order(self, missing_buy_amount: Wad): # We always want to deposit at least `min_sai_deposit`. If `missing_buy_amount` is less # than that, we deposit `min_sai_deposit` anyway. if Wad(0) < missing_buy_amount < self.min_sai_deposit: missing_buy_amount = self.min_sai_deposit # We can never deposit more than our available SAI balance. depositable_sai = self.sai.balance_of(self.our_address) missing_buy_amount = Wad.min(missing_buy_amount, depositable_sai) # If we still can deposit something, and it's at least `min_sai_deposit`, then we do deposit. if missing_buy_amount > Wad(0) and missing_buy_amount >= self.min_sai_deposit: receipt = self.idex.deposit_token(self.sai.address, missing_buy_amount).transact(gas_price=self.gas_price) return receipt is not None and receipt.successful else: return False def balances_match(self, our_balances) -> bool: try: eth_available = Wad.from_number(our_balances['ETH']['available']) except KeyError: eth_available = Wad(0) try: eth_on_orders = Wad.from_number(our_balances['ETH']['onOrders']) except KeyError: eth_on_orders = Wad(0) try: dai_available = Wad.from_number(our_balances['DAI']['available']) except KeyError: dai_available = Wad(0) try: dai_on_orders = Wad.from_number(our_balances['DAI']['onOrders']) except KeyError: dai_on_orders = Wad(0) return self.idex.balance_of(self.our_address) == eth_available + eth_on_orders and \ self.idex.balance_of_token(self.sai.address, self.our_address) == dai_available + dai_on_orders
class RadarRelayMarketMakerKeeper: """Keeper acting as a market maker on RadarRelay, on the WETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='radarrelay-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument( "--exchange-address", type=str, required=True, help="Ethereum address of the 0x Exchange contract") parser.add_argument("--relayer-api-server", type=str, required=True, help="Address of the 0x Relayer API") parser.add_argument("--config", type=str, required=True, help="Buy/sell bands configuration file") parser.add_argument( "--price-feed", type=str, help= "Source of price feed. Tub price feed will be used if not specified" ) parser.add_argument( "--price-feed-expiry", type=int, default=120, help="Maximum age of non-Tub price feed (in seconds, default: 120)" ) parser.add_argument( "--order-expiry", type=int, required=True, help="Expiration time of created orders (in seconds)") parser.add_argument( "--order-expiry-threshold", type=int, default=0, help= "Order expiration time at which order is considered already expired (in seconds)" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help= "Minimum ETH balance below which keeper with either terminate or not start at all" ) parser.add_argument( '--cancel-on-shutdown', dest='cancel_on_shutdown', action='store_true', help= "Whether should cancel all open orders on RadarRelay on keeper shutdown" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--gas-price-increase", type=int, help="Gas price increase (in Wei) if no confirmation within" " `--gas-price-increase-every` seconds") parser.add_argument( "--gas-price-increase-every", type=int, default=120, help="Gas price increase frequency (in seconds, default: 120)") parser.add_argument("--gas-price-max", type=int, help="Maximum gas price (in Wei)") parser.add_argument("--gas-price-file", type=str, help="Gas price configuration file") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.vox = Vox(web3=self.web3, address=self.tub.vox()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) logging.getLogger('requests.packages.urllib3.connectionpool').setLevel( logging.INFO) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.bands_config = ReloadableConfig(self.arguments.config) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments.price_feed, self.arguments.price_feed_expiry, self.tub, self.vox) self.radar_relay = ZrxExchange(web3=self.web3, address=Address( self.arguments.exchange_address)) self.radar_relay_api = ZrxRelayerApi( exchange=self.radar_relay, api_server=self.arguments.relayer_api_server) def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.every(15, self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): if self.arguments.cancel_on_shutdown: self.cancel_orders(self.our_orders()) def approve(self): """Approve 0x to access our tokens, so we can sell it on the exchange.""" self.radar_relay.approve( [self.token_sell(), self.token_buy()], directly(gas_price=self.gas_price)) def price(self) -> Wad: return self.price_feed.get_price() def token_sell(self) -> ERC20Token: return self.gem def token_buy(self) -> ERC20Token: return self.sai def our_balance(self, token: ERC20Token) -> Wad: return token.balance_of(self.our_address) def our_orders(self) -> list: our_orders = self.radar_relay_api.get_orders_by_maker(self.our_address) current_timestamp = int(time.time()) our_orders = list( filter( lambda order: order.expiration > current_timestamp - self. arguments.order_expiry_threshold, our_orders)) our_orders = list( filter( lambda order: self.radar_relay.get_unavailable_buy_amount( order) < order.buy_amount, our_orders)) return our_orders def our_sell_orders(self, our_orders: list) -> list: return list( filter( lambda order: order.buy_token == self.token_buy().address and order.pay_token == self.token_sell().address, our_orders)) def our_buy_orders(self, our_orders: list) -> list: return list( filter( lambda order: order.buy_token == self.token_sell().address and order.pay_token == self.token_buy().address, our_orders)) def synchronize_orders(self): """Update our positions in the order book to reflect keeper parameters.""" if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning( "Keeper ETH balance below minimum. Cancelling all orders.") self.cancel_orders(self.our_orders()) return bands = Bands(self.bands_config) our_orders = self.our_orders() target_price = self.price() if target_price is None: self.logger.warning( "Cancelling all orders as no price feed available.") self.cancel_orders(our_orders) return # Cancel orders cancellable_orders = bands.cancellable_orders( our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), target_price=target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders) return # Place new orders self.create_orders( bands.new_orders( our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), our_buy_balance=self.our_balance(self.token_buy()), our_sell_balance=self.our_balance(self.token_sell()), target_price=target_price)) def cancel_orders(self, orders): """Cancel orders asynchronously.""" synchronize([ self.radar_relay.cancel_order(order).transact_async( gas_price=self.gas_price) for order in orders ]) def create_orders(self, orders): """Create and submit orders synchronously.""" for order in orders: pay_token = self.token_sell() if order.is_sell else self.token_buy( ) buy_token = self.token_buy() if order.is_sell else self.token_sell( ) order = self.radar_relay.create_order(pay_token=pay_token.address, pay_amount=order.pay_amount, buy_token=buy_token.address, buy_amount=order.buy_amount, expiration=int(time.time()) + self.arguments.order_expiry) order = self.radar_relay_api.calculate_fees(order) order = self.radar_relay.sign_order(order) self.radar_relay_api.submit_order(order)
class EtherDeltaMarketMakerKeeper: """Keeper acting as a market maker on EtherDelta, on the ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='etherdelta-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument( "--eth-key", type=str, nargs='*', help= "Ethereum private key(s) to use (e.g. 'key_file=aaa.json,pass_file=aaa.pass')" ) parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--etherdelta-address", type=str, required=True, help="Ethereum address of the EtherDelta contract") parser.add_argument( "--etherdelta-socket", type=str, required=True, help="Ethereum address of the EtherDelta API socket") parser.add_argument( "--etherdelta-number-of-attempts", type=int, default=3, help= "Number of attempts of running the tool to talk to the EtherDelta API socket" ) parser.add_argument( "--etherdelta-retry-interval", type=int, default=10, help= "Retry interval for sending orders over the EtherDelta API socket") parser.add_argument( "--etherdelta-timeout", type=int, default=120, help="Timeout for sending orders over the EtherDelta API socket") parser.add_argument("--config", type=str, required=True, help="Bands configuration file") parser.add_argument("--price-feed", type=str, required=True, help="Source of price feed") parser.add_argument( "--price-feed-expiry", type=int, default=120, help="Maximum age of the price feed (in seconds, default: 120)") parser.add_argument("--spread-feed", type=str, help="Source of spread feed") parser.add_argument( "--spread-feed-expiry", type=int, default=3600, help="Maximum age of the spread feed (in seconds, default: 3600)") parser.add_argument("--control-feed", type=str, help="Source of control feed") parser.add_argument( "--control-feed-expiry", type=int, default=86400, help="Maximum age of the control feed (in seconds, default: 86400)" ) parser.add_argument("--order-history", type=str, help="Endpoint to report active orders to") parser.add_argument( "--order-history-every", type=int, default=30, help= "Frequency of reporting active orders (in seconds, default: 30)") parser.add_argument("--order-age", type=int, required=True, help="Age of created orders (in blocks)") parser.add_argument( "--order-expiry-threshold", type=int, default=0, help= "Remaining order age (in blocks) at which order is considered already expired, which" " means the keeper will send a new replacement order slightly ahead" ) parser.add_argument( "--order-no-cancel-threshold", type=int, default=0, help= "Remaining order age (in blocks) below which keeper does not try to cancel orders," " assuming that they will probably expire before the cancel transaction gets mined" ) parser.add_argument( "--eth-reserve", type=float, required=True, help= "Amount of ETH which will never be deposited so the keeper can cover gas" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help="Minimum ETH balance below which keeper will cease operation") parser.add_argument( "--min-eth-deposit", type=float, required=True, help= "Minimum amount of ETH that can be deposited in one transaction") parser.add_argument( "--min-sai-deposit", type=float, required=True, help= "Minimum amount of SAI that can be deposited in one transaction") parser.add_argument( '--cancel-on-shutdown', dest='cancel_on_shutdown', action='store_true', help= "Whether should cancel all open orders on EtherDelta on keeper shutdown" ) parser.add_argument( '--withdraw-on-shutdown', dest='withdraw_on_shutdown', action='store_true', help= "Whether should withdraw all tokens from EtherDelta on keeper shutdown" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") parser.set_defaults(cancel_on_shutdown=False, withdraw_on_shutdown=False) self.arguments = parser.parse_args(args) setup_logging(self.arguments) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) register_keys(self.web3, self.arguments.eth_key) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.bands_config = ReloadableConfig(self.arguments.config) self.eth_reserve = Wad.from_number(self.arguments.eth_reserve) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.min_eth_deposit = Wad.from_number(self.arguments.min_eth_deposit) self.min_sai_deposit = Wad.from_number(self.arguments.min_sai_deposit) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments, self.tub) self.spread_feed = create_spread_feed(self.arguments) self.control_feed = create_control_feed(self.arguments) self.order_history_reporter = create_order_history_reporter( self.arguments) if self.eth_reserve <= self.min_eth_balance: raise Exception( "--eth-reserve must be higher than --min-eth-balance") assert (self.arguments.order_expiry_threshold >= 0) assert (self.arguments.order_no_cancel_threshold >= self.arguments.order_expiry_threshold) self.history = History() self.etherdelta = EtherDelta(web3=self.web3, address=Address( self.arguments.etherdelta_address)) self.etherdelta_api = EtherDeltaApi( client_tool_directory="lib/pymaker/utils/etherdelta-client", client_tool_command="node main.js", api_server=self.arguments.etherdelta_socket, number_of_attempts=self.arguments.etherdelta_number_of_attempts, retry_interval=self.arguments.etherdelta_retry_interval, timeout=self.arguments.etherdelta_timeout) self.our_orders = list() def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): if self.arguments.cancel_on_shutdown: self.cancel_all_orders() if self.arguments.withdraw_on_shutdown: self.withdraw_everything() def approve(self): token_addresses = filter( lambda address: address != EtherDelta.ETH_TOKEN, [self.token_sell(), self.token_buy()]) tokens = list( map(lambda address: ERC20Token(web3=self.web3, address=address), token_addresses)) self.etherdelta.approve(tokens, directly(gas_price=self.gas_price)) def place_order(self, order: Order): self.our_orders.append(order) self.etherdelta_api.publish_order(order) def token_sell(self) -> Address: return EtherDelta.ETH_TOKEN def token_buy(self) -> Address: return self.sai.address def our_total_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return self.etherdelta.balance_of(self.our_address) else: return self.etherdelta.balance_of_token(token, self.our_address) def our_sell_orders(self): return list( filter( lambda order: order.buy_token == self.token_buy() and order. pay_token == self.token_sell(), self.our_orders)) def our_buy_orders(self): return list( filter( lambda order: order.buy_token == self.token_sell() and order. pay_token == self.token_buy(), self.our_orders)) def synchronize_orders(self): # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. # # The exception is when we can withdraw some ETH from EtherDelta. Then we do it and carry on. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: if self.etherdelta.balance_of(self.our_address) > self.eth_reserve: self.logger.warning( f"Keeper ETH balance below minimum, withdrawing {self.eth_reserve}." ) self.etherdelta.withdraw(self.eth_reserve).transact() else: self.logger.warning( f"Keeper ETH balance below minimum, cannot withdraw. Cancelling all orders." ) self.cancel_all_orders() return bands = Bands.read(self.bands_config, self.spread_feed, self.control_feed, self.history) block_number = self.web3.eth.blockNumber target_price = self.price_feed.get_price() # Remove expired orders from the local order list self.remove_expired_orders(block_number) # Cancel orders cancellable_orders = bands.cancellable_orders(self.our_buy_orders(), self.our_sell_orders(), target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders, block_number) return # In case of EtherDelta, balances returned by `our_total_balance` still contain amounts "locked" # by currently open orders, so we need to explicitly subtract these amounts. our_buy_balance = self.our_total_balance( self.token_buy()) - Bands.total_amount(self.our_buy_orders()) our_sell_balance = self.our_total_balance( self.token_sell()) - Bands.total_amount(self.our_sell_orders()) # Evaluate if we need to create new orders, and how much do we need to deposit new_orders, missing_buy_amount, missing_sell_amount = bands.new_orders( our_buy_orders=self.our_buy_orders(), our_sell_orders=self.our_sell_orders(), our_buy_balance=our_buy_balance, our_sell_balance=our_sell_balance, target_price=target_price) # If deposited amount too low for placing buy orders, try to deposit. # If deposited amount too low for placing sell orders, try to deposit. made_deposit = False if missing_buy_amount > Wad(0): if self.deposit_for_buy_order(): made_deposit = True if missing_sell_amount > Wad(0): if self.deposit_for_sell_order(): made_deposit = True # If we managed to deposit something, do not do anything so we can reevaluate new orders to be created. # Otherwise, create new orders. if not made_deposit: self.place_orders(new_orders) @staticmethod def is_order_age_above_threshold(order: Order, block_number: int, threshold: int): return block_number >= order.expires - threshold # we do >= 0, which makes us effectively detect an order # as expired one block earlier than the contract, but # this is desirable from the keeper point of view def is_expired(self, order: Order, block_number: int): return self.is_order_age_above_threshold( order, block_number, self.arguments.order_expiry_threshold) def is_non_cancellable(self, order: Order, block_number: int): return self.is_order_age_above_threshold( order, block_number, self.arguments.order_no_cancel_threshold) def remove_expired_orders(self, block_number: int): self.our_orders = list( filter(lambda order: not self.is_expired(order, block_number), self.our_orders)) def cancel_orders(self, orders: Iterable, block_number: int): cancellable_orders = list( filter( lambda order: not self.is_non_cancellable(order, block_number), orders)) synchronize([ self.etherdelta.cancel_order(order).transact_async( gas_price=self.gas_price) for order in cancellable_orders ]) self.our_orders = list(set(self.our_orders) - set(cancellable_orders)) def cancel_all_orders(self): self.cancel_orders(self.our_orders, self.web3.eth.blockNumber) def place_orders(self, new_orders): # EtherDelta sometimes rejects orders when the amounts are not rounded. Choice of choosing # rounding to 9 decimal digits is completely arbitrary as it's not documented anywhere. for new_order in new_orders: if new_order.is_sell: order = self.etherdelta.create_order( pay_token=self.token_sell(), pay_amount=round(new_order.pay_amount, 9), buy_token=self.token_buy(), buy_amount=round(new_order.buy_amount, 9), expires=self.web3.eth.blockNumber + self.arguments.order_age) else: order = self.etherdelta.create_order( pay_token=self.token_buy(), pay_amount=round(new_order.pay_amount, 9), buy_token=self.token_sell(), buy_amount=round(new_order.buy_amount, 9), expires=self.web3.eth.blockNumber + self.arguments.order_age) self.place_order(order) new_order.confirm() def withdraw_everything(self): eth_balance = self.etherdelta.balance_of(self.our_address) if eth_balance > Wad(0): self.etherdelta.withdraw(eth_balance).transact( gas_price=self.gas_price) sai_balance = self.etherdelta.balance_of_token(self.sai.address, self.our_address) if sai_balance > Wad(0): self.etherdelta.withdraw_token(self.sai.address, sai_balance).transact() def depositable_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return Wad.max( eth_balance(self.web3, self.our_address) - self.eth_reserve, Wad(0)) else: return ERC20Token(web3=self.web3, address=token).balance_of(self.our_address) def deposit_for_sell_order(self): depositable_eth = self.depositable_balance(self.token_sell()) if depositable_eth > self.min_eth_deposit: return self.etherdelta.deposit(depositable_eth).transact( gas_price=self.gas_price).successful else: return False def deposit_for_buy_order(self): depositable_sai = self.depositable_balance(self.token_buy()) if depositable_sai > self.min_sai_deposit: return self.etherdelta.deposit_token( self.token_buy(), depositable_sai).transact(gas_price=self.gas_price).successful else: return False
class DAIv1(Market): def __init__(self, web3, dai_tub = '0x448a5065aeBB8E423F0896E6c5D525C040f59af3'): self.web3 = web3 self.tub = Tub(web3=web3, address=Address(dai_tub)) self.tap = Tap(web3=web3, address=self.tub.tap()) self.tokens = { 'MKR': ERC20Token(web3, self.tub.gov()), 'PETH': ERC20Token(web3, self.tub.skr()), 'WETH': ERC20Token(web3, self.tub.gem()), 'DAI': ERC20Token(web3, self.tub.sai()), } def get_cup(self, cup_id): cup = self.tub.cups(cup_id) return { 'id': cup.cup_id, 'lad': cup.lad.address, 'art': float(cup.art), 'ink': float(cup.ink), 'safe': self.tub.safe(cup_id) } def get_cups(self): last_cup_id = self.tub.cupi() cups = map(self.get_cup, range(1, last_cup_id+1)) not_empty_cups = filter(lambda cup: cup['lad'] != "0x0000000000000000000000000000000000000000", cups) return list(not_empty_cups) def get_pairs(self): pairs = ['PETH/DAI', 'PETH/WETH'] return pairs def get_orders(self, base, quote): depth = {'bids': [], 'asks': []} # PETH/DAI order book if base == 'PETH' and quote == 'DAI': # boom is a taker using a bid side from maker tap # a taker convert PETH to DAI using tap.bid(1) as price # maker side offer DAI in exchange for PETH (flap) # DAI qty offered by is min(joy - woe, 0) order = { 'price': float(self.tap.bid(Wad.from_number(1))), 'amount': float(min(self.tap.joy() - self.tap.woe(), Wad.from_number(0))), 'id': 'take:tap.boom()', } if order['amount'] > 0: depth['bids'].append(order) # bust is a taker using ask side from maker tap # a taker convert DAI to PETH using tap.ask(1) as price # maker side offer PETH from fog (flip) and PETH minted to cover woe (flop) # PETH qty offered by maker is fog+min(woe-joy, 0)/ask order = { 'price': float(self.tap.ask(Wad.from_number(1))), 'amount': float(self.tap.fog() + min(self.tap.woe() - self.tap.joy(), Wad.from_number(0)) / self.tap.ask(Wad.from_number(1))), 'id': 'take:tap.bust()', } if order['amount'] > 0: depth['asks'].append(order) # PETH/WETH order book if base == 'PETH' and quote == 'WETH': # exit is a taker using a bid side from maker tub # a taker PETH to WETH using tub.bid(1) as price # maker side offer WETH in exchange for PETH # WETH qty offered by maker is infinity (2**32 as a large number for infinity ...) order = { 'price': float(self.tub.bid(Wad.from_number(1))), 'amount': float(2**32), 'id': 'take:tub.exit()', } depth['bids'].append(order) # join is a taker using ask side from maker tub # a taker convert WETH to PETH usgin tub.ask(1) as price # maker side offer PETH in exchange for WETH # PETH qty offered by maker is infinity (2**32 as a large number for infinity ...) order = { 'price': float(self.tub.ask(Wad.from_number(1))), 'amount': float(2**32), 'id': 'take:tub.join()', } depth['asks'].append(order) return depth def get_accounts(self, manager_url): accounts = {} for addr in requests.get(manager_url).json(): accounts[addr] = { 'balance' : self.web3.eth.getBalance(addr), } accounts[addr]['tokens'] = {} for name, token in self.tokens.items(): balance = token.balance_of(Address(addr)) allowance = token.allowance_of(Address(addr), self.tub.address) #TODO check tap allowance ... if float(allowance) or float(balance): accounts[addr]['tokens'][name] = { 'allowance': float(allowance), 'balance': float(balance), } return accounts
class IdexMarketMakerKeeper: """Keeper acting as a market maker on IDEX, on the ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='idex-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument("--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--idex-address", type=str, required=True, help="Ethereum address of the IDEX contract") parser.add_argument("--idex-api-server", type=str, default='https://api.idex.market', help="Address of the IDEX API server (default: 'https://api.idex.market')") parser.add_argument("--idex-timeout", type=float, default=9.5, help="Timeout for accessing the IDEX API (in seconds, default: 9.5)") parser.add_argument("--config", type=str, required=True, help="Bands configuration file") parser.add_argument("--price-feed", type=str, required=True, help="Source of price feed") parser.add_argument("--price-feed-expiry", type=int, default=120, help="Maximum age of the price feed (in seconds, default: 120)") parser.add_argument("--spread-feed", type=str, help="Source of spread feed") parser.add_argument("--spread-feed-expiry", type=int, default=3600, help="Maximum age of the spread feed (in seconds, default: 3600)") parser.add_argument("--order-history", type=str, help="Endpoint to report active orders to") parser.add_argument("--order-history-every", type=int, default=30, help="Frequency of reporting active orders (in seconds, default: 30)") parser.add_argument("--eth-reserve", type=float, required=True, help="Amount of ETH which will never be deposited so the keeper can cover gas") parser.add_argument("--min-eth-balance", type=float, default=0, help="Minimum ETH balance below which keeper will cease operation") parser.add_argument("--min-eth-deposit", type=float, required=True, help="Minimum amount of ETH that can be deposited in one transaction") parser.add_argument("--min-sai-deposit", type=float, required=True, help="Minimum amount of SAI that can be deposited in one transaction") parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument("--smart-gas-price", dest='smart_gas_price', action='store_true', help="Use smart gas pricing strategy, based on the ethgasstation.info feed") parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") parser.set_defaults(cancel_on_shutdown=False, withdraw_on_shutdown=False) self.arguments = parser.parse_args(args) setup_logging(self.arguments) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3(HTTPProvider(endpoint_uri=f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.bands_config = ReloadableConfig(self.arguments.config) self.eth_reserve = Wad.from_number(self.arguments.eth_reserve) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.min_eth_deposit = Wad.from_number(self.arguments.min_eth_deposit) self.min_sai_deposit = Wad.from_number(self.arguments.min_sai_deposit) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed(self.arguments, self.tub) self.spread_feed = create_spread_feed(self.arguments) self.order_history_reporter = create_order_history_reporter(self.arguments) if self.eth_reserve <= self.min_eth_balance: raise Exception("--eth-reserve must be higher than --min-eth-balance") self.history = History() self.idex = IDEX(self.web3, Address(self.arguments.idex_address)) self.idex_api = IDEXApi(self.idex, self.arguments.idex_api_server, self.arguments.idex_timeout) def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): self.cancel_all_orders() def approve(self): token_addresses = filter(lambda address: address != IDEX.ETH_TOKEN, [self.token_sell(), self.token_buy()]) tokens = list(map(lambda address: ERC20Token(web3=self.web3, address=address), token_addresses)) self.idex.approve(tokens, directly(gas_price=self.gas_price)) def pair(self): # IDEX is inconsistent here. They call the pair `DAI_ETH`, but in reality all prices are # calculated like it was an `ETH/DAI` pair. return 'DAI_ETH' def token_sell(self) -> Address: return IDEX.ETH_TOKEN def token_buy(self) -> Address: return self.sai.address def our_balances(self): return self.idex_api.get_balances() def our_available_balance(self, our_balances, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: try: return Wad.from_number(our_balances['ETH']['available']) except KeyError: return Wad(0) elif token == self.sai.address: try: return Wad.from_number(our_balances['DAI']['available']) except KeyError: return Wad(0) else: raise Exception("Unknown token") def our_orders(self) -> list: return self.idex_api.get_orders(self.pair()) def our_sell_orders(self, our_orders: list): return list(filter(lambda order: order.is_sell, our_orders)) def our_buy_orders(self, our_orders: list): return list(filter(lambda order: not order.is_sell, our_orders)) def synchronize_orders(self): # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning(f"Keeper ETH balance below minimum, cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config, self.spread_feed, self.history) our_balances = self.our_balances() our_orders = self.our_orders() target_price = self.price_feed.get_price() # Cancel orders cancellable_orders = bands.cancellable_orders(our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), target_price=target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders) return # If we detect that our total balance reported by the API is not equal to the # total balance reported by the Ethereum contract, it probably means that some # deposits are still pending being credited to our account. In this case # we also do not create any new orders, but at the same time existing orders # can still be cancelled. if not self.balances_match(our_balances): self.logger.info("Balances do not match, probably deposits are in progress, waiting.") return # Evaluate if we need to create new orders, and how much do we need to deposit new_orders, missing_buy_amount, missing_sell_amount = bands.new_orders(our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), our_buy_balance=self.our_available_balance(our_balances, self.token_buy()), our_sell_balance=self.our_available_balance(our_balances, self.token_sell()), target_price=target_price) # If deposited amount too low for placing buy orders, try to deposit. # If deposited amount too low for placing sell orders, try to deposit. made_deposit = False if missing_buy_amount > Wad(0): if self.deposit_for_buy_order(missing_buy_amount): made_deposit = True if missing_sell_amount > Wad(0): if missing_sell_amount > Wad(0): if self.deposit_for_sell_order(missing_sell_amount): made_deposit = True # If we managed to deposit something, do not do anything so we can reevaluate new orders to be placed. # Otherwise, place new orders. if not made_deposit: self.place_orders(new_orders) def cancel_orders(self, orders: list): for order in orders: self.idex_api.cancel_order(order) def cancel_all_orders(self): self.cancel_orders(self.our_orders()) def place_orders(self, new_orders): for new_order in new_orders: if new_order.is_sell: self.idex_api.place_order(pay_token=self.token_sell(), pay_amount=new_order.pay_amount, buy_token=self.token_buy(), buy_amount=new_order.buy_amount) else: self.idex_api.place_order(pay_token=self.token_buy(), pay_amount=new_order.pay_amount, buy_token=self.token_sell(), buy_amount=new_order.buy_amount) def deposit_for_sell_order(self, missing_sell_amount: Wad): # We always want to deposit at least `min_eth_deposit`. If `missing_sell_amount` is less # than that, we deposit `min_eth_deposit` anyway. if Wad(0) < missing_sell_amount < self.min_eth_deposit: missing_sell_amount = self.min_eth_deposit # We can never deposit more than our available ETH balance minus `eth_reserve` (reserve for gas). depositable_eth = Wad.max(eth_balance(self.web3, self.our_address) - self.eth_reserve, Wad(0)) missing_sell_amount = Wad.min(missing_sell_amount, depositable_eth) # If we still can deposit something, and it's at least `min_eth_deposit`, then we do deposit. if missing_sell_amount > Wad(0) and missing_sell_amount >= self.min_eth_deposit: receipt = self.idex.deposit(missing_sell_amount).transact(gas_price=self.gas_price) return receipt is not None and receipt.successful else: return False def deposit_for_buy_order(self, missing_buy_amount: Wad): # We always want to deposit at least `min_sai_deposit`. If `missing_buy_amount` is less # than that, we deposit `min_sai_deposit` anyway. if Wad(0) < missing_buy_amount < self.min_sai_deposit: missing_buy_amount = self.min_sai_deposit # We can never deposit more than our available SAI balance. depositable_sai = self.sai.balance_of(self.our_address) missing_buy_amount = Wad.min(missing_buy_amount, depositable_sai) # If we still can deposit something, and it's at least `min_sai_deposit`, then we do deposit. if missing_buy_amount > Wad(0) and missing_buy_amount >= self.min_sai_deposit: receipt = self.idex.deposit_token(self.sai.address, missing_buy_amount).transact(gas_price=self.gas_price) return receipt is not None and receipt.successful else: return False def balances_match(self, our_balances) -> bool: try: eth_available = Wad.from_number(our_balances['ETH']['available']) except KeyError: eth_available = Wad(0) try: eth_on_orders = Wad.from_number(our_balances['ETH']['onOrders']) except KeyError: eth_on_orders = Wad(0) try: dai_available = Wad.from_number(our_balances['DAI']['available']) except KeyError: dai_available = Wad(0) try: dai_on_orders = Wad.from_number(our_balances['DAI']['onOrders']) except KeyError: dai_on_orders = Wad(0) return self.idex.balance_of(self.our_address) == eth_available + eth_on_orders and \ self.idex.balance_of_token(self.sai.address, self.our_address) == dai_available + dai_on_orders
class OasisMarketMakerKeeper: """Keeper acting as a market maker on OasisDEX, on the W-ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='oasis-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--oasis-address", type=str, required=True, help="Ethereum address of the OasisDEX contract") parser.add_argument("--config", type=str, required=True, help="Buy/sell bands configuration file") parser.add_argument( "--price-feed", type=str, help= "Source of price feed. Tub price feed will be used if not specified" ) parser.add_argument( "--price-feed-expiry", type=int, default=120, help="Maximum age of non-Tub price feed (in seconds, default: 120)" ) parser.add_argument( "--round-places", type=int, default=2, help="Number of decimal places to round order prices to (default=2)" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help= "Minimum ETH balance below which keeper with either terminate or not start at all" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--gas-price-increase", type=int, help="Gas price increase (in Wei) if no confirmation within" " `--gas-price-increase-every` seconds") parser.add_argument( "--gas-price-increase-every", type=int, default=120, help="Gas price increase frequency (in seconds, default: 120)") parser.add_argument("--gas-price-max", type=int, help="Maximum gas price (in Wei)") parser.add_argument("--gas-price-file", type=str, help="Gas price configuration file") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.otc = MatchingMarket(web3=self.web3, address=Address( self.arguments.oasis_address)) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.vox = Vox(web3=self.web3, address=self.tub.vox()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO) logging.getLogger('requests.packages.urllib3.connectionpool').setLevel( logging.INFO) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.bands_config = ReloadableConfig(self.arguments.config) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments.price_feed, self.arguments.price_feed_expiry, self.tub, self.vox) def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.on_block) lifecycle.every(3, self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): self.cancel_all_orders() def on_block(self): # This method is present only so the lifecycle binds the new block listener, which makes # it then terminate the keeper if no new blocks have been arriving for 300 seconds. pass def approve(self): """Approve OasisDEX to access our balances, so we can place orders.""" self.otc.approve( [self.token_sell(), self.token_buy()], directly(gas_price=self.gas_price)) def price(self) -> Wad: return self.price_feed.get_price() def token_sell(self) -> ERC20Token: return self.gem def token_buy(self) -> ERC20Token: return self.sai def our_balance(self, token: ERC20Token) -> Wad: return token.balance_of(self.our_address) def our_orders(self): return self.otc.get_orders_by_maker(self.our_address) def our_sell_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.token_buy().address and order.pay_token == self.token_sell().address, our_orders)) def our_buy_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.token_sell().address and order.pay_token == self.token_buy().address, our_orders)) def synchronize_orders(self): # If market is closed, cancel all orders but do not terminate the keeper. if self.otc.is_closed(): self.logger.warning("Market is closed. Cancelling all orders.") self.cancel_all_orders() return # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning( "Keeper ETH balance below minimum. Cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config) our_orders = self.our_orders() target_price = self.price() # If the is no target price feed, cancel all orders but do not terminate the keeper. # The moment the price feed comes back, the keeper will resume placing orders. if target_price is None: self.logger.warning( "No price feed available. Cancelling all orders.") self.cancel_all_orders() return # If there are any orders to be cancelled, cancel them. It is deliberate that we wait with topping-up # bands until the next block. This way we would create new orders based on the most recent price and # order book state. We could theoretically retrieve both (`target_price` and `our_orders`) again here, # but it just seems cleaner to do it in one place instead of in two. cancellable_orders = bands.cancellable_orders( our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), target_price=target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders) return # If there are any new orders to be created, create them. new_orders = bands.new_orders( our_buy_orders=self.our_buy_orders(our_orders), our_sell_orders=self.our_sell_orders(our_orders), our_buy_balance=self.our_balance(self.token_buy()), our_sell_balance=self.our_balance(self.token_sell()), target_price=target_price) if len(new_orders) > 0: self.create_orders(new_orders) # We do wait some time after the orders have been created. The reason for that is sometimes # orders that have been just placed were not picked up by the next `our_orders()` call # (one can presume the block hasn't been fully imported into the node yet), which made # the keeper try to place same order(s) again. Of course the second transaction did fail, but it # resulted in wasted gas and significant delay in keeper operation. # # There is no specific reason behind choosing to wait exactly 3s. time.sleep(3) def cancel_all_orders(self): """Cancel all orders owned by the keeper.""" self.cancel_orders(self.our_orders()) def cancel_orders(self, orders): """Cancel orders asynchronously.""" synchronize([ self.otc.kill( order.order_id).transact_async(gas_price=self.gas_price) for order in orders ]) def create_orders(self, new_orders): """Create orders asynchronously.""" def to_transaction(new_order: NewOrder): assert (isinstance(new_order, NewOrder)) if new_order.is_sell: return self.otc.make(pay_token=self.token_sell().address, pay_amount=new_order.pay_amount, buy_token=self.token_buy().address, buy_amount=new_order.buy_amount) else: return self.otc.make(pay_token=self.token_buy().address, pay_amount=new_order.pay_amount, buy_token=self.token_sell().address, buy_amount=new_order.buy_amount) synchronize([ transaction.transact_async(gas_price=self.gas_price) for transaction in map(to_transaction, new_orders) ])
class EtherDeltaMarketMakerKeeper: """Keeper acting as a market maker on EtherDelta, on the ETH/SAI pair.""" logger = logging.getLogger() def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='etherdelta-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument("--rpc-timeout", type=int, default=10, help="JSON-RPC timeout (in seconds, default: 10)") parser.add_argument("--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--etherdelta-address", type=str, required=True, help="Ethereum address of the EtherDelta contract") parser.add_argument("--etherdelta-socket", type=str, required=True, help="Ethereum address of the EtherDelta API socket") parser.add_argument("--etherdelta-number-of-attempts", type=int, default=3, help="Number of attempts of running the tool to talk to the EtherDelta API socket") parser.add_argument("--etherdelta-retry-interval", type=int, default=10, help="Retry interval for sending orders over the EtherDelta API socket") parser.add_argument("--etherdelta-timeout", type=int, default=120, help="Timeout for sending orders over the EtherDelta API socket") parser.add_argument("--config", type=str, required=True, help="Bands configuration file") parser.add_argument("--price-feed", type=str, required=True, help="Source of price feed") parser.add_argument("--price-feed-expiry", type=int, default=120, help="Maximum age of the price feed (in seconds, default: 120)") parser.add_argument("--spread-feed", type=str, help="Source of spread feed") parser.add_argument("--spread-feed-expiry", type=int, default=3600, help="Maximum age of the spread feed (in seconds, default: 3600)") parser.add_argument("--order-history", type=str, help="Endpoint to report active orders to") parser.add_argument("--order-history-every", type=int, default=30, help="Frequency of reporting active orders (in seconds, default: 30)") parser.add_argument("--order-age", type=int, required=True, help="Age of created orders (in blocks)") parser.add_argument("--order-expiry-threshold", type=int, default=0, help="Remaining order age (in blocks) at which order is considered already expired, which" " means the keeper will send a new replacement order slightly ahead") parser.add_argument("--order-no-cancel-threshold", type=int, default=0, help="Remaining order age (in blocks) below which keeper does not try to cancel orders," " assuming that they will probably expire before the cancel transaction gets mined") parser.add_argument("--eth-reserve", type=float, required=True, help="Amount of ETH which will never be deposited so the keeper can cover gas") parser.add_argument("--min-eth-balance", type=float, default=0, help="Minimum ETH balance below which keeper will cease operation") parser.add_argument("--min-eth-deposit", type=float, required=True, help="Minimum amount of ETH that can be deposited in one transaction") parser.add_argument("--min-sai-deposit", type=float, required=True, help="Minimum amount of SAI that can be deposited in one transaction") parser.add_argument('--cancel-on-shutdown', dest='cancel_on_shutdown', action='store_true', help="Whether should cancel all open orders on EtherDelta on keeper shutdown") parser.add_argument('--withdraw-on-shutdown', dest='withdraw_on_shutdown', action='store_true', help="Whether should withdraw all tokens from EtherDelta on keeper shutdown") parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument("--smart-gas-price", dest='smart_gas_price', action='store_true', help="Use smart gas pricing strategy, based on the ethgasstation.info feed") parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") parser.set_defaults(cancel_on_shutdown=False, withdraw_on_shutdown=False) self.arguments = parser.parse_args(args) setup_logging(self.arguments) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3(HTTPProvider(endpoint_uri=f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}", request_kwargs={"timeout": self.arguments.rpc_timeout})) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) self.bands_config = ReloadableConfig(self.arguments.config) self.eth_reserve = Wad.from_number(self.arguments.eth_reserve) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.min_eth_deposit = Wad.from_number(self.arguments.min_eth_deposit) self.min_sai_deposit = Wad.from_number(self.arguments.min_sai_deposit) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed(self.arguments, self.tub) self.spread_feed = create_spread_feed(self.arguments) self.order_history_reporter = create_order_history_reporter(self.arguments) if self.eth_reserve <= self.min_eth_balance: raise Exception("--eth-reserve must be higher than --min-eth-balance") assert(self.arguments.order_expiry_threshold >= 0) assert(self.arguments.order_no_cancel_threshold >= self.arguments.order_expiry_threshold) self.history = History() self.etherdelta = EtherDelta(web3=self.web3, address=Address(self.arguments.etherdelta_address)) self.etherdelta_api = EtherDeltaApi(client_tool_directory="lib/pymaker/utils/etherdelta-client", client_tool_command="node main.js", api_server=self.arguments.etherdelta_socket, number_of_attempts=self.arguments.etherdelta_number_of_attempts, retry_interval=self.arguments.etherdelta_retry_interval, timeout=self.arguments.etherdelta_timeout) self.our_orders = list() def main(self): with Lifecycle(self.web3) as lifecycle: lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() @retry(delay=5, logger=logger) def shutdown(self): if self.arguments.cancel_on_shutdown: self.cancel_all_orders() if self.arguments.withdraw_on_shutdown: self.withdraw_everything() def approve(self): token_addresses = filter(lambda address: address != EtherDelta.ETH_TOKEN, [self.token_sell(), self.token_buy()]) tokens = list(map(lambda address: ERC20Token(web3=self.web3, address=address), token_addresses)) self.etherdelta.approve(tokens, directly(gas_price=self.gas_price)) def place_order(self, order: Order): self.our_orders.append(order) self.etherdelta_api.publish_order(order) def token_sell(self) -> Address: return EtherDelta.ETH_TOKEN def token_buy(self) -> Address: return self.sai.address def our_total_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return self.etherdelta.balance_of(self.our_address) else: return self.etherdelta.balance_of_token(token, self.our_address) def our_sell_orders(self): return list(filter(lambda order: order.buy_token == self.token_buy() and order.pay_token == self.token_sell(), self.our_orders)) def our_buy_orders(self): return list(filter(lambda order: order.buy_token == self.token_sell() and order.pay_token == self.token_buy(), self.our_orders)) def synchronize_orders(self): # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. # # The exception is when we can withdraw some ETH from EtherDelta. Then we do it and carry on. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: if self.etherdelta.balance_of(self.our_address) > self.eth_reserve: self.logger.warning(f"Keeper ETH balance below minimum, withdrawing {self.eth_reserve}.") self.etherdelta.withdraw(self.eth_reserve).transact() else: self.logger.warning(f"Keeper ETH balance below minimum, cannot withdraw. Cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config, self.spread_feed, self.history) block_number = self.web3.eth.blockNumber target_price = self.price_feed.get_price() # Remove expired orders from the local order list self.remove_expired_orders(block_number) # Cancel orders cancellable_orders = bands.cancellable_orders(self.our_buy_orders(), self.our_sell_orders(), target_price) if len(cancellable_orders) > 0: self.cancel_orders(cancellable_orders, block_number) return # In case of EtherDelta, balances returned by `our_total_balance` still contain amounts "locked" # by currently open orders, so we need to explicitly subtract these amounts. our_buy_balance = self.our_total_balance(self.token_buy()) - Bands.total_amount(self.our_buy_orders()) our_sell_balance = self.our_total_balance(self.token_sell()) - Bands.total_amount(self.our_sell_orders()) # Evaluate if we need to create new orders, and how much do we need to deposit new_orders, missing_buy_amount, missing_sell_amount = bands.new_orders(our_buy_orders=self.our_buy_orders(), our_sell_orders=self.our_sell_orders(), our_buy_balance=our_buy_balance, our_sell_balance=our_sell_balance, target_price=target_price) # If deposited amount too low for placing buy orders, try to deposit. # If deposited amount too low for placing sell orders, try to deposit. made_deposit = False if missing_buy_amount > Wad(0): if self.deposit_for_buy_order(): made_deposit = True if missing_sell_amount > Wad(0): if self.deposit_for_sell_order(): made_deposit = True # If we managed to deposit something, do not do anything so we can reevaluate new orders to be created. # Otherwise, create new orders. if not made_deposit: self.place_orders(new_orders) @staticmethod def is_order_age_above_threshold(order: Order, block_number: int, threshold: int): return block_number >= order.expires-threshold # we do >= 0, which makes us effectively detect an order # as expired one block earlier than the contract, but # this is desirable from the keeper point of view def is_expired(self, order: Order, block_number: int): return self.is_order_age_above_threshold(order, block_number, self.arguments.order_expiry_threshold) def is_non_cancellable(self, order: Order, block_number: int): return self.is_order_age_above_threshold(order, block_number, self.arguments.order_no_cancel_threshold) def remove_expired_orders(self, block_number: int): self.our_orders = list(filter(lambda order: not self.is_expired(order, block_number), self.our_orders)) def cancel_orders(self, orders: Iterable, block_number: int): cancellable_orders = list(filter(lambda order: not self.is_non_cancellable(order, block_number), orders)) synchronize([self.etherdelta.cancel_order(order).transact_async(gas_price=self.gas_price) for order in cancellable_orders]) self.our_orders = list(set(self.our_orders) - set(cancellable_orders)) def cancel_all_orders(self): self.cancel_orders(self.our_orders, self.web3.eth.blockNumber) def place_orders(self, new_orders): # EtherDelta sometimes rejects orders when the amounts are not rounded. Choice of choosing # rounding to 9 decimal digits is completely arbitrary as it's not documented anywhere. for new_order in new_orders: if new_order.is_sell: order = self.etherdelta.create_order(pay_token=self.token_sell(), pay_amount=round(new_order.pay_amount, 9), buy_token=self.token_buy(), buy_amount=round(new_order.buy_amount, 9), expires=self.web3.eth.blockNumber + self.arguments.order_age) else: order = self.etherdelta.create_order(pay_token=self.token_buy(), pay_amount=round(new_order.pay_amount, 9), buy_token=self.token_sell(), buy_amount=round(new_order.buy_amount, 9), expires=self.web3.eth.blockNumber + self.arguments.order_age) self.place_order(order) new_order.confirm() def withdraw_everything(self): eth_balance = self.etherdelta.balance_of(self.our_address) if eth_balance > Wad(0): self.etherdelta.withdraw(eth_balance).transact(gas_price=self.gas_price) sai_balance = self.etherdelta.balance_of_token(self.sai.address, self.our_address) if sai_balance > Wad(0): self.etherdelta.withdraw_token(self.sai.address, sai_balance).transact() def depositable_balance(self, token: Address) -> Wad: if token == EtherDelta.ETH_TOKEN: return Wad.max(eth_balance(self.web3, self.our_address) - self.eth_reserve, Wad(0)) else: return ERC20Token(web3=self.web3, address=token).balance_of(self.our_address) def deposit_for_sell_order(self): depositable_eth = self.depositable_balance(self.token_sell()) if depositable_eth > self.min_eth_deposit: return self.etherdelta.deposit(depositable_eth).transact(gas_price=self.gas_price).successful else: return False def deposit_for_buy_order(self): depositable_sai = self.depositable_balance(self.token_buy()) if depositable_sai > self.min_sai_deposit: return self.etherdelta.deposit_token(self.token_buy(), depositable_sai).transact(gas_price=self.gas_price).successful else: return False
class OasisMarketMakerKeeper: """Keeper acting as a market maker on OasisDEX, on the W-ETH/SAI pair.""" logger = logging.getLogger('oasis-market-maker-keeper') def __init__(self, args: list, **kwargs): parser = argparse.ArgumentParser(prog='oasis-market-maker-keeper') parser.add_argument("--rpc-host", type=str, default="localhost", help="JSON-RPC host (default: `localhost')") parser.add_argument("--rpc-port", type=int, default=8545, help="JSON-RPC port (default: `8545')") parser.add_argument( "--eth-from", type=str, required=True, help="Ethereum account from which to send transactions") parser.add_argument("--tub-address", type=str, required=True, help="Ethereum address of the Tub contract") parser.add_argument("--oasis-address", type=str, required=True, help="Ethereum address of the OasisDEX contract") parser.add_argument("--config", type=str, required=True, help="Buy/sell bands configuration file") parser.add_argument( "--price-feed", type=str, help= "Source of price feed. Tub price feed will be used if not specified" ) parser.add_argument( "--round-places", type=int, default=2, help="Number of decimal places to round order prices to (default=2)" ) parser.add_argument( "--min-eth-balance", type=float, default=0, help= "Minimum ETH balance below which keeper with either terminate or not start at all" ) parser.add_argument("--gas-price", type=int, default=0, help="Gas price (in Wei)") parser.add_argument( "--gas-price-increase", type=int, help="Gas price increase (in Wei) if no confirmation within" " `--gas-price-increase-every` seconds") parser.add_argument( "--gas-price-increase-every", type=int, default=120, help="Gas price increase frequency (in seconds, default: 120)") parser.add_argument("--gas-price-max", type=int, help="Maximum gas price (in Wei)") parser.add_argument("--gas-price-file", type=str, help="Gas price configuration file") parser.add_argument( "--smart-gas-price", dest='smart_gas_price', action='store_true', help= "Use smart gas pricing strategy, based on the ethgasstation.info feed" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = kwargs['web3'] if 'web3' in kwargs else Web3( HTTPProvider( endpoint_uri= f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}")) self.web3.eth.defaultAccount = self.arguments.eth_from self.our_address = Address(self.arguments.eth_from) self.otc = MatchingMarket(web3=self.web3, address=Address( self.arguments.oasis_address)) self.tub = Tub(web3=self.web3, address=Address(self.arguments.tub_address)) self.vox = Vox(web3=self.web3, address=self.tub.vox()) self.sai = ERC20Token(web3=self.web3, address=self.tub.sai()) self.gem = ERC20Token(web3=self.web3, address=self.tub.gem()) logging.basicConfig( format='%(asctime)-15s %(levelname)-8s %(message)s', level=(logging.DEBUG if self.arguments.debug else logging.INFO)) self.min_eth_balance = Wad.from_number(self.arguments.min_eth_balance) self.bands_config = ReloadableConfig(self.arguments.config) self.gas_price = GasPriceFactory().create_gas_price(self.arguments) self.price_feed = PriceFeedFactory().create_price_feed( self.arguments.price_feed, self.tub, self.vox) def main(self): with Web3Lifecycle(self.web3) as lifecycle: self.lifecycle = lifecycle lifecycle.initial_delay(10) lifecycle.on_startup(self.startup) lifecycle.on_block(self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): self.approve() def shutdown(self): self.cancel_all_orders() def approve(self): """Approve OasisDEX to access our balances, so we can place orders.""" self.otc.approve([self.gem, self.sai], directly(gas_price=self.gas_price)) def our_orders(self): return list( filter(lambda order: order.maker == self.our_address, self.otc.get_orders())) def our_sell_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.sai.address and order. pay_token == self.gem.address, our_orders)) def our_buy_orders(self, our_orders: list): return list( filter( lambda order: order.buy_token == self.gem.address and order. pay_token == self.sai.address, our_orders)) def synchronize_orders(self): # If market is closed, cancel all orders but do not terminate the keeper. if self.otc.is_closed(): self.logger.warning("Marked is closed. Cancelling all orders.") self.cancel_all_orders() return # If keeper balance is below `--min-eth-balance`, cancel all orders but do not terminate # the keeper, keep processing blocks as the moment the keeper gets a top-up it should # resume activity straight away, without the need to restart it. if eth_balance(self.web3, self.our_address) < self.min_eth_balance: self.logger.warning( "Keeper ETH balance below minimum. Cancelling all orders.") self.cancel_all_orders() return bands = Bands(self.bands_config) our_orders = self.our_orders() target_price = self.price_feed.get_price() # If the is no target price feed, cancel all orders but do not terminate the keeper. # The moment the price feed comes back, the keeper will resume placing orders. if target_price is None: self.logger.warning( "No price feed available. Cancelling all orders.") self.cancel_all_orders() return # If there are any orders to be cancelled, cancel them. It is deliberate that we wait with topping-up # bands until the next block. This way we would create new orders based on the most recent price and # order book state. We could theoretically retrieve both (`target_price` and `our_orders`) again here, # but it just seems cleaner to do it in one place instead of in two. orders_to_cancel = list( itertools.chain( bands.excessive_buy_orders(self.our_buy_orders(our_orders), target_price), bands.excessive_sell_orders(self.our_sell_orders(our_orders), target_price), bands.outside_orders(self.our_buy_orders(our_orders), self.our_sell_orders(our_orders), target_price))) if len(orders_to_cancel) > 0: self.cancel_orders(orders_to_cancel) else: self.top_up_bands(our_orders, bands.buy_bands, bands.sell_bands, target_price) # We do wait some time after the orders have been created. The reason for that is sometimes # orders that have been just placed were not picked up by the next `our_orders()` call # (one can presume the block hasn't been fully imported into the node yet), which made # the keeper try to place same order(s) again. Of course the second transaction did fail, but it # resulted in wasted gas and significant delay in keeper operation. # # There is no specific reason behind choosing to wait exactly 7s. time.sleep(7) def cancel_all_orders(self): """Cancel all orders owned by the keeper.""" self.cancel_orders(self.our_orders()) def cancel_orders(self, orders): """Cancel orders asynchronously.""" synchronize([ self.otc.kill( order.order_id).transact_async(gas_price=self.gas_price) for order in orders ]) def top_up_bands(self, our_orders: list, buy_bands: list, sell_bands: list, target_price: Wad): """Asynchronously create new buy and sell orders in all send and buy bands if necessary.""" synchronize([ transact.transact_async(gas_price=self.gas_price) for transact in itertools.chain( self.top_up_buy_bands(our_orders, buy_bands, target_price), self.top_up_sell_bands(our_orders, sell_bands, target_price)) ]) def top_up_sell_bands(self, our_orders: list, sell_bands: list, target_price: Wad): """Ensure our WETH engagement is not below minimum in all sell bands. Yield new orders if necessary.""" our_balance = self.gem.balance_of(self.our_address) for band in sell_bands: orders = [ order for order in self.our_sell_orders(our_orders) if band.includes(order, target_price) ] total_amount = self.total_amount(orders) if total_amount < band.min_amount: have_amount = Wad.min(band.avg_amount - total_amount, our_balance) want_amount = have_amount * round(band.avg_price(target_price), self.arguments.round_places) if (have_amount >= band.dust_cutoff) and ( have_amount > Wad(0)) and (want_amount > Wad(0)): our_balance = our_balance - have_amount yield self.otc.make(pay_token=self.gem.address, pay_amount=have_amount, buy_token=self.sai.address, buy_amount=want_amount) def top_up_buy_bands(self, our_orders: list, buy_bands: list, target_price: Wad): """Ensure our SAI engagement is not below minimum in all buy bands. Yield new orders if necessary.""" our_balance = self.sai.balance_of(self.our_address) for band in buy_bands: orders = [ order for order in self.our_buy_orders(our_orders) if band.includes(order, target_price) ] total_amount = self.total_amount(orders) if total_amount < band.min_amount: have_amount = Wad.min(band.avg_amount - total_amount, our_balance) want_amount = have_amount / round(band.avg_price(target_price), self.arguments.round_places) if (have_amount >= band.dust_cutoff) and ( have_amount > Wad(0)) and (want_amount > Wad(0)): our_balance = our_balance - have_amount yield self.otc.make(pay_token=self.sai.address, pay_amount=have_amount, buy_token=self.gem.address, buy_amount=want_amount) @staticmethod def total_amount(orders: List[Order]): return reduce(operator.add, map(lambda order: order.pay_amount, orders), Wad(0))