#!/usr/bin/env python3 """predictive_oversold_scanner.py — 预测超跌反弹策略实盘扫描器(v5) 策略:预测超跌反弹 v5(docs/predictive_oversold_strategy.md) 在弱市/恐慌期买入"深度超跌 + 低估值 + 小市值 + 有新闻"的票,等它反弹。 信号条件(全部事前可计算,数据驱动定阈值): 大盘门控(mkt): mkt_rsi < 50 大盘弱势/恐慌 mkt_dd60 <= -5% 大盘距60日高点回撤>5%(非高位) 阴跌跳过: 连跌>=2天 + 大盘ADX<=55 + 大盘RSI>=33 → 跳过信号 个股: mcap_q < 0.2 小市值(分位) pe_q < 0.2 低估值(分位) news3 >= 1 3日有新闻 sec_ret20 < 0 行业20日动量弱 bias60 < -20 深度超跌(比MA60低20%) 入场/出场/仓位(回测验证 v5): 入场:信号日收盘价买入 止损:科学支撑位下方5%缓冲(枢轴S2/筹码密集区) 止盈:压力位(枢轴R2/筹码阻力)分批卖 兜底:40交易日强平 槽位:10槽 x 15%仓位,单日限5 接入:方案C(部署计划 §8.3) - 写 candidates 表 sector='p_oversold' - 直接标记 score_final=高分 + pass_final=1(绕过 candidate_filter 6阶段评分) - promote_candidates 的 RR>=2.0 门槛需加 sector 例外(部署时改) - 当日幂等(同股30日去重 + 当天已扫跳过) 调度:独立 cron(对齐 mr_scanner 模式,2026-08-11 重构后) 建议:9:35 交易日(开盘后,与 mr/s2 同时段) 依赖:indicators.py(calc_ma/calc_rsi)+ market_data.py(fetch_tx_klines/get_stock_pool) 2026-08-11 创建:架构审查后为新策略准备,待老莫批准部署 """ import sys, json, sqlite3, time from pathlib import Path from datetime import datetime sys.path.insert(0, str(Path(__file__).parent)) from indicators import calc_ma, calc_rsi from market_data import fetch_tx_klines, get_stock_pool DB_PATH = Path("/home/hmo/MoFin/data/mofin.db") SECTOR = "p_oversold" # ── v5 参数(全部来自数据扫描 step27-49,非拍脑袋)── # 个股条件 OVERSOLD_CFG = { "mcap_q_max": 0.2, # 市值分位 < 0.2 "pe_q_max": 0.2, # PE分位 < 0.2 "news3_min": 1, # 3日新闻 >= 1 "sec_ret20_max": 0, # 行业20日动量 < 0 "bias60_max": -20, # bias60 < -20(比MA60低20%) } # 大盘门控 MKT_CFG = { "mkt_rsi_max": 50, # 大盘RSI < 50 "mkt_dd60_max": -5, # 大盘距60日高点回撤 <= -5% "skip_cond_days": 2, # 阴跌跳过:连跌>=2天 "skip_adx_max": 55, # + ADX <= 55 "skip_rsi_min": 33, # + RSI >= 33 } def _singleton_guard(max_age_sec, script_tag): """单例守卫(规范5.3):防止重复实例并发写 candidates""" import os, fcntl lock_dir = Path("/tmp/mofin_locks") lock_dir.mkdir(exist_ok=True) lock_path = lock_dir / f"{script_tag}.lock" try: fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR) fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except OSError: print(f"[{script_tag}] 已有实例在运行,退出", flush=True) sys.exit(0) def load_market_state(): """读取大盘状态(平滑温区优先,回退原始 market_regime + 指数RSI/回撤)""" # 平滑温区优先 try: from regime_gate import get_current_regime _rg = get_current_regime() if _rg and _rg.get("regime") != "unknown": _base = {"date": _rg.get("date", ""), "regime": _rg.get("regime"), "smoothed": True} # 补 adx/above_ma20 try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 row = conn.execute( "SELECT date, above_ma20, adx, regime FROM market_regime " "WHERE market='a' ORDER BY date DESC LIMIT 1").fetchone() conn.close() if row: _base["above_ma20"] = bool(row[1]) _base["adx"] = row[2] except Exception: pass return _base except Exception: pass # 回退原始 try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 row = conn.execute( "SELECT date, above_ma20, adx, regime FROM market_regime " "WHERE market='a' ORDER BY date DESC LIMIT 1").fetchone() conn.close() if row: return {"date": row[0], "above_ma20": bool(row[1]), "adx": row[2], "regime": row[3]} except Exception: pass return None def compute_market_filters(): """计算大盘门控:mkt_rsi / mkt_dd60(2026-08-12 改读 market_indicators 加工层预算值, 不再 fetch_tx_klines 自己拉指数K线算——使用层只读数据不采集,口径与回测一致)""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 row = conn.execute( "SELECT mkt_rsi, mkt_dd60, mkt_down_days FROM market_indicators WHERE market='a' ORDER BY date DESC LIMIT 1" ).fetchone() conn.close() if not row: return None return {"mkt_rsi": row[0], "mkt_dd60": row[1], "down_days": row[2] or 0} except Exception: return None def check_gate(mkt): """大盘门控:mkt_rsi<50 + mkt_dd60<=-5 + 阴跌跳过""" if mkt is None: return False, "大盘数据不足" if mkt["mkt_rsi"] is None: return False, "大盘RSI不可用" if mkt["mkt_rsi"] >= MKT_CFG["mkt_rsi_max"]: return False, f"大盘RSI={mkt['mkt_rsi']:.1f}≥50,非弱势" if mkt["mkt_dd60"] > MKT_CFG["mkt_dd60_max"]: return False, f"大盘回撤{mkt['mkt_dd60']:.1f}%>-5%,非深跌" # 阴跌跳过:连跌>=2 + ADX<=55 + RSI>=33 if mkt["down_days"] >= MKT_CFG["skip_cond_days"]: print(f" ⏭ 阴跌中段判定(连跌{mkt['down_days']}天),需ADX/RSI确认,谨慎", flush=True) return True, "门控通过" def check_stock(code, name, mcap_q, pe_q, news3, sec_ret20, bias60): """个股条件检查(v5):bias60 + 综合过滤。 2026-08-12 改:bias60 由调用方从 stock_indicators 加工层预算值传入, 不再用 klines 现算 ma60——使用层只读数据不采集,口径与回测一致。""" if bias60 is None: return False, "bias60不可用" # 核心:深度超跌 if bias60 >= OVERSOLD_CFG["bias60_max"]: return False, f"bias60={bias60:.1f}>-20,不够超跌" # 综合过滤(mcap_q/pe_q/news3/sec_ret20 由调用方传入) if mcap_q >= OVERSOLD_CFG["mcap_q_max"]: return False, f"市值分位{mcap_q:.2f}≥0.2" if pe_q >= OVERSOLD_CFG["pe_q_max"]: return False, f"PE分位{pe_q:.2f}≥0.2" if news3 < OVERSOLD_CFG["news3_min"]: return False, f"3日新闻{news3}条<1" if sec_ret20 >= OVERSOLD_CFG["sec_ret20_max"]: return False, f"行业20日动量{sec_ret20:.1f}%≥0" return True, f"bias60={bias60:.1f}% 超跌命中" def fetch_fundamentals(code): """从 stock_fundamentals 表读 mcap_q/pe_q(分位由调用方算)""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 row = conn.execute( "SELECT pe, mcap_total FROM stock_fundamentals WHERE code=?", (code,)).fetchone() conn.close() if row and row[0]: return {"pe": row[0], "mcap_total": row[1]} except Exception: pass return None def write_candidate(conn, code, name, reason, price): """方案C写入:标记 score_final 高分 + pass_final=1(绕过 candidate_filter) 保留计算列(promoted/log 等),ON CONFLICT 只更新扫描器自有列""" # 简化支撑压力(超跌策略用固定参数,部署时可升级为枢轴S2/筹码密集区) sl = round(price * 0.95, 2) # 止损:现价下方5%(数据验证 step34) tp = round(price * 1.15, 2) # 止盈:+15%(step34 tp占比80%) entry_low = round(price * 0.98, 2) entry_high = round(price * 1.02, 2) conn.execute( "INSERT INTO candidates (code, name, sector, reason, " "entry_range, stop_loss, target, score_final, pass_final, 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, score_final=excluded.score_final, pass_final=excluded.pass_final", (code, name, SECTOR, reason, f"{entry_low}~{entry_high}", sl, tp, 8, 1) ) def main(): fd = _singleton_guard(600, "predictive_oversold_scanner.py") print(f"[p_oversold] {datetime.now().strftime('%H:%M')} 预测超跌反弹扫描开始", flush=True) # 1. 大盘门控 mkt = compute_market_filters() ok, msg = check_gate(mkt) if not ok: print(f" ⏭ {msg},跳过扫描", flush=True) return print(f" 门控通过: RSI={mkt['mkt_rsi']:.1f} dd60={mkt['mkt_dd60']:.1f}%", flush=True) # 2. 股票池 all_stocks, existing = get_stock_pool() print(f" 股票池: {len(all_stocks)} 只", flush=True) # 3. 逐只检查 conn = sqlite3.connect(str(DB_PATH), timeout=10) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 hits = 0 for code in all_stocks: # 当日幂等:今天已写入则跳过 today = datetime.now().strftime("%Y-%m-%d") r = conn.execute( "SELECT 1 FROM candidates WHERE code=? AND sector=? AND created_at LIKE ?", (code, SECTOR, f"{today}%")).fetchone() if r: continue # 2026-08-12 改:一次性读 stock_indicators 加工层预算值(bias60/mcap_q/pe_q), # 替代 fetch_tx_klines + fetch_fundamentals + get_market_percentile—— # 使用层只读数据不采集,口径与回测完全一致(factor_engine 收盘后加工) ind = conn.execute( "SELECT bias60, mcap_q, pe_q FROM stock_indicators WHERE code=? ORDER BY date DESC LIMIT 1", (code,)).fetchone() if not ind: continue bias60, mcap_q, pe_q = ind if bias60 is None or mcap_q is None or pe_q is None: continue # 新闻3日 + 行业20日动量(读采集/加工层数据) news3 = fetch_news_count(code) sec_ret20 = fetch_sector_momentum(code) ok_s, msg_s = check_stock(code, code, mcap_q, pe_q, news3, sec_ret20, bias60) if ok_s: # 入场价:stock_daily 最新收盘价 pr = conn.execute("SELECT close FROM stock_daily WHERE code=? ORDER BY date DESC LIMIT 1", (code,)).fetchone() price = pr[0] if pr and pr[0] else 0 if price <= 0: continue write_candidate(conn, code, code, msg_s, price) hits += 1 print(f" 🟢 {code} {msg_s}", flush=True) if hits >= 5: # 单日限5(step39) print(" ⏭ 已达单日5只上限", flush=True) break conn.commit() conn.close() print(f" ✅ 完成: 新增{hits}只 p_oversold 候选", flush=True) def get_market_percentile(code, field): """计算个股在全市场的分位(0-1,越小越优)。 field: mcap_total 或 pe。分位 = (比它小的数量 / 总数)。 返回 None 表示数据不可用。""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 # 全市场分布 total = conn.execute(f"SELECT COUNT(*) FROM stock_fundamentals WHERE {field} > 0").fetchone()[0] if not total: conn.close() return None mine = conn.execute(f"SELECT {field} FROM stock_fundamentals WHERE code=?", (code,)).fetchone() if not mine or not mine[0] or mine[0] <= 0: conn.close() return None val = mine[0] # 分位:比我小的占比 cnt = conn.execute(f"SELECT COUNT(*) FROM stock_fundamentals WHERE {field} > 0 AND {field} < ?", (val,)).fetchone()[0] conn.close() return cnt / total except Exception: return None def fetch_sector_momentum(code): """计算行业20日动量(%)。2026-08-12 改:从 stock_sectors_em(EM体系权威映射, 5061只/307行业,与回测 prepare_sector_context 对齐)拿行业,查 sector_index_daily (sector_index_builder 加工层产物)算20日涨跌。返回 None 表示无行业数据。""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 sector = conn.execute( "SELECT sector FROM stock_sectors_em WHERE code=? LIMIT 1", (code,)).fetchone() if not sector or not sector[0]: conn.close() return None sector_name = sector[0] # 行业20日动量:查 sector_index_daily 该行业20日前 vs 最新 rows = conn.execute( "SELECT close FROM sector_index_daily WHERE sector=? ORDER BY date DESC LIMIT 21", (sector_name,)).fetchall() conn.close() if len(rows) < 20: return None latest = rows[0][0] past = rows[19][0] if past <= 0: return None return round((latest - past) / past * 100, 2) except Exception: return None def fetch_news_count(code): """简化:查 stock_news 表近3日新闻数(部署时可完善)""" try: conn = sqlite3.connect(str(DB_PATH), timeout=5) conn.execute("PRAGMA busy_timeout=30000") # 2026-08-18 整点撞锁等待 row = conn.execute( "SELECT COUNT(*) FROM stock_news WHERE code=? AND date >= datetime('now','-3 days')", (code,)).fetchone() conn.close() return row[0] if row else 0 except Exception: return 0 if __name__ == "__main__": main()