321 lines
12 KiB
Python
321 lines
12 KiB
Python
#!/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线)"""
|
||
# 000001 = 上证指数(fetch_tx_klines 自动加 sh 前缀;不能用带前缀的 sh000001)
|
||
klines = fetch_tx_klines("000001", 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
|
||
# 2026-08-11:真实分位(全市场 PE/市值分位,替代简化值)
|
||
mcap_q = get_market_percentile(code, "mcap_total")
|
||
pe_q = get_market_percentile(code, "pe")
|
||
if mcap_q is None or pe_q is None:
|
||
continue
|
||
# 2026-08-11:真实新闻数(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, 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 get_market_percentile(code, field):
|
||
"""计算个股在全市场的分位(0-1,越小越优)。
|
||
field: mcap_total 或 pe。分位 = (比它小的数量 / 总数)。
|
||
返回 None 表示数据不可用。"""
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||
# 全市场分布
|
||
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日动量(%)。从 stock_sectors 拿行业,再算行业指数20日涨跌。
|
||
返回 None 表示无行业数据。"""
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||
sector = conn.execute(
|
||
"SELECT sector_name FROM stock_sectors 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)
|
||
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()
|