Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Monitor market resolutions #16

Merged
merged 6 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ celerybeat.pid

# Environments
.env
.env.monitor
.venv
env/
venv/
Expand Down
19 changes: 3 additions & 16 deletions examples/monitor/monitor.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import streamlit as st

from prediction_market_agent_tooling.markets.manifold import get_authenticated_user
from prediction_market_agent_tooling.monitor.markets.manifold import (
DeployedManifoldAgent,
)
from prediction_market_agent_tooling.monitor.monitor import monitor_agent
from prediction_market_agent_tooling.monitor.monitor_app import monitor_app

if __name__ == "__main__":
start_time = datetime.now() - timedelta(weeks=1)
agent = DeployedManifoldAgent(
name="foo",
start_time=start_time.astimezone(ZoneInfo("UTC")),
manifold_user_id=get_authenticated_user().id,
)
st.set_page_config(layout="wide") # Best viewed with a wide screen
st.title(f"Monitoring Agent: '{agent.name}'")
monitor_agent(agent)
st.title(f"Monitoring")
monitor_app()
13 changes: 12 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 88 additions & 17 deletions prediction_market_agent_tooling/benchmark/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import json
import typing as t
from datetime import datetime
from enum import Enum

import pytz
import requests
from pydantic import BaseModel, validator

MANIFOLD_API_LIMIT = 1000 # Manifold will only return up to 1000 markets


class EvaluatedQuestion(BaseModel):
question: str
Expand All @@ -23,6 +27,7 @@ class Market(BaseModel):
p_yes: float
volume: float
is_resolved: bool
created_time: datetime
resolution: str | None = None
outcomePrices: list[float] | None = None

Expand All @@ -34,6 +39,12 @@ def _validate_outcome_prices(cls, value: list[float] | None) -> list[float] | No
raise ValueError("outcomePrices must have exactly 2 elements.")
return value

@validator("created_time")
def _validate_created_time(cls, value: datetime) -> datetime:
if value.tzinfo is None:
value = value.replace(tzinfo=pytz.UTC)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need timezone-aware datetime to compare it with others.

return value

@property
def p_no(self) -> float:
return 1 - self.p_yes
Expand Down Expand Up @@ -103,22 +114,24 @@ def save(self, path: str) -> None:
@staticmethod
def load(path: str) -> "PredictionsCache":
with open(path, "r") as f:
return PredictionsCache.parse_obj(json.load(f))
return PredictionsCache.model_validate(json.load(f))


