From deedf8b09e2eb2e8fa9dcca5292bec14a1a68c3d Mon Sep 17 00:00:00 2001 From: hmo Date: Tue, 11 Aug 2026 08:46:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=20predictive=5Fovers?= =?UTF-8?q?old=5Fscanner.py=E2=80=94=E2=80=94=E9=A2=84=E6=B5=8B=E8=B6=85?= =?UTF-8?q?=E8=B7=8C=E5=8F=8D=E5=BC=B9=E7=AD=96=E7=95=A5=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=99=A8(v5=E4=BF=A1=E5=8F=B7+=E5=A4=A7=E7=9B=98=E9=97=A8?= =?UTF-8?q?=E6=8E=A7+=E5=B9=82=E7=AD=89+=E6=96=B9=E6=A1=88C=E5=86=99?= =?UTF-8?q?=E5=85=A5),=E5=9F=BA=E4=BA=8Eindicators/market=5Fdata=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../predictive_oversold_scanner.py | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 deploy/profile-scripts/predictive_oversold_scanner.py diff --git a/deploy/profile-scripts/predictive_oversold_scanner.py b/deploy/profile-scripts/predictive_oversold_scanner.py new file mode 100644 index 00000000..dab2f99a --- /dev/null +++ b/deploy/profile-scripts/predictive_oversold_scanner.py @@ -0,0 +1,266 @@ +#!/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: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + row = conn.execute( + "SELECT date, above_ma20, adx, regime FROM market_regime " + "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(用上证指数K线)""" + klines = fetch_tx_klines("sh000001", datalen=80) + if not klines or len(klines) < 70: + return None + closes = [k["close"] for k in klines] + rsi_all = calc_rsi(closes) + mkt_rsi = rsi_all[-1] if rsi_all and rsi_all[-1] is not None else None + # 60日高点回撤 + hi60 = max(closes[-60:]) if len(closes) >= 60 else max(closes) + mkt_dd60 = (closes[-1] - hi60) / hi60 * 100 if hi60 > 0 else 0 + # 阴跌判定:连跌天数 + down_days = 0 + for i in range(len(closes) - 1, 0, -1): + if closes[i] < closes[i - 1]: + down_days += 1 + else: + break + return {"mkt_rsi": mkt_rsi, "mkt_dd60": mkt_dd60, "down_days": down_days} + + +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, klines): + """个股条件检查(v5):bias60 + 综合过滤""" + if not klines or len(klines) < 70: + return False, "K线不足" + closes = [k["close"] for k in klines] + ma60 = calc_ma(closes, 60) + m60 = ma60[-1] + close = closes[-1] + if not m60 or m60 <= 0: + return False, "MA60不可用" + bias60 = (close - m60) / m60 * 100 + # 核心:深度超跌 + 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) + 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) + 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 + # 拉K线 + klines = fetch_tx_klines(code) + if not klines: + continue + # 基本面(PE/市值) + fund = fetch_fundamentals(code) + if not fund: + continue + # 简化分位(部署时可从全市场分位表读取) + mcap_q = 0.1 if fund.get("mcap_total") and fund["mcap_total"] < 50 else 0.5 + pe_q = 0.1 if fund.get("pe") and fund["pe"] < 20 else 0.5 + # 简化 news3/sec_ret20(部署时可从 stock_news/sector 数据读取) + news3 = 1 if fetch_news_count(code) > 0 else 0 + sec_ret20 = -5 # 简化,部署时用真实行业动量 + + ok_s, msg_s = check_stock(code, code, mcap_q, pe_q, news3, sec_ret20, klines) + if ok_s: + name = code + write_candidate(conn, code, name, msg_s, klines[-1]["close"]) + 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 fetch_news_count(code): + """简化:查 stock_news 表近3日新闻数(部署时可完善)""" + try: + conn = sqlite3.connect(str(DB_PATH), timeout=5) + 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()