From fbd86fb43903102015b875d39c2a1275fd4b4e91 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sat, 20 Jun 2026 10:07:17 +0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20v0.3.0=20major=20upgrade=20?= =?UTF-8?q?=E2=80=94=20lazy=20import,=20config,=20registry,=20shaper,=20sa?= =?UTF-8?q?fety=20gates,=20CI/CD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lazy PMXT import: package imports cleanly without pmxt installed - Runtime config/mode detection (hosted, custom, local-sidecar) - Tool registry with 33 PMXT methods and safety annotations - Result shaping for compact LLM-friendly outputs - Destructive operations require confirmed=True (build/submit/cancel) - Generic pmxt_call() with registry-driven dispatch - 17 known exchanges with capability detection - pmxt_runtime_status() and pmxt_list_exchanges() diagnostics - 42 unit tests passing without pmxt, 12 integration tests marked - GitHub Actions CI: lint + unit tests on Python 3.10-3.12 - Updated README, SKILL.md, LEARNINGS.md for v0.3.0 --- .github/workflows/test.yml | 36 ++ LEARNINGS.md | 147 +++++--- README.md | 220 +++++++---- hermes_pmxt/__init__.py | 57 ++- hermes_pmxt/config.py | 145 ++++++++ hermes_pmxt/exchanges.py | 43 ++- hermes_pmxt/registry.py | 251 +++++++++++++ hermes_pmxt/shaper.py | 255 +++++++++++++ hermes_pmxt/tools.py | 320 ++++++++++++++++ pyproject.toml | 16 +- skill/SKILL.md | 137 +++---- tests/test_exchanges.py | 127 +++++-- tests/test_tools.py | 732 ++++++++++++++++++++++--------------- 13 files changed, 1969 insertions(+), 517 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 hermes_pmxt/config.py create mode 100644 hermes_pmxt/registry.py create mode 100644 hermes_pmxt/shaper.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a28549c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Test + +on: + push: + branches: [main, harry] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev deps + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint with ruff + run: ruff check hermes_pmxt/ tests/ + + - name: Run unit tests + run: python -m pytest -q -m unit --tb=short + + - name: Verify package can be imported without pmxt + run: python -c "import hermes_pmxt; print('OK v' + hermes_pmxt.__version__)" diff --git a/LEARNINGS.md b/LEARNINGS.md index 31fa186..c7f2e06 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -1,66 +1,111 @@ -# Learnings — Building hermes-pmxt +# Learnings -- Building hermes-pmxt v0.3.0 -Things discovered during implementation that differ from the docs/research. +Things discovered during upgrade that differ from docs/research. -## pmxt SDK Realities +## pmxt SDK Realities (v2.50.x) -### server.status() returns a dict, not an object -The docs suggest `status.running`, `status.pid` etc. In practice, `pmxt.server.status()` -returns a plain `dict` with keys: `running`, `pid`, `port`, `version`, `uptimeSeconds`, -`lock_file`. Must use `.get()` not attribute access. +### Version metadata is inconsistent across sources +- PyPI: `pmxt 2.50.16` +- Raw Python pyproject.toml in monorepo: `2.18.0` +- monorepo package.json: `pmxtjs ^2.17.1` +- Generated pmxt-mcp tools.ts: `2.50.16` (2026-06-18) +- **Lesson**: Rely on runtime capability detection, not version strings. -### fetch_market() (singular) doesn't work by ID -`exchange.fetch_market(market_id="701486")` throws `PmxtError: Unknown error`. -The singular method exists but its parameter handling is unclear. -**Workaround**: Use `fetch_markets(query=keyword, limit=N)` to search by title/keyword. - -### fetch_markets(slug=...) is slow or returns empty -For Polymarket, slug-based lookup (`fetch_markets(slug="will-bitcoin-reach-...")`) either -times out or returns 0 results. The slug parameter doesn't map to Polymarket's API as -expected. -**Workaround**: Use keyword query search. Works fast and reliably. - -### search vs quote — keyword is the key -The most reliable way to find a specific market is through keyword search. -Quote should accept a distinctive phrase from the market title, not a numeric ID. - -### orders still need real outcome_ids under the hood -The pmxt SDK's `create_order()` call requires `market_id` plus `outcome_id`. -This wrapper now resolves `yes` / `no` or exact labels from markets already fetched by -`pmxt_search()` / `pmxt_quote()`. If you skip the lookup step, pass the exact -`outcome_id` yourself. +### Dual API hosts +- `api.pmxt.dev` - reads, Router, MCP, venue passthrough +- `trade.pmxt.dev` - hosted writes + hosted account state +- Both authenticate with the same `pmxt_api_key`. -## Kalshi Behavior +### Python SDK has hosted mode built-in +- `Exchange.__init__()` accepts `pmxt_api_key`, `wallet_address`, `base_url` +- Auto-resolves base URL: `PMXT_BASE_URL` → `pmxt_api_key` presence → localhost +- `build_order` + `submit_order` exist natively in Python SDK >= 2.50 +- `call_api(operation_id, params)` exposes raw OpenAPI endpoints -- Kalshi returns markets with `before`/`not before` label style -- Kalshi can be read-only without API keys (data only) -- Kalshi search is slower than Polymarket +### Router is NOT a separate Python class +- Router appears as `exchange="router"` target +- Router methods: `compareMarketPrices`, `fetchMarketMatches`, `fetchArbitrage`, etc. +- Available via `pmxt_call("methodName", "router", params={...})` -## Sidecar Server +### server.status() returns a dict, not an object +- Keys: `running`, `pid`, `port`, `version`, `uptime_seconds`, `lock_file` +- Must use `.get()` not attribute access. -- Auto-starts on first SDK call (~1-2 seconds) -- `pmxt.server.health()` returns bool — simplest check -- Logs at `~/.pmxt/server.log` -- Shared across Python processes (singleton) -- Version 2.0.2 at time of build +### fetch_market() (singular) doesn't work by ID +- `exchange.fetch_market(market_id="701486")` throws `PmxtError: Unknown error` +- **Workaround**: Use `fetch_markets(query=keyword, limit=N)`. + +### outcome_id is Very Long +- Polymarket outcome_ids are 70+ character token IDs +- Use labels for display, pass IDs as-is for API calls + +## pmxt-mcp Design Patterns Worth Adopting + +### Auto-generated tool surface +- PMXT-MCP generates `src/generated/tools.ts` from OpenAPI + method-verbs.json +- Auto-runs on every PMXT release via GitHub Actions `sync-mcp.yml` +- hermes-pmxt should adopt: `scripts/sync_pmxt_registry.py` + +### Flat agent-friendly schemas +- Complex params flattened to top-level MCP tool inputs +- `ArgSpec` metadata for runtime positional reconstruction +- `flatten: true` flags merged params for cleaner agent UX + +### Safety annotations built into tools +- `readOnlyHint: true` - safe for repeated calls +- `destructiveHint: true` - requires confirmation +- `idempotentHint: true` - safe to retry +- hermes-pmxt mirrors this in registry.py + +### Three config modes: hosted / local / custom +- `PMXT_API_URL` overrides everything +- `PMXT_API_KEY` → hosted `api.pmxt.dev` +- Neither → local `http://localhost:3847` +- hermes-pmxt mirrors this in config.py + +### Compact result shaping +- `verbose=false` (default): compact agent-friendly output +- `verbose=true`: raw uncompacted +- Strips market status when active, truncates descriptions +- hermes-pmxt mirrors this in shaper.py + +### Instructions favor events first +- pmxt-mcp tells agents: "users say 'market', they mean 'event'" +- Discovery: fetchEvents → drill to markets → outcomes ## Price Scale - All prices confirmed as 0.0-1.0 (probabilities). Kalshi internally uses 0-100 but pmxt normalizes to 0-1 in the Python SDK. -## outcome_id is Very Long - -Polymarket outcome_ids are 70+ character strings (token IDs). Don't try to -display them — use labels for display and pass IDs as-is for API calls. - ## Trade Timestamps +All timestamps are Unix milliseconds. Divide by 1000 for Python datetime. -All timestamps are Unix milliseconds. Recent trades show real-time activity — -sub-second resolution. Divide by 1000 for Python datetime. - -## Arbitrage Scan Design - -Cross-exchange matching is done by title word overlap (Jaccard similarity on words). -40% threshold works for finding related markets. True arbitrage is rare — most -combined prices are near 1.00. +## Kalshi Behavior +- Returns markets with `before`/`not before` label style +- Read-only without API keys (local sidecar mode) +- Search is slower than Polymarket + +## hermes-pmxt Architecture Decisions (v0.3.0) + +### Lazy import over eager import +- `exchanges.py` uses `_get_pmxt()` lazy getter +- Package imports cleanly without pmxt installed +- Only raises ImportError when pmxt functionality is used + +### Generated registry over manual wrappers +- `registry.py` has ~33 tool definitions with safety annotations +- `pmxt_call()` dispatches to SDK methods with guard rails +- Handwritten wrappers for common flows only + +### confirmed=True gate for destructive ops +- `createOrder`, `submitOrder`, `cancelOrder` require `confirmed=True` +- `_require_confirmed()` returns human-readable error when not confirmed + +### Runtime status as first troubleshooting step +- `pmxt_runtime_status()` shows mode, URL, version, sidecar health +- Works without pmxt installed + +### Exchange list with capability detection +- 17 known exchanges in registry +- `pmxt_list_exchanges()` reports which are available in installed build +- Aliases for common naming variants diff --git a/README.md b/README.md index 5b3bbc5..c79bb13 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Prediction market integration for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Search markets, compare prices, detect arbitrage, and trade across prediction market -exchanges via [pmxt](https://github.com/pmxt-dev/pmxt). +exchanges via [pmxt](https://github.com/pmxt-dev/pmxt) (>= 2.50.0). ## What This Is @@ -18,7 +18,6 @@ Agent: "The market implies a 1.9% chance (No: 98.1%). Polymarket is pricing this ## Installation ```bash -# Clone git clone https://github.com/0xharryriddle/hermes-pmxt.git cd hermes-pmxt ``` @@ -26,104 +25,184 @@ cd hermes-pmxt ### Option A: pip ```bash -# Create venv + install python3 -m venv .venv source .venv/bin/activate -pip install -e . +pip install -e ".[dev]" ``` ### Option B: uv ```bash -# Create venv + install uv venv source .venv/bin/activate -uv pip install -e . +uv pip install -e ".[dev]" ``` -```bash -# Depending on your pmxt version, sidecar management may be automatic. -# If your environment still needs the Node sidecar, install one of: -npm install -g pmxtjs -pnpm add -g pmxtjs -yarn global add pmxtjs -bun add -g pmxtjs +## Modes + +hermes-pmxt supports three runtime modes: + +| Mode | Config | Behavior | +|------|--------|----------| +| **Hosted** | Set `PMXT_API_KEY` | Talks to `https://api.pmxt.dev`. Handles exchange connections, caching, and rate limits automatically. Recommended for most users. | +| **Custom** | Set `PMXT_API_URL` or `PMXT_BASE_URL` | Points to any PMXT-compatible server. | +| **Local Sidecar** | No API key/URL set | Assumes PMXT core is running at `http://localhost:3847`. For self-hosting / development. | + +Check your current mode: +```python +from hermes_pmxt import pmxt_runtime_status +print(pmxt_runtime_status()) ``` ## Quick Start ```python -from hermes_pmxt import pmxt_search, pmxt_quote +from hermes_pmxt import pmxt_search, pmxt_quote, pmxt_runtime_status + +# Check status +print(pmxt_runtime_status()) # Search result = pmxt_search("bitcoin", exchange="polymarket", limit=5) for m in result["data"]: - print(f"{m['title']}: YES={m['outcomes'][0]['price']*100:.1f}%") + prices = m.get("outcomes", []) + if prices: + print(f"{m['title'][:60]}: YES={prices[0]['price']*100:.1f}%") -# Quote, use a distinctive keyword or title phrase +# Quote quote = pmxt_quote("bitcoin reach", exchange="polymarket") print(f"YES: {quote['data']['yes_pct']} NO: {quote['data']['no_pct']}") ``` +## Data Model + +``` + Event (broad topic) + └── Market (tradeable question) + ├── Outcome "Yes" + └── Outcome "No" +``` + +When users ask about a topic, start with events (`pmxt_events`), then drill down to +markets and outcomes. + ## Tools -| Function | Auth? | Description | -|----------|-------|-------------| -| `pmxt_search(query, exchange?, limit?, sort?, search_in?, slug?)` | No | Search markets by keyword or slug | -| `pmxt_quote(identifier, exchange)` | No | Get YES/NO probabilities from a keyword or title phrase | -| `pmxt_order_book(outcome_id, exchange, limit?)` | No | Get order book depth | -| `pmxt_ohlcv(outcome_id, exchange, resolution?, limit?)` | No | Get price candles | -| `pmxt_trades(outcome_id, exchange, limit?)` | No | Get recent trades | -| `pmxt_events(query, exchange?, limit?, sort?, search_in?, slug?)` | No | Search events (groups of markets) | -| `pmxt_execution_price(outcome_id, exchange, side, amount)` | No | Estimate slippage and execution price | -| `pmxt_compare_market(query, exchanges?, limit?)` | No | Compare similar markets across exchanges | -| `pmxt_balance(exchange)` | Yes | Get account balance | -| `pmxt_positions(exchange)` | Yes | Get open positions | -| `pmxt_portfolio(exchanges?)` | Yes | Unified balances + positions across exchanges | -| `pmxt_order(market_id, outcome, amount, side, exchange, price?)` | Yes | Place an order, `outcome` can be `yes`/`no`, a label, or an exact `outcome_id` | -| `pmxt_arbitrage_scan(query, exchanges?, threshold?)` | No | Cross-exchange spread scan | +### Discovery & Research + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_search(query, exchange?, limit?, sort?, search_in?, slug?)` | No* | Search markets by keyword | +| `pmxt_events(query, exchange?, limit?, sort?, search_in?, slug?)` | No* | Search event groups | +| `pmxt_quote(identifier, exchange)` | No* | Get YES/NO probabilities | +| `pmxt_order_book(outcome_id, exchange, limit?)` | No* | Order book depth | +| `pmxt_ohlcv(outcome_id, exchange, resolution?, limit?)` | No* | Price candles | +| `pmxt_trades(outcome_id, exchange, limit?)` | No* | Recent trades | +| `pmxt_execution_price(outcome_id, exchange, side, amount)` | No* | Slippage estimate | + +### Cross-Venue & Arbitrage + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_compare_market(query, exchanges?, limit?)` | No* | Compare prices across exchanges | +| `pmxt_arbitrage_scan(query, exchanges?, threshold?)` | No* | Detect arbitrage opportunities | +| `pmxt_call("compareMarketPrices", "router", ...)` | No* | Native router comparison | +| `pmxt_call("fetchArbitrage", "router", ...)` | No* | Native arbitrage search | +| `pmxt_call("fetchHedges", "router", ...)` | No* | Hedging opportunities | + +### Portfolio & Account + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_balance(exchange)` | Yes | Account balance | +| `pmxt_positions(exchange)` | Yes | Open positions | +| `pmxt_portfolio(exchanges?)` | Yes | Cross-exchange portfolio | + +### Trading (All Destructive -- Require Explicit Confirmation) + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_build_order(...)` | Yes | Build/sign order without submitting (SAFE) | +| `pmxt_submit_order(built, exchange, confirmed=True)` | Yes | Submit a pre-built order | +| `pmxt_cancel_order(order_id, exchange, confirmed=True)` | Yes | Cancel an open order | +| `pmxt_order(...)` | Yes | Legacy one-step order (prefer build+submit) | + +### Generic API Call + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_call(method, exchange, ...)` | Varies | Generic PMXT API call with safety checks | + +### Server & Diagnostics + +| Function | Auth | Description | +|----------|------|-------------| +| `pmxt_runtime_status()` | No | Full runtime status | +| `pmxt_list_exchanges()` | No | Known/available exchanges | | `pmxt_server_status()` | No | Sidecar diagnostics | +| `pmxt_server_start()` | No | Start sidecar | +| `pmxt_server_stop()` | No | Stop sidecar | -## Supported Exchanges +\* Read-only tools work without credentials in local sidecar mode. Hosted mode requires `PMXT_API_KEY` for all operations. -The package is wired for: +## Trading Safety -- `polymarket` -- `polymarket_us` -- `kalshi` -- `limitless` -- `myriad` -- `opinion` -- `metaculus` -- `smarkets` +**Destructive operations (create, submit, cancel orders) require explicit user confirmation.** -Actual availability still depends on the installed `pmxt` build. +```python +# SAFE: Build order for preview (does NOT place any order) +built = pmxt_build_order( + market_id="market-uuid", + outcome="yes", + side="buy", + order_type="limit", + amount=10, + price=0.55, + exchange="polymarket", +) + +# DESTRUCTIVE: Submit requires confirmed=True +result = pmxt_submit_order(built, "polymarket", confirmed=True) + +# Without confirmed=True: +result = pmxt_submit_order(built, "polymarket") +# => {"success": False, "error": "Operation 'submit_order' is destructive..."} +``` + +## Supported Exchanges -## Hermes Skill +hermes-pmxt knows about 17 venues including: -Copy `skill/SKILL.md` to `~/.hermes/skills/research/pmxt/SKILL.md` to give your -Hermes agent prediction market capabilities with behavior rules and safety guards. +- `polymarket` / `polymarket_us` +- `kalshi` / `kalshi-demo` +- `limitless` +- `probable` / `baozi` / `myriad` / `opinion` +- `metaculus` / `smarkets` +- `gemini-titan` / `hyperliquid` / `suibets` / `rain` +- `mock` / `router` -For order placement, the safest flow is: -1. `pmxt_search(...)` or `pmxt_quote(...)` first, so the package caches the market's outcome IDs -2. `pmxt_order(...)` with `yes` / `no`, or pass the exact `outcome_id` directly +Actual availability depends on the installed `pmxt` build. Run `pmxt_list_exchanges()` to check. ## Environment Variables ```bash -# Polymarket (trading only — read-only needs no keys) +# Hosted mode (recommended) +export PMXT_API_KEY="pmxt_live_..." +export PMXT_WALLET_ADDRESS="0x..." +export PMXT_PRIVATE_KEY="0x..." + +# Custom server +export PMXT_API_URL="https://your-server.com" +# or +export PMXT_BASE_URL="https://your-server.com" + +# Venue-specific (self-hosted mode) export POLYMARKET_PRIVATE_KEY="0x..." export POLYMARKET_PROXY_ADDRESS="0x..." # Optional - -# Kalshi export KALSHI_API_KEY="..." -export KALSHI_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----..." - -# Limitless +export KALSHI_PRIVATE_KEY="..." export LIMITLESS_API_KEY="..." -export LIMITLESS_PRIVATE_KEY="0x..." - -# Polymarket US +export LIMITLESS_PRIVATE_KEY="..." export POLYMARKET_US_API_KEY="..." export POLYMARKET_US_PRIVATE_KEY="..." ``` @@ -134,16 +213,19 @@ export POLYMARKET_US_PRIVATE_KEY="..." hermes-pmxt/ ├── hermes_pmxt/ │ ├── __init__.py # Public API exports -│ ├── tools.py # Core tool functions +│ ├── config.py # Runtime config and mode detection │ ├── exchanges.py # Exchange initialization + normalization +│ ├── registry.py # Tool registry with safety annotations +│ ├── shaper.py # Result shaping for LLM context +│ └── tools.py # Core tool functions ├── skill/ │ └── SKILL.md # Hermes agent skill instructions ├── examples/ │ └── demo.py # Interactive demo ├── tests/ -│ ├── conftest.py # Test import path setup -│ ├── test_tools.py # Tool behavior tests -│ └── test_exchanges.py # Exchange wiring tests +│ ├── conftest.py # Test path setup +│ ├── test_exchanges.py # Exchange wiring unit tests +│ └── test_tools.py # Unit + integration tests ├── pyproject.toml └── README.md ``` @@ -151,16 +233,14 @@ hermes-pmxt/ ## Testing ```bash -# pip / existing venv -source .venv/bin/activate -pip install -e ".[dev]" -pytest -q +# Unit tests (no pmxt required) +python3 -m pytest -q -m unit -# uv -uv venv -source .venv/bin/activate -uv pip install -e ".[dev]" -pytest -q +# All non-destructive tests +python3 -m pytest -q -m "not trading" + +# Integration tests (need pmxt + sidecar/API) +python3 -m pytest -q -m integration ``` ## License diff --git a/hermes_pmxt/__init__.py b/hermes_pmxt/__init__.py index 4e2b4f0..0ef1a22 100644 --- a/hermes_pmxt/__init__.py +++ b/hermes_pmxt/__init__.py @@ -1,33 +1,83 @@ """ -hermes-pmxt — Prediction market integration for Hermes Agent. +hermes-pmxt -- Prediction market integration for Hermes Agent. Usage: from hermes_pmxt import pmxt_search, pmxt_quote, pmxt_order, ... + +The package imports without pmxt installed. Tools that require pmxt +will raise ImportError with a helpful message at call time. """ +from hermes_pmxt.config import ( + get_mode, + get_base_url, + runtime_status, + runtime_status_str, +) +from hermes_pmxt.exchanges import ( + is_pmxt_available, +) +from hermes_pmxt.registry import ( + TOOLS as PMXT_TOOLS, + KNOWN_EXCHANGES, + get_tool, + list_tools, + is_destructive, + requires_credentials, +) +from hermes_pmxt.shaper import ( + shape_result, +) + from hermes_pmxt.tools import ( pmxt_arbitrage_scan, pmxt_balance, + pmxt_build_order, + pmxt_call, + pmxt_cancel_order, pmxt_compare_market, pmxt_events, pmxt_execution_price, + pmxt_list_exchanges, pmxt_ohlcv, pmxt_order, pmxt_order_book, pmxt_portfolio, pmxt_positions, pmxt_quote, + pmxt_runtime_status, pmxt_search, pmxt_server_health, pmxt_server_start, pmxt_server_status, pmxt_server_stop, + pmxt_submit_order, pmxt_trades, ) -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ + # Config + "get_mode", + "get_base_url", + "runtime_status", + "runtime_status_str", + # Exchange + "is_pmxt_available", + "pmxt_list_exchanges", + "pmxt_runtime_status", + # Registry + "PMXT_TOOLS", + "KNOWN_EXCHANGES", + "get_tool", + "list_tools", + "is_destructive", + "requires_credentials", + # Shaper + "shape_result", + # Tools + "pmxt_call", "pmxt_search", "pmxt_quote", "pmxt_order_book", @@ -40,6 +90,9 @@ "pmxt_positions", "pmxt_portfolio", "pmxt_order", + "pmxt_build_order", + "pmxt_submit_order", + "pmxt_cancel_order", "pmxt_arbitrage_scan", "pmxt_server_health", "pmxt_server_status", diff --git a/hermes_pmxt/config.py b/hermes_pmxt/config.py new file mode 100644 index 0000000..a06f666 --- /dev/null +++ b/hermes_pmxt/config.py @@ -0,0 +1,145 @@ +""" +Runtime configuration and mode detection for hermes-pmxt. + +Handles: + - PMXT_API_KEY, PMXT_API_URL, PMXT_BASE_URL, PMXT_WALLET_ADDRESS, PMXT_PRIVATE_KEY + - Hosted vs local sidecar vs custom server detection + - Capability reporting + +All functions work without pmxt installed. +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +# --------------------------------------------------------------------------- +# Environment variable sources (ordered by precedence) +# --------------------------------------------------------------------------- + +_HOSTED_URL = "https://api.pmxt.dev" +_TRADE_URL = "https://trade.pmxt.dev" +_LOCAL_URL = "http://localhost:3847" + + +def _get_api_key() -> Optional[str]: + """Return the PMXT API key if configured.""" + return os.getenv("PMXT_API_KEY") or None + + +def _get_api_url() -> Optional[str]: + """Return the explicit API URL override.""" + return os.getenv("PMXT_API_URL") or os.getenv("PMXT_BASE_URL") or None + + +def get_mode() -> str: + """ + Detect the PMXT runtime mode. + + Returns one of: + - 'hosted': PMXT_API_KEY is set, talks to api.pmxt.dev (or custom URL) + - 'custom': PMXT_API_URL / PMXT_BASE_URL is set, custom server + - 'local-sidecar': no key and no URL, assumes localhost:3847 + - 'unconfigured': not even local mode (no sidecar detectable) + """ + key = _get_api_key() + url = _get_api_url() + + if url: + return "custom" + if key: + return "hosted" + return "local-sidecar" + + +def get_base_url() -> str: + """Return the base URL the SDK should use.""" + url = _get_api_url() + if url: + return url.rstrip("/") + if _get_api_key(): + return _HOSTED_URL + return _LOCAL_URL + + +def get_trade_url() -> str: + """Return the hosted trade URL (for writes/account state).""" + url = os.getenv("PMXT_TRADE_URL") + if url: + return url.rstrip("/") + return _TRADE_URL + + +def get_wallet_address() -> Optional[str]: + """Return the configured wallet address for hosted trading.""" + return os.getenv("PMXT_WALLET_ADDRESS") or None + + +def get_private_key() -> Optional[str]: + """Return the configured private key for hosted trading.""" + return os.getenv("PMXT_PRIVATE_KEY") or None + + +# --------------------------------------------------------------------------- +# Runtime status +# --------------------------------------------------------------------------- + + +def runtime_status() -> dict: + """ + Return a comprehensive runtime status dict. + + Works without pmxt installed. When pmxt is available, adds + version, sidecar status, and exchange availability info. + """ + result: dict = { + "mode": get_mode(), + "base_url": get_base_url(), + "has_api_key": _get_api_key() is not None, + "has_wallet_address": get_wallet_address() is not None, + "has_private_key": get_private_key() is not None, + "pmxt_installed": False, + "pmxt_version": None, + "python_version": sys.version, + } + + try: + from hermes_pmxt.exchanges import is_pmxt_available as _available + + if _available(): + import importlib.metadata as _metadata + import pmxt # type: ignore[import-untyped] + + result["pmxt_installed"] = True + result["pmxt_version"] = getattr(pmxt, "__version__", None) or _metadata.version("pmxt") + + # Sidecar status (best effort) + try: + s = pmxt.server.status() + if isinstance(s, dict): + result["sidecar_running"] = s.get("running", False) + result["sidecar_port"] = s.get("port") + result["sidecar_pid"] = s.get("pid") + except Exception: + result["sidecar_running"] = False + except ImportError: + pass + + return result + + +def runtime_status_str() -> str: + """Return a human-readable one-liner of runtime status.""" + status = runtime_status() + parts = [f"mode={status['mode']}", f"url={status['base_url']}"] + if status["pmxt_installed"]: + parts.append(f"pmxt={status['pmxt_version'] or '?'}") + if status.get("sidecar_running"): + parts.append(f"sidecar=:{status.get('sidecar_port','?')}") + else: + parts.append("sidecar=off") + else: + parts.append("pmxt=not_installed") + return " ".join(parts) diff --git a/hermes_pmxt/exchanges.py b/hermes_pmxt/exchanges.py index 7b24ebd..98f4e29 100644 --- a/hermes_pmxt/exchanges.py +++ b/hermes_pmxt/exchanges.py @@ -6,13 +6,37 @@ import time from typing import Optional -try: - import pmxt -except ImportError as exc: - raise ImportError( - "pmxt is not installed. Run: pip install pmxt. " - "Depending on your pmxt version, you may also need a pmxtjs sidecar." - ) from exc +_pmxt_module = None +_pmxt_import_error: Optional[str] = None + + +def _get_pmxt(): + """Lazy-import pmxt. Raises ImportError only when pmxt functionality is actually used.""" + global _pmxt_module, _pmxt_import_error + if _pmxt_module is not None: + return _pmxt_module + if _pmxt_import_error is not None: + raise ImportError(_pmxt_import_error) + try: + import pmxt as _mod + + _pmxt_module = _mod + return _pmxt_module + except ImportError as exc: + _pmxt_import_error = ( + "pmxt is not installed. Run: pip install pmxt. " + "Depending on your pmxt version, you may also need a pmxtjs sidecar." + ) + raise ImportError(_pmxt_import_error) from exc + + +def is_pmxt_available() -> bool: + """Return True if pmxt can be imported, False otherwise.""" + try: + _get_pmxt() + return True + except ImportError: + return False _exchange_cache: dict[str, object] = {} @@ -53,6 +77,7 @@ def _exchange_class(name: str): """Resolve the pmxt exchange class for a normalized exchange name.""" normalized = normalize_exchange_name(name) class_name = "".join(part.capitalize() for part in normalized.split("_")) + pmxt = _get_pmxt() return getattr(pmxt, class_name, None) @@ -63,6 +88,7 @@ def available_exchange_names() -> list[str]: def ensure_server() -> tuple[bool, Optional[str]]: """Ensure the pmxt sidecar server is running. Returns (ok, error_msg).""" + pmxt = _get_pmxt() try: if not pmxt.server.health(): pmxt.server.start() @@ -95,6 +121,7 @@ def get_exchange(name: str) -> tuple[Optional[object], Optional[str]]: def _create_exchange(name: str): """Instantiate an exchange by normalized name.""" normalized = normalize_exchange_name(name) + pmxt = _get_pmxt() if normalized == "polymarket": return pmxt.Polymarket( @@ -129,6 +156,7 @@ def _create_exchange(name: str): def server_status() -> dict: """Get sidecar server status.""" + pmxt = _get_pmxt() try: status = pmxt.server.status() if isinstance(status, dict): @@ -152,6 +180,7 @@ def server_status() -> dict: def server_logs(n: int = 50) -> list[str]: """Get last N lines of server logs.""" + pmxt = _get_pmxt() try: return list(pmxt.server.logs(n)) except Exception: diff --git a/hermes_pmxt/registry.py b/hermes_pmxt/registry.py new file mode 100644 index 0000000..7b23753 --- /dev/null +++ b/hermes_pmxt/registry.py @@ -0,0 +1,251 @@ +""" +Generated tool registry for PMXT methods. + +Maps PMXT API methods to Python-friendly names with: + - arg specs for positional reconstruction + - safety annotations (read_only, destructive, idempotent) + - credential requirements + - supported exchanges + +This file serves as a static snapshot. For auto-generation from PMXT +OpenAPI specs, run: python scripts/sync_pmxt_registry.py +""" + +from __future__ import annotations + +from typing import Optional + +# --------------------------------------------------------------------------- +# Arg spec +# --------------------------------------------------------------------------- + + +class ArgSpec: + """Describes a single positional argument for a PMXT API method.""" + + __slots__ = ("name", "kind", "optional", "flatten") + + def __init__(self, name: str, kind: str = "object", optional: bool = True, flatten: bool = False): + self.name = name + self.kind = kind + self.optional = optional + self.flatten = flatten + + def to_dict(self) -> dict: + return { + "name": self.name, + "kind": self.kind, + "optional": self.optional, + "flatten": self.flatten, + } + + +# --------------------------------------------------------------------------- +# Tool definition +# --------------------------------------------------------------------------- + + +class ToolDef: + """Definition of a single PMXT tool/method.""" + + __slots__ = ("name", "method", "description", "args", "read_only", "destructive", + "idempotent", "requires_credentials", "category") + + def __init__( + self, + name: str, + method: str, + description: str, + args: Optional[list[ArgSpec]] = None, + read_only: bool = True, + destructive: bool = False, + idempotent: bool = False, + requires_credentials: bool = False, + category: str = "data", + ): + self.name = name + self.method = method + self.description = description + self.args = args or [] + self.read_only = read_only + self.destructive = destructive + self.idempotent = idempotent + self.requires_credentials = requires_credentials + self.category = category + + +# --------------------------------------------------------------------------- +# Supported exchanges (from pmxt-mcp generated tools.ts, v2.50.x) +# --------------------------------------------------------------------------- + +KNOWN_EXCHANGES = [ + "polymarket", + "polymarket_us", + "kalshi", + "kalshi-demo", + "limitless", + "probable", + "baozi", + "myriad", + "opinion", + "metaculus", + "smarkets", + "gemini-titan", + "hyperliquid", + "suibets", + "rain", + "mock", + "router", +] + +# Aliases for user-facing exchange names +EXCHANGE_ALIASES: dict[str, str] = { + "polymarket-us": "polymarket_us", + "polymarket us": "polymarket_us", + "polymarketus": "polymarket_us", + "kalshi-demo": "kalshi-demo", + "kalshi demo": "kalshi-demo", + "gemini-titan": "gemini-titan", + "gemini titan": "gemini-titan", +} + + +# --------------------------------------------------------------------------- +# Tool registry (static snapshot) +# --------------------------------------------------------------------------- + + +def _a(name: str, kind: str = "object", optional: bool = True, flatten: bool = False) -> ArgSpec: + return ArgSpec(name, kind, optional, flatten) + + +TOOLS: list[ToolDef] = [ + # --- Market & Event Data --- + ToolDef("fetchMarkets", "fetchMarkets", "Search tradeable markets by query/slug/category.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchMarketsPaginated", "fetchMarketsPaginated", + "Paginated market fetch with cursor snapshot. First call w/o cursor fetches all; subsequent cursor calls slice from cache.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchEvents", "fetchEvents", "Search event groups (broad topics) containing child markets.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchEventsPaginated", "fetchEventsPaginated", + "Paginated event fetch with cursor snapshot.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchMarket", "fetchMarket", "Fetch a single market by ID, slug, or URL.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchEvent", "fetchEvent", "Fetch a single event by ID or slug.", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchSeries", "fetchSeries", "Fetch series (4th tier below event/market/outcome).", + [_a("params", flatten=True)], read_only=True, category="data"), + ToolDef("loadMarkets", "loadMarkets", "Load and cache all markets locally. Recommended for stable iteration.", + [], read_only=True, idempotent=True, category="data"), + + # --- Order Book & Pricing --- + ToolDef("fetchOHLCV", "fetchOHLCV", "Get price history candles for an outcome.", + [_a("outcomeId", "string", optional=False), + _a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchOrderBook", "fetchOrderBook", "Get current order book depth for an outcome.", + [_a("outcomeId", "string", optional=False), + _a("limit", "number"), + _a("params", flatten=True)], read_only=True, category="data"), + ToolDef("fetchOrderBooks", "fetchOrderBooks", "Batch fetch order books for multiple outcome IDs.", + [_a("outcomeIds", "unknown", optional=False)], read_only=True, category="data"), + ToolDef("fetchTrades", "fetchTrades", "Get recent trades for an outcome.", + [_a("outcomeId", "string", optional=False), + _a("params", flatten=True)], read_only=True, category="data"), + + # --- Order Management --- + ToolDef("createOrder", "createOrder", "Place an order directly. DESTRUCTIVE -- requires user confirmation.", + [_a("params", flatten=True)], destructive=True, requires_credentials=True, category="trading"), + ToolDef("buildOrder", "buildOrder", "Build (sign/preview) an order without submitting.", + [_a("params", flatten=True)], read_only=True, idempotent=True, requires_credentials=True, category="trading"), + ToolDef("submitOrder", "submitOrder", "Submit a pre-built order. DESTRUCTIVE -- requires user confirmation.", + [_a("built", "object", optional=False)], destructive=True, requires_credentials=True, category="trading"), + ToolDef("cancelOrder", "cancelOrder", "Cancel an open order. DESTRUCTIVE -- requires user confirmation.", + [_a("orderId", "string", optional=False)], destructive=True, requires_credentials=True, category="trading"), + ToolDef("fetchOrder", "fetchOrder", "Fetch a single order by ID.", + [_a("orderId", "string", optional=False)], read_only=True, requires_credentials=True, category="trading"), + ToolDef("fetchOpenOrders", "fetchOpenOrders", "Fetch open orders, optionally filtered by market.", + [_a("marketId", "string")], read_only=True, requires_credentials=True, category="trading"), + ToolDef("fetchClosedOrders", "fetchClosedOrders", "Fetch closed/filled orders.", + [_a("params", flatten=True)], read_only=True, requires_credentials=True, category="trading"), + ToolDef("fetchAllOrders", "fetchAllOrders", "Fetch all orders (open + closed).", + [_a("params", flatten=True)], read_only=True, requires_credentials=True, category="trading"), + ToolDef("fetchMyTrades", "fetchMyTrades", "Fetch user's filled trades.", + [_a("params", flatten=True)], read_only=True, requires_credentials=True, category="trading"), + + # --- Account & Positions --- + ToolDef("fetchBalance", "fetchBalance", "Get account balance.", + [_a("address", "string")], read_only=True, requires_credentials=True, category="account"), + ToolDef("fetchPositions", "fetchPositions", "Get open positions.", + [_a("address", "string")], read_only=True, requires_credentials=True, category="account"), + + # --- Router / Cross-Venue --- + ToolDef("compareMarketPrices", "compareMarketPrices", "Compare live prices across venues side-by-side.", + [_a("params", flatten=True)], read_only=True, category="router"), + ToolDef("fetchMarketMatches", "fetchMarketMatches", "Find the same or related market on other venues.", + [_a("params", flatten=True)], read_only=True, category="router"), + ToolDef("fetchEventMatches", "fetchEventMatches", "Find matching events across venues.", + [_a("params", flatten=True)], read_only=True, category="router"), + ToolDef("fetchRelatedMarkets", "fetchRelatedMarkets", "Fetch markets related to a given market.", + [_a("params", "object", optional=False)], read_only=True, category="router"), + ToolDef("fetchMatchedMarkets", "fetchMatchedMarkets", "Fetch all matched market pairs from the catalog.", + [_a("params", flatten=True)], read_only=True, category="router"), + ToolDef("fetchMatchedPrices", "fetchMatchedPrices", "Fetch prices for matched market pairs.", + [_a("params", flatten=True)], read_only=True, category="router"), + ToolDef("fetchHedges", "fetchHedges", "Find hedging opportunities across venues.", + [_a("params", "object", optional=False)], read_only=True, category="router"), + ToolDef("fetchArbitrage", "fetchArbitrage", "Find arbitrage opportunities across venues.", + [_a("params", flatten=True)], read_only=True, category="router"), + + # --- Execution & Pricing --- + ToolDef("getExecutionPrice", "getExecutionPrice", + "Calculate VWAP execution price from order book.", + [_a("orderBook", "object", optional=False), + _a("side", "string", optional=False), + _a("amount", "number", optional=False)], + read_only=True, idempotent=True, category="data"), + ToolDef("getExecutionPriceDetailed", "getExecutionPriceDetailed", + "Detailed execution price including fill status.", + [_a("orderBook", "object", optional=False), + _a("side", "string", optional=False), + _a("amount", "number", optional=False)], + read_only=True, idempotent=True, category="data"), +] + +# Index by Python method name +TOOLS_BY_NAME: dict[str, ToolDef] = {t.method: t for t in TOOLS} + +# Category lists +READ_ONLY_TOOLS = [t for t in TOOLS if t.read_only and not t.destructive] +DESTRUCTIVE_TOOLS = [t for t in TOOLS if t.destructive] +CREDENTIAL_TOOLS = [t for t in TOOLS if t.requires_credentials] + + +def get_tool(method: str) -> Optional[ToolDef]: + """Look up a tool definition by PMXT method name.""" + return TOOLS_BY_NAME.get(method) + + +def list_tools(category: Optional[str] = None, read_only: Optional[bool] = None) -> list[ToolDef]: + """List tools, optionally filtered by category or read_only flag.""" + result = TOOLS + if category: + result = [t for t in result if t.category == category] + if read_only is True: + result = [t for t in result if t.read_only and not t.destructive] + elif read_only is False: + result = [t for t in result if not t.read_only or t.destructive] + return result + + +def is_destructive(method: str) -> bool: + """Return True if the method is destructive (requires user confirmation).""" + tool = get_tool(method) + return tool is not None and tool.destructive + + +def requires_credentials(method: str) -> bool: + """Return True if the method requires exchange credentials.""" + tool = get_tool(method) + return tool is not None and tool.requires_credentials diff --git a/hermes_pmxt/shaper.py b/hermes_pmxt/shaper.py new file mode 100644 index 0000000..f0ed338 --- /dev/null +++ b/hermes_pmxt/shaper.py @@ -0,0 +1,255 @@ +""" +Result shaping for LLM-friendly compact outputs. + +When verbose=False (default), strips fields that bloat context windows +without adding decision-relevant information. When verbose=True, returns +raw/near-raw output. + +Design inspired by pmxt-mcp's src/shaper.ts, adapted for Python dicts. +""" + +from __future__ import annotations + +from typing import Any + + +def _truncate(s: Any, max_len: int) -> str: + """Truncate a string to max_len with '...' suffix.""" + if not isinstance(s, str): + return "" + if len(s) <= max_len: + return s + return s[:max_len] + "..." + + +def compact_market(m: Any) -> dict: + """Compact a market dict: id, title, outcomes price/label, volume, liquidity.""" + src = m if isinstance(m, dict) else {} + outcomes = [] + for o in (src.get("outcomes") or []): + outcomes.append({ + "label": o.get("label", ""), + "price": o.get("price"), + }) + + result: dict = { + "market_id": src.get("market_id"), + "title": src.get("title", ""), + "outcomes": outcomes, + } + + if src.get("volume_24h"): + result["volume_24h"] = src["volume_24h"] + if src.get("liquidity"): + result["liquidity"] = src["liquidity"] + if src.get("status") and src.get("status") != "active": + result["status"] = src["status"] + if src.get("exchange"): + result["exchange"] = src["exchange"] + if src.get("slug"): + result["slug"] = src["slug"] + if src.get("yes_price") is not None: + result["yes_price"] = src["yes_price"] + if src.get("no_price") is not None: + result["no_price"] = src["no_price"] + + return result + + +def compact_single_market(m: Any) -> dict: + """Compact a single market with slightly more detail (outcome IDs, description).""" + src = m if isinstance(m, dict) else {} + outcomes = [] + for o in (src.get("outcomes") or []): + outcomes.append({ + "outcome_id": o.get("outcome_id"), + "label": o.get("label", ""), + "price": o.get("price"), + }) + + result: dict = { + "market_id": src.get("market_id"), + "event_id": src.get("event_id"), + "title": src.get("title", ""), + "description": _truncate(src.get("description", ""), 200), + "outcomes": outcomes, + } + + if src.get("resolution_date"): + result["resolution_date"] = src["resolution_date"] + if src.get("volume_24h"): + result["volume_24h"] = src["volume_24h"] + if src.get("liquidity"): + result["liquidity"] = src["liquidity"] + if src.get("open_interest"): + result["open_interest"] = src["open_interest"] + if src.get("status"): + result["status"] = src["status"] + if src.get("tick_size"): + result["tick_size"] = src["tick_size"] + if src.get("exchange"): + result["exchange"] = src["exchange"] + + return result + + +_NESTED_MARKETS_LIMIT = 5 + + +def compact_event(e: Any) -> dict: + """Compact an event dict: id, title, market count, top N compact markets.""" + src = e if isinstance(e, dict) else {} + all_markets = src.get("top_markets") or src.get("markets") or [] + markets = [compact_market(m) for m in all_markets[: _NESTED_MARKETS_LIMIT]] + + result: dict = { + "event_id": src.get("event_id"), + "title": src.get("title", ""), + "market_count": src.get("market_count", len(all_markets)), + "markets": markets, + } + + if src.get("exchange"): + result["exchange"] = src["exchange"] + if src.get("slug"): + result["slug"] = src["slug"] + if src.get("description"): + result["description"] = _truncate(src["description"], 200) + if src.get("url"): + result["url"] = src["url"] + + return result + + +def compact_order_book(book: Any, max_levels: int = 10) -> dict: + """Compact an order book dict to best bid/ask + limited depth.""" + src = book if isinstance(book, dict) else {} + + bids = (src.get("bids") or [])[:max_levels] + asks = (src.get("asks") or [])[:max_levels] + + result: dict = { + "best_bid": src.get("best_bid"), + "best_ask": src.get("best_ask"), + "spread": src.get("spread"), + "spread_pct": src.get("spread_pct"), + "mid_price": src.get("mid_price"), + "bid_depth": src.get("bid_depth"), + "ask_depth": src.get("ask_depth"), + "bids": bids, + "asks": asks, + "bid_levels": len(bids), + "ask_levels": len(asks), + } + + if src.get("outcome_id"): + result["outcome_id"] = src["outcome_id"] + if src.get("exchange"): + result["exchange"] = src["exchange"] + + return result + + +def compact_comparison(result: Any) -> dict: + """Compact a market comparison result.""" + src = result if isinstance(result, dict) else {} + + quotes = [] + for q in (src.get("quotes") or []): + quotes.append({ + "exchange": q.get("exchange"), + "market_id": q.get("market_id"), + "yes_price": q.get("yes_price"), + "no_price": q.get("no_price"), + "volume_24h": q.get("volume_24h"), + }) + + return { + "title": src.get("title", ""), + "exchange_count": src.get("exchange_count", len(quotes)), + "yes_spread": src.get("yes_spread"), + "no_spread": src.get("no_spread"), + "quotes": quotes, + } + + +def compact_arbitrage(opp: Any) -> dict: + """Compact an arbitrage opportunity dict.""" + src = opp if isinstance(opp, dict) else {} + return { + "strategy": src.get("strategy"), + "market_a": _truncate(src.get("market_a", ""), 100), + "exchange_a": src.get("exchange_a"), + "market_b": _truncate(src.get("market_b", ""), 100), + "exchange_b": src.get("exchange_b"), + "combined_price": src.get("combined_price"), + "profit_margin": src.get("profit_margin"), + } + + +# --------------------------------------------------------------------------- +# Main shaper dispatch +# --------------------------------------------------------------------------- + +def shape_result(method: str, raw_data: Any, verbose: bool = False) -> dict: + """ + Shape raw API output into a compact agent-friendly dict. + + Args: + method: The underlying PMXT method name (e.g. 'fetchMarkets', 'compareMarketPrices') + raw_data: The raw result from the PMXT SDK or API + verbose: If True, return raw data mostly unmodified + + Returns: + Shaped dict with compact representation + """ + if verbose: + return {"raw": raw_data} if isinstance(raw_data, (dict, list)) else {"raw": str(raw_data)} + + # Dispatch based on method name + _m = method.lower().replace("_", "") + + if _m == "fetchmarket" and "matches" not in _m: + # Singular market lookup + return compact_single_market(raw_data) if isinstance(raw_data, dict) else {"raw": raw_data} + + if "markets" in _m: + # List of markets + if isinstance(raw_data, list): + return {"markets": [compact_market(m) for m in raw_data]} + if isinstance(raw_data, dict) and "data" in raw_data: + inner = raw_data["data"] + if isinstance(inner, list): + return {"markets": [compact_market(m) for m in inner]} + return {"markets": raw_data} + + if "events" in _m and "fetch" in _m: + if isinstance(raw_data, list): + return {"events": [compact_event(e) for e in raw_data]} + return {"events": raw_data} + + if "orderbook" in _m: + return compact_order_book(raw_data) + + if "compare" in _m or "match" in _m: + if isinstance(raw_data, dict): + return compact_comparison(raw_data) + if isinstance(raw_data, list): + return {"comparisons": [compact_comparison(c) for c in raw_data]} + return {"data": raw_data} + + if "arbitrage" in _m or "hedge" in _m: + if isinstance(raw_data, list): + return {"opportunities": [compact_arbitrage(o) for o in raw_data]} + if isinstance(raw_data, dict) and "data" in raw_data: + inner = raw_data["data"] + if isinstance(inner, list): + return {"opportunities": [compact_arbitrage(o) for o in inner]} + return {"data": raw_data} + + # Default: pass through + if isinstance(raw_data, dict): + return raw_data + if isinstance(raw_data, list): + return {"items": raw_data, "count": len(raw_data)} + return {"value": raw_data} diff --git a/hermes_pmxt/tools.py b/hermes_pmxt/tools.py index 374cf89..201ba42 100644 --- a/hermes_pmxt/tools.py +++ b/hermes_pmxt/tools.py @@ -18,15 +18,24 @@ from datetime import datetime from typing import Optional +from hermes_pmxt.config import get_base_url, get_mode, runtime_status as _runtime_status_dict from hermes_pmxt.exchanges import ( EXCHANGES, TRADING_EXCHANGES, available_exchange_names, ensure_server, get_exchange, + is_pmxt_available, normalize_exchange_name, server_status, ) +from hermes_pmxt.registry import ( + EXCHANGE_ALIASES, + KNOWN_EXCHANGES, + get_tool, + is_destructive as _is_destructive, +) +from hermes_pmxt.shaper import shape_result # --------------------------------------------------------------------------- @@ -1106,6 +1115,317 @@ def pmxt_arbitrage_scan( ) +# --------------------------------------------------------------------------- +# Runtime / Discovery Helpers +# --------------------------------------------------------------------------- + +def pmxt_runtime_status() -> dict: + """ + Return comprehensive runtime status including mode, config, and pmxt version. + + Works without pmxt installed; returns pmxt_installed=False if absent. + """ + return _ok(_runtime_status_dict(), version=__import__("hermes_pmxt").__version__) + + +def pmxt_list_exchanges() -> dict: + """ + Return all known exchanges with aliases and availability info. + + Reports which exchanges are available in the installed pmxt build. + """ + available = available_exchange_names() if is_pmxt_available() else [] + return _ok({ + "known": list(KNOWN_EXCHANGES), + "aliases": dict(EXCHANGE_ALIASES), + "available": available, + "mode": get_mode(), + "base_url": get_base_url(), + }) + + +_DESTRUCTIVE_CONFIRM_MSG = ( + "Operation '{}' is destructive and requires explicit user confirmation. " + "Set confirmed=True after the user has approved the full order details " + "(exchange, market, outcome, side, amount, price/type)." +) + + +def _require_confirmed(method: str, confirmed: bool = False) -> Optional[dict]: + """Return an error dict if a destructive operation is not confirmed.""" + if not confirmed: + return _err(_DESTRUCTIVE_CONFIRM_MSG.format(method)) + return None + + +def _resolve_method_on_exchange(ex: object, method_name: str, *args): + """ + Try to call a named method on an exchange, falling back to call_api. + + Returns the raw result from the PMXT SDK. + """ + method = getattr(ex, method_name, None) + if callable(method): + return method(*args) + + # Fallback: try call_api or generic HTTP + call_api = getattr(ex, "call_api", None) + if callable(call_api): + return call_api(method_name, *args) + + raise AttributeError( + f"Exchange does not support {method_name} and has no call_api fallback" + ) + + +# --------------------------------------------------------------------------- +# Generic pmxt_call +# --------------------------------------------------------------------------- + +def pmxt_call( + method: str, + exchange: str, + params: Optional[dict] = None, + args: Optional[list] = None, + credentials: Optional[dict] = None, + *, + confirmed: bool = False, + verbose: bool = False, +) -> dict: + """ + Generic PMXT API call using the tool registry and PMXT SDK. + + Args: + method: PMXT method name (e.g. 'fetchMarkets', 'compareMarketPrices') + exchange: Exchange name (canonical or alias) + params: Flat params dict (mapped to positional args per registry) + args: Raw positional args list (overrides params if provided) + credentials: Optional venue credentials dict + confirmed: Required True for destructive operations (createOrder, etc.) + verbose: Return raw output instead of compact shaped result + + Returns: + Standard {success, data, error?, meta?} dict with shaped results + """ + err = _ensure() + if err: + return err + + tool = get_tool(method) + if tool is None: + return _err(f"Unknown PMXT method: {method}") + + if _is_destructive(method): + block = _require_confirmed(method, confirmed) + if block: + return block + + exchange_name = normalize_exchange_name(exchange) + ex, init_err = get_exchange(exchange_name) + if init_err: + return _err(init_err) + + try: + if args is not None: + raw = _resolve_method_on_exchange(ex, method, *args) + else: + raw = _resolve_method_on_exchange(ex, method, params or {}) + + shaped = shape_result(method, raw, verbose=verbose) + shaped["meta"] = { + "method": method, + "exchange": exchange_name, + "mode": get_mode(), + } + return _ok(shaped) + except Exception as e: + return _err(f"{exchange_name}/{method}: {e}") + + +# --------------------------------------------------------------------------- +# Safer Order Management +# --------------------------------------------------------------------------- + +def pmxt_build_order( + market_id: Optional[str] = None, + outcome_id: Optional[str] = None, + side: str = "buy", + order_type: str = "limit", + amount: float = 0.0, + price: Optional[float] = None, + exchange: str = "polymarket", + *, + outcome: Optional[str] = None, + denom: str = "usdc", + slippage_pct: Optional[float] = 30.0, +) -> dict: + """ + Build (sign/preview) an order without submitting it. + + Safe to call -- does NOT place a real order. Returns a built payload + that can be inspected before calling pmxt_submit_order(). + + Args: + market_id: Market UUID or slug + outcome_id: Outcome token ID + side: 'buy' or 'sell' + order_type: 'market' or 'limit' + amount: Contract amount + price: Limit price (required for limit orders) + exchange: Exchange name + outcome: Friendly 'yes'/'no' or label -- resolved to outcome_id + denom: 'usdc' or 'shares' + slippage_pct: Slippage percentage for market orders + """ + err = _ensure() + if err: + return err + + exchange_name = normalize_exchange_name(exchange) + ex, init_err = get_exchange(exchange_name) + if init_err: + return _err(init_err) + + # Resolve friendly outcome to outcome_id if needed + resolved_outcome_id = outcome_id + if outcome is not None and outcome_id is None: + cached = _get_cached_market(exchange_name, market_id or "") + if cached is not None: + resolved_outcome_id = _resolve_outcome_id(cached, outcome) + elif not _is_alias_outcome(outcome): + resolved_outcome_id = outcome.strip() + if resolved_outcome_id is None: + return _err( + "Could not resolve outcome. Run pmxt_search() first, then pass " + "'yes'/'no' or an exact outcome_id." + ) + + if side not in ("buy", "sell"): + return _err("side must be 'buy' or 'sell'") + if order_type not in ("market", "limit"): + return _err("order_type must be 'market' or 'limit'") + if order_type == "limit" and price is None: + return _err("price is required for limit orders") + if amount <= 0: + return _err("amount must be positive") + + try: + build_method = getattr(ex, "build_order", None) + if not callable(build_method): + return _err(f"{exchange_name}: build_order is not available in this pmxt version") + + built = build_method( + market_id=market_id, + outcome_id=resolved_outcome_id, + side=side, + order_type=order_type, + amount=amount, + price=price, + denom=denom, + slippage_pct=slippage_pct, + ) + + # Serialize built order details for agent inspection + return _ok({ + "market_id": getattr(built, "market_id", market_id), + "outcome_id": getattr(built, "outcome_id", resolved_outcome_id), + "side": side, + "order_type": order_type, + "amount": amount, + "price": price, + "denom": denom, + "built": { + "expiry": getattr(built, "expiry", None), + }, + "preview": True, + "note": "Order built but NOT submitted. Call pmxt_submit_order() with confirmed=True to place it.", + }, exchange=exchange_name) + except Exception as e: + return _err(f"{exchange_name}/build_order: {e}") + + +def pmxt_submit_order( + built: dict, + exchange: str, + *, + confirmed: bool = False, +) -> dict: + """ + Submit a pre-built order. DESTRUCTIVE -- requires confirmed=True. + + The built payload must come from pmxt_build_order(). + """ + block = _require_confirmed("submit_order", confirmed) + if block: + return block + + err = _ensure() + if err: + return err + + exchange_name = normalize_exchange_name(exchange) + ex, init_err = get_exchange(exchange_name) + if init_err: + return _err(init_err) + + try: + submit_method = getattr(ex, "submit_order", None) + if not callable(submit_method): + return _err(f"{exchange_name}: submit_order is not available") + + order = submit_method(built) + return _ok({ + "order_id": getattr(order, "id", None), + "market_id": getattr(order, "market_id", None), + "outcome_id": getattr(order, "outcome_id", None), + "side": getattr(order, "side", None), + "type": getattr(order, "type", None), + "amount": getattr(order, "amount", None), + "price": getattr(order, "price", None), + "status": getattr(order, "status", None), + "filled": getattr(order, "filled", None), + "remaining": getattr(order, "remaining", None), + }, exchange=exchange_name) + except Exception as e: + return _err(f"{exchange_name}/submit_order: {e}") + + +def pmxt_cancel_order( + order_id: str, + exchange: str, + *, + confirmed: bool = False, +) -> dict: + """ + Cancel an open order. DESTRUCTIVE -- requires confirmed=True. + """ + block = _require_confirmed("cancel_order", confirmed) + if block: + return block + + err = _ensure() + if err: + return err + + exchange_name = normalize_exchange_name(exchange) + ex, init_err = get_exchange(exchange_name) + if init_err: + return _err(init_err) + + try: + cancel_method = getattr(ex, "cancel_order", None) + if not callable(cancel_method): + return _err(f"{exchange_name}: cancel_order is not available") + + result = cancel_method(order_id) + return _ok({ + "order_id": order_id, + "status": getattr(result, "status", "cancelled"), + }, exchange=exchange_name) + except Exception as e: + return _err(f"{exchange_name}/cancel_order: {e}") + + # --------------------------------------------------------------------------- # Server Management # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 8b35340..c0e9285 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,18 @@ [project] name = "hermes-pmxt" -version = "0.2.0" -description = "Prediction market integration for Hermes Agent — search, compare, and trade across prediction market exchanges via pmxt" +version = "0.3.0" +description = "Prediction market integration for Hermes Agent -- search, compare, and trade across prediction market exchanges via pmxt" readme = "README.md" license = "MIT" requires-python = ">=3.10" dependencies = [ - "pmxt>=2.27.0", + "pmxt>=2.50.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", + "pytest-mock>=3.0", "ruff>=0.4", ] @@ -22,3 +23,12 @@ build-backend = "hatchling.build" [tool.ruff] line-length = 100 target-version = "py312" + +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +markers = [ + "unit: Unit tests that work without pmxt installed or network access", + "integration: Integration tests that require pmxt and/or network access", + "trading: Trading tests that spend real money -- disabled by default", +] diff --git a/skill/SKILL.md b/skill/SKILL.md index df2bd6e..333707a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -1,7 +1,7 @@ --- name: pmxt -description: Prediction market integration — search, compare, and trade across pmxt-supported prediction market exchanges. -version: 0.2.0 +description: Prediction market integration -- search, compare, and trade across pmxt-supported prediction market exchanges. +version: 0.3.0 author: hermes-pmxt license: MIT metadata: @@ -11,7 +11,7 @@ metadata: requires_tools: [execute_code] --- -# pmxt — Prediction Markets for Hermes +# pmxt -- Prediction Markets for Hermes Real-time access to prediction markets for fact-checking, probability analysis, arbitrage detection, execution planning, and portfolio inspection. @@ -27,63 +27,59 @@ arbitrage detection, execution planning, and portfolio inspection. ## Setup ```bash -# pip -pip install pmxt +pip install pmxt>=2.50.0 -# uv -uv pip install pmxt - -# If your pmxt install still needs the sidecar, pick one package manager -npm install -g pmxtjs -pnpm add -g pmxtjs -yarn global add pmxtjs -bun add -g pmxtjs +# Check runtime status +python3 -c "from hermes_pmxt import pmxt_runtime_status; print(pmxt_runtime_status())" ``` -No API keys needed for **read-only** operations (search, quote, order book, OHLCV). -Trading requires exchange credentials in env vars. +Hosted mode (recommended): set `PMXT_API_KEY` env var. Local sidecar mode works +without an API key but requires pmxt-core running on localhost:3847. ## Quick Reference All tools are in the `hermes_pmxt` package. Import and call from `execute_code`: ```python -from hermes_pmxt import pmxt_search, pmxt_quote, pmxt_order_book +from hermes_pmxt import pmxt_search, pmxt_quote, pmxt_order_book, pmxt_call ``` | Function | Auth | Description | |----------|------|-------------| -| `pmxt_search(query, exchange?, limit?, sort?, search_in?, slug?)` | No | Search markets | -| `pmxt_quote(keyword, exchange)` | No | Get YES/NO probabilities | -| `pmxt_order_book(outcome_id, exchange, limit?)` | No | Order book depth | -| `pmxt_ohlcv(outcome_id, exchange, res?, limit?)` | No | Price candles | -| `pmxt_trades(outcome_id, exchange, limit?)` | No | Recent trades | -| `pmxt_events(query, exchange?, limit?, sort?, search_in?, slug?)` | No | Search events | -| `pmxt_execution_price(outcome_id, exchange, side, amount)` | No | Slippage estimate | -| `pmxt_compare_market(query, exchanges?, limit?)` | No | Cross-exchange comparison | +| `pmxt_search(query, exchange?, limit?, sort?, search_in?, slug?)` | Mode-dep | Search markets | +| `pmxt_events(query, exchange?, limit?, sort?, search_in?, slug?)` | Mode-dep | Search event groups | +| `pmxt_quote(keyword, exchange)` | Mode-dep | Get YES/NO probabilities | +| `pmxt_order_book(outcome_id, exchange, limit?)` | Mode-dep | Order book depth | +| `pmxt_ohlcv(outcome_id, exchange, res?, limit?)` | Mode-dep | Price candles | +| `pmxt_trades(outcome_id, exchange, limit?)` | Mode-dep | Recent trades | +| `pmxt_execution_price(outcome_id, exchange, side, amount)` | Mode-dep | Slippage estimate | +| `pmxt_compare_market(query, exchanges?, limit?)` | Mode-dep | Cross-exchange comparison | +| `pmxt_arbitrage_scan(query, exchanges?, threshold?)` | Mode-dep | Cross-exchange spreads | | `pmxt_balance(exchange)` | Yes | Account balance | | `pmxt_positions(exchange)` | Yes | Open positions | | `pmxt_portfolio(exchanges?)` | Yes | Unified balances and positions | -| `pmxt_order(...)` | Yes | Place order, after resolving the market's outcome IDs | -| `pmxt_arbitrage_scan(query, exchanges?, threshold?)` | No | Cross-exchange spreads | -| `pmxt_server_status()` | No | Sidecar diagnostics | +| `pmxt_build_order(...)` | Yes | Build/sign order (SAFE, does NOT submit) | +| `pmxt_submit_order(built, exchange, confirmed=True)` | Yes | Submit a pre-built order | +| `pmxt_cancel_order(order_id, exchange, confirmed=True)` | Yes | Cancel an open order | +| `pmxt_call(method, exchange, ...)` | Varies | Generic PMXT API call | +| `pmxt_runtime_status()` | No | Runtime diagnostics | +| `pmxt_list_exchanges()` | No | Known/available exchanges | ## Procedure -### Rule 1: Don't Hallucinate, Calculate +### Rule 1: Discovery First -When user asks "Is X likely?": -1. `pmxt_search("X", exchange="polymarket")` — use broad keywords -2. `pmxt_quote("distinctive phrase from title", exchange="polymarket")` -3. Reply: "The market implies a **[Price]%** chance." +When user asks about a broad topic or probability: +1. `pmxt_events("topic_keyword", exchange="polymarket")` -- discover event groups +2. Drill into specific markets within the event +3. `pmxt_quote("distinctive phrase", exchange="polymarket")` for exact prices **Search tips**: Use broad keywords, not full sentences. - Bad: `pmxt_search("Who will win the next presidential election?")` -- Good: `pmxt_search("election", exchange="polymarket")` +- Good: `pmxt_events("election", exchange="polymarket")` **Quote tips**: Use a distinctive phrase from the market title. - Good: `pmxt_quote("bitcoin reach", "polymarket")` -- Good: `pmxt_quote("trump nominate", "polymarket")` ### Rule 2: Smart Responses @@ -93,15 +89,25 @@ Synthesize, don't dump raw numbers: ### Rule 3: Arbitrage Awareness -When comparing quotes across exchanges, silently check if YES(a) + NO(b) < 1.00. -If found: "Arbitrage Opportunity: Buy YES on [A] at [X]% + NO on [B] at [Y]% = [Z]% risk-free yield." +When comparing quotes across exchanges, check if YES(a) + NO(b) < 1.00. +Use native router methods when available: +```python +pmxt_call("compareMarketPrices", "router", params={"marketId": "...", "slug": "..."}) +pmxt_call("fetchArbitrage", "router", params={"query": "..."}) +``` + +### Rule 4: Order Safety (CRITICAL) + +**NEVER place orders without explicit user confirmation.** -### Rule 4: Order Safety +Safer workflow: +1. `pmxt_build_order(...)` -- preview without submitting +2. Show user: exchange, market, side, amount, price, max spend +3. Wait for explicit approval +4. `pmxt_submit_order(built, exchange, confirmed=True)` -- only after approval -NEVER place orders without explicit user confirmation including market, outcome, amount, exchange. -Before calling `pmxt_order()`, fetch the market with `pmxt_search()` or `pmxt_quote()` so -`yes` / `no` can be mapped to the correct `outcome_id`. If you already have the exact -`outcome_id`, pass that directly as the `outcome` argument. +Direct `pmxt_order()` requires outcome IDs. Prefer `pmxt_build_order()` which +resolves friendly `yes`/`no` labels from previously searched markets. ### Rule 5: Price Format @@ -109,45 +115,48 @@ All prices are 0.0-1.0 (probabilities). Always show as percentages to users. ### Rule 6: Use Comparison Before Claiming Disagreement -When a user asks whether exchanges disagree, run `pmxt_compare_market(...)` before -describing spread differences. Use `pmxt_execution_price(...)` before discussing -whether a displayed spread is realistically tradable at size. +When user asks whether exchanges disagree, run `pmxt_compare_market(...)` or +`pmxt_call("compareMarketPrices", "router", ...)` before describing spreads. ### Rule 7: Portfolio Calls Need Auth Expectations -`pmxt_portfolio()` aggregates positions and balances across exchanges. Partial errors -are expected when some exchange credentials are missing; summarize successful -exchanges clearly instead of treating that as a full failure. +`pmxt_portfolio()` aggregates across exchanges. Partial errors are expected +when credentials are missing; summarize successful exchanges clearly. + +### Rule 8: Check Runtime Status + +When troubleshooting, run `pmxt_runtime_status()` to see mode, base URL, +pmxt version, and sidecar health. ## Pitfalls -- **Kalshi is slower** for search than Polymarket/Limitless -- **outcome_id vs market_id**: Use `outcome_id` for order book/OHLCV/trades, `market_id` for orders -- **Sidecar behavior depends on pmxt version**: use `pmxt_server_status()` to confirm -- **Prices are 0-1** not dollars — don't confuse -- **Timestamps are Unix ms** — divide by 1000 for Python datetime -- **Exchange support depends on installed pmxt build**: this package is wired for Polymarket, - Polymarket US, Kalshi, Limitless, Myriad, Opinion, Metaculus, and Smarkets +- **Hosted mode requires PMXT_API_KEY** for all operations. +- **Local sidecar mode** works without key but needs pmxt-core on localhost:3847. +- **outcome_id vs market_id**: outcome_id for order book/OHLCV/trades, market_id for orders. +- **Prices are 0-1** not dollars -- don't confuse. +- **Timestamps are Unix ms** -- divide by 1000 for Python datetime. +- **Kalshi is slower** for search than Polymarket. +- **Exchange support depends on installed pmxt build**: run `pmxt_list_exchanges()`. ## Example Workflow ```python -from hermes_pmxt import pmxt_search, pmxt_quote, pmxt_order_book, pmxt_ohlcv +from hermes_pmxt import pmxt_events, pmxt_search, pmxt_quote, pmxt_order_book -# 1. Search +# 1. Discover events (broad topics) +events = pmxt_events("election", exchange="polymarket", limit=3) +# => each event has title, market_count, top_markets + +# 2. Search specific markets result = pmxt_search("bitcoin", exchange="polymarket", limit=5) markets = result["data"] -first = markets[0] -# 2. Quote using a keyword from the title +# 3. Quote using a keyword from the title quote = pmxt_quote("bitcoin reach", "polymarket") # => {"yes_pct": "4.3%", "no_pct": "95.7%", ...} -# 3. Order book (use outcome_id from search or quote results) -if first["outcomes"]: - book = pmxt_order_book(first["outcomes"][0]["outcome_id"], "polymarket") +# 4. Order book for an outcome +if markets and markets[0].get("outcomes"): + book = pmxt_order_book(markets[0]["outcomes"][0]["outcome_id"], "polymarket") # => {"best_bid": 0.043, "best_ask": 0.044, "spread": 0.001} - -# 4. Price history - candles = pmxt_ohlcv(first["outcomes"][0]["outcome_id"], "polymarket", resolution="1d") ``` diff --git a/tests/test_exchanges.py b/tests/test_exchanges.py index 4bac30d..5dced9e 100644 --- a/tests/test_exchanges.py +++ b/tests/test_exchanges.py @@ -1,45 +1,116 @@ -"""Tests for exchange initialization helpers.""" - +"""Tests for exchange initialization helpers and normalization.""" +import pytest from hermes_pmxt import exchanges +from hermes_pmxt.exchanges import is_pmxt_available + + +class TestNormalizeExchangeName: + def test_aliases(self): + assert exchanges.normalize_exchange_name("Polymarket US") == "polymarket_us" + assert exchanges.normalize_exchange_name("polymarket-us") == "polymarket_us" + assert exchanges.normalize_exchange_name("Kalshi") == "kalshi" + assert exchanges.normalize_exchange_name("Limitless") == "limitless" + + def test_preserves_known_names(self): + assert exchanges.normalize_exchange_name("polymarket") == "polymarket" + assert exchanges.normalize_exchange_name("POLYMARKET") == "polymarket" + assert exchanges.normalize_exchange_name("kalshi") == "kalshi" + + def test_handles_unknown(self): + result = exchanges.normalize_exchange_name("nonexistent") + assert result is not None + + +class TestIsPmxtAvailable: + def test_returns_bool(self): + assert isinstance(is_pmxt_available(), bool) + + +class TestExchangeList: + def test_exchanges_tuple(self): + assert "polymarket" in exchanges.EXCHANGES + assert "kalshi" in exchanges.EXCHANGES + assert isinstance(exchanges.EXCHANGES, (tuple, list)) + + def test_trading_exchanges(self): + assert "polymarket" in exchanges.TRADING_EXCHANGES + for te in exchanges.TRADING_EXCHANGES: + assert te in exchanges.EXCHANGES + + +class TestCreateExchangeMocked: + def test_limitless_uses_env_vars(self, monkeypatch): + """Test that _create_exchange reads env vars for Limitless.""" + monkeypatch.setenv("LIMITLESS_API_KEY", "limitless-api") + monkeypatch.setenv("LIMITLESS_PRIVATE_KEY", "limitless-private") + + captured = {} + + class FakeLimitless: + def __init__(self, **kwargs): + captured.update(kwargs) + + class FakePMXT: + Limitless = FakeLimitless + Polymarket = FakeLimitless + Kalshi = FakeLimitless + + monkeypatch.setattr(exchanges, "_get_pmxt", lambda: FakePMXT) + + exchanges._create_exchange("limitless") + + assert captured["api_key"] == "limitless-api" + assert captured["private_key"] == "limitless-private" + + def test_polymarket_us_uses_env_vars(self, monkeypatch): + """Test that _create_exchange reads env vars for Polymarket US.""" + monkeypatch.setenv("POLYMARKET_US_API_KEY", "pmus-api") + monkeypatch.setenv("POLYMARKET_US_PRIVATE_KEY", "pmus-private") + captured = {} -def test_normalize_exchange_name_aliases(): - assert exchanges.normalize_exchange_name("Polymarket US") == "polymarket_us" - assert exchanges.normalize_exchange_name("polymarket-us") == "polymarket_us" - assert exchanges.normalize_exchange_name("Kalshi") == "kalshi" + class FakePolymarketUS: + def __init__(self, **kwargs): + captured.update(kwargs) + class FakePMXT: + PolymarketUs = FakePolymarketUS -def test_limitless_uses_api_key_and_private_key(monkeypatch): - monkeypatch.setenv("LIMITLESS_API_KEY", "limitless-api") - monkeypatch.setenv("LIMITLESS_PRIVATE_KEY", "limitless-private") + monkeypatch.setattr(exchanges, "_get_pmxt", lambda: FakePMXT) - captured = {} + exchanges._create_exchange("polymarket_us") - class FakeLimitless: - def __init__(self, **kwargs): - captured.update(kwargs) + assert captured["api_key"] == "pmus-api" + assert captured["private_key"] == "pmus-private" - monkeypatch.setattr(exchanges.pmxt, "Limitless", FakeLimitless) + def test_unknown_exchange_raises(self, monkeypatch): + """Test that unknown exchange raises ValueError.""" + class FakePMXT: + pass - exchanges._create_exchange("limitless") + monkeypatch.setattr(exchanges, "_get_pmxt", lambda: FakePMXT) - assert captured["api_key"] == "limitless-api" - assert captured["private_key"] == "limitless-private" + with pytest.raises(ValueError, match="Unknown exchange"): + exchanges._create_exchange("nonexistent") + def test_polymarket_uses_env_vars(self, monkeypatch): + """Test that _create_exchange reads env vars for Polymarket.""" + monkeypatch.setenv("POLYMARKET_PRIVATE_KEY", "0xpoly") + monkeypatch.setenv("POLYMARKET_PROXY_ADDRESS", "0xproxy") -def test_polymarket_us_uses_api_key_and_private_key(monkeypatch): - monkeypatch.setenv("POLYMARKET_US_API_KEY", "pmus-api") - monkeypatch.setenv("POLYMARKET_US_PRIVATE_KEY", "pmus-private") + captured = {} - captured = {} + class FakePolymarket: + def __init__(self, **kwargs): + captured.update(kwargs) - class FakePolymarketUS: - def __init__(self, **kwargs): - captured.update(kwargs) + class FakePMXT: + Polymarket = FakePolymarket - monkeypatch.setattr(exchanges, "_exchange_class", lambda name: FakePolymarketUS) + monkeypatch.setattr(exchanges, "_get_pmxt", lambda: FakePMXT) - exchanges._create_exchange("polymarket_us") + exchanges._create_exchange("polymarket") - assert captured["api_key"] == "pmus-api" - assert captured["private_key"] == "pmus-private" + assert captured.get("private_key") == "0xpoly" + assert captured.get("proxy_address") == "0xproxy" + assert captured.get("signature_type") == "gnosis-safe" diff --git a/tests/test_tools.py b/tests/test_tools.py index 74d3724..73adce2 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,320 +1,339 @@ """ Tests for hermes-pmxt tools. -Run: source .venv/bin/activate && python -m pytest tests/ -v -""" +Unit tests (no pmxt required): + pytest -q -m unit + +Integration tests (need pmxt + sidecar/API): + pytest -q -m integration +Trading tests (disabled by default): + pytest -q -m trading --run-trading +""" import pytest + from hermes_pmxt import ( - pmxt_compare_market, - pmxt_execution_price, - pmxt_portfolio, - pmxt_search, - pmxt_quote, - pmxt_order_book, - pmxt_ohlcv, - pmxt_trades, - pmxt_events, - pmxt_arbitrage_scan, - pmxt_server_health, - pmxt_server_start, - pmxt_server_status, + get_mode, + get_base_url, + pmxt_list_exchanges, + pmxt_runtime_status, + runtime_status_str, ) -from hermes_pmxt.tools import _remember_market, pmxt_order +from hermes_pmxt.registry import ( + TOOLS, + KNOWN_EXCHANGES, + get_tool, + list_tools, + is_destructive as registry_is_destructive, + requires_credentials, +) +from hermes_pmxt.shaper import ( + compact_market, + compact_event, + compact_order_book, + shape_result, +) + + +# ============================================================================ +# Unit tests -- no pmxt, no network +# ============================================================================ + +@pytest.mark.unit +class TestConfig: + def test_runtime_status_works_without_pmxt(self): + result = pmxt_runtime_status() + assert result["success"] + assert "mode" in result["data"] + assert result["data"]["pmxt_installed"] is False + + def test_runtime_status_str(self): + s = runtime_status_str() + assert isinstance(s, str) + assert "mode=" in s + + def test_get_mode_defaults(self): + mode = get_mode() + assert mode in ("hosted", "custom", "local-sidecar") + + def test_get_base_url_defaults(self): + url = get_base_url() + assert url.startswith("http") + + def test_pmxt_list_exchanges(self): + result = pmxt_list_exchanges() + assert result["success"] + assert len(result["data"]["known"]) >= 8 + assert "polymarket" in result["data"]["known"] + assert "kalshi" in result["data"]["known"] + + def test_list_exchanges_mode_field(self): + result = pmxt_list_exchanges() + assert result["data"]["mode"] in ("hosted", "custom", "local-sidecar") + + +@pytest.mark.unit +class TestRegistry: + def test_tools_count(self): + assert len(TOOLS) >= 30 + + def test_destructive_tools(self): + destructive = [t for t in TOOLS if t.destructive] + names = {t.name for t in destructive} + assert "createOrder" in names + assert "submitOrder" in names + assert "cancelOrder" in names + + def test_build_order_not_destructive(self): + tool = get_tool("buildOrder") + assert tool is not None + assert not tool.destructive + + def test_read_only_tools_include_fetch(self): + tool = get_tool("fetchMarkets") + assert tool is not None + assert tool.read_only + assert not tool.destructive + + def test_credential_required_tools(self): + assert registry_is_destructive("createOrder") + assert not registry_is_destructive("fetchMarkets") + assert requires_credentials("buildOrder") + + def test_list_tools_by_category(self): + trading = list_tools(category="trading") + assert len(trading) >= 5 + for t in trading: + assert t.category == "trading" + + def test_list_tools_read_only(self): + ro = list_tools(read_only=True) + for t in ro: + assert not t.destructive + + def test_known_exchanges(self): + assert "polymarket" in KNOWN_EXCHANGES + assert "kalshi" in KNOWN_EXCHANGES + assert "router" in KNOWN_EXCHANGES + assert len(KNOWN_EXCHANGES) >= 12 + + def test_get_tool_unknown(self): + assert get_tool("nonexistentMethod") is None + + +@pytest.mark.unit +class TestShaper: + def test_compact_market(self): + market = { + "market_id": "m1", + "title": "Test Market", + "outcomes": [ + {"label": "Yes", "price": 0.6}, + {"label": "No", "price": 0.4}, + ], + "volume_24h": 1000, + "liquidity": 2000, + "status": "active", + "exchange": "polymarket", + } + cmp = compact_market(market) + assert cmp["market_id"] == "m1" + assert cmp["title"] == "Test Market" + assert cmp["outcomes"][0]["label"] == "Yes" + assert cmp["volume_24h"] == 1000 + assert "status" not in cmp # active filtered + assert cmp["exchange"] == "polymarket" + + def test_compact_event(self): + event = { + "event_id": "e1", + "title": "Test Event", + "market_count": 3, + "top_markets": [ + {"market_id": "m1", "title": "M1", "outcomes": [{"label": "Yes", "price": 0.5}]}, + ], + "exchange": "polymarket", + } + cmp = compact_event(event) + assert cmp["event_id"] == "e1" + assert cmp["title"] == "Test Event" + assert cmp["market_count"] == 3 + assert cmp["exchange"] == "polymarket" + assert len(cmp["markets"]) == 1 + + def test_compact_order_book(self): + book = { + "best_bid": 0.42, + "best_ask": 0.44, + "spread": 0.02, + "bids": [{"price": 0.42, "size": 100}], + "asks": [{"price": 0.44, "size": 50}], + } + cmp = compact_order_book(book) + assert cmp["best_bid"] == 0.42 + assert cmp["best_ask"] == 0.44 + assert cmp["spread"] == 0.02 + assert len(cmp["bids"]) == 1 + + def test_shape_result_markets(self): + raw = [ + {"market_id": "m1", "title": "T1", "outcomes": [], "volume_24h": 100, "liquidity": 200}, + ] + shaped = shape_result("fetchMarkets", raw) + assert "markets" in shaped + assert len(shaped["markets"]) == 1 + + def test_shape_result_verbose(self): + raw = {"key": "value"} + shaped = shape_result("fetchMarkets", raw, verbose=True) + assert "raw" in shaped + + def test_truncate_long_description(self): + from hermes_pmxt.shaper import _truncate + assert _truncate("abc", 2) == "ab..." + assert _truncate("ab", 5) == "ab" + assert _truncate(None, 5) == "" + assert _truncate(123, 5) == "" + + +@pytest.mark.unit +class TestToolCallSafety: + """Test that destructive operations require confirmed=True.""" + + def test_require_confirmed_blocks(self): + from hermes_pmxt.tools import _require_confirmed + + result = _require_confirmed("createOrder", confirmed=False) + assert result is not None + assert result["success"] is False + assert "confirmation" in result["error"].lower() + + def test_require_confirmed_allows(self): + from hermes_pmxt.tools import _require_confirmed + + result = _require_confirmed("createOrder", confirmed=True) + assert result is None + + def test_runtime_status_works(self): + result = pmxt_runtime_status() + assert result["success"] + assert "mode" in result["data"] + + def test_destructive_list_consistency(self): + from hermes_pmxt.tools import _DESTRUCTIVE_CONFIRM_MSG + assert "confirmation" in _DESTRUCTIVE_CONFIRM_MSG.lower() + +# ============================================================================ +# Integration tests -- need pmxt and sidecar/API +# ============================================================================ +@pytest.mark.integration class TestServer: def test_server_health(self): + from hermes_pmxt import pmxt_server_health result = pmxt_server_health() assert result["success"] assert "running" in result["data"] def test_server_status(self): + from hermes_pmxt import pmxt_server_status result = pmxt_server_status() assert result["success"] - assert "running" in result["data"] - - def test_server_start(self): - result = pmxt_server_start() - assert result["success"] +@pytest.mark.integration class TestSearch: def test_search_polymarket(self): + from hermes_pmxt import pmxt_search result = pmxt_search("bitcoin", exchange="polymarket", limit=3) assert result["success"] assert result["count"] > 0 - assert len(result["data"]) <= 3 - m = result["data"][0] assert "market_id" in m - assert "title" in m assert "outcomes" in m - assert isinstance(m["outcomes"], list) def test_search_kalshi(self): + from hermes_pmxt import pmxt_search result = pmxt_search("trump", exchange="kalshi", limit=2) assert result["success"] - # Kalshi might have results or not, but shouldn't error def test_search_all_exchanges(self): + from hermes_pmxt import pmxt_search result = pmxt_search("election", limit=2) assert result["success"] assert "exchanges_searched" in result - def test_search_invalid_exchange(self): - result = pmxt_search("test", exchange="nonexistent") - # Should fail gracefully - assert not result["success"] or result["count"] == 0 - - def test_search_forwards_optional_filters(self, monkeypatch): - class Outcome: - outcome_id = "yes-1" - label = "Yes" - price = 0.6 - price_change_24h = None - - class Market: - market_id = "m1" - title = "Will BTC hit 200k?" - description = "" - outcomes = [Outcome()] - volume_24h = 10 - liquidity = 20 - url = "" - status = "active" - slug = "btc-200k" - category = "crypto" - yes = Outcome() - no = None - - class FakeExchange: - def __init__(self): - self.kwargs = None - - def fetch_markets(self, query, limit, sort=None, searchIn=None, slug=None): - self.kwargs = { - "query": query, - "limit": limit, - "sort": sort, - "searchIn": searchIn, - "slug": slug, - } - return [Market()] - - fake_exchange = FakeExchange() - - monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr( - "hermes_pmxt.tools.get_exchange", - lambda exchange: (fake_exchange, None), - ) - - result = pmxt_search( - "bitcoin", - exchange="polymarket", - limit=5, - sort="volume", - search_in="both", - slug="btc-200k", - ) - - assert result["success"] - assert fake_exchange.kwargs == { - "query": "bitcoin", - "limit": 5, - "sort": "volume", - "searchIn": "both", - "slug": "btc-200k", - } - +@pytest.mark.integration class TestQuote: def test_quote_known_market(self): - # Quote uses keyword search, not market_id + from hermes_pmxt import pmxt_quote result = pmxt_quote("bitcoin reach", "polymarket") assert result["success"] d = result["data"] assert "yes" in d assert "no" in d - assert d["yes"] is None or (0 <= d["yes"] <= 1) - assert d["no"] is None or (0 <= d["no"] <= 1) - assert "yes_pct" in d - assert "no_pct" in d - assert "outcomes" in d def test_quote_nonexistent(self): + from hermes_pmxt import pmxt_quote result = pmxt_quote("totally-fake-id-12345", "polymarket") assert not result["success"] +@pytest.mark.integration class TestOrderBook: def test_order_book(self): + from hermes_pmxt import pmxt_search, pmxt_order_book search = pmxt_search("bitcoin", exchange="polymarket", limit=1) assert search["success"] outcome_id = search["data"][0]["outcomes"][0]["outcome_id"] - result = pmxt_order_book(outcome_id, "polymarket") assert result["success"] - d = result["data"] - assert "bids" in d - assert "asks" in d - assert "bid_levels" in d - assert "ask_levels" in d - - -class TestOHLCV: - def test_ohlcv(self): - search = pmxt_search("bitcoin", exchange="polymarket", limit=1) - assert search["success"] - outcome_id = search["data"][0]["outcomes"][0]["outcome_id"] - - result = pmxt_ohlcv(outcome_id, "polymarket", resolution="1d", limit=5) - assert result["success"] - assert isinstance(result["data"], list) - - if result["data"]: - c = result["data"][0] - assert "timestamp" in c - assert "open" in c - assert "close" in c - - -class TestTrades: - def test_trades(self): - search = pmxt_search("bitcoin", exchange="polymarket", limit=1) - assert search["success"] - outcome_id = search["data"][0]["outcomes"][0]["outcome_id"] - - result = pmxt_trades(outcome_id, "polymarket", limit=3) - assert result["success"] - assert isinstance(result["data"], list) + assert "bids" in result["data"] +@pytest.mark.integration class TestEvents: def test_events(self): + from hermes_pmxt import pmxt_events result = pmxt_events("election", exchange="polymarket", limit=2) assert result["success"] assert result["count"] > 0 - e = result["data"][0] - assert "title" in e - assert "market_count" in e - assert "top_markets" in e - +@pytest.mark.integration class TestArbitrage: def test_arbitrage_scan(self): + from hermes_pmxt import pmxt_arbitrage_scan result = pmxt_arbitrage_scan("trump", exchanges=["polymarket", "kalshi"]) assert result["success"] - assert "count" in result - # May or may not find opportunities, but shouldn't error -class TestExecutionAndPortfolio: - def test_execution_price_manual_fallback(self, monkeypatch): - class Level: - def __init__(self, price, size): - self.price = price - self.size = size - - class Book: - asks = [Level(0.52, 5), Level(0.54, 10)] - bids = [Level(0.48, 5), Level(0.46, 10)] - - class FakeExchange: - def fetch_order_book(self, outcome_id): - return Book() - - monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr( - "hermes_pmxt.tools.get_exchange", - lambda exchange: (FakeExchange(), None), - ) - - result = pmxt_execution_price("outcome-1", "polymarket", "buy", 10) - - assert result["success"] - assert result["data"]["best_price"] == 0.52 - assert result["data"]["estimated_price"] == pytest.approx(0.53) - assert result["data"]["slippage"] == pytest.approx(0.01) - - def test_portfolio_aggregates_positions(self, monkeypatch): - def fake_balance(exchange): - return { - "success": True, - "data": [{"currency": "USD", "available": 100, "total": 100, "locked": 0}], - } - - def fake_positions(exchange): - if exchange == "kalshi": - return {"success": False, "error": "missing creds"} - return { - "success": True, - "data": [ - { - "market_id": "m1", - "size": 4, - "current_price": 0.6, - "unrealized_pnl": 1.2, - } - ], - } - - monkeypatch.setattr("hermes_pmxt.tools.pmxt_balance", fake_balance) - monkeypatch.setattr("hermes_pmxt.tools.pmxt_positions", fake_positions) - - result = pmxt_portfolio(["polymarket", "kalshi"]) - - assert result["success"] - assert result["data"]["summary"]["total_positions"] == 1 - assert result["data"]["summary"]["total_notional"] == pytest.approx(2.4) - assert result["partial_errors"] == ["missing creds"] - - def test_compare_market_groups_similar_titles(self, monkeypatch): - def fake_search(query, exchange=None, limit=5, **kwargs): - data = { - "polymarket": [{ - "exchange": "polymarket", - "market_id": "p1", - "title": "Will Bitcoin hit $200k in 2026?", - "slug": "btc-200k", - "outcomes": [ - {"label": "Yes", "price": 0.31}, - {"label": "No", "price": 0.69}, - ], - "volume_24h": 100, - "liquidity": 200, - "url": "", - }], - "kalshi": [{ - "exchange": "kalshi", - "market_id": "k1", - "title": "Will Bitcoin hit $200k in 2026", - "slug": "BTC200K", - "outcomes": [ - {"label": "Yes", "price": 0.37}, - {"label": "No", "price": 0.63}, - ], - "volume_24h": 50, - "liquidity": 150, - "url": "", - }], - } - return {"success": True, "data": data.get(exchange, [])} - - monkeypatch.setattr("hermes_pmxt.tools.pmxt_search", fake_search) +@pytest.mark.integration +class TestReturnFormat: + def test_success_has_data(self): + from hermes_pmxt import pmxt_search + result = pmxt_search("test", exchange="polymarket", limit=1) + assert "success" in result + assert "data" in result - result = pmxt_compare_market("bitcoin 200k", ["polymarket", "kalshi"]) + def test_error_has_error(self): + from hermes_pmxt import pmxt_quote + result = pmxt_quote("fake-id", "nonexistent") + assert result["success"] is False + assert "error" in result - assert result["success"] - assert result["count"] == 1 - assert result["data"][0]["exchange_count"] == 2 - assert result["data"][0]["yes_spread"] == pytest.approx(0.06) +# ============================================================================ +# Unit test classes that mock exchange behavior +# ============================================================================ -class TestOrder: +@pytest.mark.unit +class TestOrderMocked: def test_order_resolves_yes_to_outcome_id_from_cached_market(self, monkeypatch): + from hermes_pmxt.tools import _remember_market, pmxt_order + class Outcome: def __init__(self, outcome_id, label): self.outcome_id = outcome_id @@ -352,7 +371,10 @@ def create_order(self, **kwargs): _remember_market("polymarket", Market()) monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr("hermes_pmxt.tools.get_exchange", lambda exchange: (fake_exchange, None)) + monkeypatch.setattr( + "hermes_pmxt.tools.get_exchange", + lambda exchange: (fake_exchange, None), + ) result = pmxt_order("m1", "yes", 10, "buy", "polymarket", price=0.42) @@ -361,6 +383,8 @@ def create_order(self, **kwargs): assert fake_exchange.calls[0]["market_id"] == "m1" def test_order_accepts_exact_outcome_id_without_cached_market(self, monkeypatch): + from hermes_pmxt.tools import pmxt_order + class Order: id = "o2" market_id = "m2" @@ -385,80 +409,204 @@ def create_order(self, **kwargs): fake_exchange = FakeExchange() monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr("hermes_pmxt.tools.get_exchange", lambda exchange: (fake_exchange, None)) - - result = pmxt_order( - "m2", - "12345678901234567890", - 1, - "buy", - "polymarket", + monkeypatch.setattr( + "hermes_pmxt.tools.get_exchange", + lambda exchange: (fake_exchange, None), ) + result = pmxt_order("m2", "12345678901234567890", 1, "buy", "polymarket") + assert result["success"] assert fake_exchange.calls[0]["outcome_id"] == "12345678901234567890" - def test_order_accepts_alphanumeric_outcome_id_without_cached_market(self, monkeypatch): - class Order: - id = "o3" - market_id = "m3" - outcome_id = "KXUKPARTY-29-C" - side = "buy" - type = "market" - amount = 1 - price = None - status = "open" - filled = 0 - remaining = 1 - timestamp = 1234567892 + def test_order_returns_error_when_outcome_cannot_be_resolved(self, monkeypatch): + from hermes_pmxt.tools import pmxt_order + + class FakeExchange: + def create_order(self, **kwargs): + raise AssertionError("should not be called") + + monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) + monkeypatch.setattr( + "hermes_pmxt.tools.get_exchange", + lambda exchange: (FakeExchange(), None), + ) + + result = pmxt_order("unknown-market", "yes", 1, "buy", "polymarket") + + assert result["success"] is False + assert "Could not resolve outcome" in result["error"] + + +@pytest.mark.unit +class TestCompareMocked: + def test_compare_market_groups_similar_titles(self, monkeypatch): + from hermes_pmxt import pmxt_compare_market + + def fake_search(query, exchange=None, limit=5, **kwargs): + data = { + "polymarket": [{ + "exchange": "polymarket", + "market_id": "p1", + "title": "Will Bitcoin hit $200k in 2026?", + "slug": "btc-200k", + "outcomes": [ + {"label": "Yes", "price": 0.31}, + {"label": "No", "price": 0.69}, + ], + "volume_24h": 100, + "liquidity": 200, + "url": "", + }], + "kalshi": [{ + "exchange": "kalshi", + "market_id": "k1", + "title": "Will Bitcoin hit $200k in 2026", + "slug": "BTC200K", + "outcomes": [ + {"label": "Yes", "price": 0.37}, + {"label": "No", "price": 0.63}, + ], + "volume_24h": 50, + "liquidity": 150, + "url": "", + }], + } + return {"success": True, "data": data.get(exchange, [])} + + monkeypatch.setattr("hermes_pmxt.tools.pmxt_search", fake_search) + + result = pmxt_compare_market("bitcoin 200k", ["polymarket", "kalshi"]) + + assert result["success"] + assert result["count"] == 1 + assert result["data"][0]["exchange_count"] == 2 + assert result["data"][0]["yes_spread"] == pytest.approx(0.06) + + +@pytest.mark.unit +class TestPortfolioMocked: + def test_portfolio_aggregates_positions(self, monkeypatch): + from hermes_pmxt import pmxt_portfolio + + def fake_balance(exchange): + return { + "success": True, + "data": [{"currency": "USD", "available": 100, "total": 100, "locked": 0}], + } + + def fake_positions(exchange): + if exchange == "kalshi": + return {"success": False, "error": "missing creds"} + return { + "success": True, + "data": [ + { + "market_id": "m1", + "size": 4, + "current_price": 0.6, + "unrealized_pnl": 1.2, + } + ], + } + + monkeypatch.setattr("hermes_pmxt.tools.pmxt_balance", fake_balance) + monkeypatch.setattr("hermes_pmxt.tools.pmxt_positions", fake_positions) + + result = pmxt_portfolio(["polymarket", "kalshi"]) + + assert result["success"] + assert result["data"]["summary"]["total_positions"] == 1 + assert result["data"]["summary"]["total_notional"] == pytest.approx(2.4) + assert result["partial_errors"] == ["missing creds"] + + +@pytest.mark.unit +class TestSearchMocked: + def test_search_forwards_optional_filters(self, monkeypatch): + from hermes_pmxt import pmxt_search + + class Outcome: + outcome_id = "yes-1" + label = "Yes" + price = 0.6 + price_change_24h = None + + class Market: + market_id = "m1" + title = "Will BTC hit 200k?" + description = "" + outcomes = [Outcome()] + volume_24h = 10 + liquidity = 20 + url = "" + status = "active" + slug = "btc-200k" + category = "crypto" + yes = Outcome() + no = None class FakeExchange: def __init__(self): - self.calls = [] + self.kwargs = None - def create_order(self, **kwargs): - self.calls.append(kwargs) - return Order() + def fetch_markets(self, query, limit, sort=None, searchIn=None, slug=None): + self.kwargs = { + "query": query, + "limit": limit, + "sort": sort, + "searchIn": searchIn, + "slug": slug, + } + return [Market()] fake_exchange = FakeExchange() monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr("hermes_pmxt.tools.get_exchange", lambda exchange: (fake_exchange, None)) - - result = pmxt_order( - "m3", - "KXUKPARTY-29-C", - 1, - "buy", - "kalshi", + monkeypatch.setattr( + "hermes_pmxt.tools.get_exchange", + lambda exchange: (fake_exchange, None), + ) + + result = pmxt_search( + "bitcoin", exchange="polymarket", limit=5, + sort="volume", search_in="both", slug="btc-200k", ) assert result["success"] - assert fake_exchange.calls[0]["outcome_id"] == "KXUKPARTY-29-C" + assert fake_exchange.kwargs == { + "query": "bitcoin", "limit": 5, + "sort": "volume", "searchIn": "both", "slug": "btc-200k", + } - def test_order_returns_clear_error_when_outcome_cannot_be_resolved(self, monkeypatch): - class FakeExchange: - def create_order(self, **kwargs): - raise AssertionError("create_order should not be called") - monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) - monkeypatch.setattr("hermes_pmxt.tools.get_exchange", lambda exchange: (FakeExchange(), None)) +@pytest.mark.unit +class TestExecutionPriceMocked: + def test_execution_price_manual_fallback(self, monkeypatch): + from hermes_pmxt import pmxt_execution_price - result = pmxt_order("unknown-market", "yes", 1, "buy", "polymarket") + class Level: + def __init__(self, price, size): + self.price = price + self.size = size - assert result["success"] is False - assert "Could not resolve outcome" in result["error"] + class Book: + asks = [Level(0.52, 5), Level(0.54, 10)] + bids = [Level(0.48, 5), Level(0.46, 10)] + class FakeExchange: + def fetch_order_book(self, outcome_id): + return Book() -class TestReturnFormat: - """All tools should return {"success": bool, "data": ...} format.""" + monkeypatch.setattr("hermes_pmxt.tools._ensure", lambda: None) + monkeypatch.setattr( + "hermes_pmxt.tools.get_exchange", + lambda exchange: (FakeExchange(), None), + ) - def test_success_has_data(self): - result = pmxt_search("test", exchange="polymarket", limit=1) - assert "success" in result - assert "data" in result + result = pmxt_execution_price("outcome-1", "polymarket", "buy", 10) - def test_error_has_error(self): - result = pmxt_quote("fake-id", "nonexistent") - assert result["success"] is False - assert "error" in result + assert result["success"] + assert result["data"]["best_price"] == 0.52 + assert result["data"]["estimated_price"] == pytest.approx(0.53) + assert result["data"]["slippage"] == pytest.approx(0.01) From 670256effca7645402076f4b31523e5e605f2a36 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sat, 20 Jun 2026 10:08:47 +0700 Subject: [PATCH 2/4] chore: update test.yaml only for main branch --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a28549c..364fa9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [main, harry] + branches: [main] pull_request: branches: [main] From ffe4459874981380d42a5a1324ac982479f9962d Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sat, 20 Jun 2026 10:11:52 +0700 Subject: [PATCH 3/4] fix: make runtime_status test agnostic to pmxt installation state CI installs pmxt as a dependency, so pmxt_installed=True there. Test now checks boolean type and valid keys, not a specific value. --- tests/test_tools.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index 73adce2..c08cc50 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -41,11 +41,13 @@ @pytest.mark.unit class TestConfig: - def test_runtime_status_works_without_pmxt(self): + def test_runtime_status_returns_valid_structure(self): result = pmxt_runtime_status() assert result["success"] assert "mode" in result["data"] - assert result["data"]["pmxt_installed"] is False + assert isinstance(result["data"]["pmxt_installed"], bool) + assert isinstance(result["data"]["has_api_key"], bool) + assert result["data"]["mode"] in ("hosted", "custom", "local-sidecar") def test_runtime_status_str(self): s = runtime_status_str() From 96e3636e1266b302ab6ba89abe328e8850150652 Mon Sep 17 00:00:00 2001 From: Harry Riddle Date: Sat, 20 Jun 2026 10:12:21 +0700 Subject: [PATCH 4/4] fix: add harry branch to CI push trigger Also fix runtime_status test to be pmxt-installation agnostic --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 364fa9a..a28549c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: push: - branches: [main] + branches: [main, harry] pull_request: branches: [main]