feat: tune scan dashboard and telegram monitor
This commit is contained in:
@@ -43,17 +43,6 @@
|
|||||||
background: rgba(77, 163, 255, 0.04);
|
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 {
|
.preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -142,9 +131,6 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile < 640px */
|
/* Mobile < 640px */
|
||||||
|
|||||||
@@ -95,10 +95,6 @@ export function MarketOverviewBanner({
|
|||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
aria-label={isEn ? "Toggle market overview" : "切换市场概览"}
|
aria-label={isEn ? "Toggle market overview" : "切换市场概览"}
|
||||||
>
|
>
|
||||||
<span className={styles.badge}>
|
|
||||||
<Sparkles size={13} />
|
|
||||||
{isEn ? "AI Overview" : "AI 概览"}
|
|
||||||
</span>
|
|
||||||
<span className={styles.preview}>
|
<span className={styles.preview}>
|
||||||
{collapsed && overviewText ? overviewText.slice(0, isEn ? 120 : 60) + "…" : ""}
|
{collapsed && overviewText ? overviewText.slice(0, isEn ? 120 : 60) + "…" : ""}
|
||||||
</span>
|
</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,
|
readReadyCachedAiForecastState,
|
||||||
} from "./ai-city-forecast-stream-state";
|
} from "./ai-city-forecast-stream-state";
|
||||||
|
|
||||||
|
const AI_CITY_READ_SOFT_TIMEOUT_MS = 4_500;
|
||||||
|
|
||||||
export function useAiCityForecast({
|
export function useAiCityForecast({
|
||||||
detail,
|
detail,
|
||||||
detailCityName,
|
detailCityName,
|
||||||
@@ -50,6 +52,7 @@ export function useAiCityForecast({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let resolved = false;
|
||||||
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
|
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
|
||||||
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
|
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
|
||||||
|
|
||||||
@@ -69,6 +72,17 @@ export function useAiCityForecast({
|
|||||||
report,
|
report,
|
||||||
});
|
});
|
||||||
setAiForecast(loadingState);
|
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({
|
void scanTerminalClient.streamAiCityRead({
|
||||||
city: detailCityName,
|
city: detailCityName,
|
||||||
forceRefresh: aiRefreshToken > 0,
|
forceRefresh: aiRefreshToken > 0,
|
||||||
@@ -88,6 +102,8 @@ export function useAiCityForecast({
|
|||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
resolved = true;
|
||||||
|
window.clearTimeout(softTimeoutId);
|
||||||
const readyState = buildAiCityReadyForecastState({
|
const readyState = buildAiCityReadyForecastState({
|
||||||
cacheKey,
|
cacheKey,
|
||||||
detail,
|
detail,
|
||||||
@@ -100,6 +116,8 @@ export function useAiCityForecast({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
resolved = true;
|
||||||
|
window.clearTimeout(softTimeoutId);
|
||||||
const errorState = buildAiCityErrorForecastState({
|
const errorState = buildAiCityErrorForecastState({
|
||||||
cacheKey,
|
cacheKey,
|
||||||
detail,
|
detail,
|
||||||
@@ -113,6 +131,7 @@ export function useAiCityForecast({
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
window.clearTimeout(softTimeoutId);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
aiForecastKey,
|
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(
|
function pickPreferredNearbyStations(
|
||||||
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||||
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||||
@@ -536,6 +553,7 @@ function mergeCityDetail(
|
|||||||
incoming.multi_model_daily,
|
incoming.multi_model_daily,
|
||||||
),
|
),
|
||||||
forecast: pickRicherForecast(current.forecast, incoming.forecast),
|
forecast: pickRicherForecast(current.forecast, incoming.forecast),
|
||||||
|
hourly: pickRicherHourly(current.hourly, incoming.hourly),
|
||||||
official_nearby: pickPreferredNearbyStations(
|
official_nearby: pickPreferredNearbyStations(
|
||||||
current.official_nearby,
|
current.official_nearby,
|
||||||
incoming.official_nearby,
|
incoming.official_nearby,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class StartupCoordinator:
|
|||||||
def start_all(self) -> RuntimeStatus:
|
def start_all(self) -> RuntimeStatus:
|
||||||
loops = [
|
loops = [
|
||||||
self._start_airport_high_freq_loop(),
|
self._start_airport_high_freq_loop(),
|
||||||
|
self._start_market_monitor_push_loop(),
|
||||||
self._start_dashboard_prewarm_loop(),
|
self._start_dashboard_prewarm_loop(),
|
||||||
self._start_polygon_wallet_loop(),
|
self._start_polygon_wallet_loop(),
|
||||||
self._start_polymarket_wallet_activity_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:
|
def _start_dashboard_prewarm_loop(self) -> LoopStatus:
|
||||||
enabled = _env_bool("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False)
|
enabled = _env_bool("POLYWEATHER_DASHBOARD_PREWARM_ENABLED", False)
|
||||||
interval = max(30, _env_int("POLYWEATHER_PREWARM_INTERVAL_SEC", 300))
|
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:
|
def get_primary_telegram_chat_id_from_env() -> str:
|
||||||
chat_ids = get_telegram_chat_ids_from_env()
|
chat_ids = get_telegram_chat_ids_from_env()
|
||||||
return chat_ids[0] if chat_ids else ""
|
return chat_ids[0] if chat_ids else ""
|
||||||
|
|||||||
+184
-4
@@ -15,7 +15,10 @@ from src.database.runtime_state import (
|
|||||||
get_state_storage_mode,
|
get_state_storage_mode,
|
||||||
)
|
)
|
||||||
from src.data_collection.city_registry import CITY_REGISTRY
|
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 = {
|
SEVERITY_RANK = {
|
||||||
@@ -486,7 +489,117 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
|
|||||||
# ── high-freq airport push loop ──
|
# ── 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_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(
|
_AIRPORT_PUSH_STATE_PATH = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
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",
|
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
||||||
"istanbul": "Airport", "paris": "Le Bourget",
|
"istanbul": "Airport", "paris": "Le Bourget",
|
||||||
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
|
"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()
|
en_name = city.title()
|
||||||
ap_name = _AIRPORT_EN.get(city, "")
|
ap_name = _AIRPORT_EN.get(city, "")
|
||||||
time_suffix = f" {local_time}" if local_time else ""
|
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)
|
and latest_temp - max_so_far >= 0.3)
|
||||||
|
|
||||||
flag = " \U0001f536新" if new_high else ""
|
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
|
runway_shown = False
|
||||||
if runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps):
|
if runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps):
|
||||||
for (r1, r2), (t, _d) in zip(runway_pairs, 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()
|
thread.start()
|
||||||
logger.info("airport high-freq push loop thread started")
|
logger.info("airport high-freq push loop thread started")
|
||||||
return thread
|
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