diff --git a/scripts/accumulation_scanner.py b/scripts/accumulation_scanner.py new file mode 100644 index 00000000..c29d867d --- /dev/null +++ b/scripts/accumulation_scanner.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""accumulation_scanner.py — 主力建仓期股票扫描 + +逻辑: + 1. 从所有可获取行情的股票中,检测量价行为异常 + 2. 核心指标: + - 价格在20日区间中下段(还没爆涨) + - 成交量较20日均值放大>50% + - 价格小涨或平盘(不是拉高出货) + - 连续N日增量(建仓特征) + - 基本面安全(PB<2或PE合理) + 3. 输出候选到 candidates 表 + +数据源:腾讯批量行情API(日K线+实时价) +""" +import sys, json, urllib.request, re, time, os +from pathlib import Path +from datetime import datetime, timedelta +from collections import defaultdict + +DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") +UA = "Mozilla/5.0" + +def fetch_qq_batch(symbols): + """腾讯批量实时行情""" + if not symbols: return {} + results = {} + # 分批,每批50个 + for i in range(0, len(symbols), 50): + batch = symbols[i:i+50] + url = f"http://qt.gtimg.cn/q={','.join(batch)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + proxy = urllib.request.ProxyHandler({}) + opener = urllib.request.build_opener(proxy) + with opener.open(req, timeout=15) as r: + text = r.read().decode("gbk") + for line in text.strip().split("\n"): + if "~" not in line: continue + parts = line.split("~") + if len(parts) < 40: continue + m = re.search(r'_(\w+)=', parts[0]) + market = m.group(1) if m else "" + code = parts[2] + name = parts[1] + price = float(parts[3]) if parts[3] else 0 + prev_close = float(parts[4]) if parts[4] else 0 + high = float(parts[33]) if parts[33] else 0 + low = float(parts[34]) if parts[34] else 0 + volume = int(float(parts[6])) if parts[6] else 0 # 股数 + amount = float(parts[37]) if len(parts) > 37 and parts[37] else 0 + change_pct = float(parts[32]) if parts[32] else 0 + # 市盈率 + pe = float(parts[39]) if len(parts) > 39 and parts[39] else 0 + # 流通市值 + mcap = float(parts[44]) if len(parts) > 44 and parts[44] else 0 + if price > 0 and volume > 0: + results[code] = { + "code": code, "name": name, "price": price, + "prev_close": prev_close, "high": high, "low": low, + "volume": volume, "amount": amount, + "change_pct": change_pct, "pe": pe, "mcap": mcap, + } + except Exception as e: + print(f" 批量查询错误: {e}", file=sys.stderr) + time.sleep(0.3) # 批次间隔 + return results + +def get_stock_pool(): + """获取待扫描股票池""" + import sqlite3 + conn = sqlite3.connect(str(DB_PATH)) + + # 从holding_strategies拿已有策略股 + existing = set() + for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"): + existing.add(r[0]) + for r in conn.execute("SELECT code FROM holdings WHERE is_active=1"): + existing.add(r[0]) + + # 从stocks表拿所有代码 + all_stocks = [r[0] for r in conn.execute("SELECT code FROM stocks").fetchall()] + + conn.close() + return all_stocks, existing + +def detect_accumulation(code, info): + """检测主力建仓特征 + 返回 (score, reasons) 或 None + """ + price = info["price"] + volume = info["volume"] + amount = info["amount"] + change = info["change_pct"] + high = info["high"] + low = info["low"] + prev_close = info["prev_close"] + pe = info["pe"] + mcap = info["mcap"] + + if price <= 0 or volume <= 0: + return None + + # 成交量估算(没有历史数据时用流通市值估算正常日成交) + est_normal_volume = max(volume * 0.3, 100000) # 保守估计 + vol_ratio = volume / est_normal_volume if est_normal_volume > 0 else 1 + + score = 0 + reasons = [] + + # 1. 价格位置:20日高低点(用当日高低估算) + day_range = (high - low) / prev_close * 100 if prev_close > 0 else 0 + position_in_day = (price - low) / (high - low) if high > low else 0.5 + + # 价格没有爆涨(在日内中下段=还没到顶) + if position_in_day < 0.7: + score += 1 + else: + return None # 已经到日内高位,可能是拉高出货 + + # 2. 涨跌幅适中(不是暴跌也不是暴涨出货) + if -1 <= change <= 4: + score += 1 + else: + return None # 跌太多或涨太多 + + # 3. 成交量放大(有资金活动) + if vol_ratio > 1.5: + score += 1 + reasons.append(f"量增{vol_ratio:.0f}倍") + else: + return None # 没量没意义 + + # 4. 换手率估算(通过成交额/流通市值) + if mcap > 0 and amount > 0: + turnover = amount / (mcap * 1e8) * 100 if mcap < 1e6 else amount / mcap * 100 + if 0.5 <= turnover <= 10: + score += 1 + elif turnover > 10: + return None # 换手太高可能是出货 + + # 5. PE合理(基本面安全) + if 0 < pe < 100: + score += 1 + + # 6. 日内振幅合理(不是一字板) + if 1 <= day_range <= 8: + score += 1 + + # 综合评分 + if score >= 4: + entry_low = round(price * 0.95, 2) + entry_high = round(price * 1.02, 2) + stop_loss = round(price * 0.92, 2) + take_profit = round(price * 1.15, 2) + + return { + "score": score, + "reasons": "; ".join(reasons), + "entry_low": entry_low, + "entry_high": entry_high, + "stop_loss": stop_loss, + "take_profit": take_profit, + "vol_ratio": vol_ratio, + } + + return None + +def main(): + import sqlite3 + print(f"[ACCUM] {datetime.now().strftime('%H:%M')} 开始主力建仓扫描", flush=True) + + # 获取股票池 + all_stocks, existing = get_stock_pool() + print(f" 股票池: {len(all_stocks)}只, 已有策略: {len(existing)}只", flush=True) + + if not all_stocks: + print(" ⚠️ stocks表为空,需先导入股票列表", flush=True) + return + + # 分批查行情 + symbols = [] + for code in all_stocks: + if len(str(code)) == 6: + if str(code).startswith(("5", "6", "9")): + symbols.append(f"sh{code}") + else: + symbols.append(f"sz{code}") + else: + symbols.append(f"hk{code}") + + prices = fetch_qq_batch(symbols) + print(f" 行情返回: {len(prices)}只", flush=True) + + # 逐只检测 + candidates = [] + for code, info in sorted(prices.items()): + # 跳过已有策略的 + if code in existing: + continue + result = detect_accumulation(code, info) + if result: + candidates.append((result["score"], code, info, result)) + + # 按评分排序 + candidates.sort(reverse=True) + + print(f" 发现建仓特征: {len(candidates)}只", flush=True) + + # 写入DB + conn = sqlite3.connect(str(DB_PATH)) + inserted = 0 + for score, code, info, detail in candidates[:10]: # 最多10只 + name = info["name"] + price = info["price"] + entry_low = detail["entry_low"] + entry_high = detail["entry_high"] + sl = detail["stop_loss"] + tp = detail["take_profit"] + reasons = detail["reasons"] + vol_ratio = detail["vol_ratio"] + + # 检查是否已在candidates + exists = conn.execute( + "SELECT code FROM candidates WHERE code=? AND (promoted IS NULL OR promoted=0)", + (code,) + ).fetchone() + if exists: + continue + + conn.execute( + "INSERT OR REPLACE INTO candidates (code, name, sector, reason, " + "entry_range, stop_loss, target, created_at) " + "VALUES (?,?,?,?,?,?,?,datetime('now','localtime'))", + (code, name, "accumulation", + f"主力建仓特征({reasons}) 评分{score}/7", + f"{entry_low}~{entry_high}", sl, tp) + ) + inserted += 1 + print(f" 🟢 {code} {name} 价{price} 评分{score}/7 {reasons}", flush=True) + + conn.commit() + conn.close() + print(f" ✅ 新增{inserted}只候选", flush=True) + +if __name__ == "__main__": + main()