#!/usr/bin/env python3 """accumulation_scanner.py — 主力建仓期股票扫描 逻辑: 1. 从所有可获取行情的股票中,检测量价行为异常 2. 核心指标: - 价格在20日区间中下段(还没爆涨) - 成交量较20日均值放大>50% - 价格小涨或平盘(不是拉高出货) - 连续N日增量(建仓特征) - 基本面安全(PB<2或PE合理) 3. 输出候选到 candidates 表 数据源:腾讯批量行情API(日K线+实时价) """ import sys, json, os from pathlib import Path from datetime import datetime, timedelta from collections import defaultdict # ── 消息通道统一路由(broadcast/xmpp by delivery) ── try: from messenger import install_stdio_hook as _msh _msh() except Exception: pass DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") UA = "Mozilla/5.0" def fetch_qq_batch(symbols): """批量行情:读 DB live_prices + stock_daily(2026-08-26 分层铁律:消费层不直连腾讯API)""" if not symbols: return {} import sqlite3 results = {} # 提取纯代码(去掉 sh/sz/hk 前缀,港股5位如 00700) codes = [] for s in symbols: c = str(s).strip() for pfx in ("sh", "sz", "hk"): if c.startswith(pfx): c = c[len(pfx):] break if c and c not in codes: codes.append(c) if not codes: return results try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 实时价:一次查 ph = ",".join("?" * len(codes)) price_rows = conn.execute( f"SELECT code, price, change_pct FROM live_prices WHERE code IN ({ph})", codes ).fetchall() prices = {r[0]: (r[1], r[2]) for r in price_rows} # 名称 name_rows = conn.execute( f"SELECT code, name FROM stocks WHERE code IN ({ph})", codes ).fetchall() names = {r[0]: r[1] or "" for r in name_rows} # 最近日K(昨收/高低/量/额) sd = {} for c in codes: row = conn.execute( "SELECT close, high, low, volume, amount FROM stock_daily " "WHERE code=? ORDER BY date DESC LIMIT 1", (c,) ).fetchone() if row: sd[c] = row conn.close() for code in codes: if code not in prices: continue price = prices[code][0] change_pct = prices[code][1] or 0 row = sd.get(code) if not price or price <= 0: continue if not row or not row[3]: # 无日K或无量 → 跳过(中性) continue prev_close = row[0] or 0 high = row[1] or 0 low = row[2] or 0 volume = int(row[3]) if row[3] else 0 # 股数 amount = row[4] or 0 # DB 无 PE/流通市值 → 中性 0(detect 中 pe/mcap 条件不贡献分) results[code] = { "code": code, "name": names.get(code, code), "price": price, "prev_close": prev_close, "high": high, "low": low, "volume": volume, "amount": amount, "change_pct": change_pct, "pe": 0, "mcap": 0, } except Exception as e: print(f" 批量查询错误: {e}", file=sys.stderr) return results def get_stock_pool(): """获取待扫描股票池""" import sqlite3 conn = sqlite3.connect(str(DB_PATH)) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 # 从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 # 综合评分(2026-07-24 老爸:入门闸 4→5,减少陪跑噪音灌入 candidates) if score >= 5: 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) # 2026-08-17: max_hold 补定义(此前无持有期)——分温区扫描 trend_down 20d 最优 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)) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 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 # UPSERT:只更新扫描器自有列,保留 score_*/pass_*/promoted/log/zhiwei_* 等计算列 # (原 INSERT OR REPLACE 会把 promoted=1 的行整行替换,计算列全部清零——2026-07-23 审计发现) _mid_v = (entry_low + entry_high) / 2 _rr_v = round((tp - _mid_v) / (_mid_v - sl), 2) if _mid_v > sl > 0 else 0 conn.execute( "INSERT INTO candidates (code, name, sector, reason, " "entry_range, stop_loss, target, rr, source_strategy, created_at) " "VALUES (?,?,?,?,?,?,?,?,?,datetime('now','localtime')) " "ON CONFLICT(code) DO UPDATE SET " "name=excluded.name, sector=excluded.sector, reason=excluded.reason, " "entry_range=excluded.entry_range, stop_loss=excluded.stop_loss, target=excluded.target, rr=excluded.rr, source_strategy=excluded.source_strategy", (code, name, "accumulation", f"主力建仓特征({reasons}) 评分{score}/7", f"{entry_low}~{entry_high}", sl, tp, _rr_v, "accumulation") ) 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()