tradusai/signals/llm_gate.py
2025-12-02 22:54:03 +08:00

256 lines
8.9 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.

"""
LLM Gate - 极简门控系统,以频率控制为主
核心原则:
1. 频率限制 - 每天最多12次间隔≥15分钟核心控制
2. 数据基本可用 - 至少100根K线基础指标完整
3. 信号基本质量 - 综合得分≥15只过滤完全中性的信号
"""
import logging
import os
import json
from typing import Dict, Any, Tuple, Optional
from datetime import datetime, timedelta
from pathlib import Path
logger = logging.getLogger(__name__)
class LLMGate:
"""
极简 LLM 门控系统 - 以频率控制为主,量化初筛为辅
设计原则:
- 频率限制是核心(防止过度调用)
- 量化分析做初步筛选(过滤完全中性信号)
- 尽可能让LLM有机会深度分析
"""
def __init__(
self,
# 数据要求
min_candles: int = 100, # 最少K线数量
# 信号质量(极简 - 只检查综合得分)
min_composite_score: float = 15.0, # 最小综合得分(过滤完全中性信号)
# 频率限制(核心控制!)
max_calls_per_day: int = 12, # 每天最多调用次数
min_call_interval_minutes: int = 15, # 最小调用间隔(分钟)
# 状态存储
state_file: str = '/app/data/llm_gate_state.json',
):
"""
初始化极简 LLM Gate
Args:
min_candles: 最少K线数量
min_composite_score: 最小综合得分(唯一的质量检查)
max_calls_per_day: 每天最多调用次数
min_call_interval_minutes: 最小调用间隔
state_file: 状态文件路径
"""
# 数据要求
self.min_candles = min_candles
# 信号质量(极简)
self.min_composite_score = min_composite_score
# 频率限制
self.max_calls_per_day = max_calls_per_day
self.min_call_interval_minutes = min_call_interval_minutes
# 状态管理
self.state_file = state_file
self.state = self._load_state()
logger.info(
f"🚦 LLM Gate 初始化 (极简模式): "
f"每天最多{max_calls_per_day}次, "
f"间隔≥{min_call_interval_minutes}分钟, "
f"综合得分≥{min_composite_score} (唯一质量检查)"
)
def should_call_llm(
self,
quant_signal: Dict[str, Any],
analysis: Dict[str, Any]
) -> Tuple[bool, str]:
"""
判断是否应该调用 LLM优化版简化检查主要靠频率限制
检查顺序 (快速失败原则):
1. 频率限制 (核心!)
2. 数据基本可用性
3. 信号基本质量 (量化初筛)
Returns:
(should_call, reason)
"""
# Check 1: 频率限制 (核心控制!)
freq_check, freq_reason = self._check_frequency_limit()
if not freq_check:
logger.info(f"🚫 LLM Gate: 频率限制 - {freq_reason}")
return False, freq_reason
# Check 2: 数据基本可用性(简化版)
data_check, data_reason = self._check_data_sufficiency(analysis)
if not data_check:
logger.info(f"🚫 LLM Gate: 数据不足 - {data_reason}")
return False, data_reason
# Check 3: 信号基本质量(量化初筛,门槛很低)
quality_check, quality_reason = self._check_signal_quality(quant_signal, analysis)
if not quality_check:
logger.info(f"🚫 LLM Gate: 信号质量不足 - {quality_reason}")
return False, quality_reason
# ✅ 所有检查通过 - 让 LLM 进行深度分析
logger.info(
f"✅ LLM Gate: PASSED! "
f"{quality_reason}, "
f"今日已调用{self.state['today_calls']}/{self.max_calls_per_day}"
)
# 记录本次调用
self._record_call()
return True, f"量化初筛通过: {quality_reason}"
def _check_frequency_limit(self) -> Tuple[bool, str]:
"""检查频率限制"""
now = datetime.now()
today_str = now.strftime('%Y-%m-%d')
# 重置每日计数
if self.state.get('last_date') != today_str:
self.state['last_date'] = today_str
self.state['today_calls'] = 0
self._save_state()
# Check 1: 每日调用次数
if self.state['today_calls'] >= self.max_calls_per_day:
return False, f"今日已调用{self.state['today_calls']}次,达到上限{self.max_calls_per_day}"
# Check 2: 调用间隔
last_call_time = self.state.get('last_call_time')
if last_call_time:
last_call = datetime.fromisoformat(last_call_time)
elapsed = (now - last_call).total_seconds() / 60 # 转为分钟
if elapsed < self.min_call_interval_minutes:
return False, f"距离上次调用仅{elapsed:.1f}分钟,需≥{self.min_call_interval_minutes}分钟"
return True, "频率检查通过"
def _check_data_sufficiency(self, analysis: Dict[str, Any]) -> Tuple[bool, str]:
"""检查数据充足性 (提高到200根K线)"""
metadata = analysis.get('metadata', {})
candle_count = metadata.get('candle_count', 0)
if candle_count < self.min_candles:
return False, f"K线数量不足: {candle_count}/{self.min_candles}"
# 确保所有必要的指标都已计算
required_keys = ['trend_analysis', 'momentum', 'support_resistance', 'orderflow']
for key in required_keys:
if key not in analysis:
return False, f"缺少关键指标: {key}"
return True, f"数据充足: {candle_count}根K线"
def _check_signal_quality(
self,
quant_signal: Dict[str, Any],
analysis: Dict[str, Any]
) -> Tuple[bool, str]:
"""
检查信号质量(极简版 - 只检查综合得分)
只要量化分析给出了明确信号不是完全中性就让LLM来深度分析
"""
# 唯一检查: 综合得分强度 (门槛非常低,只过滤完全中性的信号)
composite_score = abs(quant_signal.get('composite_score', 0))
if composite_score < self.min_composite_score:
return False, f"综合得分不足: {composite_score:.1f} < {self.min_composite_score}"
# ✅ 通过 - 其他所有检查都删除了
signal_type = quant_signal.get('signal_type', 'HOLD')
return True, f"信号类型: {signal_type}, 综合得分: {composite_score:.1f}"
def _load_state(self) -> Dict[str, Any]:
"""加载状态文件"""
# 确保目录存在
state_path = Path(self.state_file)
state_path.parent.mkdir(parents=True, exist_ok=True)
if state_path.exists():
try:
with open(self.state_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"加载状态文件失败: {e}")
# 默认状态
return {
'last_date': '',
'today_calls': 0,
'last_call_time': None,
'total_calls': 0,
}
def _save_state(self):
"""保存状态文件"""
try:
with open(self.state_file, 'w') as f:
json.dump(self.state, f, indent=2)
except Exception as e:
logger.error(f"保存状态文件失败: {e}")
def _record_call(self):
"""记录本次调用"""
now = datetime.now()
self.state['today_calls'] += 1
self.state['total_calls'] = self.state.get('total_calls', 0) + 1
self.state['last_call_time'] = now.isoformat()
self._save_state()
logger.info(
f"📝 记录LLM调用: 今日第{self.state['today_calls']}次, "
f"累计第{self.state['total_calls']}"
)
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
now = datetime.now()
today_str = now.strftime('%Y-%m-%d')
# 重置每日计数
if self.state.get('last_date') != today_str:
today_calls = 0
else:
today_calls = self.state['today_calls']
# 计算距离上次调用的时间
last_call_time = self.state.get('last_call_time')
if last_call_time:
last_call = datetime.fromisoformat(last_call_time)
minutes_since_last = (now - last_call).total_seconds() / 60
else:
minutes_since_last = None
return {
'today_calls': today_calls,
'max_calls_per_day': self.max_calls_per_day,
'remaining_calls_today': max(0, self.max_calls_per_day - today_calls),
'total_calls': self.state.get('total_calls', 0),
'last_call_time': last_call_time,
'minutes_since_last_call': minutes_since_last,
'can_call_now': minutes_since_last is None or minutes_since_last >= self.min_call_interval_minutes,
}