feat: tune scan dashboard and telegram monitor

This commit is contained in:
2569718930@qq.com
2026-05-15 14:36:43 +08:00
parent e7fc3c97cb
commit 747c8aa401
11 changed files with 406 additions and 22 deletions
@@ -43,17 +43,6 @@
background: rgba(77, 163, 255, 0.04);
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-accent-primary, #4DA3FF);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.preview {
flex: 1;
min-width: 0;
@@ -142,9 +131,6 @@
padding: 4px 8px;
}
.badge {
font-size: 11px;
}
}
/* Mobile < 640px */
@@ -95,10 +95,6 @@ export function MarketOverviewBanner({
onClick={() => setCollapsed((c) => !c)}
aria-label={isEn ? "Toggle market overview" : "切换市场概览"}
>
<span className={styles.badge}>
<Sparkles size={13} />
{isEn ? "AI Overview" : "AI 概览"}
</span>
<span className={styles.preview}>
{collapsed && overviewText ? overviewText.slice(0, isEn ? 120 : 60) + "…" : ""}
</span>
@@ -0,0 +1,33 @@
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 hookPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"use-ai-city-forecast.ts",
);
const source = fs.readFileSync(hookPath, "utf8");
assert(
source.includes("AI_CITY_READ_SOFT_TIMEOUT_MS") &&
source.includes("softTimeoutId"),
"AI city read hook must use a soft timeout so airport-read loading does not linger",
);
assert(
source.includes("ai_soft_timeout_fallback") &&
source.includes("buildAiCityErrorForecastState"),
"AI city read soft timeout must switch to the fast fallback state while allowing later merge",
);
assert(
!source.includes("controller.abort()"),
"AI city read soft timeout should not abort the stream; late AI results should still merge",
);
}
@@ -0,0 +1,24 @@
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 source = fs.readFileSync(
path.join(process.cwd(), "hooks", "useDashboardStore.tsx"),
"utf8",
);
assert(
source.includes("function pickRicherHourly"),
"city detail merge should have an explicit hourly-series preservation helper",
);
assert(
/hourly:\s*pickRicherHourly\(\s*current\.hourly,\s*incoming\.hourly\s*\)/.test(
source,
),
"deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data",
);
}
@@ -0,0 +1,25 @@
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 bannerPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"MarketOverviewBanner.tsx",
);
const source = fs.readFileSync(bannerPath, "utf8");
assert(
!source.includes("AI Overview") &&
!source.includes("AI 概览") &&
!source.includes("styles.badge"),
"market overview banner should not render the AI overview badge",
);
}
@@ -15,6 +15,8 @@ import {
readReadyCachedAiForecastState,
} from "./ai-city-forecast-stream-state";
const AI_CITY_READ_SOFT_TIMEOUT_MS = 4_500;
export function useAiCityForecast({
detail,
detailCityName,
@@ -50,6 +52,7 @@ export function useAiCityForecast({
return;
}
let cancelled = false;
let resolved = false;
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
@@ -69,6 +72,17 @@ export function useAiCityForecast({
report,
});
setAiForecast(loadingState);
const softTimeoutId = window.setTimeout(() => {
if (cancelled || resolved) return;
const fallbackState = buildAiCityErrorForecastState({
cacheKey,
detail,
error: "ai_soft_timeout_fallback",
isEn,
report,
});
setAiForecast(fallbackState);
}, AI_CITY_READ_SOFT_TIMEOUT_MS);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
@@ -88,6 +102,8 @@ export function useAiCityForecast({
})
.then((payload) => {
if (!payload) return;
resolved = true;
window.clearTimeout(softTimeoutId);
const readyState = buildAiCityReadyForecastState({
cacheKey,
detail,
@@ -100,6 +116,8 @@ export function useAiCityForecast({
}
})
.catch((error) => {
resolved = true;
window.clearTimeout(softTimeoutId);
const errorState = buildAiCityErrorForecastState({
cacheKey,
detail,
@@ -113,6 +131,7 @@ export function useAiCityForecast({
});
return () => {
cancelled = true;
window.clearTimeout(softTimeoutId);
};
}, [
aiForecastKey,
+18
View File
@@ -492,6 +492,23 @@ function pickRicherForecast(
};
}
function countHourlyPoints(value: CityDetail["hourly"] | undefined) {
const times = Array.isArray(value?.times) ? value?.times || [] : [];
const temps = Array.isArray(value?.temps) ? value?.temps || [] : [];
return Math.min(times.length, temps.length);
}
function pickRicherHourly(
currentValue: CityDetail["hourly"] | undefined,
incomingValue: CityDetail["hourly"] | undefined,
) {
const incomingCount = countHourlyPoints(incomingValue);
const currentCount = countHourlyPoints(currentValue);
if (incomingCount <= 0) return currentValue;
if (currentCount <= 0) return incomingValue;
return incomingCount >= currentCount ? incomingValue : currentValue;
}
function pickPreferredNearbyStations(
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
@@ -536,6 +553,7 @@ function mergeCityDetail(
incoming.multi_model_daily,
),
forecast: pickRicherForecast(current.forecast, incoming.forecast),
hourly: pickRicherHourly(current.hourly, incoming.hourly),
official_nearby: pickPreferredNearbyStations(
current.official_nearby,
incoming.official_nearby,
+28
View File
@@ -84,6 +84,7 @@ class StartupCoordinator:
def start_all(self) -> RuntimeStatus:
loops = [
self._start_airport_high_freq_loop(),
self._start_market_monitor_push_loop(),
self._start_dashboard_prewarm_loop(),
self._start_polygon_wallet_loop(),
self._start_polymarket_wallet_activity_loop(),
@@ -175,6 +176,33 @@ class StartupCoordinator:
),
)
def _start_market_monitor_push_loop(self) -> LoopStatus:
enabled = _env_bool("TELEGRAM_MARKET_MONITOR_PUSH_ENABLED", True)
chat_ids = import_module(
"src.utils.telegram_chat_ids"
).get_market_monitor_chat_ids_from_env()
telegram_push = import_module("src.utils.telegram_push")
interval = int(getattr(telegram_push, "MARKET_MONITOR_INTERVAL_SEC", 60))
cities_count = len(getattr(telegram_push, "MARKET_MONITOR_CITIES", []))
details = {
"mode": "market-monitor-periodic",
"interval_sec": interval,
"cities_count": cities_count,
"chat_targets": len(chat_ids),
"window": "every 60s, available Polymarket scans only",
}
validation_error = None if chat_ids else "missing_TELEGRAM_MARKET_MONITOR_CHAT_IDS"
return self._start_with_validation(
key="market_monitor_push",
label="市场监控频道推送",
configured_enabled=enabled,
details=details,
validation_error=validation_error,
starter=lambda: telegram_push.start_market_monitor_push_loop(
self.bot,
),
)
def _start_dashboard_prewarm_loop(self) -> LoopStatus:
enabled = _env_bool("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False)
interval = max(30, _env_int("POLYWEATHER_PREWARM_INTERVAL_SEC", 300))
+20
View File
@@ -60,6 +60,26 @@ def get_polymarket_wallet_activity_chat_ids_from_env() -> List[str]:
)
def get_market_monitor_chat_ids_from_env() -> List[str]:
"""
Preferred envs for market monitor push:
- TELEGRAM_MARKET_MONITOR_CHAT_IDS (comma-separated)
- TELEGRAM_MARKET_MONITOR_CHAT_ID (single)
Falls back to global TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID for compatibility.
"""
dedicated = parse_telegram_chat_ids(
os.getenv("TELEGRAM_MARKET_MONITOR_CHAT_IDS"),
os.getenv("TELEGRAM_MARKET_MONITOR_CHAT_ID"),
)
if dedicated:
return dedicated
return parse_telegram_chat_ids(
os.getenv("TELEGRAM_CHAT_IDS"),
os.getenv("TELEGRAM_CHAT_ID"),
)
def get_primary_telegram_chat_id_from_env() -> str:
chat_ids = get_telegram_chat_ids_from_env()
return chat_ids[0] if chat_ids else ""
+184 -4
View File
@@ -15,7 +15,10 @@ from src.database.runtime_state import (
get_state_storage_mode,
)
from src.data_collection.city_registry import CITY_REGISTRY
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
from src.utils.telegram_chat_ids import (
get_market_monitor_chat_ids_from_env,
get_telegram_chat_ids_from_env,
)
SEVERITY_RANK = {
@@ -486,7 +489,117 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
# ── high-freq airport push loop ──
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei"}
HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920"}
HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "shenzhen": "ZGSZ", "qingdao": "ZSQD", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH"}
MARKET_MONITOR_INTERVAL_SEC = 60
MARKET_MONITOR_CITIES = [
"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
"istanbul", "paris", "hong kong", "lau fau shan", "taipei",
"new york", "los angeles", "chicago", "denver", "atlanta",
"miami", "san francisco", "houston", "dallas", "austin", "seattle",
"beijing", "shanghai", "guangzhou", "shenzhen", "qingdao",
"chengdu", "chongqing", "wuhan",
]
_FUNCTION_HASHTAGS = {
"runway": "#跑道观测",
"market": "#市场监控",
"trade": "#交易机会",
}
def _city_hashtag(city: Optional[str]) -> Optional[str]:
text = str(city or "").strip()
if not text:
return None
parts = [part for part in re.split(r"[^A-Za-z0-9]+", text.title()) if part]
if not parts:
return None
return "#" + "".join(parts)
def _station_hashtag(station: Optional[str]) -> Optional[str]:
text = re.sub(r"[^A-Za-z0-9]+", "", str(station or "").upper())
return f"#{text}" if text else None
def _build_telegram_hashtag_line(
kind: str,
*,
city: Optional[str] = None,
station: Optional[str] = None,
extra: Optional[List[str]] = None,
) -> str:
tags: List[str] = []
primary = _FUNCTION_HASHTAGS.get(kind)
if primary:
tags.append(primary)
for item in extra or []:
tag = _FUNCTION_HASHTAGS.get(item, item)
if tag and tag not in tags:
tags.append(tag)
city_tag = _city_hashtag(city)
if city_tag and city_tag not in tags:
tags.append(city_tag)
station_tag = _station_hashtag(station)
if station_tag and station_tag not in tags:
tags.append(station_tag)
return " ".join(tags)
def _format_percent(value: Any) -> str:
try:
numeric = float(value)
except Exception:
return "--"
sign = "+" if numeric > 0 else ""
return f"{sign}{numeric:.1f}%"
def _format_prob(value: Any) -> str:
try:
numeric = float(value)
except Exception:
return "--"
if numeric <= 1:
numeric *= 100
return f"{numeric:.1f}%"
def _build_market_monitor_message(city: str, city_weather: Dict[str, Any]) -> str:
market = city_weather.get("market_scan") or {}
current = city_weather.get("current") or {}
deb = city_weather.get("deb") or {}
local_time = str(city_weather.get("local_time") or "").strip() or "--"
city_label = str(city or "").strip().title()
signal = str(market.get("signal_label") or market.get("signal_status") or "MONITOR").strip()
confidence = str(market.get("confidence") or "low").strip()
bucket = str(
market.get("selected_bucket")
or (market.get("forecast_bucket") or {}).get("label")
or "--"
).strip()
yes_buy = (
market.get("yes_buy")
if market.get("yes_buy") is not None
else (market.get("forecast_bucket") or {}).get("yes_buy")
)
edge = market.get("edge_percent")
current_temp = current.get("temp")
deb_pred = deb.get("prediction")
lines = [
_build_telegram_hashtag_line("market", city=city),
f"{city_label} 1分钟市场监控 {local_time}",
f"信号:{signal} · 置信度:{confidence}",
]
if bucket and bucket != "--":
lines.append(f"桶位:{bucket}")
lines.append(f"Yes 买价:{_format_prob(yes_buy)} · Edge{_format_percent(edge)}")
if current_temp is not None or deb_pred is not None:
current_text = f"{float(current_temp):.1f}°C" if current_temp is not None else "--"
deb_text = f"{float(deb_pred):.1f}°C" if deb_pred is not None else "--"
lines.append(f"当前:{current_text} · DEB{deb_text}")
return "\n".join(lines)
_AIRPORT_PUSH_STATE_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
@@ -562,7 +675,9 @@ def _build_airport_status_message(
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
"istanbul": "Airport", "paris": "Le Bourget",
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
"taipei": "Songshan"}
"taipei": "Songshan", "beijing": "ZBAA", "shanghai": "ZSPD",
"guangzhou": "ZGGG", "shenzhen": "ZGSZ", "qingdao": "ZSQD",
"chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH"}
en_name = city.title()
ap_name = _AIRPORT_EN.get(city, "")
time_suffix = f" {local_time}" if local_time else ""
@@ -599,7 +714,12 @@ def _build_airport_status_message(
and latest_temp - max_so_far >= 0.3)
flag = " \U0001f536" if new_high else ""
lines = [header + flag, ""]
hashtag_line = _build_telegram_hashtag_line(
"runway",
city=city,
station=airport_icao,
)
lines = [hashtag_line, header + flag, ""]
runway_shown = False
if runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps):
for (r1, r2), (t, _d) in zip(runway_pairs, runway_temps):
@@ -894,3 +1014,63 @@ def start_high_freq_airport_push_loop(bot: Any, config: Dict[str, Any]) -> Optio
thread.start()
logger.info("airport high-freq push loop thread started")
return thread
def _run_market_monitor_cycle(bot: Any, chat_ids: List[str]) -> bool:
sent_any = False
try:
from web.app import _analyze
except Exception as exc:
logger.warning("market monitor push skipped: analyze import failed: {}", exc)
return False
for city in MARKET_MONITOR_CITIES:
try:
city_weather = _analyze(city)
market = city_weather.get("market_scan") or {}
if not market.get("available"):
continue
message = _build_market_monitor_message(city, city_weather)
for chat_id in chat_ids:
try:
bot.send_message(chat_id, message)
sent_any = True
except Exception as exc:
logger.warning("market monitor push failed city={} chat_id={}: {}", city, chat_id, exc)
except Exception:
logger.exception("market monitor cycle failed for city={}", city)
return sent_any
def start_market_monitor_push_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("TELEGRAM_MARKET_MONITOR_PUSH_ENABLED", True)
chat_ids = get_market_monitor_chat_ids_from_env()
if not enabled:
logger.info("market monitor push loop disabled")
return None
if not chat_ids:
logger.warning("market monitor push loop skipped: TELEGRAM_MARKET_MONITOR_CHAT_IDS/TELEGRAM_CHAT_IDS is not set")
return None
interval_sec = MARKET_MONITOR_INTERVAL_SEC
def _runner() -> None:
logger.info(
"market monitor push loop started cities={} interval={}s chat_targets={}",
len(MARKET_MONITOR_CITIES), interval_sec, len(chat_ids),
)
while True:
cycle_started = time.time()
_run_market_monitor_cycle(bot=bot, chat_ids=chat_ids)
elapsed = time.time() - cycle_started
sleep_sec = max(5, interval_sec - int(elapsed))
time.sleep(sleep_sec)
thread = threading.Thread(
target=_runner,
name="market-monitor-pusher",
daemon=True,
)
thread.start()
logger.info("market monitor push loop thread started")
return thread
+55
View File
@@ -0,0 +1,55 @@
from src.utils.telegram_push import (
MARKET_MONITOR_INTERVAL_SEC,
_build_airport_status_message,
_build_market_monitor_message,
)
def test_airport_status_message_starts_with_runway_city_and_station_hashtags():
text = _build_airport_status_message(
"qingdao",
{
"current": {"temp": 22.8},
"deb": {"prediction": 24.0},
"airport_current": {"max_so_far": 23.1, "max_temp_time": "13:00"},
"amos": {
"observation_time": "2026-05-15T05:00:00Z",
"runway_obs": {
"temperatures": [(23.0, "TDZ"), (23.2, "MID"), (23.1, "END")],
},
},
},
24.0,
"13:00",
)
first_line = text.splitlines()[0]
assert first_line == "#跑道观测 #Qingdao #ZSQD"
assert "Qingdao / ZSQD 13:00" in text
def test_market_monitor_message_starts_with_market_hashtag_and_city():
text = _build_market_monitor_message(
"shanghai",
{
"local_time": "14:01",
"current": {"temp": 29.4},
"deb": {"prediction": 31.2},
"market_scan": {
"available": True,
"signal_label": "MONITOR",
"confidence": "medium",
"selected_bucket": "31° or higher",
"yes_buy": 0.42,
"edge_percent": 8.4,
},
},
)
assert text.splitlines()[0] == "#市场监控 #Shanghai"
assert "1分钟市场监控" in text
assert "Edge+8.4%" in text
def test_market_monitor_default_interval_is_one_minute():
assert MARKET_MONITOR_INTERVAL_SEC == 60