diff --git a/card.py b/card.py index 4ffd3a9..0505811 100644 --- a/card.py +++ b/card.py @@ -464,6 +464,26 @@ def news_view(nv) -> str: return f"相关快讯(近 3 天 {nv.get('count')} 条,财联社电报点名,不判利好利空):" + ";".join(parts) +def industry_catalyst_view(lst) -> str: + """行业催化的整句(2026-09-08 台账 051):票所在环节的行业级催化,量级大→小最多三条。只展示不进判决。""" + if not isinstance(lst, list) or not lst: + return "行业催化:暂无(环节评析未标出行业级催化,或材料未到)" + parts = [] + for x in lst: + head = f"{(x.get('event_date') or '日期不明')[5:] if x.get('event_date') else '日期不明'} [{x.get('magnitude')}·{x.get('horizon')}·{x.get('direction')}·把握{x.get('confidence')}] {x.get('segment')}:{x.get('title')}" + if x.get("mechanism"): + head += f"({x['mechanism'][:60]})" + parts.append(head) + return f"行业催化(环节级,数据基座按材料标出,{len(lst)} 条,不进判决):" + ";".join(parts) + + +def industry_catalyst_short(lst) -> str: + if not isinstance(lst, list) or not lst: + return "—" + x = lst[0] + return f"{x.get('magnitude')}·{x.get('horizon')} {str(x.get('title') or '')[:20]}" + + def news_short(nv) -> str: if not isinstance(nv, dict) or not nv.get("items"): return "—" diff --git a/plan.py b/plan.py index 62c3c26..5c647f8 100644 --- a/plan.py +++ b/plan.py @@ -305,6 +305,12 @@ def _assemble_cards(ds: str, codes: list, ev: dict, upside: pd.Series, # 相关快讯(2026-09-08 台账 050):财联社电报近 3 天点名这只票的条目,只当上下文送人看与送研判, # 不判正负、不进判决、不进定价状态。表读不到整体缺席不断产。 news = sources.news_flashes(codes, ds) + # 行业催化(2026-09-08 台账 051):数据基座按环节评析材料标出的行业级催化,挂在环节上; + # 按票所在的被指向环节取,只展示加复盘分组,不进判决、不进逻辑状态。表没建就整体缺席。 + _seg_of = inp["seg_of"] + ind_cat = sources.catalysts_for_codes( + {k: _seg_of.get(k, []) for k in codes}, + sources.segment_catalysts(sorted({t for k in codes for t in _seg_of.get(k, [])}), ds)) if risk is None: # collect 会传入读过一次的名单;单独调用时自己读 try: risk = factors._risk_set() or set() # noqa: SLF001 —— 同仓自用 @@ -342,6 +348,7 @@ def _assemble_cards(ds: str, codes: list, ev: dict, upside: pd.Series, "events": events.get(k), "events_text": card.events_view(events.get(k)), "pricing_state": card.pricing_state(ev_fields.get(k)), "news": news.get(k), "news_text": card.news_view(news.get(k)), + "industry_catalyst": ind_cat.get(k), "industry_catalyst_text": card.industry_catalyst_view(ind_cat.get(k)), "theme": theme, "n_sources": n_sources, "chain_fit": evd["chain_fit"], "started_source": "moved_view" if mv else None, "logic_claims": evd["logic"], @@ -476,6 +483,9 @@ def collect(date: str | None = None, top: int = 20, obs_top: int = 10, pricing_text=card.pricing_view(c.get("pricing_state")), # 相关快讯(2026-09-08 台账 050):原值给程序,整句给人;只是上下文,不进判决。 news=c.get("news"), news_text=c.get("news_text"), + # 行业催化(2026-09-08 台账 051):环节级,原值给程序,整句给人;只展示不进判决。 + industry_catalyst=c.get("industry_catalyst"), + industry_catalyst_text=c.get("industry_catalyst_text"), card={"pct0": c.get("pct0"), "net_z": c.get("net_z"), "heat_chg": c.get("heat_chg"), "accum": c.get("accum"), "night": c.get("night"), "gates": c.get("gates"), diff --git a/plan_review.py b/plan_review.py index 0f68905..99d96ae 100644 --- a/plan_review.py +++ b/plan_review.py @@ -449,6 +449,15 @@ def run(since: str, until: str | None, horizons: tuple, start: str, out_dir: str if s: rows.append({"date": day, "h": h, "list": lst_name, "group": f"催化事件={label}", "regime_post": regime_post, "regime_pre": regime_pre, **s}) + # 行业催化有无(2026-09-08 台账 051):环节级催化,只作观察分组;材料未到时全部落"无"。 + ind_of = {r["code"]: bool(r.get("industry_catalyst")) for r in main_rows + obs_rows} + for lst_name, lst_codes in (("候选单", cands), ("档位表", list(ind_of))): + for flag, label in ((True, "有"), (False, "无")): + codes = [c for c in lst_codes if ind_of.get(c, False) is flag] + s = summarize(ret, codes, base_all, base_main, caps, cap_base) + if s: + rows.append({"date": day, "h": h, "list": lst_name, "group": f"行业催化={label}", + "regime_post": regime_post, "regime_pre": regime_pre, **s}) notes.append(f"{day}: 主榜 {len(main_codes)} 观察 {len(obs_rows)} 候选 {len(cands)} " f"关注 {len(watch)} 生产 {len(prod)}({prod_note});" f"账本 机器通过 {len(ledger['机器通过名单'])} 人批 {len(ledger['人批名单'])} " diff --git a/sources.py b/sources.py index 3ac82da..9046b5c 100644 --- a/sources.py +++ b/sources.py @@ -1103,3 +1103,68 @@ def news_flashes(codes, ds: str, *, days: int = NEWS_WINDOW_DAYS, read_mysql=Non slot["items"].append(item) return out +# --------------------------------------------------------------------------- +# 行业催化(2026-09-08,台账 051):数据基座按环节评析的材料另标出来的"行业级催化事件", +# 挂在环节上不挂个股(表 segment_catalysts,基座 PG)。候选卡按票所在的被指向环节取, +# 只展示、只分组复盘,不进判决、不进逻辑状态。表没建或没材料就整体缺席,不断产。 +# --------------------------------------------------------------------------- +CATALYST_WINDOW_DAYS = 180 +CATALYST_MAX_PER_SEGMENT = 3 +_MAG_ORDER = {"大": 0, "中": 1, "小": 2} + + +def segment_catalysts(segments, ds: str, *, days: int = CATALYST_WINDOW_DAYS, read_pg=None, + limit_per_segment: int = CATALYST_MAX_PER_SEGMENT) -> dict: + """{环节名: [{segment, event_date, title, direction, magnitude, horizon, confidence, mechanism}, ...]}, + 每个环节按量级大→小、事件日新→旧排,最多 limit_per_segment 条。""" + rp = read_pg or db.read_pg + want = sorted({str(x) for x in (segments or []) if x}) + if not want: + return {} + try: + since = (dt.date.fromisoformat(ds) - dt.timedelta(days=int(days))).isoformat() + except ValueError: + return {} + try: + df = rp("SELECT segment_name, event_date, first_seen, title, direction, magnitude, horizon, " + "confidence, mechanism FROM segment_catalysts " + "WHERE segment_name = ANY(%s) AND coalesce(event_date, first_seen) >= %s", + (want, since)) + except Exception as e: # noqa: BLE001 —— 表没建、连接失败:整体缺席 + logging.getLogger("bridge.catalyst").warning("segment_catalysts 读不到: %r", e) + return {} + rows = [] + for r in df.to_dict("records"): + ev = r.get("event_date") or r.get("first_seen") + rows.append({"segment": str(r.get("segment_name")), "event_date": str(ev)[:10] if ev else None, + "title": str(r.get("title") or "")[:80], "direction": r.get("direction") or "利好", + "magnitude": r.get("magnitude") or "小", "horizon": r.get("horizon") or "短期", + "confidence": r.get("confidence") or "低", + "mechanism": (str(r.get("mechanism") or "")[:120] or None)}) + rows.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), x["event_date"] or ""), reverse=False) + rows.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), -(int((x["event_date"] or "0000-00-00").replace("-", "") or 0)))) + out: dict = {} + for x in rows: + slot = out.setdefault(x["segment"], []) + if len(slot) < limit_per_segment: + slot.append(x) + return out + + +def catalysts_for_codes(seg_of: dict, cat_by_seg: dict, *, limit: int = 3) -> dict: + """按票汇总所在环节的行业催化:同标题去重,量级大→小,最多 limit 条。没有的票不在字典里。""" + out: dict = {} + for k, segs in (seg_of or {}).items(): + seen: set = set() + lst = [] + for seg in segs or []: + for x in cat_by_seg.get(seg) or []: + if x["title"] in seen: + continue + seen.add(x["title"]) + lst.append(x) + if lst: + lst.sort(key=lambda x: (_MAG_ORDER.get(x["magnitude"], 9), -(int((x["event_date"] or "0000-00-00").replace("-", "") or 0)))) + out[k] = lst[:limit] + return out + diff --git a/test_events_pricing.py b/test_events_pricing.py index 17b47db..dad6f41 100644 --- a/test_events_pricing.py +++ b/test_events_pricing.py @@ -230,11 +230,39 @@ def test_news(): t("短句是最新一条", card.news_short(out["SH688172"]).startswith("09-08 12:39 燕东微")) +def test_industry_catalyst(): + """行业催化(台账 051):按环节取、按票汇总、量级排序、缺席不断产。""" + rows = [ + {"segment_name": "端侧AI芯片", "event_date": "2026-09-05", "first_seen": "2026-09-06", "title": "2B 模型媲美百亿模型", + "direction": "利好", "magnitude": "大", "horizon": "长期", "confidence": "中", "mechanism": "端侧推理可行性大增"}, + {"segment_name": "端侧AI芯片", "event_date": None, "first_seen": "2026-09-01", "title": "某厂扩产", + "direction": "利好", "magnitude": "小", "horizon": "短期", "confidence": "低", "mechanism": None}, + {"segment_name": "存储", "event_date": "2026-09-07", "first_seen": "2026-09-07", "title": "2B 模型媲美百亿模型", + "direction": "利好", "magnitude": "中", "horizon": "中期", "confidence": "高", "mechanism": "端侧内存需求上移"}, + ] + calls = [] + def rp(sql, params=None): + calls.append(params); return _DF(rows) + by_seg = sources.segment_catalysts(["端侧AI芯片", "存储", "无材料环节"], "2026-09-08", read_pg=rp) + t("按环节名与 180 天窗口查", calls[0][0] == ["存储", "无材料环节", "端侧AI芯片"] and calls[0][1] == "2026-03-12") + t("量级大的排前、没事件日的用首见日", by_seg["端侧AI芯片"][0]["magnitude"] == "大" and by_seg["端侧AI芯片"][1]["event_date"] == "2026-09-01") + t("没材料的环节不在字典里", "无材料环节" not in by_seg) + per = sources.catalysts_for_codes({"SH600000": ["端侧AI芯片", "存储"], "SZ000001": ["无材料环节"], "SZ000002": []}, by_seg) + t("按票汇总同标题去重、量级大→小", [x["title"] for x in per["SH600000"]] == ["2B 模型媲美百亿模型", "某厂扩产"]) + t("所在环节没有催化的票不在字典里", "SZ000001" not in per and "SZ000002" not in per) + v = card.industry_catalyst_view(per["SH600000"]) + t("整句带量级持续期方向与环节名", v.startswith("行业催化(环节级") and "[大·长期·利好·把握中] 端侧AI芯片" in v) + t("缺席整句与短句", "暂无" in card.industry_catalyst_view(None) and card.industry_catalyst_short([]) == "—") + def boom(*a, **k): raise RuntimeError("no table") + t("表读不到整体缺席不断产", sources.segment_catalysts(["端侧AI芯片"], "2026-09-08", read_pg=boom) == {}) + + def main(): test_events() test_event_day_fields() test_pricing_state() test_news() + test_industry_catalyst() print("ALL OK — 四类事件 / 事件日字段 / 定价状态四情形 / 卡上文字 / 相关快讯 全部通过")