From 9d9e7e89d9a038570ed1a4d9449d49ab582a0c79 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 25 Feb 2026 23:07:43 -0800 Subject: [PATCH] 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 --- ...25-feat-polymarket-quality-ranking-plan.md | 189 ++++++++++++++++++ fixtures/polymarket_sample.json | 67 +++++++ scripts/lib/normalize.py | 4 +- scripts/lib/polymarket.py | 128 +++++++++--- tests/test_polymarket.py | 148 +++++++++++++- 5 files changed, 496 insertions(+), 40 deletions(-) create mode 100644 docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md diff --git a/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md b/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md new file mode 100644 index 0000000..ab7cf67 --- /dev/null +++ b/docs/plans/2026-02-25-feat-polymarket-quality-ranking-plan.md @@ -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` diff --git a/fixtures/polymarket_sample.json b/fixtures/polymarket_sample.json index cfc68fd..6955807 100644 --- a/fixtures/polymarket_sample.json +++ b/fixtures/polymarket_sample.json @@ -7,6 +7,13 @@ "active": true, "closed": false, "updatedAt": "2026-02-24T18:30:00.000Z", + "volume24hr": 342000, + "volume1wk": 1200000, + "volume1mo": 3500000, + "volume": 5000000, + "liquidity": 2100000, + "competitive": 0.92, + "commentCount": 15, "markets": [ { "id": "mkt-arizona-big12-1", @@ -32,6 +39,13 @@ "active": true, "closed": false, "updatedAt": "2026-02-23T12:00:00.000Z", + "volume24hr": 89000, + "volume1wk": 400000, + "volume1mo": 800000, + "volume": 1200000, + "liquidity": 450000, + "competitive": 0.76, + "commentCount": 8, "markets": [ { "id": "mkt-arizona-ncaa-1", @@ -57,6 +71,11 @@ "active": true, "closed": true, "updatedAt": "2026-02-20T10:00:00.000Z", + "volume24hr": 0, + "volume1mo": 0, + "liquidity": 0, + "competitive": 0, + "commentCount": 0, "markets": [ { "id": "mkt-resolved-1", @@ -81,6 +100,13 @@ "active": true, "closed": false, "updatedAt": "2026-02-24T20:00:00.000Z", + "volume24hr": 150000, + "volume1wk": 800000, + "volume1mo": 2000000, + "volume": 4000000, + "liquidity": 1800000, + "competitive": 0.99, + "commentCount": 22, "markets": [ { "id": "mkt-multi-1", @@ -105,6 +131,11 @@ "active": true, "closed": false, "updatedAt": "2026-02-10T00:00:00.000Z", + "volume24hr": 0, + "volume1mo": 0, + "liquidity": 0, + "competitive": 0, + "commentCount": 0, "markets": [ { "id": "mkt-dead-1", @@ -129,6 +160,11 @@ "active": true, "closed": false, "updatedAt": "2026-02-22T08:00:00.000Z", + "volume24hr": 5000, + "volume1mo": 50000, + "liquidity": 30000, + "competitive": 0.5, + "commentCount": 1, "markets": [ { "id": "mkt-malformed-1", @@ -145,6 +181,37 @@ "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 + } + ] } ] } diff --git a/scripts/lib/normalize.py b/scripts/lib/normalize.py index d209f20..516c9d0 100644 --- a/scripts/lib/normalize.py +++ b/scripts/lib/normalize.py @@ -275,8 +275,10 @@ def normalize_polymarket_items( normalized = [] 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( - volume=item.get("volume24hr", 0.0), + volume=volume, liquidity=item.get("liquidity", 0.0), ) diff --git a/scripts/lib/polymarket.py b/scripts/lib/polymarket.py index 879967a..f692f79 100644 --- a/scripts/lib/polymarket.py +++ b/scripts/lib/polymarket.py @@ -16,7 +16,15 @@ from . import http 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 = { + "quick": 1, + "default": 2, + "deep": 3, +} + +# Max events to return after merge + dedup + re-ranking +RESULT_CAP = { "quick": 5, "default": 10, "deep": 20, @@ -82,22 +90,19 @@ def _expand_queries(topic: str) -> List[str]: 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.""" - params = { - "q": query, - "limit": str(limit), - } + params = {"q": query, "page": str(page)} url = f"{GAMMA_SEARCH_URL}?{urlencode(params)}" try: response = http.request("GET", url, timeout=15, retries=2) return response 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)} 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)} @@ -120,20 +125,22 @@ def search_polymarket( Returns: 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) - _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) errors = [] - with ThreadPoolExecutor(max_workers=min(4, len(queries))) as executor: - futures = { - executor.submit(_search_single_query, q, limit_per_query): i - for i, q in enumerate(queries) - } + with ThreadPoolExecutor(max_workers=min(8, len(queries) * pages)) as executor: + futures = {} + 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): query_idx = futures[future] @@ -156,11 +163,10 @@ def search_polymarket( except Exception as 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])] - _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: result["error"] = "; ".join(errors[:2]) return result @@ -227,6 +233,38 @@ def _parse_outcome_prices(market: Dict[str, Any]) -> List[tuple]: 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]]: """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 price_movement = _format_price_movement(top_market) - # Volume and liquidity - try: - volume24hr = float(top_market.get("volume24hr", 0) or 0) - except (ValueError, TypeError): - volume24hr = 0.0 - try: - liquidity = float(top_market.get("liquidity", 0) or 0) - except (ValueError, TypeError): - liquidity = 0.0 + # Volume and liquidity - prefer event-level (more stable), fall back to market-level + event_volume1mo = _safe_float(event.get("volume1mo")) + event_volume1wk = _safe_float(event.get("volume1wk")) + event_liquidity = _safe_float(event.get("liquidity")) + event_competitive = _safe_float(event.get("competitive")) + volume24hr = _safe_float(event.get("volume24hr")) or _safe_float(top_market.get("volume24hr")) + liquidity = event_liquidity or _safe_float(top_market.get("liquidity")) # Event URL 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", "") date_str = None if updated_at: - # Parse ISO format: "2026-02-20T15:30:00.000Z" try: date_str = updated_at[:10] # YYYY-MM-DD except (IndexError, TypeError): @@ -324,10 +359,33 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List except (IndexError, TypeError): end_date = None - # Relevance: position-based decay - rank_score = max(0.3, 1.0 - (i * 0.03)) # 1.0 -> 0.3 over ~23 items - engagement_boost = min(0.15, math.log1p(volume24hr) / 60) - relevance = min(1.0, rank_score * 0.75 + engagement_boost + 0.1) + # Quality-signal relevance (replaces position-based decay) + text_score = _compute_text_similarity(topic, title) if topic else 0.5 + + # 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_outcomes = outcome_prices[:3] @@ -344,6 +402,7 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List "outcomes_remaining": remaining, "price_movement": price_movement, "volume24hr": volume24hr, + "volume1mo": event_volume1mo, "liquidity": liquidity, "date": date_str, "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]}", }) - 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] diff --git a/tests/test_polymarket.py b/tests/test_polymarket.py index f659cdb..04cd424 100644 --- a/tests/test_polymarket.py +++ b/tests/test_polymarket.py @@ -458,14 +458,150 @@ class TestPolymarketSchemaRoundTrip(unittest.TestCase): class TestDepthConfig(unittest.TestCase): - def test_quick_depth(self): - self.assertEqual(polymarket.DEPTH_CONFIG["quick"], 5) + def test_quick_pages(self): + self.assertEqual(polymarket.DEPTH_CONFIG["quick"], 1) - def test_default_depth(self): - self.assertEqual(polymarket.DEPTH_CONFIG["default"], 10) + def test_default_pages(self): + self.assertEqual(polymarket.DEPTH_CONFIG["default"], 2) - def test_deep_depth(self): - self.assertEqual(polymarket.DEPTH_CONFIG["deep"], 20) + def test_deep_pages(self): + 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__":