diff --git a/prediction_market_agent_tooling/markets/omen/omen.py b/prediction_market_agent_tooling/markets/omen/omen.py index 1041bfd5..fe3e6e2c 100644 --- a/prediction_market_agent_tooling/markets/omen/omen.py +++ b/prediction_market_agent_tooling/markets/omen/omen.py @@ -10,6 +10,7 @@ ChecksumAddress, HexAddress, HexStr, + OmenOutcomeToken, OutcomeStr, Probability, Wei, @@ -54,7 +55,10 @@ ) from prediction_market_agent_tooling.tools.balances import get_balances from prediction_market_agent_tooling.tools.hexbytes_custom import HexBytes -from prediction_market_agent_tooling.tools.utils import check_not_none +from prediction_market_agent_tooling.tools.utils import ( + calculate_sell_amount_in_collateral, + check_not_none, +) from prediction_market_agent_tooling.tools.web3_utils import ( add_fraction, remove_fraction, @@ -80,6 +84,8 @@ class OmenAgentMarket(AgentMarket): finalized_time: datetime | None created_time: datetime close_time: datetime + outcome_token_amounts: list[OmenOutcomeToken] + fee: float # proportion, from 0 to 1 INVALID_MARKET_ANSWER: HexStr = HexStr( "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" @@ -155,23 +161,50 @@ def place_bet( web3=web3, ) + def calculate_sell_amount_in_collateral( + self, amount: TokenAmount, outcome: bool + ) -> xDai: + if len(self.outcome_token_amounts) != 2: + raise ValueError( + f"Market {self.id} has {len(self.outcome_token_amounts)} " + f"outcomes. This method only supports binary markets." + ) + sell_index = self.yes_index if outcome else self.no_index + other_index = self.no_index if outcome else self.yes_index + collateral = calculate_sell_amount_in_collateral( + shares_to_sell=amount.amount, + holdings=wei_to_xdai(Wei(self.outcome_token_amounts[sell_index])), + other_holdings=wei_to_xdai(Wei(self.outcome_token_amounts[other_index])), + fee=self.fee, + ) + return xDai(collateral) + def sell_tokens( self, outcome: bool, amount: TokenAmount, auto_withdraw: bool = False, api_keys: APIKeys | None = None, + web3: Web3 | None = None, ) -> None: if not self.can_be_traded(): raise ValueError( f"Market {self.id} is not open for trading. Cannot sell tokens." ) + + # Convert from token (i.e. share) number to xDai value of tokens, as + # this is the expected unit of the argument in the smart contract. + collateral = self.calculate_sell_amount_in_collateral( + amount=amount, + outcome=outcome, + ) binary_omen_sell_outcome_tx( + amount=collateral, api_keys=api_keys if api_keys is not None else APIKeys(), - amount=xDai(amount.amount), market=self, binary_outcome=outcome, auto_withdraw=auto_withdraw, + web3=web3, ) def was_any_bet_outcome_correct( @@ -249,6 +282,8 @@ def from_data_model(model: OmenMarket) -> "OmenAgentMarket": url=model.url, volume=wei_to_xdai(model.collateralVolume), close_time=model.close_time, + outcome_token_amounts=model.outcomeTokenAmounts, + fee=float(wei_to_xdai(model.fee)) if model.fee is not None else 0.0, ) @staticmethod diff --git a/prediction_market_agent_tooling/tools/utils.py b/prediction_market_agent_tooling/tools/utils.py index 23cebed0..14b96dcf 100644 --- a/prediction_market_agent_tooling/tools/utils.py +++ b/prediction_market_agent_tooling/tools/utils.py @@ -7,6 +7,7 @@ import pytz import requests from pydantic import BaseModel, ValidationError +from scipy.optimize import newton from scipy.stats import entropy from prediction_market_agent_tooling.gtypes import ( @@ -173,3 +174,35 @@ def prob_uncertainty(prob: Probability) -> float: - Market's probability is 0.95, so the market is quite certain about YES: prob_uncertainty(0.95) == 0.286 """ return float(entropy([prob, 1 - prob], base=2)) + + +def calculate_sell_amount_in_collateral( + shares_to_sell: float, + holdings: float, + other_holdings: float, + fee: float, +) -> float: + """ + Computes the amount of collateral that needs to be sold to get `shares` + amount of shares. Returns None if the amount can't be computed. + + Taken from https://github.com/protofire/omen-exchange/blob/29d0ab16bdafa5cc0d37933c1c7608a055400c73/app/src/util/tools/fpmm/trading/index.ts#L99 + Simplified for binary markets. + """ + + if not (0 <= fee < 1.0): + raise ValueError("Fee must be between 0 and 1") + + for v in [shares_to_sell, holdings, other_holdings]: + if v <= 0: + raise ValueError("All share args must be greater than 0") + + def f(r: float) -> float: + R = r / (1 - fee) + first_term = other_holdings - R + second_term = holdings + shares_to_sell - R + third_term = holdings * other_holdings + return (first_term * second_term) - third_term + + amount_to_sell = newton(f, 0) + return float(amount_to_sell) * 0.999999 # Avoid rounding errors diff --git a/pyproject.toml b/pyproject.toml index e4b8806e..5f6abad7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "prediction-market-agent-tooling" -version = "0.34.0" +version = "0.35.0" description = "Tools to benchmark, deploy and monitor prediction market agents." authors = ["Gnosis"] readme = "README.md" diff --git a/scripts/sell_all_omen.py b/scripts/sell_all_omen.py index 8bbcceb4..33d8e732 100644 --- a/scripts/sell_all_omen.py +++ b/scripts/sell_all_omen.py @@ -49,7 +49,8 @@ def sell_all( ) bets_total_usd = sum(b.collateralAmountUSD for b in bets) unique_market_urls = set(b.fpmm.url for b in bets) - balances_before = get_balances(better_address) + starting_balance = get_balances(better_address) + new_balance = starting_balance # initialisation logger.info( f"For {better_address}, found the following {len(bets)} bets on {len(unique_market_urls)} unique markets worth of {bets_total_usd} USD: {unique_market_urls}" @@ -66,37 +67,20 @@ def sell_all( ) continue - # TODO: This should be fixable once https://github.com/gnosis/prediction-market-agent-tooling/issues/195 is resolved and used in this script. - # We need to convert `current_token_balance.amount` properly into their actual xDai value and then use it here. - # Afterwards, we can sell the actual xDai value, instead of just trying to sell these hard-coded values. - for current_xdai_value in [1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1]: - current_token_balance.amount = current_xdai_value + old_balance = new_balance + agent_market.sell_tokens( + bet.boolean_outcome, + current_token_balance, + auto_withdraw=auto_withdraw, + api_keys=api_keys, + ) + new_balance = get_balances(better_address) - try: - agent_market.sell_tokens( - bet.boolean_outcome, - current_token_balance, - auto_withdraw=auto_withdraw, - api_keys=api_keys, - ) - logger.info( - f"Sold bet on {bet.fpmm.url} for {current_xdai_value} xDai." - ) - break - except Exception as e: - # subtraction error is currently expected because of the TODO above, so log only other errors - if "Reverted SafeMath: subtraction overflow" not in str(e): - logger.error( - f"Failed to sell bet on {bet.fpmm.url} for {current_xdai_value} xDai because of {e}." - ) - continue - else: - logger.warning( - f"Skipped bet on {bet.fpmm.url} because of insufficient balance." - ) + logger.info( + f"Sold bet on {bet.fpmm.url} for {new_balance.wxdai - old_balance.wxdai} xDai." + ) - balances_after = get_balances(better_address) - logger.info(f"Obtained back {balances_after.wxdai - balances_before.wxdai} wxDai.") + logger.info(f"Obtained back {new_balance.wxdai - starting_balance.wxdai} wxDai.") if __name__ == "__main__": diff --git a/tests/markets/test_betting_strategies.py b/tests/markets/test_betting_strategies.py index 9b702962..79a5715c 100644 --- a/tests/markets/test_betting_strategies.py +++ b/tests/markets/test_betting_strategies.py @@ -118,6 +118,8 @@ def test_minimum_bet_to_win( url="url", volume=None, finalized_time=None, + outcome_token_amounts=[OmenOutcomeToken(2), OmenOutcomeToken(3)], + fee=0.02, ), ) assert ( diff --git a/tests/tools/test_utils.py b/tests/tools/test_utils.py new file mode 100644 index 00000000..00b1666c --- /dev/null +++ b/tests/tools/test_utils.py @@ -0,0 +1,83 @@ +import numpy as np +import pytest + +from prediction_market_agent_tooling.tools.utils import ( + calculate_sell_amount_in_collateral, +) + + +def test_calculate_sell_amount_in_collateral_0() -> None: + # Sanity check: If the market is 50/50, then the collateral value of one + # share is 0.5 + collateral = calculate_sell_amount_in_collateral( + shares_to_sell=1, + holdings=1000000000000 - 1, + other_holdings=1000000000000, + fee=0, + ) + assert np.isclose(collateral, 0.5) + + +def test_calculate_sell_amount_in_collateral_1() -> None: + # Sanity check that shares have near-zero value with this ratio + near_zero_collateral = calculate_sell_amount_in_collateral( + shares_to_sell=1, + holdings=10000000000000, + other_holdings=1, + fee=0, + ) + assert np.isclose(near_zero_collateral, 0) + + # Sanity check that shares have near-one value with this ratio + near_zero_collateral = calculate_sell_amount_in_collateral( + shares_to_sell=1, + holdings=1, + other_holdings=10000000000000, + fee=0, + ) + assert np.isclose(near_zero_collateral, 1) + + +def test_calculate_sell_amount_in_collateral_2() -> None: + # Sanity check: the value of sold shares decreases as the fee increases + def get_collateral(fee: float) -> float: + return calculate_sell_amount_in_collateral( + shares_to_sell=2.5, + holdings=10, + other_holdings=3, + fee=fee, + ) + + c1 = get_collateral(fee=0.1) + c2 = get_collateral(fee=0.35) + assert c1 > c2 + + +def test_calculate_sell_amount_in_collateral_3() -> None: + # Check error handling when fee is invalid + def get_collateral(fee: float) -> float: + return calculate_sell_amount_in_collateral( + shares_to_sell=2.5, + holdings=10, + other_holdings=3, + fee=fee, + ) + + with pytest.raises(ValueError) as e: + get_collateral(fee=-0.1) + assert str(e.value) == "Fee must be between 0 and 1" + + with pytest.raises(ValueError) as e: + get_collateral(fee=1.0) + assert str(e.value) == "Fee must be between 0 and 1" + + +def test_calculate_sell_amount_in_collateral_4() -> None: + with pytest.raises(ValueError) as e: + collateral = calculate_sell_amount_in_collateral( + shares_to_sell=100, + holdings=10, + other_holdings=0, + fee=0, + ) + assert str(e.value) == "All share args must be greater than 0" diff --git a/tests_integration/markets/omen/test_omen.py b/tests_integration/markets/omen/test_omen.py index 327906b1..3d10f9a3 100644 --- a/tests_integration/markets/omen/test_omen.py +++ b/tests_integration/markets/omen/test_omen.py @@ -1,6 +1,8 @@ +import os import time from datetime import timedelta +import numpy as np import pytest from eth_typing import HexAddress, HexStr from web3 import Web3 @@ -17,7 +19,6 @@ OMEN_DEFAULT_MARKET_FEE, OmenAgentMarket, binary_omen_buy_outcome_tx, - binary_omen_sell_outcome_tx, omen_create_market_tx, omen_fund_market_tx, omen_redeem_full_position_tx, @@ -30,6 +31,7 @@ from prediction_market_agent_tooling.markets.omen.omen_subgraph_handler import ( OmenSubgraphHandler, ) +from prediction_market_agent_tooling.tools.balances import get_balances from prediction_market_agent_tooling.tools.utils import utcnow from prediction_market_agent_tooling.tools.web3_utils import xdai_to_wei from tests_integration.conftest import is_contract @@ -220,25 +222,40 @@ def test_omen_buy_and_sell_outcome( # Tests both buying and selling, so we are back at the square one in the wallet (minues fees). # You can double check your address at https://gnosisscan.io/ afterwards. market = OmenAgentMarket.from_data_model(pick_binary_market()) - buy_amount = xdai_type(0.00142) - sell_amount = xdai_type( - buy_amount / 2 - ) # There will be some fees, so this has to be lower. - - binary_omen_buy_outcome_tx( - api_keys=test_keys, - amount=buy_amount, - market=market, - binary_outcome=True, - auto_deposit=True, + outcome = True + outcome_str = OMEN_TRUE_OUTCOME if outcome else OMEN_FALSE_OUTCOME + bet_amount = market.get_bet_amount(amount=0.4) + + # TODO hack until https://github.com/gnosis/prediction-market-agent-tooling/issues/266 is complete + os.environ[ + "BET_FROM_PRIVATE_KEY" + ] = test_keys.bet_from_private_key.get_secret_value() + api_keys = APIKeys() + + def get_market_outcome_tokens() -> TokenAmount: + return market.get_token_balance( + user_id=api_keys.bet_from_address, + outcome=outcome_str, + web3=local_web3, + ) + + # Check our wallet has sufficient funds + balances = get_balances(address=api_keys.bet_from_address, web3=local_web3) + assert balances.xdai + balances.wxdai > bet_amount.amount + + market.place_bet(outcome=outcome, amount=bet_amount, web3=local_web3) + + # Check that we now have a position in the market. + outcome_tokens = get_market_outcome_tokens() + assert outcome_tokens.amount > 0 + + market.sell_tokens( + outcome=outcome, + amount=outcome_tokens, web3=local_web3, + api_keys=api_keys, ) - binary_omen_sell_outcome_tx( - api_keys=test_keys, - amount=sell_amount, - market=market, - binary_outcome=True, - auto_withdraw=True, - web3=local_web3, - ) + # Check that we have sold our entire stake in the market. + remaining_tokens = get_market_outcome_tokens() + assert np.isclose(remaining_tokens.amount, 0, atol=5e-3)