Add ops low-price market opportunities

This commit is contained in:
2569718930@qq.com
2026-07-03 23:52:03 +08:00
parent 459909539c
commit 7a5fe8b571
10 changed files with 1195 additions and 0 deletions
@@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const sidebar = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const pagePath = path.join(projectRoot, "app", "ops", "market-opportunities", "page.tsx");
const proxyPath = path.join(projectRoot, "app", "api", "ops", "market-opportunities", "route.ts");
const clientPath = path.join(
projectRoot,
"components",
"ops",
"market-opportunities",
"MarketOpportunitiesPageClient.tsx",
);
assert(
sidebar.includes("/ops/market-opportunities") && sidebar.includes("市场机会"),
"ops sidebar must expose the internal market opportunities page",
);
assert(
fs.existsSync(pagePath) && fs.readFileSync(pagePath, "utf8").includes("requireOpsAdmin"),
"market opportunities page must exist and require ops admin access",
);
assert(
fs.existsSync(proxyPath) &&
fs.readFileSync(proxyPath, "utf8").includes("requireOpsProxyAuth") &&
fs.readFileSync(proxyPath, "utf8").includes("/api/ops/market-opportunities"),
"market opportunities proxy must stay ops-admin protected",
);
assert(
opsApi.includes("marketOpportunities") &&
opsApi.includes("/api/ops/market-opportunities"),
"ops API client must expose market opportunities",
);
const client = fs.existsSync(clientPath) ? fs.readFileSync(clientPath, "utf8") : "";
for (const column of [
"城市",
"选项",
"方向",
"买入价",
"模型概率",
"Edge",
"市场链接",
]) {
assert(client.includes(column), `market opportunities table must include ${column}`);
}
assert(
client.includes("positive_edge_only") && client.includes("显示全部低价"),
"market opportunities page must default to positive edge and allow showing every low-price option",
);
}
@@ -10,6 +10,7 @@ import {
Users,
UserCheck,
BarChart3,
TrendingUp,
Settings,
FileText,
ScrollText,
@@ -27,6 +28,7 @@ const navGroups = [
{ href: "/ops/system", icon: Cpu, label: "系统状态" },
{ href: "/ops/training", icon: Database, label: "训练数据" },
{ href: "/ops/analytics", icon: BarChart3, label: "转化分析" },
{ href: "/ops/market-opportunities", icon: TrendingUp, label: "市场机会" },
],
},
{
@@ -0,0 +1,308 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, RefreshCcw, Search, TrendingUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { opsApi } from "@/lib/ops-api";
type MarketOpportunityRow = {
id?: string;
city?: string;
display_name?: string;
target_date?: string;
bucket_label?: string;
side?: string;
ask_price?: number;
model_probability?: number;
edge?: number;
liquidity?: number | null;
volume?: number | null;
market_url?: string;
current_max_so_far?: number | null;
deb_prediction?: number | null;
model_median?: number | null;
model_spread?: number | null;
local_time?: string;
region?: string;
};
type MarketOpportunitiesPayload = {
generated_at?: string;
summary?: {
opportunity_count?: number;
positive_edge_count?: number;
min_price?: number | null;
max_edge?: number | null;
quote_status?: string;
scanned_city_count?: number;
matched_event_count?: number;
error?: string | null;
};
rows?: MarketOpportunityRow[];
};
function percent(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${Math.round(value * 100)}%`;
}
function cents(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${(value * 100).toFixed(value * 100 < 1 ? 1 : 0)}¢`;
}
function numberLabel(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
return value.toFixed(1);
}
function tempLabel(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return value.toFixed(1);
}
function generatedLabel(value?: string) {
if (!value) return "—";
return value.slice(0, 19).replace("T", " ");
}
function sideTone(side?: string) {
return String(side).toLowerCase() === "no"
? "border-red-200 bg-red-50 text-red-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700";
}
function Stat({
label,
value,
sub,
}: {
label: string;
value: string | number;
sub: string;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-black text-slate-950">{value}</div>
<div className="mt-1 text-xs text-slate-500">{sub}</div>
</div>
);
}
export function MarketOpportunitiesPageClient() {
const [payload, setPayload] = useState<MarketOpportunitiesPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [side, setSide] = useState("both");
const [maxPrice, setMaxPrice] = useState("0.20");
const [minEdge, setMinEdge] = useState("0");
const [showAllLowPrice, setShowAllLowPrice] = useState(false);
const positive_edge_only = !showAllLowPrice;
const load = async () => {
setLoading(true);
setError("");
try {
const data = await opsApi.marketOpportunities({
q: query.trim(),
side,
max_price: maxPrice,
min_edge: minEdge,
positive_edge_only,
limit: 200,
});
setPayload(data as MarketOpportunitiesPayload);
} catch (err) {
setError(err instanceof Error ? err.message : "加载市场机会失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [side, showAllLowPrice]);
const rows = useMemo(() => payload?.rows ?? [], [payload]);
const summary = payload?.summary ?? {};
const quoteStatus = summary.quote_status || "unknown";
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-white">
<TrendingUp className="h-5 w-5 text-blue-300" />
</h1>
<p className="mt-1 text-sm text-slate-300">
Ops 使 20¢ Yes/No
</p>
</div>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="机会数" value={summary.opportunity_count ?? rows.length} sub="当前筛选结果" />
<Stat label="正边际" value={summary.positive_edge_count ?? 0} sub="模型概率高于买入价" />
<Stat label="最低价格" value={cents(summary.min_price)} sub="低价合约下限" />
<Stat label="最大 Edge" value={percent(summary.max_edge)} sub="模型概率 - 市场价" />
<Stat label="报价状态" value={quoteStatus} sub={`${summary.matched_event_count ?? 0}/${summary.scanned_city_count ?? 0} 城市匹配`} />
</div>
<Card>
<CardHeader className="gap-3">
<CardTitle></CardTitle>
<div className="grid gap-2 md:grid-cols-[minmax(180px,1fr)_120px_120px_120px_auto]">
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void load();
}}
className="h-9 w-full rounded-md border border-slate-300 bg-white pl-8 pr-3 text-sm font-semibold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
placeholder="搜索城市或选项"
/>
</label>
<select
value={side}
onChange={(event) => setSide(event.target.value)}
className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
>
<option value="both">Yes / No</option>
<option value="yes"> Yes</option>
<option value="no"> No</option>
</select>
<input
value={maxPrice}
onChange={(event) => setMaxPrice(event.target.value)}
className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
inputMode="decimal"
aria-label="最高买入价"
/>
<input
value={minEdge}
onChange={(event) => setMinEdge(event.target.value)}
className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
inputMode="decimal"
aria-label="最小 Edge"
/>
<Button variant="outline" size="sm" onClick={load}>
</Button>
</div>
<label className="flex w-fit items-center gap-2 text-sm font-semibold text-slate-600">
<input
type="checkbox"
checked={showAllLowPrice}
onChange={(event) => setShowAllLowPrice(event.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
</label>
</CardHeader>
<CardContent>
{error ? (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{error}
</div>
) : null}
{summary.error ? (
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-700">
{summary.error}
</div>
) : null}
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-[1320px] 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>
<th className="px-3 py-2"></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">Edge</th>
<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 text-right"></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"></th>
</tr>
</thead>
<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>
</tr>
) : rows.length ? (
rows.map((row) => (
<tr key={row.id || `${row.city}-${row.bucket_label}-${row.side}`} className="hover:bg-blue-50/40">
<td className="px-3 py-2 font-black text-slate-900">
<div>{row.display_name || row.city || "—"}</div>
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
{row.local_time || "—"} · {row.target_date || "—"}
</div>
</td>
<td className="px-3 py-2 font-bold text-slate-800">{row.bucket_label || "—"}</td>
<td className="px-3 py-2">
<span className={`inline-flex min-w-10 justify-center rounded-md border px-2 py-1 text-xs font-black uppercase ${sideTone(row.side)}`}>
{row.side || "—"}
</span>
</td>
<td className="px-3 py-2 text-right font-black text-slate-950">{cents(row.ask_price)}</td>
<td className="px-3 py-2 text-right font-bold text-blue-700">{percent(row.model_probability)}</td>
<td className="px-3 py-2 text-right font-black text-emerald-700">{percent(row.edge)}</td>
<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 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>
<td className="px-3 py-2">
{row.market_url ? (
<a
href={row.market_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-black text-blue-700 hover:text-blue-900"
>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-slate-400"></span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={13} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-3 text-xs font-semibold text-slate-400">
{generatedLabel(payload?.generated_at)}
</div>
</CardContent>
</Card>
</div>
);
}