def get_manifold_markets(
number: int = 100,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added offset, so I renamed this to limit to adhere to the API convention.

excluded_questions: t.List[str] = [],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wasn't used anywhere and it would make logic for the two new functions harder, but lmk if we need it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😢 I was using it here gnosis/mech#1. Am I missing something - can't you just add it as an arg to the two new functions and pass it in from here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops sorry! Sure added!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

limit: int = 100,
offset: int = 0,
filter_: t.Literal[
"open", "closed", "resolved", "closing-this-month", "closing-next-month"
] = "open",
sort: t.Literal["liquidity", "score", "newest"] = "liquidity",
) -> t.List[Market]:
url = "https://api.manifold.markets/v0/search-markets"
params = {
"term": "",
"sort": "liquidity",
"sort": sort,
"filter": filter_,
"limit": f"{number + len(excluded_questions)}",
"limit": f"{limit}",
"offset": offset,
"contractType": "BINARY", # TODO support CATEGORICAL markets
}
response = requests.get(url, params=params)
Expand All @@ -132,27 +145,85 @@ def get_manifold_markets(
fields_map = {
"probability": "p_yes",
"isResolved": "is_resolved",
"createdTime": "created_time",
}

def _map_fields(old: dict[str, str], mapping: dict[str, str]) -> dict[str, str]:
return {mapping.get(k, k): v for k, v in old.items()}

markets = [Market.parse_obj(_map_fields(m, fields_map)) for m in markets_json]
markets = [Market.model_validate(_map_fields(m, fields_map)) for m in markets_json]

return markets


def get_manifold_markets_paged(
number: int = 100,
filter_: t.Literal[
"open", "closed", "resolved", "closing-this-month", "closing-next-month"
] = "open",
sort: t.Literal["liquidity", "score", "newest"] = "liquidity",
starting_offset: int = 0,
excluded_questions: set[str] | None = None,
) -> t.List[Market]:
markets: list[Market] = []

offset = starting_offset
while len(markets) < number:
new_markets = get_manifold_markets(
limit=min(MANIFOLD_API_LIMIT, number - len(markets)),
offset=offset,
filter_=filter_,
sort=sort,
)
if not new_markets:
break
markets.extend(
market
for market in new_markets
if not excluded_questions or market.question not in excluded_questions
)
offset += len(new_markets)

return markets

# Filter out markets with excluded questions
markets = [m for m in markets if m.question not in excluded_questions]

return markets[:number]
def get_manifold_markets_dated(
oldest_date: datetime,
filter_: t.Literal[
"open", "closed", "resolved", "closing-this-month", "closing-next-month"
] = "open",
excluded_questions: set[str] | None = None,
) -> t.List[Market]:
markets: list[Market] = []

offset = 0
while True:
new_markets = get_manifold_markets(
limit=MANIFOLD_API_LIMIT,
offset=offset,
filter_=filter_,
sort="newest", # Enforce sorting by newest, because there aren't date filters on the API.
)
if not new_markets:
break
for market in new_markets:
if market.created_time < oldest_date:
return markets
if not excluded_questions or market.question not in excluded_questions:
markets.append(market)
offset += 1

return markets


def get_polymarket_markets(
number: int = 100,
excluded_questions: t.List[str] = [],
limit: int = 100,
active: bool | None = True,
closed: bool | None = False,
excluded_questions: set[str] | None = None,
) -> t.List[Market]:
params: dict[str, str | int] = {
"_limit": number + len(excluded_questions),
"_limit": limit,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to be on par with manifold logic. I wanted to implement the same new functions for polymarket, but:

Who did you ask for the special endpoint? I can ask if they can make it paged as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's annoying. It was pre-existing and I just found it by googling.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then let's leave it for now, and I will take a look in the next PR.

}
if active is not None:
params["active"] = "true" if active else "false"
Expand All @@ -167,8 +238,7 @@ def get_polymarket_markets(
if m_json["outcomes"] != ["Yes", "No"]:
continue

if m_json["question"] in excluded_questions:
print(f"Skipping market with 'excluded question': {m_json['question']}")
if excluded_questions and m_json["question"] in excluded_questions:
continue

markets.append(
Expand All @@ -178,6 +248,7 @@ def get_polymarket_markets(
p_yes=m_json["outcomePrices"][
0
], # For binary markets on Polymarket, the first outcome is "Yes" and outcomePrices are equal to probabilities.
created_time=m_json["created_at"],
outcomePrices=m_json["outcomePrices"],
volume=m_json["volume"],
is_resolved=False,
Expand All @@ -190,15 +261,15 @@ def get_polymarket_markets(
def get_markets(
number: int,
source: MarketSource,
excluded_questions: t.List[str] = [],
excluded_questions: set[str] | None = None,
) -> t.List[Market]:
if source == MarketSource.MANIFOLD:
return get_manifold_markets(
return get_manifold_markets_paged(
number=number, excluded_questions=excluded_questions
)
elif source == MarketSource.POLYMARKET:
return get_polymarket_markets(
number=number, excluded_questions=excluded_questions
limit=number, excluded_questions=excluded_questions
)
else:
raise ValueError(f"Unknown market source: {source}")
Expand Down
2 changes: 1 addition & 1 deletion prediction_market_agent_tooling/markets/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ class ManifoldMarket(BaseModel):
isResolved: bool
resolution: t.Optional[str] = None
resolutionTime: t.Optional[datetime] = None
lastBetTime: datetime
lastBetTime: t.Optional[datetime] = None
lastCommentTime: t.Optional[datetime] = None
lastUpdatedTime: datetime
mechanism: str
Expand Down
4 changes: 2 additions & 2 deletions prediction_market_agent_tooling/markets/manifold.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ def place_bet(amount: Mana, market_id: str, outcome: bool) -> None:
)


def get_authenticated_user() -> ManifoldUser:
def get_authenticated_user(api_key: str) -> ManifoldUser:
url = "https://api.manifold.markets/v0/me"
headers = {
"Authorization": f"Key {APIKeys().manifold_api_key}",
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
}
response = requests.get(url, headers=headers)
Expand Down
84 changes: 82 additions & 2 deletions prediction_market_agent_tooling/monitor/monitor.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import typing as t
from datetime import datetime
from itertools import groupby

import altair as alt
import numpy as np
import pandas as pd
import streamlit as st
from pydantic import BaseModel

from prediction_market_agent_tooling.benchmark.utils import Market
from prediction_market_agent_tooling.markets.data_models import ResolvedBet
from prediction_market_agent_tooling.tools.utils import should_not_happen


class DeployedAgent(BaseModel):
Expand All @@ -20,6 +24,9 @@ def get_resolved_bets(self) -> list[ResolvedBet]:

def monitor_agent(agent: DeployedAgent) -> None:
agent_bets = agent.get_resolved_bets()
if not agent_bets:
st.warning(f"No resolved bets found for {agent.name}.")
return
bets_info = {
"Market Question": [bet.market_question for bet in agent_bets],
"Bet Amount": [bet.amount.amount for bet in agent_bets],
Expand Down Expand Up @@ -47,7 +54,6 @@ def monitor_agent(agent: DeployedAgent) -> None:
profit_df.groupby("Date")["Cumulative Profit"].sum().cumsum().reset_index()
)
profit_df["Cumulative Profit"] = profit_df["Cumulative Profit"].astype(float)
st.empty()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed these two empty functions. Empty is used as a placeholder, for example we could do

placeholder = st.empty()

# Some logic

...

# Write something as usual

st.write(...)

# Use the placeholder, now this would be actually written first at the page.

placeholder.write(...)

But alone like this it should do nothing. Sorry I didn't catch it during the review!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah my bad. I meant it for vertical whitespace. Looks like that should be st.write('\n') instead

st.altair_chart(
alt.Chart(profit_df)
.mark_line()
Expand All @@ -60,6 +66,80 @@ def monitor_agent(agent: DeployedAgent) -> None:
)

# Table of resolved bets
st.empty()
st.subheader("Resolved Bet History")
st.table(bets_df)


def monitor_market(open_markets: list[Market], resolved_markets: list[Market]) -> None:
date_to_open_yes_proportion = {
d: np.mean([int(m.p_yes > 0.5) for m in markets])
for d, markets in groupby(open_markets, lambda x: x.created_time.date())
}
date_to_resolved_yes_proportion = {
d: np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in markets
]
)
for d, markets in groupby(resolved_markets, lambda x: x.created_time.date())
}

df_open = pd.DataFrame(
date_to_open_yes_proportion.items(), columns=["date", "open_proportion"]
)
df_open["open_label"] = "Open's yes proportion"
df_resolved = pd.DataFrame(
date_to_resolved_yes_proportion.items(), columns=["date", "resolved_proportion"]
)
df_resolved["resolved_label"] = "Resolved's yes proportion"

df = pd.merge(df_open, df_resolved, on="date")

open_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="open_proportion:Q", color="open_label:N")
)

resolved_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="resolved_proportion:Q", color="resolved_label:N")
)

