256 lines
8.9 KiB
Python
256 lines
8.9 KiB
Python
"""
|
||
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,
|
||
}
|