stale_push_wlin: 重评段删除,只推有清晰操作信号的个股
改动: - 移除「策略需重评」报告段 — 内部流程,Dad不需要看到 - 移除pick/watch拆分的旧逻辑 — 统一为actionable过滤 - 跳过信号含等企稳关注信号不充分neutral持有等无用描述的个股 - 无操作信号 → 静默不推 - 有操作信号 → 标准格式(含行业context+技术位+止损止盈+RR+1手成本) Dad要求:要看到的是可以直接操作的建议,不是内部流程记录
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user