返回 last30days-skill
test_polymarket.py
根目录 / tests / test_polymarket.py
1 """Tests for polymarket.py - Polymarket prediction market search."""
2
3 import json
4 from unittest.mock import Mock, patch
5
6 import pytest
7
8 from lib import polymarket
9
10 # === Helper Functions ===
11
12
13 def create_mock_event(
14 event_id="evt-123",
15 title="Test Event",
16 slug="test-event",
17 volume24hr=100000,
18 liquidity=50000,
19 closed=False,
20 markets=None,
21 ):
22 """Create a mock Polymarket event."""
23 if markets is None:
24 markets = [create_mock_market()]
25
26 return {
27 "id": event_id,
28 "title": title,
29 "slug": slug,
30 "active": True,
31 "closed": closed,
32 "volume24hr": volume24hr,
33 "liquidity": liquidity,
34 "markets": markets,
35 }
36
37
38 def create_mock_market(
39 market_id="mkt-123",
40 question="Will X happen?",
41 outcomes='["Yes", "No"]',
42 prices='["0.60", "0.40"]',
43 volume="100000",
44 liquidity="50000",
45 closed=False,
46 ):
47 """Create a mock Polymarket market."""
48 return {
49 "id": market_id,
50 "question": question,
51 "active": True,
52 "closed": closed,
53 "outcomes": outcomes,
54 "outcomePrices": prices,
55 "volume": volume,
56 "liquidity": liquidity,
57 }
58
59 # === Tests for _extract_core_subject() ===
60
61
62 def test_extract_core_subject_basic():
63 """Test basic subject extraction."""
64 result = polymarket._extract_core_subject("AI frameworks")
65 assert result == "AI frameworks"
66
67
68 def test_extract_core_subject_with_time_prefix():
69 """Test stripping time prefixes."""
70 result = polymarket._extract_core_subject("last 7 days AI frameworks")
71 assert result == "AI frameworks"
72
73
74 def test_extract_core_subject_with_question_prefix():
75 """Test stripping question prefixes."""
76 result = polymarket._extract_core_subject("what are people saying about Kanye West")
77 assert result == "Kanye West"
78
79
80 def test_extract_core_subject_multiple_prefixes():
81 """Test handling multiple prefix patterns."""
82 result = polymarket._extract_core_subject("research AI models")
83 assert result == "AI models"
84
85 # === Tests for _expand_queries() ===
86
87
88 def test_expand_queries_basic():
89 """Test basic query expansion."""
90 queries = polymarket._expand_queries("AI framework")
91
92 # Should include core + individual words
93 assert "AI framework" in queries or "ai framework" in queries
94 assert len(queries) >= 2
95
96
97 def test_expand_queries_single_word():
98 """Test query expansion with single word."""
99 queries = polymarket._expand_queries("AI")
100
101 # Single word, should just return that word
102 assert len(queries) >= 1
103 assert any("ai" in q.lower() for q in queries)
104
105
106 def test_expand_queries_filters_noise():
107 """Test that low-signal tokens are filtered."""
108 queries = polymarket._expand_queries("the AI framework")
109
110 # "the" should be filtered from individual word expansions
111 # But may appear in the full phrase
112 assert len(queries) >= 1
113 assert any("ai" in q.lower() or "framework" in q.lower() for q in queries)
114
115
116 def test_expand_queries_deduplication():
117 """Test that duplicate queries are removed."""
118 queries = polymarket._expand_queries("test test test")
119
120 # Should dedupe
121 assert len(queries) == len(set(q.lower() for q in queries))
122
123
124 def test_expand_queries_cap_at_six():
125 """Test that query list is capped at 6."""
126 long_topic = "one two three four five six seven eight"
127 queries = polymarket._expand_queries(long_topic)
128
129 assert len(queries) <= 6
130
131 # === Tests for _passes_topic_filter() ===
132
133
134 def test_passes_topic_filter_match():
135 """Test that matching events pass the filter."""
136 assert polymarket._passes_topic_filter("AI safety", "AI Safety Conference 2026") is True
137
138
139 def test_passes_topic_filter_no_match():
140 """Test that non-matching events are filtered."""
141 # "West" is a noise word, so "Kanye West" requires "Kanye" to match
142 assert polymarket._passes_topic_filter("Kanye West", "NFC West Championship") is False
143
144
145 def test_passes_topic_filter_partial_match():
146 """Test that at least one informative word must match."""
147 # "AI" appears in both topic and title
148 assert polymarket._passes_topic_filter("AI safety", "New AI Safety Conference") is True
149 # LOCAL DIVERGENCE from upstream: this asserted False, on the rule that a
150 # domain word ("ai") is too broad to be the sole match signal. That rule
151 # made domain sweeps impossible — an AI-news topic matched zero AI markets
152 # ever, because real titles say "AI" and never "artificial intelligence".
153 # _passes_topic_filter now falls back to _DOMAIN_WORDS when the informative
154 # words miss, so "AI models" does match an AI market.
155 assert polymarket._passes_topic_filter("AI models", "New AI prediction") is True
156
157
158 def test_passes_topic_filter_all_noise_words():
159 """Test that all-noise-word topics don't filter anything."""
160 # "the west" has no informative words, so should pass everything
161 result = polymarket._passes_topic_filter("the west", "Any title")
162 assert result is True
163
164
165 def test_passes_topic_filter_empty_topic():
166 """Test empty topic always passes."""
167 assert polymarket._passes_topic_filter("", "Any title") is True
168
169
170 def test_passes_topic_filter_multi_word_requires_two_matches():
171 """Topics with 3+ informative words require at least 2 to match."""
172 # "Mill food recycler" has 3 informative words. "Meek Mill YC" only matches "mill".
173 assert polymarket._passes_topic_filter(
174 "Mill food recycler", "Meek Mill gets Y Combinator funding"
175 ) is False
176
177
178 def test_passes_topic_filter_multi_word_passes_with_two_matches():
179 """Topics with 3+ informative words pass when 2+ match."""
180 assert polymarket._passes_topic_filter(
181 "Sam Altman OpenAI", "Sam Altman CEO OpenAI"
182 ) is True
183
184
185 def test_passes_topic_filter_two_word_still_needs_one():
186 """Topics with 2 informative words still only need 1 match (existing behavior)."""
187 assert polymarket._passes_topic_filter(
188 "Kanye West", "Kanye divorce settlement"
189 ) is True
190
191
192
193 def test_domain_fallback_keeps_soft_sweep_ai_markets():
194 """Soft sweep residue may match via domain words (the #859 fix)."""
195 assert polymarket._passes_topic_filter(
196 "AI frontier developments", "Will AI models beat humans at coding?"
197 ) is True
198
199
200
201 def test_domain_fallback_treats_plural_domain_terms_as_domain():
202 """Plural domain tokens (models) must not block soft AI sweeps."""
203 assert polymarket._passes_topic_filter(
204 "AI models frontier developments",
205 "Will AI beat humans at coding by 2027?",
206 ) is True
207 assert polymarket._passes_topic_filter(
208 "AI models", "New AI prediction"
209 ) is True
210
211
212 def test_domain_fallback_blocked_when_hard_informative_misses():
213 """Mixed topics must not accept unrelated markets via a shared domain token."""
214 assert polymarket._passes_topic_filter(
215 "MCP protocol benchmark", "Will the Kyoto Protocol survive?"
216 ) is False
217 assert polymarket._passes_any_informative_word(
218 "MCP protocol benchmark", "Will the Kyoto Protocol survive?"
219 ) is False
220
221
222 def test_passes_topic_filter_multi_word_edge_exactly_three():
223 """Topic with exactly 3 informative words, 1 match -> rejected."""
224 assert polymarket._passes_topic_filter(
225 "Tesla stock price", "Tesla quarterly earnings"
226 ) is False # only "tesla" matches, needs 2
227
228 # === Tests for _parse_outcome_prices() ===
229
230
231 def test_parse_outcome_prices_basic():
232 """Test basic outcome price parsing."""
233 market = {
234 "outcomes": '["Yes", "No"]',
235 "outcomePrices": '["0.65", "0.35"]',
236 }
237
238 result = polymarket._parse_outcome_prices(market)
239
240 assert len(result) == 2
241 assert result[0] == ("Yes", 0.65)
242 assert result[1] == ("No", 0.35)
243
244
245 def test_parse_outcome_prices_already_parsed():
246 """Test handling when outcomes/prices are already lists."""
247 market = {
248 "outcomes": ["Yes", "No"],
249 "outcomePrices": ["0.70", "0.30"],
250 }
251
252 result = polymarket._parse_outcome_prices(market)
253
254 assert len(result) == 2
255 assert result[0][0] == "Yes"
256 assert result[0][1] == 0.70
257
258
259 def test_parse_outcome_prices_multi_outcome():
260 """Test multi-outcome markets."""
261 market = {
262 "outcomes": '["Team A", "Team B", "Team C", "Team D"]',
263 "outcomePrices": '["0.40", "0.30", "0.20", "0.10"]',
264 }
265
266 result = polymarket._parse_outcome_prices(market)
267
268 assert len(result) == 4
269 assert result[0] == ("Team A", 0.40)
270 assert result[3] == ("Team D", 0.10)
271
272
273 def test_parse_outcome_prices_missing_data():
274 """Test handling of missing outcome prices."""
275 market = {"outcomes": '["Yes"]'}
276
277 result = polymarket._parse_outcome_prices(market)
278
279 assert result == []
280
281
282 def test_parse_outcome_prices_invalid_json():
283 """Test handling of invalid JSON."""
284 market = {
285 "outcomes": "not valid json",
286 "outcomePrices": "also not valid",
287 }
288
289 result = polymarket._parse_outcome_prices(market)
290
291 assert result == []
292
293 # === Tests for _format_price_movement() ===
294
295
296 def test_format_price_movement_one_day():
297 """Test formatting one-day price movement."""
298 market = {
299 "oneDayPriceChange": 0.05, # Up 5%
300 "oneWeekPriceChange": 0.02,
301 "oneMonthPriceChange": 0.01,
302 }
303
304 result = polymarket._format_price_movement(market)
305
306 assert "up" in result
307 assert "5.0%" in result
308 assert "today" in result
309
310
311 def test_format_price_movement_negative():
312 """Test formatting negative price movement."""
313 market = {
314 "oneDayPriceChange": -0.15, # Down 15%
315 }
316
317 result = polymarket._format_price_movement(market)
318
319 assert "down" in result
320 assert "15.0%" in result
321
322
323 def test_format_price_movement_picks_largest():
324 """Test that largest change is picked."""
325 market = {
326 "oneDayPriceChange": 0.02,
327 "oneWeekPriceChange": 0.10, # Largest
328 "oneMonthPriceChange": 0.05,
329 }
330
331 result = polymarket._format_price_movement(market)
332
333 assert "10.0%" in result
334 assert "this week" in result
335
336
337 def test_format_price_movement_below_threshold():
338 """Test that small changes return None."""
339 market = {
340 "oneDayPriceChange": 0.005, # 0.5%, below 1% threshold
341 }
342
343 result = polymarket._format_price_movement(market)
344
345 assert result is None
346
347
348 def test_format_price_movement_missing_data():
349 """Test handling of missing price change data."""
350 market = {}
351
352 result = polymarket._format_price_movement(market)
353
354 assert result is None
355
356 # === Tests for _shorten_question() ===
357
358
359 def test_shorten_question_will_pattern():
360 """Test shortening 'Will X...' questions."""
361 result = polymarket._shorten_question("Will Arizona win the NCAA Tournament?")
362
363 assert result == "Arizona"
364
365
366 def test_shorten_question_complex_will():
367 """Test shortening complex Will questions."""
368 result = polymarket._shorten_question("Will Duke be a number 1 seed?")
369
370 assert result == "Duke"
371
372
373 def test_shorten_question_no_pattern():
374 """Test questions that don't match patterns."""
375 result = polymarket._shorten_question("Arizona wins championship")
376
377 # Should return original or truncated
378 assert len(result) > 0
379
380
381 def test_shorten_question_long():
382 """Test truncation of very long questions."""
383 long_q = "A" * 100
384 result = polymarket._shorten_question(long_q)
385
386 assert len(result) <= 40
387
388
389 def test_shorten_question_fallback_strips_leading_article():
390 """The truncation fallback must not keep a leading article like 'an' or 'the'.
391
392 Without stripping, a question like 'an Anthropic Claude model scores...' yields
393 a lead name of just 'an', which renders as the mangled 'an 19%' footer fragment.
394 """
395 result = polymarket._shorten_question(
396 "an Anthropic Claude model scores at the top of the leaderboard this month"
397 )
398 lower = result.lower()
399 assert not lower.startswith("a ")
400 assert not lower.startswith("an ")
401 assert not lower.startswith("the ")
402
403
404 def test_shorten_question_fallback_keeps_non_article_lead():
405 """Stripping only removes a leading article, not the first informative word."""
406 result = polymarket._shorten_question(
407 "Anthropic ships a major Claude model update before the end of this month"
408 )
409 assert result.lower().startswith("anthropic")
410
411 # === Tests for search_polymarket() ===
412
413
414 def test_search_polymarket_result_cap():
415 """Test that result cap configuration exists."""
416 assert "quick" in polymarket.RESULT_CAP
417 assert "default" in polymarket.RESULT_CAP
418 assert "deep" in polymarket.RESULT_CAP
419
420 # Deep should return more results
421 assert polymarket.RESULT_CAP["deep"] >= polymarket.RESULT_CAP["quick"]
422
423
424 def test_search_polymarket_depth_config():
425 """Test that depth configuration exists."""
426 # Verify depth config
427 assert "quick" in polymarket.DEPTH_CONFIG
428 assert "default" in polymarket.DEPTH_CONFIG
429 assert "deep" in polymarket.DEPTH_CONFIG
430
431 # Quick should be least pages
432 assert polymarket.DEPTH_CONFIG["quick"] <= polymarket.DEPTH_CONFIG["default"]
433
434
435 def test_search_polymarket_query_expansion():
436 """Test that _expand_queries creates multiple queries."""
437 queries = polymarket._expand_queries("AI framework")
438
439 # Should expand to multiple queries
440 assert len(queries) >= 2
441
442 @patch('lib.polymarket.http.post')
443
444
445 def test_search_polymarket_http_error_handling(mock_post):
446 """Test graceful handling of HTTP errors."""
447 from lib.http import HTTPError
448 mock_post.side_effect = HTTPError("HTTP 429: Rate limit")
449
450 result = polymarket.search_polymarket("test", "2026-01-01", "2026-01-31")
451
452 # Should return structure with error
453 assert "events" in result or "error" in result
454
455 # === Tests for parse_polymarket_response() ===
456
457
458 def test_parse_polymarket_response_basic():
459 """Test basic response parsing."""
460 response = {
461 "events": [create_mock_event(
462 title="Will AI surpass humans?",
463 volume24hr=500000,
464 )]
465 }
466
467 items = polymarket.parse_polymarket_response(response, topic="AI")
468
469 assert len(items) >= 0
470 # Items may be filtered by topic filter
471
472
473 def test_parse_polymarket_response_filters_closed():
474 """Test that closed events are filtered."""
475 response = {
476 "events": [
477 create_mock_event(closed=False),
478 create_mock_event(closed=True),
479 ]
480 }
481
482 items = polymarket.parse_polymarket_response(response)
483
484 # Closed events should be filtered
485 # (exact count depends on market filtering)
486 assert isinstance(items, list)
487
488
489 def test_parse_polymarket_response_empty():
490 """Test handling of empty response."""
491 response = {"events": []}
492
493 items = polymarket.parse_polymarket_response(response)
494
495 assert items == []
496
497
498 def test_parse_polymarket_response_market_url():
499 """Test that Polymarket URLs are generated."""
500 response = {
501 "events": [create_mock_event(
502 slug="test-event-slug",
503 title="Test Event"
504 )]
505 }
506
507 items = polymarket.parse_polymarket_response(response, topic="test")
508
509 if items: # If not filtered
510 assert "url" in items[0]
511 assert "polymarket.com" in items[0]["url"]
512
513
514 def test_parse_polymarket_response_engagement():
515 """Test that engagement/volume metrics are captured."""
516 response = {
517 "events": [create_mock_event(
518 title="AI Event",
519 volume24hr=250000,
520 liquidity=100000,
521 )]
522 }
523
524 items = polymarket.parse_polymarket_response(response, topic="AI")
525
526 if items: # If not filtered
527 # Check for volume or liquidity fields
528 assert "volume24hr" in items[0] or "liquidity" in items[0] or isinstance(items[0], dict)
529
530
531 def _claude_downtime_response():
532 """An off-topic Polymarket event that mentions only the generic word 'Claude'."""
533 return {
534 "events": [
535 create_mock_event(
536 event_id="evt-noise",
537 title="Will Claude go down 3-5 times in June?",
538 slug="claude-downtime",
539 ),
540 ]
541 }
542
543
544 def test_parse_polymarket_response_filters_noise_on_full_subquery():
545 """A multi-word subquery filters off-topic 'Claude downtime' noise.
546
547 'Claude Code subagents workflow' carries 3 informative words; the downtime
548 title matches only one ('claude'), so the min-2 rule drops it.
549 """
550 items = polymarket.parse_polymarket_response(
551 _claude_downtime_response(), topic="Claude Code subagents workflow"
552 )
553 assert items == []
554
555
556 def test_parse_polymarket_response_narrow_subquery_leaks_noise():
557 """The SAME off-topic market leaks through a single-word subquery.
558
559 'claude' has one informative word, so the min-match threshold drops to 1 and
560 the downtime market passes. Because the pipeline previously fed the per-subquery
561 search_query, filtering swung between these two outcomes across the fanout;
562 keying off the stable original topic makes it consistent. This pair pins that
563 threshold-by-word-count behavior the wiring fix depends on.
564 """
565 items = polymarket.parse_polymarket_response(
566 _claude_downtime_response(), topic="claude"
567 )
568 assert len(items) == 1
569
570 # === Tests for engagement scoring ===
571
572
573 def test_engagement_with_volume():
574 """Test engagement calculation with volume."""
575 response = {
576 "events": [create_mock_event(
577 title="Test",
578 volume24hr=500000,
579 )]
580 }
581
582 items = polymarket.parse_polymarket_response(response, topic="test")
583
584 if items:
585 engagement = items[0].get("engagement", {})
586 # volume24hr should be captured
587 assert "volume24hr" in engagement or isinstance(engagement, dict)
588
589 # === Tests for noise-word query skipping ===
590
591
592 def test_expand_queries_skips_noise_words():
593 """Noise words like 'west' should not become standalone queries."""
594 queries = polymarket._expand_queries("kanye west")
595 lowered = [q.lower() for q in queries]
596 assert "kanye west" in lowered # full phrase kept
597 assert "kanye" in lowered # informative word kept
598 assert "west" not in lowered # noise word skipped
599
600
601 def test_expand_queries_keeps_informative_words():
602 """Non-noise words should still be expanded as standalone queries."""
603 queries = polymarket._expand_queries("arizona basketball")
604 lowered = [q.lower() for q in queries]
605 assert "arizona" in lowered
606 assert "basketball" in lowered
607
608
609 def test_expand_queries_all_noise_words_keeps_phrase():
610 """If all words are noise, the full phrase is still searched."""
611 queries = polymarket._expand_queries("north west")
612 assert len(queries) >= 1
613 assert any("north west" in q.lower() for q in queries)
614 # Neither individual word should be a standalone query
615 lowered = [q.lower() for q in queries]
616 assert "north" not in lowered or "north west" in lowered # only as part of phrase
617
618 # === Tests for per-item relevance floor ===
619
620
621 def test_per_item_relevance_floor_drops_zero_items():
622 """Items with relevance 0.0 should be dropped even if best item is high."""
623 # Simulate the filtering logic directly
624 items = [
625 {"relevance": 0.85, "title": "Kanye market"},
626 {"relevance": 0.45, "title": "Related market"},
627 {"relevance": 0.0, "title": "Golf noise"},
628 {"relevance": 0.0, "title": "Cycling noise"},
629 ]
630 filtered = [i for i in items if i["relevance"] >= 0.10]
631 assert len(filtered) == 2
632 assert all(i["title"] != "Golf noise" for i in filtered)
633
634
635 def test_per_item_relevance_floor_keeps_borderline():
636 """Items at exactly 0.10 should be kept."""
637 items = [
638 {"relevance": 0.85, "title": "Main market"},
639 {"relevance": 0.10, "title": "Borderline market"},
640 {"relevance": 0.09, "title": "Below floor"},
641 ]
642 filtered = [i for i in items if i["relevance"] >= 0.10]
643 assert len(filtered) == 2
644 assert filtered[1]["title"] == "Borderline market"
645
646
647 def test_per_item_relevance_floor_no_drops_when_all_high():
648 """Nothing dropped when all items are above the floor."""
649 items = [
650 {"relevance": 0.85, "title": "A"},
651 {"relevance": 0.50, "title": "B"},
652 {"relevance": 0.30, "title": "C"},
653 ]
654 filtered = [i for i in items if i["relevance"] >= 0.10]
655 assert len(filtered) == 3
656
657 if __name__ == "__main__":
658 pytest.main([__file__, "-v"])
659
659 lines PYTHON