334 lines
12 KiB
Python
334 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
divergence_detector.py — 跨市场背离监测器(no_agent)
|
|
|
|
每30分钟检测:
|
|
1. 科创50 vs 恒指 → 科技股超买/超卖信号
|
|
2. A/H 隐含溢价 → 内外资分歧度
|
|
3. 上证50 vs 创业板 → 风格轮动信号
|
|
4. 恒指 vs 国企指数 → 离岸市场情绪
|
|
5. 指数连涨/连跌天数 → 趋势延续/衰竭
|
|
|
|
输出:
|
|
- HIGH/medium divergence → 写入 signal_news (source=divergence_watch)
|
|
- 状态文件 macro_divergence_state.json
|
|
- no_agent: 有信号才出声
|
|
"""
|
|
import sys, json, re, datetime, os, urllib.request
|
|
|
|
from pathlib import Path
|
|
|
|
BASE = Path("/home/hmo/MoFin")
|
|
DATA = BASE / "data"
|
|
DB_PATH = DATA / "mofin.db"
|
|
STATE_PATH = DATA / "macro_divergence_state.json"
|
|
|
|
# ── 监测的指数 ──
|
|
INDEX_CODES = {
|
|
"上证指数": "sh000001",
|
|
"深证成指": "sz399001",
|
|
"创业板指": "sz399006",
|
|
"科创50": "sh000688",
|
|
"上证50": "sh000016",
|
|
"沪深300": "sh000300",
|
|
"恒生指数": "hkHSI",
|
|
"国企指数": "hkHSCEI",
|
|
}
|
|
|
|
# ── 背离阈值 ──
|
|
DIVERGENCE_STRONG = 5.0 # >5% → strong信号
|
|
DIVERGENCE_MODERATE = 3.0 # >3% → moderate信号
|
|
STREAK_DAYS = 3 # 连涨/连跌3天 → 信号
|
|
|
|
def fetch_indices():
|
|
"""获取所有指数实时数据(指数无 DB 缓存,腾讯 API 是唯一源)"""
|
|
symbols = list(INDEX_CODES.values())
|
|
url = f"http://qt.gtimg.cn/q={','.join(symbols)}"
|
|
try:
|
|
r = urllib.request.urlopen(url, timeout=10)
|
|
text = r.read().decode("gbk")
|
|
except Exception as e:
|
|
print(f"[DIVERGE] 采集失败: {e}", file=sys.stderr)
|
|
return {}
|
|
|
|
indices = {}
|
|
for line in text.strip().split("\n"):
|
|
line = line.strip()
|
|
if not line or "=" not in line:
|
|
continue
|
|
try:
|
|
sym = line.split("=", 1)[0].strip().lstrip("v_")
|
|
raw = line.split("=", 1)[1].strip().strip('"').strip(";")
|
|
fields = raw.split("~")
|
|
if len(fields) < 35:
|
|
continue
|
|
name = fields[1]
|
|
price = float(fields[3]) if fields[3].strip() else 0
|
|
close = float(fields[4]) if fields[4].strip() else 0
|
|
change_pct = ((price - close) / close * 100) if close else 0
|
|
high = float(fields[33]) if fields[33].strip() else 0
|
|
low = float(fields[34]) if fields[34].strip() else 0
|
|
timestamp = fields[30] if len(fields) > 30 else ""
|
|
indices[sym] = {
|
|
"name": name,
|
|
"price": price,
|
|
"close": close,
|
|
"change_pct": round(change_pct, 2),
|
|
"high": high,
|
|
"low": low,
|
|
"timestamp": timestamp,
|
|
}
|
|
except Exception:
|
|
continue
|
|
return indices
|
|
|
|
def load_history():
|
|
"""从MACRO_CONTEXT_LOG加载前几天的指数数据"""
|
|
try:
|
|
import sqlite3
|
|
conn = sqlite3.connect(str(DB_PATH))
|
|
rows = conn.execute(
|
|
"SELECT indices, created_at FROM macro_context_log "
|
|
"WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 5"
|
|
).fetchall()
|
|
conn.close()
|
|
history = []
|
|
for row in rows:
|
|
idx_data = json.loads(row[0]) if row[0] else {}
|
|
history.append({
|
|
"indices": idx_data,
|
|
"timestamp": row[1],
|
|
})
|
|
return history
|
|
except Exception:
|
|
return []
|
|
|
|
def detect_divergences(indices, history):
|
|
"""
|
|
检测跨市场背离信号
|
|
返回: list of signal dicts {type, level, desc, pairs}
|
|
"""
|
|
signals = []
|
|
|
|
def get(name):
|
|
"""按中文名找指数"""
|
|
sym = INDEX_CODES.get(name)
|
|
if sym and sym in indices:
|
|
return indices[sym]
|
|
# 模糊匹配
|
|
for s, idx in indices.items():
|
|
if name in idx.get("name", ""):
|
|
return idx
|
|
return None
|
|
|
|
sh_comp = get("上证指数")
|
|
sz_comp = get("深证成指")
|
|
cyb = get("创业板指")
|
|
kc = get("科创50")
|
|
sz50 = get("上证50")
|
|
hs300 = get("沪深300")
|
|
hsi = get("恒生指数")
|
|
hscei = get("国企指数")
|
|
|
|
# ── 信号1: 科创50 vs 恒指(科技股vs国际资本)──
|
|
if kc and hsi:
|
|
kc_chg = kc["change_pct"]
|
|
hsi_chg = hsi["change_pct"]
|
|
divergence = abs(kc_chg - hsi_chg)
|
|
if divergence > DIVERGENCE_STRONG:
|
|
signals.append({
|
|
"type": "a_h_tech_divergence",
|
|
"level": "high",
|
|
"desc": f"科创50({kc_chg:+.1f}%) vs 恒指({hsi_chg:+.1f}%) 背离{divergence:.1f}个百分点",
|
|
"pairs": [kc, hsi],
|
|
"direction": "risk" if kc_chg > hsi_chg else "opportunity",
|
|
})
|
|
elif divergence > DIVERGENCE_MODERATE:
|
|
signals.append({
|
|
"type": "a_h_tech_divergence",
|
|
"level": "medium",
|
|
"desc": f"科创50({kc_chg:+.1f}%) vs 恒指({hsi_chg:+.1f}%) 背离{divergence:.1f}个百分点",
|
|
"pairs": [kc, hsi],
|
|
"direction": "risk" if kc_chg > hsi_chg else "opportunity",
|
|
})
|
|
|
|
# ── 信号2: 上证50 vs 创业板(价值vs成长)──
|
|
if sz50 and cyb:
|
|
sz50_chg = sz50["change_pct"]
|
|
cyb_chg = cyb["change_pct"]
|
|
divergence = abs(sz50_chg - cyb_chg)
|
|
if divergence > DIVERGENCE_STRONG:
|
|
direction = "opportunity" if sz50_chg > cyb_chg else "risk"
|
|
signals.append({
|
|
"type": "value_growth_divergence",
|
|
"level": "high",
|
|
"desc": f"上证50({sz50_chg:+.1f}%) vs 创业板({cyb_chg:+.1f}%) 背离{divergence:.1f}个百分点",
|
|
"pairs": [sz50, cyb],
|
|
"direction": direction,
|
|
})
|
|
elif divergence > DIVERGENCE_MODERATE:
|
|
direction = "opportunity" if sz50_chg > cyb_chg else "risk"
|
|
signals.append({
|
|
"type": "value_growth_divergence",
|
|
"level": "medium",
|
|
"desc": f"上证50({sz50_chg:+.1f}%) vs 创业板({cyb_chg:+.1f}%) 背离{divergence:.1f}个百分点",
|
|
"pairs": [sz50, cyb],
|
|
"direction": direction,
|
|
})
|
|
|
|
# ── 信号3: 恒指 vs 国企指数(国际资本流向)──
|
|
if hsi and hscei:
|
|
hsi_chg = hsi["change_pct"]
|
|
hscei_chg = hscei["change_pct"]
|
|
if hsi_chg < 0 and hscei_chg < hsi_chg:
|
|
# 国企跌得比恒指多 → 外资恐慌性卖出H股
|
|
signals.append({
|
|
"type": "hk_panic_selling",
|
|
"level": "high",
|
|
"desc": f"国企指数({hscei_chg:+.1f}%)跌幅大于恒指({hsi_chg:+.1f}%)→外资恐慌抛售H股",
|
|
"pairs": [hscei, hsi],
|
|
"direction": "risk",
|
|
})
|
|
|
|
# ── 信号4: A/H 价格背离(用历史数据检测趋势延续)──
|
|
if history and len(history) >= 2:
|
|
latest = history[0]["indices"]
|
|
prev = history[1]["indices"]
|
|
|
|
# 科创50连涨检测
|
|
if kc:
|
|
kc_now = kc["change_pct"]
|
|
kc_prev = prev.get("科创50", {}).get("change_pct", 0) if "科创50" in prev else 0
|
|
kc_yest = latest.get("科创50", {}).get("change_pct", 0)
|
|
|
|
# 检测连涨
|
|
if isinstance(kc_yest, (int, float)) and isinstance(kc_prev, (int, float)):
|
|
streak = 0
|
|
if kc_yest > 0: streak += 1
|
|
if kc_prev > 0: streak += 1
|
|
if kc_now > 0: streak += 1
|
|
if streak >= STREAK_DAYS and kc_now > 0:
|
|
signals.append({
|
|
"type": "tech_streak",
|
|
"level": "medium",
|
|
"desc": f"科创50连涨{streak}日({kc_prev:+.1f}%→{kc_yest:+.1f}%→{kc_now:+.1f}%)→超买风险",
|
|
"pairs": [kc],
|
|
"direction": "risk",
|
|
})
|
|
|
|
# ── 信号5: 大盘宽度 + 季节效应 ──
|
|
now = datetime.datetime.now()
|
|
is_month_end = now.day >= 25 # 月末最后一周
|
|
is_friday = now.weekday() == 4
|
|
|
|
# 总体判断
|
|
risk_count = sum(1 for s in signals if s["direction"] == "risk")
|
|
opp_count = sum(1 for s in signals if s["direction"] == "opportunity")
|
|
|
|
# 月末+周五叠加 → 脆弱性增强
|
|
if is_month_end and is_friday and risk_count >= 2:
|
|
signals.append({
|
|
"type": "time_window_risk",
|
|
"level": "high",
|
|
"desc": f"月末({now.day}日)+周五效应+{risk_count}个风险信号叠加→市场脆弱性高",
|
|
"pairs": [],
|
|
"direction": "risk",
|
|
})
|
|
elif is_month_end and risk_count >= 1:
|
|
signals.append({
|
|
"type": "time_window_risk",
|
|
"level": "medium",
|
|
"desc": f"月末窗口({now.day}日)+{risk_count}个风险信号→注意控制仓位",
|
|
"pairs": [],
|
|
"direction": "risk",
|
|
})
|
|
|
|
return signals
|
|
|
|
def write_state(signals, indices):
|
|
"""写入状态文件,供监控 cron 消费"""
|
|
levels = [s["level"] for s in signals]
|
|
highest = "high" if "high" in levels else ("medium" if "medium" in levels else "none")
|
|
directions = [s["direction"] for s in signals]
|
|
bias = "risk" if directions.count("risk") > directions.count("opportunity") else "opportunity"
|
|
|
|
state = {
|
|
"level": highest,
|
|
"bias": bias,
|
|
"signal_count": len(signals),
|
|
"signals": signals,
|
|
"indices": {k: v["change_pct"] for k, v in indices.items()},
|
|
"created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
STATE_PATH.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
|
|
|
def write_to_signal_news(signals):
|
|
"""HIGH signal → signal_news"""
|
|
if not signals:
|
|
return
|
|
import sqlite3
|
|
conn = sqlite3.connect(str(DB_PATH))
|
|
|
|
high_signals = [s for s in signals if s["level"] == "high"]
|
|
if high_signals:
|
|
for s in high_signals:
|
|
conn.execute(
|
|
"INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(0, "跨市场", f"背离-{s['direction'].upper()}", s["desc"], json.dumps(s, ensure_ascii=False), "", "divergence_watch")
|
|
)
|
|
conn.commit()
|
|
|
|
med_signals = [s for s in signals if s["level"] == "medium"]
|
|
if med_signals:
|
|
summary = "\n".join([s["desc"] for s in med_signals])
|
|
conn.execute(
|
|
"INSERT INTO signal_news (signal_id, sector, overall_sentiment, summary, key_articles, searched_stocks, source) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(0, "跨市场", "背离-MEDIUM", summary, json.dumps(med_signals, ensure_ascii=False), "", "divergence_watch")
|
|
)
|
|
conn.commit()
|
|
|
|
conn.close()
|
|
|
|
def print_report(signals, indices):
|
|
"""no_agent 输出"""
|
|
if not signals:
|
|
return # SILENT
|
|
|
|
# 有信号就输出
|
|
high = [s for s in signals if s["level"] == "high"]
|
|
med = [s for s in signals if s["level"] == "medium"]
|
|
|
|
lines = []
|
|
if high:
|
|
for s in high:
|
|
icon = "\u26a0\ufe0f" if s["direction"] == "risk" else "\u2b06\ufe0f"
|
|
lines.append(f"[DIVERGE] {icon} {s['level'].upper()} {s['type']}: {s['desc']}")
|
|
if med:
|
|
for s in med[:3]: # 最多3条
|
|
icon = "\u26a0\ufe0f" if s["direction"] == "risk" else "\u2b06\ufe0f"
|
|
lines.append(f"[DIVERGE] {icon} {s['level'].upper()} {s['type']}: {s['desc']}")
|
|
|
|
# 输出指数全景
|
|
idx_line = " | ".join([f"{n}: {indices.get(s, {}).get('change_pct', 0):+.1f}%" for n, s in INDEX_CODES.items() if s in indices])
|
|
lines.append(f"[DIVERGE] 指数全景: {idx_line}")
|
|
|
|
print("\n".join(lines))
|
|
|
|
def main():
|
|
indices = fetch_indices()
|
|
if not indices:
|
|
return
|
|
|
|
history = load_history()
|
|
signals = detect_divergences(indices, history)
|
|
|
|
# 写入 state + signal_news
|
|
write_state(signals, indices)
|
|
write_to_signal_news(signals)
|
|
|
|
# no_agent 输出
|
|
print_report(signals, indices)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|