class TestFlopStrategy:
    def setup_class(self):
        self.mcd = mcd(web3())
        self.strategy = FlopperStrategy(self.mcd.flopper)
        self.mock_flopper = MockFlopper()

    def test_price(self, mocker):
        mocker.patch("pymaker.auctions.Flopper.bids",
                     return_value=self.mock_flopper.bids(1))
        mocker.patch("pymaker.auctions.Flopper.dent",
                     return_value="tx goes here")
        model_price = Wad.from_number(190.0)
        (price, tx, bid) = self.strategy.bid(1, model_price)
        assert price == model_price
        assert bid == MockFlopper.bid
        lot1 = MockFlopper.sump / model_price
        Flopper.dent.assert_called_once_with(1, lot1, MockFlopper.bid)

        # When bid price increases, lot should decrease
        model_price = Wad.from_number(200.0)
        (price, tx, bid) = self.strategy.bid(1, model_price)
        lot2 = Flopper.dent.call_args[0][1]
        assert lot2 < lot1
        assert lot2 == MockFlopper.sump / model_price
Exemple #2
0
class AuctionKeeper:
    logger = logging.getLogger()

    def __init__(self, args: list, **kwargs):
        parser = argparse.ArgumentParser(prog='auction-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('--type',
                            type=str,
                            choices=['flip', 'flap', 'flop'],
                            help="Auction type in which to participate")
        parser.add_argument(
            '--ilk',
            type=str,
            help=
            "Name of the collateral type for a flip keeper (e.g. 'ETH-B', 'ZRX-A'); "
            "available collateral types can be found at the left side of the CDP Portal"
        )

        parser.add_argument(
            '--bid-only',
            dest='create_auctions',
            action='store_false',
            help="Do not take opportunities to create new auctions")
        parser.add_argument(
            '--max-auctions',
            type=int,
            default=100,
            help="Maximum number of auctions to simultaneously interact with, "
            "used to manage OS and hardware limitations")
        parser.add_argument(
            '--min-flip-lot',
            type=float,
            default=0,
            help="Minimum lot size to create or bid upon a flip auction")

        parser.add_argument(
            "--vulcanize-endpoint",
            type=str,
            help=
            "When specified, frob history will be queried from a VulcanizeDB lite node, "
            "reducing load on the Ethereum node for flip auctions")
        parser.add_argument(
            '--from-block',
            type=int,
            help=
            "Starting block from which to look at history (set to block where MCD was deployed)"
        )

        parser.add_argument(
            '--vat-dai-target',
            type=float,
            help="Amount of Dai to keep in the Vat contract (e.g. 2000)")
        parser.add_argument(
            '--keep-dai-in-vat-on-exit',
            dest='exit_dai_on_shutdown',
            action='store_false',
            help=
            "Retain Dai in the Vat on exit, saving gas when restarting the keeper"
        )
        parser.add_argument('--keep-gem-in-vat-on-exit',
                            dest='exit_gem_on_shutdown',
                            action='store_false',
                            help="Retain collateral in the Vat on exit")

        parser.add_argument(
            "--model",
            type=str,
            required=True,
            nargs='+',
            help="Commandline to use in order to start the bidding model")

        parser.add_argument("--debug",
                            dest='debug',
                            action='store_true',
                            help="Enable debug output")

        self.arguments = parser.parse_args(args)

        # Configure connection to the chain
        if self.arguments.rpc_host.startswith("http"):
            endpoint_uri = f"{self.arguments.rpc_host}:{self.arguments.rpc_port}"
        else:
            # Should probably default this to use TLS, but I don't want to break existing configs
            endpoint_uri = f"http://{self.arguments.rpc_host}:{self.arguments.rpc_port}"
        self.web3: Web3 = kwargs['web3'] if 'web3' in kwargs else Web3(
            HTTPProvider(
                endpoint_uri=endpoint_uri,
                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)

        # Check configuration for retrieving urns/bites
        if self.arguments.type == 'flip' and self.arguments.create_auctions \
                and self.arguments.from_block is None and self.arguments.vulcanize_endpoint is None:
            raise RuntimeError(
                "Either --from-block or --vulcanize-endpoint must be specified to kick off "
                "flip auctions")
        if self.arguments.type == 'flip' and not self.arguments.ilk:
            raise RuntimeError(
                "--ilk must be supplied when configuring a flip keeper")
        if self.arguments.type == 'flop' and self.arguments.create_auctions \
                and self.arguments.from_block is None:
            raise RuntimeError(
                "--from-block must be specified to kick off flop auctions")

        # Configure core and token contracts
        mcd = DssDeployment.from_node(web3=self.web3)
        self.vat = mcd.vat
        self.cat = mcd.cat
        self.vow = mcd.vow
        self.mkr = mcd.mkr
        self.dai_join = mcd.dai_adapter
        if self.arguments.type == 'flip':
            self.collateral = mcd.collaterals[self.arguments.ilk]
            self.ilk = self.collateral.ilk
            self.gem_join = self.collateral.adapter
        else:
            self.collateral = None
            self.ilk = None
            self.gem_join = None

        # Configure auction contracts
        self.flipper = self.collateral.flipper if self.arguments.type == 'flip' else None
        self.flapper = mcd.flapper if self.arguments.type == 'flap' else None
        self.flopper = mcd.flopper if self.arguments.type == 'flop' else None
        self.urn_history = None
        if self.flipper:
            self.min_flip_lot = Wad.from_number(self.arguments.min_flip_lot)
            self.strategy = FlipperStrategy(self.flipper, self.min_flip_lot)
            self.urn_history = UrnHistory(self.web3, mcd, self.ilk,
                                          self.arguments.from_block,
                                          self.arguments.vulcanize_endpoint)
        elif self.flapper:
            self.strategy = FlapperStrategy(self.flapper, self.mkr.address)
        elif self.flopper:
            self.strategy = FlopperStrategy(self.flopper)
        else:
            raise RuntimeError("Please specify auction type")

        # Create the collection used to manage auctions relevant to this keeper
        self.auctions = Auctions(
            flipper=self.flipper.address if self.flipper else None,
            flapper=self.flapper.address if self.flapper else None,
            flopper=self.flopper.address if self.flopper else None,
            model_factory=ModelFactory(' '.join(self.arguments.model)))
        self.auctions_lock = threading.Lock()
        self.dead_auctions = set()

        self.vat_dai_target = Wad.from_number(self.arguments.vat_dai_target) if \
            self.arguments.vat_dai_target is not None else None

        logging.basicConfig(
            format='%(asctime)-15s %(levelname)-8s %(message)s',
            level=(logging.DEBUG if self.arguments.debug else logging.INFO))
        # reduce logspew
        logging.getLogger('urllib3').setLevel(logging.INFO)
        logging.getLogger("web3").setLevel(logging.INFO)
        logging.getLogger("asyncio").setLevel(logging.INFO)
        logging.getLogger("requests").setLevel(logging.INFO)

    def main(self):
        def seq_func(check_func: callable):
            assert callable(check_func)
            if self.arguments.create_auctions:
                try:
                    check_func()
                except (RequestException, ConnectionError, ValueError,
                        AttributeError):
                    logging.exception(
                        "Error checking for opportunities to start an auction")

            try:
                self.check_all_auctions()
            except (RequestException, ConnectionError, ValueError,
                    AttributeError):
                logging.exception("Error checking auction states")

        with Lifecycle(self.web3) as lifecycle:
            lifecycle.on_startup(self.startup)
            lifecycle.on_shutdown(self.shutdown)
            if self.flipper and self.cat:
                lifecycle.on_block(
                    functools.partial(seq_func, check_func=self.check_cdps))
            elif self.flapper and self.vow:
                lifecycle.on_block(
                    functools.partial(seq_func, check_func=self.check_flap))
            elif self.flopper and self.vow:
                lifecycle.on_block(
                    functools.partial(seq_func, check_func=self.check_flop))
            else:  # unusual corner case
                lifecycle.on_block(self.check_all_auctions)

            lifecycle.every(2, self.check_for_bids)

    def startup(self):
        self.approve()
        self.rebalance_dai()
        if self.flapper:
            self.logger.info(
                f"MKR balance is {self.mkr.balance_of(self.our_address)}")

        if not self.arguments.create_auctions:
            logging.info("Keeper will not create new auctions")

    def approve(self):
        self.strategy.approve()
        time.sleep(2)
        if self.dai_join:
            self.dai_join.approve(hope_directly(), self.vat.address)
            time.sleep(2)
            self.dai_join.dai().approve(self.dai_join.address).transact()

    def shutdown(self):
        with self.auctions_lock:
            del self.auctions
            self.exit_dai_on_shutdown()
            self.exit_collateral_on_shutdown()

    def exit_dai_on_shutdown(self):
        if not self.arguments.exit_dai_on_shutdown or not self.dai_join:
            return

        vat_balance = Wad(self.vat.dai(self.our_address))
        if vat_balance > Wad(0):
            self.logger.info(
                f"Exiting {str(vat_balance)} Dai from the Vat before shutdown")
            assert self.dai_join.exit(self.our_address, vat_balance).transact()

    def exit_collateral_on_shutdown(self):
        if not self.arguments.exit_gem_on_shutdown or not self.gem_join:
            return

        vat_balance = self.vat.gem(self.ilk, self.our_address)
        if vat_balance > Wad(0):
            self.logger.info(
                f"Exiting {str(vat_balance)} {self.ilk.name} from the Vat before shutdown"
            )
            assert self.gem_join.exit(self.our_address, vat_balance).transact()

    def check_cdps(self):
        started = datetime.now()
        ilk = self.vat.ilk(self.ilk.name)
        rate = ilk.rate
        dai_to_bid = self.vat.dai(self.our_address)

        # Look for unsafe CDPs and bite them
        urns = self.urn_history.get_urns()
        for urn in urns.values():
            safe = urn.ink * ilk.spot >= urn.art * rate
            if not safe:
                if dai_to_bid == Rad(0):
                    self.logger.warning(
                        f"Skipping opportunity to bite urn {urn.address} "
                        "because there is no Dai to bid")
                    break

                if urn.ink < self.min_flip_lot:
                    self.logger.info(
                        f"Ignoring urn {urn.address.address} with ink={urn.ink} < "
                        f"min_lot={self.min_flip_lot}")
                    continue

                self._run_future(self.cat.bite(ilk, urn).transact_async())

        self.logger.debug(
            f"Checked {len(urns)} urns in {(datetime.now()-started).seconds} seconds"
        )
        # Cat.bite implicitly kicks off the flip auction; no further action needed.

    def check_flap(self):
        # Check if Vow has a surplus of Dai compared to bad debt
        joy = self.vat.dai(self.vow.address)
        awe = self.vat.sin(self.vow.address)

        # Check if Vow has Dai in excess
        if joy > awe:
            bump = self.vow.bump()
            hump = self.vow.hump()

            # Check if Vow has enough Dai surplus to start an auction and that we have enough mkr balance
            if (joy - awe) >= (bump + hump):

                if self.mkr.balance_of(self.our_address) == Wad(0):
                    self.logger.warning(
                        "Skipping opportunity to heal/flap because there is no MKR to bid"
                    )
                    return

                woe = self.vow.woe()
                # Heal the system to bring Woe to 0
                if woe > Rad(0):
                    self.vow.heal(woe).transact()
                self.vow.flap().transact()

    def check_flop(self):
        # Check if Vow has a surplus of bad debt compared to Dai
        joy = self.vat.dai(self.vow.address)
        awe = self.vat.sin(self.vow.address)

        # Check if Vow has bad debt in excess
        excess_debt = joy < awe
        if not excess_debt:
            return

        woe = self.vow.woe()
        sin = self.vow.sin()
        sump = self.vow.sump()
        wait = self.vow.wait()

        # Check if Vow has enough bad debt to start an auction and that we have enough dai balance
        if woe + sin >= sump:
            # We need to bring Joy to 0 and Woe to at least sump

            if self.vat.dai(self.our_address) == Rad(0):
                self.logger.warning(
                    "Skipping opportunity to kiss/flog/heal/flop because there is no Dai to bid"
                )
                return

            # first use kiss() as it settled bad debt already in auctions and doesn't decrease woe
            ash = self.vow.ash()
            goodnight = min(ash, joy)
            if goodnight > Rad(0):
                self.vow.kiss(goodnight).transact()

            # Convert enough sin in woe to have woe >= sump + joy
            if woe < (sump + joy) and self.cat is not None:
                past_blocks = self.web3.eth.blockNumber - self.arguments.from_block
                for bite_event in self.cat.past_bites(
                        past_blocks):  # TODO: cache ?
                    era = bite_event.era(self.web3)
                    now = self.web3.eth.getBlock('latest')['timestamp']
                    sin = self.vow.sin_of(era)
                    # If the bite hasn't already been flogged and has aged past the `wait`
                    if sin > Rad(0) and era + wait <= now:
                        self.vow.flog(era).transact()

                        # flog() sin until woe is above sump + joy
                        joy = self.vat.dai(self.vow.address)
                        if self.vow.woe() - joy >= sump:
                            break

            # use heal() for reconciling the remaining joy
            joy = self.vat.dai(self.vow.address)
            if Rad(0) < joy <= self.vow.woe():
                self.vow.heal(joy).transact()
                # heal() changes joy and woe (the balance of surplus and debt)
                joy = self.vat.dai(self.vow.address)

            woe = self.vow.woe()
            if sump <= woe and joy == Rad(0):
                self.vow.flop().transact()

    def check_all_auctions(self):
        started = datetime.now()
        for id in range(1, self.strategy.kicks() + 1):
            with self.auctions_lock:
                if not self.check_auction(id):
                    continue

                # Prevent growing the auctions collection beyond the configured size
                if len(self.auctions.auctions) < self.arguments.max_auctions:
                    self.feed_model(id)
                else:
                    logging.warning(
                        f"Processing {len(self.auctions.auctions)} auctions; "
                        f"ignoring auction {id} and beyond")
                    break

        self.logger.debug(
            f"Checked {self.strategy.kicks()} auctions in {(datetime.now() - started).seconds} seconds"
        )

    def check_for_bids(self):
        with self.auctions_lock:
            for id, auction in self.auctions.auctions.items():
                self.handle_bid(id=id, auction=auction)

    # TODO if we will introduce multithreading here, proper locking should be introduced as well
    #     locking should not happen on `auction.lock`, but on auction.id here. as sometimes we will
    #     intend to lock on auction id but not create `Auction` object for it (as the auction is already finished
    #     for example).
    def check_auction(self, id: int) -> bool:
        assert isinstance(id, int)

        if id in self.dead_auctions:
            return False

        # Read auction information
        input = self.strategy.get_input(id)
        auction_missing = (input.end == 0)
        auction_finished = (input.tic < input.era
                            and input.tic != 0) or (input.end < input.era)

        if auction_missing:
            # Try to remove the auction so the model terminates and we stop tracking it.
            # If auction has already been removed, nothing happens.
            self.auctions.remove_auction(id)
            self.dead_auctions.add(id)
            return False

        # Check if the auction is finished.
        # If it is finished and we are the winner, `deal` the auction.
        # If it is finished and we aren't the winner, there is no point in carrying on with this auction.
        elif auction_finished:
            if input.guy == self.our_address:
                # Always using default gas price for `deal`
                self._run_future(
                    self.strategy.deal(id).transact_async(
                        gas_price=DefaultGasPrice()))

                # Upon winning a flip or flop auction, we may need to replenish Dai to the Vat.
                # Upon winning a flap auction, we may want to withdraw won Dai from the Vat.
                self.rebalance_dai()

            else:
                # Try to remove the auction so the model terminates and we stop tracking it.
                # If auction has already been removed, nothing happens.
                self.auctions.remove_auction(id)
            return False

        else:
            return True

    def feed_model(self, id: int):
        assert isinstance(id, int)

        auction = self.auctions.get_auction(id)
        input = self.strategy.get_input(id)

        # Feed the model with current state
        auction.feed_model(input)

    def handle_bid(self, id: int, auction: Auction):
        assert isinstance(id, int)
        assert isinstance(auction, Auction)

        output = auction.model_output()

        if output is not None:
            bid_price, bid_transact, cost = self.strategy.bid(id, output.price)
            # If we can't afford the bid, log a warning/error and back out.
            # By continuing, we'll burn through gas fees while the keeper pointlessly retries the bid.
            if cost is not None:
                if not self.check_bid_cost(cost):
                    return

            if bid_price is not None and bid_transact is not None:
                # if no transaction in progress, send a new one
                transaction_in_progress = auction.transaction_in_progress()

                if transaction_in_progress is None:
                    self.logger.info(
                        f"Sending new bid @{output.price} (gas_price={output.gas_price})"
                    )

                    auction.price = bid_price
                    auction.gas_price = UpdatableGasPrice(output.gas_price)
                    auction.register_transaction(bid_transact)

                    self._run_future(
                        bid_transact.transact_async(
                            gas_price=auction.gas_price))

                # if transaction in progress and gas price went up...
                elif output.gas_price and output.gas_price > auction.gas_price.gas_price:

                    # ...replace the entire bid if the price has changed...
                    if bid_price != auction.price:
                        self.logger.info(
                            f"Overriding pending bid with new bid @{output.price} (gas_price={output.gas_price})"
                        )

                        auction.price = bid_price
                        auction.gas_price = UpdatableGasPrice(output.gas_price)
                        auction.register_transaction(bid_transact)

                        self._run_future(
                            bid_transact.transact_async(
                                replace=transaction_in_progress,
                                gas_price=auction.gas_price))
                    # ...or just replace gas_price if price stays the same
                    else:
                        self.logger.info(
                            f"Overriding pending bid with new gas_price ({output.gas_price})"
                        )

                        auction.gas_price.update_gas_price(output.gas_price)

    def check_bid_cost(self, cost: Rad) -> bool:
        assert isinstance(cost, Rad)

        # If this is an auction where we bid with Dai...
        if self.flipper or self.flopper:
            vat_dai = self.vat.dai(self.our_address)
            if cost > vat_dai:
                self.logger.debug(
                    f"Bid cost {str(cost)} exceeds vat balance of {vat_dai}; "
                    "bid will not be submitted")
                return False
        # If this is an auction where we bid with MKR...
        elif self.flapper:
            mkr_balance = self.mkr.balance_of(self.our_address)
            if cost > Rad(mkr_balance):
                self.logger.debug(
                    f"Bid cost {str(cost)} exceeds MKR balance of {mkr_balance}; "
                    "bid will not be submitted")
                return False
        return True

    def rebalance_dai(self):
        if self.vat_dai_target is None or not self.dai_join or (
                not self.flipper and not self.flopper):
            return

        dai = self.dai_join.dai()
        token_balance = dai.balance_of(self.our_address)  # Wad
        difference = Wad(self.vat.dai(
            self.our_address)) - self.vat_dai_target  # Wad
        if difference < Wad(0):
            # Join tokens to the vat
            if token_balance >= difference * -1:
                self.logger.info(
                    f"Joining {str(difference * -1)} Dai to the Vat")
                assert self.dai_join.join(self.our_address,
                                          difference * -1).transact()
            elif token_balance > Wad(0):
                self.logger.warning(
                    f"Insufficient balance to maintain Dai target; joining {str(token_balance)} "
                    "Dai to the Vat")
                assert self.dai_join.join(self.our_address,
                                          token_balance).transact()
            else:
                self.logger.warning(
                    "No Dai is available to join to Vat; cannot maintain Dai target"
                )
        elif difference > Wad(0):
            # Exit dai from the vat
            self.logger.info(f"Exiting {str(difference)} Dai from the Vat")
            assert self.dai_join.exit(self.our_address, difference).transact()
        self.logger.info(
            f"Dai token balance: {str(dai.balance_of(self.our_address))}, "
            f"Vat balance: {self.vat.dai(self.our_address)}")

    @staticmethod
    def _run_future(future):
        def worker():
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                asyncio.get_event_loop().run_until_complete(future)
            finally:
                loop.close()

        thread = threading.Thread(target=worker, daemon=True)
        thread.start()
Exemple #3
0
class AuctionKeeper:
    logger = logging.getLogger()
    dead_after = 10  # Assume block reorgs cannot resurrect an auction id after this many blocks

    def __init__(self, args: list, **kwargs):
        parser = argparse.ArgumentParser(prog='auction-keeper')

        parser.add_argument(
            "--rpc-host",
            type=str,
            default="http://*****:*****@{output.price} for auction {id}")
                auction.price = bid_price
                auction.gas_price = new_gas_strategy if new_gas_strategy else auction.gas_price
                auction.register_transaction(bid_transact)

                # ...submit a new transaction and wait the delay period (if so configured)
                self._run_future(
                    bid_transact.transact_async(gas_price=auction.gas_price))
                if self.arguments.bid_delay:
                    logging.debug(f"Waiting {self.arguments.bid_delay}s")
                    time.sleep(self.arguments.bid_delay)

            # if transaction in progress and the bid price changed...
            elif auction.price and bid_price != auction.price:
                self.logger.info(
                    f"Attempting to override pending bid with new bid @{output.price} for auction {id}"
                )
                auction.price = bid_price
                if new_gas_strategy:  # gas strategy changed
                    auction.gas_price = new_gas_strategy
                elif fixed_gas_price_changed:  # gas price updated
                    assert isinstance(auction.gas_price, UpdatableGasPrice)
                    auction.gas_price.update_gas_price(output.gas_price)
                auction.register_transaction(bid_transact)

                # ...ask pymaker to replace the transaction
                self._run_future(
                    bid_transact.transact_async(
                        replace=transaction_in_progress,
                        gas_price=auction.gas_price))

            # if model has been providing a gas price, and only that changed...
            elif fixed_gas_price_changed:
                assert isinstance(auction.gas_price, UpdatableGasPrice)
                self.logger.info(
                    f"Overriding pending bid with new gas_price ({output.gas_price}) for auction {id}"
                )
                auction.gas_price.update_gas_price(output.gas_price)

            # if transaction in progress, bid price unchanged, but gas strategy changed...
            elif new_gas_strategy:
                self.logger.info(
                    f"Changing gas strategy for pending bid @{output.price} for auction {id}"
                )
                auction.price = bid_price
                auction.gas_price = new_gas_strategy
                auction.register_transaction(bid_transact)

                # ...ask pymaker to replace the transaction
                self._run_future(
                    bid_transact.transact_async(
                        replace=transaction_in_progress,
                        gas_price=auction.gas_price))

    def check_bid_cost(self,
                       id: int,
                       cost: Rad,
                       reservoir: Reservoir,
                       already_rebalanced=False) -> bool:
        assert isinstance(id, int)
        assert isinstance(cost, Rad)

        # If this is an auction where we bid with Dai...
        if self.auction_type in ['clip', 'flip', 'flop']:
            if not reservoir.check_bid_cost(id, cost):
                if not already_rebalanced:
                    # Try to synchronously join Dai the Vat
                    if self.is_joining_dai:
                        self.logger.info(
                            f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; "
                            "waiting for Dai to rebalance")
                        return False
                    else:
                        rebalanced = self.rebalance_dai()
                        if rebalanced and rebalanced > Wad(0):
                            reservoir.refill(Rad(rebalanced))
                            return self.check_bid_cost(id,
                                                       cost,
                                                       reservoir,
                                                       already_rebalanced=True)

                self.logger.info(
                    f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; "
                    "bid will not be submitted")
                return False
        # If this is an auction where we bid with MKR...
        elif self.auction_type == 'flap':
            mkr_balance = self.mkr.balance_of(self.our_address)
            if cost > Rad(mkr_balance):
                self.logger.debug(
                    f"Bid cost {str(cost)} exceeds reservoir level of {reservoir.level}; "
                    "bid will not be submitted")
                return False
        return True

    def rebalance_dai(self) -> Optional[Wad]:
        # Returns amount joined (positive) or exited (negative) as a result of rebalancing towards vat_dai_target

        if self.arguments.vat_dai_target is None:
            return None

        logging.info(
            f"Checking if internal Dai balance needs to be rebalanced")
        dai = self.dai_join.dai()
        token_balance = dai.balance_of(self.our_address)  # Wad
        # Prevent spending gas on small rebalances
        dust = Wad(self.mcd.vat.ilk(
            self.ilk.name).dust) if self.ilk else Wad.from_number(20)

        dai_to_join = Wad(0)
        dai_to_exit = Wad(0)
        try:
            if self.arguments.vat_dai_target.upper() == "ALL":
                dai_to_join = token_balance
            else:
                dai_target = Wad.from_number(
                    float(self.arguments.vat_dai_target))
                if dai_target < dust:
                    self.logger.warning(
                        f"Dust cutoff of {dust} exceeds Dai target {dai_target}; "
                        "please adjust configuration accordingly")
                vat_balance = Wad(self.vat.dai(self.our_address))
                if vat_balance < dai_target:
                    dai_to_join = dai_target - vat_balance
                elif vat_balance > dai_target:
                    dai_to_exit = vat_balance - dai_target
        except ValueError:
            raise ValueError("Unsupported --vat-dai-target")

        if dai_to_join >= dust:
            # Join tokens to the vat
            if token_balance >= dai_to_join:
                self.logger.info(f"Joining {str(dai_to_join)} Dai to the Vat")
                return self.join_dai(dai_to_join)
            elif token_balance > Wad(0):
                self.logger.warning(
                    f"Insufficient balance to maintain Dai target; joining {str(token_balance)} "
                    "Dai to the Vat")
                return self.join_dai(token_balance)
            else:
                self.logger.warning(
                    "Insufficient Dai is available to join to Vat; cannot maintain Dai target"
                )
                return Wad(0)
        elif dai_to_exit > dust:
            # Exit dai from the vat
            self.logger.info(f"Exiting {str(dai_to_exit)} Dai from the Vat")
            assert self.dai_join.exit(
                self.our_address,
                dai_to_exit).transact(gas_price=self.gas_price)
            return dai_to_exit * -1
        self.logger.info(
            f"Dai token balance: {str(dai.balance_of(self.our_address))}, "
            f"Vat balance: {self.vat.dai(self.our_address)}")

    def join_dai(self, amount: Wad):
        assert isinstance(amount, Wad)
        assert not self.is_joining_dai
        try:
            self.is_joining_dai = True
            assert self.dai_join.join(
                self.our_address, amount).transact(gas_price=self.gas_price)
        finally:
            self.is_joining_dai = False
        return amount

    def exit_gem(self):
        if not self.collateral:
            return

        token = Token(
            self.collateral.ilk.name.split('-')[0],
            self.collateral.gem.address, self.collateral.adapter.dec())
        vat_balance = self.vat.gem(self.ilk, self.our_address)
        if vat_balance > token.min_amount:
            self.logger.info(
                f"Exiting {str(vat_balance)} {self.ilk.name} from the Vat")
            assert self.gem_join.exit(
                self.our_address,
                token.unnormalize_amount(vat_balance)).transact(
                    gas_price=self.gas_price)

    @staticmethod
    def _run_future(future):
        def worker():
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                asyncio.get_event_loop().run_until_complete(future)
            finally:
                loop.close()

        thread = threading.Thread(target=worker, daemon=True)
        thread.start()
Exemple #4
0
class AuctionKeeper:
    logger = logging.getLogger()
    dead_after = 10  # Assume block reorgs cannot resurrect an auction id after this many blocks

    def __init__(self, args: list, **kwargs):
        parser = argparse.ArgumentParser(prog='auction-keeper')

        parser.add_argument(
            "--rpc-host",
            type=str,
            default="http://*****:*****@ {bid_price} {cost}")

        if cost is not None:
            if not self.check_bid_cost(cost):
                return

        if bid_price is not None and bid_transact is not None:
            # Ensure this auction has a gas strategy assigned
            (new_gas_strategy,
             fixed_gas_price_changed) = auction.determine_gas_strategy_for_bid(
                 output, self.gas_price)

            # if no transaction in progress, send a new one
            transaction_in_progress = auction.transaction_in_progress()
            logging.info(f"should be bidding")
            # if transaction has not been submitted...
            if transaction_in_progress is None:
                self.logger.info(
                    f"Sending new bid @{managed_price} (gas_price={output.gas_price})"
                )

                auction.price = bid_price
                auction.gas_price = new_gas_strategy if new_gas_strategy else auction.gas_price
                auction.register_transaction(bid_transact)

                # ...submit a new transaction and wait the delay period (if so configured)
                self._run_future(
                    bid_transact.transact_async(gas_price=auction.gas_price))
                if self.arguments.bid_delay:
                    logging.debug(f"Waiting {self.arguments.bid_delay}s")
                    time.sleep(self.arguments.bid_delay)

            # if transaction in progress and the bid price changed...
            elif bid_price != auction.price:
                self.logger.info(
                    f"Attempting to override pending bid with new bid @{managed_price} for auction {id}"
                )
                auction.price = bid_price
                if new_gas_strategy:  # gas strategy changed
                    auction.gas_price = new_gas_strategy
                elif fixed_gas_price_changed:  # gas price updated
                    assert isinstance(auction.gas_price, UpdatableGasPrice)
                    auction.gas_price.update_gas_price(output.gas_price)
                auction.register_transaction(bid_transact)

                # ...ask pymaker to replace the transaction
                self._run_future(
                    bid_transact.transact_async(
                        replace=transaction_in_progress,
                        gas_price=auction.gas_price))

            # if model has been providing a gas price, and only that changed...
            elif fixed_gas_price_changed:
                assert isinstance(auction.gas_price, UpdatableGasPrice)
                self.logger.info(
                    f"Overriding pending bid with new gas_price ({output.gas_price}) for auction {id}"
                )
                auction.gas_price.update_gas_price(output.gas_price)

            # if transaction in progress, bid price unchanged, but gas strategy changed...
            elif new_gas_strategy:
                self.logger.info(
                    f"Changing gas strategy for pending bid @{managed_price} for auction {id}"
                )
                auction.price = bid_price
                auction.gas_price = new_gas_strategy
                auction.register_transaction(bid_transact)

                # ...ask pymaker to replace the transaction
                self._run_future(
                    bid_transact.transact_async(
                        replace=transaction_in_progress,
                        gas_price=auction.gas_price))

    def check_bid_cost(self, cost: Rad) -> bool:
        assert isinstance(cost, Rad)
        # If this is an auction where we bid with Dai...
        if self.flipper or self.flopper:
            vat_dai = self.vat.dai(self.our_address)
            if cost > vat_dai:
                self.logger.info(
                    f"Bid cost {str(cost)} exceeds vat balance of {vat_dai}; "
                    "bid will not be submitted")
                return False
        # If this is an auction where we bid with MKR...
        elif self.flapper:
            mkr_balance = self.mkr.balance_of(self.our_address)
            if cost > Rad(mkr_balance):
                self.logger.debug(
                    f"Bid cost {str(cost)} exceeds MKR balance of {mkr_balance}; "
                    "bid will not be submitted")
                return False
        return True

    def rebalance_dai(self):
        logging.info(
            f"Checking if internal Dai balance needs to be rebalanced")
        if self.vat_dai_target is None or not self.dai_join or (
                not self.flipper and not self.flopper):
            return

        dai = self.dai_join.dai()
        token_balance = dai.balance_of(self.our_address)  # Wad
        difference = Wad(self.vat.dai(
            self.our_address)) - self.vat_dai_target  # Wad
        if difference < Wad(0):
            # Join tokens to the vat
            if token_balance >= difference * -1:
                self.logger.info(
                    f"Joining {str(difference * -1)} Dai to the Vat")
                assert self.dai_join.join(
                    self.our_address,
                    difference * -1).transact(gas_price=self.gas_price)
            elif token_balance > Wad(0):
                self.logger.warning(
                    f"Insufficient balance to maintain Dai target; joining {str(token_balance)} "
                    "Dai to the Vat")
                assert self.dai_join.join(
                    self.our_address,
                    token_balance).transact(gas_price=self.gas_price)
            else:
                self.logger.warning(
                    "No Dai is available to join to Vat; cannot maintain Dai target"
                )
        elif difference > Wad(0):
            # Exit dai from the vat
            self.logger.info(f"Exiting {str(difference)} Dai from the Vat")
            assert self.dai_join.exit(
                self.our_address,
                difference).transact(gas_price=self.gas_price)

        self.logger.info(
            f"Dai token balance: {str(dai.balance_of(self.our_address))}, "
            f"Vat balance: {self.vat.dai(self.our_address)}")

    @staticmethod
    def _run_future(future):
        def worker():
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                asyncio.get_event_loop().run_until_complete(future)
            finally:
                loop.close()

        thread = threading.Thread(target=worker, daemon=True)
        thread.start()