diff --git a/deploy/profile-scripts/market_watch.py b/deploy/profile-scripts/market_watch.py index 74dd77b7..cc8ad153 100644 --- a/deploy/profile-scripts/market_watch.py +++ b/deploy/profile-scripts/market_watch.py @@ -1,257 +1,278 @@ -#!/usr/bin/env python3 -"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json - -數據源優先級: - 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) - 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) - -注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), -實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 -實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 - -輸出:data/market.json → MoFin Dashboard 市場數據展示 -""" - -import json -from datetime import datetime -from pathlib import Path - -from mofin_db import get_conn, init_all_tables, write_market_snapshot - -DATA_DIR = Path(__file__).parent.parent / "data" - - -# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── - -def _fetch_em(url): - """通用 EM API 請求""" - import urllib.request - req = urllib.request.Request( - url, - headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - ) - resp = urllib.request.urlopen(req, timeout=10) - return json.loads(resp.read().decode("utf-8")) - - -def fetch_sector_em(): - """東方財富行業板塊""" - try: - data = _fetch_em( - "https://push2.eastmoney.com/api/qt/clist/get?" - "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2" - ) - return [{ - "name": i["f14"], - "code": i["f12"], - "price": i.get("f2", 0), - "change": i.get("f3", 0), - } for i in data.get("data", {}).get("diff", [])] - except Exception: - return None - - -def fetch_concept_em(): - """東方財富概念板塊""" - try: - data = _fetch_em( - "https://push2.eastmoney.com/api/qt/clist/get?" - "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3" - ) - return [{ - "name": i["f14"], - "code": i["f12"], - "change": i.get("f3", 0), - } for i in data.get("data", {}).get("diff", [])] - except Exception: - return None - - -# ── 後端B:同花順 THS / akshare(降級) ── - -def fetch_sector_ths(): - """THS 行業板塊(含漲跌家數、資金流向、領漲股)""" - try: - import akshare as ak - df = ak.stock_board_industry_summary_ths() - return [{ - "name": r["板块"], - "code": "", - "price": 0, - "change": float(r.get("涨跌幅", 0)), - "volume": float(r.get("总成交量", 0)), - "turnover": float(r.get("总成交额", 0)), - "net_inflow": float(r.get("净流入", 0)), - "up_count": int(r.get("上涨家数", 0)), - "down_count": int(r.get("下跌家数", 0)), - "avg_price": float(r.get("均价", 0)), - "lead_stock": r.get("领涨股", ""), - "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), - } for _, r in df.iterrows()] - except Exception as e: - print(f"THS行業失敗: {e}", flush=True) - return [] - - -def fetch_concept_ths(): - """THS 概念板塊(僅名稱,無實時漲跌)""" - try: - import akshare as ak - df = ak.stock_board_concept_name_ths() - return [{ - "name": r["name"], - "code": str(r.get("code", "")), - "change": 0, - } for _, r in df.iterrows()] - except Exception as e: - print(f"THS概念失敗: {e}", flush=True) - return [] - - -# ── 輔助函數 ── - -def get_market_mood(sectors): - if not sectors: - return "unknown" - ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) - return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" - - -def get_market_verdict(up_ratio, mood, sectors): - """Return (verdict, reason) based on sector data.""" - if not sectors: - return "unknown", "数据不足" - if up_ratio < 25: - return "弱势", f"仅{up_ratio}%板块上涨,{mood}" - elif up_ratio < 40: - return "偏弱", f"{up_ratio}%板块上涨,结构分化" - elif up_ratio < 60: - return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" - else: - return "强势", f"{up_ratio}%板块上涨,整体走强" - - -def get_hot_sectors(sectors, top_n=3): - """Return sectors with highest positive change as hot sectors.""" - hot = [s for s in sectors if s.get("change", 0) > 1.0] - hot.sort(key=lambda s: s.get("change", 0), reverse=True) - return [{ - "name": s["name"], - "change": s.get("change", 0), - "reason": f"板块涨{s.get('change',0):.1f}%" - } for s in hot[:top_n]] - - -def get_danger_sectors(sectors, top_n=3): - """Return sectors with lowest (negative) change as danger sectors.""" - danger = [s for s in sectors if s.get("change", 0) < -1.0] - danger.sort(key=lambda s: s.get("change", 0)) - return [{ - "name": s["name"], - "change": s.get("change", 0), - "reason": f"板块跌{s.get('change',0):.1f}%" - } for s in danger[:top_n]] - - -# ── 主流程 ── - -def main(): - # 行業板塊:EM → THS → 兜底 - sectors = fetch_sector_em() - source = "eastmoney" - if sectors is None: - sectors = fetch_sector_ths() - source = "ths" - - # 概念板塊:EM → THS → 空 - concepts = fetch_concept_em() - concept_source = "eastmoney" - if concepts is None: - concepts = fetch_concept_ths() - concept_source = "ths" - if not concepts: - concepts = [] - concept_source = "unavailable" - - # 排序 - sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) - top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] - top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] - - # 计算大盘数据 - up_ratio = round( - sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 - ) - mood = get_market_mood(sectors) - verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) - - market_data = { - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), - "source": source, - "concept_source": concept_source, - "total_sectors": len(sectors), - "up_ratio": up_ratio, - "mood": mood, - "market_verdict": verdict, - "verdict_reason": verdict_reason, - "hot_sectors": get_hot_sectors(sectors), - "danger_sectors": get_danger_sectors(sectors), - "top_gainers": top_gainers, - "top_losers": top_losers, - "sectors": sectors, - "concepts": concepts, - } - - DATA_DIR.mkdir(parents=True, exist_ok=True) - - # ── SQLite 写入(替代 market.json)── - conn = get_conn() - init_all_tables(conn) - ok, msg, sid = write_market_snapshot(conn, market_data) - if ok: - print(f"[DB] {msg}", flush=True) - else: - print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) - conn.close() - - # ── 大盘市场阶段(market_regime):与回测 _load_index_ctx 同算法 ── - # 回测用 above_ma20 + adx 做市场过滤(v_next4 要求趋势市才出手), - # 实盘此前只有当日涨跌幅 mood,缺 MA20+ADX 的市场阶段判断 —— 此处补齐。 - try: - import market_regime as _mr - _regime = _mr.compute_regime() - if _regime: - _mr.save_regime(_regime) - print(f"[regime] {_regime['date']} → {_regime['regime']} " - f"(above_ma20={_regime['above_ma20']} adx={_regime['adx']})", flush=True) - else: - print("[regime] sh000001 数据不足,跳过", flush=True) - except Exception as _e: - print(f"[regime] 计算失败: {_e}", flush=True) - - # ── v_mr 均值回复扫描(每天最多一次,regime 门控内置)── - # trend_down/choppy 时扫描深超跌小盘票,写 candidates(sector='v_mr') - # mr_scanner 自带幂等保护:当天已有 v_mr 候选则跳过,避免重复全扫描 - try: - import subprocess as _sp - import sys as _sys - _script = Path(__file__).parent / "mr_scanner.py" - if _script.exists(): - _r = _sp.run( - [_sys.executable, str(_script), "--top", "15"], - capture_output=True, text=True, timeout=1500) - if _r.stdout: - for _line in _r.stdout.strip().splitlines(): - print(f"[mr_scanner] {_line}", flush=True) - if _r.returncode != 0 and _r.stderr: - print(f"[mr_scanner] stderr: {_r.stderr[:500]}", flush=True) - else: - print("[mr_scanner] 脚本不存在,跳过", flush=True) - except Exception as _e: - print(f"[mr_scanner] 调用失败: {_e}", flush=True) - - # 靜默:只寫文件,不輸出到stdout,避免cron推送 - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點數據採集,寫入 dashboard data/market.json + +數據源優先級: + 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) + 後端B:同花順 THS / akshare(降級,有漲跌家數+資金流向) + +注意:當前服務器無法連通東方財富API(已被封禁/域名不可達), +實際運行時自動降級到同花順 THS 後端。THS 提供90+行業板塊的 +實時漲跌、上漲/下跌家數、淨流入資金等數據,足以滿足需求。 + +輸出:data/market.json → MoFin Dashboard 市場數據展示 +""" + +import json +from datetime import datetime +from pathlib import Path + +from mofin_db import get_conn, init_all_tables, write_market_snapshot + +DATA_DIR = Path(__file__).parent.parent / "data" + + +# ── 後端A:東方財富 push2 API(首選,有板塊代碼+實時指數) ── + +def _fetch_em(url): + """通用 EM API 請求""" + import urllib.request + req = urllib.request.Request( + url, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + ) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read().decode("utf-8")) + + +def fetch_sector_em(): + """東方財富行業板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=60&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "price": i.get("f2", 0), + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em( + "https://push2.eastmoney.com/api/qt/clist/get?" + "pn=1&pz=30&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3" + ) + return [{ + "name": i["f14"], + "code": i["f12"], + "change": i.get("f3", 0), + } for i in data.get("data", {}).get("diff", [])] + except Exception: + return None + + +# ── 後端B:同花順 THS / akshare(降級) ── + +def fetch_sector_ths(): + """THS 行業板塊(含漲跌家數、資金流向、領漲股)""" + try: + import akshare as ak + df = ak.stock_board_industry_summary_ths() + return [{ + "name": r["板块"], + "code": "", + "price": 0, + "change": float(r.get("涨跌幅", 0)), + "volume": float(r.get("总成交量", 0)), + "turnover": float(r.get("总成交额", 0)), + "net_inflow": float(r.get("净流入", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "avg_price": float(r.get("均价", 0)), + "lead_stock": r.get("领涨股", ""), + "lead_stock_change": float(r.get("领涨股-涨跌幅", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS行業失敗: {e}", flush=True) + return [] + + +def fetch_concept_ths(): + """THS 概念板塊(僅名稱,無實時漲跌)""" + try: + import akshare as ak + df = ak.stock_board_concept_name_ths() + return [{ + "name": r["name"], + "code": str(r.get("code", "")), + "change": 0, + } for _, r in df.iterrows()] + except Exception as e: + print(f"THS概念失敗: {e}", flush=True) + return [] + + +# ── 輔助函數 ── + +def get_market_mood(sectors): + if not sectors: + return "unknown" + ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) + return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" + + +def get_market_verdict(up_ratio, mood, sectors): + """Return (verdict, reason) based on sector data.""" + if not sectors: + return "unknown", "数据不足" + if up_ratio < 25: + return "弱势", f"仅{up_ratio}%板块上涨,{mood}" + elif up_ratio < 40: + return "偏弱", f"{up_ratio}%板块上涨,结构分化" + elif up_ratio < 60: + return "均衡", f"{up_ratio}%板块上涨,涨跌均衡" + else: + return "强势", f"{up_ratio}%板块上涨,整体走强" + + +def get_hot_sectors(sectors, top_n=3): + """Return sectors with highest positive change as hot sectors.""" + hot = [s for s in sectors if s.get("change", 0) > 1.0] + hot.sort(key=lambda s: s.get("change", 0), reverse=True) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块涨{s.get('change',0):.1f}%" + } for s in hot[:top_n]] + + +def get_danger_sectors(sectors, top_n=3): + """Return sectors with lowest (negative) change as danger sectors.""" + danger = [s for s in sectors if s.get("change", 0) < -1.0] + danger.sort(key=lambda s: s.get("change", 0)) + return [{ + "name": s["name"], + "change": s.get("change", 0), + "reason": f"板块跌{s.get('change',0):.1f}%" + } for s in danger[:top_n]] + + +# ── 主流程 ── + +def main(): + # 行業板塊:EM → THS → 兜底 + sectors = fetch_sector_em() + source = "eastmoney" + if sectors is None: + sectors = fetch_sector_ths() + source = "ths" + + # 概念板塊:EM → THS → 空 + concepts = fetch_concept_em() + concept_source = "eastmoney" + if concepts is None: + concepts = fetch_concept_ths() + concept_source = "ths" + if not concepts: + concepts = [] + concept_source = "unavailable" + + # 排序 + sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) + top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] + top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] + + # 计算大盘数据 + up_ratio = round( + sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1 + ) + mood = get_market_mood(sectors) + verdict, verdict_reason = get_market_verdict(up_ratio, mood, sectors) + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "source": source, + "concept_source": concept_source, + "total_sectors": len(sectors), + "up_ratio": up_ratio, + "mood": mood, + "market_verdict": verdict, + "verdict_reason": verdict_reason, + "hot_sectors": get_hot_sectors(sectors), + "danger_sectors": get_danger_sectors(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + # ── SQLite 写入(替代 market.json)── + conn = get_conn() + init_all_tables(conn) + ok, msg, sid = write_market_snapshot(conn, market_data) + if ok: + print(f"[DB] {msg}", flush=True) + else: + print(f"[DB] 写入失败(JSON 不受影响): {msg}", flush=True) + conn.close() + + # ── 大盘市场阶段(market_regime):与回测 _load_index_ctx 同算法 ── + # 回测用 above_ma20 + adx 做市场过滤(v_next4 要求趋势市才出手), + # 实盘此前只有当日涨跌幅 mood,缺 MA20+ADX 的市场阶段判断 —— 此处补齐。 + try: + import market_regime as _mr + _regime = _mr.compute_regime() + if _regime: + _mr.save_regime(_regime) + print(f"[regime] {_regime['date']} → {_regime['regime']} " + f"(above_ma20={_regime['above_ma20']} adx={_regime['adx']})", flush=True) + else: + print("[regime] sh000001 数据不足,跳过", flush=True) + except Exception as _e: + print(f"[regime] 计算失败: {_e}", flush=True) + + # ── v_mr 均值回复扫描(每天最多一次,regime 门控内置)── + # trend_down/choppy 时扫描深超跌小盘票,写 candidates(sector='v_mr') + # mr_scanner 自带幂等保护:当天已有 v_mr 候选则跳过,避免重复全扫描 + try: + import subprocess as _sp + import sys as _sys + _script = Path(__file__).parent / "mr_scanner.py" + if _script.exists(): + _r = _sp.run( + [_sys.executable, str(_script), "--top", "15"], + capture_output=True, text=True, timeout=1500) + if _r.stdout: + for _line in _r.stdout.strip().splitlines(): + print(f"[mr_scanner] {_line}", flush=True) + if _r.returncode != 0 and _r.stderr: + print(f"[mr_scanner] stderr: {_r.stderr[:500]}", flush=True) + else: + print("[mr_scanner] 脚本不存在,跳过", flush=True) + except Exception as _e: + print(f"[mr_scanner] 调用失败: {_e}", flush=True) + + # ── S2 恐慌买超跌扫描(每天最多一次,大盘RSI<25门控内置)── + # 策略家族(§39): S1(v_weak甜区)+S2(恐慌买)同时运行零重叠 + # s2_scanner 自带幂等:当天已有 s2_panic 候选则跳过 + try: + import subprocess as _sp2 + import sys as _sys2 + _script2 = Path(__file__).parent / "s2_scanner.py" + if _script2.exists(): + _r2 = _sp2.run( + [_sys2.executable, str(_script2), "--top", "15"], + capture_output=True, text=True, timeout=1500) + if _r2.stdout: + for _line in _r2.stdout.strip().splitlines(): + print(f"[s2_scanner] {_line}", flush=True) + if _r2.returncode != 0 and _r2.stderr: + print(f"[s2_scanner] stderr: {_r2.stderr[:500]}", flush=True) + else: + print("[s2_scanner] 脚本不存在,跳过", flush=True) + except Exception as _e2: + print(f"[s2_scanner] 调用失败: {_e2}", flush=True) + + # 靜默:只寫文件,不輸出到stdout,避免cron推送 + + +if __name__ == "__main__": + main()