Files
stock/stock-html/services/signal_detector.py
T
freedakgmail 9c7d7abdd4 Initial commit
2026-07-17 18:49:35 +08:00

664 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
交易信号检测模块(numpy向量化优化版)
实现7个交易信号:主升浪、日线底背离、龙抬头、真龙、短底背离、老鼠仓、反弹
信号按胜率排名:
1. 主升浪 85% - MACD零上金叉
2. 日线底背离 80% - 价格新低但MACD不新低(20日版)
3. 龙抬头 75% - SKDJ超跌金叉
4. 真龙 70% - 趋势启动确认
5. 短底背离 65% - 短周期底背离(10日版)
6. 老鼠仓 60% - 盘中急跌后快速回收
7. 反弹 55% - EMA3上穿EMA21
优化要点:
- 所有信号检测函数使用 .values numpy原生数组替代 pandas .iloc
- numpy arr[i] 访问 ~50nspandas iloc[i] 访问 ~5μs,提升 ~100x
- 底背离函数使用 np.argmin 替代 pandas idxmin
- detect_all_signals 智能跳过已是 float 的类型转换
- _check_all_signal_status 使用 numpy 数组切片替代 pandas 切片
"""
import pandas as pd
import numpy as np
from services.technical_indicators import calc_all_indicators
def detect_main_rising_wave(df, lookback=5):
"""
主升浪信号(胜率85%)— numpy优化版
条件:MACD零上金叉 —— DIF和DEA都在零轴上方,DIF从下往上穿越DEA
含义:趋势走好,进入加速拉升阶段
"""
signals = []
if len(df) < 30:
return signals
dif = df['dif'].values
dea = df['dea'].values
dates = df['date'].values
closes = df['close'].values
n = len(dif)
start = max(1, n - lookback)
for i in range(start, n):
if dif[i] > 0 and dea[i] > 0 and dif[i - 1] <= dea[i - 1] and dif[i] > dea[i]:
signals.append({
'date': str(dates[i]),
'type': 'main_rising_wave',
'name': '主升浪',
'direction': 'buy',
'strength': 85,
'price': float(closes[i]),
'description': f"MACD零上金叉: DIF={dif[i]:.3f}, DEA={dea[i]:.3f},进入加速拉升阶段",
})
return signals
def detect_daily_bottom_divergence(df, lookback=5, window=20):
"""
日线底背离信号(胜率80%)— numpy优化版
条件:价格创20日新低,但MACD的DIF未创对应新低
含义:真正跌透,迎来大级别反转
"""
signals = []
if len(df) < window + 10:
return signals
close = df['close'].values.astype(np.float64)
dif = df['dif'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(window, n - lookback)
for i in range(start, n):
window_slice = close[i - window:i + 1]
curr_price = close[i]
price_min = window_slice.min()
if curr_price > price_min * 1.01:
continue
# 当前日必须是窗口内的实际最低点(等价于 idxmin() == index[-1]
if np.argmin(window_slice) == len(window_slice) - 1:
dif_window = dif[i - window:i]
if len(dif_window) == 0:
continue
dif_at_prev_lows = dif_window.min()
curr_dif = dif[i]
if curr_dif > dif_at_prev_lows and curr_dif < 0:
signals.append({
'date': str(dates[i]),
'type': 'daily_bottom_divergence',
'name': '日线底背离',
'direction': 'buy',
'strength': 80,
'price': float(curr_price),
'description': f"价格创{window}日新低,但MACD的DIF未创新低(DIF={curr_dif:.3f}),大级别反转信号",
})
return signals
def detect_dragon_head(df, lookback=5):
"""
龙抬头信号(胜率75%)— numpy优化版
条件:SKDJ的K值从超跌区域(<20)发生金叉(K上穿D),且信号稳定
含义:短线起爆点,反弹稳定性强
"""
signals = []
if len(df) < 20:
return signals
sk = df['skdj_k'].values.astype(np.float64)
sd = df['skdj_d'].values.astype(np.float64)
dates = df['date'].values
closes = df['close'].values
n = len(sk)
start = max(2, n - lookback)
for i in range(start, n):
oversold = sk[i - 1] < 20 or sk[i] < 30
golden_cross = sk[i - 1] <= sd[i - 1] and sk[i] > sd[i]
stable = True
if i >= 3:
recent_k = sk[i - 2:i + 1]
# ddof=1 与 pandas Series.std() 保持一致
stable = np.std(recent_k, ddof=1) < 15
if oversold and golden_cross and stable:
signals.append({
'date': str(dates[i]),
'type': 'dragon_head',
'name': '龙抬头',
'direction': 'buy',
'strength': 75,
'price': float(closes[i]),
'description': f"SKDJ超跌金叉: K={sk[i]:.1f}, D={sd[i]:.1f},短线起爆点",
})
return signals
def detect_true_dragon(df, lookback=5):
"""
真龙信号(胜率70%)— numpy优化版
条件:价格突破MA20,MA5上穿MA20(金叉),MACD柱由负转正,成交量放大
含义:中期趋势刚刚启动
"""
signals = []
if len(df) < 25:
return signals
close = df['close'].values.astype(np.float64)
ma5 = df['ma5'].values.astype(np.float64)
ma20 = df['ma20'].values.astype(np.float64)
macd = df['macd'].values.astype(np.float64)
volume = df['volume'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(2, n - lookback)
for i in range(start, n):
price_above_ma20 = close[i] > ma20[i]
ma5_cross_ma20 = (ma5[i - 1] <= ma20[i - 1]) and (ma5[i] > ma20[i])
ma5_above_ma20 = ma5[i] > ma20[i]
macd_turn_positive = macd[i] > 0 and macd[i - 1] <= 0
vol_start = max(0, i - 10)
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
volume_up = volume[i] > vol_avg * 1.2 if vol_avg > 0 else False
conditions_met = sum([price_above_ma20, ma5_cross_ma20 or ma5_above_ma20, macd_turn_positive, volume_up])
if conditions_met >= 3 and price_above_ma20:
desc_parts = []
if ma5_cross_ma20:
desc_parts.append("MA5金叉MA20")
if macd_turn_positive:
desc_parts.append("MACD翻红")
if volume_up:
desc_parts.append("放量")
signals.append({
'date': str(dates[i]),
'type': 'true_dragon',
'name': '真龙',
'direction': 'buy',
'strength': 70,
'price': float(close[i]),
'description': f"趋势启动: {'+'.join(desc_parts)},中期趋势确立",
})
return signals
def detect_short_bottom_divergence(df, lookback=5, window=10):
"""
短底背离信号(胜率65%)— numpy优化版
条件:价格创10日新低,但MACD的DIF未创对应新低
含义:小级别反弹,灵敏度高但力度偏弱
"""
signals = []
if len(df) < window + 5:
return signals
close = df['close'].values.astype(np.float64)
dif = df['dif'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(window, n - lookback)
for i in range(start, n):
window_slice = close[i - window:i + 1]
curr_price = close[i]
price_min = window_slice.min()
if curr_price > price_min * 1.01:
continue
# 当前日必须是窗口内的实际最低点
if np.argmin(window_slice) == len(window_slice) - 1:
dif_window = dif[i - window:i]
if len(dif_window) == 0:
continue
dif_at_prev_lows = dif_window.min()
curr_dif = dif[i]
if curr_dif > dif_at_prev_lows:
signals.append({
'date': str(dates[i]),
'type': 'short_bottom_divergence',
'name': '短底背离',
'direction': 'buy',
'strength': 65,
'price': float(curr_price),
'description': f"价格创{window}日新低,但DIF未新低(DIF={curr_dif:.3f}),小级别反弹信号",
})
return signals
def detect_rat_trading(df, lookback=5):
"""
老鼠仓信号(胜率60%)— numpy优化版
条件:盘中急跌(最低价大幅低于开盘价),但收盘收回(收盘价接近或高于开盘价),且成交量放大
含义:主力偷偷吸筹,上涨不具备即时性
"""
signals = []
if len(df) < 10:
return signals
close = df['close'].values.astype(np.float64)
open_p = df['open'].values.astype(np.float64)
low = df['low'].values.astype(np.float64)
high = df['high'].values.astype(np.float64)
volume = df['volume'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(1, n - lookback)
for i in range(start, n):
if open_p[i] <= 0:
continue
drop_from_open = (low[i] - open_p[i]) / open_p[i] * 100
hl_diff = high[i] - low[i]
recovery = (close[i] - low[i]) / hl_diff * 100 if hl_diff != 0 else 50.0
close_vs_open = (close[i] - open_p[i]) / open_p[i] * 100
vol_start = max(0, i - 10)
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
volume_up = volume[i] > vol_avg * 1.3 if vol_avg > 0 else False
if drop_from_open < -3 and recovery > 60 and close_vs_open > -1 and volume_up:
signals.append({
'date': str(dates[i]),
'type': 'rat_trading',
'name': '老鼠仓',
'direction': 'buy',
'strength': 60,
'price': float(close[i]),
'description': f"盘中急跌{drop_from_open:.1f}%后回收{recovery:.0f}%,放量吸筹信号",
})
return signals
def detect_rebound(df, lookback=5):
"""
反弹信号(胜率55%)— numpy优化版
条件:EMA3从下向上穿越EMA21
含义:普通均线金叉,震荡市适用、熊市易现假反弹
"""
signals = []
if len(df) < 25:
return signals
ema3 = df['ema3'].values.astype(np.float64)
ema21 = df['ema21'].values.astype(np.float64)
dates = df['date'].values
closes = df['close'].values
n = len(ema3)
start = max(1, n - lookback)
for i in range(start, n):
if ema3[i - 1] <= ema21[i - 1] and ema3[i] > ema21[i]:
signals.append({
'date': str(dates[i]),
'type': 'rebound',
'name': '反弹',
'direction': 'buy',
'strength': 55,
'price': float(closes[i]),
'description': f"EMA3上穿EMA21: EMA3={ema3[i]:.2f}, EMA21={ema21[i]:.2f},均线金叉反弹",
})
return signals
def detect_all_signals(df, lookback=5):
"""
检测所有7个交易信号(优化版)
参数:
df: 包含 date, open, high, low, close, volume 列的DataFrame
lookback: 向后检测的天数(默认检测最近5天)
返回:
dict: {
'signals': [...], # 检测到的所有信号列表
'latest_signals': [...], # 最新一天的信号
'signal_summary': {...}, # 信号统计摘要
'indicators': {...} # 最新技术指标值
}
优化:智能类型转换,已是 float64 的列直接跳过
"""
required_cols = {'date', 'open', 'high', 'low', 'close', 'volume'}
if not required_cols.issubset(set(df.columns)):
missing = required_cols - set(df.columns)
return {'error': f'缺少必要列: {missing}', 'signals': [], 'latest_signals': []}
df = df.copy()
# 智能类型转换:仅在列不是 float 时才做转换(本地DB数据已是 float64,跳过)
for col in ['open', 'high', 'low', 'close', 'volume']:
if not np.issubdtype(df[col].dtype, np.floating):
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype(np.float64)
df = calc_all_indicators(df)
all_signals = []
all_signals.extend(detect_main_rising_wave(df, lookback))
all_signals.extend(detect_daily_bottom_divergence(df, lookback))
all_signals.extend(detect_dragon_head(df, lookback))
all_signals.extend(detect_true_dragon(df, lookback))
all_signals.extend(detect_short_bottom_divergence(df, lookback))
all_signals.extend(detect_rat_trading(df, lookback))
all_signals.extend(detect_rebound(df, lookback))
all_signals.sort(key=lambda x: (-x['strength'], x['date']), reverse=False)
latest_date = str(df['date'].values[-1]) if len(df) > 0 else ''
latest_signals = [s for s in all_signals if s['date'] == latest_date]
signal_summary = {
'total_signals': len(all_signals),
'latest_date': latest_date,
'latest_count': len(latest_signals),
'signal_types': {},
}
for s in all_signals:
t = s['type']
if t not in signal_summary['signal_types']:
signal_summary['signal_types'][t] = 0
signal_summary['signal_types'][t] += 1
indicators = {}
if len(df) > 0:
last = df.iloc[-1]
indicators = {
'macd': {'dif': round(float(last.get('dif', 0)), 4),
'dea': round(float(last.get('dea', 0)), 4),
'macd': round(float(last.get('macd', 0)), 4)},
'skdj': {'k': round(float(last.get('skdj_k', 0)), 2),
'd': round(float(last.get('skdj_d', 0)), 2)},
'kdj': {'k': round(float(last.get('kdj_k', 0)), 2),
'd': round(float(last.get('kdj_d', 0)), 2),
'j': round(float(last.get('kdj_j', 0)), 2)},
'ema': {'ema3': round(float(last.get('ema3', 0)), 2),
'ema21': round(float(last.get('ema21', 0)), 2)},
'ma': {'ma5': round(float(last.get('ma5', 0)), 2),
'ma10': round(float(last.get('ma10', 0)), 2),
'ma20': round(float(last.get('ma20', 0)), 2)},
}
signal_status = _check_all_signal_status(df)
return {
'signals': all_signals,
'latest_signals': latest_signals,
'signal_summary': signal_summary,
'indicators': indicators,
'signal_status': signal_status,
}
def _check_all_signal_status(df):
"""
检查7个信号的当前状态,返回每个信号的就绪程度和说明(numpy优化版)
"""
if len(df) < 30:
return []
n = len(df)
last = df.iloc[-1]
prev = df.iloc[-2] if n > 1 else last
dif = float(last.get('dif', 0))
dea = float(last.get('dea', 0))
macd_val = float(last.get('macd', 0))
prev_dif = float(prev.get('dif', 0))
prev_dea = float(prev.get('dea', 0))
sk = float(last.get('skdj_k', 50))
sd = float(last.get('skdj_d', 50))
prev_sk = float(prev.get('skdj_k', 50))
prev_sd = float(prev.get('skdj_d', 50))
ema3 = float(last.get('ema3', 0))
ema21 = float(last.get('ema21', 0))
prev_ema3 = float(prev.get('ema3', 0))
prev_ema21 = float(prev.get('ema21', 0))
close = float(last.get('close', 0))
open_p = float(last.get('open', 0))
low = float(last.get('low', 0))
high = float(last.get('high', 0))
ma5 = float(last.get('ma5', 0))
ma20 = float(last.get('ma20', 0))
status = []
# 1. 主升浪
above_zero = dif > 0 and dea > 0
golden = prev_dif <= prev_dea and dif > dea
triggered = bool(above_zero and golden)
if triggered:
desc = f"✅ 已触发!DIF={dif:.3f}>0, DEA={dea:.3f}>0, DIF上穿DEA"
elif dif > 0 and dea > 0:
desc = f"DIF和DEA均在零上,等待DIF上穿DEA(差值{dif-dea:.3f}"
elif dif > dea:
desc = f"DIF已在DEA上方,但需等待两者都转正(DIF={dif:.3f}"
else:
desc = f"DIF={dif:.3f}, DEA={dea:.3f},均在零下,距离触发较远"
status.append({
'type': 'main_rising_wave', 'name': '主升浪', 'strength': 85,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness(dif, dea, 'main_rising_wave')
})
# 2. 日线底背离 — 使用numpy数组切片替代pandas切片
close_arr = df['close'].values.astype(np.float64)
dif_arr = df['dif'].values.astype(np.float64)
w20_start = max(0, n - 21)
window_20 = close_arr[w20_start:]
price_min_20 = float(window_20.min())
dif_w20 = dif_arr[w20_start:n - 1] if n > w20_start + 1 else dif_arr[:max(0, n - 1)]
dif_min_20 = float(dif_w20.min()) if len(dif_w20) > 0 else 0.0
at_low = close <= price_min_20 * 1.01
is_actual_min = bool(np.argmin(window_20) == len(window_20) - 1)
dif_diverge = dif > dif_min_20 and dif < 0
triggered = bool(at_low and is_actual_min and dif_diverge)
if triggered:
desc = f"✅ 已触发!价格接近20日新低,DIF({dif:.3f})高于前低({dif_min_20:.3f})"
elif at_low and not is_actual_min:
desc = f"价格接近20日低位({price_min_20:.2f}),但非当前最低点"
elif at_low:
desc = f"价格在20日低位,但DIF也在低位(DIF={dif:.3f}),暂无背离"
elif dif < 0:
desc = f"DIF在零下({dif:.3f}),需等待价格下探至20日新低({price_min_20:.2f})附近"
else:
desc = f"DIF={dif:.3f}在零上,价格距20日低点{price_min_20:.2f}较远"
status.append({
'type': 'daily_bottom_divergence', 'name': '日线底背离', 'strength': 80,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_divergence(close, price_min_20, dif, dif_min_20)
})
# 3. 龙抬头
oversold = prev_sk < 20 or sk < 30
sk_cross = prev_sk <= prev_sd and sk > sd
# 稳定性检查:与检测函数一致,最近3根K值标准差 < 15
sk_arr = df['skdj_k'].values.astype(np.float64)
stable = True
if len(sk_arr) >= 3:
# ddof=1 与 pandas Series.std() 保持一致
stable = float(np.std(sk_arr[-3:], ddof=1)) < 15
triggered = bool(oversold and sk_cross and stable)
if triggered:
desc = f"✅ 已触发!SKDJ超跌金叉 K={sk:.1f}, D={sd:.1f}"
elif sk < 20:
desc = f"K={sk:.1f}在超卖区(<20),等待K上穿DK-D={sk-sd:.1f}"
elif sk < 30:
desc = f"K={sk:.1f}接近超卖区(<20),继续下探可能触发"
elif sk < 50:
desc = f"K={sk:.1f}在中位,距超卖区(K<20)还有较大距离"
else:
desc = f"K={sk:.1f}偏高,远离超卖区,不满足条件"
status.append({
'type': 'dragon_head', 'name': '龙抬头', 'strength': 75,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_dragon(sk, sd, prev_sk, prev_sd)
})
# 4. 真龙
cond_price = close > ma20
cond_ma = ma5 > ma20
cond_macd = macd_val > 0 and float(prev.get('macd', 0)) <= 0
vol_arr = df['volume'].values.astype(np.float64)
vol_avg = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
cond_vol = float(vol_arr[-1]) > vol_avg * 1.2 if vol_avg > 0 else False
met = sum([cond_price, cond_ma, cond_macd, cond_vol])
triggered = bool(met >= 3 and cond_price)
parts = []
if cond_price:
parts.append(f"价格>{ma20:.2f}(MA20)✓")
else:
parts.append(f"价格{close:.2f}<{ma20:.2f}(MA20)✗")
if cond_ma:
parts.append("MA5>MA20✓")
else:
parts.append(f"MA5({ma5:.2f})<MA20({ma20:.2f})✗")
if cond_macd:
parts.append("MACD翻红✓")
else:
parts.append(f"MACD={macd_val:.3f}")
if cond_vol:
parts.append("放量✓")
else:
parts.append("未放量✗")
if triggered:
desc = f"✅ 已触发!{met}/4条件满足: {', '.join(parts)}"
else:
desc = f"{met}/4条件(需≥3: {', '.join(parts)}"
status.append({
'type': 'true_dragon', 'name': '真龙', 'strength': 70,
'triggered': triggered, 'description': desc,
'readiness': min(100, met * 25) if cond_price else min(50, met * 15)
})
# 5. 短底背离
w10_start = max(0, n - 11)
window_10 = close_arr[w10_start:]
price_min_10 = float(window_10.min())
dif_w10 = dif_arr[w10_start:n - 1] if n > w10_start + 1 else dif_arr[:max(0, n - 1)]
dif_min_10 = float(dif_w10.min()) if len(dif_w10) > 0 else 0.0
at_low_10 = close <= price_min_10 * 1.01
is_actual_min_10 = bool(np.argmin(window_10) == len(window_10) - 1)
dif_div_10 = dif > dif_min_10
triggered = bool(at_low_10 and is_actual_min_10 and dif_div_10)
if triggered:
desc = f"✅ 已触发!价格接近10日新低,DIF({dif:.3f})高于前低({dif_min_10:.3f})"
elif at_low_10 and not is_actual_min_10:
desc = f"价格接近10日低位({price_min_10:.2f}),但非当前最低点"
elif at_low_10:
desc = f"价格在10日低位,但DIF也在低位,暂无背离"
else:
desc = f"价格距10日低点{price_min_10:.2f}尚远,等待回调"
status.append({
'type': 'short_bottom_divergence', 'name': '短底背离', 'strength': 65,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_divergence(close, price_min_10, dif, dif_min_10)
})
# 6. 老鼠仓
if open_p > 0:
drop = (low - open_p) / open_p * 100
recovery = (close - low) / (high - low) * 100 if high != low else 50
close_vs_open = (close - open_p) / open_p * 100
vol_avg_10 = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
vol_up = float(vol_arr[-1]) > vol_avg_10 * 1.3 if vol_avg_10 > 0 else False
triggered = bool(drop < -3 and recovery > 60 and close_vs_open > -1 and vol_up)
if triggered:
desc = f"✅ 已触发!盘中跌{drop:.1f}%后回收{recovery:.0f}%,放量吸筹"
else:
parts = []
if drop >= -3:
parts.append(f"盘中最大跌幅{drop:.1f}%(需<-3%)")
else:
parts.append(f"盘中跌{drop:.1f}%✓")
if recovery <= 60:
parts.append(f"回收{recovery:.0f}%(需>60%)")
else:
parts.append(f"回收{recovery:.0f}%✓")
if not vol_up:
parts.append("未放量")
desc = f"{', '.join(parts)}"
else:
triggered = False
desc = "数据异常"
status.append({
'type': 'rat_trading', 'name': '老鼠仓', 'strength': 60,
'triggered': triggered, 'description': desc,
'readiness': 0
})
# 7. 反弹
cross = prev_ema3 <= prev_ema21 and ema3 > ema21
triggered = bool(cross)
gap = ema3 - ema21
gap_pct = gap / ema21 * 100 if ema21 > 0 else 0
if triggered:
desc = f"✅ 已触发!EMA3({ema3:.2f})上穿EMA21({ema21:.2f})"
elif ema3 < ema21:
desc = f"EMA3({ema3:.2f})<EMA21({ema21:.2f}),差{abs(gap_pct):.2f}%,等待上穿"
else:
desc = f"EMA3({ema3:.2f})>EMA21({ema21:.2f}),已在上方但非刚穿越"
status.append({
'type': 'rebound', 'name': '反弹', 'strength': 55,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21)
})
return status
def _calc_readiness(dif, dea, signal_type):
if dif > 0 and dea > 0 and dif > dea:
return 100
elif dif > 0 and dea > 0:
return 70
elif dif > dea:
return 40
else:
return max(0, int(20 + dif * 100))
def _calc_readiness_divergence(close, price_min, dif, dif_min):
price_near = close <= price_min * 1.03
dif_higher = dif > dif_min
if price_near and dif_higher:
return 90
elif price_near:
return 50
elif dif_higher and dif < 0:
return 30
return 10
def _calc_readiness_dragon(sk, sd, prev_sk, prev_sd):
if sk < 20 and sk > sd and prev_sk <= prev_sd:
return 100
elif sk < 20:
return 70
elif sk < 30:
return 40
elif sk < 50:
return 20
return 5
def _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21):
if prev_ema3 <= prev_ema21 and ema3 > ema21:
return 100
gap_pct = (ema3 - ema21) / ema21 * 100 if ema21 > 0 else 0
if gap_pct > 0:
return 60
elif gap_pct > -1:
return 40
elif gap_pct > -3:
return 20
return 5