diff --git a/config.py b/config.py index 7cd3567..77d28e4 100644 --- a/config.py +++ b/config.py @@ -114,3 +114,8 @@ UPSTREAM_MEMBER_CAP = int(os.environ.get("UPSTREAM_MEMBER_CAP", "600")) # 再交赛道成员,观察档的产业链锚也并入赛道成员。 TRACKS_YML = os.environ.get("TRACKS_YML", "config/frontier_tracks.yml") ENABLE_TRACK_GATE = os.environ.get("ENABLE_TRACK_GATE", "0") == "1" + +# --- 重大风险股闸(07-31 拍板:默认杜绝)------------------------------------- +# ST / *ST / S(S)T / 退市族按证券简称识别,默认挡在档位之外——gate 记 0(不采纳), +# 因此不进主榜、观察档、计划与升降档。研究口径想看全貌时置 0 关闭。 +EXCLUDE_RISK_NAMES = os.environ.get("EXCLUDE_RISK_NAMES", "1") == "1" diff --git a/config/frontier_tracks.yml b/config/frontier_tracks.yml index 20fa35b..207616d 100644 --- a/config/frontier_tracks.yml +++ b/config/frontier_tracks.yml @@ -2,7 +2,9 @@ # 十五五前沿赛道 · 产业链映射(硬门槛 C 的唯一事实源) # 规格出处:《量化因子导出与合成设计》v2.0 §3.2(tracks.py + config/frontier_tracks.yml) # -# 版本:v0.2 草案(2026-07-30 晚:按环节投影首跑的链名清单补图谱双轨;待逐条 review 转正) +# 版本:v1.0 转正(2026-07-31 用户批:13 条拍板"全按建议"——太空光伏收进商业 +# 航空航天,其余边界主题/链维持不收;四个候选赛道全部转正。§9-2 关闭, +# 赛道清单 = 十个 confirmed。行内"已拍"注释即拍板记录,勿删。) # 映射源:industry_pools 07-29 快照(317 池)+ G1 覆盖初判(07-27) # + segment_members_projection 首跑链名清单(07-30,用户实测回传) # 行尾注释里的数字 = 主题成员数(池口径)或链/环节的上市成员数(投影口径),会随涌现增长 @@ -13,7 +15,7 @@ # kg_segments 环节名 → 图谱环节(强锚,graph_segment;环节层碎,平均 2.2 成员/组, # 所以以链名为主、环节名点睛) # kg_concepts 概念标签——等 claims 口径体检后再启用,暂全空。 -# status: confirmed = §3.2 已明确 | candidate = 覆盖非空、待 §9-2 定夺 +# status: 全部 confirmed(07-31 起;曾有 candidate 一档,§9-2 关闭后不再使用) # layer(材料→设备→制造→应用)按评审结论不阻塞 G2,本版不标。 # 数据卫生备忘:投影里有 578 条空串链名(抽取时未写链修饰的边)——映射侧已忽略。 # ============================================================================= @@ -47,10 +49,11 @@ tracks: - 低轨卫星产业链 # 18 - 卫星导航 # 5 - 卫星终端 # 4 + - 太空光伏 # 23【已拍 07-31:收——航天应用属性明确】 kg_chains: [商业航天, 卫星, 卫星互联网, 航天] # 36/31/17/14 kg_segments: [] kg_concepts: [] - # TODO(拍板): 太空光伏(23) 归航天还是能源?大飞机(11) 属航空非航天,本版未收。 + # 已拍(07-31): 大飞机(11) 不收——民航制造,航天与低空都不贴。 - name: 通信 status: confirmed @@ -67,7 +70,7 @@ tracks: kg_chains: [光通信, 通信, 光模块] # 68/38/15 kg_segments: [] kg_concepts: [] - # TODO(拍板): 网络端(15 主题/14 链)、ICT(6) 名称含混,两侧均未收。 + # 已拍(07-31): 网络端(15 主题/14 链)、ICT(6) 不收——名称含混。 - name: 算力 status: confirmed @@ -96,9 +99,10 @@ tracks: kg_chains: [AIDC, AI 服务器, AI 基础设施, 先进封装] # 28/15/15/18 kg_segments: [] kg_concepts: [] - # TODO(拍板): PCB(67 主题/24 链) 偏泛收不收;散热材料(14)/热管理(14)/柴油发电机组(9,备电) 是否入链。 - # TODO(拍板): 泛半导体链(半导体117/集成电路32/封测25/半导体设备16)收不收—— - # 收了"算力"会宽成"半导体",且十五五清单里半导体不是独立赛道,先未收。 + # 已拍(07-31): PCB(67 主题/24 链) 不收——AIPCB 已收够用;散热材料(14)/ + # 热管理(14)/柴油发电机组(9) 不收——混消费电子与通用工业。 + # 已拍(07-31): 泛半导体链(半导体117/集成电路32/封测25/半导体设备16)不收—— + # 收了"算力"会宽成"半导体",且十五五清单里半导体不是独立赛道。 - name: 人工智能 status: confirmed @@ -116,8 +120,8 @@ tracks: kg_chains: [人形机器人] # 78 kg_segments: [] kg_concepts: [] - # TODO(拍板): 机器人(212 主题/86 链) 含大量工业机器人存量,偏泛——两侧均未收。 - # TODO(拍板): 与"算力"的边界:AI 基础设施类主题本版归算力,AI 应用类归本赛道。 + # 已拍(07-31): 机器人(212 主题/86 链) 不收——工业机器人存量大,人形机器人已收。 + # 边界口径:AI 基础设施类主题归算力,AI 应用类归本赛道(v0.1 起沿用)。 - name: 低空经济 status: confirmed @@ -128,12 +132,12 @@ tracks: kg_chains: [低空经济] # 24 kg_segments: [] kg_concepts: [] - # TODO(拍板): 大飞机(11) 通用航空口径是否入。 + # 已拍(07-31): 大飞机(11) 不收(同商业航空航天条目的拍板)。 - # ---- 以下 candidate:G1 初判覆盖非空,进不进清单等 §9-2 拍板 ---- + # ---- 以下四个赛道 07-31 由 candidate 转正(用户批"全按建议")---- - name: 脑机接口 - status: candidate + status: confirmed aliases: [] kg_themes: - 脑机接口 # 51 @@ -142,7 +146,7 @@ tracks: kg_concepts: [] - name: 氢能 - status: candidate + status: confirmed aliases: [] kg_themes: - 氢能 # 68 @@ -150,10 +154,10 @@ tracks: kg_chains: [SOFC] # 20 kg_segments: [] kg_concepts: [] - # TODO(拍板): 绿色甲醇(31 主题/18 链) 绿色燃料口径是否并入。 + # 已拍(07-31): 绿色甲醇(31 主题/18 链) 不收——保持氢能纯度,将来可单列绿色燃料。 - name: 深海科技 - status: candidate + status: confirmed aliases: [] kg_themes: - 深海科技 # 20 @@ -164,7 +168,7 @@ tracks: # LIKE '%深海%' 对 chain 和 segment_name 各查一次再补。 - name: 固态电池 - status: candidate + status: confirmed aliases: [] kg_themes: - 固态电池 # 93 @@ -172,8 +176,8 @@ tracks: kg_chains: [固态电池] # 41 kg_segments: [] kg_concepts: [] - # 备注:是否算"十五五前沿"待 §9-2;若不算独立赛道可并入新能源体系不进 C。 - # TODO(拍板): 锂电池(63)/锂电(52)/锂离子电池(14)/电池(38) 等链名太宽,均未收。 + # 已拍(07-31): 作为独立赛道转正;锂电池(63)/锂电(52)/锂离子电池(14)/ + # 电池(38) 等链名太宽,均不收——保持固态电池纯度。 # ============================================================================= # 排除清单 diff --git a/docs/选股说明_下游对接.md b/docs/选股说明_下游对接.md new file mode 100644 index 0000000..190a6fe --- /dev/null +++ b/docs/选股说明_下游对接.md @@ -0,0 +1,58 @@ +# 每日选股计划 · 下游对接说明 + +(akg-factor-bridge 产出,2026-07-31 版) + +## 系统逻辑,一句话 + +基于 A 股产业知识图谱的"景气度漏斗":每个交易日早晨,从约 2400 只覆盖股中找出 +**被前一交易日异动板块沿产业链指向、自身还没动、热度还低、券商预期仍有空间**的 +标的,按置信度分档,给出全局可排序的名单。 + +核心信号是"传导":某板块昨日异动,知识图谱知道它的上下游与供应链,顺着链找到 +"下一个"还没动的环节及其成员。热度与估值只做组内排序,不喧宾夺主。 + +## 节奏与获取 + +- 生成时间:每个交易日早晨约 07:15 前就绪(周二至周六晨跑;周一盘前读周六那期)。 +- 数据基准:上一交易日收盘的行情与资金热度、截至当日凌晨的券商一致预期与公告图谱。 +- 获取(桥机 8300 端口,局域网 HTTP): + - `GET /plan` —— 最新一期,JSON(main / observe / changes 三段); + - `GET /plan?format=md` —— 同内容 Markdown,浏览器可读; + - `GET /plan?date=YYYY-MM-DD` —— 指定期。**日期 = 数据日(上一交易日)**, + 例如周五早晨这期的日期是周四;查不到返回 404; + - `GET /plan/dates` —— 可取日期列表;`GET /health` —— 存活探测。 +- 展示默认:主榜前 20、观察档前 10、每个传导主题最多 5 只(防单板块刷屏)。 + JSON 的 counts 字段给全量规模;要更长名单可带参数(top / obs_top / theme_cap), + 或直接读平台因子表 t_factor_akg_score / t_factor_akg_gate 取全量。 + +## 三档结构与数量预期 + +| 档位 | 判据 | 分数区间 | 典型规模 | +|---|---|---|---| +| 主榜 | 有券商一致预期,且目标价不低于现价 | 190~250 | 950~1000 只 | +| 观察档 | 无券商覆盖,但在图谱传导链上(无估值锚,置信度低) | 90~110 | 80~110 只 | +| 不采纳 | 两类锚都没有,或目标价低于现价 | 不出行 | 其余 | + +- 分数全局可排序,150 为主榜与观察档的天然分界。 +- 主榜内部 = 200 + 传导档位×20 + 组内分:强传导(多个异动源指向、强度高)> + 弱传导 > 无传导,同档内按"还没热、还便宜"排。 +- **强传导档的规模随市场结构波动**:取决于前一交易日有几个板块异动并有效传导, + 典型 1~5 个主题、合计几只到几十只;无板块传导的日子主榜靠"冷+便宜"排序。 +- 每只候选带证据:传导主题(几个异动源、同板块已动比例)、热度(0~1)、预期空间。 +- changes 段(升降档):覆盖新增、估值闸翻转、传导链进出引起的档位变动,每日 + 几十只量级——"首次覆盖 / 新进传导链"本身是有信息量的事件。 + +## 口径须知 + +- **预期空间的绝对值偏高**(一致预期取 90 天内全部研报的简单平均,且券商研报 + 报喜不报忧),请当**相对排序**用,不要当收益预测读。 +- 热度是生态内日频资金热度分(0~1),次日到达,系统已按可得的最新日对齐。 +- **ST / *ST / 退市族默认已被重大风险闸挡在名单之外**(按证券简称识别,系统 + 参数可关——关了才会出现)。 +- 升降档每条带原因标注:首次覆盖 / 估值转正 / 新进传导链 / 估值转负 / + 离开传导链等(启发式归类,精确审计以档位表与因子表为准)。 +- 赛道硬门槛(十五五前沿产业链:核聚变、商业航天、通信、算力、人工智能、 + 低空经济等)已建成、暂未启用。启用后主榜将收窄为"赛道内"子集(按当前 + 测算约为现规模的三分之一),届时另行通知。 +- 传导史自 2026-07 起为纯实时积累,无回填历史;系统各环节的判定依据均可 + 审计(每只候选可回溯到具体的图谱边、异动台账与研报行)。 diff --git a/factors.py b/factors.py index d4684cc..45ff2dc 100644 --- a/factors.py +++ b/factors.py @@ -347,6 +347,38 @@ def _panel(ds: str) -> pd.DataFrame: return pd.concat([up, ht, tr], axis=1) +_RISK_PREFIXES = ("*ST", "ST", "SST", "S*ST", "退市") + + +def _risk_set() -> set: + """重大风险名单(07-31 拍板:默认杜绝):按证券简称识别 ST/*ST/退市族。 + + 名称来自 industry_pools 成员表;开关 config.EXCLUDE_RISK_NAMES(默认开)。 + 加载失败只告警不中止——宁可当天不过滤,也不让计划断产。""" + if not config.EXCLUDE_RISK_NAMES: + return set() + import json + try: + pools = db.read_pg("SELECT members FROM industry_pools") + except Exception as e: # noqa: BLE001 + print(f" ⚠️ 风险名单加载失败(本轮不过滤): {e!r}") + return set() + out = set() + for _, r in pools.iterrows(): + ms = r["members"] + if isinstance(ms, str): + ms = json.loads(ms) + for m in ms or []: + ts = (m or {}).get("ts_code") + name = str((m or {}).get("name") or "").replace(" ", "") + if ts and (name.startswith(_RISK_PREFIXES) or name.endswith("退")): + out.add(common.to_prefix(ts)) + if out: + print(f" (重大风险闸: {len(out)} 只 ST/退市族挡在档位外," + f"EXCLUDE_RISK_NAMES=0 可关)") + return out + + def _track_set(): """已转正赛道的成员集合(前缀码)。C 闸未启用时返回 None。""" if not config.ENABLE_TRACK_GATE: @@ -360,11 +392,12 @@ def _track_set(): return None -def _gate_of(p: pd.DataFrame, track_set) -> pd.Series: +def _gate_of(p: pd.DataFrame, track_set, risk_set=None) -> pd.Series: """三档(07-30 拍板): 2 = 主榜:有业绩锚(券商覆盖且 upside>=0);C 闸开启后再交赛道成员; 1 = 观察档:无业绩锚但有产业链锚(当前=在传导链上;C 转正后并入赛道成员); - 0 = 不采纳:两锚皆无,或有覆盖但 upside<0(贵了不买是绝对下限)。""" + 0 = 不采纳:两锚皆无,有覆盖但 upside<0(贵了不买是绝对下限), + 或命中重大风险闸(ST/退市族,07-31 拍板默认杜绝)。""" covered = p["upside"].notna() chain = p["transmission"].fillna(0.0) > 0 if track_set: @@ -376,6 +409,8 @@ def _gate_of(p: pd.DataFrame, track_set) -> pd.Series: main = main & in_track g[main] = 2.0 g[(~covered) & chain] = 1.0 + if risk_set: + g[p.index.isin(risk_set)] = 0.0 return g @@ -391,12 +426,13 @@ def build_gate(start, end): """akg_gate ∈ {0,1,2}:全池出行——让平台看得见门槛本身,不只是池内排序。 history 模式逐日重算三路子因子,区间大会慢;建议 daily / 短区间。""" ts = _track_set() + rs = _risk_set() rows = [] for ds in _days_of(start, end): p = _panel(ds) if p.empty: continue - g = _gate_of(p, ts) + g = _gate_of(p, ts, rs) rows.append(pd.DataFrame({"trade_date": ds, "stock_code": g.index, "factor_value": g.values})) return pd.concat(rows, ignore_index=True) if rows else _EMPTY @@ -405,19 +441,20 @@ def build_gate(start, end): def build_score(start, end): """akg_score:先档后分,仅 gate>0 出行,数值直接可排序。 - 主榜 = 200 + 传导档位×10 + 组内分:传导票按 log 传导分的组内中位数分成 + 主榜 = 200 + 传导档位×20 + 组内分:传导票按 log 传导分的组内中位数分成 强(2)/弱(1)两档,无传导 0 档;组内分 = 0.6·z(−热度) + 0.4·z(upside), 即"还没热、还便宜"。观察档 = 100 + 0.6·z(传导) + 0.4·z(−热度)——无估值锚, 低置信。**档位步长 20、组内分夹 ±9.9**:组内分全幅 19.8 小于步长, 先档后分的词典序才真正成立(步长 10 会档间重叠,07-30 交付当天修正)。 z 都在各自档内算;缺失处置同设计 §3.4:传导缺=0,热度缺=档内中位数。""" ts = _track_set() + rs = _risk_set() out = [] for ds in _days_of(start, end): p = _panel(ds) if p.empty: continue - g = _gate_of(p, ts) + g = _gate_of(p, ts, rs) P = p[g == 2.0].copy() # ---- 主榜 if not P.empty: diff --git a/plan.py b/plan.py index 9d30d23..d7afa19 100644 --- a/plan.py +++ b/plan.py @@ -158,9 +158,44 @@ def collect(date: str | None = None, top: int = 20, obs_top: int = 10, up_df = both[both["cur"] > both["prev"]].sort_values("cur", ascending=False) down_df = both[both["cur"] < both["prev"]].sort_values("prev", ascending=False) + # ---- 升降原因(07-31 加):区分首次覆盖 / 估值转正 / 新进传导链等。 + # 依据前一日的 upside / 传导因子表;表空时现算兜底。原因是启发式归类 + # (取最主要的一条),精确审计以档位表与因子表为准。 + prev_up = _factor("t_factor_akg_upside", prev_ds) + if prev_up.empty: + try: + import factors + dfu = factors.build_upside(prev_ds, prev_ds) + if dfu is not None and not dfu.empty: + x = dfu.copy() + x["k"] = x["stock_code"].map(common.to_prefix) + prev_up = x.groupby("k")["factor_value"].max().astype(float) + except Exception: # noqa: BLE001 —— 兜底失败则原因退化为通用文案 + pass + prev_tr = _factor("t_factor_akg_transmission", prev_ds) + + def _why(k: str, pg: float, cg: float) -> str: + if cg == 2.0: # 升入主榜 + pu = _val(prev_up, k) + if pu is None: + return "首次覆盖" + return "估值转正" if pu < 0 else "重获资格" + if cg == 1.0: # 升入观察档 + pt = _val(prev_tr, k) + return "新进传导链" if pt is None or pt <= 0 else "档位调整" + if pg == 2.0: # 从主榜降出 + cu = _val(upside, k) + if cu is None: + return "覆盖脱落" + return "估值转负" if cu < 0 else "风险闸/档位调整" + if pg == 1.0: # 从观察档降出 + return "离开传导链" if k not in ev else "风险闸/档位调整" + return "出入面板" + def _mv(d: pd.DataFrame): return [{"code": k, "name": names.get(k), - "from": lab.get(r["prev"], "?"), "to": lab.get(r["cur"], "?")} + "from": lab.get(r["prev"], "?"), "to": lab.get(r["cur"], "?"), + "reason": _why(k, r["prev"], r["cur"])} for k, r in d.iterrows()] changes = {"base_date": prev_ds, @@ -245,6 +280,7 @@ def render_md(d: dict) -> str: L.append("") L.append("**升档**:") L += [f"- {m['code']} {m['name'] or ''}:{m['from']} → {m['to']}" + f"({m.get('reason', '—')})" for m in ch["upgrades"]] if ch["upgrades_total"] > len(ch["upgrades"]): L.append(f"- ……共 {ch['upgrades_total']} 只,其余见档位表") @@ -252,6 +288,7 @@ def render_md(d: dict) -> str: L.append("") L.append("**降档**:") L += [f"- {m['code']} {m['name'] or ''}:{m['from']} → {m['to']}" + f"({m.get('reason', '—')})" for m in ch["downgrades"]] if ch["downgrades_total"] > len(ch["downgrades"]): L.append(f"- ……共 {ch['downgrades_total']} 只,其余见档位表") diff --git a/run.py b/run.py index 2e8c85a..5132f5b 100644 --- a/run.py +++ b/run.py @@ -206,7 +206,11 @@ def main(): print(f"\nconfirmed 成员表快照: {out}" f"({df['ts_code'].nunique()} 只,{len(df)} 行 股票×赛道)") if missing: - print(f"⚠️ {len(missing)} 个主题在 industry_pools 里没找到(见体检表)") + print(f"⚠️ {len(missing)} 个映射键未命中(明细见体检表各行)") + try: + tracks.gate_simulation() + except Exception as e: # noqa: BLE001 —— 演算失败不影响体检本体 + print(f"(赛道闸演算失败: {e!r})") elif a.cmd == "build": cmd_build(a.factor, a.mode, a.start, a.end, a.date, do_freeze=not a.no_freeze) diff --git a/tracks.py b/tracks.py index cf20d91..db2f891 100644 --- a/tracks.py +++ b/tracks.py @@ -166,3 +166,53 @@ def coverage_report(path: str | None = None) -> pd.DataFrame: n_conf_graph = conf[conf["source_rule"] != "pool_theme"]["ts_code"].nunique() print(f" —— confirmed 合计(去重): {n_conf} 只,其中带图谱锚 {n_conf_graph} 只") return df + + +def gate_simulation(path: str | None = None) -> None: + """赛道闸演算(只读):若 ENABLE_TRACK_GATE 置 1,主榜/观察档变成什么规模。 + + 口径与开闸后的 factors._gate_of 一致: + 主榜' = 现主榜 ∩ 赛道成员; + 观察档' = 现观察档 ∪(无券商预期 ∧ 在赛道 ∧ 现为不采纳 ∧ 非风险股)。 + 读最新档位 / upside 因子表与 confirmed 成员表,不写任何东西。""" + import common + + d = db.read_mysql("factor", "SELECT MAX(trade_date) d FROM t_factor_akg_gate") + v = None if d.empty else d.iloc[0, 0] + if v is None or pd.isna(v): + print("(尚无档位表,跳过赛道闸演算)") + return + ds = pd.Timestamp(v).date().isoformat() + g = db.read_mysql("factor", "SELECT stock_code, factor_value " + "FROM t_factor_akg_gate WHERE trade_date = %s", (ds,)) + up = db.read_mysql("factor", "SELECT stock_code FROM t_factor_akg_upside " + "WHERE trade_date = %s", (ds,)) + gate = g.set_index("stock_code")["factor_value"].astype(float) + covered = set(up["stock_code"]) if not up.empty else set() + df, _missing = resolve_members(only_confirmed=True, path=path) + if df.empty: + print("(赛道成员表为空,跳过演算)") + return + by_track = {t: {common.to_prefix(x) for x in sub["ts_code"]} + for t, sub in df.groupby("track")} + tset = set().union(*by_track.values()) + try: + import factors + risk = factors._risk_set() # noqa: SLF001 —— 同仓自用 + except Exception: # noqa: BLE001 —— 风险名单拿不到时演算照做 + risk = set() + main_now = set(gate[gate == 2.0].index) + obs_now = set(gate[gate == 1.0].index) + main_after = main_now & tset + obs_extra = {k for k in tset + if k not in covered and gate.get(k) == 0.0 and k not in risk} + obs_after = obs_now | obs_extra + print(f"\n赛道闸演算(截面 {ds},假想 ENABLE_TRACK_GATE=1):") + print(f" 主榜: {len(main_now)} → {len(main_after)}" + f"(保留 {len(main_after) / max(1, len(main_now)):.0%})") + print(f" 观察档: {len(obs_now)} → {len(obs_after)}" + f"(并入赛道内无覆盖股 +{len(obs_extra)})") + parts = [f"{t} {len(main_now & s)}" + for t, s in sorted(by_track.items(), + key=lambda kv: -len(main_now & kv[1]))] + print(f" 逐赛道主榜贡献(跨赛道有重叠): {' | '.join(parts)}")