def test_should_use_new_spreads_even_if_config_not_changed(self, tmpdir): # given reloadable_config = ReloadableConfig(self.write_spread_importing_config(tmpdir)) reloadable_config.logger = MagicMock() # when spread_feed = { "buySpread": "0.1", "sellSpread": "1.0" } config = reloadable_config.get_config(spread_feed) # then assert config["usedBuySpread"] == 0.2 assert config["usedSellSpread"] == 3.0 # and # [a log message that the config was loaded gets generated] assert reloadable_config.logger.info.call_count == 1 # when spread_feed = { "buySpread": "0.2", "sellSpread": "0.5" } config = reloadable_config.get_config(spread_feed) # then assert config["usedBuySpread"] == 0.4 assert config["usedSellSpread"] == 1.5 # and # [no log message that the config was reloaded gets generated] # [as it was only parsed again] assert reloadable_config.logger.info.call_count == 1
def test_should_read_file_again_if_changed(self, tmpdir): # given reloadable_config = ReloadableConfig(self.write_advanced_config(tmpdir, "b")) reloadable_config.logger = MagicMock() # when config = reloadable_config.get_config({}) # then assert config["a"] == "b" # and # [a log message that the config was loaded gets generated] assert reloadable_config.logger.info.call_count == 1 # when self.write_advanced_config(tmpdir, "z") config = reloadable_config.get_config({}) # then assert config["a"] == "z" # and # [a log message that the config was reloaded gets generated] assert reloadable_config.logger.info.call_count == 2
def test_should_use_new_spreads_even_if_config_not_changed(self, tmpdir): # given reloadable_config = ReloadableConfig(self.write_spread_importing_config(tmpdir)) reloadable_config.logger = MagicMock() # when spread_feed = { "buySpread": "0.1", "sellSpread": "1.0" } config = reloadable_config.get_config(spread_feed) # then assert config["usedBuySpread"] == 0.2 assert config["usedSellSpread"] == 3.0 # and # [a log message that the config was loaded gets generated] assert reloadable_config.logger.info.call_count == 1 # when spread_feed = { "buySpread": "0.2", "sellSpread": "0.5" } config = reloadable_config.get_config(spread_feed) # then assert config["usedBuySpread"] == 0.4 assert config["usedSellSpread"] == 1.5 # and # [a log message that the config was reloaded gets generated] assert reloadable_config.logger.info.call_count == 2
def read(reloadable_config: ReloadableConfig, spread_feed: Feed, history: History): assert (isinstance(reloadable_config, ReloadableConfig)) assert (isinstance(history, History)) try: config = reloadable_config.get_config(spread_feed.get()[0]) buy_bands = list(map(BuyBand, config['buyBands'])) buy_limits = SideLimits( config['buyLimits'] if 'buyLimits' in config else [], history.buy_history) sell_bands = list(map(SellBand, config['sellBands'])) sell_limits = SideLimits( config['sellLimits'] if 'sellLimits' in config else [], history.sell_history) except Exception as e: logging.getLogger().warning( f"Config file is invalid ({e}). Treating the config file as it has no bands." ) buy_bands = [] buy_limits = SideLimits([], history.buy_history) sell_bands = [] sell_limits = SideLimits([], history.buy_history) return Bands(buy_bands=buy_bands, buy_limits=buy_limits, sell_bands=sell_bands, sell_limits=sell_limits)
class GasPriceFile(GasPrice): """Gas price configuration dynamically reloadable from a file. It is roughly an equivalent of implementation of :py:class:`pymaker.gas.IncreasingGasPrice`, but it uses `ReloadableConfig` to read the gas parameters from a file, and will dynamically reload that file whenever it changes. It allows to update the gas price dynamically for running keepers. Attributes: filename: Filename of the configuration file. """ def __init__(self, filename: str): assert(isinstance(filename, str)) self.reloadable_config = ReloadableConfig(filename) def get_gas_price(self, time_elapsed: int) -> Optional[int]: assert(isinstance(time_elapsed, int)) config = self.reloadable_config.get_config() gas_price = config.get('gasPrice', None) gas_price_increase = config.get('gasPriceIncrease', None) gas_price_increase_every = config.get('gasPriceIncreaseEvery', None) gas_price_max = config.get('gasPriceMax', None) if gas_price is not None: if gas_price_increase and gas_price_increase_every: strategy = IncreasingGasPrice(gas_price, gas_price_increase, gas_price_increase_every, gas_price_max) else: strategy = FixedGasPrice(gas_price) else: strategy = DefaultGasPrice() return strategy.get_gas_price(time_elapsed=time_elapsed)
def test_should_read_file_again_if_changed(self, tmpdir): # given reloadable_config = ReloadableConfig(self.write_advanced_config(tmpdir, "b")) # when config = reloadable_config.get_config({}) # then assert config["a"] == "b" # when self.write_advanced_config(tmpdir, "z") config = reloadable_config.get_config({}) # then assert config["a"] == "z"
def test_should_read_file_again_if_changed(self, tmpdir): # given reloadable_config = ReloadableConfig( self.write_advanced_config(tmpdir, "b")) # when config = reloadable_config.get_config() # then assert config["a"] == "b" # when self.write_advanced_config(tmpdir, "z") config = reloadable_config.get_config() # then assert config["a"] == "z"
def __init__(self, reloadable_config: ReloadableConfig): assert (isinstance(reloadable_config, ReloadableConfig)) config = reloadable_config.get_config() self.buy_bands = list(map(BuyBand, config['buyBands'])) self.sell_bands = list(map(SellBand, config['sellBands'])) if self._bands_overlap(self.buy_bands) or self._bands_overlap( self.sell_bands): raise Exception(f"Bands in the config file overlap")
def read(reloadable_config: ReloadableConfig, spread_feed: Feed, control_feed: Feed, history: History): assert (isinstance(reloadable_config, ReloadableConfig)) assert (isinstance(spread_feed, Feed)) assert (isinstance(control_feed, Feed)) assert (isinstance(history, History)) try: config = reloadable_config.get_config(spread_feed.get()[0]) control_feed_value = control_feed.get()[0] buy_bands = list(map(BuyBand, config['buyBands'])) buy_limits = SideLimits( config['buyLimits'] if 'buyLimits' in config else [], history.buy_history) sell_bands = list(map(SellBand, config['sellBands'])) sell_limits = SideLimits( config['sellLimits'] if 'sellLimits' in config else [], history.sell_history) if 'canBuy' not in control_feed_value or 'canSell' not in control_feed_value: logging.getLogger().warning( "Control feed expired. Assuming no buy bands and no sell bands." ) buy_bands = [] sell_bands = [] else: if not control_feed_value['canBuy']: logging.getLogger().warning( "Control feed says we shall not buy. Assuming no buy bands." ) buy_bands = [] if not control_feed_value['canSell']: logging.getLogger().warning( "Control feed says we shall not sell. Assuming no sell bands." ) sell_bands = [] except Exception as e: logging.getLogger().exception( f"Config file is invalid ({e}). Treating the config file as it has no bands." ) buy_bands = [] buy_limits = SideLimits([], history.buy_history) sell_bands = [] sell_limits = SideLimits([], history.buy_history) return Bands(buy_bands=buy_bands, buy_limits=buy_limits, sell_bands=sell_bands, sell_limits=sell_limits)
def test_should_reevaluate_if_other_config_file_changed(self, tmpdir): # given reloadable_config = ReloadableConfig(self.write_importing_config(tmpdir)) # when self.write_global_config(tmpdir, 17.0, 11.0) config = reloadable_config.get_config({}) # then assert len(config) == 2 assert config["firstValueMultiplied"] == 34.0 assert config["secondValueMultiplied"] == 33.0 # when self.write_global_config(tmpdir, 18.0, 3.0) config = reloadable_config.get_config({}) # then assert len(config) == 2 assert config["firstValueMultiplied"] == 36.0 assert config["secondValueMultiplied"] == 9.0
def __init__(self, reloadable_config: ReloadableConfig, history: History): assert (isinstance(reloadable_config, ReloadableConfig)) assert (isinstance(history, History)) config = reloadable_config.get_config() self.buy_bands = list(map(BuyBand, config['buyBands'])) self.buy_limits = SideLimits( config['buyLimits'] if 'buyLimits' in config else [], history.buy_history) self.sell_bands = list(map(SellBand, config['sellBands'])) self.sell_limits = SideLimits( config['sellLimits'] if 'sellLimits' in config else [], history.sell_history) if self._bands_overlap(self.buy_bands) or self._bands_overlap( self.sell_bands): raise Exception(f"Bands in the config file overlap")
def __init__(self, reloadable_config: ReloadableConfig, spread_feed: Feed, history: History): assert(isinstance(reloadable_config, ReloadableConfig)) assert(isinstance(history, History)) try: config = reloadable_config.get_config(spread_feed.get()[0]) self.buy_bands = list(map(BuyBand, config['buyBands'])) self.buy_limits = SideLimits(config['buyLimits'] if 'buyLimits' in config else [], history.buy_history) self.sell_bands = list(map(SellBand, config['sellBands'])) self.sell_limits = SideLimits(config['sellLimits'] if 'sellLimits' in config else [], history.sell_history) except Exception as e: self.logger.warning(f"Config file is invalid ({e}). Treating the config file as it has no bands.") self.buy_bands = [] self.buy_limits = SideLimits([], history.buy_history) self.sell_bands = [] self.sell_limits = SideLimits([], history.buy_history) if self._bands_overlap(self.buy_bands) or self._bands_overlap(self.sell_bands): self.logger.warning("Bands in the config file overlap. Treating the config file as it has no bands.") self.buy_bands = [] self.sell_bands = []
class LeverjMarketMakerKeeper: """Keeper acting as a market maker on leverj.""" logger = logging.getLogger() def __init__(self, args: list): parser = argparse.ArgumentParser(prog='leverj-market-maker-keeper') parser.add_argument( "--leverj-api-server", type=str, default="https://test.leverj.io", help= "Address of the leverj API server (default: 'https://test.leverj.io')" ) parser.add_argument("--account-id", type=str, default="", help="Address of leverj api account id") parser.add_argument("--api-key", type=str, default="", help="Address of leverj api key") parser.add_argument("--api-secret", type=str, default="", help="Address of leverj api secret") parser.add_argument( "--leverj-timeout", type=float, default=9.5, help= "Timeout for accessing the Leverj API (in seconds, default: 9.5)") 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 watch our trades") 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("--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( "--refresh-frequency", type=int, default=3, help="Order book refresh frequency (in seconds, default: 3)") parser.add_argument( "--pair", type=str, required=True, help="Token pair (sell/buy) on which the keeper will operate") parser.add_argument("--leverage", type=float, default=1.0, help="Leverage chosen for futures orders") parser.add_argument( "--buffer-factor", type=float, default=0.6, help= "Should not go below 0.5, basically how much capital can you use on one side of quotes" ) parser.add_argument("--debug", dest='debug', action='store_true', help="Enable debug output") self.arguments = parser.parse_args(args) self.web3 = 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) setup_logging(self.arguments) self.bands_config = ReloadableConfig(self.arguments.config) self.price_feed = PriceFeedFactory().create_price_feed(self.arguments) 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) self.target_price_lean = Wad(0) self.leverage = self.arguments.leverage self.buffer_factor = self.arguments.buffer_factor self.history = History() self.leverj_api = LeverjFuturesAPI( web3=self.web3, api_server=self.arguments.leverj_api_server, account_id=self.arguments.account_id, api_key=self.arguments.api_key, api_secret=self.arguments.api_secret, timeout=self.arguments.leverj_timeout) self.order_book_manager = OrderBookManager( refresh_frequency=self.arguments.refresh_frequency) self.order_book_manager.get_orders_with( lambda: self.leverj_api.get_orders(self.pair())) self.order_book_manager.get_balances_with( lambda: self.leverj_api.get_balances()) self.order_book_manager.cancel_orders_with( lambda order: self.leverj_api.cancel_order(order.order_id)) self.order_book_manager.enable_history_reporting( self.order_history_reporter, self.our_buy_orders, self.our_sell_orders) self.order_book_manager.start() def main(self): with Lifecycle() as lifecycle: lifecycle.initial_delay(1) lifecycle.on_startup(self.startup) lifecycle.every(1, self.synchronize_orders) lifecycle.on_shutdown(self.shutdown) def startup(self): quote_increment = self.leverj_api.get_tickSize(self.pair()) self.precision = -(int(log10(float(quote_increment))) + 1) def shutdown(self): self.order_book_manager.cancel_all_orders() def pair(self): name_to_id_map = {'BTCDAI': '1', 'ETHDAI': '2'} return name_to_id_map[self.arguments.pair.upper()] def token_sell(self) -> str: return self.arguments.pair.upper()[:3] def token_buy(self) -> str: return self.arguments.pair.upper()[3:] def allocated_balance(self, token: str) -> Wad: quote_asset_address = self.leverj_api.get_product( self.pair())["quote"]["address"] # for perpetual contracts, the quote balance is allocated across instruments and sides to enter into trades total_available = self.leverj_api.get_plasma_balance( quote_asset_address) # adjust for leverage total_available = Decimal(total_available) * Decimal(self.leverage) self.logger.debug(f'total_available: {total_available}') return self._allocate_to_pair(total_available).get(token) def _allocate_to_pair(self, total_available): # total number of instruments across which the total_available balance is distributed # total_available is denominated in quote units total_number_of_instruments = 1 # there are 2 partitions for allocation per instrument # the dai amount is divided in 2, one for the buy side and another for the sell side number_of_partitions_for_allocation = Wad.from_number( total_number_of_instruments * 2) # buffer_adjustment_factor is a small intentional buffer to avoid allocating the maximum possible. # the allocated amount is a little smaller than the maximum possible allocation # and that is determined by the buffer_adjustment_factor buffer_adjustment_factor = Wad.from_number(self.buffer_factor) base = self.arguments.pair.upper()[:3] quote = self.arguments.pair.upper()[3:] target_price = self.price_feed.get_price() product = self.leverj_api.get_product(self.pair()) minimum_order_quantity = self.leverj_api.get_minimum_order_quantity( self.pair()) minimum_quantity_wad = Wad.from_number(minimum_order_quantity) if ((base == product['baseSymbol']) and (quote == product['quoteSymbol'])): if ((target_price is None) or (target_price.buy_price is None) or (target_price.sell_price is None)): base_allocation = Wad(0) quote_allocation = Wad(0) self.logger.debug( f'target_price not available to calculate allocations') else: average_price = (target_price.buy_price + target_price.sell_price) / Wad.from_number(2) # at 1x average_price * minimum_quantity_wad is the minimum_required_balance # multiplying this minimum_required_balance by 2 to avoid sending very small orders to the exchange minimum_required_balance = average_price * minimum_quantity_wad * Wad.from_number( 2) # conversion_divisor is the divisor that determines how many chunks should Dai be distributed into. # It considers the price of the base to convert into base denomination. conversion_divisor = average_price * number_of_partitions_for_allocation * buffer_adjustment_factor open_position_for_base = self.leverj_api.get_position_in_wad( base) total_available_wad = Wad.from_number( Decimal(total_available) / Decimal(Decimal(10)**Decimal(18))) base_allocation = total_available_wad / conversion_divisor quote_allocation = total_available_wad / ( number_of_partitions_for_allocation * buffer_adjustment_factor) self.logger.debug( f'open_position_for_base: {open_position_for_base}') # bids are made basis quote_allocation and asks basis base_allocation # if open position is net long then quote_allocation is adjusted. # if open position is net too long then target_price is adjusted to reduce price of the asks/offers if (open_position_for_base.value > 0): open_position_for_base_in_quote = open_position_for_base * average_price net_adjusted_quote_value = quote_allocation.value - abs( open_position_for_base_in_quote.value) self.logger.debug( f'net_adjusted_quote_value: {net_adjusted_quote_value}' ) quote_allocation = Wad( net_adjusted_quote_value ) if net_adjusted_quote_value > minimum_required_balance.value else Wad( 0) # if open position is within 1 Wad range or more than quote allocations then target price is leaned down by 0.1 percent if Wad(net_adjusted_quote_value) < Wad(1): self.target_price_lean = Wad.from_number(0.999) else: self.target_price_lean = Wad(0) elif (open_position_for_base.value < 0): # if open position is net short then base_allocation is adjusted # if open position is net too short then target_price is adjusted to increase price of the bids net_adjusted_base_value = base_allocation.value - abs( open_position_for_base.value) minimum_required_balance_in_base = minimum_required_balance / average_price self.logger.debug( f'net_adjusted_base_value: {net_adjusted_base_value}') base_allocation = Wad( net_adjusted_base_value ) if net_adjusted_base_value > minimum_required_balance_in_base.value else Wad( 0) # if open position is within 1 Wad range or more than base allocations then target price is leaned up by 0.1 percent if Wad(net_adjusted_base_value) < Wad(1): self.target_price_lean = Wad.from_number(1.001) else: self.target_price_lean = Wad(0) else: base_allocation = Wad(0) quote_allocation = Wad(0) allocation = {base: base_allocation, quote: quote_allocation} self.logger.debug(f'allocation: {allocation}') return allocation def our_sell_orders(self, our_orders: list) -> list: return list(filter(lambda order: order.is_sell, our_orders)) def our_buy_orders(self, our_orders: list) -> list: return list(filter(lambda order: not order.is_sell, our_orders)) def adjust_target_price(self, target_price: Price) -> Price: assert (isinstance(target_price, Price)) target_price_lean = self.target_price_lean if ((target_price is None) or (target_price.buy_price is None) or (target_price.sell_price is None)): return target_price if target_price_lean.value == 0: return target_price else: self.logger.debug(f'target_price_lean: {target_price_lean}') adjusted_target_price = target_price adjusted_target_price.buy_price = ( target_price.buy_price) * target_price_lean adjusted_target_price.sell_price = ( target_price.sell_price) * target_price_lean return adjusted_target_price def sumMaxSellAmounts(self) -> Wad: #amounts produced are in base token sell_bands_list = self.bands_config.get_config( self.spread_feed.get()[0])['sellBands'] sum_max_sell = 0.0 for band_dict in sell_bands_list: sum_max_sell = sum_max_sell + float(band_dict["maxAmount"]) return Wad.from_number(sum_max_sell) def sumMaxBuyAmounts(self) -> Wad: #amounts produced are in base token buy_bands_list = self.bands_config.get_config( self.spread_feed.get()[0])['buyBands'] target_price = self.price_feed.get_price() sum_max_buy = 0.0 for band_dict in buy_bands_list: sum_max_buy = sum_max_buy + float(band_dict["maxAmount"]) if ((target_price is None) or (target_price.buy_price is None) or (target_price.sell_price is None)): self.logger.info( f'target_price is not available, defaulting to large sum max buy amount' ) return Wad.from_number(2.0) * self.sumMaxSellAmounts() else: average_price = (target_price.buy_price + target_price.sell_price) / Wad.from_number(2) baseTokenSumBuyAmount = Wad.from_number(1.2) * Wad.from_number( sum_max_buy) / average_price return baseTokenSumBuyAmount def synchronize_orders(self): bands = Bands.read(self.bands_config, self.spread_feed, self.control_feed, self.history) order_book = self.order_book_manager.get_order_book() target_price = self.price_feed.get_price() target_price = self.adjust_target_price(target_price) self.logger.debug( f'target_price buy_price: {target_price.buy_price}, target_price sell_price: {target_price.sell_price}' ) # Cancel orders 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.order_book_manager.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.info( "Order book is in progress, not placing new orders") return # Place new orders new_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.allocated_balance(self.token_buy()), our_sell_balance=self.allocated_balance(self.token_sell()), target_price=target_price)[0] self.place_orders(new_orders) def place_orders(self, new_orders: List[NewOrder]): def place_order_function(new_order_to_be_placed): price = round(new_order_to_be_placed.price, self.precision + 2) amount = new_order_to_be_placed.pay_amount if new_order_to_be_placed.is_sell else new_order_to_be_placed.buy_amount self.logger.debug(f'amount: {amount}') leverage_in_wad = Wad.from_number(self.leverage) maxSellAmountConfig = self.sumMaxSellAmounts() maxBuyAmountConfig = self.sumMaxBuyAmounts() base = self.arguments.pair.upper()[:3] open_position_for_base = self.leverj_api.get_position_in_wad(base) if new_order_to_be_placed.is_sell == True and open_position_for_base > maxSellAmountConfig: #placing reduce only sell order order_id = str( self.leverj_api.place_order(self.pair(), price, 'LMT', new_order_to_be_placed.is_sell, price, amount, leverage_in_wad, True)) elif open_position_for_base < Wad.from_number( -1 ) * maxBuyAmountConfig and new_order_to_be_placed.is_sell == False: #placing reduce only buy order order_id = str( self.leverj_api.place_order(self.pair(), price, 'LMT', new_order_to_be_placed.is_sell, price, amount, leverage_in_wad, True)) else: #placing regular leverage order that requires DAI or stable coin capital order_id = str( self.leverj_api.place_order(self.pair(), price, 'LMT', new_order_to_be_placed.is_sell, price, amount, leverage_in_wad, False)) return Order(order_id=order_id, pair=self.pair(), is_sell=new_order_to_be_placed.is_sell, price=price, amount=amount) for new_order in new_orders: self.order_book_manager.place_order( lambda new_order=new_order: place_order_function(new_order))