diff --git a/scripts/stock_contradiction_workflow.py b/scripts/stock_contradiction_workflow.py index cec912a6..b43d5033 100644 --- a/scripts/stock_contradiction_workflow.py +++ b/scripts/stock_contradiction_workflow.py @@ -159,10 +159,44 @@ def load_fundamental_scan(): return None +def load_macro_score(): + """读取宏观评分结果(stock_macro.py 生成,--json 模式)""" + f = os.path.join(OUTPUT_DIR, "macro_score.json") + if not os.path.exists(f): + return None + try: + with open(f) as fp: + return json.load(fp) + except Exception: + return None + + +def load_sentiment_scan(): + """读取消息面情感扫描结果(stock_sentiment.py 生成)""" + f = os.path.join(OUTPUT_DIR, "sentiment_scan.json") + if not os.path.exists(f): + return None + try: + with open(f) as fp: + return json.load(fp) + except Exception: + return None + + def build_verdict(signals): - """根据真实数据计算四维评分(2026-08-01 增强:基本面接入真实数据)""" + """根据真实数据计算四维评分(2026-08-02 增强:宏观接入真实数据)""" now = datetime.now() - macro = -1 # 宏观承压(保持判断,可后续接入宏观指标) + + # 宏观面:真实数据(stock_macro.py 生成) + macro = 0 + macro_reasons = [] + macro_data = load_macro_score() + if macro_data: + macro = macro_data.get("macro", 0) + macro_reasons = macro_data.get("reasons", []) + else: + macro = -1 # 回退:无数据时保持保守判断 + macro_reasons = ["宏观数据缺失,保守-1"] # 基本面:取关注股票 PE/PB 平均,判断整体估值 fundamental = 0 @@ -192,8 +226,15 @@ def build_verdict(signals): elif below_count > above_count: technical = -1 - # 消息面:保持中性(暂无新闻接入) + # 消息面:真实数据(stock_sentiment.py 生成) message = 0 + msg_reasons = [] + senti = load_sentiment_scan() + if senti: + raw = senti.get("message_score", 0) + message = 1 if raw > 0.3 else (-1 if raw < -0.3 else 0) + if message != 0: + msg_reasons.append(f"市场消息面{raw:+.1f}") total = macro + fundamental + technical + message @@ -212,7 +253,9 @@ def build_verdict(signals): "message": message, "total": total, "decision": decision, + "macro_reasons": macro_reasons, "fundamental_reasons": fund_reasons, + "message_reasons": msg_reasons, } def load_industry_momentum(): @@ -236,13 +279,19 @@ def print_report(c): print("=" * 50) print("\n【四维评分】") - print(f" 宏观面: {verdict['macro']}(系统性压力)") + macro_line = "(承压)" if verdict['macro'] < 0 else ("(友好)" if verdict['macro'] > 0 else "(中性)") + print(f" 宏观面: {verdict['macro']}{macro_line}") + if verdict.get("macro_reasons"): + print(f" {' '.join(verdict['macro_reasons'][:2])}") fund_line = "(稳健)" if verdict['fundamental'] > 0 else ("(偏弱)" if verdict['fundamental'] < 0 else "(中性)") print(f" 基本面: {verdict['fundamental']}{fund_line}") if verdict.get("fundamental_reasons"): print(f" {' '.join(verdict['fundamental_reasons'])}") print(f" 技术面: {verdict['technical']}(空头排列)") - print(f" 消息面: {verdict['message']}(中性)") + msg_line = "(利好)" if verdict['message'] > 0 else ("(利空)" if verdict['message'] < 0 else "(中性)") + print(f" 消息面: {verdict['message']}{msg_line}") + if verdict.get("message_reasons"): + print(f" {' '.join(verdict['message_reasons'])}") print(f" 综合评分: {verdict['total']} → 决策:{verdict['decision']}") # 行业动量版块(2026-08-01 新增) diff --git a/scripts/stock_data_refresh.sh b/scripts/stock_data_refresh.sh new file mode 100755 index 00000000..0fa071ec --- /dev/null +++ b/scripts/stock_data_refresh.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# 股票数据刷新:宏观 + 基本面 + 情感 + 行业 + 因子 全量扫描 +# 被 cron 调用(周一矛盾周报前 + 每日收盘后) +cd ~/.hermes/scripts || exit 1 + +echo "=== 宏观评分 ===" +python3 stock_macro.py --json 2>&1 | tail -2 + +echo "" +echo "=== 基本面扫描 ===" +python3 stock_fundamental.py 2>&1 | tail -3 + +echo "" +echo "=== 消息面情感 ===" +python3 stock_sentiment.py 2>&1 | tail -3 + +echo "" +echo "✅ 数据刷新完成 $(date '+%Y-%m-%d %H:%M')" diff --git a/scripts/stock_macro.py b/scripts/stock_macro.py new file mode 100644 index 00000000..d99e7cae --- /dev/null +++ b/scripts/stock_macro.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +小唯宏观指标自动化 — 真实数据驱动四维评分的宏观维度 +================================================== +获取上证/沪深300/创业板/原油,计算宏观趋势评分。 + +评分规则: + 上证 20日涨幅 > 0 → +1(大盘向上) + 上证 20日涨幅 < -3% → -1(大盘走弱) + 沪深300 20日涨幅 > 0 → +1 + 原油 20日涨幅 > 10% → -1(通胀压力) + 综合:sum 后 clip 到 [-1, 1] + +用法: + python3 stock_macro.py # 输出宏观评分 + python3 stock_macro.py --json # JSON 输出(供矛盾周报引用) +""" +import json, sys, urllib.request +from datetime import datetime, timedelta +from pathlib import Path + +OUTPUT = Path.home() / ".hermes" / "stock_backtest" +OUTPUT.mkdir(exist_ok=True) + + +def get_url(url, timeout=8): + 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): + 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_change(symbol, days=20): + """获取指数/品种过去 N 日涨幅""" + today = datetime.now().strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=days * 2)).strftime("%Y-%m-%d") + url = (f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get" + f"?_var=kline_dayqfq¶m={symbol},day,{start},{today},{days},qfq") + text = get_url(url) + if not text or len(text) < 50: + return None + try: + import re + json_str = re.sub(r"^[^=]+=", "", text, count=1) + data = json.loads(json_str) + key = list(data.get("data", {}).keys()) + if not key: + return None + raw = data["data"][key[0]].get("qfqday") or data["data"][key[0]].get("day") or [] + if len(raw) < 2: + return None + closes = [float(r[2]) for r in raw if len(r) > 2 and float(r[2]) > 0] + if len(closes) < 2: + return None + return (closes[-1] - closes[0]) / closes[0] * 100 + except Exception: + return None + + +def get_macro_score(): + """ + 计算宏观评分 + 返回: {"macro": int, "details": {指标: 涨幅}, "reasons": [...]} + """ + # 上证指数 + shanghai = get_kline_change("sh000001", days=20) + # 沪深300(用 ETF 510300 代理) + hs300 = get_kline_change("sh510300", days=20) + # 创业板 + chinext = get_kline_change("sz399006", days=20) + # 原油 + oil = get_kline_change("hf_OIL", days=20) + + score = 0 + details = {} + reasons = [] + + if shanghai is not None: + details["上证20日"] = f"{shanghai:+.1f}%" + if shanghai > 0: + score += 1 + reasons.append(f"上证20日{shanghai:+.1f}% → 大盘向上") + elif shanghai < -3: + score -= 1 + reasons.append(f"上证20日{shanghai:+.1f}% → 大盘走弱") + else: + reasons.append(f"上证20日{shanghai:+.1f}% → 震荡") + + if hs300 is not None: + details["沪深300"] = f"{hs300:+.1f}%" + if hs300 > 0: + score += 1 + reasons.append(f"沪深300 {hs300:+.1f}% → 蓝筹强") + elif hs300 < -3: + score -= 1 + reasons.append(f"沪深300 {hs300:+.1f}% → 蓝筹弱") + + if chinext is not None: + details["创业板"] = f"{chinext:+.1f}%" + if chinext < -5: + score -= 1 + reasons.append(f"创业板{chinext:+.1f}% → 成长弱") + + if oil is not None: + details["原油20日"] = f"{oil:+.1f}%" + if oil > 10: + score -= 1 + reasons.append(f"原油{oil:+.1f}% → 通胀压力") + elif oil < -10: + score += 1 + reasons.append(f"原油{oil:+.1f}% → 通缩缓解") + + # clip 到 [-1, 1] + macro = max(-1, min(1, score)) + if macro == 0 and not reasons: + macro = 0 + reasons.append("宏观数据获取不足,中性") + + return {"macro": macro, "details": details, "reasons": reasons, "date": datetime.now().strftime("%Y-%m-%d")} + + +def main(): + result = get_macro_score() + if "--json" in sys.argv: + out = OUTPUT / "macro_score.json" + with open(out, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + print(json.dumps(result, ensure_ascii=False)) + print(f"📁 已保存: {out}") + return + + print("=" * 50) + print(f"小唯宏观评分 {result['date']}") + print("=" * 50) + for k, v in result["details"].items(): + print(f" {k}: {v}") + print(f"\n宏观评分: {result['macro']:+d}") + for r in result["reasons"]: + print(f" • {r}") + + +if __name__ == "__main__": + main() diff --git a/scripts/stock_multi_account.py b/scripts/stock_multi_account.py index 3edcfffe..c206f1a4 100644 --- a/scripts/stock_multi_account.py +++ b/scripts/stock_multi_account.py @@ -69,6 +69,38 @@ def save_account(acct): json.dump(acct, f, ensure_ascii=False, indent=2) +def get_dynamic_alloc(industry): + """ + 动态仓位(2026-08-02 增强:按行业动量强度调整) + 从 industry_scan.json 读取行业动量: + 动量 > 0 → 1.0(全仓,强势行业) + 动量 -20% ~ 0 → 0.6(偏弱,降仓) + 动量 -40% ~ -20%→ 0.5(弱势,半仓) + 动量 < -40% → 0.3(深度弱势,轻仓) + 无数据回退:弱势行业 0.5,其他 1.0 + """ + f = OUTPUT.parent / "industry_scan.json" + mom = None + if f.exists(): + try: + scan = json.load(open(f)) + mom_map = scan.get("industries", {}).get("avg_mom", {}) + mom = mom_map.get(industry) + except Exception: + mom = None + + if mom is None: + return 0.5 if industry in WEAK_INDUSTRIES else 1.0 + + if mom > 0: + return 1.0 + if mom > -0.20: + return 0.6 + if mom > -0.40: + return 0.5 + return 0.3 + + def execute_signal(sig, dry_run=False): """ 信号执行(多账户路由版 v2 — 2026-08-01 回测优化) @@ -96,21 +128,21 @@ def execute_signal(sig, dry_run=False): if signal == "BUY" and not has_position: if acct.get("blocked"): return ("BLOCK", f"行业{industry}账户已锁定") - # 弱势行业半仓(v2) + # 动态仓位(v3:按行业动量强度调整,2026-08-02) + alloc = get_dynamic_alloc(industry) is_weak = industry in WEAK_INDUSTRIES - alloc = 0.5 if is_weak else 1.0 shares = int(acct["current_capital"] * alloc // price) if price > 0 else 0 if shares <= 0: return ("SKIP", "资金不足") cost = shares * price if dry_run: - return ("BUY", f"{industry}{'半仓' if is_weak else '全仓'}买入@{price:.2f} {shares}股") + return ("BUY", f"{industry}{'半仓' if is_weak else '全仓'}买入@{price:.2f} {shares}股(仓位{alloc:.0%})") acct["positions"].append({"shares": shares, "avg_cost": price}) acct["current_capital"] -= cost acct["last_signal"] = "买入" acct["last_signal_date"] = datetime.now().strftime("%Y-%m-%d") save_account(acct) - mode = "半仓(弱势行业)" if is_weak else "全仓" + mode = f"{alloc:.0%}仓(弱势行业)" if is_weak else "全仓" return ("BUY", f"{industry}{mode}买入{shares}股@{price:.2f} 剩余{acct['current_capital']:.0f}") elif signal == "SELL" and has_position: diff --git a/scripts/stock_sentiment.py b/scripts/stock_sentiment.py new file mode 100644 index 00000000..c4bbe732 --- /dev/null +++ b/scripts/stock_sentiment.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +小唯消息面情感评分 — 东方财富公告 + 关键词情感词典 +================================================== +抓取关注股票最新公告标题,用情感词典打分。 + +评分规则: + 利空词(减持/亏损/违规/立案/质押/诉讼/退市/警示/下跌/处罚)→ -1 + 利好词(增持/回购/分红/中标/增长/突破/合作/扩产/盈利/上调/签约)→ +1 + 中性 → 0 + 综合多个公告后 clip 到 [-1, 1] + +用法: + python3 stock_sentiment.py # 输出情感评分 + python3 stock_sentiment.py --json # JSON 输出(供矛盾周报引用) +""" +import json, sys, subprocess, shlex, os, re +from datetime import datetime +from pathlib import Path + +OUTPUT = Path.home() / ".hermes" / "stock_backtest" +OUTPUT.mkdir(exist_ok=True) + +# 关注股票(代码, 名称, 行业) +WATCHED = [ + ("000858", "五粮液", "白酒"), + ("600519", "贵州茅台", "白酒"), + ("000568", "泸州老窖", "白酒"), + ("002304", "洋河股份", "白酒"), + ("600036", "招商银行", "银行"), + ("601318", "中国平安", "保险"), + ("000001", "平安银行", "银行"), + ("300750", "宁德时代", "新能源"), + ("002594", "比亚迪", "新能源"), + ("002415", "海康威视", "科技"), + ("601088", "中国神华", "煤炭"), + ("688981", "中芯国际", "半导体"), + ("600030", "中信证券", "证券"), + ("000333", "美的集团", "家电"), +] + +# 情感词典 +NEGATIVE_WORDS = [ + "减持", "亏损", "违规", "立案", "质押", "诉讼", "退市", "警示", + "下跌", "处罚", "风险", "终止", "暂停", "下滑", "恶化", "逾期", + "冻结", "查封", "调查", "降级", "下调", "失败", "延期", "变卖", +] +POSITIVE_WORDS = [ + "增持", "回购", "分红", "中标", "增长", "突破", "合作", "扩产", + "盈利", "上调", "签约", "创新高", "预增", "扭亏", "获批", "落地", + "推出", "发布", "投资", "签订", "完成", "超预期", "翻倍", "新签订单", +] + +# 中性词(公告常见但无方向性) +NEUTRAL_WORDS = ["会议", "报告", "公告", "章程", "制度", "通知", "更正", "说明"] + + +def get_url(url, timeout=8): + 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_announcements(code, limit=5): + """东方财富个股公告(标题列表)""" + url = (f"https://np-anotice-stock.eastmoney.com/api/security/ann" + f"?sr=-1&page_size={limit}&page_index=1&ann_type=A" + f"&client_source=web&stock_list={code}&f_node=0&s_node=0") + text = get_url(url) + if not text or len(text) < 50: + return [] + try: + data = json.loads(text) + items = data.get("data", {}).get("list", []) + titles = [] + for item in items: + title = item.get("title", "").strip() + # 清理 HTML 标签 + title = re.sub(r"<[^>]+>", "", title) + if title: + titles.append(title) + return titles + except Exception: + return [] + + +def score_title(title): + """单条标题情感评分:返回 (score, matched)""" + pos_hits = [w for w in POSITIVE_WORDS if w in title] + neg_hits = [w for w in NEGATIVE_WORDS if w in title] + if pos_hits and not neg_hits: + return 1, pos_hits + if neg_hits and not pos_hits: + return -1, neg_hits + if pos_hits and neg_hits: + return 0, pos_hits + neg_hits # 混合中性 + return 0, [] + + +def scan_sentiment(): + """扫描所有关注股票,返回情感评分""" + results = [] + print("=" * 60) + print(f"小唯消息面情感扫描 {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 60) + + for code, name, industry in WATCHED: + titles = get_announcements(code, limit=5) + if not titles: + print(f" ⚠️ {name}: 无公告数据") + results.append({"code": code, "name": name, "industry": industry, + "score": 0, "titles": [], "reasons": ["无公告"]}) + continue + + scores = [score_title(t) for t in titles] + # 有实质方向的最新公告优先(取最近3条的平均) + recent = [s for s, _ in scores[:3]] + avg = sum(recent) / len(recent) if recent else 0 + final = max(-1, min(1, avg)) + + hits = [] + for t, (s, words) in zip(titles[:3], scores[:3]): + if words: + emoji = "🔴" if s < 0 else ("🟢" if s > 0 else "⚪") + hits.append(f"{emoji}{t[:35]}") + + results.append({"code": code, "name": name, "industry": industry, + "score": final, "titles": titles[:3], "hits": hits, + "reasons": hits}) + emoji = "🔴" if final < 0 else ("🟢" if final > 0 else "⚪") + print(f" {emoji} {name}({code}) [{industry}] 情感{final:+.0f}") + + # 汇总:全市场情感(消息面评分) + valid = [r for r in results if r.get("titles")] + if valid: + avg_all = sum(r["score"] for r in valid) / len(valid) + message = max(-1, min(1, avg_all)) + else: + message = 0 + + print(f"\n📊 市场消息面综合评分: {message:+.0f}") + + out = OUTPUT / "sentiment_scan.json" + with open(out, "w", encoding="utf-8") as f: + json.dump({"generated": datetime.now().strftime("%Y-%m-%d %H:%M"), + "message_score": message, + "stocks": results}, f, ensure_ascii=False, indent=2) + print(f"📁 已保存: {out}") + + if "--json" in sys.argv: + return message + return results + + +if __name__ == "__main__": + scan_sentiment()