st.altair_chart(
alt.layer(open_chart, resolved_chart).interactive(), # type: ignore # Doesn't expect `LayerChart`, but `Chart`, yet it works.
use_container_width=True,
)

all_open_markets_yes_mean = np.mean([int(m.p_yes > 0.5) for m in open_markets])
all_resolved_markets_yes_mean = np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in resolved_markets
]
)
st.markdown(
f"Total number of open markets {len(open_markets)} and resolved markets {len(resolved_markets)}"
"\n\n"
f"Mean proportion of 'YES' in open markets: {all_open_markets_yes_mean:.2f}"
"\n\n"
f"Mean proportion of 'YES' in resolved markets: {all_resolved_markets_yes_mean:.2f}"
)
Comment on lines +73 to +145
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new monitor_market function introduces comprehensive logic for analyzing and visualizing market data. Consider the following points for improvement:

  1. The usage of groupby requires the input list to be sorted by the key function (lambda x: x.created_time.date()). Ensure that open_markets and resolved_markets are sorted before applying groupby, or sort them within the function.
  2. The handling of unexpected resolutions using should_not_happen is a good practice for error detection. However, ensure that this does not interrupt the user experience by catching exceptions and displaying an appropriate message instead.
  3. The visualization with Altair charts is a great addition. Verify that the charts display correctly in the intended environment (e.g., Streamlit) and that the data is presented in an understandable manner.

