feat: Implement new bot architecture including handlers, services, analysis modules, and comprehensive tests.

This commit is contained in:
2569718930@qq.com
2026-03-12 11:44:52 +08:00
parent c582015163
commit f4fea03f35
28 changed files with 1525 additions and 445 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Bot analysis services."""
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
class CityAnalysisService:
"""City analysis adapter with lazy imports to trim cold startup."""
def __init__(self, weather: Any):
self.weather = weather
def resolve_city(self, city_input: str):
from src.analysis.city_query_service import resolve_city_name
return resolve_city_name(city_input)
def build_city_report(self, city_name: str, city_query_cost: int) -> str:
coords = self.weather.get_coordinates(city_name)
if not coords:
raise ValueError(f"未找到城市坐标: {city_name}")
weather_data = self.weather.fetch_all_sources(
city_name,
lat=coords["lat"],
lon=coords["lon"],
force_refresh=True,
)
from src.analysis.city_query_service import build_city_query_report
return build_city_query_report(
city_name=city_name,
weather_data=weather_data,
city_query_cost=city_query_cost,
)
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import os
from datetime import datetime as _dt
from datetime import timedelta as _td
class DebAnalysisService:
"""DEB analytics adapter with lazy imports to trim cold startup."""
def __init__(self, project_root: str):
self.project_root = project_root
self.history_file = os.path.join(project_root, "data", "daily_records.json")
@staticmethod
def _load_aliases() -> dict[str, str]:
from src.data_collection.city_registry import ALIASES
return ALIASES
@staticmethod
def _load_deb_module_api():
from src.analysis.deb_algorithm import (
_is_excluded_model_name,
load_history,
reconcile_recent_actual_highs,
)
return _is_excluded_model_name, load_history, reconcile_recent_actual_highs
def resolve_city(self, city_input: str) -> str:
aliases = self._load_aliases()
city_input_norm = city_input.strip().lower()
return aliases.get(city_input_norm, city_input_norm)
def has_history(self, city_name: str) -> bool:
_is_excluded_model_name, load_history, reconcile_recent_actual_highs = (
self._load_deb_module_api()
)
del _is_excluded_model_name, reconcile_recent_actual_highs
data = load_history(self.history_file)
city_data = data.get(city_name)
return isinstance(city_data, dict) and bool(city_data)
def build_deb_accuracy_report(self, city_name: str, deb_query_cost: int) -> str:
_is_excluded_model_name, load_history, reconcile_recent_actual_highs = (
self._load_deb_module_api()
)
data = load_history(self.history_file)
if city_name not in data or not data[city_name]:
raise ValueError(f"暂无 {city_name} 的历史数据。")
reconcile_info = reconcile_recent_actual_highs(city_name, lookback_days=7)
data = load_history(self.history_file)
city_data = data[city_name]
today = _dt.now().date()
today_str = today.strftime("%Y-%m-%d")
cutoff_date = today - _td(days=6)
recent_items = []
for date_str, record in city_data.items():
try:
row_date = _dt.strptime(date_str, "%Y-%m-%d").date()
except Exception:
continue
if row_date >= cutoff_date:
recent_items.append((date_str, record, row_date))
recent_items.sort(key=lambda item: item[0])
lines = [
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
"",
"📅 <b>近日记录:</b>",
]
if (
isinstance(reconcile_info, dict)
and reconcile_info.get("ok")
and int(reconcile_info.get("updated") or 0) > 0
):
lines.extend(
[
f"🔁 已用 METAR 历史回填修正 {int(reconcile_info.get('updated'))} 天实测最高温",
"",
]
)
total_days = 0
hits = 0
deb_errors = []
signed_errors = []
model_errors: dict[str, list[float]] = {}
for date_str, record, _row_date in recent_items:
actual = record.get("actual_high")
deb_pred = record.get("deb_prediction")
forecasts = record.get("forecasts", {}) or {}
if actual is None:
continue
try:
actual = float(actual)
if deb_pred is not None:
deb_pred = float(deb_pred)
except Exception:
continue
if deb_pred is None and forecasts:
valid_preds = [
float(v)
for k, v in forecasts.items()
if v is not None and not _is_excluded_model_name(k)
]
if valid_preds:
deb_pred = round(sum(valid_preds) / len(valid_preds), 1)
actual_wu = round(actual)
if date_str == today_str:
lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})")
elif deb_pred is not None:
total_days += 1
deb_wu = round(deb_pred)
hit = deb_wu == actual_wu
if hit:
hits += 1
err = deb_pred - actual
deb_errors.append(abs(err))
signed_errors.append(err)
if hit:
result_icon = ""
err_text = f"偏差{abs(err):.1f}°"
elif err < 0:
result_icon = ""
err_text = f"低估{abs(err):.1f}°"
else:
result_icon = ""
err_text = f"高估{abs(err):.1f}°"
retro = "" if "deb_prediction" not in record else ""
lines.append(
f" {date_str}: DEB {retro}{deb_pred:.1f}{deb_wu} vs 实测 {actual:.1f}{actual_wu} "
f"{result_icon} {err_text}"
)
if date_str != today_str and actual is not None:
for model, pred in forecasts.items():
if _is_excluded_model_name(model):
continue
if pred is None:
continue
try:
model_errors.setdefault(model, []).append(abs(float(pred) - actual))
except Exception:
continue
if total_days > 0:
hit_rate = hits / total_days * 100
deb_mae = sum(deb_errors) / len(deb_errors)
lines.append("")
lines.append(
f"🏁 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
)
if model_errors:
lines.append("")
lines.append("📈 <b>模型 MAE 对比:</b>")
model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e}
sorted_models = sorted(model_maes.items(), key=lambda item: item[1])
for model, mae in sorted_models:
tag = "" if mae <= deb_mae else ""
lines.append(f" {model}: {mae:.1f}°{tag}")
lines.append(f" <b>DEB融合: {deb_mae:.1f}°</b>")
mean_bias = sum(signed_errors) / len(signed_errors)
underest = sum(1 for e in signed_errors if e < -0.3)
overest = sum(1 for e in signed_errors if e > 0.3)
accurate = total_days - underest - overest
lines.append("")
lines.append("🔍 <b>偏差分析:</b>")
if abs(mean_bias) > 0.3:
bias_label = "系统性低估" if mean_bias < 0 else "系统性高估"
lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°")
else:
lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°")
lines.append(f" (低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次)")
lines.append("")
lines.append("💡 <b>建议:</b>")
if underest > overest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 "
f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。"
)
elif overest > underest and abs(mean_bias) > 0.5:
lines.append(
f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。"
)
elif deb_mae > 1.5:
lines.append(
f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。"
)
elif hit_rate >= 60:
lines.append(" DEB 近期表现稳定,可继续作为主要参考。")
else:
lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。")
lines.append("")
lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。")
lines.append("📅 统计窗口:近7天滚动样本。")
else:
lines.append("")
lines.append("🔔 近 7 天尚无完整的 DEB 预测记录。")
lines.append("")
lines.append(f"💸 本次消耗 <code>{deb_query_cost}</code> 积分。")
return "\n".join(lines)