diff --git a/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.module.css b/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.module.css index 3de641b0..e9502ba0 100644 --- a/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.module.css +++ b/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.module.css @@ -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 */ diff --git a/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.tsx b/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.tsx index 6bb29e6c..a2e3da75 100644 --- a/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.tsx +++ b/frontend/components/dashboard/scan-terminal/MarketOverviewBanner.tsx @@ -95,10 +95,6 @@ export function MarketOverviewBanner({ onClick={() => setCollapsed((c) => !c)} aria-label={isEn ? "Toggle market overview" : "切换市场概览"} > - - - {isEn ? "AI Overview" : "AI 概览"} - {collapsed && overviewText ? overviewText.slice(0, isEn ? 120 : 60) + "…" : ""} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts new file mode 100644 index 00000000..716a08fb --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/aiCityReadSoftTimeout.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts new file mode 100644 index 00000000..e4160313 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/cityDetailMergeChartCoverage.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts new file mode 100644 index 00000000..0888618a --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/marketOverviewBadge.test.ts @@ -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", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts index e6970734..f06473db 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-forecast.ts @@ -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, diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index e19aa538..5a78bf4f 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -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, diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index d0457d7f..604fa914 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -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)) diff --git a/src/utils/telegram_chat_ids.py b/src/utils/telegram_chat_ids.py index 02cb2264..7f9a0d9a 100644 --- a/src/utils/telegram_chat_ids.py +++ b/src/utils/telegram_chat_ids.py @@ -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 "" diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 5261ca31..15afd19c 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -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 diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py new file mode 100644 index 00000000..e1c76e6b --- /dev/null +++ b/tests/test_telegram_hashtags.py @@ -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