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