feat: 推荐原因丰富化——因子值→带数字/方向的中文描述
This commit is contained in:
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
+4
-3
@@ -212,6 +212,7 @@ class QuantEngine:
|
|||||||
top_q = None
|
top_q = None
|
||||||
|
|
||||||
recs = []
|
recs = []
|
||||||
|
from src.quant.reason import build_reason
|
||||||
for code, score, _z in scored[:top_n]:
|
for code, score, _z in scored[:top_n]:
|
||||||
k = klines.get(code)
|
k = klines.get(code)
|
||||||
if k is None or len(k) < 6:
|
if k is None or len(k) < 6:
|
||||||
@@ -219,8 +220,8 @@ class QuantEngine:
|
|||||||
price = float(k['close'].iloc[-1])
|
price = float(k['close'].iloc[-1])
|
||||||
name = names.get(code, code)
|
name = names.get(code, code)
|
||||||
star = 1 + int(round((score - smin) / spread * 4)) # 1..5
|
star = 1 + int(round((score - smin) / spread * 4)) # 1..5
|
||||||
contrib = sorted(_z.items(), key=lambda kv: -abs(kv[1]))[:2]
|
rich_reason, _detail = build_reason(code, k)
|
||||||
reason = '、'.join('{}突出'.format(f) for f, _ in contrib)
|
reason = rich_reason or '多因子综合打分靠前'
|
||||||
recs.append({
|
recs.append({
|
||||||
'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
'ts': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
'code': code, 'name': name,
|
'code': code, 'name': name,
|
||||||
@@ -230,7 +231,7 @@ class QuantEngine:
|
|||||||
'buy_high': round(price * 1.005, 2),
|
'buy_high': round(price * 1.005, 2),
|
||||||
'expected_return_pct': None, # 由回测基准填充
|
'expected_return_pct': None, # 由回测基准填充
|
||||||
'stars': max(1, min(5, star)),
|
'stars': max(1, min(5, star)),
|
||||||
'reason': reason or '多因子综合打分靠前',
|
'reason': reason,
|
||||||
})
|
})
|
||||||
# 预计收益(逐股):个股同状态条件 5 日收益中位数(真实历史统计)
|
# 预计收益(逐股):个股同状态条件 5 日收益中位数(真实历史统计)
|
||||||
# 组合层面中位数作为参考基准附在卡片级
|
# 组合层面中位数作为参考基准附在卡片级
|
||||||
|
|||||||
@@ -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), {}
|
||||||
Reference in New Issue
Block a user