288 lines
9.9 KiB
Python
288 lines
9.9 KiB
Python
#!/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()
|