feat: tune scan dashboard and telegram monitor
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
+24
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user