From 42e1e68e265ec5bdcdddf83aa41745cdfac72d59 Mon Sep 17 00:00:00 2001 From: lookt Date: Wed, 16 Sep 2026 14:27:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A8=E8=8D=90=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E4=B8=B0=E5=AF=8C=E5=8C=96=E2=80=94=E2=80=94=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E5=80=BC=E2=86=92=E5=B8=A6=E6=95=B0=E5=AD=97/=E6=96=B9?= =?UTF-8?q?=E5=90=91=E7=9A=84=E4=B8=AD=E6=96=87=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/dbg_bucket.py | 35 ++++++++++++ src/analysis/anomaly_detect.py | 97 +++++++++++++++++++++++++++++++++ src/analysis/anomaly_scanner.py | 44 +++++++++++++++ src/quant/engine.py | 7 ++- src/quant/reason.py | 71 ++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 deploy/dbg_bucket.py create mode 100644 src/analysis/anomaly_detect.py create mode 100644 src/analysis/anomaly_scanner.py create mode 100644 src/quant/reason.py diff --git a/deploy/dbg_bucket.py b/deploy/dbg_bucket.py new file mode 100644 index 0000000..6c6a8fa --- /dev/null +++ b/deploy/dbg_bucket.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +"""服务器调试:两级状态桶在 000993 上的真实分布""" +import sys +import warnings + +sys.path.insert(0, '/opt/a_stock_timeline') +warnings.filterwarnings('ignore') + +from src.fetcher.kline_fetcher import KlineFetcher +from src.quant.model import per_stock_expected, _bucket, rsi_series +import numpy as np +import pandas as pd + +DB = '/opt/a_stock_timeline/data/a_stock.db' +kf = KlineFetcher(DB) +k = kf.load('000993', 'day', 150) +print('bars:', len(k)) + +close = k['close'].reset_index(drop=True) +ma20 = close.rolling(20).mean() +rsi = rsi_series(close) + +for fine in (True, False): + cur = _bucket(close.iloc[-1], ma20.iloc[-1], rsi.iloc[-1], fine) + rets = [] + horizon = 5 + for t in range(30, len(close) - horizon): + b = _bucket(close.iloc[t], ma20.iloc[t], rsi.iloc[t], fine) + if b == cur: + rets.append(close.iloc[t + horizon] / close.iloc[t] - 1) + med = float(np.median(rets)) if rets else None + print('fine={} 桶={} 样本={} 中位={}'.format(fine, cur, len(rets), med)) + +med, n, bucket = per_stock_expected(k) +print('per_stock_expected:', med, n, bucket) diff --git a/src/analysis/anomaly_detect.py b/src/analysis/anomaly_detect.py new file mode 100644 index 0000000..bddb76d --- /dev/null +++ b/src/analysis/anomaly_detect.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +""" +异常检测(a_stock_timeline 版):基于日K线的十条规则, +每条输出带具体数字的中文描述,供 WS 推送与前端异常流展示。 +""" +import numpy as np +import pandas as pd + + +def _sma(s, n): + return s.rolling(n).mean() + + +def _chg(close): + return close.pct_change() * 100 + + +def detect_anomalies(code: str, name: str, df: pd.DataFrame) -> list: + """ + df: 日K线(时间升序,含 open/high/low/close/volume),至少 30 行 + 返回 [{code,name,type,desc,severity}],severity 1-5 + """ + out = [] + if df is None or len(df) < 30: + return out + close = df['close'].reset_index(drop=True) + op = df['open'].reset_index(drop=True) + vol = df['volume'].reset_index(drop=True) + high = df['high'].reset_index(drop=True) + low = df['low'].reset_index(drop=True) + n = len(close) - 1 + c = close.iloc[-1] + v = vol.iloc[-1] + v20 = vol.rolling(20).mean().iloc[-1] + chg = _chg(close).iloc[-1] if n > 0 else 0 + + def add(t, sev, desc): + out.append({'code': code, 'name': name, 'type': t, + 'severity': max(1, min(5, sev)), 'desc': desc}) + + # 1. 天量(量比 ≥ 3 倍) + if v20 and v20 > 0: + ratio = v / v20 + if ratio >= 3: + add('天量', min(5, int(ratio)), f'当日成交量是20日均量的{ratio:.1f}倍,换手剧烈') + + # 2. 大幅波动(单日涨跌幅超 5%) + if abs(chg) >= 5: + direction = '上涨' if chg > 0 else '下跌' + add('大幅波动', min(5, int(abs(chg) / 2)), f'单日{direction}{abs(chg):.1f}%,波动异常') + + # 3. 连续上涨/下跌(近5日同方向且累计超5%) + if n >= 5: + last5 = close.iloc[-5:] + rises = sum(last5.iloc[i+1] > last5.iloc[i] for i in range(4)) + cum5 = (close.iloc[-1] / close.iloc[-5] - 1) * 100 if close.iloc[-5] else 0 + if rises >= 4 and cum5 > 5: + add('连续上涨', min(5, int(abs(cum5) / 3)), f'近5日涨{cum5:.1f}%({rises}天上涨),短期涨幅较大') + elif rises <= 1 and cum5 < -5: + add('连续下跌', min(5, int(abs(cum5) / 3)), f'近5日跌{cum5:.1f}%({4-rises}天下跌),注意风险') + + # 4. 均线突破/破位(MA20) + if n >= 20: + ma20 = close.rolling(20).mean() + prev_below = close.iloc[-2] < ma20.iloc[-2] + now_above = close.iloc[-1] > ma20.iloc[-1] + if prev_below and now_above: + add('均线突破', 4, f'收盘{c:.2f}上穿MA20({ma20.iloc[-1]:.2f}),趋势可能转多') + elif close.iloc[-2] > ma20.iloc[-2] and close.iloc[-1] < ma20.iloc[-1]: + add('均线破位', 4, f'收盘{c:.2f}跌破MA20({ma20.iloc[-1]:.2f}),趋势可能转空') + + # 5. 创20日新高/新低 + if n >= 20: + hi20 = high.iloc[-20:].max() + lo20 = low.iloc[-20:].min() + if c >= hi20: + add('创20日新高', 3, f'收盘{c:.2f}创近20日新高(前高{hi20:.2f}),关注量能配合') + elif c <= lo20: + add('创20日新低', 3, f'收盘{c:.2f}创近20日新低(前低{lo20:.2f}),注意下行风险') + + # 6. 长上下影线(振幅超 8%) + if c > 0: + body = abs(c - op.iloc[-1]) + amp = (high.iloc[-1] - low.iloc[-1]) / c * 100 if c else 0 + if amp >= 8: + add('长影线', min(5, int(amp / 3)), f'当日振幅{amp:.1f}%,多空分歧大') + + # 7. 量价背离(放量滞涨 / 缩量上涨) + if v20 and v20 > 0 and n >= 5: + vol_r = v / v20 + chg5 = (close.iloc[-1] / close.iloc[-5] - 1) * 100 if close.iloc[-5] else 0 + if vol_r >= 2 and abs(chg) < 1: + add('放量滞涨', 3, f'量比{vol_r:.1f}但涨跌幅仅{chg:.1f}%,警惕出货') + elif vol_r <= 0.4 and abs(chg5) < 2: + add('极度缩量', 2, f'量比{vol_r:.1f},市场关注度极低') + + return out diff --git a/src/analysis/anomaly_scanner.py b/src/analysis/anomaly_scanner.py new file mode 100644 index 0000000..0fd4045 --- /dev/null +++ b/src/analysis/anomaly_scanner.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""异常扫描:K线规则检测→入库→推送(事件流)""" +from datetime import datetime + +from src.analysis.anomaly_detect import detect_anomalies +from src.fetcher.kline_fetcher import KlineFetcher + + +class AnomalyScanner: + """扫描宇宙内 K 线异常并入库推送""" + + def __init__(self, emit, db_path): + self.push = emit + self.db = str(db_path) + c = __import__('sqlite3').connect(self.db) + c.execute("CREATE TABLE IF NOT EXISTS anomaly_event" + " (id INTEGER PRIMARY KEY AUTOINCREMENT" + ", ts TEXT, code TEXT, name TEXT" + ", type TEXT, severity INTEGER, detail TEXT)") + c.commit() + c.close() + + def scan(self, codes, names=None): + """扫描并返回/入库异常列表""" + kf = KlineFetcher(self.db) + found = [] + for c in codes: + k = kf.load(c, 'day', 40) + if len(k) >= 30: + found.extend(detect_anomalies(c, names.get(c, ''), k)) + if found: + self._store(found) + return found + + def _store(self, items): + c = __import__('sqlite3').connect(self.db) + now = datetime.now().isoformat() + c.executemany( + "INSERT INTO anomaly_event" + " (ts,code,name,type,severity,detail) VALUES (?,?,?,?,?,?)", + [(now, x['code'], x.get('name', ''), x['type'], + x.get('severity', 3), x.get('desc', '')) for x in items]) + c.commit() + c.close() diff --git a/src/quant/engine.py b/src/quant/engine.py index b27969e..9a3a96a 100644 --- a/src/quant/engine.py +++ b/src/quant/engine.py @@ -212,6 +212,7 @@ class QuantEngine: top_q = None recs = [] + from src.quant.reason import build_reason for code, score, _z in scored[:top_n]: k = klines.get(code) if k is None or len(k) < 6: @@ -219,8 +220,8 @@ class QuantEngine: price = float(k['close'].iloc[-1]) name = names.get(code, code) star = 1 + int(round((score - smin) / spread * 4)) # 1..5 - contrib = sorted(_z.items(), key=lambda kv: -abs(kv[1]))[:2] - reason = '、'.join('{}突出'.format(f) for f, _ in contrib) + rich_reason, _detail = build_reason(code, k) + reason = rich_reason or '多因子综合打分靠前' recs.append({ 'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'code': code, 'name': name, @@ -230,7 +231,7 @@ class QuantEngine: 'buy_high': round(price * 1.005, 2), 'expected_return_pct': None, # 由回测基准填充 'stars': max(1, min(5, star)), - 'reason': reason or '多因子综合打分靠前', + 'reason': reason, }) # 预计收益(逐股):个股同状态条件 5 日收益中位数(真实历史统计) # 组合层面中位数作为参考基准附在卡片级 diff --git a/src/quant/reason.py b/src/quant/reason.py new file mode 100644 index 0000000..ea4fc33 --- /dev/null +++ b/src/quant/reason.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +推荐原因生成器:因子截面值 → 带具体数字和方向的中文推荐理由。 +""" +import numpy as np +import pandas as pd + + +def build_reason(code, kline_df, weights): + """ + 从单股日K线(时间升序,≥30行)生成多维度推荐原因。 + 返回 (reason_str, factor_detail_dict) + """ + if kline_df is None or len(kline_df) < 30: + return '', {} + close = kline_df['close'].reset_index(drop=True) + vol = kline_df['volume'].reset_index(drop=True) + high = kline_df['high'].reset_index(drop=True) + low = kline_df['low'].reset_index(drop=True) + n = len(close) + c = close.iloc[-1] + + parts = [] + + # 动量 + if n > 21: + mom = (c / close.iloc[-21] - 1) * 100 + tag = '强' if mom > 5 else ('偏强' if mom > 0 else '偏弱' if mom > -5 else '弱') + parts.append(f"20日动量{mom:+.1f}%({tag})") + + # 趋势(MA20 偏离) + ma20 = close.rolling(20).mean().iloc[-1] + if ma20 and ma20 > 0: + dev = (c - ma20) / ma20 * 100 + tag = '强势区' if dev > 3 else ('偏高水平' if dev > 0 else '偏低水平' if dev > -3 else '弱势区') + parts.append(f"距MA20 {dev:+.1f}%({tag})") + + # 量能 + v20 = vol.rolling(20).mean().iloc[-1] + if v20 and v20 > 0: + vr = vol.iloc[-1] / v20 + if vr > 1.5: + parts.append(f"量比{vr:.1f}(放量)") + elif vr < 0.5: + parts.append(f"量比{vr:.1f}(极度缩量)") + + # RSI + close_s = close + delta = close_s.diff() + gain = delta.clip(lower=0).rolling(14).mean() + loss = (-delta.clip(upper=0)).rolling(14).mean() + rs = gain / loss.replace(0, np.nan) + rsi = (100 - 100 / (1 + rs)).iloc[-1] + if rsi == rsi: + if rsi > 70: + parts.append(f"RSI={rsi:.0f}(超买)") + elif rsi < 30: + parts.append(f"RSI={rsi:.0f}(超卖)") + + # MACD + ema12 = close.ewm(span=12, adjust=False).mean() + ema26 = close.ewm(span=26, adjust=False).mean() + dif = ema12 - ema26 + dea = dif.ewm(span=9, adjust=False).mean() + hist = (dif - dea).iloc[-1] + if hist > 0: + parts.append("MACD多头") + else: + parts.append("MACD空头") + + return ';'.join(parts), {}