diff --git a/tests/test_ops_market_opportunities.py b/tests/test_ops_market_opportunities.py index 4cb1480b..151038dc 100644 --- a/tests/test_ops_market_opportunities.py +++ b/tests/test_ops_market_opportunities.py @@ -154,6 +154,91 @@ def test_build_market_opportunities_aggregates_fahrenheit_distribution(): assert round(rows[0]["edge"], 2) == 0.46 +def test_build_market_opportunities_filters_late_priced_no_noise(): + event = { + "slug": "highest-temperature-in-jeddah-on-july-3-2026", + "markets": [ + { + "question": "Will the highest temperature in Jeddah be 36°C on July 3?", + "slug": "highest-temperature-in-jeddah-on-july-3-2026-36c", + "active": True, + "closed": False, + "enableOrderBook": True, + "liquidity": "154", + "volume": "6833", + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["yes-36", "no-36"]', + } + ], + } + row = _row("jeddah") + row["local_time"] = "19:22" + row["current_max_so_far"] = None + row["deb_prediction"] = 35.3 + row["model_cluster_sources"] = {"ECMWF": 34.5, "GFS": 44.0, "ICON": 35.0} + row["distribution_full"] = [ + {"value": 36, "probability": 0.10}, + {"value": 37, "probability": 0.90}, + ] + + rows = build_market_opportunity_rows( + [row], + {"jeddah": event}, + {"yes-36": 0.99, "no-36": 0.01}, + max_price=0.20, + side="both", + positive_edge_only=True, + min_edge=0.0, + limit=20, + ) + + assert rows == [] + + +def test_build_market_opportunities_keeps_late_no_when_bucket_conflicts_with_anchors(): + event = { + "slug": "highest-temperature-in-jeddah-on-july-3-2026", + "markets": [ + { + "question": "Will the highest temperature in Jeddah be 36°C on July 3?", + "slug": "highest-temperature-in-jeddah-on-july-3-2026-36c", + "active": True, + "closed": False, + "enableOrderBook": True, + "liquidity": "154", + "volume": "6833", + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["yes-36", "no-36"]', + } + ], + } + row = _row("jeddah") + row["local_time"] = "19:22" + row["current_max_so_far"] = None + row["deb_prediction"] = 31.0 + row["model_cluster_sources"] = {"ECMWF": 30.5, "GFS": 31.0, "ICON": 31.5} + row["distribution_full"] = [ + {"value": 36, "probability": 0.10}, + {"value": 31, "probability": 0.90}, + ] + + rows = build_market_opportunity_rows( + [row], + {"jeddah": event}, + {"yes-36": 0.99, "no-36": 0.01}, + max_price=0.20, + side="both", + positive_edge_only=True, + min_edge=0.0, + limit=20, + ) + + assert len(rows) == 1 + assert rows[0]["bucket_label"] == "36°C" + assert rows[0]["side"] == "no" + assert rows[0]["side_probability"] == 0.90 + + def test_ops_market_opportunities_requires_ops_admin(monkeypatch): def deny(_request): raise HTTPException(status_code=403, detail="ops only") diff --git a/web/services/ops/market_opportunities.py b/web/services/ops/market_opportunities.py index fd964527..52462b45 100644 --- a/web/services/ops/market_opportunities.py +++ b/web/services/ops/market_opportunities.py @@ -229,6 +229,60 @@ def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[floa return round(median, 1), round(max(values) - min(values), 1) +def _local_hour(row: Mapping[str, Any]) -> Optional[int]: + text = str(row.get("local_time") or "").strip() + if not text: + return None + try: + hour = int(text.split(":", 1)[0]) + except (TypeError, ValueError): + return None + return hour if 0 <= hour <= 23 else None + + +def _option_near_value(option: Mapping[str, Any], value: Any) -> bool: + number = _finite_number(value) + if number is None: + return False + unit = str(option.get("unit") or "").upper() + tolerance = 2.0 if "F" in unit else 1.0 + lower = _finite_number(option.get("lower")) + upper = _finite_number(option.get("upper")) + if lower is not None and upper is not None: + return lower - tolerance <= number <= upper + tolerance + if lower is not None: + return number >= lower - tolerance + if upper is not None: + return number <= upper + tolerance + return False + + +def _is_late_priced_no_noise( + row: Mapping[str, Any], + option: Mapping[str, Any], + ask_prices_by_token: Mapping[str, Optional[float]], + tokens: Mapping[str, Optional[str]], + *, + no_ask: float, + model_median: Optional[float], +) -> bool: + if no_ask > 0.05: + return False + hour = _local_hour(row) + if hour is None or hour < 17: + return False + yes_token = tokens.get("yes") + yes_ask = _finite_number(ask_prices_by_token.get(yes_token)) if yes_token else None + if yes_ask is not None and yes_ask < 0.80: + return False + anchors = ( + row.get("current_max_so_far"), + row.get("deb_prediction"), + model_median, + ) + return any(_option_near_value(option, anchor) for anchor in anchors) + + def _market_tokens(market: Mapping[str, Any]) -> Dict[str, Optional[str]]: outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))] token_ids = [str(item).strip() for item in _json_list(market.get("clobTokenIds"))] @@ -315,6 +369,15 @@ def build_market_opportunity_rows( ask_number = _finite_number(ask) if ask_number is None or ask_number <= 0 or ask_number >= float(max_price): continue + if option_side == "no" and _is_late_priced_no_noise( + scan_row, + option, + ask_prices_by_token, + tokens, + no_ask=ask_number, + model_median=model_median, + ): + continue target_probability = ( model_probability if option_side == "yes"