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"),