From 99ffa717efdfb73142601047beffab187d0d1f02 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 24 Apr 2026 15:12:53 +0800 Subject: [PATCH] Use model cluster for tail no scan signals --- .../components/dashboard/OpportunityTable.tsx | 7 +- frontend/lib/dashboard-types.ts | 11 ++ src/data_collection/polymarket_readonly.py | 142 +++++++++++++++++- tests/test_polymarket_readonly.py | 107 +++++++++++++ web/analysis_service.py | 4 + 5 files changed, 263 insertions(+), 8 deletions(-) diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 4e57ee35..b8105451 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -342,6 +342,11 @@ export const OpportunityTable = React.memo(function OpportunityTable({ : row.model_event_probability != null ? row.model_event_probability * 100 : null; + const modelLabel = row.cluster_adjusted + ? isEn + ? "Model" + : "模型" + : "EMOS"; const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场"; const edgePositive = Number(row.edge_percent || 0) >= 0; return ( @@ -360,7 +365,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({ - EMOS + {modelLabel} {formatPercent(modelProbability)} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 1683fc92..57dbd3c7 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -470,6 +470,7 @@ export interface ScanOpportunityRow { model_probability?: number | null; market_probability?: number | null; model_event_probability?: number | null; + raw_model_event_probability?: number | null; market_event_probability?: number | null; gap?: number | null; signed_gap?: number | null; @@ -492,6 +493,7 @@ export interface ScanOpportunityRow { edge_percent?: number | null; edge_score?: number | null; bias_score?: number | null; + consensus_score?: number | null; distribution_bias?: DistributionBias | null; distribution_preview?: DistributionPreviewPoint[] | null; distribution_bias_direction?: string | null; @@ -502,6 +504,15 @@ export interface ScanOpportunityRow { peak_distance?: number | null; peak_alignment_score?: number | null; is_peak_candidate?: boolean; + is_directional_candidate?: boolean; + cluster_adjusted?: boolean; + cluster_role?: string | null; + cluster_center?: number | null; + cluster_core_low?: number | null; + cluster_core_high?: number | null; + cluster_model_count?: number | null; + cluster_deb_reference?: number | null; + cluster_median?: number | null; window_phase?: string | null; window_score?: number | null; remaining_window_minutes?: number | null; diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 6ec6370b..d4dc2159 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -2896,24 +2896,125 @@ class PolymarketReadOnlyLayer: ), ) + raw_model_values: List[float] = [] + scan_models = (scan_context or {}).get("models") + if isinstance(scan_models, dict): + for raw_value in scan_models.values(): + value = _safe_float(raw_value) + if value is not None: + raw_model_values.append(value) + raw_deb_prediction = _safe_float((scan_context or {}).get("deb_prediction")) + current_reference_raw = _safe_float( (scan_context or {}).get("current_max_so_far") or (scan_context or {}).get("current_temp") ) + def _median(values: List[float]) -> Optional[float]: + if not values: + return None + sorted_values = sorted(values) + middle = len(sorted_values) // 2 + if len(sorted_values) % 2: + return sorted_values[middle] + return (sorted_values[middle - 1] + sorted_values[middle]) / 2.0 + + def _build_cluster_meta(market_unit: str) -> Dict[str, Any]: + converted_values = [ + self._convert_temp_to_market_unit( + value, + source_symbol=temp_symbol, + market_unit=market_unit, + ) + for value in raw_model_values + ] + model_values = [value for value in converted_values if value is not None] + deb_reference = self._convert_temp_to_market_unit( + raw_deb_prediction, + source_symbol=temp_symbol, + market_unit=market_unit, + ) + median_value = _median(model_values) + if deb_reference is not None and median_value is not None: + center = (deb_reference + median_value) / 2.0 + elif deb_reference is not None: + center = deb_reference + elif median_value is not None: + center = median_value + elif peak_value is not None: + center = peak_value + else: + center = None + + unit_step = 1.8 if str(market_unit or "").upper() == "F" else 1.0 + return { + "available": center is not None and bool(model_values), + "center": center, + "core_low": center - 0.75 * unit_step if center is not None else None, + "core_high": center + 1.25 * unit_step if center is not None else None, + "low_tail": center - 0.75 * unit_step if center is not None else None, + "high_tail": center + 1.75 * unit_step if center is not None else None, + "model_count": len(model_values), + "deb_reference": deb_reference, + "median": median_value, + } + + def _cluster_role_for_target( + *, + target_value: Optional[float], + cluster_meta: Dict[str, Any], + ) -> str: + if not cluster_meta.get("available") or target_value is None: + return "unknown" + low_tail = _safe_float(cluster_meta.get("low_tail")) + high_tail = _safe_float(cluster_meta.get("high_tail")) + core_low = _safe_float(cluster_meta.get("core_low")) + core_high = _safe_float(cluster_meta.get("core_high")) + if low_tail is not None and target_value <= low_tail: + return "low_tail" + if high_tail is not None and target_value >= high_tail: + return "high_tail" + if ( + core_low is not None + and core_high is not None + and core_low < target_value <= core_high + ): + return "core" + return "shoulder" + def _row_from_entry( entry: Dict[str, Any], side: str, *, entry_index: int, ) -> Optional[Dict[str, Any]]: - model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability"))) + raw_model_event_probability = _clamp_probability(_safe_float(entry.get("model_event_probability"))) + model_event_probability = raw_model_event_probability market_event_probability = _clamp_probability(_safe_float(entry.get("market_event_probability"))) ask = _clamp_probability(_safe_float(entry.get("yes_ask") if side == "yes" else entry.get("no_ask"))) bid = _clamp_probability(_safe_float(entry.get("yes_bid") if side == "yes" else entry.get("no_bid"))) if model_event_probability is None or ask is None: return None + market = entry["market"] + target_threshold = _safe_float(entry.get("target_threshold")) + bucket_range = entry.get("bucket_range") + market_unit = bucket_range[2] if bucket_range else ("F" if self._is_fahrenheit_symbol(temp_symbol) else "C") + cluster_meta = _build_cluster_meta(market_unit) + cluster_target = _safe_float(entry.get("bucket_temp")) or target_threshold + cluster_role = _cluster_role_for_target( + target_value=cluster_target, + cluster_meta=cluster_meta, + ) + cluster_adjusted = False + if ( + raw_model_event_probability is not None + and str(entry.get("market_direction") or "exact") in {"exact", "range"} + and cluster_role in {"low_tail", "high_tail"} + ): + model_event_probability = _clamp_probability(raw_model_event_probability * 0.45) + cluster_adjusted = True + model_probability = ( model_event_probability if side == "yes" @@ -2927,10 +3028,6 @@ class PolymarketReadOnlyLayer: if model_probability is None: return None - market = entry["market"] - target_threshold = _safe_float(entry.get("target_threshold")) - bucket_range = entry.get("bucket_range") - market_unit = bucket_range[2] if bucket_range else ("F" if self._is_fahrenheit_symbol(temp_symbol) else "C") current_reference = self._convert_temp_to_market_unit( current_reference_raw, source_symbol=temp_symbol, @@ -2947,6 +3044,23 @@ class PolymarketReadOnlyLayer: if entry_order is not None and peak_entry_order is not None: peak_distance = abs(entry_order - peak_entry_order) is_peak_candidate = peak_distance <= 1 + market_structure = str(entry.get("market_direction") or "exact") + is_consensus_tail_no = ( + side == "no" + and market_structure in {"exact", "range"} + and cluster_role in {"low_tail", "high_tail"} + ) + is_consensus_core_yes = ( + side == "yes" + and market_structure in {"exact", "range"} + and cluster_role in {"core", "shoulder", "unknown"} + and (is_peak_candidate or cluster_role == "core") + ) + is_directional_candidate = ( + is_consensus_tail_no + or is_consensus_core_yes + or (market_structure not in {"exact", "range"} and is_peak_candidate) + ) peak_alignment_score = 0.0 if peak_distance is None: peak_alignment_score = 0.35 @@ -2999,13 +3113,15 @@ class PolymarketReadOnlyLayer: min(((spread or 0.0) - 0.01) / 0.02, 1.0), ) * 15.0 edge_score = max(0.0, min(edge_percent / 12.0, 1.0)) + consensus_score = 1.0 if is_directional_candidate else 0.0 final_score = 100.0 * ( - 0.35 * edge_score + 0.32 * edge_score + 0.25 * bias_score + 0.20 * float(window_meta.get("score") or 0.0) + 0.10 * liquidity_score + 0.10 * price_usefulness_score + 0.08 * peak_alignment_score + + 0.12 * consensus_score ) - spread_penalty market_slug = str(market.get("slug") or "").strip() target_label = str(entry.get("target_label") or "").strip() @@ -3032,6 +3148,7 @@ class PolymarketReadOnlyLayer: "model_probability": model_probability, "market_probability": market_probability, "model_event_probability": model_event_probability, + "raw_model_event_probability": raw_model_event_probability, "market_event_probability": market_event_probability, "gap": ( model_event_probability - market_event_probability @@ -3070,6 +3187,7 @@ class PolymarketReadOnlyLayer: "edge_percent": edge_percent, "edge_score": edge_score, "bias_score": bias_score, + "consensus_score": consensus_score, "window_phase": window_meta.get("phase"), "window_score": window_meta.get("score"), "remaining_window_minutes": window_meta.get("remaining_minutes"), @@ -3086,6 +3204,15 @@ class PolymarketReadOnlyLayer: "peak_distance": peak_distance, "peak_alignment_score": peak_alignment_score, "is_peak_candidate": is_peak_candidate, + "is_directional_candidate": is_directional_candidate, + "cluster_adjusted": cluster_adjusted, + "cluster_role": cluster_role, + "cluster_center": cluster_meta.get("center"), + "cluster_core_low": cluster_meta.get("core_low"), + "cluster_core_high": cluster_meta.get("core_high"), + "cluster_model_count": cluster_meta.get("model_count"), + "cluster_deb_reference": cluster_meta.get("deb_reference"), + "cluster_median": cluster_meta.get("median"), "current_reference": current_reference, "gap_to_target": gap_to_target, "touch_distance": abs(gap_to_target) if gap_to_target is not None else None, @@ -3191,7 +3318,7 @@ class PolymarketReadOnlyLayer: if scan_mode == "tradable": return ( float(row.get("window_score") or 0.0) >= 0.65 - and bool(row.get("is_peak_candidate")) + and bool(row.get("is_directional_candidate")) ) if scan_mode == "early": return str(row.get("window_phase") or "") in {"tomorrow", "week_ahead", "early_today"} @@ -3213,6 +3340,7 @@ class PolymarketReadOnlyLayer: ] filtered_rows.sort( key=lambda row: ( + 1.0 if bool(row.get("is_directional_candidate")) else 0.0, 1.0 if bool(row.get("is_peak_candidate")) else 0.0, float(row.get("final_score") or 0.0), float(row.get("edge_percent") or 0.0), diff --git a/tests/test_polymarket_readonly.py b/tests/test_polymarket_readonly.py index 95459061..2c841959 100644 --- a/tests/test_polymarket_readonly.py +++ b/tests/test_polymarket_readonly.py @@ -738,6 +738,113 @@ def test_distribution_scan_tradable_prefers_peak_bucket_and_adjacent_only(): assert all((row.get("peak_distance") or 0) <= 1 for row in scan["rows"]) +def test_distribution_scan_uses_model_cluster_to_prefer_tail_no_over_yes(): + layer = PolymarketReadOnlyLayer() + markets = [ + { + "id": "m-21", + "slug": "highest-temperature-in-paris-on-april-24-2026-21c", + "question": "Will the highest temperature in Paris be 21C on April 24?", + "active": True, + "closed": False, + "acceptingOrders": True, + "enableOrderBook": True, + "liquidityNum": 6000, + "volumeNum": 5000, + "_model_prob": 0.205, + }, + { + "id": "m-22", + "slug": "highest-temperature-in-paris-on-april-24-2026-22c", + "question": "Will the highest temperature in Paris be 22C on April 24?", + "active": True, + "closed": False, + "acceptingOrders": True, + "enableOrderBook": True, + "liquidityNum": 6000, + "volumeNum": 5000, + "_model_prob": 0.34, + }, + { + "id": "m-24", + "slug": "highest-temperature-in-paris-on-april-24-2026-24c", + "question": "Will the highest temperature in Paris be 24C on April 24?", + "active": True, + "closed": False, + "acceptingOrders": True, + "enableOrderBook": True, + "liquidityNum": 6000, + "volumeNum": 5000, + "_model_prob": 0.06, + }, + ] + token_map = { + "m-21": {"yes": "yes-21", "no": "no-21"}, + "m-22": {"yes": "yes-22", "no": "no-22"}, + "m-24": {"yes": "yes-24", "no": "no-24"}, + } + quote_map = { + "yes-21": {"buy": 0.16, "sell": 0.14, "midpoint": 0.15, "spread": 0.02, "book_liquidity": 6000}, + "no-21": {"buy": 0.85, "sell": 0.83, "midpoint": 0.84, "spread": 0.02, "book_liquidity": 6000}, + "yes-22": {"buy": 0.34, "sell": 0.32, "midpoint": 0.33, "spread": 0.02, "book_liquidity": 6000}, + "no-22": {"buy": 0.67, "sell": 0.65, "midpoint": 0.66, "spread": 0.02, "book_liquidity": 6000}, + "yes-24": {"buy": 0.06, "sell": 0.05, "midpoint": 0.055, "spread": 0.01, "book_liquidity": 6000}, + "no-24": {"buy": 0.948, "sell": 0.93, "midpoint": 0.94, "spread": 0.018, "book_liquidity": 6000}, + } + + layer._collect_related_temperature_markets = lambda **_kwargs: markets + layer._aggregate_distribution_probability_for_market = ( + lambda market, **_kwargs: market.get("_model_prob") + ) + layer._extract_market_tokens = lambda market: [ + {"outcome": "Yes", "token_id": token_map[market["id"]]["yes"]}, + {"outcome": "No", "token_id": token_map[market["id"]]["no"]}, + ] + layer._batch_get_token_market_data = ( + lambda token_ids, include_books=False: { + token_id: dict(quote_map[token_id]) + for token_id in token_ids + if token_id in quote_map + } + ) + + scan = layer._build_distribution_scan_pack( + city_key="paris", + target_date="2026-04-24", + primary_market=markets[1], + probability_distribution=[], + temp_symbol="°C", + scan_context={ + "local_date": "2026-04-24", + "local_time": "08:54", + "peak": {"first_h": 14, "last_h": 16}, + "current_max_so_far": 20.0, + "current_temp": 20.0, + "trend": {"recent": []}, + "network_lead_signal": {}, + "deb_prediction": 22.0, + "models": { + "Open-Meteo": 22.4, + "ICON": 22.4, + "GEM": 22.2, + "GDPS": 22.2, + "ECMWF": 21.2, + "JMA": 20.9, + "GFS": 20.6, + "AIFS": 22.9, + }, + }, + scan_filters={"limit": 10, "scan_mode": "tradable", "min_edge_pct": 2}, + ) + + recommendations = {(row["target_value"], row["side"]) for row in scan["rows"]} + assert (21.0, "no") in recommendations + assert (24.0, "no") in recommendations + assert (21.0, "yes") not in recommendations + assert all(row.get("is_directional_candidate") for row in scan["rows"]) + assert all(row.get("cluster_adjusted") for row in scan["rows"]) + + def test_batch_token_market_data_falls_back_to_single_fetch_when_batch_fails(): layer = PolymarketReadOnlyLayer() layer._clob_post = lambda *_args, **_kwargs: None diff --git a/web/analysis_service.py b/web/analysis_service.py index 2fafb065..632a2c65 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2880,6 +2880,8 @@ def _build_city_market_scan_payload( if isinstance(p, dict) ] current = data.get("current") or {} + selected_deb = selected_daily.get("deb") if isinstance(selected_daily.get("deb"), dict) else {} + current_deb = data.get("deb") if isinstance(data.get("deb"), dict) else {} scan_context = { "local_date": data.get("local_date"), "local_time": data.get("local_time"), @@ -2888,6 +2890,8 @@ def _build_city_market_scan_payload( "current_temp": current.get("temp"), "trend": data.get("trend") or {}, "network_lead_signal": data.get("network_lead_signal") or {}, + "models": model_map, + "deb_prediction": selected_deb.get("prediction") or current_deb.get("prediction"), } market_scan = _market_layer.build_market_scan( city=data.get("name"),