Overall, the function significantly enhances the tool's capabilities but requires careful attention to error handling and data preparation.

+ # Ensure markets are sorted by created_time.date() before applying groupby
+ open_markets.sort(key=lambda x: x.created_time.date())
+ resolved_markets.sort(key=lambda x: x.created_time.date())

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
def monitor_market(open_markets: list[Market], resolved_markets: list[Market]) -> None:
date_to_open_yes_proportion = {
d: np.mean([int(m.p_yes > 0.5) for m in markets])
for d, markets in groupby(open_markets, lambda x: x.created_time.date())
}
date_to_resolved_yes_proportion = {
d: np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in markets
]
)
for d, markets in groupby(resolved_markets, lambda x: x.created_time.date())
}
df_open = pd.DataFrame(
date_to_open_yes_proportion.items(), columns=["date", "open_proportion"]
)
df_open["open_label"] = "Open's yes proportion"
df_resolved = pd.DataFrame(
date_to_resolved_yes_proportion.items(), columns=["date", "resolved_proportion"]
)
df_resolved["resolved_label"] = "Resolved's yes proportion"
df = pd.merge(df_open, df_resolved, on="date")
open_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="open_proportion:Q", color="open_label:N")
)
resolved_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="resolved_proportion:Q", color="resolved_label:N")
)
st.altair_chart(
alt.layer(open_chart, resolved_chart).interactive(), # type: ignore # Doesn't expect `LayerChart`, but `Chart`, yet it works.
use_container_width=True,
)
all_open_markets_yes_mean = np.mean([int(m.p_yes > 0.5) for m in open_markets])
all_resolved_markets_yes_mean = np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in resolved_markets
]
)
st.markdown(
f"Total number of open markets {len(open_markets)} and resolved markets {len(resolved_markets)}"
"\n\n"
f"Mean proportion of 'YES' in open markets: {all_open_markets_yes_mean:.2f}"
"\n\n"
f"Mean proportion of 'YES' in resolved markets: {all_resolved_markets_yes_mean:.2f}"
)
def monitor_market(open_markets: list[Market], resolved_markets: list[Market]) -> None:
# Ensure markets are sorted by created_time.date() before applying groupby
open_markets.sort(key=lambda x: x.created_time.date())
resolved_markets.sort(key=lambda x: x.created_time.date())
date_to_open_yes_proportion = {
d: np.mean([int(m.p_yes > 0.5) for m in markets])
for d, markets in groupby(open_markets, lambda x: x.created_time.date())
}
date_to_resolved_yes_proportion = {
d: np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in markets
]
)
for d, markets in groupby(resolved_markets, lambda x: x.created_time.date())
}
df_open = pd.DataFrame(
date_to_open_yes_proportion.items(), columns=["date", "open_proportion"]
)
df_open["open_label"] = "Open's yes proportion"
df_resolved = pd.DataFrame(
date_to_resolved_yes_proportion.items(), columns=["date", "resolved_proportion"]
)
df_resolved["resolved_label"] = "Resolved's yes proportion"
df = pd.merge(df_open, df_resolved, on="date")
open_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="open_proportion:Q", color="open_label:N")
)
resolved_chart = (
alt.Chart(df)
.mark_line()
.encode(x="date:T", y="resolved_proportion:Q", color="resolved_label:N")
)
st.altair_chart(
alt.layer(open_chart, resolved_chart).interactive(), # type: ignore # Doesn't expect `LayerChart`, but `Chart`, yet it works.
use_container_width=True,
)
all_open_markets_yes_mean = np.mean([int(m.p_yes > 0.5) for m in open_markets])
all_resolved_markets_yes_mean = np.mean(
[
(
1
if m.resolution == "YES"
else (
0
if m.resolution == "NO"
else should_not_happen(f"Unexpected resolution: {m.resolution}")
)
)
for m in resolved_markets
]
)
st.markdown(
f"Total number of open markets {len(open_markets)} and resolved markets {len(resolved_markets)}"
"\n\n"
f"Mean proportion of 'YES' in open markets: {all_open_markets_yes_mean:.2f}"
"\n\n"
f"Mean proportion of 'YES' in resolved markets: {all_resolved_markets_yes_mean:.2f}"
)

Loading
Loading