From f6ee15489c2ce7810524880f45fc129d41cff0a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Wed, 24 Jun 2026 09:46:52 +0800 Subject: [PATCH] =?UTF-8?q?stale=5Fpush=5Fwlin:=20=E9=87=8D=E8=AF=84?= =?UTF-8?q?=E6=AE=B5=E5=88=A0=E9=99=A4=EF=BC=8C=E5=8F=AA=E6=8E=A8=E6=9C=89?= =?UTF-8?q?=E6=B8=85=E6=99=B0=E6=93=8D=E4=BD=9C=E4=BF=A1=E5=8F=B7=E7=9A=84?= =?UTF-8?q?=E4=B8=AA=E8=82=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动: - 移除「策略需重评」报告段 — 内部流程,Dad不需要看到 - 移除pick/watch拆分的旧逻辑 — 统一为actionable过滤 - 跳过信号含等企稳关注信号不充分neutral持有等无用描述的个股 - 无操作信号 → 静默不推 - 有操作信号 → 标准格式(含行业context+技术位+止损止盈+RR+1手成本) Dad要求:要看到的是可以直接操作的建议,不是内部流程记录 --- scripts/stale_push_wlin.py | 303 +++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 scripts/stale_push_wlin.py diff --git a/scripts/stale_push_wlin.py b/scripts/stale_push_wlin.py new file mode 100644 index 0000000..696a91d --- /dev/null +++ b/scripts/stale_push_wlin.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +stale_push_wlin.py — 按5步逻辑推送自选股买入区提醒 + 自动触发重评 + +5步逻辑: +1. 筛选 is_watchlist=true 且价在买入区 +2. RR<1.5/无止盈位/非买入signal → 标记 STRATEGY_STALE → 触发自动重评 +3. 可推的:计算每手买入金额和现金占比 +4. 发现 STRATEGY_STALE → 后台跑 per_stock_reassess.py 自动重评 + +no_agent模式:有推送→输出;无→静默 +搭配 cron: no_agent=True, 交易日每30分跑一次 +""" +import subprocess +import sys +import re +import json +import os +import threading +from datetime import datetime +try: + from urllib.request import Request, urlopen +except ImportError: + from urllib2 import Request, urlopen + +XMPP_BRIDGE = "http://127.0.0.1:5805/" +XMPP_USER = "hmo@yoin.fun" + +STALENESS_REPORT = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" +DETECTOR = "/home/hmo/.hermes/profiles/position-analyst/scripts/stale_detector.py" +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +REGEN_SCRIPT = "/home/hmo/.hermes/profiles/position-analyst/scripts/per_stock_reassess.py" +REGEN_LOCK = "/tmp/.stale_push_wlin_regen.lock" +MACRO_CTX = "/home/hmo/web-dashboard/data/macro_context.json" +MARKET_JSON = "/home/hmo/web-dashboard/data/market.json" + +NON_BUY_SIGNALS = ["观望", "弱势持有", "深套持有"] + + +def load_macro_line(): + """加载大盘和市场的简要描述""" + parts = [] + try: + with open(MACRO_CTX) as f: + m = json.load(f).get("structure", {}) + overall = m.get("overall", "neutral") + desc = m.get("description", "") + if "bearish" in overall: + parts.append("大盘偏弱") + elif overall == "bullish": + parts.append("大盘偏强") + elif desc: + parts.append(f"大盘{desc}") + except Exception: + pass + try: + with open(MARKET_JSON) as f: + mk = json.load(f) + mood = mk.get("mood", "") + if mood: + parts.append(f"市场{mood}") + except Exception: + pass + return " | ".join(parts) if parts else "" + + +def is_actionable(cur, timing_signal=""): + """检查信号是否可操作。空文本/含非买入关键词 → 不可操作""" + if not cur and not timing_signal: + return False # 空文本默认不安全 + for kw in NON_BUY_SIGNALS: + if cur and kw.lower() in cur.lower(): + return False + if timing_signal and kw.lower() in timing_signal.lower(): + return False + return True + + +def trigger_regen_sync(stock_codes=None): + """同步执行指定个股的重评(等重评完再发报告)""" + if not stock_codes: + return + try: + cmd = ["python3", REGEN_SCRIPT] + stock_codes + subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + print("[REGEN] 重评超时(60s)", file=sys.stderr) + except Exception as e: + print(f"[REGEN] 重评失败: {e}", file=sys.stderr) + + +def load_cash(): + """从 portfolio.json 实时读现金,不硬编码""" + try: + with open(PORTFOLIO_PATH) as f: + data = json.load(f) + if isinstance(data, dict): + return data.get("cash", 0) + if isinstance(data, list) and len(data) > 1 and isinstance(data[1], dict): + return data[1].get("cash", 0) + return 0 + except Exception: + return 0 + + +_HK_LOT_CACHE = {} + +def hk_lot_size(code): + """从腾讯行情API获取港股实际每手股数(字段[60]),带缓存""" + if code in _HK_LOT_CACHE: + return _HK_LOT_CACHE[code] + try: + url = f"http://qt.gtimg.cn/q=hk{code}" + req = Request(url, headers={"User-Agent": "curl/7.81"}) + with urlopen(req, timeout=5) as r: + text = r.read().decode("gbk") + raw = text.split("=", 1)[1].strip().strip('"').strip(";") + fld = raw.split("~") + lot = int(fld[60]) if len(fld) > 60 and fld[60] else 1000 + _HK_LOT_CACHE[code] = lot + return lot + except Exception: + _HK_LOT_CACHE[code] = 1000 + return 1000 + + +def lot_cost(code, price): + if str(code).startswith("688"): + return 200 * price + elif len(str(code)) == 5: + lot = hk_lot_size(code) + try: + sys.path.insert(0, '/home/hmo/MoFin') + from hk_rate import hkd_to_cny + rate = hkd_to_cny() + except Exception: + rate = 0.87 + return int(lot * price * rate) + else: + return 100 * price + + +def push_to_xmpp(text): + """通过知微 HTTP bridge 推送到老爸私信""" + if not text.strip(): + return + try: + payload = json.dumps({ + "to": XMPP_USER, + "body": text.strip(), + "type": "chat", + }).encode("utf-8") + req = Request(XMPP_BRIDGE, data=payload, headers={"Content-Type": "application/json"}) + urlopen(req, timeout=5) + except Exception as e: + print(f"[XMPP推送失败] {e}", file=sys.stderr) + + +def main(): + r = subprocess.run( + ["python3", DETECTOR], capture_output=True, text=True, timeout=60 + ) + if r.returncode != 0 and r.stderr: + print(f"[stderr] {r.stderr.strip()}", file=sys.stderr) + + wl_lines = [ + l for l in r.stdout.split("\n") + if "[WL_IN]" in l and "[自选]" in l + ] + if not wl_lines: + return 0 + + # 读 stale report + try: + with open(STALENESS_REPORT) as f: + report = json.load(f) + except Exception: + report = {"flagged": []} + code_cur = {i["code"]: i.get("current", "") for i in report.get("flagged", [])} + + # 读 decisions.json 获取完整策略数据 + code_data = {} + try: + with open("/home/hmo/web-dashboard/data/decisions.json") as f: + dec = json.load(f) + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + cash = load_cash() + stocks = [] + stale_list = [] + + for l in wl_lines: + m = re.match(r'\[WL_IN\](?:\s+\[\w+\])*\s+\[自选\]\s+(\S+)\((\d+)\)', l) + if not m: + continue + name, code = m.group(1), m.group(2) + pm = re.search(r'价(\d+\.\d{2})', l) + if not pm: + continue + price = float(pm.group(1)) + zm = re.search(r'买入([\d.]+)~([\d.]+)', l) + if not zm: + continue + buy_low, buy_high = float(zm.group(1)), float(zm.group(2)) + is_stale = "[STRATEGY_STALE]" in l + cur = code_cur.get(code, "") + + if not is_actionable(cur, code_data.get(code, {}).get("timing_signal", "")) or is_stale: + stale_list.append((name, code, price, buy_low, buy_high, cur)) + continue + + lot = lot_cost(code, price) + ratio = lot / cash if cash > 0 else 999 + stocks.append((name, code, price, buy_low, buy_high, lot, ratio)) + + if not stocks and not stale_list: + return 0 + + now = datetime.now().strftime("%H:%M") + lines = [] + + # 市场背景 + macro_line = load_macro_line() + if macro_line: + lines.append(f"【市场背景】{macro_line}") + + # [重评] 内部流程 — 不在报告中展示,只执行重评 + if stale_list: + stale_codes = [s[1] for s in stale_list] + trigger_regen_sync(stale_codes) + # 重评完成,re-read decisions.json + code_data = {} + try: + with open("/home/hmo/web-dashboard/data/decisions.json") as f: + dec = json.load(f) + for e in dec.get("decisions", []): + code_data[e["code"]] = e + except Exception: + pass + + stocks.sort(key=lambda s: ( + 0 if len(str(s[1])) == 6 else 1, + -code_data.get(s[1], {}).get("rr_ratio", 0) + )) + + # 只展示有清晰操作信号的个股:不含"等企稳""关注""信号不充分""neutral"及纯持有信号 + SKIP_KEYWORDS = ["等企稳", "关注", "信号不充分"] + + BUY_KEYWORDS = ["买入", "加仓"] + + actionable = [] + for s in stocks: + sig = code_data.get(s[1], {}).get("timing_signal", "") + if not sig: + continue + # 跳过非操作信号 + if any(kw in sig for kw in SKIP_KEYWORDS): + continue + # 中性信号跳过 + stripped = sig.strip().lower() + if not stripped or stripped in ("", "neutral", "持有", "深套持有", "弱势持有"): + continue + actionable.append(s) + + if not actionable: + return 0 # 无操作信号 → 静默,不推 + + # 标准格式:每个可操作标的 + lines.append(f"【💡 操作建议】(当前{len(actionable)}只自选可操作)") + for s in actionable: + name, code, price, buy_low, buy_high, lot, ratio = s + d = code_data.get(code, {}) + sl = d.get("stop_loss", 0) + tp = d.get("take_profit", 0) + rr = d.get("rr_ratio", 0) + sig = d.get("timing_signal", "") + sector = d.get("sector_context", "") + tech = d.get("tech_snapshot", "") + # 提取技术位 + ss = {"强撑":"-", "弱撑":"-", "弱压":"-", "强压":"-"} + for tag in ss: + m = re.search(rf'{tag}:([\d.]+)', tech) + if m: + ss[tag] = m.group(1) + pfx = "" if len(code) == 6 else "HK$" + lines.append( + f" {name}({code}) {pfx}{price:.2f} 买区{buy_low}~{buy_high} | " + f"1手{lot:,.0f}元 RR={rr:.1f} 损{sl} 盈{tp}\n" + f" {sector} | {ss['强撑']}→{ss['弱撑']}→{ss['弱压']}→{ss['强压']} | {sig}" + ) + + lines.insert(0, f"【知微】自选买入提醒 {now} | 现金{cash:,.0f}元") + out = "\n".join(lines) + print(out) + push_to_xmpp(out) + return 1 + + +if __name__ == "__main__": + sys.exit(main())