diff --git a/data/updown.duckdb.wal b/data/updown.duckdb.wal
index ce02206..9ea6134 100644
Binary files a/data/updown.duckdb.wal and b/data/updown.duckdb.wal differ
diff --git a/src/poly_updown/clob_ws.py b/src/poly_updown/clob_ws.py
index fd1a7d9..5f16cab 100644
--- a/src/poly_updown/clob_ws.py
+++ b/src/poly_updown/clob_ws.py
@@ -82,6 +82,7 @@ async def clob_market_stream(
asset_ids: list[str],
state: ClobBookState,
on_event=None,
+ receive_timeout_s: float = 15.0,
) -> None:
subscribe = {
"assets_ids": asset_ids,
@@ -92,7 +93,8 @@ async def clob_market_stream(
try:
async with session.ws_connect(CLOB_WS, heartbeat=20) as ws:
await ws.send_json(subscribe)
- async for msg in ws:
+ while True:
+ msg = await ws.receive(timeout=receive_timeout_s)
if msg.type == aiohttp.WSMsgType.TEXT:
payload = msg.json()
items = payload if isinstance(payload, list) else [payload]
@@ -103,6 +105,10 @@ async def clob_market_stream(
on_event(item)
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
+ break
+ except (TimeoutError, asyncio.TimeoutError):
+ await asyncio.sleep(0.2)
except Exception:
await asyncio.sleep(1)
diff --git a/src/poly_updown/live.py b/src/poly_updown/live.py
index 809c005..56a1a2e 100644
--- a/src/poly_updown/live.py
+++ b/src/poly_updown/live.py
@@ -494,11 +494,11 @@ def _health_dict(
add(1, f"状态刷新偏慢 {state_age_ms} ms")
if rtds_lag_ms is None:
- add(1, "RTDS 延迟未知")
- elif rtds_lag_ms > 30_000:
- add(2, f"RTDS 延迟过高 {rtds_lag_ms} ms")
- elif rtds_lag_ms > 10_000:
- add(1, f"RTDS 延迟偏高 {rtds_lag_ms} ms")
+ add(1, "RTDS 源时间未知")
+ elif rtds_lag_ms > 180_000:
+ add(2, f"RTDS 源时间过旧 {rtds_lag_ms} ms")
+ elif rtds_lag_ms > 90_000:
+ add(1, f"RTDS 源时间偏旧 {rtds_lag_ms} ms")
if clob_book_age_ms is None:
add(1, "CLOB 盘口尚未到达")
diff --git a/src/poly_updown/prices.py b/src/poly_updown/prices.py
index 6892a7d..42008f3 100644
--- a/src/poly_updown/prices.py
+++ b/src/poly_updown/prices.py
@@ -91,9 +91,17 @@ async def polymarket_chainlink_btc_stream(
async def resilient_btc_price_stream(
session: aiohttp.ClientSession,
) -> AsyncIterator[PriceTick]:
+ last_source_timestamp_ms: int | None = None
while True:
try:
async for tick in polymarket_chainlink_btc_stream(session):
+ if tick.source_timestamp_ms is not None:
+ if (
+ last_source_timestamp_ms is not None
+ and tick.source_timestamp_ms <= last_source_timestamp_ms
+ ):
+ continue
+ last_source_timestamp_ms = tick.source_timestamp_ms
yield tick
except Exception:
await asyncio.sleep(1)
@@ -128,7 +136,11 @@ def _parse_chainlink_message(message: dict) -> list[PriceTick]:
now = datetime.now(timezone.utc)
if "data" in payload:
ticks = []
- for item in payload.get("data") or []:
+ items = sorted(
+ payload.get("data") or [],
+ key=lambda item: _int_or_none(item.get("timestamp")) or 0,
+ )
+ for item in items:
value = item.get("value")
if value is not None:
ticks.append(
diff --git a/src/poly_updown/web/app.js b/src/poly_updown/web/app.js
index badf275..2fbfd50 100644
--- a/src/poly_updown/web/app.js
+++ b/src/poly_updown/web/app.js
@@ -172,7 +172,7 @@ function renderHealth(health) {
els.healthText.textContent = health?.label || "--";
els.healthDetail.textContent = health?.issues?.length
? health.issues.join(" · ")
- : "RTDS / CLOB / DuckDB 正常";
+ : "接收 / CLOB / DuckDB 正常";
els.healthCard.className = `metric health-card ${status}`;
}
diff --git a/src/poly_updown/web/index.html b/src/poly_updown/web/index.html
index 0d2e5ee..e726c5e 100644
--- a/src/poly_updown/web/index.html
+++ b/src/poly_updown/web/index.html
@@ -95,7 +95,7 @@
--
-
RTDS 延迟
+ RTDS 源年龄
--
diff --git a/src/poly_updown/web/styles.css b/src/poly_updown/web/styles.css
index 7cf1527..90d662f 100644
--- a/src/poly_updown/web/styles.css
+++ b/src/poly_updown/web/styles.css
@@ -18,6 +18,7 @@
body {
margin: 0;
min-height: 100vh;
+ overflow-x: hidden;
color: var(--ink);
background:
linear-gradient(90deg, rgba(20, 18, 14, 0.045) 1px, transparent 1px),
@@ -452,14 +453,50 @@ dd {
font-size: 20px;
}
+@media (max-width: 1240px) {
+ .shell {
+ width: min(100% - 24px, 1120px);
+ padding-top: 18px;
+ }
+
+ .ticker-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .signal-card,
+ .ticker-grid .metric:last-child {
+ grid-column: span 2;
+ }
+
+ .main-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .market-panel,
+ .paper-panel,
+ .replay-panel,
+ .log-panel {
+ grid-column: span 2;
+ }
+}
+
@media (max-width: 980px) {
- .ticker-grid,
.main-grid,
.facts {
grid-template-columns: 1fr;
}
+ .ticker-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .signal-card {
+ grid-column: span 2;
+ }
+
.market-panel,
+ .book-panel,
+ .edge-panel,
.log-panel,
.replay-panel,
.paper-panel {
@@ -467,7 +504,7 @@ dd {
}
.paper-summary {
- grid-template-columns: 1fr;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
.topbar {
@@ -481,3 +518,200 @@ dd {
gap: 3px;
}
}
+
+@media (max-width: 640px) {
+ body {
+ background-size: 18px 18px;
+ }
+
+ .shell {
+ width: 100%;
+ padding: 10px 10px 24px;
+ }
+
+ .topbar {
+ gap: 12px;
+ padding: 8px 0 14px;
+ border-bottom-width: 2px;
+ }
+
+ h1 {
+ max-width: 100%;
+ font-size: clamp(36px, 13vw, 50px);
+ line-height: 0.94;
+ overflow-wrap: anywhere;
+ }
+
+ h2 {
+ font-size: 17px;
+ }
+
+ .kicker,
+ .label,
+ .metric small,
+ dt,
+ .analytics-row.header,
+ .paper-summary span {
+ font-size: 10px;
+ letter-spacing: 0.05em;
+ }
+
+ .status-strip {
+ width: 100%;
+ min-width: 0;
+ justify-content: flex-start;
+ padding: 8px 10px;
+ box-shadow: 3px 3px 0 var(--ink);
+ }
+
+ .ticker-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin: 14px 0 10px;
+ }
+
+ .metric {
+ min-height: 92px;
+ padding: 10px;
+ }
+
+ .metric strong {
+ font-size: clamp(24px, 9vw, 34px);
+ }
+
+ .metric small {
+ white-space: normal;
+ line-height: 1.25;
+ }
+
+ .signal-card {
+ box-shadow: 4px 4px 0 var(--ink);
+ }
+
+ .health-card {
+ grid-column: span 2;
+ }
+
+ .ticker-grid .metric:last-child {
+ grid-column: auto;
+ }
+
+ .main-grid {
+ gap: 10px;
+ }
+
+ .panel {
+ padding: 12px;
+ box-shadow: none;
+ }
+
+ .panel-head {
+ gap: 10px;
+ padding-bottom: 10px;
+ }
+
+ .badge {
+ padding: 5px 7px;
+ font-size: 11px;
+ }
+
+ .facts {
+ gap: 8px;
+ margin-top: 10px;
+ }
+
+ .facts div,
+ .paper-summary div {
+ padding: 9px;
+ }
+
+ dd {
+ min-height: 18px;
+ font-size: 15px;
+ }
+
+ .edge-bars {
+ gap: 14px;
+ margin-top: 12px;
+ }
+
+ .edge-row {
+ grid-template-columns: 64px 1fr 72px;
+ gap: 8px;
+ font-size: 12px;
+ }
+
+ .bar-track {
+ height: 16px;
+ }
+
+ .book {
+ min-height: auto;
+ gap: 5px;
+ }
+
+ .book-row {
+ grid-template-columns: 36px minmax(88px, 1fr) 54px;
+ gap: 6px;
+ }
+
+ .paper-summary {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .paper-summary b {
+ font-size: 17px;
+ }
+
+ .analytics-table {
+ overflow-x: auto;
+ padding-bottom: 2px;
+ }
+
+ .analytics-row {
+ min-width: 0;
+ min-height: 0;
+ padding: 8px;
+ font-size: 12px;
+ }
+
+ .analytics-row.header {
+ display: none;
+ }
+
+ .analytics-row span,
+ .analytics-row b {
+ min-width: 0;
+ }
+
+ .event-log {
+ max-height: 180px;
+ }
+
+ .event {
+ padding: 8px;
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 340px) {
+ .ticker-grid,
+ .paper-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .signal-card,
+ .health-card,
+ .ticker-grid .metric:last-child {
+ grid-column: auto;
+ }
+
+ .edge-row {
+ grid-template-columns: 1fr;
+ }
+
+ .bar-track {
+ width: 100%;
+ }
+}
diff --git a/tests/test_prices.py b/tests/test_prices.py
index 727b09c..c13b31e 100644
--- a/tests/test_prices.py
+++ b/tests/test_prices.py
@@ -24,6 +24,25 @@ def test_parse_chainlink_rtds_message() -> None:
assert tick.price == 77124.45
+def test_parse_chainlink_rtds_sorts_batch_by_timestamp() -> None:
+ ticks = _parse_chainlink_message(
+ {
+ "topic": "crypto_prices",
+ "type": "update",
+ "payload": {
+ "symbol": "btc/usd",
+ "data": [
+ {"timestamp": 1779376676000, "value": 77124.45},
+ {"timestamp": 1779376675000, "value": 77123.45},
+ ],
+ },
+ }
+ )
+
+ assert [tick.source_timestamp_ms for tick in ticks] == [1779376675000, 1779376676000]
+ assert ticks[-1].price == 77124.45
+
+
def test_parse_chainlink_rtds_ignores_other_symbols() -> None:
assert _parse_chainlink_message(
{