From 1a8713f927570ce82672f1434e47c9d5af7136e4 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 4 Jul 2026 01:59:48 +0800 Subject: [PATCH] Add multimodel context to market opportunities --- ...026-07-04-ops-market-multimodel-context.md | 82 +++++++++++++++++++ .../__tests__/opsMarketOpportunities.test.ts | 8 ++ .../MarketOpportunitiesPageClient.tsx | 36 +++++++- tests/test_ops_market_opportunities.py | 57 ++++++++++++- web/services/ops/market_opportunities.py | 65 +++++++++++++-- 5 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-04-ops-market-multimodel-context.md diff --git a/docs/superpowers/plans/2026-07-04-ops-market-multimodel-context.md b/docs/superpowers/plans/2026-07-04-ops-market-multimodel-context.md new file mode 100644 index 00000000..f6a7b3d5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-ops-market-multimodel-context.md @@ -0,0 +1,82 @@ +# Ops Market Multimodel Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Ops market opportunities evaluate and display full multi-model context instead of relying mainly on DEB and median. + +**Architecture:** Keep the existing Ops opportunities API and page. Extend each opportunity row with multi-model source values and bucket-relative counts, then use those counts in the late NO filter so high/low multi-model consensus is preserved for manual review. + +**Tech Stack:** Python FastAPI service code, pytest, Next.js React TypeScript, business state tests. + +--- + +### Task 1: Backend Multi-Model Signals + +**Files:** +- Modify: `web/services/ops/market_opportunities.py` +- Test: `tests/test_ops_market_opportunities.py` + +- [ ] **Step 1: Add tests** + +Add tests asserting opportunities include `model_cluster_sources`, `model_min`, `model_max`, `models_in_bucket`, `models_above_bucket`, `models_below_bucket`, and `models_above_deb`. + +- [ ] **Step 2: Implement source extraction** + +Create helper functions to parse `row["model_cluster_sources"]`, classify model values against a market option, and return counts plus min/max. + +- [ ] **Step 3: Return fields on opportunity rows** + +Include the new fields in every row produced by `build_market_opportunity_rows`. + +- [ ] **Step 4: Verify** + +Run `python -m pytest tests/test_ops_market_opportunities.py -q`. + +### Task 2: Conservative Late NO Filtering + +**Files:** +- Modify: `web/services/ops/market_opportunities.py` +- Test: `tests/test_ops_market_opportunities.py` + +- [ ] **Step 1: Update tests** + +Change the late NO tests so rows are filtered only when multi-model consensus is inside the target bucket, and preserved when models mostly sit above or below the target bucket. + +- [ ] **Step 2: Implement filtering** + +Pass model relation counts into `_is_late_priced_no_noise`; return `False` when outside-bucket model count is greater than inside-bucket count. + +- [ ] **Step 3: Verify** + +Run `python -m pytest tests/test_ops_market_opportunities.py -q`. + +### Task 3: Frontend Display + +**Files:** +- Modify: `frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx` +- Test: `frontend/components/ops/__tests__/opsMarketOpportunities.test.ts` + +- [ ] **Step 1: Add frontend assertions** + +Assert the Ops market opportunities page includes `多模型`, `models_above_bucket`, and `model_cluster_sources`. + +- [ ] **Step 2: Display compact context** + +Add a `多模型` column showing min/max range, in/high/low bucket counts, and compact source values. + +- [ ] **Step 3: Verify** + +Run `cd frontend && npm run test:business` and `cd frontend && npm run typecheck`. + +### Task 4: Final Validation and Deploy + +**Files:** +- No new source files. + +- [ ] **Step 1: Run full checks** + +Run `python -m ruff check .`, `python -m pytest`, `cd frontend && npm run test:business`, and `cd frontend && npm run typecheck`. + +- [ ] **Step 2: Commit and push** + +Commit the backend and frontend changes, push `main`, monitor GitHub Actions, and smoke check production. diff --git a/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts b/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts index becba70c..a99ce976 100644 --- a/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts +++ b/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts @@ -49,6 +49,7 @@ export function runTests() { "买入价", "方向概率", "Edge", + "多模型", "市场链接", ]) { assert(client.includes(column), `market opportunities table must include ${column}`); @@ -69,4 +70,11 @@ export function runTests() { client.includes("YES"), "NO opportunities must show side probability while keeping the underlying YES probability visible", ); + assert( + client.includes("model_cluster_sources") && + client.includes("models_above_bucket") && + client.includes("models_below_bucket") && + client.includes("models_in_bucket"), + "market opportunities must expose multi-model context and bucket-relative model counts", + ); } diff --git a/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx b/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx index a6ebb10c..2184c5fb 100644 --- a/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx +++ b/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx @@ -17,6 +17,14 @@ type MarketOpportunityRow = { model_probability?: number; yes_probability?: number; side_probability?: number; + model_cluster_sources?: Record; + model_count?: number; + model_min?: number | null; + model_max?: number | null; + models_below_bucket?: number; + models_in_bucket?: number; + models_above_bucket?: number; + models_above_deb?: number; edge?: number; liquidity?: number | null; volume?: number | null; @@ -89,6 +97,16 @@ function sideProbability(row: MarketOpportunityRow) { return yes; } +function modelSourceSummary(row: MarketOpportunityRow) { + const sources = row.model_cluster_sources || {}; + return Object.entries(sources) + .filter(([, value]) => typeof value === "number" && Number.isFinite(value)) + .sort(([left], [right]) => left.localeCompare(right)) + .slice(0, 6) + .map(([name, value]) => `${name} ${value.toFixed(1)}`) + .join(" · "); +} + function Stat({ label, value, @@ -251,7 +269,7 @@ export function MarketOpportunitiesPageClient() { ) : null}
- +
@@ -263,6 +281,7 @@ export function MarketOpportunitiesPageClient() { + @@ -272,7 +291,7 @@ export function MarketOpportunitiesPageClient() { {loading ? ( - @@ -304,6 +323,17 @@ export function MarketOpportunitiesPageClient() { + @@ -325,7 +355,7 @@ export function MarketOpportunitiesPageClient() { )) ) : ( - diff --git a/tests/test_ops_market_opportunities.py b/tests/test_ops_market_opportunities.py index 05911f27..139ff580 100644 --- a/tests/test_ops_market_opportunities.py +++ b/tests/test_ops_market_opportunities.py @@ -103,6 +103,13 @@ def test_build_market_opportunities_scans_yes_and_no_low_price_edges(): assert yes["model_probability"] == 0.46 assert yes["yes_probability"] == 0.46 assert yes["side_probability"] == 0.46 + assert yes["model_cluster_sources"] == {"ECMWF": 32.0, "GFS": 33.0, "ICON": 31.0} + assert yes["model_min"] == 31.0 + assert yes["model_max"] == 33.0 + assert yes["models_below_bucket"] == 2 + assert yes["models_in_bucket"] == 1 + assert yes["models_above_bucket"] == 0 + assert yes["models_above_deb"] == 1 assert yes["ask_price"] == 0.18 assert round(yes["edge"], 2) == 0.28 assert no["model_probability"] == 0.10 @@ -185,7 +192,7 @@ 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(): +def test_build_market_opportunities_filters_late_priced_no_when_models_are_in_bucket(): event = { "slug": "highest-temperature-in-jeddah-on-july-3-2026", "markets": [ @@ -205,8 +212,8 @@ def test_build_market_opportunities_filters_late_priced_no_noise(): 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["deb_prediction"] = 35.8 + row["model_cluster_sources"] = {"ECMWF": 35.7, "GFS": 36.1, "ICON": 36.2} row["distribution_full"] = [ {"value": 36, "probability": 0.10}, {"value": 37, "probability": 0.90}, @@ -270,6 +277,50 @@ def test_build_market_opportunities_keeps_late_no_when_bucket_conflicts_with_anc assert rows[0]["side_probability"] == 0.90 +def test_build_market_opportunities_keeps_late_no_when_models_are_above_bucket(): + 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": 37.0, "GFS": 38.5, "ICON": 36.8} + 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 len(rows) == 1 + assert rows[0]["models_above_bucket"] == 3 + assert rows[0]["models_in_bucket"] == 0 + assert rows[0]["side"] == "no" + + 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 f65c58b2..d438153b 100644 --- a/web/services/ops/market_opportunities.py +++ b/web/services/ops/market_opportunities.py @@ -213,15 +213,21 @@ def _bucket_probability(row: Mapping[str, Any], option: Mapping[str, Any]) -> Op return _round_probability(probability) -def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]: +def _model_sources(row: Mapping[str, Any]) -> Dict[str, float]: sources = row.get("model_cluster_sources") or {} if not isinstance(sources, Mapping): - return None, None - values = sorted( - number - for number in (_finite_number(value) for value in sources.values()) - if number is not None - ) + return {} + result: Dict[str, float] = {} + for name, value in sources.items(): + number = _finite_number(value) + if number is None: + continue + result[str(name)] = round(number, 1) + return result + + +def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]: + values = sorted(_model_sources(row).values()) if not values: return None, None mid = len(values) // 2 @@ -229,6 +235,42 @@ def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[floa return round(median, 1), round(max(values) - min(values), 1) +def _model_option_relation( + row: Mapping[str, Any], + option: Mapping[str, Any], +) -> Dict[str, Any]: + sources = _model_sources(row) + values = list(sources.values()) + lower = _finite_number(option.get("lower")) + upper = _finite_number(option.get("upper")) + unit = str(option.get("unit") or "").upper() + half_width = 1.0 if "F" in unit and lower != upper else 0.5 + effective_lower = lower - half_width if lower is not None else None + effective_upper = upper + half_width if upper is not None else None + below = 0 + inside = 0 + above = 0 + for value in values: + if effective_lower is not None and value < effective_lower: + below += 1 + elif effective_upper is not None and value > effective_upper: + above += 1 + else: + inside += 1 + deb = _finite_number(row.get("deb_prediction")) + above_deb = sum(1 for value in values if deb is not None and value > deb) + return { + "model_cluster_sources": sources, + "model_count": len(values), + "model_min": round(min(values), 1) if values else None, + "model_max": round(max(values), 1) if values else None, + "models_below_bucket": below, + "models_in_bucket": inside, + "models_above_bucket": above, + "models_above_deb": above_deb, + } + + def _local_hour(row: Mapping[str, Any]) -> Optional[int]: text = str(row.get("local_time") or "").strip() if not text: @@ -265,6 +307,7 @@ def _is_late_priced_no_noise( *, no_ask: float, model_median: Optional[float], + model_relation: Optional[Mapping[str, Any]] = None, ) -> bool: if no_ask > 0.05: return False @@ -275,6 +318,11 @@ def _is_late_priced_no_noise( 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 + relation = model_relation or _model_option_relation(row, option) + outside = int(relation.get("models_above_bucket") or 0) + int(relation.get("models_below_bucket") or 0) + inside = int(relation.get("models_in_bucket") or 0) + if outside > inside: + return False anchors = ( row.get("current_max_so_far"), row.get("deb_prediction"), @@ -361,6 +409,7 @@ def build_market_opportunity_rows( if model_probability is None: continue tokens = _market_tokens(market) + model_relation = _model_option_relation(scan_row, option) for option_side in _iter_market_sides(side): token_id = tokens.get(option_side) if not token_id: @@ -376,6 +425,7 @@ def build_market_opportunity_rows( tokens, no_ask=ask_number, model_median=model_median, + model_relation=model_relation, ): continue target_probability = ( @@ -411,6 +461,7 @@ def build_market_opportunity_rows( "deb_prediction": _finite_number(scan_row.get("deb_prediction")), "model_median": model_median, "model_spread": model_spread, + **model_relation, "local_time": scan_row.get("local_time"), "region": row_region, }
城市当前最高 DEB 模型中位数多模型 分歧 流动性 成交量
+ 加载中...
{tempLabel(row.current_max_so_far)} {tempLabel(row.deb_prediction)} {tempLabel(row.model_median)} +
+ {tempLabel(row.model_min)}-{tempLabel(row.model_max)} +
+
+ 低 {row.models_below_bucket ?? 0} · 内 {row.models_in_bucket ?? 0} · 高 {row.models_above_bucket ?? 0} +
+
+ {modelSourceSummary(row) || "—"} +
+
{tempLabel(row.model_spread)} {numberLabel(row.liquidity)} {numberLabel(row.volume)}
+ 当前筛选下没有低价市场机会。