From 05aa0c89f002b973b7b1e81e479de9112e10d582 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Sun, 30 Nov 2025 13:38:29 +0800 Subject: [PATCH] update pay function --- __pycache__/mcp_database.cpython-310.pyc | Bin 0 -> 29510 bytes __pycache__/mcp_quant.cpython-310.pyc | Bin 0 -> 65844 bytes mcp_database.py | 34 +- mcp_quant.py | 2692 +++++++++++++++++ mcp_server.py | 710 ++++- .../MeetingRoom/MeetingMessageBubble.js | 47 + src/views/AgentChat/constants/meetingRoles.ts | 4 +- src/views/AgentChat/constants/tools.ts | 249 ++ test_quant_tools.py | 295 ++ 9 files changed, 3972 insertions(+), 59 deletions(-) create mode 100644 __pycache__/mcp_database.cpython-310.pyc create mode 100644 __pycache__/mcp_quant.cpython-310.pyc create mode 100644 mcp_quant.py create mode 100644 test_quant_tools.py diff --git a/__pycache__/mcp_database.cpython-310.pyc b/__pycache__/mcp_database.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6d84f78cacad40e2e6015e21dcf0f42c1c59c90 GIT binary patch literal 29510 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 literal 0 HcmV?d00001 diff --git a/__pycache__/mcp_quant.cpython-310.pyc b/__pycache__/mcp_quant.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f98f424328c8224efbfdc748e0bb391c27ce0b79 GIT binary patch literal 65844 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 Rj| .SH (上海), 0/3开头 -> .SZ (深圳), 其他 -> .BJ (北京) + if '.' in code: + # 已经有后缀,直接使用 + stock_code = code + else: + # 需要添加后缀 + if code.startswith('6'): + stock_code = f"{code}.SH" + elif code.startswith('0') or code.startswith('3'): + stock_code = f"{code}.SZ" + else: + stock_code = f"{code}.BJ" - # 构建查询 - query = """ + # 构建查询 - 使用字符串格式化(ClickHouse 参数化语法兼容性问题) + query = f""" SELECT code, timestamp, @@ -851,24 +862,19 @@ async def get_stock_minute_data( volume, amt FROM stock_minute - WHERE code = %(code)s + WHERE code = '{stock_code}' """ - params = {'code': stock_code} - if start_time: - query += " AND timestamp >= %(start_time)s" - params['start_time'] = start_time + query += f" AND timestamp >= '{start_time}'" if end_time: - query += " AND timestamp <= %(end_time)s" - params['end_time'] = end_time + query += f" AND timestamp <= '{end_time}'" - query += " ORDER BY timestamp DESC LIMIT %(limit)s" - params['limit'] = limit + query += f" ORDER BY timestamp DESC LIMIT {limit}" # 执行查询 - result = client.execute(query, params, with_column_types=True) + result = client.execute(query, with_column_types=True) rows = result[0] columns = [col[0] for col in result[1]] diff --git a/mcp_quant.py b/mcp_quant.py new file mode 100644 index 00000000..71733a1f --- /dev/null +++ b/mcp_quant.py @@ -0,0 +1,2692 @@ +""" +MCP Quant - 量化因子计算模块 +基于日线(MySQL ea_trade)和分钟频(ClickHouse stock_minute)数据计算技术指标和量化因子 + +数据源: +- MySQL ea_trade: OHLCV, 换手率, 涨跌幅, PE等日线数据 +- ClickHouse stock_minute: 分钟级 OHLCV 数据 + +设计原则: +1. 返回"状态"而非原始数值 - 便于大模型理解 +2. 支持批量计算 - 提高效率 +3. 错误优雅降级 - 数据不足时返回 None 而非报错 +""" + +import numpy as np +import pandas as pd +from typing import Dict, List, Any, Optional, Tuple, Literal +from datetime import datetime, timedelta +from dataclasses import dataclass +from enum import Enum +import logging +import asyncio + +# 导入数据库模块 +import mcp_database as db + +logger = logging.getLogger(__name__) + + +# ==================== 信号枚举定义 ==================== + +class TrendSignal(str, Enum): + """趋势信号""" + STRONG_BULLISH = "强势上涨" + BULLISH = "上涨" + NEUTRAL = "震荡" + BEARISH = "下跌" + STRONG_BEARISH = "强势下跌" + + +class MACDSignal(str, Enum): + """MACD信号""" + GOLDEN_CROSS = "金叉" + DEATH_CROSS = "死叉" + BULLISH_DIVERGENCE = "底背离" + BEARISH_DIVERGENCE = "顶背离" + MOMENTUM_INCREASING = "动能增强" + MOMENTUM_DECREASING = "动能减弱" + NEUTRAL = "中性" + + +class OscillatorZone(str, Enum): + """超买超卖区域""" + OVERBOUGHT = "超买" + OVERSOLD = "超卖" + NEUTRAL = "中性" + + +class BollingerSignal(str, Enum): + """布林带信号""" + ABOVE_UPPER = "触及上轨压力" + BELOW_LOWER = "触及下轨支撑" + ABOVE_MIDDLE = "中轨之上强势" + BELOW_MIDDLE = "中轨之下弱势" + SQUEEZE = "布林带收窄变盘在即" + EXPANSION = "布林带扩张趋势加速" + + +class VolumeSignal(str, Enum): + """量能信号""" + VOLUME_SURGE = "放量" + VOLUME_SHRINK = "缩量" + VOLUME_PRICE_DIVERGENCE = "量价背离" + ACCUMULATION = "主力吸筹" + DISTRIBUTION = "主力出货" + NORMAL = "正常" + + +class BreakoutSignal(str, Enum): + """突破信号""" + NEW_HIGH_BREAKOUT = "突破新高" + NEW_LOW_BREAKDOWN = "跌破新低" + RESISTANCE_TEST = "测试压力位" + SUPPORT_TEST = "测试支撑位" + NO_SIGNAL = "无信号" + + +# ==================== 数据类定义 ==================== + +@dataclass +class MACDResult: + """MACD计算结果""" + signal: MACDSignal + dif: float + dea: float + macd_bar: float + trend_strength: str # 弱/中/强 + description: str + + +@dataclass +class OscillatorResult: + """超买超卖指标结果""" + zone: OscillatorZone + rsi_value: float + kdj_k: float + kdj_d: float + kdj_j: float + description: str + + +@dataclass +class BollingerResult: + """布林带分析结果""" + signal: BollingerSignal + upper: float + middle: float + lower: float + bandwidth: float # 带宽百分比 + position: str # 价格在通道中的位置描述 + description: str + + +@dataclass +class ATRResult: + """ATR计算结果""" + atr: float + atr_percent: float # ATR占价格的百分比 + suggested_stop_loss: float # 建议止损价 + suggested_stop_loss_pct: float # 建议止损幅度 + volatility_level: str # 低/中/高 + description: str + + +@dataclass +class VolumeAnalysisResult: + """成交量分析结果""" + signal: VolumeSignal + turnover_rate: float + volume_ratio: float # 相对于MA5的量比 + obv_trend: str # OBV趋势 + heat_level: str # 冷门/正常/活跃/火热/极热 + description: str + + +@dataclass +class BreakoutResult: + """突破信号结果""" + signal: BreakoutSignal + high_20d: float + low_20d: float + high_60d: float + low_60d: float + distance_to_high: float # 距离20日高点的百分比 + distance_to_low: float # 距离20日低点的百分比 + description: str + + +@dataclass +class RiskMetricsResult: + """风险指标结果""" + max_drawdown: float # 最大回撤 + max_drawdown_period: str # 最大回撤发生期间 + sharpe_ratio: float # 夏普比率 + volatility: float # 波动率 + risk_level: str # 低风险/中风险/高风险 + description: str + + +@dataclass +class ValuationResult: + """估值分析结果""" + pe_current: float + pe_percentile: float # PE历史百分位 + pb_current: Optional[float] + peg_ratio: Optional[float] + valuation_level: str # 低估/合理/高估 + description: str + + +# ==================== 核心计算函数 ==================== + +def _calc_ema(data: np.ndarray, period: int) -> np.ndarray: + """计算EMA指数移动平均""" + ema = np.zeros_like(data) + ema[0] = data[0] + multiplier = 2 / (period + 1) + for i in range(1, len(data)): + ema[i] = (data[i] - ema[i-1]) * multiplier + ema[i-1] + return ema + + +def _calc_sma(data: np.ndarray, period: int) -> np.ndarray: + """计算SMA简单移动平均""" + return pd.Series(data).rolling(window=period, min_periods=1).mean().values + + +def _calc_std(data: np.ndarray, period: int) -> np.ndarray: + """计算滚动标准差""" + return pd.Series(data).rolling(window=period, min_periods=1).std().values + + +def _calc_rsi(close: np.ndarray, period: int = 14) -> np.ndarray: + """计算RSI相对强弱指标""" + delta = np.diff(close) + gain = np.where(delta > 0, delta, 0) + loss = np.where(delta < 0, -delta, 0) + + avg_gain = pd.Series(gain).rolling(window=period, min_periods=1).mean().values + avg_loss = pd.Series(loss).rolling(window=period, min_periods=1).mean().values + + rs = np.where(avg_loss != 0, avg_gain / avg_loss, 100) + rsi = 100 - (100 / (1 + rs)) + + # 补齐第一个值 + return np.insert(rsi, 0, 50) + + +def _calc_kdj(high: np.ndarray, low: np.ndarray, close: np.ndarray, + n: int = 9, m1: int = 3, m2: int = 3) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """计算KDJ指标""" + length = len(close) + rsv = np.zeros(length) + k = np.zeros(length) + d = np.zeros(length) + j = np.zeros(length) + + for i in range(length): + start = max(0, i - n + 1) + high_n = np.max(high[start:i+1]) + low_n = np.min(low[start:i+1]) + + if high_n != low_n: + rsv[i] = (close[i] - low_n) / (high_n - low_n) * 100 + else: + rsv[i] = 50 + + if i == 0: + k[i] = 50 + d[i] = 50 + else: + k[i] = (m1 - 1) / m1 * k[i-1] + 1 / m1 * rsv[i] + d[i] = (m2 - 1) / m2 * d[i-1] + 1 / m2 * k[i] + + j[i] = 3 * k[i] - 2 * d[i] + + return k, d, j + + +def _calc_macd(close: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """计算MACD指标""" + ema_fast = _calc_ema(close, fast) + ema_slow = _calc_ema(close, slow) + dif = ema_fast - ema_slow + dea = _calc_ema(dif, signal) + macd_bar = 2 * (dif - dea) + return dif, dea, macd_bar + + +def _calc_bollinger(close: np.ndarray, period: int = 20, std_dev: float = 2) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """计算布林带""" + middle = _calc_sma(close, period) + std = _calc_std(close, period) + upper = middle + std_dev * std + lower = middle - std_dev * std + return upper, middle, lower + + +def _calc_atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray: + """计算ATR真实波幅""" + tr = np.zeros(len(close)) + tr[0] = high[0] - low[0] + + for i in range(1, len(close)): + tr[i] = max( + high[i] - low[i], + abs(high[i] - close[i-1]), + abs(low[i] - close[i-1]) + ) + + atr = _calc_sma(tr, period) + return atr + + +def _calc_obv(close: np.ndarray, volume: np.ndarray) -> np.ndarray: + """计算OBV能量潮""" + obv = np.zeros(len(close)) + obv[0] = volume[0] + + for i in range(1, len(close)): + if close[i] > close[i-1]: + obv[i] = obv[i-1] + volume[i] + elif close[i] < close[i-1]: + obv[i] = obv[i-1] - volume[i] + else: + obv[i] = obv[i-1] + + return obv + + +def _detect_divergence(price: np.ndarray, indicator: np.ndarray, lookback: int = 20) -> Optional[str]: + """检测背离""" + if len(price) < lookback: + return None + + recent_price = price[-lookback:] + recent_indicator = indicator[-lookback:] + + # 找到价格的高点和低点 + price_high_idx = np.argmax(recent_price) + price_low_idx = np.argmin(recent_price) + + # 检查顶背离:价格创新高但指标没创新高 + if price_high_idx > lookback // 2: # 高点在后半段 + prev_high = np.max(recent_price[:lookback//2]) + if recent_price[price_high_idx] > prev_high: + prev_indicator_high = np.max(recent_indicator[:lookback//2]) + if recent_indicator[price_high_idx] < prev_indicator_high: + return "bearish_divergence" + + # 检查底背离:价格创新低但指标没创新低 + if price_low_idx > lookback // 2: # 低点在后半段 + prev_low = np.min(recent_price[:lookback//2]) + if recent_price[price_low_idx] < prev_low: + prev_indicator_low = np.min(recent_indicator[:lookback//2]) + if recent_indicator[price_low_idx] > prev_indicator_low: + return "bullish_divergence" + + return None + + +# ==================== MCP 工具函数 ==================== + +async def get_macd_signal(code: str, days: int = 60) -> Dict[str, Any]: + """ + 获取MACD趋势判定信号 + + Args: + code: 股票代码 + days: 分析天数,默认60天 + + Returns: + MACD信号分析结果 + """ + try: + # 获取日线数据 + trade_data = await db.get_stock_trade_data(code, limit=days + 30) + + if len(trade_data) < 35: + return {"success": False, "error": "数据不足,需要至少35个交易日"} + + # 按日期排序(从旧到新) + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算MACD + dif, dea, macd_bar = _calc_macd(close) + + # 获取最新值 + current_dif = dif[-1] + current_dea = dea[-1] + current_bar = macd_bar[-1] + prev_bar = macd_bar[-2] if len(macd_bar) > 1 else 0 + + # 判断信号 + signal = MACDSignal.NEUTRAL + + # 金叉/死叉判断 + if len(dif) >= 2: + if dif[-1] > dea[-1] and dif[-2] <= dea[-2]: + signal = MACDSignal.GOLDEN_CROSS + elif dif[-1] < dea[-1] and dif[-2] >= dea[-2]: + signal = MACDSignal.DEATH_CROSS + + # 动能判断 + if signal == MACDSignal.NEUTRAL: + if current_bar > 0 and current_bar > prev_bar: + signal = MACDSignal.MOMENTUM_INCREASING + elif current_bar < 0 and current_bar < prev_bar: + signal = MACDSignal.MOMENTUM_DECREASING + + # 背离检测 + divergence = _detect_divergence(close, dif, lookback=20) + if divergence == "bullish_divergence": + signal = MACDSignal.BULLISH_DIVERGENCE + elif divergence == "bearish_divergence": + signal = MACDSignal.BEARISH_DIVERGENCE + + # 趋势强度 + bar_abs = abs(current_bar) + if bar_abs < 0.5: + trend_strength = "弱" + elif bar_abs < 1.5: + trend_strength = "中" + else: + trend_strength = "强" + + # 生成描述 + descriptions = { + MACDSignal.GOLDEN_CROSS: f"MACD金叉形成,DIF上穿DEA,短期看涨信号,动能{trend_strength}", + MACDSignal.DEATH_CROSS: f"MACD死叉形成,DIF下穿DEA,短期看跌信号,动能{trend_strength}", + MACDSignal.BULLISH_DIVERGENCE: "出现底背离,股价创新低但MACD没创新低,可能反转向上", + MACDSignal.BEARISH_DIVERGENCE: "出现顶背离,股价创新高但MACD没创新高,上涨动能衰竭", + MACDSignal.MOMENTUM_INCREASING: f"红柱放大,上涨动能增强,趋势强度:{trend_strength}", + MACDSignal.MOMENTUM_DECREASING: f"绿柱放大,下跌动能增强,趋势强度:{trend_strength}", + MACDSignal.NEUTRAL: "MACD处于中性状态,无明显信号", + } + + return { + "success": True, + "data": { + "signal": signal.value, + "dif": round(current_dif, 4), + "dea": round(current_dea, 4), + "macd_bar": round(current_bar, 4), + "trend_strength": trend_strength, + "description": descriptions[signal], + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[MACD] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def check_oscillator_status(code: str, days: int = 60) -> Dict[str, Any]: + """ + 检查KDJ/RSI超买超卖状态 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 超买超卖状态分析 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + high = np.array([float(d['high_price']) for d in trade_data]) + low = np.array([float(d['low_price']) for d in trade_data]) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算RSI + rsi = _calc_rsi(close, 14) + current_rsi = rsi[-1] + + # 计算KDJ + k, d, j = _calc_kdj(high, low, close) + current_k = k[-1] + current_d = d[-1] + current_j = j[-1] + + # 判断区域 + zone = OscillatorZone.NEUTRAL + + # RSI超买超卖判断 + if current_rsi > 80 or current_j > 100: + zone = OscillatorZone.OVERBOUGHT + elif current_rsi < 20 or current_j < 0: + zone = OscillatorZone.OVERSOLD + + # 生成描述 + if zone == OscillatorZone.OVERBOUGHT: + desc = f"RSI={current_rsi:.1f},KDJ的J值={current_j:.1f},处于超买区域,短期回调风险较大" + elif zone == OscillatorZone.OVERSOLD: + desc = f"RSI={current_rsi:.1f},KDJ的J值={current_j:.1f},处于超卖区域,可能存在反弹机会" + else: + desc = f"RSI={current_rsi:.1f},KDJ(K={current_k:.1f},D={current_d:.1f},J={current_j:.1f}),处于中性区域" + + return { + "success": True, + "data": { + "zone": zone.value, + "rsi_value": round(current_rsi, 2), + "kdj_k": round(current_k, 2), + "kdj_d": round(current_d, 2), + "kdj_j": round(current_j, 2), + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[Oscillator] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def analyze_bollinger_bands(code: str, days: int = 60, period: int = 20) -> Dict[str, Any]: + """ + 分析布林带通道 + + Args: + code: 股票代码 + days: 分析天数 + period: 布林带周期,默认20 + + Returns: + 布林带分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < period + 5: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算布林带 + upper, middle, lower = _calc_bollinger(close, period) + + current_price = close[-1] + current_upper = upper[-1] + current_middle = middle[-1] + current_lower = lower[-1] + + # 计算带宽 + bandwidth = (current_upper - current_lower) / current_middle * 100 + prev_bandwidth = (upper[-5] - lower[-5]) / middle[-5] * 100 if len(upper) > 5 else bandwidth + + # 判断信号 + signal = BollingerSignal.ABOVE_MIDDLE if current_price > current_middle else BollingerSignal.BELOW_MIDDLE + + # 检查是否触及上下轨 + if current_price >= current_upper * 0.99: + signal = BollingerSignal.ABOVE_UPPER + elif current_price <= current_lower * 1.01: + signal = BollingerSignal.BELOW_LOWER + + # 检查布林带收窄/扩张 + if bandwidth < prev_bandwidth * 0.7: + signal = BollingerSignal.SQUEEZE + elif bandwidth > prev_bandwidth * 1.3: + signal = BollingerSignal.EXPANSION + + # 价格位置描述 + position_pct = (current_price - current_lower) / (current_upper - current_lower) * 100 + position = f"价格位于布林带{position_pct:.0f}%位置" + + # 生成描述 + descriptions = { + BollingerSignal.ABOVE_UPPER: f"股价触及布林带上轨({current_upper:.2f}),面临短期压力", + BollingerSignal.BELOW_LOWER: f"股价触及布林带下轨({current_lower:.2f}),可能存在支撑", + BollingerSignal.ABOVE_MIDDLE: f"股价在中轨({current_middle:.2f})之上运行,整体偏强", + BollingerSignal.BELOW_MIDDLE: f"股价在中轨({current_middle:.2f})之下运行,整体偏弱", + BollingerSignal.SQUEEZE: f"布林带收窄(带宽{bandwidth:.1f}%),变盘在即,关注突破方向", + BollingerSignal.EXPANSION: f"布林带扩张(带宽{bandwidth:.1f}%),趋势加速中", + } + + return { + "success": True, + "data": { + "signal": signal.value, + "upper": round(current_upper, 2), + "middle": round(current_middle, 2), + "lower": round(current_lower, 2), + "bandwidth": round(bandwidth, 2), + "position": position, + "description": descriptions[signal], + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[Bollinger] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_stop_loss_atr(code: str, days: int = 30, atr_multiplier: float = 2.0) -> Dict[str, Any]: + """ + 使用ATR计算止损位 + + Args: + code: 股票代码 + days: 分析天数 + atr_multiplier: ATR倍数,默认2倍 + + Returns: + ATR止损建议 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 15: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + high = np.array([float(d['high_price']) for d in trade_data]) + low = np.array([float(d['low_price']) for d in trade_data]) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算ATR + atr = _calc_atr(high, low, close, 14) + current_atr = atr[-1] + current_price = close[-1] + + # 计算ATR百分比 + atr_percent = current_atr / current_price * 100 + + # 计算止损价 + stop_loss = current_price - atr_multiplier * current_atr + stop_loss_pct = (current_price - stop_loss) / current_price * 100 + + # 波动级别 + if atr_percent < 2: + volatility_level = "低" + elif atr_percent < 4: + volatility_level = "中" + else: + volatility_level = "高" + + desc = (f"ATR={current_atr:.2f}(占价格{atr_percent:.1f}%),波动性{volatility_level}。" + f"建议止损位:{stop_loss:.2f}({atr_multiplier}倍ATR),止损幅度{stop_loss_pct:.1f}%") + + return { + "success": True, + "data": { + "atr": round(current_atr, 3), + "atr_percent": round(atr_percent, 2), + "suggested_stop_loss": round(stop_loss, 2), + "suggested_stop_loss_pct": round(stop_loss_pct, 2), + "volatility_level": volatility_level, + "current_price": round(current_price, 2), + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[ATR] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def analyze_market_heat(code: str, days: int = 30) -> Dict[str, Any]: + """ + 分析换手率活跃度和量能 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 市场热度分析 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 10: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + volume = np.array([float(d['volume']) for d in trade_data]) + turnover_rates = [float(d.get('turnover_rate', 0) or 0) for d in trade_data] + + current_turnover = turnover_rates[-1] + avg_turnover_5 = np.mean(turnover_rates[-5:]) + + # 计算量比 + volume_ma5 = _calc_sma(volume, 5) + volume_ratio = volume[-1] / volume_ma5[-1] if volume_ma5[-1] > 0 else 1 + + # 计算OBV + obv = _calc_obv(close, volume) + obv_ma5 = _calc_sma(obv, 5) + obv_trend = "上升" if obv[-1] > obv_ma5[-1] else "下降" + + # 换手率分级 + if current_turnover < 1: + heat_level = "冷门" + elif current_turnover < 3: + heat_level = "正常" + elif current_turnover < 7: + heat_level = "活跃" + elif current_turnover < 15: + heat_level = "火热" + else: + heat_level = "极热" + + # 判断信号 + signal = VolumeSignal.NORMAL + + if volume_ratio > 2: + signal = VolumeSignal.VOLUME_SURGE + elif volume_ratio < 0.5: + signal = VolumeSignal.VOLUME_SHRINK + + # 检查量价背离 + price_up = close[-1] > close[-5] if len(close) > 5 else False + volume_down = volume[-1] < volume[-5] if len(volume) > 5 else False + + if price_up and volume_down: + signal = VolumeSignal.VOLUME_PRICE_DIVERGENCE + + # OBV判断主力动向 + if obv_trend == "上升" and close[-1] <= close[-5]: + signal = VolumeSignal.ACCUMULATION + elif obv_trend == "下降" and close[-1] >= close[-5]: + signal = VolumeSignal.DISTRIBUTION + + desc = (f"换手率{current_turnover:.2f}%({heat_level}),量比{volume_ratio:.2f}," + f"OBV趋势{obv_trend}。{signal.value}") + + return { + "success": True, + "data": { + "signal": signal.value, + "turnover_rate": round(current_turnover, 2), + "avg_turnover_5d": round(avg_turnover_5, 2), + "volume_ratio": round(volume_ratio, 2), + "obv_trend": obv_trend, + "heat_level": heat_level, + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[MarketHeat] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def check_new_high_breakout(code: str, days: int = 60) -> Dict[str, Any]: + """ + 检查唐奇安通道突破(新高/新低突破) + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 突破信号分析 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days + 10) + + if len(trade_data) < 25: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + high = np.array([float(d['high_price']) for d in trade_data]) + low = np.array([float(d['low_price']) for d in trade_data]) + close = np.array([float(d['close_price']) for d in trade_data]) + + current_price = close[-1] + + # 计算20日和60日高低点 + high_20d = np.max(high[-21:-1]) if len(high) > 21 else np.max(high[:-1]) + low_20d = np.min(low[-21:-1]) if len(low) > 21 else np.min(low[:-1]) + high_60d = np.max(high[-61:-1]) if len(high) > 61 else np.max(high[:-1]) + low_60d = np.min(low[-61:-1]) if len(low) > 61 else np.min(low[:-1]) + + # 判断突破信号 + signal = BreakoutSignal.NO_SIGNAL + + if current_price > high_20d: + signal = BreakoutSignal.NEW_HIGH_BREAKOUT + elif current_price < low_20d: + signal = BreakoutSignal.NEW_LOW_BREAKDOWN + elif current_price > high_20d * 0.97: + signal = BreakoutSignal.RESISTANCE_TEST + elif current_price < low_20d * 1.03: + signal = BreakoutSignal.SUPPORT_TEST + + # 计算距离 + distance_to_high = (high_20d - current_price) / current_price * 100 + distance_to_low = (current_price - low_20d) / current_price * 100 + + descriptions = { + BreakoutSignal.NEW_HIGH_BREAKOUT: f"突破20日新高({high_20d:.2f}),海龟交易法则买入信号触发", + BreakoutSignal.NEW_LOW_BREAKDOWN: f"跌破20日新低({low_20d:.2f}),海龟交易法则卖出信号触发", + BreakoutSignal.RESISTANCE_TEST: f"接近20日高点({high_20d:.2f}),距离{distance_to_high:.1f}%,关注能否突破", + BreakoutSignal.SUPPORT_TEST: f"接近20日低点({low_20d:.2f}),距离{distance_to_low:.1f}%,关注能否守住", + BreakoutSignal.NO_SIGNAL: f"价格在20日通道内运行({low_20d:.2f} - {high_20d:.2f})", + } + + return { + "success": True, + "data": { + "signal": signal.value, + "high_20d": round(high_20d, 2), + "low_20d": round(low_20d, 2), + "high_60d": round(high_60d, 2), + "low_60d": round(low_60d, 2), + "current_price": round(current_price, 2), + "distance_to_high_pct": round(distance_to_high, 2), + "distance_to_low_pct": round(distance_to_low, 2), + "description": descriptions[signal], + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[Breakout] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_max_drawdown(code: str, days: int = 250) -> Dict[str, Any]: + """ + 计算最大回撤 + + Args: + code: 股票代码 + days: 分析天数,默认250天(约一年) + + Returns: + 最大回撤分析 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + dates = [d['TRADEDATE'] for d in trade_data] + + # 计算累计最大值 + running_max = np.maximum.accumulate(close) + + # 计算回撤 + drawdown = (running_max - close) / running_max * 100 + + # 找到最大回撤 + max_dd = np.max(drawdown) + max_dd_idx = np.argmax(drawdown) + + # 找到回撤开始的位置(峰值) + peak_idx = np.argmax(close[:max_dd_idx + 1]) if max_dd_idx > 0 else 0 + + # 回撤期间 + period_start = dates[peak_idx] if peak_idx < len(dates) else dates[0] + period_end = dates[max_dd_idx] if max_dd_idx < len(dates) else dates[-1] + + # 计算波动率(年化) + returns = np.diff(close) / close[:-1] + volatility = np.std(returns) * np.sqrt(252) * 100 + + # 计算夏普比率(假设无风险利率2%) + avg_return = np.mean(returns) * 252 * 100 + sharpe_ratio = (avg_return - 2) / volatility if volatility > 0 else 0 + + # 风险等级 + if max_dd < 10: + risk_level = "低风险" + elif max_dd < 25: + risk_level = "中风险" + else: + risk_level = "高风险" + + desc = (f"最大回撤{max_dd:.1f}%(从{period_start}到{period_end})," + f"年化波动率{volatility:.1f}%,夏普比率{sharpe_ratio:.2f},{risk_level}") + + return { + "success": True, + "data": { + "max_drawdown": round(max_dd, 2), + "max_drawdown_period": f"{period_start} 至 {period_end}", + "sharpe_ratio": round(sharpe_ratio, 2), + "volatility": round(volatility, 2), + "risk_level": risk_level, + "description": desc, + "code": code, + "analysis_days": len(trade_data) + } + } + + except Exception as e: + logger.error(f"[MaxDrawdown] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def check_valuation_rank(code: str, years: int = 3) -> Dict[str, Any]: + """ + 检查历史PE/PB百分位估值 + + Args: + code: 股票代码 + years: 历史年数,默认3年 + + Returns: + 估值分析结果 + """ + try: + days = years * 250 + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 60: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + # 提取PE数据 + pe_values = [float(d.get('pe_ratio', 0) or 0) for d in trade_data if d.get('pe_ratio')] + pe_values = [pe for pe in pe_values if 0 < pe < 1000] # 过滤异常值 + + if len(pe_values) < 30: + return {"success": False, "error": "PE数据不足"} + + current_pe = pe_values[-1] + + # 计算百分位 + pe_percentile = (np.sum(np.array(pe_values) < current_pe) / len(pe_values)) * 100 + + # 获取财务数据计算PEG + peg_ratio = None + try: + financial_data = await db.get_stock_financial_index(code, limit=2) + if len(financial_data) >= 2: + growth_rate = financial_data[0].get('profit_growth', 0) or 0 + if growth_rate > 0: + peg_ratio = current_pe / growth_rate + except: + pass + + # 估值水平判断 + if pe_percentile < 20: + valuation_level = "低估" + elif pe_percentile < 50: + valuation_level = "较低" + elif pe_percentile < 80: + valuation_level = "合理" + else: + valuation_level = "高估" + + # PEG修正 + peg_desc = "" + if peg_ratio is not None: + if peg_ratio < 1: + peg_desc = f",PEG={peg_ratio:.2f}<1(成长性强)" + elif peg_ratio < 2: + peg_desc = f",PEG={peg_ratio:.2f}(合理)" + else: + peg_desc = f",PEG={peg_ratio:.2f}(偏贵)" + + desc = (f"当前PE={current_pe:.1f},处于近{years}年{pe_percentile:.0f}%分位," + f"估值{valuation_level}{peg_desc}") + + return { + "success": True, + "data": { + "pe_current": round(current_pe, 2), + "pe_percentile": round(pe_percentile, 1), + "pe_min": round(min(pe_values), 2), + "pe_max": round(max(pe_values), 2), + "pe_median": round(np.median(pe_values), 2), + "peg_ratio": round(peg_ratio, 2) if peg_ratio else None, + "valuation_level": valuation_level, + "description": desc, + "code": code, + "analysis_years": years + } + } + + except Exception as e: + logger.error(f"[Valuation] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_price_zscore(code: str, period: int = 60) -> Dict[str, Any]: + """ + 计算价格Z-Score(乖离率标准化) + + Args: + code: 股票代码 + period: 均线周期,默认60日 + + Returns: + Z-Score分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=period + 10) + + if len(trade_data) < period: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算均线和标准差 + ma = _calc_sma(close, period) + std = _calc_std(close, period) + + current_price = close[-1] + current_ma = ma[-1] + current_std = std[-1] + + # 计算Z-Score + zscore = (current_price - current_ma) / current_std if current_std > 0 else 0 + + # 计算乖离率 + bias = (current_price - current_ma) / current_ma * 100 + + # 判断状态 + if zscore > 2: + status = "极度偏高" + probability = "历史上此位置回落概率约95%" + elif zscore > 1: + status = "偏高" + probability = "历史上此位置回落概率约68%" + elif zscore < -2: + status = "极度偏低" + probability = "历史上此位置反弹概率约95%" + elif zscore < -1: + status = "偏低" + probability = "历史上此位置反弹概率约68%" + else: + status = "正常" + probability = "价格在正常波动范围内" + + desc = f"Z-Score={zscore:.2f},乖离率{bias:+.1f}%,{status}。{probability}" + + return { + "success": True, + "data": { + "zscore": round(zscore, 3), + "bias": round(bias, 2), + "ma_period": period, + "current_ma": round(current_ma, 2), + "current_price": round(current_price, 2), + "status": status, + "mean_reversion_hint": probability, + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[ZScore] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +# ==================== 分钟级高阶算子(基于ClickHouse) ==================== + +async def calc_market_profile_vpoc(code: str, date: str = None) -> Dict[str, Any]: + """ + 计算市场轮廓VPOC(成交量最大的价格档位) + + Args: + code: 股票代码 + date: 日期,格式 YYYY-MM-DD(可选,默认使用最近交易日) + + Returns: + VPOC分析结果 + """ + try: + # 获取分钟数据(如果未指定日期,获取最近的数据) + if date: + minute_data = await db.get_stock_minute_data(code, start_time=date, end_time=date + " 23:59:59", limit=500) + else: + # 获取最近的分钟数据 + minute_data = await db.get_stock_minute_data(code, limit=500) + + if len(minute_data) < 10: + return {"success": False, "error": "分钟数据不足,可能该股票无分钟级数据或非交易时段"} + + # 从数据中提取实际日期 + if not date and minute_data: + date = minute_data[0].get('timestamp', '')[:10] + + # 按价格区间统计成交量 + prices = np.array([float(d['close']) for d in minute_data]) + volumes = np.array([float(d['volume']) for d in minute_data]) + + # 创建价格档位(将价格四舍五入到0.01) + price_bins = np.round(prices, 2) + + # 统计每个价格档位的成交量 + unique_prices, indices = np.unique(price_bins, return_inverse=True) + volume_by_price = np.zeros(len(unique_prices)) + for i, idx in enumerate(indices): + volume_by_price[idx] += volumes[i] + + # 找到VPOC + vpoc_idx = np.argmax(volume_by_price) + vpoc_price = unique_prices[vpoc_idx] + vpoc_volume = volume_by_price[vpoc_idx] + + # 当前价格 + current_price = prices[-1] + + # 判断位置关系 + if current_price > vpoc_price * 1.01: + position = "价格在VPOC之上,短期支撑位" + elif current_price < vpoc_price * 0.99: + position = "价格在VPOC之下,短期压力位" + else: + position = "价格在VPOC附近,成交密集区" + + desc = f"VPOC价格={vpoc_price:.2f},成交量占比{vpoc_volume/np.sum(volumes)*100:.1f}%。{position}" + + return { + "success": True, + "data": { + "vpoc_price": round(vpoc_price, 2), + "vpoc_volume": int(vpoc_volume), + "vpoc_volume_pct": round(vpoc_volume / np.sum(volumes) * 100, 1), + "current_price": round(current_price, 2), + "position": position, + "description": desc, + "code": code, + "date": date + } + } + + except Exception as e: + logger.error(f"[VPOC] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_realized_volatility(code: str, date: str = None) -> Dict[str, Any]: + """ + 计算已实现波动率(Realized Volatility) + + Args: + code: 股票代码 + date: 日期(可选,默认使用最近交易日) + + Returns: + RV分析结果 + """ + try: + # 获取分钟数据 + if date: + minute_data = await db.get_stock_minute_data(code, start_time=date, end_time=date + " 23:59:59", limit=500) + else: + minute_data = await db.get_stock_minute_data(code, limit=500) + + if len(minute_data) < 30: + return {"success": False, "error": "分钟数据不足,可能该股票无分钟级数据或非交易时段"} + + # 从数据中提取实际日期 + if not date and minute_data: + date = minute_data[0].get('timestamp', '')[:10] + + # 按时间排序 + minute_data = sorted(minute_data, key=lambda x: x['timestamp']) + + close = np.array([float(d['close']) for d in minute_data]) + + # 计算分钟收益率 + returns = np.diff(np.log(close)) + + # 计算已实现波动率(日内) + rv_intraday = np.sqrt(np.sum(returns ** 2)) * 100 + + # 年化波动率(假设一天240分钟,一年250天) + rv_annual = rv_intraday * np.sqrt(250) * 100 + + # 波动率水平 + if rv_intraday < 1: + vol_level = "低波动" + elif rv_intraday < 3: + vol_level = "中波动" + else: + vol_level = "高波动" + + desc = f"日内已实现波动率{rv_intraday:.2f}%(年化约{rv_annual:.0f}%),{vol_level}" + + return { + "success": True, + "data": { + "rv_intraday": round(rv_intraday, 3), + "rv_annualized": round(rv_annual, 1), + "volatility_level": vol_level, + "description": desc, + "code": code, + "date": date + } + } + + except Exception as e: + logger.error(f"[RV] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def analyze_buying_pressure(code: str, date: str = None) -> Dict[str, Any]: + """ + 分析买卖压力失衡 + + Args: + code: 股票代码 + date: 日期(可选,默认使用最近交易日) + + Returns: + 买卖压力分析 + """ + try: + # 获取分钟数据 + if date: + minute_data = await db.get_stock_minute_data(code, start_time=date, end_time=date + " 23:59:59", limit=500) + else: + minute_data = await db.get_stock_minute_data(code, limit=500) + + if len(minute_data) < 30: + return {"success": False, "error": "分钟数据不足,可能该股票无分钟级数据或非交易时段"} + + # 从数据中提取实际日期 + if not date and minute_data: + date = minute_data[0].get('timestamp', '')[:10] + + minute_data = sorted(minute_data, key=lambda x: x['timestamp']) + + high = np.array([float(d['high']) for d in minute_data]) + low = np.array([float(d['low']) for d in minute_data]) + close = np.array([float(d['close']) for d in minute_data]) + volume = np.array([float(d['volume']) for d in minute_data]) + + # 计算买卖压力指标 + # (Close - Low) - (High - Close),正值表示买压,负值表示卖压 + pressure = (close - low) - (high - close) + + # 成交量加权 + weighted_pressure = np.sum(pressure * volume) / np.sum(volume) + + # 计算买压占比 + total_range = high - low + buying_pct = np.where(total_range > 0, (close - low) / total_range, 0.5) + avg_buying_pct = np.mean(buying_pct) * 100 + + # 判断信号 + if weighted_pressure > 0.1: + signal = "强买压" + desc = f"盘中主力抢筹明显,买方占优,收盘偏向日内高点" + elif weighted_pressure < -0.1: + signal = "强卖压" + desc = f"盘中抛压沉重,卖方占优,收盘偏向日内低点" + else: + signal = "均衡" + desc = f"买卖双方博弈均衡,无明显方向" + + return { + "success": True, + "data": { + "pressure_signal": signal, + "weighted_pressure": round(weighted_pressure, 4), + "avg_buying_pct": round(avg_buying_pct, 1), + "description": desc, + "code": code, + "date": date + } + } + + except Exception as e: + logger.error(f"[BuyingPressure] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def check_volume_price_divergence(code: str, days: int = 20) -> Dict[str, Any]: + """ + 检测量价背离 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 量价背离分析 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 10: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + volume = np.array([float(d['volume']) for d in trade_data]) + + # 检查价格趋势 + price_trend = "up" if close[-1] > close[-5] else "down" + price_change = (close[-1] - close[-5]) / close[-5] * 100 + + # 检查成交量趋势 + vol_avg_recent = np.mean(volume[-5:]) + vol_avg_prev = np.mean(volume[-10:-5]) if len(volume) >= 10 else np.mean(volume[:-5]) + vol_trend = "up" if vol_avg_recent > vol_avg_prev else "down" + vol_change = (vol_avg_recent - vol_avg_prev) / vol_avg_prev * 100 if vol_avg_prev > 0 else 0 + + # 判断背离 + has_divergence = False + divergence_type = None + + if price_trend == "up" and vol_trend == "down": + has_divergence = True + divergence_type = "顶背离" + desc = f"股价上涨{price_change:.1f}%但成交量萎缩{-vol_change:.1f}%,上涨动能不足,警惕回调" + elif price_trend == "down" and vol_trend == "down": + has_divergence = True + divergence_type = "底背离" + desc = f"股价下跌{-price_change:.1f}%但成交量萎缩{-vol_change:.1f}%,抛压减弱,可能企稳" + else: + desc = f"量价配合正常,价格{price_trend},成交量{vol_trend}" + + return { + "success": True, + "data": { + "has_divergence": has_divergence, + "divergence_type": divergence_type, + "price_change_5d": round(price_change, 2), + "volume_change_5d": round(vol_change, 2), + "signal": "量价背离" if has_divergence else "量价正常", + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[VolPriceDivergence] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def identify_candlestick_pattern(code: str, days: int = 10) -> Dict[str, Any]: + """ + 识别K线组合形态 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + K线形态识别结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 3: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + patterns = [] + + # 获取最近3天的OHLC + for i in range(len(trade_data) - 2, len(trade_data)): + if i < 0: + continue + d = trade_data[i] + open_p = float(d['open_price']) + high = float(d['high_price']) + low = float(d['low_price']) + close = float(d['close_price']) + + body = close - open_p + upper_shadow = high - max(open_p, close) + lower_shadow = min(open_p, close) - low + body_size = abs(body) + + # 十字星 + if body_size < (high - low) * 0.1: + patterns.append(("十字星", "犹豫信号,可能变盘")) + + # 锤子线 + if lower_shadow > body_size * 2 and upper_shadow < body_size * 0.5: + patterns.append(("锤子线", "底部反转信号")) + + # 上吊线 + if lower_shadow > body_size * 2 and upper_shadow < body_size * 0.5 and body < 0: + patterns.append(("上吊线", "顶部反转信号")) + + # 检查多日形态(需要至少3天数据) + if len(trade_data) >= 3: + d1, d2, d3 = trade_data[-3], trade_data[-2], trade_data[-1] + + close1 = float(d1['close_price']) + open1 = float(d1['open_price']) + close2 = float(d2['close_price']) + open2 = float(d2['open_price']) + close3 = float(d3['close_price']) + open3 = float(d3['open_price']) + + # 红三兵 + if (close1 > open1 and close2 > open2 and close3 > open3 and + close2 > close1 and close3 > close2): + patterns.append(("红三兵", "强势上涨信号")) + + # 三只乌鸦 + if (close1 < open1 and close2 < open2 and close3 < open3 and + close2 < close1 and close3 < close2): + patterns.append(("三只乌鸦", "强势下跌信号")) + + # 早晨之星 + body1 = abs(close1 - open1) + body2 = abs(close2 - open2) + body3 = abs(close3 - open3) + + if (close1 < open1 and body2 < body1 * 0.3 and close3 > open3 and + close3 > (open1 + close1) / 2): + patterns.append(("早晨之星", "底部反转强信号")) + + # 黄昏之星 + if (close1 > open1 and body2 < body1 * 0.3 and close3 < open3 and + close3 < (open1 + close1) / 2): + patterns.append(("黄昏之星", "顶部反转强信号")) + + # 生成描述 + if patterns: + pattern_names = [f"{p[0]}({p[1]})" for p in patterns] + desc = f"识别到以下K线形态:" + "、".join(pattern_names) + else: + desc = "未识别到明显的K线形态" + + return { + "success": True, + "data": { + "patterns": [{"name": p[0], "meaning": p[1]} for p in patterns], + "pattern_count": len(patterns), + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[CandlestickPattern] 识别失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def find_price_gaps(code: str, days: int = 30) -> Dict[str, Any]: + """ + 寻找跳空缺口 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 缺口分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 5: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + gaps = [] + current_price = float(trade_data[-1]['close_price']) + + for i in range(1, len(trade_data)): + prev = trade_data[i-1] + curr = trade_data[i] + + prev_high = float(prev['high_price']) + prev_low = float(prev['low_price']) + curr_high = float(curr['high_price']) + curr_low = float(curr['low_price']) + + # 上涨缺口 + if curr_low > prev_high: + gap_size = (curr_low - prev_high) / prev_high * 100 + is_filled = current_price <= curr_low + gaps.append({ + "type": "上涨缺口", + "date": curr['TRADEDATE'], + "gap_low": prev_high, + "gap_high": curr_low, + "gap_size_pct": round(gap_size, 2), + "is_filled": is_filled + }) + + # 下跌缺口 + if curr_high < prev_low: + gap_size = (prev_low - curr_high) / prev_low * 100 + is_filled = current_price >= curr_high + gaps.append({ + "type": "下跌缺口", + "date": curr['TRADEDATE'], + "gap_low": curr_high, + "gap_high": prev_low, + "gap_size_pct": round(gap_size, 2), + "is_filled": is_filled + }) + + # 只保留未回补的缺口 + unfilled_gaps = [g for g in gaps if not g['is_filled']] + + if unfilled_gaps: + gap_desc = [f"{g['type']}({g['date']},{g['gap_size_pct']}%)" for g in unfilled_gaps[:3]] + desc = f"发现{len(unfilled_gaps)}个未回补缺口:" + "、".join(gap_desc) + else: + desc = "近期无未回补缺口" + + return { + "success": True, + "data": { + "total_gaps": len(gaps), + "unfilled_gaps": unfilled_gaps, + "unfilled_count": len(unfilled_gaps), + "description": desc, + "code": code + } + } + + except Exception as e: + logger.error(f"[PriceGaps] 分析失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def get_comprehensive_analysis(code: str) -> Dict[str, Any]: + """ + 综合技术分析(一次性返回多个指标) + + Args: + code: 股票代码 + + Returns: + 综合分析结果 + """ + try: + # 并行执行多个分析 + results = await asyncio.gather( + get_macd_signal(code), + check_oscillator_status(code), + analyze_bollinger_bands(code), + analyze_market_heat(code), + check_new_high_breakout(code), + check_volume_price_divergence(code), + identify_candlestick_pattern(code), + return_exceptions=True + ) + + analysis = {} + indicators = [ + ("macd", "MACD趋势"), + ("oscillator", "超买超卖"), + ("bollinger", "布林带"), + ("market_heat", "市场热度"), + ("breakout", "突破信号"), + ("volume_price", "量价分析"), + ("candlestick", "K线形态") + ] + + signals = [] + for i, (key, name) in enumerate(indicators): + if isinstance(results[i], dict) and results[i].get("success"): + analysis[key] = results[i]["data"] + signals.append(f"【{name}】{results[i]['data'].get('description', '')}") + + # 生成综合评估 + bullish_count = 0 + bearish_count = 0 + + for result in results: + if isinstance(result, dict) and result.get("success"): + data = result.get("data", {}) + signal = data.get("signal", "") + + if any(word in signal for word in ["金叉", "底背离", "超卖", "支撑", "突破新高", "红三兵"]): + bullish_count += 1 + elif any(word in signal for word in ["死叉", "顶背离", "超买", "压力", "跌破", "乌鸦"]): + bearish_count += 1 + + if bullish_count > bearish_count + 2: + overall = "多方占优,短期看涨" + elif bearish_count > bullish_count + 2: + overall = "空方占优,短期看跌" + else: + overall = "多空胶着,观望为主" + + return { + "success": True, + "data": { + "code": code, + "analysis": analysis, + "signals_summary": signals, + "bullish_signals": bullish_count, + "bearish_signals": bearish_count, + "overall_assessment": overall + } + } + + except Exception as e: + logger.error(f"[ComprehensiveAnalysis] 分析失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +# ==================== 新增因子(12个) ==================== + +async def calc_rsi_divergence(code: str, days: int = 60, rsi_period: int = 14) -> Dict[str, Any]: + """ + RSI背离检测(独立于MACD的RSI专项背离分析) + + Args: + code: 股票代码 + days: 分析天数 + rsi_period: RSI周期,默认14 + + Returns: + RSI背离分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < rsi_period + 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算RSI + rsi = _calc_rsi(close, rsi_period) + + # 检测背离 + divergence = _detect_divergence(close, rsi, lookback=20) + + current_rsi = rsi[-1] + + # 判断RSI位置 + if current_rsi > 70: + rsi_zone = "超买区" + elif current_rsi < 30: + rsi_zone = "超卖区" + else: + rsi_zone = "中性区" + + # 生成描述 + if divergence == "bullish_divergence": + signal = "底背离" + desc = f"RSI底背离形成:股价创新低但RSI没有创新低,当前RSI={current_rsi:.1f},位于{rsi_zone},反弹概率增大" + elif divergence == "bearish_divergence": + signal = "顶背离" + desc = f"RSI顶背离形成:股价创新高但RSI没有创新高,当前RSI={current_rsi:.1f},位于{rsi_zone},回调风险加大" + else: + signal = "无背离" + desc = f"RSI当前值={current_rsi:.1f},位于{rsi_zone},未检测到明显背离" + + return { + "success": True, + "data": { + "signal": signal, + "rsi_value": round(current_rsi, 2), + "rsi_zone": rsi_zone, + "has_divergence": divergence is not None, + "divergence_type": divergence, + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[RSI Divergence] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_bollinger_squeeze(code: str, days: int = 60, period: int = 20) -> Dict[str, Any]: + """ + 布林带挤压分析(Bollinger Squeeze) + 当布林带收窄到一定程度时,通常预示着变盘 + + Args: + code: 股票代码 + days: 分析天数 + period: 布林带周期 + + Returns: + 布林带挤压分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < period + 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算布林带 + upper, middle, lower = _calc_bollinger(close, period) + + # 计算带宽序列 + bandwidths = (upper - lower) / middle * 100 + + current_bandwidth = bandwidths[-1] + + # 计算历史带宽百分位 + bandwidth_percentile = (np.sum(bandwidths < current_bandwidth) / len(bandwidths)) * 100 + + # 计算带宽变化趋势 + bandwidth_ma5 = np.mean(bandwidths[-5:]) + bandwidth_ma20 = np.mean(bandwidths[-20:]) + bandwidth_trend = "收窄" if bandwidth_ma5 < bandwidth_ma20 else "扩张" + + # 判断挤压状态 + if bandwidth_percentile < 20: + squeeze_status = "强挤压" + signal = "变盘在即" + desc = f"布林带处于强挤压状态,带宽{current_bandwidth:.2f}%(历史{bandwidth_percentile:.0f}%分位),变盘信号强烈" + elif bandwidth_percentile < 40: + squeeze_status = "中度挤压" + signal = "关注突破" + desc = f"布林带中度挤压,带宽{current_bandwidth:.2f}%(历史{bandwidth_percentile:.0f}%分位),关注突破方向" + else: + squeeze_status = "无挤压" + signal = "正常波动" + desc = f"布林带正常,带宽{current_bandwidth:.2f}%(历史{bandwidth_percentile:.0f}%分位),趋势{bandwidth_trend}中" + + return { + "success": True, + "data": { + "squeeze_status": squeeze_status, + "signal": signal, + "bandwidth": round(current_bandwidth, 2), + "bandwidth_percentile": round(bandwidth_percentile, 1), + "bandwidth_trend": bandwidth_trend, + "upper": round(upper[-1], 2), + "middle": round(middle[-1], 2), + "lower": round(lower[-1], 2), + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[Bollinger Squeeze] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def analyze_obv_trend(code: str, days: int = 60) -> Dict[str, Any]: + """ + OBV能量潮独立分析 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + OBV趋势分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + volume = np.array([float(d['volume']) for d in trade_data]) + + # 计算OBV + obv = _calc_obv(close, volume) + + # 计算OBV均线 + obv_ma5 = _calc_sma(obv, 5) + obv_ma20 = _calc_sma(obv, 20) + + current_obv = obv[-1] + current_obv_ma5 = obv_ma5[-1] + current_obv_ma20 = obv_ma20[-1] + + # 判断OBV趋势 + if current_obv > current_obv_ma5 > current_obv_ma20: + obv_trend = "强势上升" + signal = "资金持续流入" + elif current_obv > current_obv_ma5: + obv_trend = "短期上升" + signal = "资金开始流入" + elif current_obv < current_obv_ma5 < current_obv_ma20: + obv_trend = "强势下降" + signal = "资金持续流出" + elif current_obv < current_obv_ma5: + obv_trend = "短期下降" + signal = "资金开始流出" + else: + obv_trend = "震荡" + signal = "资金观望" + + # 检测OBV与价格的背离 + price_change_20d = (close[-1] - close[-20]) / close[-20] * 100 if len(close) >= 20 else 0 + obv_change_20d = (obv[-1] - obv[-20]) / abs(obv[-20]) * 100 if len(obv) >= 20 and obv[-20] != 0 else 0 + + obv_divergence = None + if price_change_20d > 5 and obv_change_20d < -5: + obv_divergence = "顶背离" + elif price_change_20d < -5 and obv_change_20d > 5: + obv_divergence = "底背离" + + desc = f"OBV趋势:{obv_trend},{signal}。" + if obv_divergence: + desc += f"警告:出现OBV{obv_divergence}!" + + return { + "success": True, + "data": { + "obv_trend": obv_trend, + "signal": signal, + "current_obv": int(current_obv), + "obv_ma5": int(current_obv_ma5), + "obv_ma20": int(current_obv_ma20), + "obv_divergence": obv_divergence, + "price_change_20d": round(price_change_20d, 2), + "obv_change_20d": round(obv_change_20d, 2), + "description": desc, + "code": code, + "analysis_date": trade_data[-1]['TRADEDATE'] + } + } + + except Exception as e: + logger.error(f"[OBV Trend] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_amihud_illiquidity(code: str, days: int = 20) -> Dict[str, Any]: + """ + 计算Amihud非流动性因子 + Amihud = |收益率| / 成交额 + 值越大表示流动性越差(价格对成交额的敏感度越高) + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + Amihud非流动性分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 5: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + turnover = np.array([float(d.get('turnover', 0) or d.get('amount', 0) or 0) for d in trade_data]) + + # 过滤掉成交额为0的数据 + valid_mask = turnover > 0 + if np.sum(valid_mask) < 5: + return {"success": False, "error": "有效成交额数据不足"} + + # 计算日收益率 + returns = np.zeros(len(close)) + returns[1:] = np.abs(np.diff(close) / close[:-1]) + + # 计算Amihud因子(单位:每百万成交额导致的价格变动百分比) + amihud_daily = np.where(turnover > 0, returns / (turnover / 1e6), 0) + + # 平均Amihud值 + avg_amihud = np.mean(amihud_daily[valid_mask]) * 1e6 # 放大以便阅读 + + # 流动性评级 + if avg_amihud < 0.1: + liquidity_level = "极高流动性" + signal = "大单冲击成本低" + elif avg_amihud < 0.5: + liquidity_level = "高流动性" + signal = "交易成本可控" + elif avg_amihud < 2: + liquidity_level = "中等流动性" + signal = "注意交易量" + elif avg_amihud < 10: + liquidity_level = "低流动性" + signal = "大单需分批" + else: + liquidity_level = "极低流动性" + signal = "谨慎交易" + + desc = f"Amihud非流动性指标={avg_amihud:.4f},{liquidity_level},{signal}" + + return { + "success": True, + "data": { + "amihud_value": round(avg_amihud, 6), + "liquidity_level": liquidity_level, + "signal": signal, + "avg_daily_turnover": round(np.mean(turnover[valid_mask]) / 1e4, 2), # 万元 + "description": desc, + "code": code, + "analysis_days": days + } + } + + except Exception as e: + logger.error(f"[Amihud] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_parkinson_volatility(code: str, date: str = None) -> Dict[str, Any]: + """ + 计算帕金森波动率(基于分钟级High/Low数据) + 帕金森波动率只使用最高价和最低价,比传统波动率更准确 + + Args: + code: 股票代码 + date: 日期(可选,默认使用最近交易日) + + Returns: + 帕金森波动率结果 + """ + try: + # 获取分钟数据 + if date: + minute_data = await db.get_stock_minute_data(code, start_time=date, end_time=date + " 23:59:59", limit=500) + else: + minute_data = await db.get_stock_minute_data(code, limit=500) + + if len(minute_data) < 30: + return {"success": False, "error": "分钟数据不足,可能该股票无分钟级数据或非交易时段"} + + # 从数据中提取实际日期 + if not date and minute_data: + date = minute_data[0].get('timestamp', '')[:10] + + high = np.array([float(d['high']) for d in minute_data]) + low = np.array([float(d['low']) for d in minute_data]) + + # 帕金森波动率公式 + # σ = sqrt(1/(4*ln(2)) * mean((ln(H/L))^2)) + log_hl = np.log(high / low) + parkinson_var = np.mean(log_hl ** 2) / (4 * np.log(2)) + parkinson_vol = np.sqrt(parkinson_var) * 100 # 转为百分比 + + # 年化 + parkinson_vol_annual = parkinson_vol * np.sqrt(252 * 240) # 240分钟/天 + + # 与传统波动率对比(使用收盘价) + close = np.array([float(d['close']) for d in minute_data]) + returns = np.diff(np.log(close)) + traditional_vol = np.std(returns) * 100 + + # 波动率评级 + if parkinson_vol < 0.5: + vol_level = "低波动" + elif parkinson_vol < 1.5: + vol_level = "中波动" + else: + vol_level = "高波动" + + desc = f"帕金森波动率={parkinson_vol:.3f}%(年化约{parkinson_vol_annual:.1f}%),{vol_level}。传统波动率={traditional_vol:.3f}%" + + return { + "success": True, + "data": { + "parkinson_vol": round(parkinson_vol, 4), + "parkinson_vol_annual": round(parkinson_vol_annual, 2), + "traditional_vol": round(traditional_vol, 4), + "vol_level": vol_level, + "description": desc, + "code": code, + "date": date + } + } + + except Exception as e: + logger.error(f"[Parkinson Vol] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_trend_slope(code: str, days: int = 20) -> Dict[str, Any]: + """ + 计算趋势线性回归斜率 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 趋势斜率分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 10: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 线性回归 + x = np.arange(len(close)) + slope, intercept = np.polyfit(x, close, 1) + + # 计算R² + y_pred = slope * x + intercept + ss_res = np.sum((close - y_pred) ** 2) + ss_tot = np.sum((close - np.mean(close)) ** 2) + r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0 + + # 将斜率转化为日均涨幅百分比 + slope_pct = slope / close[0] * 100 + + # 判断趋势 + if slope_pct > 0.5 and r_squared > 0.7: + trend = "强上升趋势" + signal = "顺势做多" + elif slope_pct > 0.2 and r_squared > 0.5: + trend = "弱上升趋势" + signal = "关注回调买点" + elif slope_pct < -0.5 and r_squared > 0.7: + trend = "强下降趋势" + signal = "顺势做空或观望" + elif slope_pct < -0.2 and r_squared > 0.5: + trend = "弱下降趋势" + signal = "关注反弹卖点" + else: + trend = "震荡无趋势" + signal = "区间交易" + + desc = f"近{days}日趋势斜率={slope_pct:.3f}%/日,R²={r_squared:.2f},{trend},建议{signal}" + + return { + "success": True, + "data": { + "slope": round(slope, 4), + "slope_pct_daily": round(slope_pct, 3), + "r_squared": round(r_squared, 3), + "trend": trend, + "signal": signal, + "intercept": round(intercept, 2), + "description": desc, + "code": code, + "analysis_days": days + } + } + + except Exception as e: + logger.error(f"[Trend Slope] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_hurst_exponent(code: str, days: int = 100) -> Dict[str, Any]: + """ + 计算Hurst指数(用于判断市场是趋势还是均值回归) + H > 0.5: 趋势市场(序列具有长期记忆) + H = 0.5: 随机游走 + H < 0.5: 均值回归市场 + + Args: + code: 股票代码 + days: 分析天数(建议100天以上) + + Returns: + Hurst指数分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 50: + return {"success": False, "error": "数据不足,Hurst指数需要至少50个数据点"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + + # R/S分析法计算Hurst指数 + n = len(close) + max_k = min(n // 2, 50) # 最大分组数 + + rs_list = [] + n_list = [] + + for k in range(10, max_k + 1): + # 将序列分成多个子序列 + rs_values = [] + for start in range(0, n - k + 1, k): + segment = close[start:start + k] + + # 计算累积离差 + mean_seg = np.mean(segment) + cumdev = np.cumsum(segment - mean_seg) + + # R = max - min + r = np.max(cumdev) - np.min(cumdev) + + # S = 标准差 + s = np.std(segment) + + if s > 0: + rs_values.append(r / s) + + if rs_values: + rs_list.append(np.mean(rs_values)) + n_list.append(k) + + if len(rs_list) < 3: + return {"success": False, "error": "无法计算Hurst指数"} + + # 线性回归计算H + log_n = np.log(n_list) + log_rs = np.log(rs_list) + hurst, _ = np.polyfit(log_n, log_rs, 1) + + # 解读Hurst指数 + if hurst > 0.6: + market_type = "趋势市场" + signal = "趋势策略有效" + desc = f"Hurst指数={hurst:.3f}>0.6,市场呈现明显趋势特征,适合趋势跟踪策略" + elif hurst > 0.5: + market_type = "弱趋势市场" + signal = "混合策略" + desc = f"Hurst指数={hurst:.3f},接近随机游走,趋势与均值回归策略均可尝试" + elif hurst > 0.4: + market_type = "弱均值回归" + signal = "关注反转" + desc = f"Hurst指数={hurst:.3f},市场有轻微均值回归倾向" + else: + market_type = "强均值回归" + signal = "反转策略有效" + desc = f"Hurst指数={hurst:.3f}<0.4,市场呈现强均值回归特征,适合反转策略" + + return { + "success": True, + "data": { + "hurst_exponent": round(hurst, 3), + "market_type": market_type, + "signal": signal, + "description": desc, + "code": code, + "analysis_days": len(trade_data) + } + } + + except Exception as e: + logger.error(f"[Hurst] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def test_cointegration(code1: str, code2: str, days: int = 250) -> Dict[str, Any]: + """ + 协整性测试(用于配对交易) + + Args: + code1: 股票代码1 + code2: 股票代码2 + days: 分析天数 + + Returns: + 协整性测试结果 + """ + try: + # 获取两只股票的数据 + data1 = await db.get_stock_trade_data(code1, limit=days) + data2 = await db.get_stock_trade_data(code2, limit=days) + + if len(data1) < 60 or len(data2) < 60: + return {"success": False, "error": "数据不足"} + + # 按日期排序 + data1 = sorted(data1, key=lambda x: x['TRADEDATE']) + data2 = sorted(data2, key=lambda x: x['TRADEDATE']) + + # 对齐日期 + dates1 = {d['TRADEDATE']: float(d['close_price']) for d in data1} + dates2 = {d['TRADEDATE']: float(d['close_price']) for d in data2} + + common_dates = sorted(set(dates1.keys()) & set(dates2.keys())) + + if len(common_dates) < 60: + return {"success": False, "error": "共同交易日不足"} + + price1 = np.array([dates1[d] for d in common_dates]) + price2 = np.array([dates2[d] for d in common_dates]) + + # 计算对数价格 + log_price1 = np.log(price1) + log_price2 = np.log(price2) + + # 线性回归找对冲比率 + hedge_ratio, intercept = np.polyfit(log_price2, log_price1, 1) + + # 计算价差(spread) + spread = log_price1 - hedge_ratio * log_price2 + + # ADF检验的简化版本(计算价差的平稳性指标) + spread_diff = np.diff(spread) + spread_lag = spread[:-1] + + # 计算回归系数 + if np.var(spread_lag) > 0: + rho = np.cov(spread_diff, spread_lag)[0, 1] / np.var(spread_lag) + else: + rho = 0 + + # rho接近-1表示强平稳性 + stationarity_score = -rho + + # 计算价差的均值回归半衰期 + if rho < 0: + half_life = -np.log(2) / np.log(1 + rho) + else: + half_life = float('inf') + + # 当前价差Z-score + spread_mean = np.mean(spread) + spread_std = np.std(spread) + current_zscore = (spread[-1] - spread_mean) / spread_std if spread_std > 0 else 0 + + # 判断协整性 + if stationarity_score > 0.3 and half_life < 30: + cointegration_level = "强协整" + signal = "适合配对交易" + elif stationarity_score > 0.1 and half_life < 60: + cointegration_level = "中等协整" + signal = "可尝试配对" + else: + cointegration_level = "弱协整或无协整" + signal = "不建议配对" + + # 交易建议 + if abs(current_zscore) > 2: + if current_zscore > 0: + trade_signal = f"做空价差(卖{code1}买{code2})" + else: + trade_signal = f"做多价差(买{code1}卖{code2})" + elif abs(current_zscore) > 1: + trade_signal = "关注价差回归机会" + else: + trade_signal = "价差在正常范围" + + desc = (f"{code1}与{code2}{cointegration_level},对冲比率={hedge_ratio:.3f}," + f"半衰期={half_life:.1f}天,当前价差Z-score={current_zscore:.2f},{signal}") + + return { + "success": True, + "data": { + "code1": code1, + "code2": code2, + "cointegration_level": cointegration_level, + "hedge_ratio": round(hedge_ratio, 4), + "half_life_days": round(half_life, 1) if half_life < 1000 else "N/A", + "current_spread_zscore": round(current_zscore, 3), + "stationarity_score": round(stationarity_score, 3), + "trade_signal": trade_signal, + "signal": signal, + "description": desc, + "common_days": len(common_dates) + } + } + + except Exception as e: + logger.error(f"[Cointegration] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_kelly_position(win_rate: float, win_loss_ratio: float, max_position: float = 0.25) -> Dict[str, Any]: + """ + 凯利公式计算最优仓位 + Kelly% = W - (1-W)/R + 其中 W=胜率,R=盈亏比 + + Args: + win_rate: 胜率(0-1之间) + win_loss_ratio: 盈亏比(平均盈利/平均亏损) + max_position: 最大允许仓位(默认25%) + + Returns: + 凯利仓位计算结果 + """ + try: + if not 0 < win_rate < 1: + return {"success": False, "error": "胜率必须在0-1之间"} + + if win_loss_ratio <= 0: + return {"success": False, "error": "盈亏比必须大于0"} + + # 计算凯利比例 + kelly_pct = win_rate - (1 - win_rate) / win_loss_ratio + + # 半凯利(更保守) + half_kelly = kelly_pct / 2 + + # 限制最大仓位 + recommended_position = min(max(kelly_pct, 0), max_position) + conservative_position = min(max(half_kelly, 0), max_position) + + # 判断策略质量 + if kelly_pct >= 0.2: + strategy_quality = "优秀" + signal = "可以较大仓位" + elif kelly_pct >= 0.1: + strategy_quality = "良好" + signal = "适中仓位" + elif kelly_pct > 0: + strategy_quality = "一般" + signal = "小仓位试探" + else: + strategy_quality = "不可行" + signal = "不建议交易" + + desc = (f"胜率{win_rate*100:.1f}%,盈亏比{win_loss_ratio:.2f}," + f"凯利比例={kelly_pct*100:.1f}%,策略{strategy_quality}。" + f"建议仓位:激进{recommended_position*100:.1f}%,保守{conservative_position*100:.1f}%") + + return { + "success": True, + "data": { + "kelly_pct": round(kelly_pct * 100, 2), + "half_kelly_pct": round(half_kelly * 100, 2), + "recommended_position": round(recommended_position * 100, 2), + "conservative_position": round(conservative_position * 100, 2), + "strategy_quality": strategy_quality, + "signal": signal, + "win_rate": round(win_rate * 100, 1), + "win_loss_ratio": round(win_loss_ratio, 2), + "description": desc + } + } + + except Exception as e: + logger.error(f"[Kelly] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def search_similar_kline(code: str, lookback: int = 10, top_n: int = 5) -> Dict[str, Any]: + """ + 相似K线检索(基于形态相似度) + + Args: + code: 股票代码 + lookback: 匹配窗口大小(默认10天) + top_n: 返回最相似的N个历史片段 + + Returns: + 相似K线检索结果 + """ + try: + # 获取足够的历史数据 + trade_data = await db.get_stock_trade_data(code, limit=500) + + if len(trade_data) < lookback * 3: + return {"success": False, "error": "历史数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + + close = np.array([float(d['close_price']) for d in trade_data]) + dates = [d['TRADEDATE'] for d in trade_data] + + # 当前形态(标准化) + current_pattern = close[-lookback:] + current_norm = (current_pattern - np.mean(current_pattern)) / np.std(current_pattern) + + # 在历史中搜索相似形态 + similar_patterns = [] + + for i in range(len(close) - lookback * 2): # 不包括最近的数据 + historical_pattern = close[i:i + lookback] + + # 标准化 + if np.std(historical_pattern) > 0: + hist_norm = (historical_pattern - np.mean(historical_pattern)) / np.std(historical_pattern) + + # 计算相似度(使用相关系数) + correlation = np.corrcoef(current_norm, hist_norm)[0, 1] + + # 计算后续N天的收益 + future_return = 0 + if i + lookback + 5 < len(close): + future_return = (close[i + lookback + 5] - close[i + lookback - 1]) / close[i + lookback - 1] * 100 + + if correlation > 0.8: # 只保留高相似度的 + similar_patterns.append({ + "start_date": dates[i], + "end_date": dates[i + lookback - 1], + "correlation": correlation, + "future_return_5d": future_return + }) + + # 按相似度排序,取前N个 + similar_patterns = sorted(similar_patterns, key=lambda x: x['correlation'], reverse=True)[:top_n] + + if not similar_patterns: + return { + "success": True, + "data": { + "message": "未找到高度相似的历史形态", + "code": code, + "lookback": lookback + } + } + + # 统计历史相似形态后的走势 + future_returns = [p['future_return_5d'] for p in similar_patterns if p['future_return_5d'] != 0] + + if future_returns: + avg_future_return = np.mean(future_returns) + up_probability = sum(1 for r in future_returns if r > 0) / len(future_returns) * 100 + else: + avg_future_return = 0 + up_probability = 50 + + # 预测信号 + if avg_future_return > 2 and up_probability > 60: + prediction = "历史形态后多数上涨" + elif avg_future_return < -2 and up_probability < 40: + prediction = "历史形态后多数下跌" + else: + prediction = "历史走势分化,谨慎参考" + + desc = (f"找到{len(similar_patterns)}个相似历史形态," + f"历史5日平均收益{avg_future_return:.1f}%,上涨概率{up_probability:.0f}%,{prediction}") + + return { + "success": True, + "data": { + "similar_patterns": similar_patterns, + "pattern_count": len(similar_patterns), + "avg_future_return_5d": round(avg_future_return, 2), + "up_probability": round(up_probability, 1), + "prediction": prediction, + "description": desc, + "code": code, + "lookback": lookback + } + } + + except Exception as e: + logger.error(f"[Similar KLine] 检索失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def decompose_trend_simple(code: str, days: int = 120) -> Dict[str, Any]: + """ + 简化版趋势分解(不依赖Prophet) + 将价格序列分解为:趋势 + 周期 + 残差 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 趋势分解结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 60: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + dates = [d['TRADEDATE'] for d in trade_data] + + # 1. 提取趋势(使用长周期移动平均) + trend = _calc_sma(close, min(60, len(close) // 2)) + + # 2. 去趋势 + detrended = close - trend + + # 3. 提取周期成分(使用FFT的简化方法) + # 计算周期性强度 + fft_result = np.fft.fft(detrended) + fft_freq = np.fft.fftfreq(len(detrended)) + + # 找到主要周期 + positive_freq_idx = fft_freq > 0 + amplitudes = np.abs(fft_result[positive_freq_idx]) + frequencies = fft_freq[positive_freq_idx] + + # 找最强的周期 + if len(amplitudes) > 0: + dominant_idx = np.argmax(amplitudes) + dominant_period = 1 / frequencies[dominant_idx] if frequencies[dominant_idx] > 0 else 0 + periodicity_strength = amplitudes[dominant_idx] / np.sum(amplitudes) * 100 + else: + dominant_period = 0 + periodicity_strength = 0 + + # 4. 残差 = 原始 - 趋势 - 周期 + residual = detrended # 简化处理,这里残差就是去趋势后的数据 + + # 分析当前趋势方向 + trend_direction = "上升" if trend[-1] > trend[-20] else "下降" + trend_strength = abs(trend[-1] - trend[-20]) / trend[-20] * 100 + + # 当前位置相对于趋势 + current_deviation = (close[-1] - trend[-1]) / trend[-1] * 100 + + if current_deviation > 3: + position = "高于趋势线" + signal = "短期或有回调" + elif current_deviation < -3: + position = "低于趋势线" + signal = "短期或有反弹" + else: + position = "贴近趋势线" + signal = "跟随趋势" + + desc = (f"趋势{trend_direction}(强度{trend_strength:.1f}%)," + f"主周期约{dominant_period:.0f}天,周期性强度{periodicity_strength:.1f}%," + f"当前{position}(偏离{current_deviation:+.1f}%),{signal}") + + return { + "success": True, + "data": { + "trend_direction": trend_direction, + "trend_strength_pct": round(trend_strength, 2), + "dominant_period_days": round(dominant_period, 0), + "periodicity_strength": round(periodicity_strength, 1), + "current_deviation_pct": round(current_deviation, 2), + "position": position, + "signal": signal, + "current_price": round(close[-1], 2), + "current_trend": round(trend[-1], 2), + "description": desc, + "code": code, + "analysis_days": len(trade_data) + } + } + + except Exception as e: + logger.error(f"[Trend Decompose] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +async def calc_price_entropy(code: str, days: int = 60) -> Dict[str, Any]: + """ + 计算价格信息熵(衡量市场混乱程度) + 熵值越高表示市场越混乱/随机,越低表示趋势越明显 + + Args: + code: 股票代码 + days: 分析天数 + + Returns: + 价格熵值分析结果 + """ + try: + trade_data = await db.get_stock_trade_data(code, limit=days) + + if len(trade_data) < 20: + return {"success": False, "error": "数据不足"} + + trade_data = sorted(trade_data, key=lambda x: x['TRADEDATE']) + close = np.array([float(d['close_price']) for d in trade_data]) + + # 计算日收益率 + returns = np.diff(close) / close[:-1] + + # 将收益率离散化为bins + n_bins = 10 + hist, bin_edges = np.histogram(returns, bins=n_bins) + + # 计算概率分布 + prob = hist / np.sum(hist) + prob = prob[prob > 0] # 去掉0概率 + + # 计算香农熵 + entropy = -np.sum(prob * np.log2(prob)) + + # 最大熵(均匀分布) + max_entropy = np.log2(n_bins) + + # 标准化熵值(0-1之间) + normalized_entropy = entropy / max_entropy + + # 近期熵值变化 + recent_returns = returns[-10:] + recent_hist, _ = np.histogram(recent_returns, bins=5) + recent_prob = recent_hist / np.sum(recent_hist) + recent_prob = recent_prob[recent_prob > 0] + recent_entropy = -np.sum(recent_prob * np.log2(recent_prob)) + recent_max_entropy = np.log2(5) + recent_normalized = recent_entropy / recent_max_entropy + + # 判断市场状态 + if normalized_entropy > 0.85: + market_state = "高度混乱" + signal = "随机性强,难以预测" + elif normalized_entropy > 0.7: + market_state = "中度混乱" + signal = "市场分歧大" + elif normalized_entropy > 0.5: + market_state = "适度有序" + signal = "有一定规律可循" + else: + market_state = "高度有序" + signal = "趋势明显,易于预测" + + # 熵值变化趋势 + entropy_trend = "增加" if recent_normalized > normalized_entropy else "减少" + + desc = (f"价格熵值={entropy:.2f}(标准化{normalized_entropy:.2f})," + f"{market_state},{signal}。近期熵值{entropy_trend}") + + return { + "success": True, + "data": { + "entropy": round(entropy, 3), + "normalized_entropy": round(normalized_entropy, 3), + "recent_entropy": round(recent_normalized, 3), + "entropy_trend": entropy_trend, + "market_state": market_state, + "signal": signal, + "description": desc, + "code": code, + "analysis_days": days + } + } + + except Exception as e: + logger.error(f"[Entropy] 计算失败: {e}", exc_info=True) + return {"success": False, "error": str(e)} + + +# ==================== 工具注册(供MCP使用) ==================== + +QUANT_TOOLS = { + # 经典技术指标 + "get_macd_signal": get_macd_signal, + "check_oscillator_status": check_oscillator_status, + "analyze_bollinger_bands": analyze_bollinger_bands, + "calc_stop_loss_atr": calc_stop_loss_atr, + + # 资金与情绪 + "analyze_market_heat": analyze_market_heat, + "check_volume_price_divergence": check_volume_price_divergence, + + # 形态与突破 + "check_new_high_breakout": check_new_high_breakout, + "identify_candlestick_pattern": identify_candlestick_pattern, + "find_price_gaps": find_price_gaps, + + # 风险与估值 + "calc_max_drawdown": calc_max_drawdown, + "check_valuation_rank": check_valuation_rank, + "calc_price_zscore": calc_price_zscore, + + # 分钟级高阶算子 + "calc_market_profile_vpoc": calc_market_profile_vpoc, + "calc_realized_volatility": calc_realized_volatility, + "analyze_buying_pressure": analyze_buying_pressure, + + # 综合分析 + "get_comprehensive_analysis": get_comprehensive_analysis, + + # ==================== 新增12个因子 ==================== + + # RSI背离检测 + "calc_rsi_divergence": calc_rsi_divergence, + + # 布林带挤压 + "calc_bollinger_squeeze": calc_bollinger_squeeze, + + # OBV能量潮独立分析 + "analyze_obv_trend": analyze_obv_trend, + + # Amihud非流动性因子 + "calc_amihud_illiquidity": calc_amihud_illiquidity, + + # 帕金森波动率 + "calc_parkinson_volatility": calc_parkinson_volatility, + + # 趋势线性回归斜率 + "calc_trend_slope": calc_trend_slope, + + # Hurst指数 + "calc_hurst_exponent": calc_hurst_exponent, + + # 协整性测试 + "test_cointegration": test_cointegration, + + # 凯利公式仓位 + "calc_kelly_position": calc_kelly_position, + + # 相似K线检索 + "search_similar_kline": search_similar_kline, + + # 趋势分解(简化版,不依赖Prophet) + "decompose_trend_simple": decompose_trend_simple, + + # 价格熵值计算 + "calc_price_entropy": calc_price_entropy, +} diff --git a/mcp_server.py b/mcp_server.py index 9256d8b8..6a3c043f 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -15,6 +15,7 @@ import httpx import time from enum import Enum import mcp_database as db +import mcp_quant as quant # 量化因子计算模块 from openai import OpenAI import json import asyncio @@ -770,6 +771,566 @@ TOOLS: List[ToolDefinition] = [ "required": ["code", "date"] } ), + # ==================== 量化因子工具 ==================== + ToolDefinition( + name="get_macd_signal", + description="获取MACD趋势判定信号,包括金叉/死叉、动能增减、顶底背离等状态。适用于判断股票短期趋势方向。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60天", + "default": 60 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="check_oscillator_status", + description="检查KDJ/RSI超买超卖状态,判断股票是否处于超买区(风险积聚)或超卖区(可能反弹)。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60天", + "default": 60 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="analyze_bollinger_bands", + description="分析布林带通道,判断股价是在中轨之上(强势)、触及上轨(压力)、触及下轨(支撑)或布林带收窄(变盘在即)。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60天", + "default": 60 + }, + "period": { + "type": "integer", + "description": "布林带周期,默认20", + "default": 20 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_stop_loss_atr", + description="使用ATR真实波幅计算止损位。告诉用户\"如果买入,止损点应该设在当前价格减去N倍ATR的位置\"。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认30天", + "default": 30 + }, + "atr_multiplier": { + "type": "number", + "description": "ATR倍数,默认2倍", + "default": 2.0 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="analyze_market_heat", + description="分析换手率活跃度和量能,判断股票是冷门股、活跃股还是妖股,以及主力是在吸筹还是出货。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认30天", + "default": 30 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="check_new_high_breakout", + description="检查唐奇安通道突破(海龟交易法则),判断是否突破20日/60日新高或新低。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60天", + "default": 60 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="identify_candlestick_pattern", + description="识别K线组合形态,如早晨之星(反转信号)、红三兵(上涨信号)、穿头破脚(吞没形态)等经典形态。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认10天", + "default": 10 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="find_price_gaps", + description="寻找跳空缺口,筛选出近期有未回补缺口的情况。缺口往往代表主力资金的强势突破意图或恐慌抛售。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认30天", + "default": 30 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="check_volume_price_divergence", + description="检测量价背离。股价创新高但成交量萎缩(量价背离),预警信号,提示上涨动力不足。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认20天", + "default": 20 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_max_drawdown", + description="计算最大回撤和夏普比率。用于评估\"买这只票最坏情况会亏多少\"以及风险调整后收益。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认250天(约一年)", + "default": 250 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="check_valuation_rank", + description="检查历史PE/PB百分位估值。计算当前PE处于过去N年的什么位置(例如:比过去90%的时间都便宜)。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "years": { + "type": "integer", + "description": "历史年数,默认3年", + "default": 3 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_price_zscore", + description="计算价格Z-Score(乖离率标准化),判断均值回归概率。当Z-Score过大时,统计回调概率。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "period": { + "type": "integer", + "description": "均线周期,默认60日", + "default": 60 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_market_profile_vpoc", + description="计算市场轮廓VPOC(成交量最大的价格档位),基于分钟级数据。VPOC是当日极强的支撑线或阻力线。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "date": { + "type": "string", + "description": "日期,格式:YYYY-MM-DD" + } + }, + "required": ["code", "date"] + } + ), + ToolDefinition( + name="calc_realized_volatility", + description="计算已实现波动率(RV),基于分钟级数据。比日线波动率更精准,用于判断趋势动能是否耗尽。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "date": { + "type": "string", + "description": "日期,格式:YYYY-MM-DD" + } + }, + "required": ["code", "date"] + } + ), + ToolDefinition( + name="analyze_buying_pressure", + description="分析买卖压力失衡,基于分钟级数据。捕捉盘中主力资金的\"抢筹\"或\"砸盘\"意图。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "date": { + "type": "string", + "description": "日期,格式:YYYY-MM-DD" + } + }, + "required": ["code", "date"] + } + ), + ToolDefinition( + name="get_comprehensive_analysis", + description="综合技术分析,一次性返回MACD、KDJ/RSI、布林带、量能、突破、K线形态等多个指标,并给出多空信号总结。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + } + }, + "required": ["code"] + } + ), + + # ==================== 新增量化因子工具(12个) ==================== + + ToolDefinition( + name="calc_rsi_divergence", + description="RSI背离检测,独立分析RSI指标的顶背离和底背离信号,判断反转概率。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60", + "default": 60 + }, + "rsi_period": { + "type": "integer", + "description": "RSI周期,默认14", + "default": 14 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_bollinger_squeeze", + description="布林带挤压分析,检测布林带收窄程度,预判变盘时机。当带宽处于历史低位时发出变盘预警。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60", + "default": 60 + }, + "period": { + "type": "integer", + "description": "布林带周期,默认20", + "default": 20 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="analyze_obv_trend", + description="OBV能量潮独立分析,追踪资金流向,检测OBV与价格的背离,判断主力动向。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60", + "default": 60 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_amihud_illiquidity", + description="计算Amihud非流动性因子,衡量股票流动性。值越大表示流动性越差,大单交易冲击成本越高。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认20", + "default": 20 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_parkinson_volatility", + description="计算帕金森波动率(基于分钟级高低价),比传统波动率更准确,适用于日内波动分析。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "date": { + "type": "string", + "description": "日期,格式YYYY-MM-DD" + } + }, + "required": ["code", "date"] + } + ), + ToolDefinition( + name="calc_trend_slope", + description="计算趋势线性回归斜率,量化趋势强度和方向。返回斜率、R²拟合度和趋势判断。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认20", + "default": 20 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_hurst_exponent", + description="计算Hurst指数,判断市场是趋势型(H>0.5)还是均值回归型(H<0.5),指导策略选择。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,建议100以上", + "default": 100 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="test_cointegration", + description="协整性测试,用于配对交易。检测两只股票是否存在长期均衡关系,计算对冲比率和价差。", + parameters={ + "type": "object", + "properties": { + "code1": { + "type": "string", + "description": "股票代码1" + }, + "code2": { + "type": "string", + "description": "股票代码2" + }, + "days": { + "type": "integer", + "description": "分析天数,默认250", + "default": 250 + } + }, + "required": ["code1", "code2"] + } + ), + ToolDefinition( + name="calc_kelly_position", + description="凯利公式计算最优仓位。根据胜率和盈亏比计算理论最优仓位,并提供保守建议。", + parameters={ + "type": "object", + "properties": { + "win_rate": { + "type": "number", + "description": "胜率(0-1之间,如0.6表示60%)" + }, + "win_loss_ratio": { + "type": "number", + "description": "盈亏比(平均盈利/平均亏损)" + }, + "max_position": { + "type": "number", + "description": "最大允许仓位,默认0.25", + "default": 0.25 + } + }, + "required": ["win_rate", "win_loss_ratio"] + } + ), + ToolDefinition( + name="search_similar_kline", + description="相似K线检索,在历史中搜索与当前形态相似的K线组合,统计历史后续走势作为参考。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "lookback": { + "type": "integer", + "description": "匹配窗口大小,默认10天", + "default": 10 + }, + "top_n": { + "type": "integer", + "description": "返回最相似的N个历史片段,默认5", + "default": 5 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="decompose_trend_simple", + description="趋势分解分析,将价格序列分解为趋势+周期+残差,识别主周期和趋势方向。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认120", + "default": 120 + } + }, + "required": ["code"] + } + ), + ToolDefinition( + name="calc_price_entropy", + description="计算价格信息熵,衡量市场混乱程度。熵值越低表示趋势越明显,越高表示随机性越强。", + parameters={ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "股票代码" + }, + "days": { + "type": "integer", + "description": "分析天数,默认60", + "default": 60 + } + }, + "required": ["code"] + } + ), ] # ==================== MCP协议端点 ==================== @@ -1251,6 +1812,8 @@ TOOL_HANDLERS = { "get_stock_minute_data": handle_get_stock_minute_data, "get_stock_minute_aggregation": handle_get_stock_minute_aggregation, "get_stock_intraday_statistics": handle_get_stock_intraday_statistics, + # 量化因子工具(从 mcp_quant 模块导入) + **quant.QUANT_TOOLS, } # ==================== Agent系统实现 ==================== @@ -2202,10 +2765,18 @@ async def agent_chat(request: AgentChatRequest): except Exception as e: logger.error(f"保存用户消息失败: {e}") - # 获取工具列表 - tools = [tool.dict() for tool in TOOLS] + # 获取工具列表(根据前端选择过滤) + if request.tools and len(request.tools) > 0: + # 用户指定了工具列表,按名称过滤 + selected_tool_names = set(request.tools) + tools = [tool.dict() for tool in TOOLS if tool.name in selected_tool_names] + logger.info(f"使用用户选择的 {len(tools)} 个工具: {request.tools[:10]}...") + else: + # 用户未指定,使用全部工具 + tools = [tool.dict() for tool in TOOLS] + logger.info(f"使用全部 {len(tools)} 个工具") - # 添加特殊工具:summarize_news + # 添加特殊工具:summarize_news(始终可用) tools.append({ "name": "summarize_news", "description": "使用 DeepMoney 模型总结新闻数据,提取关键信息", @@ -2510,7 +3081,28 @@ def clean_deepseek_tool_markers(content: str) -> str: ROLE_TOOLS = { "buffett": ["search_china_news", "search_research_reports", "get_stock_basic_info", "get_stock_financial_index"], "big_short": ["search_china_news", "get_stock_financial_index", "get_stock_balance_sheet", "get_stock_cashflow"], - "simons": ["get_stock_trade_data", "search_limit_up_stocks", "get_concept_statistics"], + "simons": [ + # 基础数据 + "get_stock_trade_data", "search_limit_up_stocks", "get_concept_statistics", + # 经典技术指标 + "get_macd_signal", "check_oscillator_status", "analyze_bollinger_bands", "calc_stop_loss_atr", + # 资金与情绪 + "analyze_market_heat", "check_volume_price_divergence", "analyze_obv_trend", + # 形态与突破 + "check_new_high_breakout", "identify_candlestick_pattern", "find_price_gaps", + # 风险与估值 + "calc_max_drawdown", "check_valuation_rank", "calc_price_zscore", + # 分钟级高阶算子 + "calc_market_profile_vpoc", "calc_realized_volatility", "analyze_buying_pressure", "calc_parkinson_volatility", + # 高级趋势分析 + "calc_bollinger_squeeze", "calc_trend_slope", "calc_hurst_exponent", "decompose_trend_simple", + # 流动性与统计 + "calc_amihud_illiquidity", "calc_price_entropy", "calc_rsi_divergence", + # 配对与策略 + "test_cointegration", "calc_kelly_position", "search_similar_kline", + # 综合分析 + "get_comprehensive_analysis", + ], "leek": [], # 韭菜不用工具 "fund_manager": ["search_china_news", "search_research_reports", "get_stock_basic_info"], } @@ -2624,61 +3216,93 @@ MEETING_ROLES = { }, "simons": { "id": "simons", - "name": "量化分析员", + "name": "量化研究员", "nickname": "西蒙斯", "role_type": "quant", "avatar": "/images/agent/simons.png", "model": "kimi-k2-thinking", "color": "#3B82F6", - "description": "中性立场,使用量化工具分析技术指标", + "description": "中性立场,使用专业量化因子分析技术指标和市场特征", "tools": ROLE_TOOLS["simons"], - "system_prompt": """你是"量化分析员"(昵称:西蒙斯),一位专业的量化交易研究员。你在投研会议中担任「技术分析师」角色,保持中性客观。 + "system_prompt": """你是"量化研究员"(昵称:西蒙斯),一位专业的量化交易研究员,擅长使用各类量化因子分析市场。你在投研会议中担任「技术分析师」角色,保持中性客观。 ## 你的分析理念 -- **数据驱动**:让数据说话,不带主观情绪 +- **因子驱动**:使用经过验证的量化因子,而非主观判断 - **概率思维**:没有确定性,只有概率和赔率 -- **趋势跟踪**:顺势而为,不与趋势作对 -- **风险量化**:用数字衡量风险,而非感觉 +- **多维验证**:从趋势、动量、波动、资金多个维度交叉验证 +- **风险量化**:用数字衡量风险,止损止盈有据可依 -## 分析框架(请按此思维链分析) +## 你可用的量化因子工具(28个) -### 第一步:收集数据 -必须先调用工具获取量化数据: -- `get_stock_trade_data`: 获取价格、成交量、涨跌幅等交易数据 -- `search_limit_up_stocks`: 了解涨停板情况,判断市场情绪 -- `get_concept_statistics`: 获取概念板块统计,判断资金流向 +### 快速综合分析(推荐首选) +- `get_comprehensive_analysis`: 一次性获取MACD、RSI、KDJ、布林带、量能、K线形态等多指标汇总 -### 第二步:技术分析维度 -基于获取的数据,进行量化分析: -1. **趋势判断**: - - 当前价格在均线系统中的位置(MA5/MA10/MA20/MA60) - - 是多头排列还是空头排列? - - 趋势强度如何? -2. **量价分析**: - - 成交量变化趋势?放量还是缩量? - - 量价配合是否健康?(上涨放量、下跌缩量为佳) - - 换手率处于什么水平? -3. **动能指标**: - - 涨跌幅在同行/板块中的排名 - - 连续上涨/下跌天数 - - 离前高/前低的距离 -4. **板块联动**: - - 所属概念板块表现如何? - - 是板块龙头还是跟风? - - 板块资金流入还是流出? +### 趋势与动量因子 +- `get_macd_signal`: MACD趋势判定(金叉/死叉/背离) +- `calc_trend_slope`: 趋势线性回归斜率(R²拟合度) +- `calc_hurst_exponent`: Hurst指数(判断趋势/震荡市场) +- `check_new_high_breakout`: 唐奇安通道突破(新高/新低信号) + +### 超买超卖因子 +- `check_oscillator_status`: KDJ/RSI超买超卖状态 +- `calc_rsi_divergence`: RSI背离检测(顶底背离) +- `calc_price_zscore`: Z-Score均值回归(乖离率标准化) + +### 波动率因子 +- `analyze_bollinger_bands`: 布林带通道分析 +- `calc_bollinger_squeeze`: 布林带挤压(变盘预警) +- `calc_stop_loss_atr`: ATR动态止损位 +- `calc_realized_volatility`: 分钟级已实现波动率 +- `calc_parkinson_volatility`: 帕金森波动率(更精确) + +### 资金流向与量价因子 +- `analyze_market_heat`: 换手率活跃度+OBV趋势 +- `analyze_obv_trend`: OBV能量潮独立分析 +- `check_volume_price_divergence`: 量价背离检测 +- `analyze_buying_pressure`: 买卖压力失衡(主力意图) +- `calc_market_profile_vpoc`: VPOC筹码峰(成交密集区) + +### 形态识别因子 +- `identify_candlestick_pattern`: K线组合形态(10+种) +- `find_price_gaps`: 跳空缺口分析 +- `search_similar_kline`: 相似K线检索(历史形态预测) + +### 风险与估值因子 +- `calc_max_drawdown`: 最大回撤+夏普比率 +- `check_valuation_rank`: PE历史百分位+PEG +- `calc_amihud_illiquidity`: Amihud流动性因子 + +### 高级分析因子 +- `decompose_trend_simple`: 趋势分解(趋势+周期+残差) +- `calc_price_entropy`: 价格熵值(市场混乱度) +- `test_cointegration`: 协整性测试(配对交易) +- `calc_kelly_position`: 凯利公式最优仓位 + +## 分析框架(请按此流程) + +### 第一步:快速扫描 +首先调用 `get_comprehensive_analysis` 获取综合技术面快照,了解整体状况。 + +### 第二步:深度分析(根据情况选择) +根据综合分析结果,选择相关因子深入分析: +- 如果趋势不明:调用 `calc_hurst_exponent` 判断市场类型,`calc_trend_slope` 量化趋势强度 +- 如果疑似顶底:调用 `calc_rsi_divergence` 检测背离,`calc_bollinger_squeeze` 看是否变盘 +- 如果量能异常:调用 `analyze_obv_trend` 看资金流向,`analyze_buying_pressure` 看主力意图 +- 如果波动加大:调用 `calc_realized_volatility` 或 `calc_parkinson_volatility` 精确测量 +- 如果要设止损:调用 `calc_stop_loss_atr` 获取ATR止损位 ### 第三步:形成结论 -给出客观的技术分析结论,必须包含: -- **趋势判断**(上涨/下跌/震荡) -- **关键数据**(引用具体的价格、成交量、涨跌幅数据) -- **技术位**(支撑位、压力位) -- **量化建议**(从概率角度给出建议) +给出量化分析结论,必须包含: +- **核心因子信号**(列出2-3个关键因子的具体数值和判断) +- **趋势判断**(上涨/下跌/震荡,并给出概率估计) +- **关键价位**(支撑位、压力位、止损位) +- **量化建议**(基于因子信号的交易建议) ## 输出要求 -- 必须基于工具返回的数据分析,用数字说话 -- 保持中性客观,不偏向多头或空头 -- 如果前面有多空分歧,可以从技术面给出参考 -- 发言控制在 200 字以内,精炼专业""" +- **必须调用工具**:至少调用1个综合分析+1-2个专项因子 +- **数据说话**:每个结论都要有具体数值支撑 +- **保持中性**:不偏向多头或空头,让因子说话 +- **简洁专业**:发言控制在 300 字以内,用专业术语但要解释关键数值含义""" }, "leek": { "id": "leek", diff --git a/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js index 4d4b22b3..766afe39 100644 --- a/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js +++ b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js @@ -138,6 +138,7 @@ const getRoleIcon = (roleType) => { * 工具名称映射 */ const TOOL_NAME_MAP = { + // 基础数据工具 search_china_news: '搜索新闻', search_research_reports: '搜索研报', get_stock_basic_info: '获取股票信息', @@ -147,6 +148,52 @@ const TOOL_NAME_MAP = { get_stock_trade_data: '获取交易数据', search_limit_up_stocks: '搜索涨停股', get_concept_statistics: '获取概念统计', + + // 经典技术指标 + get_macd_signal: 'MACD信号', + check_oscillator_status: 'RSI/KDJ指标', + analyze_bollinger_bands: '布林带分析', + calc_stop_loss_atr: 'ATR止损计算', + + // 资金与情绪 + analyze_market_heat: '市场热度分析', + check_volume_price_divergence: '量价背离检测', + analyze_obv_trend: 'OBV能量潮分析', + + // 形态与突破 + check_new_high_breakout: '新高突破检测', + identify_candlestick_pattern: 'K线形态识别', + find_price_gaps: '跳空缺口分析', + + // 风险与估值 + calc_max_drawdown: '最大回撤计算', + check_valuation_rank: 'PE估值百分位', + calc_price_zscore: 'Z-Score乖离率', + + // 分钟级高阶算子 + calc_market_profile_vpoc: 'VPOC筹码峰', + calc_realized_volatility: '已实现波动率', + analyze_buying_pressure: '买卖压力分析', + calc_parkinson_volatility: '帕金森波动率', + + // 高级趋势分析 + calc_bollinger_squeeze: '布林带挤压', + calc_trend_slope: '趋势斜率分析', + calc_hurst_exponent: 'Hurst指数', + decompose_trend_simple: '趋势分解', + + // 流动性与统计 + calc_amihud_illiquidity: 'Amihud流动性', + calc_price_entropy: '价格熵值', + calc_rsi_divergence: 'RSI背离检测', + + // 配对与策略 + test_cointegration: '协整性测试', + calc_kelly_position: '凯利仓位计算', + search_similar_kline: '相似K线检索', + + // 综合分析 + get_comprehensive_analysis: '综合技术分析', }; /** diff --git a/src/views/AgentChat/constants/meetingRoles.ts b/src/views/AgentChat/constants/meetingRoles.ts index a4c2a3d3..4f060411 100644 --- a/src/views/AgentChat/constants/meetingRoles.ts +++ b/src/views/AgentChat/constants/meetingRoles.ts @@ -141,13 +141,13 @@ export const MEETING_ROLES: Record = { }, simons: { id: 'simons', - name: '量化分析员', + name: '量化研究员', nickname: '西蒙斯', roleType: 'quant', avatar: '/images/agent/simons.png', color: '#3B82F6', gradient: 'linear(to-br, blue.400, cyan.600)', - description: '中性立场,使用量化分析工具分析技术指标', + description: '中性立场,使用28个专业量化因子分析技术指标和市场特征', icon: React.createElement(BarChart2, { className: 'w-5 h-5' }), }, leek: { diff --git a/src/views/AgentChat/constants/tools.ts b/src/views/AgentChat/constants/tools.ts index 5b14ee7e..732c6e24 100644 --- a/src/views/AgentChat/constants/tools.ts +++ b/src/views/AgentChat/constants/tools.ts @@ -17,6 +17,25 @@ import { DollarSign, Search, Users, + // 量化工具图标 + TrendingDown, + BarChart2, + Gauge, + Flame, + ArrowUpDown, + Waves, + Target, + CandlestickChart, + Sparkles, + ShieldAlert, + Calculator, + Zap, + Percent, + GitCompare, + Shuffle, + Brain, + Combine, + Scale, } from 'lucide-react'; /** @@ -29,6 +48,15 @@ export enum ToolCategory { RESEARCH = '研报路演', STOCK_DATA = '股票数据', USER_DATA = '用户数据', + // 量化分析类别 + QUANT_CLASSIC = '经典技术指标', + QUANT_VOLUME = '资金与情绪', + QUANT_PATTERN = '形态与突破', + QUANT_RISK = '风险与估值', + QUANT_MINUTE = '分钟级算子', + QUANT_TREND = '高级趋势', + QUANT_LIQUIDITY = '流动性统计', + QUANT_STRATEGY = '配对与策略', } /** @@ -203,6 +231,218 @@ export const MCP_TOOLS: MCPTool[] = [ category: ToolCategory.USER_DATA, description: '用户关注的重大事件', }, + + // ==================== 量化工具:经典技术指标 ==================== + { + id: 'get_macd_signal', + name: 'MACD信号', + icon: React.createElement(TrendingUp, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_CLASSIC, + description: 'MACD金叉/死叉、动能分析、背离检测', + }, + { + id: 'check_oscillator_status', + name: 'RSI/KDJ指标', + icon: React.createElement(Gauge, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_CLASSIC, + description: 'RSI + KDJ 超买超卖分析', + }, + { + id: 'analyze_bollinger_bands', + name: '布林带分析', + icon: React.createElement(ArrowUpDown, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_CLASSIC, + description: '带宽、位置、收窄判断', + }, + { + id: 'calc_stop_loss_atr', + name: 'ATR止损计算', + icon: React.createElement(ShieldAlert, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_CLASSIC, + description: '基于ATR的动态止损位计算', + }, + + // ==================== 量化工具:资金与情绪 ==================== + { + id: 'analyze_market_heat', + name: '市场热度分析', + icon: React.createElement(Flame, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_VOLUME, + description: '换手率热度分级 + OBV趋势', + }, + { + id: 'check_volume_price_divergence', + name: '量价背离检测', + icon: React.createElement(GitCompare, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_VOLUME, + description: '价量不匹配异常检测', + }, + { + id: 'analyze_obv_trend', + name: 'OBV能量潮', + icon: React.createElement(Waves, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_VOLUME, + description: 'OBV独立分析+背离检测', + }, + + // ==================== 量化工具:形态与突破 ==================== + { + id: 'check_new_high_breakout', + name: '新高突破检测', + icon: React.createElement(Target, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_PATTERN, + description: '20/60日唐奇安通道新高突破', + }, + { + id: 'identify_candlestick_pattern', + name: 'K线形态识别', + icon: React.createElement(CandlestickChart, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_PATTERN, + description: '10+种经典K线组合形态', + }, + { + id: 'find_price_gaps', + name: '跳空缺口分析', + icon: React.createElement(Sparkles, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_PATTERN, + description: '未回补缺口筛选与分析', + }, + + // ==================== 量化工具:风险与估值 ==================== + { + id: 'calc_max_drawdown', + name: '最大回撤计算', + icon: React.createElement(TrendingDown, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_RISK, + description: '含夏普比率的回撤分析', + }, + { + id: 'check_valuation_rank', + name: 'PE估值百分位', + icon: React.createElement(Percent, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_RISK, + description: 'PE历史百分位 + PEG修正', + }, + { + id: 'calc_price_zscore', + name: 'Z-Score乖离率', + icon: React.createElement(Calculator, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_RISK, + description: '价格偏离均值程度+回归概率', + }, + + // ==================== 量化工具:分钟级高阶算子 ==================== + { + id: 'calc_market_profile_vpoc', + name: 'VPOC筹码峰', + icon: React.createElement(BarChart2, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_MINUTE, + description: '成交量密集区分析', + }, + { + id: 'calc_realized_volatility', + name: '已实现波动率', + icon: React.createElement(Activity, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_MINUTE, + description: '分钟级RV精确波动率', + }, + { + id: 'analyze_buying_pressure', + name: '买卖压力分析', + icon: React.createElement(Scale, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_MINUTE, + description: '主力意图捕捉与压力失衡', + }, + { + id: 'calc_parkinson_volatility', + name: '帕金森波动率', + icon: React.createElement(Zap, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_MINUTE, + description: '基于High/Low的精确波动率', + }, + + // ==================== 量化工具:高级趋势分析 ==================== + { + id: 'calc_bollinger_squeeze', + name: '布林带挤压', + icon: React.createElement(Combine, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_TREND, + description: '带宽历史百分位,变盘预警', + }, + { + id: 'calc_trend_slope', + name: '趋势斜率分析', + icon: React.createElement(LineChart, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_TREND, + description: 'R²拟合度+斜率方向判断', + }, + { + id: 'calc_hurst_exponent', + name: 'Hurst指数', + icon: React.createElement(Brain, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_TREND, + description: '趋势/均值回归特征判断', + }, + { + id: 'decompose_trend_simple', + name: '趋势分解', + icon: React.createElement(Shuffle, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_TREND, + description: '趋势+周期+残差分解', + }, + + // ==================== 量化工具:流动性与统计 ==================== + { + id: 'calc_amihud_illiquidity', + name: 'Amihud流动性', + icon: React.createElement(DollarSign, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_LIQUIDITY, + description: '大单冲击成本评估', + }, + { + id: 'calc_price_entropy', + name: '价格熵值', + icon: React.createElement(Activity, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_LIQUIDITY, + description: '市场混乱度/可预测性分析', + }, + { + id: 'calc_rsi_divergence', + name: 'RSI背离检测', + icon: React.createElement(GitCompare, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_LIQUIDITY, + description: 'RSI顶底背离独立分析', + }, + + // ==================== 量化工具:配对与策略 ==================== + { + id: 'test_cointegration', + name: '协整性测试', + icon: React.createElement(Combine, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_STRATEGY, + description: '配对交易信号与对冲比率', + }, + { + id: 'calc_kelly_position', + name: '凯利仓位计算', + icon: React.createElement(Calculator, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_STRATEGY, + description: '基于胜率盈亏比的最优仓位', + }, + { + id: 'search_similar_kline', + name: '相似K线检索', + icon: React.createElement(Search, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_STRATEGY, + description: '历史形态匹配预测', + }, + { + id: 'get_comprehensive_analysis', + name: '综合技术分析', + icon: React.createElement(BarChart3, { className: 'w-4 h-4' }), + category: ToolCategory.QUANT_STRATEGY, + description: '多指标汇总分析报告', + }, ]; /** @@ -216,6 +456,15 @@ export const TOOL_CATEGORIES: Record = { [ToolCategory.RESEARCH]: MCP_TOOLS.filter((t) => t.category === ToolCategory.RESEARCH), [ToolCategory.STOCK_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.STOCK_DATA), [ToolCategory.USER_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.USER_DATA), + // 量化工具类别 + [ToolCategory.QUANT_CLASSIC]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_CLASSIC), + [ToolCategory.QUANT_VOLUME]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_VOLUME), + [ToolCategory.QUANT_PATTERN]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_PATTERN), + [ToolCategory.QUANT_RISK]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_RISK), + [ToolCategory.QUANT_MINUTE]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_MINUTE), + [ToolCategory.QUANT_TREND]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_TREND), + [ToolCategory.QUANT_LIQUIDITY]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_LIQUIDITY), + [ToolCategory.QUANT_STRATEGY]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_STRATEGY), }; /** diff --git a/test_quant_tools.py b/test_quant_tools.py new file mode 100644 index 00000000..c63984bb --- /dev/null +++ b/test_quant_tools.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +量化工具测试脚本 +测试 mcp_quant.py 中的 28 个量化因子工具是否正常工作 + +使用方法: + python test_quant_tools.py [股票代码] + +示例: + python test_quant_tools.py 600519 # 测试贵州茅台 + python test_quant_tools.py 000858 # 测试五粮液 + python test_quant_tools.py # 默认使用 600519 +""" + +import asyncio +import sys +import time +import io +from datetime import datetime, timedelta +from typing import Dict, Any, List, Tuple + +# 设置标准输出编码为 UTF-8 +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +# 导入量化工具模块 +try: + import mcp_quant as quant +except ImportError: + print("[X] Cannot import mcp_quant module, please run from project root") + sys.exit(1) + + +# 颜色输出 (Windows 兼容) +class Colors: + GREEN = '\033[92m' + RED = '\033[91m' + YELLOW = '\033[93m' + BLUE = '\033[94m' + CYAN = '\033[96m' + RESET = '\033[0m' + BOLD = '\033[1m' + + +def print_header(title: str): + """打印标题""" + print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*60}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN} {title}{Colors.RESET}") + print(f"{Colors.BOLD}{Colors.CYAN}{'='*60}{Colors.RESET}\n") + + +def print_section(title: str): + """打印分节标题""" + print(f"\n{Colors.BOLD}{Colors.BLUE}>> {title}{Colors.RESET}") + print(f"{Colors.BLUE}{'-'*50}{Colors.RESET}") + + +def print_result(name: str, success: bool, description: str = "", time_ms: float = 0): + """打印测试结果""" + status = f"{Colors.GREEN}[OK]{Colors.RESET}" if success else f"{Colors.RED}[FAIL]{Colors.RESET}" + time_str = f"{Colors.YELLOW}({time_ms:.0f}ms){Colors.RESET}" if time_ms > 0 else "" + print(f" {status} {name} {time_str}") + if description: + # 截断过长的描述 + desc = description[:80] + "..." if len(description) > 80 else description + print(f" {Colors.CYAN}-> {desc}{Colors.RESET}") + + +async def test_tool(func, *args, **kwargs) -> Tuple[bool, str, float]: + """ + 测试单个工具 + 返回: (是否成功, 描述信息, 耗时ms) + """ + start = time.time() + try: + result = await func(*args, **kwargs) + elapsed = (time.time() - start) * 1000 + + if result.get("success"): + desc = result.get("data", {}).get("description", "") + return True, desc, elapsed + else: + return False, result.get("error", "未知错误"), elapsed + except Exception as e: + elapsed = (time.time() - start) * 1000 + return False, str(e), elapsed + + +async def run_tests(stock_code: str = "600519"): + """运行所有量化工具测试""" + + print_header(f"量化工具测试 - 股票代码: {stock_code}") + + results: List[Tuple[str, bool, str, float]] = [] + + # ==================== 一、经典技术指标 ==================== + print_section("一、经典技术指标 (4个)") + + # 1. MACD信号 + success, desc, ms = await test_tool(quant.get_macd_signal, stock_code) + print_result("get_macd_signal (MACD信号)", success, desc, ms) + results.append(("get_macd_signal", success, desc, ms)) + + # 2. RSI/KDJ指标 + success, desc, ms = await test_tool(quant.check_oscillator_status, stock_code) + print_result("check_oscillator_status (RSI/KDJ)", success, desc, ms) + results.append(("check_oscillator_status", success, desc, ms)) + + # 3. 布林带分析 + success, desc, ms = await test_tool(quant.analyze_bollinger_bands, stock_code) + print_result("analyze_bollinger_bands (布林带)", success, desc, ms) + results.append(("analyze_bollinger_bands", success, desc, ms)) + + # 4. ATR止损 + success, desc, ms = await test_tool(quant.calc_stop_loss_atr, stock_code) + print_result("calc_stop_loss_atr (ATR止损)", success, desc, ms) + results.append(("calc_stop_loss_atr", success, desc, ms)) + + # ==================== 二、资金与情绪 ==================== + print_section("二、资金与情绪 (3个)") + + # 5. 市场热度 + success, desc, ms = await test_tool(quant.analyze_market_heat, stock_code) + print_result("analyze_market_heat (市场热度)", success, desc, ms) + results.append(("analyze_market_heat", success, desc, ms)) + + # 6. 量价背离 + success, desc, ms = await test_tool(quant.check_volume_price_divergence, stock_code) + print_result("check_volume_price_divergence (量价背离)", success, desc, ms) + results.append(("check_volume_price_divergence", success, desc, ms)) + + # 7. OBV能量潮 + success, desc, ms = await test_tool(quant.analyze_obv_trend, stock_code) + print_result("analyze_obv_trend (OBV能量潮)", success, desc, ms) + results.append(("analyze_obv_trend", success, desc, ms)) + + # ==================== 三、形态与突破 ==================== + print_section("三、形态与突破 (3个)") + + # 8. 新高突破 + success, desc, ms = await test_tool(quant.check_new_high_breakout, stock_code) + print_result("check_new_high_breakout (新高突破)", success, desc, ms) + results.append(("check_new_high_breakout", success, desc, ms)) + + # 9. K线形态 + success, desc, ms = await test_tool(quant.identify_candlestick_pattern, stock_code) + print_result("identify_candlestick_pattern (K线形态)", success, desc, ms) + results.append(("identify_candlestick_pattern", success, desc, ms)) + + # 10. 跳空缺口 + success, desc, ms = await test_tool(quant.find_price_gaps, stock_code) + print_result("find_price_gaps (跳空缺口)", success, desc, ms) + results.append(("find_price_gaps", success, desc, ms)) + + # ==================== 四、风险与估值 ==================== + print_section("四、风险与估值 (3个)") + + # 11. 最大回撤 + success, desc, ms = await test_tool(quant.calc_max_drawdown, stock_code) + print_result("calc_max_drawdown (最大回撤)", success, desc, ms) + results.append(("calc_max_drawdown", success, desc, ms)) + + # 12. PE估值百分位 + success, desc, ms = await test_tool(quant.check_valuation_rank, stock_code) + print_result("check_valuation_rank (PE估值)", success, desc, ms) + results.append(("check_valuation_rank", success, desc, ms)) + + # 13. Z-Score乖离率 + success, desc, ms = await test_tool(quant.calc_price_zscore, stock_code) + print_result("calc_price_zscore (Z-Score)", success, desc, ms) + results.append(("calc_price_zscore", success, desc, ms)) + + # ==================== 五、分钟级高阶算子 ==================== + print_section("五、分钟级高阶算子 (4个)") + print(f" (自动使用最近交易日数据)") + + # 14. VPOC筹码峰 + success, desc, ms = await test_tool(quant.calc_market_profile_vpoc, stock_code) + print_result("calc_market_profile_vpoc (VPOC)", success, desc, ms) + results.append(("calc_market_profile_vpoc", success, desc, ms)) + + # 15. 已实现波动率 + success, desc, ms = await test_tool(quant.calc_realized_volatility, stock_code) + print_result("calc_realized_volatility (RV波动率)", success, desc, ms) + results.append(("calc_realized_volatility", success, desc, ms)) + + # 16. 买卖压力 + success, desc, ms = await test_tool(quant.analyze_buying_pressure, stock_code) + print_result("analyze_buying_pressure (买卖压力)", success, desc, ms) + results.append(("analyze_buying_pressure", success, desc, ms)) + + # 17. 帕金森波动率 + success, desc, ms = await test_tool(quant.calc_parkinson_volatility, stock_code) + print_result("calc_parkinson_volatility (帕金森波动率)", success, desc, ms) + results.append(("calc_parkinson_volatility", success, desc, ms)) + + # ==================== 六、高级趋势分析 ==================== + print_section("六、高级趋势分析 (4个)") + + # 18. 布林带挤压 + success, desc, ms = await test_tool(quant.calc_bollinger_squeeze, stock_code) + print_result("calc_bollinger_squeeze (布林带挤压)", success, desc, ms) + results.append(("calc_bollinger_squeeze", success, desc, ms)) + + # 19. 趋势斜率 + success, desc, ms = await test_tool(quant.calc_trend_slope, stock_code) + print_result("calc_trend_slope (趋势斜率)", success, desc, ms) + results.append(("calc_trend_slope", success, desc, ms)) + + # 20. Hurst指数 + success, desc, ms = await test_tool(quant.calc_hurst_exponent, stock_code) + print_result("calc_hurst_exponent (Hurst指数)", success, desc, ms) + results.append(("calc_hurst_exponent", success, desc, ms)) + + # 21. 趋势分解 + success, desc, ms = await test_tool(quant.decompose_trend_simple, stock_code) + print_result("decompose_trend_simple (趋势分解)", success, desc, ms) + results.append(("decompose_trend_simple", success, desc, ms)) + + # ==================== 七、流动性与统计 ==================== + print_section("七、流动性与统计 (3个)") + + # 22. Amihud流动性 + success, desc, ms = await test_tool(quant.calc_amihud_illiquidity, stock_code) + print_result("calc_amihud_illiquidity (Amihud)", success, desc, ms) + results.append(("calc_amihud_illiquidity", success, desc, ms)) + + # 23. 价格熵值 + success, desc, ms = await test_tool(quant.calc_price_entropy, stock_code) + print_result("calc_price_entropy (价格熵值)", success, desc, ms) + results.append(("calc_price_entropy", success, desc, ms)) + + # 24. RSI背离 + success, desc, ms = await test_tool(quant.calc_rsi_divergence, stock_code) + print_result("calc_rsi_divergence (RSI背离)", success, desc, ms) + results.append(("calc_rsi_divergence", success, desc, ms)) + + # ==================== 八、配对与策略 ==================== + print_section("八、配对与策略 (4个)") + + # 25. 协整性测试 (需要两只股票) + success, desc, ms = await test_tool(quant.test_cointegration, stock_code, "000858") + print_result("test_cointegration (协整性测试)", success, desc, ms) + results.append(("test_cointegration", success, desc, ms)) + + # 26. 凯利仓位 (纯计算,不需要股票代码) + success, desc, ms = await test_tool(quant.calc_kelly_position, 0.55, 2.0) + print_result("calc_kelly_position (凯利仓位)", success, desc, ms) + results.append(("calc_kelly_position", success, desc, ms)) + + # 27. 相似K线检索 + success, desc, ms = await test_tool(quant.search_similar_kline, stock_code) + print_result("search_similar_kline (相似K线)", success, desc, ms) + results.append(("search_similar_kline", success, desc, ms)) + + # 28. 综合技术分析 + success, desc, ms = await test_tool(quant.get_comprehensive_analysis, stock_code) + print_result("get_comprehensive_analysis (综合分析)", success, desc, ms) + results.append(("get_comprehensive_analysis", success, desc, ms)) + + # ==================== 统计结果 ==================== + print_header("测试结果统计") + + passed = sum(1 for r in results if r[1]) + failed = sum(1 for r in results if not r[1]) + total = len(results) + total_time = sum(r[3] for r in results) + + print(f" 总计: {total} 个工具") + print(f" {Colors.GREEN}通过: {passed} 个{Colors.RESET}") + print(f" {Colors.RED}失败: {failed} 个{Colors.RESET}") + print(f" 成功率: {passed/total*100:.1f}%") + print(f" 总耗时: {total_time/1000:.2f} 秒") + print(f" 平均耗时: {total_time/total:.0f} ms/工具") + + # 打印失败的工具 + if failed > 0: + print(f"\n{Colors.RED}失败的工具:{Colors.RESET}") + for name, success, desc, ms in results: + if not success: + print(f" - {name}: {desc}") + + print() + return passed == total + + +if __name__ == "__main__": + # 获取股票代码参数 + stock_code = sys.argv[1] if len(sys.argv) > 1 else "600519" + + # 运行测试 + success = asyncio.run(run_tests(stock_code)) + + # 返回退出码 + sys.exit(0 if success else 1)