diff --git a/tests/test_polymarket.py b/tests/test_polymarket.py new file mode 100644 index 000000000..e1b185218 --- /dev/null +++ b/tests/test_polymarket.py @@ -0,0 +1,129 @@ +"""Polymarket prediction-market vendor: forward-looking filtering, volume +ranking, formatting, graceful degradation, and router integration. + +All API access is mocked, so these run without a network connection. +""" +import copy +import unittest +from unittest import mock + +import pytest +import requests + +import tradingagents.dataflows.config as config_module +import tradingagents.default_config as default_config +from tradingagents.dataflows import interface, polymarket +from tradingagents.dataflows.config import set_config + + +def _market(question, prob, *, volume, end_date, closed=False, wk=None): + return { + "question": question, + "outcomes": '["Yes", "No"]', + "outcomePrices": f'["{prob}", "{round(1 - prob, 4)}"]', + "volumeNum": volume, + "endDate": end_date, + "closed": closed, + "oneWeekPriceChange": wk, + } + + +# One event with a mix: a high-volume open market, a closed one, a past-dated +# one, and a lower-volume open one. Far-future / far-past dates keep the test +# independent of the real clock. +_SEARCH = { + "events": [ + { + "markets": [ + _market("Open big?", 0.76, volume=5_000_000, end_date="2030-12-31T00:00:00Z", wk=-0.045), + _market("Resolved already?", 1.0, volume=9_000_000, end_date="2030-12-31T00:00:00Z", closed=True), + _market("Past event?", 0.5, volume=8_000_000, end_date="2020-01-01T00:00:00Z"), + _market("Open small?", 0.30, volume=1_000, end_date="2030-06-30T00:00:00Z"), + ] + } + ] +} + + +@pytest.mark.unit +class PolymarketFilterTests(unittest.TestCase): + def test_closed_and_past_markets_are_excluded(self): + with mock.patch.object(polymarket, "_request", return_value=_SEARCH): + out = polymarket.get_prediction_markets("anything", limit=10) + self.assertIn("Open big?", out) + self.assertIn("Open small?", out) + self.assertNotIn("Resolved already?", out) # closed + self.assertNotIn("Past event?", out) # endDate in the past + + def test_ranked_by_volume(self): + with mock.patch.object(polymarket, "_request", return_value=_SEARCH): + out = polymarket.get_prediction_markets("anything", limit=10) + self.assertLess(out.index("Open big?"), out.index("Open small?")) + + def test_limit_caps_results(self): + with mock.patch.object(polymarket, "_request", return_value=_SEARCH): + out = polymarket.get_prediction_markets("anything", limit=1) + self.assertIn("Open big?", out) + self.assertNotIn("Open small?", out) + + +@pytest.mark.unit +class PolymarketFormatTests(unittest.TestCase): + def test_probability_volume_and_weekly_change_render(self): + with mock.patch.object(polymarket, "_request", return_value=_SEARCH): + out = polymarket.get_prediction_markets("anything", limit=10) + self.assertIn("Yes 76%", out) + self.assertIn("$5,000,000 volume", out) + self.assertIn("resolves 2030-12-31", out) + self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp + + def test_weekly_change_omitted_when_absent(self): + # "Open small?" has wk=None -> no 1-week clause on its line. + with mock.patch.object(polymarket, "_request", return_value=_SEARCH): + out = polymarket.get_prediction_markets("anything", limit=10) + small_line = next(ln for ln in out.splitlines() if "Open small?" in ln) + self.assertNotIn("1-week", small_line) + + def test_no_matches_reports_clearly(self): + with mock.patch.object(polymarket, "_request", return_value={"events": []}): + out = polymarket.get_prediction_markets("obscure ticker", limit=6) + self.assertIn("No open prediction markets", out) + + +@pytest.mark.unit +class PolymarketResilienceTests(unittest.TestCase): + def test_network_error_degrades_gracefully(self): + # An external-service hiccup must not raise into the analyst. + with mock.patch.object( + polymarket, "_request", side_effect=requests.RequestException("boom") + ): + out = polymarket.get_prediction_markets("Fed rate cut") + self.assertIn("unavailable", out.lower()) + self.assertIn("Fed rate cut", out) + + +@pytest.mark.unit +class PolymarketRoutingTests(unittest.TestCase): + def setUp(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def tearDown(self): + config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG) + + def test_category_routes_to_polymarket(self): + self.assertEqual( + interface.get_category_for_method("get_prediction_markets"), + "prediction_markets", + ) + set_config({"data_vendors": {"prediction_markets": "polymarket"}}) + with mock.patch.dict( + interface.VENDOR_METHODS, + {"get_prediction_markets": {"polymarket": lambda *a, **k: "POLY_OK"}}, + clear=False, + ): + out = interface.route_to_vendor("get_prediction_markets", "fed", 5) + self.assertEqual(out, "POLY_OK") + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index 5f986df0a..996974549 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -5,6 +5,7 @@ from tradingagents.agents.utils.agent_utils import ( get_language_instruction, get_macro_indicators, get_news, + get_prediction_markets, ) from tradingagents.dataflows.config import get_config @@ -20,10 +21,11 @@ def create_news_analyst(llm): get_news, get_global_news, get_macro_indicators, + get_prediction_markets, ] system_message = ( - f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for {asset_label}-specific or targeted news searches, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, and get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for {asset_label}-specific or targeted news searches, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions." + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" + get_language_instruction() ) diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 5f2e46f2f..73201b785 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -26,6 +26,9 @@ from tradingagents.agents.utils.news_data_tools import ( from tradingagents.agents.utils.macro_data_tools import ( get_macro_indicators ) +from tradingagents.agents.utils.prediction_markets_tools import ( + get_prediction_markets +) from tradingagents.agents.utils.market_data_validation_tools import ( get_verified_market_snapshot ) diff --git a/tradingagents/agents/utils/prediction_markets_tools.py b/tradingagents/agents/utils/prediction_markets_tools.py new file mode 100644 index 000000000..843c9a49c --- /dev/null +++ b/tradingagents/agents/utils/prediction_markets_tools.py @@ -0,0 +1,31 @@ +from typing import Annotated + +from langchain_core.tools import tool + +from tradingagents.dataflows.interface import route_to_vendor + + +@tool +def get_prediction_markets( + topic: Annotated[ + str, + "Event topic/keyword, e.g. 'Fed rate cut', 'recession 2026', " + "'US election', or a sector/company event.", + ], + limit: Annotated[int | None, "Max markets to return; omit for a default of 6"] = None, +) -> str: + """ + Retrieve live, market-implied probabilities for forward-looking events from + prediction markets (Polymarket): Fed decisions, recession, elections, + geopolitics, crypto. Returns the most-traded open markets matching the + topic, each with its implied probability, traded volume, resolution date, + and recent move. Uses the configured prediction_markets vendor. + + Args: + topic (str): Event keyword(s) to search + limit (int): Max markets to return; omit for a default of 6 + + Returns: + str: A formatted markdown report of matching prediction markets + """ + return route_to_vendor("get_prediction_markets", topic, limit) diff --git a/tradingagents/dataflows/interface.py b/tradingagents/dataflows/interface.py index 9729b94a7..892abdbde 100644 --- a/tradingagents/dataflows/interface.py +++ b/tradingagents/dataflows/interface.py @@ -25,6 +25,7 @@ from .alpha_vantage import ( ) from .alpha_vantage_common import AlphaVantageRateLimitError from .fred import get_macro_data as get_fred_macro_data +from .polymarket import get_prediction_markets as get_polymarket_prediction_markets from .symbol_utils import NoMarketDataError # Configuration and routing logic @@ -68,12 +69,19 @@ TOOLS_CATEGORIES = { "tools": [ "get_macro_indicators", ] + }, + "prediction_markets": { + "description": "Market-implied probabilities for forward-looking events", + "tools": [ + "get_prediction_markets", + ] } } VENDOR_LIST = [ "yfinance", "fred", + "polymarket", "alpha_vantage", ] @@ -123,6 +131,10 @@ VENDOR_METHODS = { "get_macro_indicators": { "fred": get_fred_macro_data, }, + # prediction_markets + "get_prediction_markets": { + "polymarket": get_polymarket_prediction_markets, + }, } def get_category_for_method(method: str) -> str: diff --git a/tradingagents/dataflows/polymarket.py b/tradingagents/dataflows/polymarket.py new file mode 100644 index 000000000..b76dfe7f0 --- /dev/null +++ b/tradingagents/dataflows/polymarket.py @@ -0,0 +1,139 @@ +"""Polymarket prediction-market vendor. + +Surfaces live, market-implied probabilities for forward-looking events (Fed +decisions, recession, elections, geopolitics, crypto) to the news analyst, as a +complement to news (what happened) and FRED macro data (where things stand): +what the crowd actually prices to happen next. + +Uses Polymarket's public Gamma API (https://gamma-api.polymarket.com) — no key, +no auth. Each market's ``outcomePrices`` are the implied probabilities of its +outcomes (a "Yes" at 0.76 means the market prices a 76% chance). +""" +import json +import logging +from datetime import datetime, timezone + +import requests + +logger = logging.getLogger(__name__) + +GAMMA_BASE = "https://gamma-api.polymarket.com" + +# Network timeout (seconds), consistent with the other vendors. +REQUEST_TIMEOUT = 30 + +# Default number of markets to return, ranked by traded volume. +DEFAULT_LIMIT = 6 + + +def _request(path: str, params: dict) -> dict: + response = requests.get( + f"{GAMMA_BASE}/{path}", params=params, timeout=REQUEST_TIMEOUT + ) + response.raise_for_status() + return response.json() + + +def _parse_json_list(value) -> list: + """Gamma encodes ``outcomes``/``outcomePrices`` as JSON-string arrays.""" + if isinstance(value, list): + return value + try: + return json.loads(value) + except (json.JSONDecodeError, TypeError): + return [] + + +def _is_forward_looking(market: dict, now: datetime) -> bool: + """Keep only open markets that resolve in the future. + + ``closed`` is the reliable resolved flag (``active`` stays True even for + settled markets), and a past ``endDate`` means the event already resolved — + either way it is not a forward-looking signal. + """ + if market.get("closed"): + return False + end_date = market.get("endDate") + if end_date: + try: + if datetime.fromisoformat(end_date.replace("Z", "+00:00")) < now: + return False + except ValueError: + pass + return bool(_parse_json_list(market.get("outcomePrices"))) and bool( + _parse_json_list(market.get("outcomes")) + ) + + +def get_prediction_markets(topic: str, limit: int | None = None) -> str: + """Return live prediction-market probabilities for an event topic. + + Args: + topic: Event keyword(s), e.g. "Fed rate cut", "recession 2026", + "US election", or a sector/company event. + limit: Max markets to return (ranked by traded volume); ``None`` uses + DEFAULT_LIMIT. + + Returns: + A markdown report of the most-traded open markets matching the topic, + each with its implied probability, traded volume, resolution date, and + recent (1-week) move. + """ + if limit is None: + limit = DEFAULT_LIMIT + + try: + data = _request("public-search", {"q": topic, "limit_per_type": 20}) + except requests.RequestException as e: + logger.warning("Polymarket search failed for %r: %s", topic, e) + return ( + f"Polymarket data is currently unavailable (network error: {e}). " + f"Proceed without prediction-market signal for '{topic}'." + ) + + now = datetime.now(timezone.utc) + candidates = [ + m + for event in data.get("events", []) + for m in event.get("markets", []) + if _is_forward_looking(m, now) + ] + candidates.sort(key=lambda m: m.get("volumeNum") or 0, reverse=True) + + header = ( + f'## Polymarket prediction markets: "{topic}"\n' + f"Live, market-implied probabilities (higher traded volume = deeper, " + f"more reliable). A probability is the crowd's priced odds of the event, " + f"not a forecast you should take as certain.\n\n" + ) + + if not candidates: + return header + ( + f"No open prediction markets matched '{topic}'. Polymarket coverage " + f"is concentrated in macro, political, geopolitical, and crypto " + f"events; a specific equity may have none." + ) + + lines = [] + for m in candidates[:limit]: + prices = _parse_json_list(m.get("outcomePrices")) + outcomes = _parse_json_list(m.get("outcomes")) + try: + prob = float(prices[0]) + except (ValueError, IndexError): + continue + label = outcomes[0] if outcomes else "Yes" + volume = m.get("volumeNum") or 0 + end_date = (m.get("endDate") or "")[:10] + wk = m.get("oneWeekPriceChange") + wk_str = ( + f", 1-week {wk * 100:+.1f}pp" + if isinstance(wk, (int, float)) and wk + else "" + ) + lines.append( + f"- **{m.get('question')}** — {label} {prob:.0%} " + f"(${volume:,.0f} volume, resolves {end_date}{wk_str})" + ) + + return header + "\n".join(lines) + "\n" diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 9e9df9100..6601a38ac 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -107,6 +107,7 @@ DEFAULT_CONFIG = _apply_env_overrides({ "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance "news_data": "yfinance", # Options: alpha_vantage, yfinance "macro_data": "fred", # Options: fred (needs FRED_API_KEY) + "prediction_markets": "polymarket", # Options: polymarket (keyless) }, # Tool-level configuration (takes precedence over category-level) "tool_vendors": { diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8221f4e14..f9a0d9446 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -40,7 +40,8 @@ from tradingagents.agents.utils.agent_utils import ( get_news, get_insider_transactions, get_global_news, - get_macro_indicators + get_macro_indicators, + get_prediction_markets ) from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id @@ -194,6 +195,7 @@ class TradingAgentsGraph: get_global_news, get_insider_transactions, get_macro_indicators, + get_prediction_markets, ] ), "fundamentals": ToolNode(