From 2bf84fca43c22f73903bbefb945f537f7794a732 Mon Sep 17 00:00:00 2001 From: Peter Jung Date: Mon, 21 Oct 2024 10:51:14 +0200 Subject: [PATCH] Refactor omen-specific logic out of DeployableTraderAgent + fix minimum required balance to operate (#493) --- .../deploy/agent.py | 148 +++++------------- .../deploy/betting_strategy.py | 19 ++- .../markets/agent_market.py | 37 +++++ .../markets/manifold/manifold.py | 5 + .../markets/markets.py | 4 + .../markets/omen/omen.py | 66 +++++++- pyproject.toml | 2 +- .../markets/omen/test_local_chain.py | 13 +- 8 files changed, 166 insertions(+), 128 deletions(-) diff --git a/prediction_market_agent_tooling/deploy/agent.py b/prediction_market_agent_tooling/deploy/agent.py index 76f0bf20..0a282aa1 100644 --- a/prediction_market_agent_tooling/deploy/agent.py +++ b/prediction_market_agent_tooling/deploy/agent.py @@ -8,10 +8,8 @@ from enum import Enum from functools import cached_property -from pydantic import BaseModel, BeforeValidator, computed_field +from pydantic import BeforeValidator, computed_field from typing_extensions import Annotated -from web3 import Web3 -from web3.constants import HASH_ZERO from prediction_market_agent_tooling.config import APIKeys from prediction_market_agent_tooling.deploy.betting_strategy import ( @@ -32,11 +30,12 @@ gcp_function_is_active, gcp_resolve_api_keys_secrets, ) -from prediction_market_agent_tooling.gtypes import HexStr, xDai, xdai_type +from prediction_market_agent_tooling.gtypes import xDai, xdai_type from prediction_market_agent_tooling.loggers import logger from prediction_market_agent_tooling.markets.agent_market import ( AgentMarket, FilterBy, + ProcessedMarket, SortBy, ) from prediction_market_agent_tooling.markets.data_models import ( @@ -49,28 +48,16 @@ MarketType, have_bet_on_market_since, ) -from prediction_market_agent_tooling.markets.omen.data_models import ( - ContractPrediction, - IPFSAgentResult, -) from prediction_market_agent_tooling.markets.omen.omen import ( - is_minimum_required_balance, - redeem_from_all_user_positions, withdraw_wxdai_to_xdai_to_keep_balance, ) -from prediction_market_agent_tooling.markets.omen.omen_contracts import ( - OmenAgentResultMappingContract, -) from prediction_market_agent_tooling.monitor.monitor_app import ( MARKET_TYPE_TO_DEPLOYED_AGENT, ) -from prediction_market_agent_tooling.tools.hexbytes_custom import HexBytes -from prediction_market_agent_tooling.tools.ipfs.ipfs_handler import IPFSHandler from prediction_market_agent_tooling.tools.is_invalid import is_invalid from prediction_market_agent_tooling.tools.is_predictable import is_predictable_binary from prediction_market_agent_tooling.tools.langfuse_ import langfuse_context, observe from prediction_market_agent_tooling.tools.utils import DatetimeUTC, utcnow -from prediction_market_agent_tooling.tools.web3_utils import ipfscidv0_to_byte32 MAX_AVAILABLE_MARKETS = 20 TRADER_TAG = "trader" @@ -122,11 +109,6 @@ class OutOfFundsError(ValueError): pass -class ProcessedMarket(BaseModel): - answer: ProbabilisticAnswer - trades: list[PlacedTrade] - - class AnsweredEnum(str, Enum): ANSWERED = "answered" NOT_ANSWERED = "not_answered" @@ -294,7 +276,6 @@ def get_gcloud_fname(self, market_type: MarketType) -> str: class DeployableTraderAgent(DeployableAgent): bet_on_n_markets_per_run: int = 1 - min_required_balance_to_operate: xDai | None = xdai_type(1) min_balance_to_keep_in_native_currency: xDai | None = xdai_type(0.1) allow_invalid_questions: bool = False same_market_bet_interval: timedelta = timedelta(hours=24) @@ -353,38 +334,25 @@ def update_langfuse_trace_by_processed_market( ] ) - def check_min_required_balance_to_operate( - self, - market_type: MarketType, - check_for_gas: bool = True, - check_for_trades: bool = True, - ) -> None: + def check_min_required_balance_to_operate(self, market_type: MarketType) -> None: api_keys = APIKeys() - if ( - market_type == MarketType.OMEN - and check_for_gas - and not is_minimum_required_balance( - api_keys.public_key, - min_required_balance=xdai_type(0.001), - sum_wxdai=False, - ) - ): + + if not market_type.market_class.verify_operational_balance(api_keys): raise CantPayForGasError( - f"{api_keys.public_key=} doesn't have enough xDai to pay for gas." + f"{api_keys=} doesn't have enough operational balance." ) - if self.min_required_balance_to_operate is None: - return - if ( - market_type == MarketType.OMEN - and check_for_trades - and not is_minimum_required_balance( - api_keys.bet_from_address, - min_required_balance=self.min_required_balance_to_operate, - ) - ): + + def check_min_required_balance_to_trade(self, market: AgentMarket) -> None: + api_keys = APIKeys() + + # Get the strategy to know how much it will bet. + strategy = self.get_betting_strategy(market) + # Have a little bandwidth after the bet. + min_required_balance_to_trade = strategy.maximum_possible_bet_amount * 1.01 + + if market.get_trade_balance(api_keys) < min_required_balance_to_trade: raise OutOfFundsError( - f"Minimum required balance {self.min_required_balance_to_operate} " - f"for agent with address {api_keys.bet_from_address=} is not met." + f"Minimum required balance {min_required_balance_to_trade} for agent is not met." ) def have_bet_on_market_since(self, market: AgentMarket, since: timedelta) -> bool: @@ -447,6 +415,19 @@ def before_process_market( ) -> None: self.update_langfuse_trace_by_market(market_type, market) + api_keys = APIKeys() + + self.check_min_required_balance_to_trade(market) + + if market_type.is_blockchain_market: + # Exchange wxdai back to xdai if the balance is getting low, so we can keep paying for fees. + if self.min_balance_to_keep_in_native_currency is not None: + withdraw_wxdai_to_xdai_to_keep_balance( + api_keys, + min_required_balance=self.min_balance_to_keep_in_native_currency, + withdraw_multiplier=2, + ) + def process_market( self, market_type: MarketType, @@ -510,72 +491,16 @@ def after_process_market( market: AgentMarket, processed_market: ProcessedMarket, ) -> None: - if market_type != MarketType.OMEN: - logger.info( - f"Skipping after_process_market since market_type {market_type} != OMEN" - ) - return keys = APIKeys() - self.store_prediction( - market_id=market.id, processed_market=processed_market, keys=keys - ) - - def store_prediction( - self, market_id: str, processed_market: ProcessedMarket, keys: APIKeys - ) -> None: - reasoning = ( - processed_market.answer.reasoning - if processed_market.answer.reasoning - else "" - ) - - ipfs_hash_decoded = HexBytes(HASH_ZERO) - if keys.enable_ipfs_upload: - logger.info("Storing prediction on IPFS.") - ipfs_hash = IPFSHandler(keys).store_agent_result( - IPFSAgentResult(reasoning=reasoning) - ) - ipfs_hash_decoded = ipfscidv0_to_byte32(ipfs_hash) - - tx_hashes = [ - HexBytes(HexStr(i.id)) for i in processed_market.trades if i.id is not None - ] - prediction = ContractPrediction( - publisher=keys.public_key, - ipfs_hash=ipfs_hash_decoded, - tx_hashes=tx_hashes, - estimated_probability_bps=int(processed_market.answer.p_yes * 10000), - ) - tx_receipt = OmenAgentResultMappingContract().add_prediction( - api_keys=keys, - market_address=Web3.to_checksum_address(market_id), - prediction=prediction, - ) - logger.info( - f"Added prediction to market {market_id}. - receipt {tx_receipt['transactionHash'].hex()}." - ) + market.store_prediction(processed_market=processed_market, keys=keys) def before_process_markets(self, market_type: MarketType) -> None: """ Executes actions that occur before bets are placed. """ api_keys = APIKeys() - if market_type == MarketType.OMEN: - # First, check if we have enough xDai to pay for gas, there is no way of doing anything without it. - self.check_min_required_balance_to_operate( - market_type, check_for_trades=False - ) - # Omen is specific, because the user (agent) needs to manually withdraw winnings from the market. - redeem_from_all_user_positions(api_keys) - # After redeeming, check if we have enough xDai to pay for gas and place bets. - self.check_min_required_balance_to_operate(market_type) - # Exchange wxdai back to xdai if the balance is getting low, so we can keep paying for fees. - if self.min_balance_to_keep_in_native_currency is not None: - withdraw_wxdai_to_xdai_to_keep_balance( - api_keys, - min_required_balance=self.min_balance_to_keep_in_native_currency, - withdraw_multiplier=2, - ) + self.check_min_required_balance_to_operate(market_type) + market_type.market_class.redeem_winnings(api_keys) def process_markets(self, market_type: MarketType) -> None: """ @@ -589,9 +514,6 @@ def process_markets(self, market_type: MarketType) -> None: processed = 0 for market in available_markets: - # We need to check it again before each market bet, as the balance might have changed. - self.check_min_required_balance_to_operate(market_type) - processed_market = self.process_market(market_type, market) if processed_market is not None: @@ -603,7 +525,7 @@ def process_markets(self, market_type: MarketType) -> None: logger.info("All markets processed.") def after_process_markets(self, market_type: MarketType) -> None: - pass + "Executes actions that occur after bets are placed." def run(self, market_type: MarketType) -> None: self.before_process_markets(market_type) diff --git a/prediction_market_agent_tooling/deploy/betting_strategy.py b/prediction_market_agent_tooling/deploy/betting_strategy.py index 8e9b3532..f4761b37 100644 --- a/prediction_market_agent_tooling/deploy/betting_strategy.py +++ b/prediction_market_agent_tooling/deploy/betting_strategy.py @@ -32,7 +32,12 @@ def calculate_trades( answer: ProbabilisticAnswer, market: AgentMarket, ) -> list[Trade]: - pass + raise NotImplementedError("Subclass should implement this.") + + @property + @abstractmethod + def maximum_possible_bet_amount(self) -> float: + raise NotImplementedError("Subclass should implement this.") def build_zero_token_amount(self, currency: Currency) -> TokenAmount: return TokenAmount(amount=0, currency=currency) @@ -126,6 +131,10 @@ class MaxAccuracyBettingStrategy(BettingStrategy): def __init__(self, bet_amount: float): self.bet_amount = bet_amount + @property + def maximum_possible_bet_amount(self) -> float: + return self.bet_amount + def calculate_trades( self, existing_position: Position | None, @@ -168,6 +177,10 @@ def __init__(self, max_bet_amount: float, max_price_impact: float | None = None) self.max_bet_amount = max_bet_amount self.max_price_impact = max_price_impact + @property + def maximum_possible_bet_amount(self) -> float: + return self.max_bet_amount + def calculate_trades( self, existing_position: Position | None, @@ -282,6 +295,10 @@ class MaxAccuracyWithKellyScaledBetsStrategy(BettingStrategy): def __init__(self, max_bet_amount: float = 10): self.max_bet_amount = max_bet_amount + @property + def maximum_possible_bet_amount(self) -> float: + return self.max_bet_amount + def adjust_bet_amount( self, existing_position: Position | None, market: AgentMarket ) -> float: diff --git a/prediction_market_agent_tooling/markets/agent_market.py b/prediction_market_agent_tooling/markets/agent_market.py index 7708aa88..713057de 100644 --- a/prediction_market_agent_tooling/markets/agent_market.py +++ b/prediction_market_agent_tooling/markets/agent_market.py @@ -11,7 +11,9 @@ Bet, BetAmount, Currency, + PlacedTrade, Position, + ProbabilisticAnswer, Resolution, ResolvedBet, TokenAmount, @@ -25,6 +27,11 @@ ) +class ProcessedMarket(BaseModel): + answer: ProbabilisticAnswer + trades: list[PlacedTrade] + + class SortBy(str, Enum): CLOSING_SOONEST = "closing-soonest" NEWEST = "newest" @@ -198,6 +205,36 @@ def get_binary_markets( def get_binary_market(id: str) -> "AgentMarket": raise NotImplementedError("Subclasses must implement this method") + @staticmethod + def redeem_winnings(api_keys: APIKeys) -> None: + """ + On some markets (like Omen), it's needed to manually claim the winner bets. If it's not needed, just implement with `pass`. + """ + raise NotImplementedError("Subclasses must implement this method") + + @staticmethod + def get_trade_balance(api_keys: APIKeys) -> float: + """ + Return balance that can be used to trade on the given market. + """ + raise NotImplementedError("Subclasses must implement this method") + + @staticmethod + def verify_operational_balance(api_keys: APIKeys) -> bool: + """ + Return `True` if the user has enough of operational balance. If not needed, just return `True`. + For example: Omen needs at least some xDai in the wallet to execute transactions. + """ + raise NotImplementedError("Subclasses must implement this method") + + def store_prediction( + self, processed_market: ProcessedMarket, keys: APIKeys + ) -> None: + """ + If market allows to upload predictions somewhere, implement it in this method. + """ + raise NotImplementedError("Subclasses must implement this method") + @staticmethod def get_bets_made_since( better_address: ChecksumAddress, start_time: DatetimeUTC diff --git a/prediction_market_agent_tooling/markets/manifold/manifold.py b/prediction_market_agent_tooling/markets/manifold/manifold.py index 53cafbb7..81baa922 100644 --- a/prediction_market_agent_tooling/markets/manifold/manifold.py +++ b/prediction_market_agent_tooling/markets/manifold/manifold.py @@ -125,6 +125,11 @@ def get_binary_markets( ) ] + @staticmethod + def redeem_winnings(api_keys: APIKeys) -> None: + # It's done automatically on Manifold. + pass + @classmethod def get_user_url(cls, keys: APIKeys) -> str: return get_authenticated_user(keys.manifold_api_key.get_secret_value()).url diff --git a/prediction_market_agent_tooling/markets/markets.py b/prediction_market_agent_tooling/markets/markets.py index 3ef9988b..2c0d1c2a 100644 --- a/prediction_market_agent_tooling/markets/markets.py +++ b/prediction_market_agent_tooling/markets/markets.py @@ -46,6 +46,10 @@ def market_class(self) -> type[AgentMarket]: raise ValueError(f"Unknown market type: {self}") return MARKET_TYPE_TO_AGENT_MARKET[self] + @property + def is_blockchain_market(self) -> bool: + return self in [MarketType.OMEN, MarketType.POLYMARKET] + MARKET_TYPE_TO_AGENT_MARKET: dict[MarketType, type[AgentMarket]] = { MarketType.MANIFOLD: ManifoldAgentMarket, diff --git a/prediction_market_agent_tooling/markets/omen/omen.py b/prediction_market_agent_tooling/markets/omen/omen.py index 042e361e..c4685c2c 100644 --- a/prediction_market_agent_tooling/markets/omen/omen.py +++ b/prediction_market_agent_tooling/markets/omen/omen.py @@ -4,6 +4,7 @@ import tenacity from web3 import Web3 +from web3.constants import HASH_ZERO from prediction_market_agent_tooling.config import APIKeys from prediction_market_agent_tooling.gtypes import ( @@ -23,6 +24,7 @@ AgentMarket, FilterBy, MarketFees, + ProcessedMarket, SortBy, ) from prediction_market_agent_tooling.markets.data_models import ( @@ -39,7 +41,9 @@ PRESAGIO_BASE_URL, Condition, ConditionPreparationEvent, + ContractPrediction, CreatedMarket, + IPFSAgentResult, OmenBet, OmenMarket, OmenUserPosition, @@ -50,6 +54,7 @@ OMEN_DEFAULT_MARKET_FEE_PERC, REALITY_DEFAULT_FINALIZATION_TIMEOUT, Arbitrator, + OmenAgentResultMappingContract, OmenConditionalTokenContract, OmenFixedProductMarketMakerContract, OmenFixedProductMarketMakerFactoryContract, @@ -70,6 +75,7 @@ to_gnosis_chain_contract, ) from prediction_market_agent_tooling.tools.hexbytes_custom import HexBytes +from prediction_market_agent_tooling.tools.ipfs.ipfs_handler import IPFSHandler from prediction_market_agent_tooling.tools.utils import ( DatetimeUTC, calculate_sell_amount_in_collateral, @@ -78,6 +84,7 @@ from prediction_market_agent_tooling.tools.web3_utils import ( add_fraction, get_receipt_block_timestamp, + ipfscidv0_to_byte32, remove_fraction, wei_to_xdai, xdai_to_wei, @@ -392,6 +399,58 @@ def get_binary_market(id: str) -> "OmenAgentMarket": ) ) + @staticmethod + def redeem_winnings(api_keys: APIKeys) -> None: + redeem_from_all_user_positions(api_keys) + + @staticmethod + def get_trade_balance(api_keys: APIKeys, web3: Web3 | None = None) -> xDai: + return get_total_balance( + address=api_keys.bet_from_address, web3=web3, sum_xdai=True, sum_wxdai=True + ) + + @staticmethod + def verify_operational_balance(api_keys: APIKeys) -> bool: + return get_total_balance( + api_keys.public_key, # Use `public_key`, not `bet_from_address` because transaction costs are paid from the EOA wallet. + sum_wxdai=False, + ) > xdai_type(0.001) + + def store_prediction( + self, processed_market: ProcessedMarket, keys: APIKeys + ) -> None: + reasoning = ( + processed_market.answer.reasoning + if processed_market.answer.reasoning + else "" + ) + + ipfs_hash_decoded = HexBytes(HASH_ZERO) + if keys.enable_ipfs_upload: + logger.info("Storing prediction on IPFS.") + ipfs_hash = IPFSHandler(keys).store_agent_result( + IPFSAgentResult(reasoning=reasoning) + ) + ipfs_hash_decoded = ipfscidv0_to_byte32(ipfs_hash) + + tx_hashes = [ + HexBytes(HexStr(i.id)) for i in processed_market.trades if i.id is not None + ] + prediction = ContractPrediction( + publisher=keys.public_key, + ipfs_hash=ipfs_hash_decoded, + tx_hashes=tx_hashes, + estimated_probability_bps=int(processed_market.answer.p_yes * 10000), + ) + tx_receipt = OmenAgentResultMappingContract().add_prediction( + api_keys=keys, + market_address=Web3.to_checksum_address(self.id), + prediction=prediction, + ) + logger.info( + f"Added prediction to market {self.id}. - receipt {tx_receipt['transactionHash'].hex()}." + ) + @staticmethod def get_bets_made_since( better_address: ChecksumAddress, start_time: DatetimeUTC @@ -1222,13 +1281,12 @@ def get_binary_market_p_yes_history(market: OmenAgentMarket) -> list[Probability return history -def is_minimum_required_balance( +def get_total_balance( address: ChecksumAddress, - min_required_balance: xDai, web3: Web3 | None = None, sum_xdai: bool = True, sum_wxdai: bool = True, -) -> bool: +) -> xDai: """ Checks if the total balance of xDai and wxDai in the wallet is above the minimum required balance. """ @@ -1239,7 +1297,7 @@ def is_minimum_required_balance( total_balance += current_balances.xdai if sum_wxdai: total_balance += current_balances.wxdai - return total_balance >= min_required_balance + return xdai_type(total_balance) def withdraw_wxdai_to_xdai_to_keep_balance( diff --git a/pyproject.toml b/pyproject.toml index cc232817..cf3cd524 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "prediction-market-agent-tooling" -version = "0.53.0" +version = "0.54.0" description = "Tools to benchmark, deploy and monitor prediction market agents." authors = ["Gnosis"] readme = "README.md" diff --git a/tests_integration_with_local_chain/markets/omen/test_local_chain.py b/tests_integration_with_local_chain/markets/omen/test_local_chain.py index d27d95a8..2df6dcfd 100644 --- a/tests_integration_with_local_chain/markets/omen/test_local_chain.py +++ b/tests_integration_with_local_chain/markets/omen/test_local_chain.py @@ -8,9 +8,7 @@ from prediction_market_agent_tooling.config import APIKeys from prediction_market_agent_tooling.gtypes import private_key_type, xDai, xdai_type -from prediction_market_agent_tooling.markets.omen.omen import ( - is_minimum_required_balance, -) +from prediction_market_agent_tooling.markets.omen.omen import get_total_balance from prediction_market_agent_tooling.tools.balances import get_balances from prediction_market_agent_tooling.tools.contract import DebuggingContract from prediction_market_agent_tooling.tools.utils import utcnow @@ -88,16 +86,13 @@ def test_anvil_account_has_more_than_minimum_required_balance( accounts: list[TestAccount], ) -> None: account_adr = Web3.to_checksum_address(accounts[0].address) - assert is_minimum_required_balance(account_adr, xdai_type(0.5), local_web3) + assert get_total_balance(account_adr, local_web3) > xdai_type(0.5) -def test_fresh_account_has_less_than_minimum_required_balance( - local_web3: Web3, - accounts: list[TestAccount], -) -> None: +def test_fresh_account_has_less_than_minimum_required_balance(local_web3: Web3) -> None: fresh_account_adr = Account.create().address account_adr = Web3.to_checksum_address(fresh_account_adr) - assert not is_minimum_required_balance(account_adr, xdai_type(0.5), local_web3) + assert get_total_balance(account_adr, local_web3) < xdai_type(0.5) def test_now(local_web3: Web3, test_keys: APIKeys) -> None: