xiaowei-system/scripts/stock_portfolio.py

360 lines
14 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
"""
小唯每日组合信号 — 全市场MA20扫描
==================================
每日扫描 ⭐关注 股票,给出综合评分和最优选择
覆盖: 五粮液/贵州茅台/泸州老窖/洋河股份/招商银行/中国平安/平安银行
用法:
python3 stock_portfolio.py # 打印组合信号
python3 stock_portfolio.py --push # 推送飞书
"""
import json, sys, urllib.request, re
from datetime import datetime
from pathlib import Path
OUTPUT = Path.home() / ".hermes" / "stock_backtest"
OUTPUT.mkdir(exist_ok=True)
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/446db983-e392-4d2c-bfb8-f9060e5df3ad"
def load_backtest_stats(code):
"""读取回测结果,用于金叉时的置信度参考"""
result_file = OUTPUT / f"ma20_result_{code}.json"
if not result_file.exists():
return None
try:
import ast
with open(result_file) as f:
d = json.load(f)
return d
except Exception:
return None
# MA20参数
MA_PERIOD = 20
# ⭐关注股票列表2026-08-01 行业扫描扩充)
# 动量正行业优先纳入:新能源/科技/煤炭/半导体/证券
WATCHED_STOCKS = [
# 原白酒(弱势观察)
("000858", "五粮液", "白酒"),
("600519", "贵州茅台", "白酒"),
("000568", "泸州老窖", "白酒"),
("002304", "洋河股份", "白酒"),
# 原金融
("600036", "招商银行", "银行"),
("601318", "中国平安", "保险"),
("000001", "平安银行", "银行"),
# 新增动量正行业2026-08-01 行业扫描)
("300750", "宁德时代", "新能源"),
("002594", "比亚迪", "新能源"),
("002415", "海康威视", "科技"),
("000063", "中兴通讯", "科技"),
("601088", "中国神华", "煤炭"),
("600188", "兖矿能源", "煤炭"),
("688981", "中芯国际", "半导体"),
("600030", "中信证券", "证券"),
("000333", "美的集团", "家电"),
# 新增:有色/石油2026-08-02 扩充扫描)
("601899", "紫金矿业", "有色"),
("601857", "中国石油", "石油"),
]
# 行业动量过滤2026-08-01 行业扫描更新)
# 用 vibe-trading 学术因子引擎扫描 16 行业后确认:
# 🟢 动量正行业:煤炭+27.7% / 半导体+22.8% / 科技+16.5% / 新能源+12.9% / 军工+5.2%
# 🔴 真弱势(动量负+年化负+Sharpe负白酒-33% / 医药-21.8% / 通信-22.2% / 汽车-36.8% / 地产-46.5%
# 弱势行业金叉硬拦截,不推荐不开仓
WEAK_TREND_INDUSTRIES = {"白酒", "医药", "通信", "汽车", "地产"}
def get_url(url, timeout=8):
"""统一用curl避免urllib在hermes/scripts目录下异常"""
import subprocess, shlex, os
env = dict(os.environ)
for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]:
env.pop(k, None)
cmd = f"curl -s --max-time {timeout} --compressed {shlex.quote(url)}"
try:
r = subprocess.run(cmd, shell=True, capture_output=True, timeout=timeout + 2, env=env)
return r.stdout.decode("utf-8", errors="ignore")
except Exception:
return ""
def get_url_gbk(url, timeout=8):
"""GBK编码的请求腾讯行情"""
import subprocess, shlex, os
env = dict(os.environ)
for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]:
env.pop(k, None)
cmd = f"curl -s --max-time {timeout} --compressed {shlex.quote(url)}"
try:
r = subprocess.run(cmd, shell=True, capture_output=True, timeout=timeout + 2, env=env)
return r.stdout.decode("gbk", errors="ignore")
except Exception:
return ""
def get_kline_data(code, min_needed=60):
"""获取足够的日K线数据前复权"""
import subprocess, shlex, os, re, json
from datetime import timedelta
market = "sz" if code.startswith(("00", "30")) else "sh"
full = f"{market}{code}"
# 实际交易日只有2/3请求足够多的数据确保有min_needed个
end_d = datetime.now()
start_d = end_d - timedelta(days=int(min_needed * 1.8))
start_str = start_d.strftime("%Y-%m-%d")
end_str = end_d.strftime("%Y-%m-%d")
# 请求足够多的条数
url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get"
f"?_var=kline_dayqfq&param={full},day,{start_str},{end_str},300,qfq")
env = dict(os.environ)
for k in ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"]:
env.pop(k, None)
cmd = f"curl -s --max-time 8 --compressed {shlex.quote(url)}"
try:
r = subprocess.run(cmd, shell=True, capture_output=True, timeout=10, env=env)
text = r.stdout.decode("utf-8", errors="ignore")
except Exception:
return []
if not text or len(text) < 50:
print(f"[DEBUG] {code} empty response, len={len(text)}")
return []
try:
json_str = re.sub(r"^[^=]+=", "", text, count=1)
data = json.loads(json_str)
raw = data.get("data", {}).get(full, {}).get("qfqday", [])
if not raw:
raw = data.get("data", {}).get(full, {}).get("day", [])
if raw:
raw = sorted(raw, key=lambda x: x[0])[-min_needed:]
return raw
except Exception:
return []
def validate_klines(raw, min_needed=25):
"""验证K线数据质量返回有效数据或空列表"""
if not raw or len(raw) < min_needed:
return []
# 检查日期连续性(允许周末/节假日跳跃)
dates = [r[0] for r in raw]
prices = [float(r[2]) for r in raw if len(r) > 2]
if len(prices) < min_needed:
return []
# 检查价格合理性过滤价格为0或异常值
valid = [r for r in raw if len(r) > 5 and float(r[2]) > 0]
return valid[-min_needed:] if len(valid) >= min_needed else []
def analyze_ma20(code, name, industry):
"""分析单只股票的MA20状态 — 含数据质量验证"""
raw_klines = get_kline_data(code, MA_PERIOD + 10)
klines = validate_klines(raw_klines, MA_PERIOD + 2)
if len(klines) < MA_PERIOD + 2:
return None
try:
closes = [float(k[2]) for k in klines] # index 2 = close
ma20 = sum(closes[-MA_PERIOD:]) / MA_PERIOD
current_price = closes[-1]
prev_price = closes[-2]
above_ma = current_price > ma20
golden_cross = prev_price <= ma20 < current_price # 今天刚上穿
dead_cross = prev_price >= ma20 > current_price # 今天刚下穿
chg_pct = (current_price - closes[0]) / closes[0] * 100 if closes else 0
return {
"code": code,
"name": name,
"industry": industry,
"price": current_price,
"ma20": ma20,
"above_ma": above_ma,
"golden_cross": golden_cross,
"dead_cross": dead_cross,
"chg_pct": chg_pct,
"diff_pct": (current_price - ma20) / ma20 * 100,
}
except Exception:
return None
def get_macro():
"""宏观指标:上证 + 原油 + 沪深300大盘风险代理"""
result = {}
# 上证
text = get_url_gbk("https://qt.gtimg.cn/q=sh000001")
parts = text.split("~")
if len(parts) > 5:
try:
p = float(parts[3])
y = float(parts[4])
result["上证"] = {"price": p, "change": (p-y)/y*100}
except:
pass
# 原油
text = get_url_gbk("https://qt.gtimg.cn/q=hf_OIL")
parts = text.split("~")
if len(parts) > 5 and parts[3]:
try:
p = float(parts[3])
y = float(parts[4])
result["原油"] = {"price": p, "change": p-y}
except:
pass
# 沪深300大盘风险代理
text = get_url_gbk("https://qt.gtimg.cn/q=sh510300")
parts = text.split("~")
if len(parts) > 5:
try:
p = float(parts[3])
y = float(parts[4])
chg = (p-y)/y*100
result["沪深300"] = {"price": p, "change": chg,
"level": "强势(>0)" if chg > 0 else "弱势(<0)"}
except:
pass
# 离岸人民币(新浪 ifzq 备用,失败则跳过)
try:
url = "https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?_var=kline_dayqfq&param=usdcny,day,2026-07-01,2026-07-12,5,qfq"
text = get_url(url)
# 解析失败则跳过,不影响其他指标
except Exception:
pass
return result
def build_portfolio_report(push=False):
today = datetime.now().strftime("%Y-%m-%d")
macro = get_macro()
print(f"\n{'='*60}")
print(f"小唯股票组合信号 {today}")
print(f"{'='*60}")
# 宏观
print("\n【宏观】")
for name, d in macro.items():
e = "📈" if d["change"] > 0 else "📉"
print(f" {e} {name}: {d['price']:.2f} ({d['change']:+.2f})")
# 个股扫描
print(f"\n【MA20扫描】MA周期={MA_PERIOD}")
print(f"{'代码':<8} {'名称':<8} {'行业':<6} {'价格':>8} {'MA20':>8} {'偏离':>7} {'信号'}")
print("-" * 65)
signals = [] # 有信号的股票
for code, name, industry in WATCHED_STOCKS:
r = analyze_ma20(code, name, industry)
if not r:
print(f"{code} {name}: 数据不足")
continue
above = "✅在MA20上方" if r["above_ma"] else "❌在MA20下方"
diff = r["diff_pct"]
signal = "持仓"
signal_icon = "🟢"
if r["golden_cross"]:
signal = "⭐金叉买入"
signal_icon = "🟡"
elif r["dead_cross"]:
signal = "🔴死叉卖出"
signal_icon = "🔴"
elif not r["above_ma"]:
signal = "空仓"
signal_icon = ""
print(f" {code} {name:<6} {industry:<5} {r['price']:>8.2f} {r['ma20']:>8.2f} {diff:>+6.1f}% {signal_icon}{signal}")
if r["golden_cross"]:
is_weak = r.get("industry", "") in WEAK_TREND_INDUSTRIES
signals.append({"code": code, "name": name, "price": r["price"], "ma20": r["ma20"], "diff": diff, "industry": r.get("industry", ""), "weak": is_weak})
# 总结
print(f"\n{'='*60}")
strong_signals = [s for s in signals if not s.get("weak")]
weak_signals = [s for s in signals if s.get("weak")]
if strong_signals:
print(f"⭐ 今日出现MA20金叉 ({len(strong_signals)}只):")
for s in strong_signals:
stats = load_backtest_stats(s["code"])
stat_line = ""
if stats:
stat_line = f" | 历史胜率{stats.get('win_rate',0):.0f}% α{stats.get('alpha',0):+.1f}% 最大回撤{stats.get('max_drawdown',0):.0f}%"
print(f"{s['name']}({s['code']}) 价格{s['price']:.2f} MA20={s['ma20']:.2f} 偏离{s['diff']:+.1f}%{stat_line}")
if weak_signals:
print(f"⚠️ 弱势行业金叉建议半仓 ({len(weak_signals)}只):")
for s in weak_signals:
print(f"{s['name']}({s['code']}) 价格{s['price']:.2f} MA20={s['ma20']:.2f} 偏离{s['diff']:+.1f}% (行业动量负,半仓)")
if not strong_signals and not weak_signals:
print("今日无金叉信号。若已持仓则继续持有,未持仓保持空仓等待。")
print(f"\n小唯股票投研 · 组合扫描")
# 飞书推送
if push:
msg = f"📊 股票组合信号 {today}\n\n"
for name, d in macro.items():
e = "📈" if d["change"] > 0 else "📉"
msg += f"{e} {name}: {d['price']:.2f} ({d['change']:+.2f}%)\n"
msg += f"\nMA20扫描 ({MA_PERIOD}日):\n"
for code, name, industry in WATCHED_STOCKS:
r = analyze_ma20(code, name, industry)
if not r:
continue
diff = r["diff_pct"]
if r["golden_cross"]:
if industry in WEAK_TREND_INDUSTRIES:
# v2弱势行业金叉半仓参与回测证明全拦截损失 α,半仓最优)
msg += f"🟡 {name}({code}) 金叉! 弱势行业建议半仓 (价格{r['price']:.2f} 偏离MA20 {diff:+.1f}%)\n"
else:
stats = load_backtest_stats(code)
stat_line = ""
if stats:
stat_line = f"\n 📊 MA20策略: 历史胜率{stats.get('win_rate',0):.0f}% | α{stats.get('alpha',0):+.1f}% | 最大回撤{stats.get('max_drawdown',0):.0f}%"
msg += f"🟡 {name}({code}) 金叉! 价格{r['price']:.2f} 偏离MA20 {diff:+.1f}%{stat_line}\n"
# 多账户自动路由2026-08-01 新增v2 弱势半仓)
if "--multi" in sys.argv:
try:
import stock_multi_account as sma
action, detail = sma.execute_signal(
{"industry": industry, "signal": "BUY", "close": r["price"], "code": code})
msg += f" 📝 多账户: [{action}] {detail}\n"
except Exception as e:
msg += f" ⚠️ 多账户执行失败: {e}\n"
elif r["dead_cross"]:
msg += f"🔴 {name}({code}) 死叉! 平仓\n"
if "--multi" in sys.argv:
try:
import stock_multi_account as sma
action, detail = sma.execute_signal(
{"industry": industry, "signal": "SELL", "close": r["price"], "code": code})
msg += f" 📝 多账户: [{action}] {detail}\n"
except Exception as e:
msg += f" ⚠️ 多账户执行失败: {e}\n"
if not signals:
msg += "\n暂无金叉,继续空仓等待。\n"
msg += f"\n生成: {datetime.now().strftime('%H:%M')}"
try:
payload = json.dumps({"msg_type": "text", "content": {"text": msg}}).encode()
req = urllib.request.Request(FEISHU_WEBHOOK, data=payload,
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=8)
print("✅ 已推送飞书")
except Exception:
print("⚠️ 飞书推送失败")
return signals
if __name__ == "__main__":
build_portfolio_report(push="--push" in sys.argv)