Simplify scan opportunity list and speed quotes

This commit is contained in:
2569718930@qq.com
2026-04-24 14:26:47 +08:00
parent edc8559147
commit 42d4a4e198
4 changed files with 92 additions and 117 deletions
@@ -9572,7 +9572,7 @@
.root :global(.scan-table-header) {
display: grid;
grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
grid-template-columns: minmax(210px, 1.1fr) minmax(132px, 0.72fr) minmax(220px, 1.12fr) minmax(150px, 0.82fr) 96px;
gap: 10px;
align-items: center;
padding: 14px 18px;
@@ -9786,7 +9786,7 @@
.root :global(.scan-table-row) {
display: grid;
grid-template-columns: 46px minmax(180px, 1.08fr) minmax(132px, 0.72fr) minmax(220px, 1.08fr) minmax(150px, 0.82fr) 92px 82px;
grid-template-columns: minmax(210px, 1.1fr) minmax(132px, 0.72fr) minmax(220px, 1.12fr) minmax(150px, 0.82fr) 96px;
gap: 10px;
align-items: center;
min-height: 132px;
@@ -9813,38 +9813,12 @@
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
}
.root :global(.scan-rank-cell),
.root :global(.scan-score-cell),
.root :global(.scan-edge-cell) {
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.scan-rank-circle) {
width: 34px;
height: 34px;
font-size: 16px;
font-weight: 800;
color: #06150d;
background: linear-gradient(180deg, #24e495, #11b86f);
}
.root :global(.scan-rank-circle.yellow) {
color: #281a00;
background: linear-gradient(180deg, #ffd95f, #ffb61c);
}
.root :global(.scan-rank-circle.gray) {
color: #08131f;
background: linear-gradient(180deg, #d6e3f7, #94a7c4);
}
.root :global(.scan-rank-circle.red) {
color: #fff3f3;
background: linear-gradient(180deg, #ff7373, #df4747);
}
.root :global(.scan-city-cell) {
display: block;
}
@@ -9870,11 +9844,6 @@
-webkit-line-clamp: 2;
}
.root :global(.scan-city-volume) {
font-size: 14px;
color: #dbe7f8;
}
.root :global(.scan-time-cell) {
display: flex;
flex-direction: column;
@@ -10036,38 +10005,6 @@
color: #ff6868;
}
.root :global(.scan-score-ring) {
width: 58px;
height: 58px;
border-radius: 50%;
background: conic-gradient(#20df91 0 85%, rgba(255, 255, 255, 0.06) 85% 100%);
}
.root :global(.scan-score-ring.tone-yellow) {
background: conic-gradient(#ffbe26 0 72%, rgba(255, 255, 255, 0.06) 72% 100%);
}
.root :global(.scan-score-ring.tone-red) {
background: conic-gradient(#ff5d5d 0 58%, rgba(255, 255, 255, 0.06) 58% 100%);
}
.root :global(.scan-score-ring::after) {
content: "";
position: absolute;
inset: 5px;
border-radius: 50%;
background: #0b1728;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.root :global(.scan-score-ring span) {
position: relative;
z-index: 1;
font-size: 18px;
font-weight: 800;
color: #ecf4ff;
}
.root :global(.scan-row-fav) {
display: none;
}
@@ -24,14 +24,6 @@ function formatPercent(value?: number | null, signed = false) {
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
function formatVolume(value?: number | null) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric) || numeric <= 0) return "--";
if (numeric >= 1_000_000) return `$${(numeric / 1_000_000).toFixed(1)}M`;
if (numeric >= 1_000) return `$${(numeric / 1_000).toFixed(0)}K`;
return `$${numeric.toFixed(0)}`;
}
function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
@@ -118,13 +110,6 @@ export function getWindowPhaseMeta(
};
}
function scoreTone(score?: number | null) {
const numeric = Number(score || 0);
if (numeric >= 85) return "green";
if (numeric >= 70) return "yellow";
return "gray";
}
function ProbabilityPreview({
row,
locale,
@@ -197,15 +182,6 @@ function ProbabilityPreview({
);
}
function ScoreRing({ score }: { score?: number | null }) {
const displayScore = Math.max(0, Math.min(100, Number(score || 0)));
return (
<div className={`scan-score-ring tone-${scoreTone(displayScore)}`}>
<span>{displayScore.toFixed(0)}</span>
</div>
);
}
export const OpportunityTable = React.memo(function OpportunityTable({
rows,
status,
@@ -271,17 +247,15 @@ export const OpportunityTable = React.memo(function OpportunityTable({
</div>
) : null}
<div className="scan-table-header">
<span />
<span>{isEn ? "City / Market" : "城市 / 市场"}</span>
<span>{isEn ? "Local Time / Phase" : "当前时间 / 阶段"}</span>
<span>{isEn ? "EMOS / Market" : "EMOS / 市场"}</span>
<span>{isEn ? "Quote / Model" : "买价 / 模型"}</span>
<span>{isEn ? "Edge" : "边际优势"}</span>
<span>{isEn ? "Score" : "综合得分"}</span>
</div>
<div className="scan-table-body">
{rows.map((row, index) => {
{rows.map((row) => {
const phaseMeta = getWindowPhaseMeta(row, locale);
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
const localizedCityName = getLocalizedCityName(
@@ -290,7 +264,6 @@ export const OpportunityTable = React.memo(function OpportunityTable({
locale,
);
const selected = selectedRowId === row.id;
const scoreClass = scoreTone(row.final_score);
return (
<button
key={row.id}
@@ -298,19 +271,12 @@ export const OpportunityTable = React.memo(function OpportunityTable({
className={`scan-table-row ${selected ? "selected" : ""}`}
onClick={() => onSelectRow?.(row)}
>
<div className="scan-rank-cell">
<div className={`scan-rank-circle ${scoreClass}`}>
{index + 1}
</div>
</div>
<div className="scan-city-cell">
<div className="scan-city-copy">
<div className="scan-city-name">{localizedCityName}</div>
<div className="scan-city-sub">
{row.market_question || row.target_label || "--"}
</div>
<div className="scan-city-volume">{formatVolume(row.volume)}</div>
</div>
</div>
@@ -344,10 +310,6 @@ export const OpportunityTable = React.memo(function OpportunityTable({
<div className={`scan-edge-cell ${Number(row.edge_percent || 0) >= 0 ? "positive" : "negative"}`}>
{formatPercent(row.edge_percent, true)}
</div>
<div className="scan-score-cell">
<ScoreRing score={row.final_score} />
</div>
</button>
);
})}
+59 -13
View File
@@ -418,6 +418,8 @@ class PolymarketReadOnlyLayer:
_safe_float(os.getenv("POLYMARKET_SIGNAL_MIN_LIQUIDITY")) or 500.0
)
self.edge_threshold = _safe_float(os.getenv("POLYMARKET_SIGNAL_EDGE_PCT")) or 2.0
fast_price_only = _safe_bool(os.getenv("POLYMARKET_FAST_PRICE_ONLY", "true"))
self.fast_price_only = True if fast_price_only is None else bool(fast_price_only)
self._session = httpx.Client(
timeout=self.http_timeout,
@@ -1747,6 +1749,22 @@ class PolymarketReadOnlyLayer:
ask = _extract_price(
self._clob_get("/price", {"token_id": token_id, "side": "SELL"})
)
if self.fast_price_only:
buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=None)
midpoint = (buy + sell) / 2.0 if buy is not None and sell is not None else (buy or sell)
spread = max(0.0, float(buy) - float(sell)) if buy is not None and sell is not None else None
return {
"buy": buy,
"sell": sell,
"midpoint": _clamp_probability(midpoint),
"spread": spread,
"last_trade_price": None,
"quote_source": "polymarket_clob_fast_price",
"quote_age_ms": 0,
"book": None,
"book_liquidity": None,
}
midpoint = _extract_price(self._clob_get("/midpoint", {"token_id": token_id}))
last_trade = _extract_price(
self._clob_get("/last-trade-price", {"token_id": token_id})
@@ -1754,10 +1772,14 @@ class PolymarketReadOnlyLayer:
orderbook_raw = self._clob_get("/book", {"token_id": token_id})
book, book_liquidity = self._normalize_orderbook(orderbook_raw)
buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=book)
if midpoint is None and buy is not None and sell is not None:
midpoint = (buy + sell) / 2.0
spread = max(0.0, float(buy) - float(sell)) if buy is not None and sell is not None else None
return {
"buy": buy,
"sell": sell,
"midpoint": midpoint,
"spread": spread,
"last_trade_price": last_trade,
"quote_source": "polymarket_clob_rest",
"quote_age_ms": 0,
@@ -2326,7 +2348,12 @@ class PolymarketReadOnlyLayer:
missing.append(token_id)
continue
cached_data = cached.get("data", {}) or {}
if include_books and not cached_data.get("book") and cached_data.get("book_liquidity") is None:
if (
include_books
and not self.fast_price_only
and not cached_data.get("book")
and cached_data.get("book_liquidity") is None
):
missing.append(token_id)
continue
results[token_id] = dict(cached_data)
@@ -2350,24 +2377,36 @@ class PolymarketReadOnlyLayer:
"/prices",
[{"token_id": token_id, "side": "BUY"} for token_id in chunk],
)
midpoint_payload = self._clob_post(
"/midpoints",
[{"token_id": token_id} for token_id in chunk],
midpoint_payload = (
self._clob_post(
"/midpoints",
[{"token_id": token_id} for token_id in chunk],
)
if not self.fast_price_only
else None
)
spread_payload = self._clob_post(
"/spreads",
[{"token_id": token_id} for token_id in chunk],
spread_payload = (
self._clob_post(
"/spreads",
[{"token_id": token_id} for token_id in chunk],
)
if not self.fast_price_only
else None
)
last_trade_payload = self._clob_post(
"/last-trade-prices",
[{"token_id": token_id} for token_id in chunk],
last_trade_payload = (
self._clob_post(
"/last-trade-prices",
[{"token_id": token_id} for token_id in chunk],
)
if not self.fast_price_only
else None
)
books_payload = (
self._clob_post(
"/books",
[{"token_id": token_id} for token_id in chunk],
)
if include_books
if include_books and not self.fast_price_only
else None
)
ask_map.update(self._extract_batch_price_map(sell_payload, "SELL"))
@@ -2419,7 +2458,11 @@ class PolymarketReadOnlyLayer:
"midpoint": midpoint,
"spread": spread,
"last_trade_price": last_trade,
"quote_source": "polymarket_clob_rest_batch",
"quote_source": (
"polymarket_clob_fast_batch"
if self.fast_price_only
else "polymarket_clob_rest_batch"
),
"quote_age_ms": 0,
"book": book,
"book_liquidity": book_liquidity,
@@ -3087,7 +3130,10 @@ class PolymarketReadOnlyLayer:
continue
shortlisted_tokens.extend([entry["yes_token_id"], entry["no_token_id"]])
precise_quotes = self._batch_get_token_market_data(shortlisted_tokens, include_books=True)
precise_quotes = self._batch_get_token_market_data(
shortlisted_tokens,
include_books=not self.fast_price_only,
)
for entry in market_entries:
market_slug = str(entry["market"].get("slug") or "").strip()
if market_slug not in seen_slugs:
+30
View File
@@ -39,6 +39,7 @@ def test_extract_market_bucket_range_supports_fahrenheit_ranges():
def test_fetch_token_market_data_uses_rest_orderbook_executable_prices():
layer = PolymarketReadOnlyLayer()
layer.fast_price_only = False
payloads = {
("/price", "BUY"): {"price": "0.27"},
("/price", "SELL"): {"price": "0.23"},
@@ -68,8 +69,37 @@ def test_fetch_token_market_data_uses_rest_orderbook_executable_prices():
assert data["quote_source"] == "polymarket_clob_rest"
def test_fetch_token_market_data_fast_price_only_skips_heavy_endpoints():
layer = PolymarketReadOnlyLayer()
calls = []
payloads = {
("/price", "BUY"): {"price": "0.23"},
("/price", "SELL"): {"price": "0.27"},
}
def _fake_clob_get(path, params):
calls.append((path, params.get("side")))
if path == "/price":
return payloads[(path, params.get("side"))]
return None
layer._clob_get = _fake_clob_get
data = layer._fetch_token_market_data("token-1")
assert calls == [("/price", "BUY"), ("/price", "SELL")]
assert data["buy"] == 0.27
assert data["sell"] == 0.23
assert data["midpoint"] == 0.25
assert round(data["spread"], 6) == 0.04
assert data["last_trade_price"] is None
assert data["book"] is None
assert data["quote_source"] == "polymarket_clob_fast_price"
def test_fetch_token_market_data_keeps_buy_sell_semantics_without_orderbook():
layer = PolymarketReadOnlyLayer()
layer.fast_price_only = False
payloads = {
("/price", "BUY"): {"price": "0.23"},
("/price", "SELL"): {"price": "0.27"},