Files
AI-Trader/market/llm_analyzer.py
T
guaiwoluo2020 a705593955 feat: 添加新闻监控、持仓管理、系统日志等功能
- 新增新闻爬取和监控模块 (news_crawler, news_monitor)
- 新增 LLM 分析模块 (llm_analyzer)
- 新增持仓管理和交易历史存储
- 新增系统日志功能
- 新增前端页面: News, Positions, Settings, SystemLog
- 更新路由和 API 接口
- 更新 .gitignore 排除敏感文件
2026-03-17 11:32:37 +08:00

793 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型行情趋势分析模块
使用大语言模型分析K线数据,生成趋势判断和交易建议
"""
import os
import json
import threading
import asyncio
import requests
from datetime import datetime
from typing import List, Dict, Optional, Set
from collections import defaultdict
# 加载 .env 文件
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
from .system_log import get_system_log
class LLMAnalyzer:
"""大模型行情分析器"""
# 分析间隔(秒)
ANALYZE_INTERVAL = 300 # 5分钟
# 趋势类型
TREND_TYPES = [
"单边上涨",
"单边下跌",
"区间震荡",
"震荡上升",
"震荡下跌",
"震荡收窄",
"震荡扩大"
]
# 各周期K线数量限制
KLINE_LIMITS = {
'H4': 20, # 4小时,发送最近20根
'H1': 24, # 1小时,发送最近24根(一天)
'M15': 32, # 15分钟,发送最近32根(8小时)
'M5': 48, # 5分钟,发送最近48根(4小时)
'M1': 60 # 1分钟,发送最近60根(1小时)
}
# 配置文件路径
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "llm_config.json")
def __init__(self, market_store):
"""
初始化大模型分析器
Args:
market_store: K线存储对象
"""
self.market_store = market_store
# 存储分析结果: {SYMBOL: analysis_result}
self._analysis_results = {}
self._last_analysis_time = None
self._lock = threading.RLock()
# WebSocket连接管理
self._ws_clients: Set = set()
self._ws_lock = threading.Lock()
# 主事件循环引用(在FastAPI启动时设置)
self._main_loop = None
# 已提醒的AI入场价记录(避免重复提醒)
# 结构: {(symbol, period, direction, entry_price): datetime}
self._alerted_entries: Dict[tuple, datetime] = {}
self._entry_alert_lock = threading.Lock()
# AI入场价提醒冷却时间(秒)
self.entry_alert_cooldown = 300 # 5分钟
# 配置(先从文件加载,再从环境变量补充)
self._api_key = ""
self._api_base = "https://api.openai.com/v1"
self._model = "gpt-4o-mini"
self._enabled = False
# 从文件加载配置
self._load_from_file()
# 环境变量覆盖(如果文件中没有配置)
if not self._api_key and os.environ.get("LLM_API_KEY"):
self._api_key = os.environ.get("LLM_API_KEY", "")
if not self._api_base or self._api_base == "https://api.openai.com/v1":
self._api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
if not self._model or self._model == "gpt-4o-mini":
self._model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
self._enabled = bool(self._api_key)
# 启动定时分析线程
if self._enabled:
self._start_analyze_thread()
print("[LLMAnalyzer] 大模型分析器已初始化(已启用)")
else:
print("[LLMAnalyzer] 大模型分析器已初始化(未配置API Key,功能禁用)")
def set_event_loop(self, loop):
"""设置主事件循环引用"""
self._main_loop = loop
print(f"[LLMAnalyzer] 已设置主事件循环")
def _load_from_file(self):
"""从文件加载配置"""
try:
if os.path.exists(self.CONFIG_FILE):
with open(self.CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
self._api_key = data.get("api_key", "")
self._api_base = data.get("api_base", "https://api.openai.com/v1")
self._model = data.get("model", "gpt-4o-mini")
print(f"[LLMAnalyzer] 已从文件加载配置: {self.CONFIG_FILE}")
except Exception as e:
print(f"[LLMAnalyzer] 加载配置文件失败: {e}")
def _save_to_file(self):
"""保存配置到文件"""
try:
# 确保目录存在
config_dir = os.path.dirname(self.CONFIG_FILE)
os.makedirs(config_dir, exist_ok=True)
data = {
"api_key": self._api_key,
"api_base": self._api_base,
"model": self._model
}
with open(self.CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"[LLMAnalyzer] 配置已保存到文件")
except Exception as e:
print(f"[LLMAnalyzer] 保存配置文件失败: {e}")
def get_config(self) -> Dict:
"""获取当前配置(API Key会脱敏显示)"""
# 脱敏API Key:只显示前4位和后4位
masked_key = ""
if self._api_key:
if len(self._api_key) > 8:
masked_key = self._api_key[:4] + "****" + self._api_key[-4:]
else:
masked_key = "****"
return {
"api_key": masked_key,
"api_key_set": bool(self._api_key),
"api_base": self._api_base,
"model": self._model,
"enabled": self._enabled
}
def _start_analyze_thread(self):
"""启动定时分析线程"""
def analyze_loop():
# 等待事件循环设置完成
import time
time.sleep(5) # 等待5秒让服务完全启动
print("[LLMAnalyzer] 分析线程启动,开始第一次分析...")
while True:
try:
self._run_analysis()
except Exception as e:
print(f"[LLMAnalyzer] 分析异常: {e}")
import traceback
traceback.print_exc()
# 等待5分钟
threading.Event().wait(self.ANALYZE_INTERVAL)
thread = threading.Thread(target=analyze_loop, daemon=True)
thread.start()
print("[LLMAnalyzer] 分析线程已创建")
def _run_analysis(self):
"""执行分析 - 合并所有品种到一次请求(流式输出)"""
symbols = self.market_store.get_symbols()
print(f"[LLMAnalyzer] _run_analysis 调用,获取到 {len(symbols) if symbols else 0} 个品种")
if not symbols:
print("[LLMAnalyzer] 没有品种数据,跳过分析")
return
print(f"[LLMAnalyzer] 开始分析 {len(symbols)} 个品种: {symbols}")
# 广播分析开始
self._broadcast_analysis_status("analyzing", f"正在检查 {len(symbols)} 个品种的数据更新状态...")
# 检查每个品种的M1 K线更新状态(3分钟内有效)
STALE_THRESHOLD = 180 # 3分钟
active_symbols = [] # 有数据更新的品种
stale_symbols = [] # 数据过期的品种
for symbol in symbols:
m1_status = self.market_store.check_m1_updated_within(symbol, STALE_THRESHOLD)
market_status = m1_status.get("market_status", "closed")
if market_status == "active":
active_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} M1数据有效,距今 {m1_status['seconds_ago']} 秒")
elif market_status == "stale":
stale_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} M1数据过期,距今 {m1_status['seconds_ago']} 秒,跳过分析")
else: # closed
stale_symbols.append(symbol)
print(f"[LLMAnalyzer] {symbol} 休市中,无新数据,跳过分析")
# 标记休市状态
with self._lock:
if symbol in self._analysis_results:
self._analysis_results[symbol]["market_status"] = "closed"
else:
# 没有历史分析结果,创建一个标记休市的记录
self._analysis_results[symbol] = {
"symbol": symbol,
"analysis": None,
"analyzed_at": None,
"market_status": "closed",
"data_stale": True
}
# 更新过期品种的状态标记(不包括休市品种,它们已经在上面处理了)
with self._lock:
for symbol in stale_symbols:
m1_status = self.market_store.check_m1_updated_within(symbol, STALE_THRESHOLD)
if m1_status.get("market_status") == "stale" and symbol in self._analysis_results:
# 保留上次分析结果,但标记为过期
self._analysis_results[symbol]["data_stale"] = True
self._analysis_results[symbol]["market_status"] = "stale"
self._analysis_results[symbol]["stale_seconds"] = m1_status.get("seconds_ago")
# 如果没有活跃品种,广播状态并返回
if not active_symbols:
print("[LLMAnalyzer] 所有品种数据均过期,跳过大模型调用")
self._broadcast_analysis_status("stale", "所有品种行情数据均未更新,使用上次分析结果")
self._last_analysis_time = datetime.now().isoformat()
self._broadcast_analysis_update()
return
# 广播实际分析的品种
if stale_symbols:
self._broadcast_analysis_status("analyzing",
f"分析 {len(active_symbols)} 个品种,{len(stale_symbols)} 个品种数据未更新")
else:
self._broadcast_analysis_status("analyzing",
f"正在分析 {len(active_symbols)} 个品种...")
# 收集活跃品种的K线数据
all_klines_data = {}
for symbol in active_symbols:
klines_data = {}
for period in ['H4', 'H1', 'M15', 'M5', 'M1']:
limit = self.KLINE_LIMITS.get(period, 30)
klines = self.market_store.get_klines(symbol, period, limit)
if klines:
klines_data[period] = klines
print(f"[LLMAnalyzer] {symbol} {period} 获取到 {len(klines)} 条K线")
if klines_data:
all_klines_data[symbol] = klines_data
print(f"[LLMAnalyzer] 共收集 {len(all_klines_data)} 个品种的K线数据: {list(all_klines_data.keys())}")
if not all_klines_data:
print("[LLMAnalyzer] 无K线数据可分析")
self._broadcast_analysis_status("error", "无K线数据可分析")
return
# 构建合并的提示词
prompt = self._build_combined_prompt(all_klines_data)
# 记录分析开始
system_log = get_system_log()
system_log.add_log(
"llm_analysis_start",
{"symbols": active_symbols, "symbol_count": len(active_symbols)},
message=f"开始分析 {len(active_symbols)} 个品种"
)
# 调用大模型(流式)
response = self._call_llm_stream(prompt)
print(f"[LLMAnalyzer] 大模型返回结果: {type(response)}, 内容长度: {len(response) if response else 0}")
if response:
print(f"[LLMAnalyzer] 返回的品种: {list(response.keys())}")
# 解析结果,按品种存储
with self._lock:
for symbol, analysis in response.items():
if isinstance(analysis, dict):
self._analysis_results[symbol] = {
"symbol": symbol,
"analysis": analysis,
"analyzed_at": datetime.now().isoformat(),
"data_stale": False # 标记数据是最新的
}
print(f"[LLMAnalyzer] 已存储 {symbol} 的分析结果")
# 记录分析完成
system_log.add_log(
"llm_analysis_complete",
{"symbols": list(response.keys()), "symbol_count": len(response)},
message=f"分析完成,{len(response)} 个品种"
)
else:
print(f"[LLMAnalyzer] 大模型返回为空,分析失败")
# 记录分析错误
system_log.add_log(
"llm_analysis_error",
{"reason": "大模型返回为空"},
message="分析失败"
)
self._last_analysis_time = datetime.now().isoformat()
print(f"[LLMAnalyzer] 分析完成,时间: {self._last_analysis_time}")
# 广播分析完成通知
self._broadcast_analysis_update()
def _build_combined_prompt(self, all_klines_data: Dict) -> str:
"""构建合并的分析提示词"""
prompt = """你是一位专业的金融分析师。请分析以下多个交易品种的K线数据,给出每个品种的趋势判断和交易建议。
## 分析要求
对于每个品种,请分析:
1. 各周期(H4、H1、M15、M5、M1)的趋势判断,包含趋势类型、置信度(0-100)和判断理由
2. 整体趋势方向、强度(0-100)和总结
3. 关键支撑位和压力位(请根据K线数据自行判断,各列出3个)
4. 交易建议:必须包含M1、M5、M15三个周期的具体交易建议
趋势类型可选值:单边上涨、单边下跌、区间震荡、震荡上升、震荡下跌、震荡收窄、震荡扩大
请按以下JSON格式输出(必须是有效的JSON格式,包含所有品种):
```json
{
"品种1": {
"trend_analysis": {
"H4": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"H1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M15": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M5": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"},
"M1": {"trend": "趋势类型", "confidence": 置信度, "reason": "判断理由"}
},
"overall_trend": {
"direction": "整体趋势方向",
"strength": 强度,
"summary": "整体趋势总结"
},
"key_levels": {
"resistance": [压力位1, 压力位2, 压力位3],
"support": [支撑位1, 支撑位2, 支撑位3]
},
"trade_suggestions": [
{
"period": "M15",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
},
{
"period": "M5",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
},
{
"period": "M1",
"direction": "buy或sell",
"entry_price": 入场价格,
"stop_loss": 止损价格,
"take_profit": 止盈价格,
"reason": "交易理由"
}
]
},
"品种2": { ... }
}
```
## K线数据
"""
# 添加各品种的K线数据
for symbol, klines_data in all_klines_data.items():
prompt += f"\n### {symbol}\n"
for period, klines in klines_data.items():
prompt += f"\n#### {period} 周期({len(klines)}根K线)\n"
prompt += "| 时间 | 开盘 | 最高 | 最低 | 收盘 |\n"
prompt += "|------|------|------|------|------|\n"
for k in klines:
prompt += f"| {k['timestamp']} | {k['open']:.2f} | {k['high']:.2f} | {k['low']:.2f} | {k['close']:.2f} |\n"
prompt += """
请确保输出是纯JSON格式,不要有其他文字说明。每个品种的分析结果都要完整,trade_suggestions必须包含M1、M5、M15三个周期的建议。
"""
return prompt
def _call_llm(self, prompt: str) -> Optional[Dict]:
"""调用大模型API(非流式,保留兼容)"""
if not self._api_key:
return None
try:
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json"
}
data = {
"model": self._model,
"messages": [
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"max_tokens": 4000
}
response = requests.post(
f"{self._api_base}/chat/completions",
headers=headers,
json=data,
timeout=120
)
if response.status_code == 200:
result = response.json()
content = result["choices"][0]["message"]["content"]
# 提取JSON部分
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
return json.loads(content.strip())
else:
print(f"[LLMAnalyzer] API调用失败: {response.status_code} - {response.text}")
return None
except Exception as e:
print(f"[LLMAnalyzer] 调用异常: {e}")
import traceback
traceback.print_exc()
return None
def _call_llm_stream(self, prompt: str) -> Optional[Dict]:
"""调用大模型API(流式输出)"""
if not self._api_key:
return None
try:
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json"
}
data = {
"model": self._model,
"messages": [
{"role": "system", "content": "你是一位专业的金融分析师,擅长技术分析和趋势判断。请用JSON格式输出分析结果,不要有任何额外的文字说明。"},
{"role": "user", "content": prompt}
],
"temperature": 0.3,
"max_tokens": 4000,
"stream": True # 启用流式输出
}
response = requests.post(
f"{self._api_base}/chat/completions",
headers=headers,
json=data,
timeout=120,
stream=True # 流式响应
)
if response.status_code != 200:
print(f"[LLMAnalyzer] API调用失败: {response.status_code} - {response.text}")
self._broadcast_analysis_status("error", f"API调用失败: {response.status_code}")
return None
# 收集完整响应
full_content = ""
chunk_count = 0
for line in response.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if line.startswith('data: '):
data_str = line[6:] # 去掉 'data: '
if data_str == '[DONE]':
break
try:
chunk_data = json.loads(data_str)
if 'choices' in chunk_data and len(chunk_data['choices']) > 0:
delta = chunk_data['choices'][0].get('delta', {})
content_piece = delta.get('content', '')
if content_piece:
full_content += content_piece
chunk_count += 1
# 每50个chunk广播一次进度
if chunk_count % 50 == 0:
self._broadcast_analysis_status(
"streaming",
f"正在接收分析结果... ({len(full_content)} 字符)"
)
except json.JSONDecodeError:
continue
print(f"[LLMAnalyzer] 流式接收完成,共 {chunk_count} 个chunk{len(full_content)} 字符")
# 提取JSON部分
if "```json" in full_content:
full_content = full_content.split("```json")[1].split("```")[0]
elif "```" in full_content:
full_content = full_content.split("```")[1].split("```")[0]
result = json.loads(full_content.strip())
return result
except json.JSONDecodeError as e:
print(f"[LLMAnalyzer] JSON解析失败: {e}")
self._broadcast_analysis_status("error", "JSON解析失败")
return None
except Exception as e:
print(f"[LLMAnalyzer] 流式调用异常: {e}")
import traceback
traceback.print_exc()
self._broadcast_analysis_status("error", f"调用异常: {str(e)}")
return None
def get_analysis(self, symbol: str = None) -> Dict:
"""
获取分析结果
Args:
symbol: 品种名称,不指定则返回所有
Returns:
分析结果
"""
with self._lock:
if symbol:
return self._analysis_results.get(symbol)
return dict(self._analysis_results)
def get_status(self) -> Dict:
"""获取分析器状态"""
with self._lock:
return {
"enabled": self._enabled,
"model": self._model,
"api_base": self._api_base,
"last_analysis_time": self._last_analysis_time,
"symbols_analyzed": list(self._analysis_results.keys()),
"interval_seconds": self.ANALYZE_INTERVAL
}
def trigger_analysis(self) -> Dict:
"""手动触发分析"""
if not self._enabled:
return {"status": "error", "message": "大模型分析未启用"}
try:
print("[LLMAnalyzer] 手动触发分析...")
self._run_analysis()
return {"status": "ok", "message": "分析完成", "analyzed_at": self._last_analysis_time}
except Exception as e:
print(f"[LLMAnalyzer] 手动触发分析失败: {e}")
import traceback
traceback.print_exc()
return {"status": "error", "message": str(e)}
def configure(self, api_key: str = None, api_base: str = None, model: str = None) -> Dict:
"""
配置大模型参数
Args:
api_key: API密钥
api_base: API基础URL
model: 模型名称
Returns:
配置结果
"""
if api_key:
self._api_key = api_key
os.environ["LLM_API_KEY"] = api_key
if api_base:
self._api_base = api_base
os.environ["LLM_API_BASE"] = api_base
if model:
self._model = model
os.environ["LLM_MODEL"] = model
# 保存到文件
self._save_to_file()
# 检查是否可以启用
was_enabled = self._enabled
self._enabled = bool(self._api_key)
# 如果从禁用变为启用,启动分析线程
if self._enabled and not was_enabled:
self._start_analyze_thread()
return {
"status": "ok",
"enabled": self._enabled,
"model": self._model,
"api_base": self._api_base
}
# ==================== WebSocket管理 ====================
def add_ws_client(self, client):
"""添加WebSocket客户端"""
with self._ws_lock:
self._ws_clients.add(client)
print(f"[LLMAnalyzer] WebSocket客户端已连接, 当前连接数: {len(self._ws_clients)}")
def remove_ws_client(self, client):
"""移除WebSocket客户端"""
with self._ws_lock:
self._ws_clients.discard(client)
print(f"[LLMAnalyzer] WebSocket客户端已断开, 当前连接数: {len(self._ws_clients)}")
def _broadcast_analysis_update(self):
"""广播分析更新通知"""
message = json.dumps({
"type": "llm_analysis_update",
"timestamp": self._last_analysis_time,
"symbols": list(self._analysis_results.keys())
})
self._broadcast_message(message)
def _broadcast_analysis_status(self, status: str, message: str):
"""广播分析状态更新"""
msg = json.dumps({
"type": "llm_analysis_status",
"status": status,
"message": message,
"timestamp": datetime.now().isoformat()
})
self._broadcast_message(msg)
def _broadcast_message(self, message: str):
"""广播消息到所有WebSocket客户端"""
with self._ws_lock:
clients = list(self._ws_clients)
if not clients:
return
# 使用保存的主事件循环
if self._main_loop and self._main_loop.is_running():
for client in clients:
try:
asyncio.run_coroutine_threadsafe(
self._send_to_client(client, message),
self._main_loop
)
except Exception as e:
print(f"[LLMAnalyzer] 广播消息失败: {e}")
else:
print(f"[LLMAnalyzer] 事件循环未就绪,跳过广播({len(clients)}个客户端)")
async def _send_to_client(self, client, message: str):
"""发送消息到客户端"""
try:
await client.send_text(message)
except Exception as e:
print(f"[LLMAnalyzer] 发送消息到客户端失败: {e}")
with self._ws_lock:
self._ws_clients.discard(client)
def check_entry_price_nearby(self, symbol: str, current_price: float, threshold: float = 0.0001) -> List[Dict]:
"""
检查当前价格是否接近AI建议的入场价
Args:
symbol: 交易品种
current_price: 当前价格
threshold: 价格接近阈值,默认万分之一(0.0001)
Returns:
匹配的交易建议列表
"""
matched_suggestions = []
current_time = datetime.now()
with self._lock:
analysis_data = self._analysis_results.get(symbol)
if not analysis_data or 'analysis' not in analysis_data:
return matched_suggestions
trade_suggestions = analysis_data['analysis'].get('trade_suggestions', [])
if not trade_suggestions:
return matched_suggestions
for suggestion in trade_suggestions:
entry_price = suggestion.get('entry_price')
period = suggestion.get('period')
direction = suggestion.get('direction')
if not entry_price or entry_price <= 0:
continue
# 计算价格差距百分比
if entry_price > 0:
price_diff_pct = abs(current_price - entry_price) / entry_price
# 如果在阈值范围内
if price_diff_pct <= threshold:
# 检查冷却
alert_key = (symbol, period, direction, entry_price)
with self._entry_alert_lock:
should_alert = True
if alert_key in self._alerted_entries:
last_alert_time = self._alerted_entries[alert_key]
elapsed = (current_time - last_alert_time).total_seconds()
if elapsed < self.entry_alert_cooldown:
should_alert = False
print(f"[LLMAnalyzer] 跳过AI入场价提醒(冷却中): {symbol} {period} "
f"入场价 {entry_price:.2f}, 剩余 {self.entry_alert_cooldown - elapsed:.0f}秒")
if should_alert:
# 记录提醒时间
self._alerted_entries[alert_key] = current_time
matched = {
"symbol": symbol,
"period": period,
"direction": direction,
"entry_price": entry_price,
"current_price": current_price,
"price_diff_pct": round(price_diff_pct * 100, 4),
"stop_loss": suggestion.get('stop_loss'),
"take_profit": suggestion.get('take_profit'),
"reason": suggestion.get('reason'),
"analyzed_at": analysis_data.get('analyzed_at'),
"match_type": "ai_entry_nearby"
}
matched_suggestions.append(matched)
print(f"[LLMAnalyzer] 价格接近AI入场价: {symbol} {period} "
f"入场价 {entry_price:.2f}, 当前价 {current_price:.2f}, 差距 {price_diff_pct*100:.4f}%")
# 清理过期的提醒记录
self._cleanup_entry_alerts()
return matched_suggestions
def _cleanup_entry_alerts(self):
"""清理过期的AI入场价提醒记录"""
current_time = datetime.now()
with self._entry_alert_lock:
keys_to_remove = []
for key, alert_time in self._alerted_entries.items():
elapsed = (current_time - alert_time).total_seconds()
if elapsed > self.entry_alert_cooldown * 2:
keys_to_remove.append(key)
for key in keys_to_remove:
del self._alerted_entries[key]