Add multimodel context to market opportunities
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user