diff --git a/prediction_market_agent_tooling/markets/agent_market.py b/prediction_market_agent_tooling/markets/agent_market.py index 60a0fea9..71168775 100644 --- a/prediction_market_agent_tooling/markets/agent_market.py +++ b/prediction_market_agent_tooling/markets/agent_market.py @@ -114,6 +114,20 @@ def get_last_trade_p_no(self) -> Probability | None: """ raise NotImplementedError("Subclasses must implement this method") + def get_last_trade_yes_outcome_price(self) -> float | None: + # Price on prediction markets are, by definition, equal to the probability of an outcome. + # Just making it explicit in this function. + if last_trade_p_yes := self.get_last_trade_p_yes(): + return float(last_trade_p_yes) + return None + + def get_last_trade_no_outcome_price(self) -> float | None: + # Price on prediction markets are, by definition, equal to the probability of an outcome. + # Just making it explicit in this function. + if last_trade_p_no := self.get_last_trade_p_no(): + return float(last_trade_p_no) + return None + def get_bet_amount(self, amount: float) -> BetAmount: return BetAmount(amount=amount, currency=self.currency) @@ -193,6 +207,13 @@ def get_positions(cls, user_id: str, liquid_only: bool = False) -> list[Position """ raise NotImplementedError("Subclasses must implement this method") + @classmethod + def get_positions_value(cls, positions: list[Position]) -> BetAmount: + """ + Get the total value of all positions held by a user. + """ + raise NotImplementedError("Subclasses must implement this method") + def can_be_traded(self) -> bool: if self.is_closed() or not self.has_liquidity(): return False diff --git a/prediction_market_agent_tooling/markets/omen/omen.py b/prediction_market_agent_tooling/markets/omen/omen.py index 352855fd..9e776f1c 100644 --- a/prediction_market_agent_tooling/markets/omen/omen.py +++ b/prediction_market_agent_tooling/markets/omen/omen.py @@ -421,6 +421,67 @@ def get_positions(cls, user_id: str, liquid_only: bool = False) -> list[Position return positions + @classmethod + def get_positions_value(cls, positions: list[Position]) -> BetAmount: + # Two dicts to map from market ids to (1) positions and (2) market. + market_ids_positions = {p.market_id: p for p in positions} + # Check there is only one position per market. + if len(set(market_ids_positions.keys())) != len(positions): + raise ValueError( + f"Markets for positions ({market_ids_positions.keys()}) are not unique." + ) + markets: list[OmenAgentMarket] = [ + OmenAgentMarket.from_data_model(m) + for m in OmenSubgraphHandler().get_omen_binary_markets( + limit=sys.maxsize, id_in=list(market_ids_positions.keys()) + ) + ] + market_ids_markets = {m.id: m for m in markets} + + # Validate that dict keys are the same. + if set(market_ids_positions.keys()) != set(market_ids_markets.keys()): + raise ValueError( + f"Market ids in {market_ids_positions.keys()} are not the same as in {market_ids_markets.keys()}" + ) + + # Initialise position value. + total_position_value = 0.0 + + for market_id in market_ids_positions.keys(): + position = market_ids_positions[market_id] + market = market_ids_markets[market_id] + + yes_tokens = 0.0 + no_tokens = 0.0 + if OMEN_TRUE_OUTCOME in position.amounts: + yes_tokens = position.amounts[OutcomeStr(OMEN_TRUE_OUTCOME)].amount + if OMEN_FALSE_OUTCOME in position.amounts: + no_tokens = position.amounts[OutcomeStr(OMEN_FALSE_OUTCOME)].amount + + # Account for the value of positions in resolved markets + if market.is_resolved() and market.has_successful_resolution(): + valued_tokens = yes_tokens if market.boolean_outcome else no_tokens + total_position_value += valued_tokens + + # Or if the market is open and trading, get the value of the position + elif market.can_be_traded(): + total_position_value += yes_tokens * market.yes_outcome_price + total_position_value += no_tokens * market.no_outcome_price + + # Or if the market is still open but not trading, estimate the value + # of the position + else: + if yes_tokens: + yes_price = check_not_none( + market.get_last_trade_yes_outcome_price() + ) + total_position_value += yes_tokens * yes_price + if no_tokens: + no_price = check_not_none(market.get_last_trade_no_outcome_price()) + total_position_value += no_tokens * no_price + + return BetAmount(amount=total_position_value, currency=Currency.xDai) + @classmethod def get_user_url(cls, keys: APIKeys) -> str: return f"https://gnosisscan.io/address/{keys.bet_from_address}" diff --git a/prediction_market_agent_tooling/markets/omen/omen_subgraph_handler.py b/prediction_market_agent_tooling/markets/omen/omen_subgraph_handler.py index 6fb99c97..53752d4d 100644 --- a/prediction_market_agent_tooling/markets/omen/omen_subgraph_handler.py +++ b/prediction_market_agent_tooling/markets/omen/omen_subgraph_handler.py @@ -166,6 +166,7 @@ def _build_where_statements( resolved: bool | None = None, liquidity_bigger_than: Wei | None = None, condition_id_in: list[HexBytes] | None = None, + id_in: list[str] | None = None, excluded_questions: set[str] | None = None, ) -> dict[str, t.Any]: where_stms: dict[str, t.Any] = { @@ -193,6 +194,9 @@ def _build_where_statements( if condition_id_in is not None: where_stms["condition_"]["id_in"] = [x.hex() for x in condition_id_in] + if id_in is not None: + where_stms["id_in"] = id_in + if resolved is not None: if resolved: where_stms["resolutionTimestamp_not"] = None @@ -308,6 +312,7 @@ def get_omen_binary_markets( creator: t.Optional[HexAddress] = None, liquidity_bigger_than: Wei | None = None, condition_id_in: list[HexBytes] | None = None, + id_in: list[str] | None = None, excluded_questions: set[str] | None = None, # question titles sort_by_field: FieldPath | None = None, sort_direction: str | None = None, @@ -326,6 +331,7 @@ def get_omen_binary_markets( finalized=finalized, resolved=resolved, condition_id_in=condition_id_in, + id_in=id_in, excluded_questions=excluded_questions, liquidity_bigger_than=liquidity_bigger_than, ) diff --git a/pyproject.toml b/pyproject.toml index e89381b1..ab3ea116 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "prediction-market-agent-tooling" -version = "0.39.0" +version = "0.39.1" description = "Tools to benchmark, deploy and monitor prediction market agents." authors = ["Gnosis"] readme = "README.md" diff --git a/tests/markets/omen/test_omen.py b/tests/markets/omen/test_omen.py index 2df7c330..b992d81d 100644 --- a/tests/markets/omen/test_omen.py +++ b/tests/markets/omen/test_omen.py @@ -1,10 +1,14 @@ +from datetime import datetime + import numpy as np from eth_account import Account from eth_typing import HexAddress, HexStr from web3 import Web3 -from prediction_market_agent_tooling.gtypes import DatetimeWithTimezone, OutcomeStr +from prediction_market_agent_tooling.gtypes import DatetimeWithTimezone, OutcomeStr, Wei from prediction_market_agent_tooling.markets.agent_market import FilterBy, SortBy +from prediction_market_agent_tooling.markets.data_models import Position, TokenAmount +from prediction_market_agent_tooling.markets.omen.data_models import OmenBet from prediction_market_agent_tooling.markets.omen.omen import ( OmenAgentMarket, get_binary_market_p_yes_history, @@ -14,6 +18,7 @@ OmenSubgraphHandler, ) from prediction_market_agent_tooling.tools.utils import check_not_none, utcnow +from prediction_market_agent_tooling.tools.web3_utils import wei_to_xdai def test_omen_pick_binary_market() -> None: @@ -145,3 +150,41 @@ def test_get_positions_1() -> None: assert token_balance.amount == position.amounts[OutcomeStr(outcome_str)].amount print(position) # For extra test coverage + + +def test_positions_value() -> None: + """ + Test that an artificial user position (generated based on a historical + resolved bet) has the correct expected value, based on the bet's profit + """ + user_address = Web3.to_checksum_address( + "0x2DD9f5678484C1F59F97eD334725858b938B4102" + ) + resolved_bets = OmenSubgraphHandler().get_resolved_bets_with_valid_answer( + start_time=datetime(2024, 3, 27, 4, 20), + end_time=datetime(2024, 3, 27, 4, 30), + better_address=user_address, + ) + assert len(resolved_bets) == 1 + bet = resolved_bets[0] + assert bet.to_generic_resolved_bet().is_correct + + def bet_to_position(bet: OmenBet) -> Position: + market = OmenAgentMarket.get_binary_market(bet.fpmm.id) + outcome_str = OutcomeStr(market.get_outcome_str(bet.outcomeIndex)) + outcome_tokens = TokenAmount( + amount=wei_to_xdai(Wei(bet.outcomeTokensTraded)), + currency=OmenAgentMarket.currency, + ) + return Position(market_id=market.id, amounts={outcome_str: outcome_tokens}) + + positions = [bet_to_position(bet)] + position_value = OmenAgentMarket.get_positions_value(positions=positions) + + bet_value_amount = bet.get_profit().amount + wei_to_xdai(bet.collateralAmount) + assert np.isclose( + position_value.amount, + bet_value_amount, + rtol=1e-3, # relax tolerances due to fees + atol=1e-3, + ) diff --git a/tests/markets/omen/test_omen_subgraph_handler.py b/tests/markets/omen/test_omen_subgraph_handler.py index 84ef1085..9f41dd66 100644 --- a/tests/markets/omen/test_omen_subgraph_handler.py +++ b/tests/markets/omen/test_omen_subgraph_handler.py @@ -268,3 +268,14 @@ def build_incomplete_user_position_from_condition_ids( conditionIds=condition_ids, ) ) + + +def test_get_markets_id_in() -> None: + market_id = "0x934b9f379dd9d8850e468df707d58711da2966cd" + sgh = OmenSubgraphHandler() + markets = sgh.get_omen_binary_markets( + limit=1, + id_in=[market_id], + ) + assert len(markets) == 1 + assert markets[0].id == market_id