Add multimodel context to market opportunities

This commit is contained in:
2569718930@qq.com
2026-07-04 01:59:48 +08:00
parent 9d76e90a28
commit 1a8713f927
5 changed files with 235 additions and 13 deletions
@@ -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.
@@ -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",
);
}
@@ -17,6 +17,14 @@ type MarketOpportunityRow = {
model_probability?: number;
yes_probability?: number;
side_probability?: number;
model_cluster_sources?: Record<string, number>;
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() {
</div>
) : null}
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-[1320px] w-full border-collapse text-sm">
<table className="min-w-[1460px] w-full border-collapse text-sm">
<thead className="bg-slate-50 text-left text-xs font-black uppercase tracking-wide text-slate-500">
<tr>
<th className="px-3 py-2"></th>
@@ -263,6 +281,7 @@ export function MarketOpportunitiesPageClient() {
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right">DEB</th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
@@ -272,7 +291,7 @@ export function MarketOpportunitiesPageClient() {
<tbody className="divide-y divide-slate-100 bg-white">
{loading ? (
<tr>
<td colSpan={13} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
<td colSpan={14} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
...
</td>
</tr>
@@ -304,6 +323,17 @@ export function MarketOpportunitiesPageClient() {
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.current_max_so_far)}</td>
<td className="px-3 py-2 text-right font-semibold text-orange-700">{tempLabel(row.deb_prediction)}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_median)}</td>
<td className="px-3 py-2">
<div className="font-bold text-slate-800">
{tempLabel(row.model_min)}-{tempLabel(row.model_max)}
</div>
<div className="mt-0.5 text-[11px] font-semibold text-slate-500">
{row.models_below_bucket ?? 0} · {row.models_in_bucket ?? 0} · {row.models_above_bucket ?? 0}
</div>
<div className="mt-0.5 max-w-[260px] truncate text-[11px] font-semibold text-slate-400" title={modelSourceSummary(row)}>
{modelSourceSummary(row) || "—"}
</div>
</td>
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_spread)}</td>
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.liquidity)}</td>
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.volume)}</td>
@@ -325,7 +355,7 @@ export function MarketOpportunitiesPageClient() {
))
) : (
<tr>
<td colSpan={13} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
<td colSpan={14} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
</td>
</tr>
+54 -3
View File
@@ -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")
+58 -7
View File
@@ -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,
}