feat(polymarket): replace position-based ranking with quality-signal relevance
Polymarket results now rank by text similarity, volume, liquidity, price movement, and competitive score instead of API return position. Also fixes pagination (DEPTH_CONFIG now controls page count, not a no-op limit param) and caps results after re-ranking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Improve Polymarket result ranking with quality signals"
|
||||||
|
type: feat
|
||||||
|
status: completed
|
||||||
|
date: 2026-02-25
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: Improve Polymarket Result Ranking with Quality Signals
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When a topic like "OpenAI" returns 163+ Polymarket events, the current implementation ranks results almost entirely by API return position (75% weight on `i`), with only a tiny volume boost (0-15%). This means the scoring doesn't reflect actual market quality - a $1M/month market and a $5K/month market get nearly identical relevance scores if they're adjacent in the API response.
|
||||||
|
|
||||||
|
Fix the ranking so the most actively traded, fastest-moving, most contested markets bubble to the top.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Current relevance formula in `parse_polymarket_response()` (line 328-330):
|
||||||
|
|
||||||
|
```python
|
||||||
|
rank_score = max(0.3, 1.0 - (i * 0.03)) # 75% weight on position
|
||||||
|
engagement_boost = min(0.15, math.log1p(volume24hr) / 60)
|
||||||
|
relevance = min(1.0, rank_score * 0.75 + engagement_boost + 0.1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Issues:
|
||||||
|
1. **Position dominance**: A market at position 2 with $0 volume scores higher than a market at position 8 with $1M volume
|
||||||
|
2. **`limit` parameter is a no-op**: The Gamma API always returns exactly 5 events per page regardless of `limit`. Our `DEPTH_CONFIG` values (5, 10, 20) do nothing
|
||||||
|
3. **Rich quality signals are ignored**: Event-level `volume1mo`, `volume1wk`, `competitive`, `commentCount` fields are available but unused
|
||||||
|
4. **Price movement is displayed but not scored**: Markets with dramatic price swings get no ranking boost
|
||||||
|
5. **No text-similarity scoring**: A tangential market that happens to mention "OpenAI" ranks the same as one directly about OpenAI
|
||||||
|
|
||||||
|
## API Findings (Verified)
|
||||||
|
|
||||||
|
**Pagination**: `?page=N` works as 1-indexed offset. Each page returns exactly 5 events. `hasMore: true` indicates more pages exist. `totalResults` gives total count.
|
||||||
|
|
||||||
|
**Event-level quality fields** (confirmed via live API):
|
||||||
|
|
||||||
|
| Field | Level | Example | Currently Used |
|
||||||
|
|-------|-------|---------|----------------|
|
||||||
|
| `volume24hr` | Event + Market | $13,334 | Market only (for engagement) |
|
||||||
|
| `volume1wk` | Event + Market | $1,051,626 | No |
|
||||||
|
| `volume1mo` | Event + Market | $1,133,684 | No |
|
||||||
|
| `liquidity` | Event + Market | $16,285 | Market only (for filtering) |
|
||||||
|
| `competitive` | Event + Market | 0.995 | No |
|
||||||
|
| `commentCount` | Event only | 2 | No |
|
||||||
|
| `oneDayPriceChange` | Market only | -0.02 | Display only, not scored |
|
||||||
|
| `oneWeekPriceChange` | Market only | -0.05 | Display only, not scored |
|
||||||
|
| `oneMonthPriceChange` | Market only | -0.117 | Display only, not scored |
|
||||||
|
|
||||||
|
**API naturally sorts well**: Page 1 has active high-volume markets ($1M+ monthly volume), page 3 is all dead historical markets ($0 volume). So the API's own ranking is decent - the problem is our scoring doesn't preserve this quality signal.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### 1. Replace position-based relevance with quality-signal relevance
|
||||||
|
|
||||||
|
New formula in `parse_polymarket_response()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Text similarity: does the event title contain the search topic?
|
||||||
|
core = _extract_core_subject(topic).lower()
|
||||||
|
title_lower = title.lower()
|
||||||
|
if core and core in title_lower:
|
||||||
|
text_score = 1.0
|
||||||
|
else:
|
||||||
|
# Token overlap fallback
|
||||||
|
topic_tokens = set(core.lower().split())
|
||||||
|
title_tokens = set(title_lower.split())
|
||||||
|
overlap = len(topic_tokens & title_tokens)
|
||||||
|
text_score = overlap / max(len(topic_tokens), 1)
|
||||||
|
|
||||||
|
# Volume signal: log-scaled monthly volume (most stable signal)
|
||||||
|
vol_score = min(1.0, math.log1p(event_volume1mo) / 16) # ~$9M = 1.0
|
||||||
|
|
||||||
|
# Liquidity signal
|
||||||
|
liq_score = min(1.0, math.log1p(event_liquidity) / 14) # ~$1.2M = 1.0
|
||||||
|
|
||||||
|
# Price movement: largest absolute change, capped
|
||||||
|
max_change = max(
|
||||||
|
abs(oneDayPriceChange or 0) * 3, # Daily weighted 3x
|
||||||
|
abs(oneWeekPriceChange or 0) * 2, # Weekly weighted 2x
|
||||||
|
abs(oneMonthPriceChange or 0) * 1, # Monthly weighted 1x
|
||||||
|
)
|
||||||
|
movement_score = min(1.0, max_change * 5) # 20% change = 1.0
|
||||||
|
|
||||||
|
# Competitive bonus: markets near 50/50 are more interesting
|
||||||
|
competitive_score = event_competitive or 0
|
||||||
|
|
||||||
|
# Final relevance
|
||||||
|
relevance = (
|
||||||
|
0.30 * text_score +
|
||||||
|
0.30 * vol_score +
|
||||||
|
0.15 * liq_score +
|
||||||
|
0.15 * movement_score +
|
||||||
|
0.10 * competitive_score
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fix DEPTH_CONFIG to use pagination
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Pages to fetch per query (API returns 5 events per page)
|
||||||
|
DEPTH_CONFIG = {
|
||||||
|
"quick": 1, # 5 events/query, ~5-10 unique after dedup
|
||||||
|
"default": 2, # 10 events/query, ~10-15 unique after dedup
|
||||||
|
"deep": 3, # 15 events/query, ~15-25 unique after dedup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use event-level volume fields
|
||||||
|
|
||||||
|
Extract `volume1mo`, `volume1wk`, `liquidity`, and `competitive` from the event object (not just the top market). These are more stable signals than market-level `volume24hr`.
|
||||||
|
|
||||||
|
### 4. Cap results after re-ranking
|
||||||
|
|
||||||
|
After pagination, merge, dedup, and re-ranking, cap at a reasonable number before sending to the scoring pipeline:
|
||||||
|
|
||||||
|
```python
|
||||||
|
RESULT_CAP = {
|
||||||
|
"quick": 5,
|
||||||
|
"default": 10,
|
||||||
|
"deep": 20,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Implementation Plan
|
||||||
|
|
||||||
|
#### Phase 1: Fix pagination and DEPTH_CONFIG
|
||||||
|
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Change `DEPTH_CONFIG` to page counts: `{"quick": 1, "default": 2, "deep": 3}`
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Add `RESULT_CAP` dict: `{"quick": 5, "default": 10, "deep": 20}`
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Update `_search_single_query()` to accept a `page` parameter
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Update `search_polymarket()` to fetch multiple pages per query in parallel (fire all `(query, page)` combinations into ThreadPoolExecutor at once)
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Apply `RESULT_CAP` after merge + dedup, before returning events
|
||||||
|
- [x] `tests/test_polymarket.py` - Update `TestDepthConfig` tests for new page-count values
|
||||||
|
|
||||||
|
#### Phase 2: Extract event-level quality signals
|
||||||
|
|
||||||
|
- [x] `scripts/lib/polymarket.py` - In `parse_polymarket_response()`, extract event-level fields: `volume1mo`, `volume1wk`, `liquidity`, `competitive`, `commentCount`
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Pass `topic` to `parse_polymarket_response()` (already has the parameter, just need to use it)
|
||||||
|
- [x] `fixtures/polymarket_sample.json` - Add event-level fields: `volume1mo`, `volume1wk`, `competitive`, `commentCount`, `volume24hr`, `liquidity`
|
||||||
|
|
||||||
|
#### Phase 3: Replace relevance formula
|
||||||
|
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Replace position-based relevance formula with quality-signal formula (text similarity + volume + liquidity + price movement + competitive)
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Add `_compute_text_similarity(topic, title)` helper
|
||||||
|
- [x] `tests/test_polymarket.py` - Add `TestTextSimilarity` test class
|
||||||
|
- [x] `tests/test_polymarket.py` - Add `TestQualityRanking` test: given events with varying volume/liquidity/text-match, verify high-volume title-matching events rank above low-volume tangential ones
|
||||||
|
|
||||||
|
#### Phase 4: Update engagement scoring
|
||||||
|
|
||||||
|
- [x] `scripts/lib/schema.py` - No changes needed (Engagement already has `volume` and `liquidity`)
|
||||||
|
- [x] `scripts/lib/polymarket.py` - Use event-level `volume1mo` instead of market-level `volume24hr` for the `volume24hr` field passed to normalization (or add a new field)
|
||||||
|
- [x] `scripts/lib/normalize.py` - Update `normalize_polymarket_items()` to use `volume1mo` for engagement volume if available, fallback to `volume24hr`
|
||||||
|
|
||||||
|
#### Phase 5: Tests and verification
|
||||||
|
|
||||||
|
- [x] Run full test suite
|
||||||
|
- [ ] Manual test: `/last30days "OpenAI" --emit=compact` - verify top markets are the most actively traded
|
||||||
|
- [ ] Manual test: `/last30days "Anthropic" --emit=compact` - verify quality ranking
|
||||||
|
- [ ] Manual test: `/last30days "best rap songs 2026" --emit=compact` - verify graceful zero results
|
||||||
|
- [x] Run `bash scripts/sync.sh` to deploy
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] "OpenAI" search surfaces IPO market cap, product announcements, and GPT benchmark markets (high volume) before niche/dead markets
|
||||||
|
- [x] Markets with $0 monthly volume are filtered out (already handled by liquidity filter, but verify)
|
||||||
|
- [x] `DEPTH_CONFIG` actually affects result count (quick=~5, default=~10, deep=~20)
|
||||||
|
- [x] Price movement is factored into ranking (markets with large swings rank higher)
|
||||||
|
- [x] Text-matching markets rank above tangential keyword matches
|
||||||
|
- [x] All existing tests pass (71 polymarket + full suite: 218 passed, 5 pre-existing failures)
|
||||||
|
- [ ] No performance regression - pagination adds latency but stays within timeout budgets
|
||||||
|
|
||||||
|
## Dependencies & Risks
|
||||||
|
|
||||||
|
**No blockers.** This is a scoring/ranking improvement within the existing Polymarket module. No new API keys, no new dependencies.
|
||||||
|
|
||||||
|
**Risk: Over-tuning the formula.** The weights (0.30/0.30/0.15/0.15/0.10) are educated guesses. May need iteration after testing with real queries. Mitigation: the formula is in one place (`parse_polymarket_response`) and easy to adjust.
|
||||||
|
|
||||||
|
**Risk: Pagination latency.** Deep mode with 3 pages x 4 queries = 12 API calls. All run in parallel via ThreadPoolExecutor. Gamma API is fast (~200-500ms per call), so worst case ~1-2s total. Well within the 45s deep timeout.
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- Polymarket Gamma API: `GET https://gamma-api.polymarket.com/public-search?q={topic}&page={N}`
|
||||||
|
- Current implementation: `scripts/lib/polymarket.py`
|
||||||
|
- Scoring pipeline: `scripts/lib/score.py`
|
||||||
|
- Original Polymarket plan: `docs/plans/2026-02-25-feat-polymarket-prediction-market-source-plan.md`
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": false,
|
"closed": false,
|
||||||
"updatedAt": "2026-02-24T18:30:00.000Z",
|
"updatedAt": "2026-02-24T18:30:00.000Z",
|
||||||
|
"volume24hr": 342000,
|
||||||
|
"volume1wk": 1200000,
|
||||||
|
"volume1mo": 3500000,
|
||||||
|
"volume": 5000000,
|
||||||
|
"liquidity": 2100000,
|
||||||
|
"competitive": 0.92,
|
||||||
|
"commentCount": 15,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-arizona-big12-1",
|
"id": "mkt-arizona-big12-1",
|
||||||
@@ -32,6 +39,13 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": false,
|
"closed": false,
|
||||||
"updatedAt": "2026-02-23T12:00:00.000Z",
|
"updatedAt": "2026-02-23T12:00:00.000Z",
|
||||||
|
"volume24hr": 89000,
|
||||||
|
"volume1wk": 400000,
|
||||||
|
"volume1mo": 800000,
|
||||||
|
"volume": 1200000,
|
||||||
|
"liquidity": 450000,
|
||||||
|
"competitive": 0.76,
|
||||||
|
"commentCount": 8,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-arizona-ncaa-1",
|
"id": "mkt-arizona-ncaa-1",
|
||||||
@@ -57,6 +71,11 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": true,
|
"closed": true,
|
||||||
"updatedAt": "2026-02-20T10:00:00.000Z",
|
"updatedAt": "2026-02-20T10:00:00.000Z",
|
||||||
|
"volume24hr": 0,
|
||||||
|
"volume1mo": 0,
|
||||||
|
"liquidity": 0,
|
||||||
|
"competitive": 0,
|
||||||
|
"commentCount": 0,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-resolved-1",
|
"id": "mkt-resolved-1",
|
||||||
@@ -81,6 +100,13 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": false,
|
"closed": false,
|
||||||
"updatedAt": "2026-02-24T20:00:00.000Z",
|
"updatedAt": "2026-02-24T20:00:00.000Z",
|
||||||
|
"volume24hr": 150000,
|
||||||
|
"volume1wk": 800000,
|
||||||
|
"volume1mo": 2000000,
|
||||||
|
"volume": 4000000,
|
||||||
|
"liquidity": 1800000,
|
||||||
|
"competitive": 0.99,
|
||||||
|
"commentCount": 22,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-multi-1",
|
"id": "mkt-multi-1",
|
||||||
@@ -105,6 +131,11 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": false,
|
"closed": false,
|
||||||
"updatedAt": "2026-02-10T00:00:00.000Z",
|
"updatedAt": "2026-02-10T00:00:00.000Z",
|
||||||
|
"volume24hr": 0,
|
||||||
|
"volume1mo": 0,
|
||||||
|
"liquidity": 0,
|
||||||
|
"competitive": 0,
|
||||||
|
"commentCount": 0,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-dead-1",
|
"id": "mkt-dead-1",
|
||||||
@@ -129,6 +160,11 @@
|
|||||||
"active": true,
|
"active": true,
|
||||||
"closed": false,
|
"closed": false,
|
||||||
"updatedAt": "2026-02-22T08:00:00.000Z",
|
"updatedAt": "2026-02-22T08:00:00.000Z",
|
||||||
|
"volume24hr": 5000,
|
||||||
|
"volume1mo": 50000,
|
||||||
|
"liquidity": 30000,
|
||||||
|
"competitive": 0.5,
|
||||||
|
"commentCount": 1,
|
||||||
"markets": [
|
"markets": [
|
||||||
{
|
{
|
||||||
"id": "mkt-malformed-1",
|
"id": "mkt-malformed-1",
|
||||||
@@ -145,6 +181,37 @@
|
|||||||
"oneMonthPriceChange": 0
|
"oneMonthPriceChange": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "evt-tangential",
|
||||||
|
"title": "Will AI regulation pass in 2026?",
|
||||||
|
"slug": "ai-regulation-2026",
|
||||||
|
"active": true,
|
||||||
|
"closed": false,
|
||||||
|
"updatedAt": "2026-02-24T10:00:00.000Z",
|
||||||
|
"volume24hr": 500000,
|
||||||
|
"volume1wk": 2000000,
|
||||||
|
"volume1mo": 8000000,
|
||||||
|
"volume": 15000000,
|
||||||
|
"liquidity": 5000000,
|
||||||
|
"competitive": 0.95,
|
||||||
|
"commentCount": 50,
|
||||||
|
"markets": [
|
||||||
|
{
|
||||||
|
"id": "mkt-tangential-1",
|
||||||
|
"question": "Will AI regulation pass in 2026?",
|
||||||
|
"active": true,
|
||||||
|
"closed": false,
|
||||||
|
"outcomes": "[\"Yes\", \"No\"]",
|
||||||
|
"outcomePrices": "[\"0.30\", \"0.70\"]",
|
||||||
|
"volume": "8000000",
|
||||||
|
"volume24hr": "500000",
|
||||||
|
"liquidity": "5000000",
|
||||||
|
"oneDayPriceChange": -0.02,
|
||||||
|
"oneWeekPriceChange": 0.05,
|
||||||
|
"oneMonthPriceChange": 0.08
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,10 @@ def normalize_polymarket_items(
|
|||||||
normalized = []
|
normalized = []
|
||||||
|
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
|
# Prefer volume1mo (more stable) for engagement scoring, fall back to volume24hr
|
||||||
|
volume = item.get("volume1mo") or item.get("volume24hr", 0.0)
|
||||||
engagement = schema.Engagement(
|
engagement = schema.Engagement(
|
||||||
volume=item.get("volume24hr", 0.0),
|
volume=volume,
|
||||||
liquidity=item.get("liquidity", 0.0),
|
liquidity=item.get("liquidity", 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+95
-33
@@ -16,7 +16,15 @@ from . import http
|
|||||||
|
|
||||||
GAMMA_SEARCH_URL = "https://gamma-api.polymarket.com/public-search"
|
GAMMA_SEARCH_URL = "https://gamma-api.polymarket.com/public-search"
|
||||||
|
|
||||||
|
# Pages to fetch per query (API returns 5 events per page, limit param is a no-op)
|
||||||
DEPTH_CONFIG = {
|
DEPTH_CONFIG = {
|
||||||
|
"quick": 1,
|
||||||
|
"default": 2,
|
||||||
|
"deep": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Max events to return after merge + dedup + re-ranking
|
||||||
|
RESULT_CAP = {
|
||||||
"quick": 5,
|
"quick": 5,
|
||||||
"default": 10,
|
"default": 10,
|
||||||
"deep": 20,
|
"deep": 20,
|
||||||
@@ -82,22 +90,19 @@ def _expand_queries(topic: str) -> List[str]:
|
|||||||
return unique[:4]
|
return unique[:4]
|
||||||
|
|
||||||
|
|
||||||
def _search_single_query(query: str, limit: int) -> Dict[str, Any]:
|
def _search_single_query(query: str, page: int = 1) -> Dict[str, Any]:
|
||||||
"""Run a single search query against Gamma API."""
|
"""Run a single search query against Gamma API."""
|
||||||
params = {
|
params = {"q": query, "page": str(page)}
|
||||||
"q": query,
|
|
||||||
"limit": str(limit),
|
|
||||||
}
|
|
||||||
url = f"{GAMMA_SEARCH_URL}?{urlencode(params)}"
|
url = f"{GAMMA_SEARCH_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = http.request("GET", url, timeout=15, retries=2)
|
response = http.request("GET", url, timeout=15, retries=2)
|
||||||
return response
|
return response
|
||||||
except http.HTTPError as e:
|
except http.HTTPError as e:
|
||||||
_log(f"Search failed for '{query}': {e}")
|
_log(f"Search failed for '{query}' page {page}: {e}")
|
||||||
return {"events": [], "error": str(e)}
|
return {"events": [], "error": str(e)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log(f"Search failed for '{query}': {e}")
|
_log(f"Search failed for '{query}' page {page}: {e}")
|
||||||
return {"events": [], "error": str(e)}
|
return {"events": [], "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@@ -120,20 +125,22 @@ def search_polymarket(
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with 'events' list and optional 'error'.
|
Dict with 'events' list and optional 'error'.
|
||||||
"""
|
"""
|
||||||
limit_per_query = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
pages = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
|
cap = RESULT_CAP.get(depth, RESULT_CAP["default"])
|
||||||
queries = _expand_queries(topic)
|
queries = _expand_queries(topic)
|
||||||
|
|
||||||
_log(f"Searching for '{topic}' with queries: {queries} (limit={limit_per_query})")
|
_log(f"Searching for '{topic}' with queries: {queries} (pages={pages})")
|
||||||
|
|
||||||
# Run all queries in parallel
|
# Run all (query, page) combinations in parallel
|
||||||
all_events = {} # event_id -> (event_data, query_index)
|
all_events = {} # event_id -> (event_data, query_index)
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(4, len(queries))) as executor:
|
with ThreadPoolExecutor(max_workers=min(8, len(queries) * pages)) as executor:
|
||||||
futures = {
|
futures = {}
|
||||||
executor.submit(_search_single_query, q, limit_per_query): i
|
for i, q in enumerate(queries):
|
||||||
for i, q in enumerate(queries)
|
for p in range(1, pages + 1):
|
||||||
}
|
future = executor.submit(_search_single_query, q, p)
|
||||||
|
futures[future] = i
|
||||||
|
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
query_idx = futures[future]
|
query_idx = futures[future]
|
||||||
@@ -156,11 +163,10 @@ def search_polymarket(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(str(e))
|
errors.append(str(e))
|
||||||
|
|
||||||
# Sort by query priority, then by position
|
|
||||||
merged_events = [ev for ev, _ in sorted(all_events.values(), key=lambda x: x[1])]
|
merged_events = [ev for ev, _ in sorted(all_events.values(), key=lambda x: x[1])]
|
||||||
_log(f"Found {len(merged_events)} unique events across {len(queries)} queries")
|
_log(f"Found {len(merged_events)} unique events across {len(queries)} queries x {pages} pages")
|
||||||
|
|
||||||
result = {"events": merged_events}
|
result = {"events": merged_events, "_cap": cap}
|
||||||
if errors and not merged_events:
|
if errors and not merged_events:
|
||||||
result["error"] = "; ".join(errors[:2])
|
result["error"] = "; ".join(errors[:2])
|
||||||
return result
|
return result
|
||||||
@@ -227,6 +233,38 @@ def _parse_outcome_prices(market: Dict[str, Any]) -> List[tuple]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_text_similarity(topic: str, title: str) -> float:
|
||||||
|
"""Score how well the event title matches the search topic.
|
||||||
|
|
||||||
|
Returns 0.0-1.0. Substring containment gets full score,
|
||||||
|
token overlap gets proportional score.
|
||||||
|
"""
|
||||||
|
core = _extract_core_subject(topic).lower()
|
||||||
|
title_lower = title.lower()
|
||||||
|
if not core:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
# Full substring match
|
||||||
|
if core in title_lower:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Token overlap fallback
|
||||||
|
topic_tokens = set(core.split())
|
||||||
|
title_tokens = set(title_lower.split())
|
||||||
|
if not topic_tokens:
|
||||||
|
return 0.5
|
||||||
|
overlap = len(topic_tokens & title_tokens)
|
||||||
|
return overlap / len(topic_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(val, default=0.0) -> float:
|
||||||
|
"""Safely convert a value to float."""
|
||||||
|
try:
|
||||||
|
return float(val or default)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List[Dict[str, Any]]:
|
def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List[Dict[str, Any]]:
|
||||||
"""Parse Gamma API response into normalized item dicts.
|
"""Parse Gamma API response into normalized item dicts.
|
||||||
|
|
||||||
@@ -293,15 +331,13 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
|||||||
# Format price movement
|
# Format price movement
|
||||||
price_movement = _format_price_movement(top_market)
|
price_movement = _format_price_movement(top_market)
|
||||||
|
|
||||||
# Volume and liquidity
|
# Volume and liquidity - prefer event-level (more stable), fall back to market-level
|
||||||
try:
|
event_volume1mo = _safe_float(event.get("volume1mo"))
|
||||||
volume24hr = float(top_market.get("volume24hr", 0) or 0)
|
event_volume1wk = _safe_float(event.get("volume1wk"))
|
||||||
except (ValueError, TypeError):
|
event_liquidity = _safe_float(event.get("liquidity"))
|
||||||
volume24hr = 0.0
|
event_competitive = _safe_float(event.get("competitive"))
|
||||||
try:
|
volume24hr = _safe_float(event.get("volume24hr")) or _safe_float(top_market.get("volume24hr"))
|
||||||
liquidity = float(top_market.get("liquidity", 0) or 0)
|
liquidity = event_liquidity or _safe_float(top_market.get("liquidity"))
|
||||||
except (ValueError, TypeError):
|
|
||||||
liquidity = 0.0
|
|
||||||
|
|
||||||
# Event URL
|
# Event URL
|
||||||
url = f"https://polymarket.com/event/{slug}" if slug else f"https://polymarket.com/event/{event_id}"
|
url = f"https://polymarket.com/event/{slug}" if slug else f"https://polymarket.com/event/{event_id}"
|
||||||
@@ -310,7 +346,6 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
|||||||
updated_at = event.get("updatedAt", "")
|
updated_at = event.get("updatedAt", "")
|
||||||
date_str = None
|
date_str = None
|
||||||
if updated_at:
|
if updated_at:
|
||||||
# Parse ISO format: "2026-02-20T15:30:00.000Z"
|
|
||||||
try:
|
try:
|
||||||
date_str = updated_at[:10] # YYYY-MM-DD
|
date_str = updated_at[:10] # YYYY-MM-DD
|
||||||
except (IndexError, TypeError):
|
except (IndexError, TypeError):
|
||||||
@@ -324,10 +359,33 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
|||||||
except (IndexError, TypeError):
|
except (IndexError, TypeError):
|
||||||
end_date = None
|
end_date = None
|
||||||
|
|
||||||
# Relevance: position-based decay
|
# Quality-signal relevance (replaces position-based decay)
|
||||||
rank_score = max(0.3, 1.0 - (i * 0.03)) # 1.0 -> 0.3 over ~23 items
|
text_score = _compute_text_similarity(topic, title) if topic else 0.5
|
||||||
engagement_boost = min(0.15, math.log1p(volume24hr) / 60)
|
|
||||||
relevance = min(1.0, rank_score * 0.75 + engagement_boost + 0.1)
|
# Volume signal: log-scaled monthly volume (most stable signal)
|
||||||
|
vol_raw = event_volume1mo or event_volume1wk or volume24hr
|
||||||
|
vol_score = min(1.0, math.log1p(vol_raw) / 16) # ~$9M = 1.0
|
||||||
|
|
||||||
|
# Liquidity signal
|
||||||
|
liq_score = min(1.0, math.log1p(liquidity) / 14) # ~$1.2M = 1.0
|
||||||
|
|
||||||
|
# Price movement: daily weighted more than monthly
|
||||||
|
day_change = abs(top_market.get("oneDayPriceChange") or 0) * 3
|
||||||
|
week_change = abs(top_market.get("oneWeekPriceChange") or 0) * 2
|
||||||
|
month_change = abs(top_market.get("oneMonthPriceChange") or 0)
|
||||||
|
max_change = max(day_change, week_change, month_change)
|
||||||
|
movement_score = min(1.0, max_change * 5) # 20% change = 1.0
|
||||||
|
|
||||||
|
# Competitive bonus: markets near 50/50 are more interesting
|
||||||
|
competitive_score = event_competitive
|
||||||
|
|
||||||
|
relevance = min(1.0, (
|
||||||
|
0.30 * text_score +
|
||||||
|
0.30 * vol_score +
|
||||||
|
0.15 * liq_score +
|
||||||
|
0.15 * movement_score +
|
||||||
|
0.10 * competitive_score
|
||||||
|
))
|
||||||
|
|
||||||
# Top 3 outcomes for multi-outcome markets
|
# Top 3 outcomes for multi-outcome markets
|
||||||
top_outcomes = outcome_prices[:3]
|
top_outcomes = outcome_prices[:3]
|
||||||
@@ -344,6 +402,7 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
|||||||
"outcomes_remaining": remaining,
|
"outcomes_remaining": remaining,
|
||||||
"price_movement": price_movement,
|
"price_movement": price_movement,
|
||||||
"volume24hr": volume24hr,
|
"volume24hr": volume24hr,
|
||||||
|
"volume1mo": event_volume1mo,
|
||||||
"liquidity": liquidity,
|
"liquidity": liquidity,
|
||||||
"date": date_str,
|
"date": date_str,
|
||||||
"end_date": end_date,
|
"end_date": end_date,
|
||||||
@@ -351,4 +410,7 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
|||||||
"why_relevant": f"Prediction market: {title[:60]}",
|
"why_relevant": f"Prediction market: {title[:60]}",
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
# Sort by relevance (quality-signal ranked) and apply cap
|
||||||
|
items.sort(key=lambda x: x["relevance"], reverse=True)
|
||||||
|
cap = response.get("_cap", len(items))
|
||||||
|
return items[:cap]
|
||||||
|
|||||||
+142
-6
@@ -458,14 +458,150 @@ class TestPolymarketSchemaRoundTrip(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestDepthConfig(unittest.TestCase):
|
class TestDepthConfig(unittest.TestCase):
|
||||||
def test_quick_depth(self):
|
def test_quick_pages(self):
|
||||||
self.assertEqual(polymarket.DEPTH_CONFIG["quick"], 5)
|
self.assertEqual(polymarket.DEPTH_CONFIG["quick"], 1)
|
||||||
|
|
||||||
def test_default_depth(self):
|
def test_default_pages(self):
|
||||||
self.assertEqual(polymarket.DEPTH_CONFIG["default"], 10)
|
self.assertEqual(polymarket.DEPTH_CONFIG["default"], 2)
|
||||||
|
|
||||||
def test_deep_depth(self):
|
def test_deep_pages(self):
|
||||||
self.assertEqual(polymarket.DEPTH_CONFIG["deep"], 20)
|
self.assertEqual(polymarket.DEPTH_CONFIG["deep"], 3)
|
||||||
|
|
||||||
|
def test_result_cap_quick(self):
|
||||||
|
self.assertEqual(polymarket.RESULT_CAP["quick"], 5)
|
||||||
|
|
||||||
|
def test_result_cap_default(self):
|
||||||
|
self.assertEqual(polymarket.RESULT_CAP["default"], 10)
|
||||||
|
|
||||||
|
def test_result_cap_deep(self):
|
||||||
|
self.assertEqual(polymarket.RESULT_CAP["deep"], 20)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextSimilarity(unittest.TestCase):
|
||||||
|
def test_exact_substring_match(self):
|
||||||
|
score = polymarket._compute_text_similarity("Arizona", "Will Arizona win the NCAA Tournament?")
|
||||||
|
self.assertEqual(score, 1.0)
|
||||||
|
|
||||||
|
def test_full_topic_substring(self):
|
||||||
|
score = polymarket._compute_text_similarity("Arizona Basketball", "Arizona Basketball Championship")
|
||||||
|
self.assertEqual(score, 1.0)
|
||||||
|
|
||||||
|
def test_partial_token_overlap(self):
|
||||||
|
score = polymarket._compute_text_similarity("Arizona Basketball", "Will Arizona win?")
|
||||||
|
# "Arizona" matches, "Basketball" doesn't -> 0.5
|
||||||
|
self.assertAlmostEqual(score, 0.5)
|
||||||
|
|
||||||
|
def test_no_overlap(self):
|
||||||
|
score = polymarket._compute_text_similarity("Arizona Basketball", "Will AI regulation pass?")
|
||||||
|
self.assertEqual(score, 0.0)
|
||||||
|
|
||||||
|
def test_empty_topic(self):
|
||||||
|
score = polymarket._compute_text_similarity("", "Will Arizona win?")
|
||||||
|
self.assertEqual(score, 0.5)
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
score = polymarket._compute_text_similarity("arizona", "ARIZONA Big 12")
|
||||||
|
self.assertEqual(score, 1.0)
|
||||||
|
|
||||||
|
def test_prefix_stripped(self):
|
||||||
|
score = polymarket._compute_text_similarity("last 7 days Arizona", "Will Arizona win?")
|
||||||
|
self.assertEqual(score, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQualityRanking(unittest.TestCase):
|
||||||
|
"""Verify quality-signal ranking: high-volume matching events rank above tangential ones."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
fixture_path = Path(__file__).parent.parent / "fixtures" / "polymarket_sample.json"
|
||||||
|
with open(fixture_path) as f:
|
||||||
|
self.sample = json.load(f)
|
||||||
|
|
||||||
|
def test_topic_matching_ranks_above_tangential(self):
|
||||||
|
"""Arizona markets should rank above AI regulation when topic is 'Arizona Basketball'."""
|
||||||
|
items = polymarket.parse_polymarket_response(self.sample, topic="Arizona Basketball")
|
||||||
|
titles = [item["title"] for item in items]
|
||||||
|
# Arizona events should come before tangential AI regulation event
|
||||||
|
arizona_indices = [i for i, t in enumerate(titles) if "Arizona" in t or "Big 12" in t]
|
||||||
|
tangential_indices = [i for i, t in enumerate(titles) if "AI regulation" in t]
|
||||||
|
if tangential_indices:
|
||||||
|
self.assertTrue(max(arizona_indices) < min(tangential_indices),
|
||||||
|
f"Arizona markets should rank above tangential. Order: {titles}")
|
||||||
|
|
||||||
|
def test_high_volume_ranks_above_low_volume(self):
|
||||||
|
"""Among matching events, higher volume should rank higher."""
|
||||||
|
items = polymarket.parse_polymarket_response(self.sample, topic="Arizona Basketball")
|
||||||
|
# Arizona Big 12 has $3.5M monthly volume, Arizona NCAA has $800K
|
||||||
|
big12 = [i for i, item in enumerate(items) if "Big 12 Championship" in item["title"]]
|
||||||
|
ncaa = [i for i, item in enumerate(items) if "NCAA Tournament" in item["title"]]
|
||||||
|
if big12 and ncaa:
|
||||||
|
self.assertLess(big12[0], ncaa[0],
|
||||||
|
"Higher volume Big 12 should rank above lower volume NCAA")
|
||||||
|
|
||||||
|
def test_result_cap_applied(self):
|
||||||
|
"""Parse should respect the _cap from search response."""
|
||||||
|
capped_response = dict(self.sample)
|
||||||
|
capped_response["_cap"] = 2
|
||||||
|
items = polymarket.parse_polymarket_response(capped_response, topic="Arizona")
|
||||||
|
self.assertLessEqual(len(items), 2)
|
||||||
|
|
||||||
|
def test_no_topic_still_ranks(self):
|
||||||
|
"""Without a topic, relevance should still be computed from volume/liquidity."""
|
||||||
|
items = polymarket.parse_polymarket_response(self.sample)
|
||||||
|
self.assertTrue(len(items) > 0)
|
||||||
|
for item in items:
|
||||||
|
self.assertGreaterEqual(item["relevance"], 0.0)
|
||||||
|
self.assertLessEqual(item["relevance"], 1.0)
|
||||||
|
|
||||||
|
def test_relevance_sorted_descending(self):
|
||||||
|
"""Items should be sorted by relevance descending."""
|
||||||
|
items = polymarket.parse_polymarket_response(self.sample, topic="Arizona Basketball")
|
||||||
|
relevances = [item["relevance"] for item in items]
|
||||||
|
self.assertEqual(relevances, sorted(relevances, reverse=True))
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizePolymarketVolume1mo(unittest.TestCase):
|
||||||
|
"""Verify normalization prefers volume1mo over volume24hr for engagement."""
|
||||||
|
|
||||||
|
def test_volume1mo_preferred(self):
|
||||||
|
raw_items = [
|
||||||
|
{
|
||||||
|
"event_id": "evt-1",
|
||||||
|
"title": "Test",
|
||||||
|
"question": "Q?",
|
||||||
|
"url": "https://polymarket.com/event/test",
|
||||||
|
"outcome_prices": [],
|
||||||
|
"outcomes_remaining": 0,
|
||||||
|
"volume24hr": 100.0,
|
||||||
|
"volume1mo": 5000000.0,
|
||||||
|
"liquidity": 1000.0,
|
||||||
|
"date": "2026-02-20",
|
||||||
|
"relevance": 0.8,
|
||||||
|
"why_relevant": "Test",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = normalize.normalize_polymarket_items(raw_items, "2026-01-01", "2026-03-01")
|
||||||
|
# Engagement volume should be volume1mo (5M), not volume24hr (100)
|
||||||
|
self.assertEqual(result[0].engagement.volume, 5000000.0)
|
||||||
|
|
||||||
|
def test_fallback_to_volume24hr(self):
|
||||||
|
raw_items = [
|
||||||
|
{
|
||||||
|
"event_id": "evt-1",
|
||||||
|
"title": "Test",
|
||||||
|
"question": "Q?",
|
||||||
|
"url": "https://polymarket.com/event/test",
|
||||||
|
"outcome_prices": [],
|
||||||
|
"outcomes_remaining": 0,
|
||||||
|
"volume24hr": 50000.0,
|
||||||
|
"liquidity": 1000.0,
|
||||||
|
"date": "2026-02-20",
|
||||||
|
"relevance": 0.8,
|
||||||
|
"why_relevant": "Test",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = normalize.normalize_polymarket_items(raw_items, "2026-01-01", "2026-03-01")
|
||||||
|
# No volume1mo, should fall back to volume24hr
|
||||||
|
self.assertEqual(result[0].engagement.volume, 50000.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user