Files

322 lines
15 KiB
Python
Raw Permalink 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
"""promote_candidates.py — 自动提拔候选股入自选(修复版 2026-08-10
修复:每次运行限处理 N 个新候选(按评分降序优中选优),
加单例守卫防重叠,缩短重评子进程超时,加总时长护栏。
"""
import sys, json, sqlite3, time
from pathlib import Path
from datetime import datetime
# ── 消息通道统一路由(broadcast/xmpp by delivery) ──
try:
from messenger import install_stdio_hook as _msh
_msh()
except Exception:
pass
import time as _time
def _exec_retry(conn, sql, params=(), retries=5, wait=2):
"""DB写操作自动重试(database is locked时指数退避)"""
for attempt in range(retries):
try:
return conn.execute(sql, params)
except sqlite3.OperationalError as e:
if "locked" in str(e).lower() and attempt < retries - 1:
_time.sleep(wait * (attempt + 1))
print(f" [DB锁重试{attempt+1}/{retries}] {wait*(attempt+1)}s", flush=True)
else:
raise
return None
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
# 2026-08-10 修复参数:控制单次运行时长,防 hermes 600s 超时杀进程
MAX_NEW_PER_RUN = 10 # 每次最多提拔 10 个新候选(评分降序优中选优)
REASSESS_TIMEOUT = 300 # 重评子进程超时(2026-08-18 老莫:120太短,LLM重评90-120s不够,至少300
TOTAL_TIME_BUDGET = 500 # 总时长护栏(hermes child_timeout=600,留100s余量)
START_TIME = time.time()
def over_budget():
return (time.time() - START_TIME) > TOTAL_TIME_BUDGET
def main():
# ── 单例守卫(2026-05-31 铁律:常驻/定时脚本必须防重复)──
import fcntl
lock_path = "/tmp/promote_candidates.lock"
lock_f = open(lock_path, "w")
try:
fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("[PROMOTE] 已有实例在运行,退出")
return
conn = sqlite3.connect(str(DB_PATH), timeout=30)
conn.execute("PRAGMA busy_timeout=30000")
conn.row_factory = sqlite3.Row
# 读未提拔候选(按评分降序)
rows = conn.execute("""
SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target, c.sector, c.rr, c.source_strategy
FROM candidates c
WHERE (c.promoted IS NULL OR c.promoted = 0)
AND (c.dropped IS NULL OR c.dropped = 0)
AND c.score_final >= 7
ORDER BY c.score_final DESC
""").fetchall()
if not rows:
print("[PROMOTE] 无待提拔候选")
conn.close()
return
print(f"[PROMOTE] 待处理 {len(rows)} 个候选(本次最多 {MAX_NEW_PER_RUN} 个新提拔)", flush=True)
promoted = 0
processed = 0
newly_promoted_codes = []
for r in rows:
if over_budget():
print(f"[PROMOTE] 已达时长护栏 {TOTAL_TIME_BUDGET}s,停止本批(已处理{processed}", flush=True)
break
# 已有"在自选"的候选(上次已插但被标记或重复)快速跳过,不计入 new 配额
code = str(r[0])
# 2026-08-11:读取候选 sector(用于 p_oversold RR 例外)
cand_sector = str(r[6] or "").strip() if len(r) > 6 else ""
source_strategy = str(r[8] or "").strip() if len(r) > 8 else ""
# 2026-08-18 fallbacksource_strategy 为 unknown/空时用 candidates.sector 作来源
# (自选退出回归候选 sector 是正确策略标签,但 source_strategy 可能 inherited unknown
if not source_strategy or source_strategy == "unknown":
source_strategy = cand_sector if cand_sector and cand_sector != "unknown" else "unknown" ""
exists = conn.execute(
"SELECT id FROM holding_strategies WHERE code=? AND status='active'",
(code,)
).fetchone()
if exists:
_exec_retry(conn, "UPDATE candidates SET promoted=1 WHERE code=?", (code,))
processed += 1
continue
# 已达本批新提拔配额 → 停止(剩余的留到下批,按评分降序保证优中选优)
if promoted >= MAX_NEW_PER_RUN:
print(f"[PROMOTE] 本批已达 {MAX_NEW_PER_RUN} 个新提拔配额,停止(剩余留待下批)", flush=True)
break
name = r[1] or code
score = r[2] or 0
entry_range = r[3] or ""
sl = r[4] or 0
tp = r[5] or 0
# 解析 entry_range
el, eh = 0, 0
if "~" in entry_range:
parts = entry_range.split("~")
try:
el = float(parts[0])
eh = float(parts[1])
except: pass
# 验证实时价格
_price = 0.0
try:
import subprocess, json as _jj
_r = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code],
capture_output=True, text=True, timeout=10)
_q = _jj.loads(_r.stdout)
_price = float(_q.get("price", 0))
if _price <= 0:
print(f" ⏭ {code} {name} 无实时价格,跳过")
processed += 1
continue
except Exception as _e:
print(f" ⏭ {code} {name} 价格获取失败({_e}),跳过")
processed += 1
continue
# ── 2026-08-17 老莫规范:RR 由策略算,promote 只卡阈值,不重锚定 ──
# 原逻辑用 technical_analysis 近20日低点当止损(暴跌插针)→ RR 从策略的2.3崩到0.82,
# promote 管道自 08-05 死12天。现改为读候选自带 c.rr(策略负责计算)。
if "ST" in (name or "").upper():
print(f" ⏭ {code} {name} ST股,不入自选")
processed += 1
continue
_rr = r[7] # c.rr(策略自带)
if _rr is None:
print(f" ⏭ {code} {name} 策略未提供RRscanner需在INSERT时写candidates.rr),跳过")
processed += 1
continue
# 超跌类策略豁免 RR 门槛(超跌反弹 RR 天然 <2,用策略自带参数即可,同 p_oversold 先例)
if _rr < 1.0 and cand_sector not in ("p_oversold", "b_td1_v3", "b_td1"):
print(f" ⏭ {code} {name} RR={_rr:.2f}<1.0(保底门槛),不入自选")
processed += 1
continue
# 用候选自带参数(不再重锚定)
_parts = (r[3] or "").split("~")
if len(_parts) >= 2:
try:
el = float(_parts[0]); eh = float(_parts[1])
except Exception:
el = eh = 0
else:
el = eh = 0
sl = r[4] or 0
tp = r[5] or 0
if not (el > 0 and eh > el and sl > 0 and tp > 0):
print(f" ⏭ {code} {name} 候选参数无效(区{el}~{eh}{sl}{tp}),跳过")
processed += 1
continue
# ── B. entry_range 偏离现价>20% → 数据异常,先重评校准(LLM算正确区间)→ 用正确区间入库 ──
_dev = 0
try:
import subprocess, json as _jj
_r2 = subprocess.run(["python3", "/home/hmo/.hermes/profiles/position-analyst/scripts/stock_quote.py", code],
capture_output=True, text=True, timeout=10)
_q = _jj.loads(_r2.stdout)
_cur_price = float(_q.get("price", 0))
if _cur_price > 0:
_dev = abs((_cur_price - (el + eh) / 2) / _cur_price) * 100
except Exception:
pass
if _dev > 20.0:
# 数据异常:先重评(LLM算正确区间),成功则用正确区间,失败则跳过
print(f" ⚠️ {code} {name} 买入区{el}~{eh} 偏离现价{_dev:.0f}%(数据异常),先重评校准...", flush=True)
try:
conn.commit() # 2026-08-25 放锁:LLM子进程60-100s,持写锁等=全库撞锁(开盘连环撞锁主嫌疑)
_r = subprocess.run(["/home/hmo/MoFin/venv/bin/python",
"/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", code],
capture_output=True, text=True, timeout=REASSESS_TIMEOUT)
if _r.returncode == 0:
# 重评后读 holding 里 LLM 校准的 entry_low/highbatch_reassess A 层已覆盖)
_cur2 = conn.execute(
"SELECT entry_low, entry_high FROM holding_strategies WHERE code=? AND status='active'",
(code,)).fetchone()
if _cur2 and _cur2[0] and _cur2[1]:
el, eh = float(_cur2[0]), float(_cur2[1])
print(f" ✅ 重评校准后买入区 {el}~{eh}", flush=True)
else:
print(f" ⏭ 重评后无有效买入区,跳过", flush=True)
processed += 1
continue
else:
print(f" ⏭ 重评失败(区间异常且无法校准),跳过", flush=True)
processed += 1
continue
except Exception as _e:
print(f" ⏭ 重评异常({_e}),跳过", flush=True)
processed += 1
continue
# ── C. 可执行性检查:止损距离≥2%、止盈空间≥3%、修正风报比(含成本)≥2 ──
_price_est = (el + eh) / 2
_stop_dist = (_price_est - sl) / _price_est * 100 if sl > 0 else 0
_tp_space = (tp - _price_est) / _price_est * 100 if tp > 0 else 0
# 修正风报比:考虑交易成本(双边~0.2%)
_COST = 0.002
_rr_exec = ((_tp_space / 100 - _COST) / (_stop_dist / 100 + _COST)) if _stop_dist > 0 else 0
if _stop_dist < 2.0:
print(f" ⏭ {code} {name} 止损距离{_stop_dist:.1f}%<2%(不可执行,贴死技术位),跳过")
processed += 1
continue
if _tp_space < 3.0:
print(f" ⏭ {code} {name} 止盈空间{_tp_space:.1f}%<3%(不可执行,覆盖不了成本),跳过")
processed += 1
continue
if _rr_exec < 2.0:
print(f" ⏭ {code} {name} 修正风报比{_rr_exec:.1f}<2.0(含交易成本后不达标的阿猫阿狗),跳过")
processed += 1
continue
# 构建策略
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
timing_signal = "关注"
price_est = (el + eh) / 2 if el > 0 and eh > 0 else 0
reason_text = []
if el > 0: reason_text.append(f"买{el}~{eh}")
if sl > 0: reason_text.append(f"损{sl}")
if tp > 0: reason_text.append(f"盈{tp}")
if sl > 0 and tp > 0 and price_est > 0:
rr = (tp - price_est) / (price_est - sl) if (price_est - sl) > 0 else 0
reason_text.append(f"RR{rr:.1f}")
reason_text.append(f"评分{score}")
action = " | ".join(reason_text) if reason_text else f"市场扫描发现(评分{score})"
# 2026-08-25 老莫:撤容量闸——不设上限、不淘汰(原60硬顶+RR置换与重评驱动的
# auto_exit 退出机制重复且脱节,单点闸被多入口绕过已失效)。进管进、出管出:
# 出清唯一权威 = watchlist_auto_exit(盘前,12维信号驱动)。本脚本只负责合格即提拔。
wl_count = conn.execute(
"SELECT COUNT(*) FROM holding_strategies WHERE status='active' AND decision_type='自选策略'").fetchone()[0]
cur = conn.execute("""
INSERT OR IGNORE INTO holding_strategies
(code, name, price, entry_low, entry_high, stop_loss, take_profit,
timing_signal, action, decision_type, strategy_type, status,
rr_ratio, stock_category, strategy_name, created_at, updated_at,
sector_context, quality_check)
VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan',
'active',?,'关注',?,?,?,'', 'pending')
""", (code, name, _price, el, eh, sl, tp, timing_signal, action, _rr, source_strategy, now, now))
newly_added = cur.rowcount > 0
_exec_retry(conn, "UPDATE candidates SET promoted=1 WHERE code=?", (code,))
if newly_added:
promoted += 1
processed += 1
newly_promoted_codes.append(code)
print(f" ✅ {code} {name} 评分{score} → 已加入自选({timing_signal})", flush=True)
else:
print(f" ⏭ {code} {name} 已在自选策略中,标记promoted", flush=True)
processed += 1
# 触发全量重评(仅新插入)
if newly_added:
try:
import subprocess as _sp
conn.commit() # 2026-08-25 放锁:LLM子进程期间不持写锁
r = _sp.run(["python3", "/home/hmo/MoFin/deploy/profile-scripts/per_stock_reassess.py", code],
capture_output=True, text=True, timeout=REASSESS_TIMEOUT)
if r.returncode == 0:
print(f" 重评完成", flush=True)
else:
print(f" 重评失败: {r.stderr.strip()[:100]}", flush=True)
except Exception as e:
print(f" 重评异常: {e}", flush=True)
conn.commit()
print(f"\n[PROMOTE] 本次处理{processed}个,提拔{promoted}只(剩余留待下批)", flush=True)
# 推XMPP2026-08-25 老莫新规则:①多票堆一条长文截断不行→每只单独一条
# ②无操作建议(观望/关注)不推XMPP只归档broadcast
if promoted > 0:
try:
codes = ",".join("?" * len(newly_promoted_codes))
promoted_rows = conn.execute(
f"SELECT code, name, timing_signal "
f"FROM holding_strategies WHERE status='active' AND code IN ({codes})",
tuple(newly_promoted_codes)
).fetchall() if newly_promoted_codes else []
_ACTION_SIG = ("买入", "可买入", "可加仓") # 有操作建议的信号
pushed = 0
for code, name, tsig in promoted_rows:
if any(s in str(tsig or "") for s in _ACTION_SIG):
# 有操作建议:走标准推荐通道(质量门禁+12维完整分析,单只单条不堆叠)
from mofin_db import push_recommend_alert
if push_recommend_alert(conn, code):
pushed += 1
print(f"[PROMOTE] XMPP: {pushed}只有操作建议已推送;其余{len(promoted_rows)-pushed}只观望/关注仅归档broadcast(不进池通知不打扰)", flush=True)
except Exception as e:
print(f"[PROMOTE] XMPP 通知失败: {e}", flush=True)
# 2026-08-18 老莫:重评未就绪的必须补推——后台 detach 补跑重评+补推
if promoted > 0:
try:
_bf = subprocess.Popen(
["/home/hmo/MoFin/venv/bin/python", "/home/hmo/MoFin/deploy/profile-scripts/promote_reassess_backfill.py", "90"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
print(f"[PROMOTE] 后台补推已启动(PID={_bf.pid}, 90s后补跑未就绪重评并补推)", flush=True)
except Exception as e:
print(f"[PROMOTE] 后台补推启动失败: {e}", flush=True)
conn.close()
if __name__ == "__main__":
main()