Files
MoFin/deploy/profile-scripts/predictive_oversold_scanner.py
T

343 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""predictive_oversold_scanner.py — 预测超跌反弹策略实盘扫描器(v5)
策略:预测超跌反弹 v5docs/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.pycalc_ma/calc_rsi+ market_data.pyfetch_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_dd602026-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: # 单日限5step39
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_emEM体系权威映射,
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()