历史深度数据探查
This commit is contained in:
parent
42659b92e9
commit
642934f58d
35
.env.example
35
.env.example
|
|
@ -2,7 +2,7 @@
|
|||
# 三处连接:读基座视图(PG) · 读热度(153) · 写因子+注册(平台MySQL)。部署在哪台
|
||||
# 服务器由"能同时连通这三处"决定。
|
||||
|
||||
# --- ① astock-kg 基座 PostgreSQL(读四个只读视图,只读账号即可)---
|
||||
# --- ① astock-kg 基座 PostgreSQL(读只读视图,只读账号即可)---
|
||||
AKG_PG_HOST=
|
||||
AKG_PG_PORT=5432
|
||||
AKG_PG_USER=
|
||||
|
|
@ -35,3 +35,36 @@ PRICE_CODE_COL=symbol
|
|||
|
||||
# 可选:用平台 REST 注册因子时填(留空=直连 ③ 写 factor_metadata)
|
||||
# FACTOR_API_BASE=http://192.168.16.155:8000
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 运行旋钮(2026-07-26 评审引入)。全部有默认值,整段留空也能跑。
|
||||
# ============================================================================
|
||||
|
||||
# 子因子是否受覆盖池限制:pool(默认,保持原行为)| market(评审 §4 建议)
|
||||
# industry_pools 只存最新态、每周一自动长大 → 用它过滤**历史**子因子会引入
|
||||
# 成员性前视,且截面统计量每周一结构性跳变。子因子表是"可独立观察的仪表",
|
||||
# 过滤应发生在消费端(akg_score)。传导不受此开关影响(天生池内语义)。
|
||||
# SUBFACTOR_UNIVERSE=pool
|
||||
|
||||
# 行情按月分块读取(防 `--mode history --start 2006` 把千万行拉进 pandas)
|
||||
# PRICE_CHUNK_DAYS=31
|
||||
|
||||
# 因子表写入分块提交行数(热度全史约 54 万行,单事务走 ShardingSphere 有风险)
|
||||
# WRITE_CHUNK_ROWS=50000
|
||||
|
||||
# 事件来源白名单(documents.source_type,逗号分隔)与单文档封顶。
|
||||
# 取值:annual_report / research_report / announcement / news / interactive_qa
|
||||
# 默认只认公告:年报也带 company_ts_code 文档锚,而一份年报能抽十几条 EVENT
|
||||
# 且含**历史**诉讼/处罚 —— S2 若用 akg_event < −θ_e 做否决闸,会因一份年报
|
||||
# 提到历史诉讼而否决掉一只好股。想放宽填 announcement,annual_report。
|
||||
# EVENT_SOURCE_TYPES=announcement
|
||||
# EVENT_MAX_PER_DOC=3
|
||||
|
||||
# 输入冻结落点(容器内路径;compose 已把仓库根挂到 /app)
|
||||
# FROZEN_ROOT=/app/data/frozen
|
||||
|
||||
# 上游截断体检阈值(基座 topic_context 的 LIMIT cap / transmission 的 quiet[:12])
|
||||
# 命中即说明该行 moved_ratio 建立在任意抽样上、覆盖被展示逻辑锁住 —— 只告警不拦截。
|
||||
# UPSTREAM_MEMBER_CAP=30
|
||||
# UPSTREAM_QUIET_CAP=12
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
# 连接凭据——绝不入库
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
# 输入冻结(G0.5):**跟踪 manifest.json,忽略数据文件**
|
||||
# manifest 才是可 diff 的审计线(行数/告警/git rev);parquet/csv.gz 是体量大的原始快照。
|
||||
data/frozen/**/*.parquet
|
||||
data/frozen/**/*.csv.gz
|
||||
|
||||
# 评审修复前的备份与补丁包(确认无误后可整目录删掉)
|
||||
.review_backup_*/
|
||||
130
README.md
130
README.md
|
|
@ -2,41 +2,46 @@
|
|||
|
||||
astock-kg(知识图谱基座)↔ `quant_factor_service`(通用因子平台)之间的**因子导出桥**。
|
||||
把基座的四路产出(分析师预期空间 / 热度 / 利好利空事件 / 板块传导)做成符合平台规范的
|
||||
子因子,写进平台因子库,供平台合成为选股因子。
|
||||
子因子;**复合因子 `akg_score` 在桥内用自洽的景气度漏斗算出**,平台只负责注册/存储/
|
||||
调度/评价,不参与合成。
|
||||
|
||||
> 设计与决策依据见 astock-kg `docs/量化因子导出与合成设计.md`。本工程**不 import**
|
||||
> 基座或平台任何代码,只靠 `.env` 里三处数据库连接工作,可单独部署于任意能连通三库的服务器。
|
||||
> 设计与决策依据见本工程根目录 `量化因子导出与合成设计.md`(v2.0,唯一事实源)。
|
||||
> ⚠️ `astock-kg/docs/` 下的同名文档还是 v1,讲的是**已被推翻**的平台拟合合成方案,别读。
|
||||
> 本工程**不 import** 基座或平台任何代码,只靠 `.env` 里三处数据库连接工作,
|
||||
> 可单独部署于任意能连通三库的服务器。
|
||||
|
||||
## 架构(基座出视图,桥算变换,平台算合成)
|
||||
## 架构(基座出视图,桥算因子与合成,平台管存储与评价)
|
||||
|
||||
```
|
||||
astock-kg 基座 PG ── 只读视图(sql/astock_kg_slot_views.sql)
|
||||
v_factor_universe / v_factor_consensus / v_factor_events / v_factor_transmission
|
||||
│ (热度不经基座:桥直连 153 读 stock_fund_heat_scores)
|
||||
▼
|
||||
akg-factor-bridge:读视图+热度 → 算四路日截面(极性/衰减/打分) → 转前缀码 SH600000
|
||||
→ 写平台 t_factor_akg_* + 注册 factor_metadata
|
||||
akg-factor-bridge:读视图+热度 → 四路日截面变换 → 【漏斗合成 akg_score】
|
||||
→ 转前缀码 SH600000 → 写平台 t_factor_akg_* + 注册 factor_metadata
|
||||
→ 每日冻结输入快照 data/frozen/(freeze.py)
|
||||
▼
|
||||
平台 quant_factor_service:把四子因子当普通 single 因子 → 合成/回测/调度
|
||||
平台 quant_factor_service:当普通 single 因子 → 评价(RankIC/分层)=仪表盘 / 调度 / 下游消费
|
||||
```
|
||||
|
||||
- **基座只暴露数据、不算因子**;桥承载因子建模(极性表、衰减、打分——见 `factors.py`);
|
||||
跨信号 alpha 组合在平台侧。
|
||||
- universe = KG 覆盖池(`v_factor_universe` = 全部 `industry_pools` 成员并集),四路都限制其内。
|
||||
- **基座只暴露数据、不算因子**;桥承载全部因子建模(极性表、衰减、门槛、权重);
|
||||
平台不感知 astock-kg。
|
||||
- universe = KG 覆盖池(`v_factor_universe` = 全部 `industry_pools` 成员并集)。
|
||||
⚠️ 它是**当前态**且每周一自动长大——子因子是否受它限制由 `SUBFACTOR_UNIVERSE` 控制。
|
||||
|
||||
## 四个子因子
|
||||
|
||||
| factor_code | 表 | 口径 | 缺失 |
|
||||
|---|---|---|---|
|
||||
| `akg_upside` | t_factor_akg_upside | 目标价中枢/现价−1(as-of) | NaN(无覆盖不出行) |
|
||||
| `akg_heat` | t_factor_akg_heat | 热度分 0~1(最新批次) | NaN |
|
||||
| `akg_event` | t_factor_akg_event | Σ 事件极性×时间衰减 | 0(无事件=中性) |
|
||||
| `akg_transmission` | t_factor_akg_transmission | 路径数×(1−已动比例) | 0 |
|
||||
| `akg_upside` | t_factor_akg_upside | 目标价中枢/现价−1(as-of backward) | NaN(无覆盖不出行) |
|
||||
| `akg_heat` | t_factor_akg_heat | 热度分 0~1(最新批次,T+1 到达) | NaN |
|
||||
| `akg_event` | t_factor_akg_event | Σ 事件极性×时间衰减(**仅公告来源、单文档封顶**) | 不出行 |
|
||||
| `akg_transmission` | t_factor_akg_transmission | **distinct 源数**×(1−已动比例) | 不出行(合成侧填 0) |
|
||||
|
||||
## 用法(全程 Docker,不在宿主机直跑)
|
||||
|
||||
```bash
|
||||
# 0) 基座视图建一次——经 astock-kg 的 postgres 容器(容器名 akg-postgres,库/用户均 akg)
|
||||
# 0) 基座视图建一次/改一次——经 astock-kg 的 postgres 容器(容器名 akg-postgres,库/用户均 akg)
|
||||
docker exec -i akg-postgres psql -U akg -d akg < sql/astock_kg_slot_views.sql
|
||||
|
||||
# 1) 配连接(地址填「桥容器可达」的:跨机=LAN IP;与基座同机同网=服务名,见下「网络」)
|
||||
|
|
@ -45,41 +50,100 @@ cp .env.example .env && vim .env
|
|||
# 2) 构建并起桥容器(常驻)
|
||||
docker compose up -d --build
|
||||
|
||||
# 3) 连通性自检(六项行数全出 = 三库都通)
|
||||
# 3) 连通性 + 视图版本 + 数据前沿自检
|
||||
docker compose exec akg-factor-bridge python run.py views
|
||||
|
||||
# 3.5) G1 体检(只读:池结构 / 行情年表 / upside 分布 / 三项相关矩阵与权重体检)
|
||||
docker compose exec akg-factor-bridge python run.py probe
|
||||
docker compose exec akg-factor-bridge python run.py probe --section corr # 单跑某节
|
||||
# price 节全表聚合,可能 1~3 分钟;corr 节直接回答「0.5/0.3/0.2 是混合还是传导优先」
|
||||
|
||||
# 4) 注册 + 跑因子(幂等,可重跑)
|
||||
docker compose exec akg-factor-bridge python run.py register
|
||||
docker compose exec akg-factor-bridge python run.py build akg_heat --mode daily --date 2026-07-24
|
||||
docker compose exec akg-factor-bridge python run.py build all --mode history --start 2024-01-01 --end 2026-07-24
|
||||
|
||||
# 5) 输入冻结(daily 构建会自动跑一次;也可单独补)
|
||||
docker compose exec akg-factor-bridge python run.py freeze --date 2026-07-24
|
||||
```
|
||||
|
||||
生产触发:宿主 cron 或平台 XXL-JOB →
|
||||
`docker exec akg_factor_bridge python run.py build all --mode daily`。
|
||||
`docker exec akg_factor_bridge python run.py build all --mode daily`(跑完自动冻结)。
|
||||
|
||||
## ❄️ 输入冻结(freeze.py)——为什么每天都要跑
|
||||
|
||||
桥内写入幂等,但**上游不是**:传导的 quiet 集合依赖 Neo4j 返回序(基座 Cypher 无
|
||||
`ORDER BY`)、`industry_pools` 只存最新态且每周一长大、`gp_day_data` 是前复权且
|
||||
锚在最新日(每次除权历史 close 被整体重写)、`consensus_daily` 会被重跑覆盖。
|
||||
|
||||
所以「可复现」唯一诚实可达的定义是:**能从冻结的输入重算出同一个值**。
|
||||
`data/frozen/<日期>/` 每天存一份 universe / 传导原始行 / 一致预期 / 当日收盘 / 热度
|
||||
+ 当日算出的因子值 + `manifest.json`(行数、告警、git rev)。
|
||||
|
||||
它一次性满足:判收标准的「重跑不产生漂移」、「赛道成员快照可审计」、以及未来任何回溯
|
||||
需求(含设计 §10 里 GRU 复活所需的「≥1 年 live 传导史」——**那个计时器只在开始存快照
|
||||
的那天启动**)。`manifest.json` 才是可 diff 的审计线,建议 git 跟踪 manifest、忽略数据文件。
|
||||
|
||||
## 运行旋钮(`.env`,全部可留空取默认)
|
||||
|
||||
| 变量 | 默认 | 作用 |
|
||||
|---|---|---|
|
||||
| `SUBFACTOR_UNIVERSE` | `pool` | 子因子是否受覆盖池限制。`market` 可避免成员性前视与周一跳变(设计评审 §4 建议) |
|
||||
| `EVENT_SOURCE_TYPES` | `announcement` | 事件只认哪些 `documents.source_type`。默认只认公告——年报会把**历史**诉讼记成披露日的当日负面事件 |
|
||||
| `EVENT_MAX_PER_DOC` | `3` | 单文档最多贡献几条事件(一份年报能抽十几条) |
|
||||
| `PRICE_CHUNK_DAYS` | `31` | 行情按月分块读取,防长区间回填 OOM |
|
||||
| `WRITE_CHUNK_ROWS` | `50000` | 因子表分块提交,防单事务过大 |
|
||||
| `FROZEN_ROOT` | `/app/data/frozen` | 冻结落点 |
|
||||
| `UPSTREAM_MEMBER_CAP` / `UPSTREAM_QUIET_CAP` | `30` / `12` | 上游截断体检阈值,命中只告警不拦截 |
|
||||
|
||||
## 网络(Docker)
|
||||
|
||||
桥要连三处,按「桥部署在哪」定地址:
|
||||
|
||||
- **跨机部署(默认,推荐——就是你要的「可部署到其他服务器」)**:三处都用 LAN IP/域名
|
||||
(`.env` 里填)。默认 bridge 网络即可出网到 LAN——前提是基座 PG、153、平台 MySQL 都对该
|
||||
服务器可达(基座 PG 需发布端口或处于可达网络;见设计文档 §8-1)。
|
||||
- **跨机部署(默认,推荐)**:三处都用 LAN IP/域名(`.env` 里填)。默认 bridge 网络即可
|
||||
出网到 LAN——前提是基座 PG、153、平台 MySQL 都对该服务器可达。
|
||||
- **与基座同机同网**:让桥加入 astock-kg 的 compose 网络,用服务名 `postgres` 连基座 PG
|
||||
(取消 `docker-compose.yml` 里 networks 段的注释,填 astock-kg 的网络名)。
|
||||
|
||||
## ⚠️ 待实机核实项(本工程 DB 细节以线上为准,跑不通按此排查)
|
||||
## 已知的上游约束(桥修不了,但会显式告警)
|
||||
|
||||
1. **三库连通性**:`python run.py views` 六项全 ✅ 才算通。任一 ❌ 先解决网络/账号
|
||||
(尤其桥所在服务器到基座 PG、153、平台 MySQL 的可达性)。
|
||||
2. **`gp_day_data` 代码列与形态**:`upside` 现价来自它。列名可能是 `ts_code` 或
|
||||
`symbol`(`.env` 的 `PRICE_CODE_COL`);代码形态(`600000.SH` / `SH600000` / `600000`)
|
||||
两边已统一折前缀式再 join——若 `upside` 出行为 0,多半是形态没对上,在此调 join 口径。
|
||||
跑 `build akg_transmission` 时若看到下列告警,是**基座侧**的问题,需在 astock-kg 修:
|
||||
|
||||
1. **`quiet` 存满 12 条** —— `transmission.py` 的 `quiet[:12]` 是给旁批/展示用的,
|
||||
却把因子的结构上限锁在 `12 候选 × 12 成员 = 144 行/日`。改法:落库存全量,
|
||||
旁批仍只喂前 12。
|
||||
2. **`members_total >= 30`** —— 基座 `graph_store.topic_context` 的 Cypher 是
|
||||
`LIMIT $cap`(默认 30)且**无 `ORDER BY`**,于是 `moved_ratio` 建立在一个任意顺序的
|
||||
≤30 抽样上,同日重跑结果可能不同。改法:加 `ORDER BY`、cap 提到 200。
|
||||
3. **`mkt_trade_date ≠ scan_date`** —— `hotspot._latest_mkt` 取的是
|
||||
`max(trade_date) FROM mkt_daily` 而非 `scan_date` 当天的快照。17:30 `sync_market`
|
||||
晚点时,18:15 的传导扫描会用 T−1 的 movers 写成今日戳。改法:`_latest_mkt` 加
|
||||
`trade_date` 参数 + scan 开头加新鲜度断言。
|
||||
|
||||
(另:`gp_sector_daily` / `gp_market_sentiment` 当前空表、`t_signal_daily_results`
|
||||
05-15 停更 → hotspot 的**板块级异动源整个缺失**,这才是传导每日只有几十行的主因。
|
||||
要提传导覆盖,优先补 `gp_sector_daily`,比调任何参数都有用。)
|
||||
|
||||
## ⚠️ 待实机核实项
|
||||
|
||||
1. **三库连通性**:`python run.py views` 七项全 ✅ 才算通。
|
||||
2. **视图版本**:`views` 会打印 `n_sources / mkt_trade_date / source_type` 是否就绪;
|
||||
有 ⬜ 说明 `sql/astock_kg_slot_views.sql` 还没应用到基座(桥会自动退回旧口径并告警)。
|
||||
3. **`factor_metadata` 列**:`register` 自适应实际列写入;若无 `factor_type` 列,平台
|
||||
`/mining/factors/all` 可能查不到本因子(会打印告警),需与平台侧确认。
|
||||
4. **事件极性/方向(§8-3 开放问题)**:`factors.EVENT_POLARITY / EVENT_DIR` 是草案;
|
||||
`增减持` 的增/减方向若 `qualifiers.direction` 里没有(当前视图取 direction),会落 0,
|
||||
需确认基座 EVENT 的方向到底存在哪个 qualifier 键。半衰期 10 交易日 / 窗口 60 交易日可调。
|
||||
5. **事件交易日龄近似**:v1 用自然日×(5/7) 折算交易日龄,非精确交易日历——够用,后续可
|
||||
换真实交易日历向量化。
|
||||
6. **universe 覆盖面(F0 前置)**:先用 astock-kg 的 `factor_coverage_probe.py` 确认池内
|
||||
四信号日截面覆盖数(尤其热度 ≥30~50/日),再决定是否放量。
|
||||
4. **事件极性/方向**:`factors.EVENT_POLARITY / EVENT_DIR` 是草案(设计 §9-5);
|
||||
`增减持` 的增/减方向若 `qualifiers.direction` 里没有会落 0,需确认基座 EVENT 的方向
|
||||
存在哪个 qualifier 键。半衰期 10 交易日 / 窗口 60 交易日可调。
|
||||
5. **事件交易日龄近似**:v1 用自然日×(5/7) 折算交易日龄,非精确交易日历——够用,
|
||||
后续可换真实交易日历向量化。
|
||||
6. **`documents.source_type` 实际取值**:默认白名单是 `announcement`。若基座里公告的
|
||||
`source_type` 不是这个字符串,事件因子会被过滤成空(会显式告警),按实际值改
|
||||
`EVENT_SOURCE_TYPES`。
|
||||
|
||||
## 未完成(设计 §8 里程碑)
|
||||
|
||||
- **G2**:`config/frontier_tracks.yml` + `tracks.py` + `data/track_members.csv`
|
||||
(赛道门槛 C;需要基座先出第五/六个插槽视图 `v_factor_segment_members` /
|
||||
`v_factor_segment_edges`——`industry_pools` 成员级没有 segment/layer,图谱在 Neo4j)。
|
||||
- **G3**:漏斗合成 `akg_score` + `akg_gate`(0/1 全池出行,让平台分层能读出门槛的价值)。
|
||||
- **G4**:回填与仪表盘(upside 历史重建走「给基座 `sync_consensus` 加 `asof` 参数」)。
|
||||
|
|
|
|||
72
common.py
72
common.py
|
|
@ -1,8 +1,9 @@
|
|||
"""共用:股票码规范化、覆盖池、幂等写因子表、注册 factor_metadata(自适应列)。"""
|
||||
"""共用:股票码规范化、覆盖池、交易日历、幂等写因子表、注册 factor_metadata(自适应列)。"""
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import config
|
||||
import db
|
||||
|
||||
|
||||
|
|
@ -16,17 +17,52 @@ def to_prefix(ts_code: str) -> str:
|
|||
|
||||
|
||||
def load_universe() -> set[str]:
|
||||
"""KG 覆盖池 ts_code 集合(600000.SH 形态),来自基座视图 v_factor_universe。"""
|
||||
"""KG 覆盖池 ts_code 集合(600000.SH 形态),来自基座视图 v_factor_universe。
|
||||
|
||||
⚠️ 这是**当前态**:industry_pools 只存最新快照、每周一 refresh_pools 自动长大。
|
||||
用它过滤历史数据会引入成员性前视——见 universe_filter 与 config.SUBFACTOR_UNIVERSE。
|
||||
"""
|
||||
df = db.read_pg("SELECT ts_code FROM v_factor_universe")
|
||||
return set(df["ts_code"].astype(str).str.strip())
|
||||
|
||||
|
||||
def universe_filter(df: pd.DataFrame, col: str = "stock_code",
|
||||
as_prefix: bool = True) -> pd.DataFrame:
|
||||
"""按 config.SUBFACTOR_UNIVERSE 决定子因子是否受覆盖池限制。
|
||||
|
||||
'pool'(默认,保持原行为):只留池内成员。
|
||||
'market'(评审 §4 建议):全市场出行,池只在 akg_score 侧当过滤器。
|
||||
|
||||
为什么建议 market:industry_pools 只存最新态且每周一长大,用它过滤历史子因子,
|
||||
值是点时正确的、**成员性却是前视的**(用今天的池去筛历史,而今天的池里有大量
|
||||
主题是后来的研报才长出来的——正是 §2.2 批评传导时用的同一条论证);
|
||||
同时截面统计量会在每周一发生结构性跳变,IC 序列带周期性伪影。
|
||||
子因子表是「可独立观察的仪表」,过滤该发生在消费端。
|
||||
"""
|
||||
if config.SUBFACTOR_UNIVERSE == "pool":
|
||||
uni = load_universe()
|
||||
if as_prefix:
|
||||
uni = {to_prefix(x) for x in uni}
|
||||
return df[df[col].astype(str).str.strip().isin(uni)]
|
||||
return df
|
||||
|
||||
|
||||
def trading_days(start: str, end: str) -> list:
|
||||
"""目标区间交易日历——用热度表(153,日频、覆盖广)的 distinct trade_date 近似。"""
|
||||
df = db.read_mysql("heat",
|
||||
"SELECT DISTINCT trade_date FROM stock_fund_heat_scores "
|
||||
"WHERE trade_date BETWEEN %s AND %s ORDER BY trade_date",
|
||||
"""目标区间交易日历。
|
||||
|
||||
⚠️ 曾用热度表 stock_fund_heat_scores —— 但它最早只有 2025-03-26,
|
||||
导致 `build akg_event --mode history --start 2024-01-01`(README 里的示例命令)
|
||||
拿到空日历 → 静默返回空 → 只打印「无数据(跳过)」,不报错。
|
||||
改用行情表 gp_day_data(实测 5584 天,覆盖全历史)。
|
||||
"""
|
||||
df = db.read_mysql(
|
||||
"price",
|
||||
"SELECT DISTINCT `timestamp` AS trade_date FROM gp_day_data "
|
||||
"WHERE `timestamp` BETWEEN %s AND %s ORDER BY `timestamp`",
|
||||
(start, end))
|
||||
if df.empty:
|
||||
print(f" ⚠️ 交易日历为空(gp_day_data 在 {start}~{end} 无数据)"
|
||||
f"——上层会跳过该区间,请核对区间与行情表覆盖")
|
||||
return list(pd.to_datetime(df["trade_date"]))
|
||||
|
||||
|
||||
|
|
@ -49,9 +85,14 @@ def ensure_table(table: str) -> None:
|
|||
conn.commit()
|
||||
|
||||
|
||||
def write_factor(table: str, df: pd.DataFrame, mode: str) -> None:
|
||||
def write_factor(table: str, df: pd.DataFrame, mode: str = "daily") -> None:
|
||||
"""df 列 [trade_date, stock_code, factor_value] → 幂等写因子表。
|
||||
daily/history 都是「删涉及日期区间 → 批插」。stock_code 统一转前缀式。"""
|
||||
daily/history 都是「删涉及日期区间 → 批插」。stock_code 统一转前缀式。
|
||||
|
||||
分块提交(评审):热度全史约 54 万行,原来单事务 executemany 走 ShardingSphere
|
||||
代理有风险。改成 DELETE 一个事务 + INSERT 按 config.WRITE_CHUNK_ROWS 分块提交。
|
||||
代价是中途失败会留下部分区间——但整段写入按日期区间幂等,重跑即修复。
|
||||
"""
|
||||
if df is None or df.empty:
|
||||
print(f" {table}: 无数据(跳过)")
|
||||
return
|
||||
|
|
@ -66,13 +107,20 @@ def write_factor(table: str, df: pd.DataFrame, mode: str) -> None:
|
|||
ensure_table(table)
|
||||
rows = list(df[["trade_date", "stock_code", "factor_value"]]
|
||||
.itertuples(index=False, name=None))
|
||||
chunk = max(1, config.WRITE_CHUNK_ROWS)
|
||||
with db.factor_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {table} WHERE trade_date BETWEEN %s AND %s", (dmin, dmax))
|
||||
cur.executemany(
|
||||
f"INSERT INTO {table} (trade_date, stock_code, factor_value) VALUES (%s,%s,%s)",
|
||||
rows)
|
||||
cur.execute(f"DELETE FROM {table} WHERE trade_date BETWEEN %s AND %s",
|
||||
(dmin, dmax))
|
||||
conn.commit()
|
||||
for i in range(0, len(rows), chunk):
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
f"INSERT INTO {table} (trade_date, stock_code, factor_value) "
|
||||
f"VALUES (%s,%s,%s)", rows[i:i + chunk])
|
||||
conn.commit()
|
||||
if len(rows) > chunk:
|
||||
print(f" ...{min(i + chunk, len(rows))}/{len(rows)}")
|
||||
print(f" {table}: 写入 {len(rows)} 行, 日期 {dmin}~{dmax}")
|
||||
|
||||
|
||||
|
|
|
|||
49
config.py
49
config.py
|
|
@ -1,7 +1,7 @@
|
|||
"""连接配置:全部从环境变量/.env 读取,不硬编码任何主机。
|
||||
"""连接配置与运行旋钮:全部从环境变量/.env 读取,不硬编码任何主机。
|
||||
|
||||
三处连接:
|
||||
AKG_PG_* —— astock-kg 基座 PostgreSQL(读四个只读视图,只读账号即可)
|
||||
AKG_PG_* —— astock-kg 基座 PostgreSQL(读只读视图,只读账号即可)
|
||||
HEAT_MYSQL_* —— 153 代理 MySQL(读 stock_fund_heat_scores 热度)
|
||||
FACTOR_MYSQL_* —— 平台 PROXY_DB_URL 指向的 MySQL(写 t_factor_* + factor_metadata)
|
||||
现价来源 PRICE_MYSQL_*(upside 用)默认复用 FACTOR_MYSQL_* 同实例。
|
||||
|
|
@ -60,6 +60,49 @@ def price_mysql() -> Conn:
|
|||
_opt("PRICE_MYSQL_DB", _req("FACTOR_MYSQL_DB")))
|
||||
|
||||
|
||||
# gp_day_data 股票代码列名(待实机核实:ts_code 或 symbol)
|
||||
# gp_day_data 股票代码列名(平台实测 = symbol;代码仍会自动兜底逐个试)
|
||||
PRICE_CODE_COL = os.environ.get("PRICE_CODE_COL", "ts_code")
|
||||
FACTOR_API_BASE = os.environ.get("FACTOR_API_BASE", "").rstrip("/")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 运行旋钮(2026-07-26 评审引入)。全部有默认值,不填也能跑。
|
||||
# ============================================================================
|
||||
|
||||
# --- 子因子是否受覆盖池限制:pool(现状)| market(评审 §4 建议)------------
|
||||
# industry_pools 只存最新态、每周一 refresh_pools 自动长大。用它过滤**历史**子因子
|
||||
# 会引入成员性前视(值点时正确、成员性前视——这是 §2.2 批传导用的同一条论证,
|
||||
# 上升了一层),且截面统计量每周一结构性跳变。
|
||||
# 子因子表的定位是「可独立观察的仪表」,过滤应发生在消费端(akg_score),不在这里。
|
||||
# 传导不受此开关影响——它天生就是池内语义。
|
||||
SUBFACTOR_UNIVERSE = os.environ.get("SUBFACTOR_UNIVERSE", "pool").strip().lower()
|
||||
|
||||
# --- 行情读取按月分块 -------------------------------------------------------
|
||||
# 原来一次拉全区间,`--mode history --start 2006-01-01` 会把千万级行拉进 pandas。
|
||||
PRICE_CHUNK_DAYS = int(os.environ.get("PRICE_CHUNK_DAYS", "31"))
|
||||
|
||||
# --- 因子表写入分块行数 -----------------------------------------------------
|
||||
# 热度全史约 322×1674≈54 万行,原来单事务 executemany 走 ShardingSphere 代理有风险。
|
||||
WRITE_CHUNK_ROWS = int(os.environ.get("WRITE_CHUNK_ROWS", "50000"))
|
||||
|
||||
# --- 事件来源白名单与单文档封顶(评审 §6.5:年报污染)----------------------
|
||||
# documents.source_type 取值(astock-kg db/postgres/init.sql:12 已核实):
|
||||
# annual_report / research_report / announcement / news / interactive_qa
|
||||
# 年报也带 company_ts_code 文档锚,而一份年报能抽十几条 EVENT,且含**历史**诉讼/
|
||||
# 处罚——会在年报披露日形成巨大负值尖峰。S2 若用 akg_event < −θ_e 做否决闸,
|
||||
# 会因为一份年报提到历史诉讼而否决掉一只好股。默认只认公告。
|
||||
EVENT_SOURCE_TYPES = {
|
||||
s.strip() for s in
|
||||
os.environ.get("EVENT_SOURCE_TYPES", "announcement").split(",") if s.strip()}
|
||||
EVENT_MAX_PER_DOC = int(os.environ.get("EVENT_MAX_PER_DOC", "3"))
|
||||
|
||||
# --- 输入冻结落点(freeze.py)----------------------------------------------
|
||||
# 容器内路径;docker-compose 已把仓库根挂到 /app,故默认落在仓库的 data/frozen/。
|
||||
FROZEN_ROOT = os.environ.get("FROZEN_ROOT", "/app/data/frozen")
|
||||
|
||||
# --- 上游截断体检阈值(评审 硬伤1)-----------------------------------------
|
||||
# 基座 graph_store.topic_context 的 Cypher 是 `LIMIT $cap`(默认 30)且无 ORDER BY;
|
||||
# transmission.scan 落库时又有 `quiet[:12]`。命中这两个数就说明上游被截断了,
|
||||
# 该行的 moved_ratio 建立在任意抽样上、覆盖也被展示逻辑锁住。
|
||||
UPSTREAM_MEMBER_CAP = int(os.environ.get("UPSTREAM_MEMBER_CAP", "30"))
|
||||
UPSTREAM_QUIET_CAP = int(os.environ.get("UPSTREAM_QUIET_CAP", "12"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,691 @@
|
|||
# 评审问题的修复方案(2026-07-26)
|
||||
|
||||
> 按依赖顺序分三批。**第一批**(桥侧 + 视图)不依赖任何决策、不动基座,今天就能跑;
|
||||
> **第二批**(基座侧)是确定性缺陷修复,需要动 astock-kg 代码;
|
||||
> **第三批**(赛道门槛 C 与权重结构)需要你先拍板,我给出两种拍法各自的代码形态。
|
||||
>
|
||||
> 附件:`astock_kg_slot_views_v2.sql`(完整替换)、`freeze.py`(新文件,直接放桥根目录)。
|
||||
> 所有改动都保持"桥只连三库、基座不算因子"的边界。
|
||||
|
||||
---
|
||||
|
||||
## 0. 数据安全性核对:已抽取的成果一份都不会白费
|
||||
|
||||
已逐表核实(`db/postgres/init.sql` + 全仓 grep),结论先行:
|
||||
**本方案的全部改动都不触碰 `claims` / `documents`,不需要 replay,不需要重抽。**
|
||||
|
||||
### 0.1 每一处改动写了什么
|
||||
|
||||
| 改动 | 写入对象 | 对已有数据的影响 |
|
||||
|---|---|---|
|
||||
| 1.1 视图替换 | 视图(不含数据) | 无 |
|
||||
| 1.1 `ALTER ... ADD COLUMN IF NOT EXISTS mkt_trade_date` | `transmission_candidates` 加可空列 | 无(已存行取 NULL) |
|
||||
| 1.1 `CREATE INDEX IF NOT EXISTS` | 索引 | 无 |
|
||||
| 1.2–1.8 桥侧补丁 | 平台 MySQL `t_factor_*` | 只动平台侧派生表,可随时重算 |
|
||||
| 1.9 `freeze.py` | 桥工程 `data/frozen/` | 纯新增文件 |
|
||||
| 2.1 `topic_context` 加 `ORDER BY` | **只读 Cypher** | 无 |
|
||||
| 2.2 `transmission.scan` | `transmission_candidates`(按 `scan_date` 删后插的日台账) | 只影响新扫描日 |
|
||||
| 2.3 `hotspot._latest_mkt` 加参数 | `hotspot_candidates`(同为日台账) | 只影响新扫描日 |
|
||||
| 2.4 `sync_consensus(asof)` | `consensus_daily`(主键 `ts_code, asof_date`) | 回填是**新增** asof 行;不覆盖历史 |
|
||||
| 2.5 `industry_pools_history` | 新表 | 纯新增 |
|
||||
|
||||
**没有一处 DELETE / UPDATE / TRUNCATE 落在抽取产物上。** 全仓 grep 也确认:
|
||||
`claims` / `documents` / `mkt_daily` / `company_master` 全库没有任何删除或清空语句
|
||||
(`claims` 的 DDL 注释就写着 `---- 断言表:只追加 ----`,并有
|
||||
`CONSTRAINT uq_claim_dedup UNIQUE (dedup_key)` 保证幂等)。
|
||||
|
||||
### 0.2 为什么架构上就不会白费
|
||||
|
||||
抽取的产物是 `claims` + `documents`,它们是**事实源**;Neo4j 里的 belief 图是**投影**。
|
||||
`replay_all()` 的 docstring 说得很明白:"从 claim 层全量重建 belief 图:
|
||||
只跑融合规则,**不重新抽取、不调用任何 LLM**。因此确定、便宜、稳定。"
|
||||
|
||||
所以就算将来真要动本体/闸门/融合规则,代价也只是一次 replay(12297 条约 80 秒),
|
||||
claims 无损。真正昂贵的 re-extraction 是另一件事,`claims.extractor_model` 这一列
|
||||
就是专门为"按模型版本圈定重抽范围"留的——架构本来就是为这种演进设计的。
|
||||
|
||||
**而本方案连 replay 都不需要**:2.1 是只读查询,2.2–2.5 全部写 PG 应用层表,
|
||||
没有一处改动落在融合规则或 Neo4j 投影路径上。
|
||||
|
||||
### 0.3 需要你知道的三点(不是"白费",但要说清)
|
||||
|
||||
1. **已存的 3 天传导台账修不"好",但也不用修。**
|
||||
`transmission_candidates` 现有的 07-11~07-23 三天,`quiet` 是被 `[:12]` 截断的、
|
||||
`moved_ratio` 建立在任意 ≤30 抽样上。修法上线后新扫描才正确。
|
||||
**好消息**:`mkt_daily` 是持久快照表(`trade_date` 在主键里、全库无删除),
|
||||
所以历史 movers 其实**是可重建的** —— 2.2/2.3 改完之后,
|
||||
`transmission.scan(scan_date=D, allow_stale=True)` 能对任意有 mkt 快照的 D 重扫。
|
||||
(这也顺带修正了 v1.1 文档里"历史 movers 难重建"的判断——难的不是 movers,
|
||||
是当时的**图谱状态**。)
|
||||
**但我建议不要重扫这三天**:会用今天的图谱去套那三天,而 44079 份公告正在
|
||||
以 200 份/30 分钟入库,图谱这两周变化不小 —— 那就是标准的成员性前视。
|
||||
这三天目前也没被任何东西消费,直接从修好之后重新起算最干净。
|
||||
|
||||
2. **新鲜度断言会让传导"少产出",这是有意的。**
|
||||
2.2 加的断言在 `mkt_daily` 最新 stock 快照 ≠ `scan_date` 时**中止扫描**。
|
||||
在公告批量入库期间,如果 beat 被饿到(就是 `announcement-corpus-bulk-load`
|
||||
记的那个单队列风险),17:30 快照晚点 → 当天传导直接没有产出。
|
||||
这比"用 T−1 的 movers 生成带今日戳的候选"好,但你要预期到它会偶发触发,
|
||||
也意味着 live 传导史积累得慢一点。队列分离那次修复的实机验证,
|
||||
现在多了一个理由要尽快做完。
|
||||
|
||||
3. **唯一可能产生 LLM 花费的是"提环节覆盖",但它也不浪费任何东西。**
|
||||
如果 G1 体检发现赛道覆盖太薄,对策是 `segment_backfill`(环节专项遍存量重抽)。
|
||||
它零新语料、`dedup_key` 幂等、只追加不删旧 claims —— 花的是 brain 调用,
|
||||
不是把已有成果作废。
|
||||
|
||||
### 0.4 落地前后的自查(跑一遍留个数,最稳)
|
||||
|
||||
```bash
|
||||
# 改动前后各跑一次,两次输出应完全一致(除 transmission_candidates 的列数 +1)
|
||||
docker exec -i akg-postgres psql -U akg -d akg <<'SQL'
|
||||
SELECT 'claims' t, count(*) n, max(ingestion_date)::date latest FROM claims
|
||||
UNION ALL SELECT 'documents', count(*), max(ingested_at)::date FROM documents
|
||||
UNION ALL SELECT 'entity_links', count(*), max(created_at)::date FROM entity_links
|
||||
UNION ALL SELECT 'company_master', count(*), max(synced_at)::date FROM company_master
|
||||
UNION ALL SELECT 'consensus_daily', count(*), max(asof_date) FROM consensus_daily
|
||||
UNION ALL SELECT 'mkt_daily', count(*), max(trade_date) FROM mkt_daily
|
||||
UNION ALL SELECT 'industry_pools', count(*), max(refreshed_at)::date FROM industry_pools;
|
||||
|
||||
-- 顺带回答一个对回填很关键的问题:mkt_daily 的 stock 切片到底存了多少天?
|
||||
-- (它决定了传导"理论上"能重扫到哪一天,也决定了 §7 回填的真实边界)
|
||||
SELECT kind, min(trade_date), max(trade_date), count(DISTINCT trade_date) days
|
||||
FROM mkt_daily GROUP BY kind ORDER BY kind;
|
||||
SQL
|
||||
```
|
||||
|
||||
最后那条查询的结果请一并发我 —— 如果 `kind='stock'` 的天数明显多于 3 天,
|
||||
设计文档 §7 里"传导只 live 累积"这一条的**边界可以往前推**(图谱漂移仍在,
|
||||
但至少多了一个"用当时 movers + 今天图谱"的、带明确标注的近似区间可选)。
|
||||
|
||||
---
|
||||
|
||||
## 第一批 · 桥侧 + 视图(零决策,今天可落)
|
||||
|
||||
### 1.1 `sql/astock_kg_slot_views.sql` → 用附件 `astock_kg_slot_views_v2.sql` 整体替换
|
||||
|
||||
三处变更:`n_paths` → `n_sources`(修重复计数);暴露 `target/members_total/moved/
|
||||
n_quiet_stored/mkt_trade_date`(让桥能自检上游截断与快照新鲜度);`v_factor_events`
|
||||
补 `doc_id/source_type/tier`(供年报封顶)。文件里带一段幂等 `ALTER TABLE`,一起跑即可。
|
||||
|
||||
```bash
|
||||
docker exec -i akg-postgres psql -U akg -d akg < sql/astock_kg_slot_views.sql
|
||||
```
|
||||
|
||||
**数据安全性**:只建视图 + 加一个可空列 + 加索引,**不写不改不删任何一行数据**。
|
||||
`claims` / `documents` 完全不动,无需 replay、无需重抽。
|
||||
|
||||
✅ 列名已核实(不用再探):`documents.source_type`,取值
|
||||
`annual_report / research_report / announcement / news / interactive_qa`
|
||||
(`db/postgres/init.sql:12`)。`claims` 有 `tier / confidence / dedup_key / doc_id /
|
||||
subject_norm`(`init.sql:22-58, 263`),`entity_links(alias_norm, canonical_id, entity_type)`、
|
||||
`company_master(ts_code, short_name)` 均存在(`init.sql:230-251`)——
|
||||
第三批 3.1 的救急 SQL 依赖的列全部对得上。
|
||||
|
||||
### 1.2 `common.py` — 交易日历改用行情表(**修硬伤 3**)
|
||||
|
||||
```python
|
||||
def trading_days(start: str, end: str) -> list:
|
||||
"""目标区间交易日历。
|
||||
|
||||
⚠️ 曾用热度表(stock_fund_heat_scores) —— 它最早只有 2026-03-26 前后,
|
||||
导致 `build akg_event --mode history --start 2024-01-01` 静默返回空
|
||||
(cal 为空 → _EMPTY → 只打印"无数据(跳过)",不报错)。
|
||||
改用行情表 gp_day_data(5584 天,覆盖全历史)。"""
|
||||
df = db.read_mysql(
|
||||
"price",
|
||||
"SELECT DISTINCT `timestamp` AS trade_date FROM gp_day_data "
|
||||
"WHERE `timestamp` BETWEEN %s AND %s ORDER BY `timestamp`",
|
||||
(start, end))
|
||||
if df.empty:
|
||||
print(f" ⚠️ 交易日历为空(gp_day_data 在 {start}~{end} 无数据)"
|
||||
f"——上层会跳过该区间,请核对区间与行情表覆盖")
|
||||
return list(pd.to_datetime(df["trade_date"]))
|
||||
```
|
||||
|
||||
### 1.3 `common.py` — 分块提交(**修放量会炸**)
|
||||
|
||||
```python
|
||||
def write_factor(table: str, df: pd.DataFrame, mode: str = "daily") -> None:
|
||||
"""df[trade_date, stock_code, factor_value] → 幂等写因子表。
|
||||
|
||||
分块提交(评审):热度全史约 322×1674≈54 万行,原来单事务 executemany
|
||||
走 ShardingSphere 代理有风险。改成 DELETE 一个事务 + INSERT 按块提交。
|
||||
代价是中途失败会留下部分区间——但整个写入按区间幂等,重跑即修复。"""
|
||||
if df is None or df.empty:
|
||||
print(f" {table}: 无数据(跳过)")
|
||||
return
|
||||
df = df.dropna(subset=["trade_date", "stock_code", "factor_value"]).copy()
|
||||
df["stock_code"] = df["stock_code"].map(to_prefix)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
|
||||
df = df.drop_duplicates(["trade_date", "stock_code"], keep="last")
|
||||
if df.empty:
|
||||
print(f" {table}: 清洗后无数据")
|
||||
return
|
||||
dmin, dmax = df["trade_date"].min(), df["trade_date"].max()
|
||||
ensure_table(table)
|
||||
rows = list(df[["trade_date", "stock_code", "factor_value"]]
|
||||
.itertuples(index=False, name=None))
|
||||
chunk = config.WRITE_CHUNK_ROWS
|
||||
with db.factor_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {table} WHERE trade_date BETWEEN %s AND %s",
|
||||
(dmin, dmax))
|
||||
conn.commit()
|
||||
for i in range(0, len(rows), chunk):
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
f"INSERT INTO {table} (trade_date, stock_code, factor_value) "
|
||||
f"VALUES (%s,%s,%s)", rows[i:i + chunk])
|
||||
conn.commit()
|
||||
if len(rows) > chunk:
|
||||
print(f" ...{min(i + chunk, len(rows))}/{len(rows)}")
|
||||
print(f" {table}: 写入 {len(rows)} 行, 日期 {dmin}~{dmax}")
|
||||
```
|
||||
|
||||
顶部加 `import config`。
|
||||
|
||||
### 1.4 `common.py` — universe 过滤改成可切换(**评审 §4**)
|
||||
|
||||
```python
|
||||
def universe_filter(df: pd.DataFrame, col: str = "stock_code",
|
||||
as_prefix: bool = True) -> pd.DataFrame:
|
||||
"""按 config.SUBFACTOR_UNIVERSE 决定子因子是否受覆盖池限制。
|
||||
|
||||
默认 'pool'(保持现状)。**建议改 'market'**:industry_pools 只存最新态、
|
||||
每周一 refresh_pools 自动长大,用它过滤历史子因子会引入成员性前视
|
||||
(§2.2 批传导用的同一条论证,上升一层),且 z 统计量每周一结构性跳变。
|
||||
子因子表的定位是"可独立观察的仪表",过滤该发生在消费端(akg_score)而不是这里。"""
|
||||
if config.SUBFACTOR_UNIVERSE != "pool":
|
||||
return df
|
||||
uni = load_universe()
|
||||
if as_prefix:
|
||||
uni = {to_prefix(x) for x in uni}
|
||||
return df[df[col].astype(str).str.strip().isin(uni)]
|
||||
```
|
||||
|
||||
`build_heat` / `build_upside` / `build_event` 里的 `uni = ...` + `isin(uni)` 都换成调它。
|
||||
`build_transmission` **不换** —— 传导天生就是池内语义。
|
||||
|
||||
### 1.5 `config.py` — 追加四个旋钮
|
||||
|
||||
```python
|
||||
# 子因子是否受覆盖池限制:pool(现状)| market(评审建议,避免成员性前视)
|
||||
SUBFACTOR_UNIVERSE = os.environ.get("SUBFACTOR_UNIVERSE", "pool").lower()
|
||||
# 行情读取按月分块(防 `--start 2006` 把千万行拉进 pandas)
|
||||
PRICE_CHUNK_DAYS = int(os.environ.get("PRICE_CHUNK_DAYS", "31"))
|
||||
# 因子表写入分块行数
|
||||
WRITE_CHUNK_ROWS = int(os.environ.get("WRITE_CHUNK_ROWS", "50000"))
|
||||
# 单文档最多贡献几条事件(年报能抽十几条,见评审 §6.5)
|
||||
EVENT_MAX_PER_DOC = int(os.environ.get("EVENT_MAX_PER_DOC", "3"))
|
||||
# 事件只认哪些来源(documents.source_type)。默认只认公告——年报里的"历史诉讼"
|
||||
# 会被记成披露日的当日负面事件,是 S2 否决闸最危险的假信号来源。
|
||||
# 想放宽就填 "announcement,annual_report"。
|
||||
EVENT_SOURCE_TYPES = {
|
||||
s.strip() for s in
|
||||
os.environ.get("EVENT_SOURCE_TYPES", "announcement").split(",") if s.strip()}
|
||||
```
|
||||
|
||||
`.env.example` 同步加这四行(都给默认值,可不填)。
|
||||
|
||||
### 1.6 `factors.py` — 行情按月分块(**修放量会炸**)
|
||||
|
||||
```python
|
||||
def _read_gp_price(start, end):
|
||||
"""gp_day_data 现价。两处修正(评审):
|
||||
① 按月分块 —— 原来一次拉全区间,`--mode history --start 2006-01-01`
|
||||
会把千万级行拉进 pandas;
|
||||
② 不在 SQL 里按代码过滤 —— 代码形态(600000.SH / SH600000 / 600000)
|
||||
两边不一致,SQL 过滤容易全空且难排查,改在 pandas 侧折前缀后过滤。"""
|
||||
cands = [config.PRICE_CODE_COL] + [c for c in ("symbol", "ts_code")
|
||||
if c != config.PRICE_CODE_COL]
|
||||
col, last = None, None
|
||||
for c in cands: # 先用 LIMIT 1 探列名,别用全区间去试错
|
||||
try:
|
||||
db.read_mysql("price", f"SELECT `{c}` FROM gp_day_data LIMIT 1")
|
||||
col = c
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = e
|
||||
if col is None:
|
||||
raise RuntimeError(f"gp_day_data 代码列都不行(试了 {cands}): {last!r}")
|
||||
print(f" (upside 现价用 gp_day_data.{col})")
|
||||
|
||||
parts, cur = [], pd.Timestamp(start)
|
||||
endts = pd.Timestamp(end)
|
||||
while cur <= endts:
|
||||
hi = min(cur + pd.Timedelta(days=config.PRICE_CHUNK_DAYS - 1), endts)
|
||||
parts.append(db.read_mysql(
|
||||
"price",
|
||||
f"SELECT `timestamp` AS trade_date, `{col}` AS ts_code, close "
|
||||
f"FROM gp_day_data WHERE `timestamp` BETWEEN %s AND %s",
|
||||
(cur.date().isoformat(), hi.date().isoformat())))
|
||||
cur = hi + pd.Timedelta(days=1)
|
||||
return (pd.concat(parts, ignore_index=True) if parts
|
||||
else pd.DataFrame(columns=["trade_date", "ts_code", "close"]))
|
||||
```
|
||||
|
||||
### 1.7 `factors.py` — 传导改用 `n_sources` + 上游截断自检(**修硬伤 2**)
|
||||
|
||||
```python
|
||||
def build_transmission(start, end):
|
||||
"""传导分 = 指向该股所在环节的 **distinct 源数** ×(1 − 已动比例);
|
||||
同股同日多候选取最大。
|
||||
|
||||
口径修正(评审 硬伤2):原用 n_paths = jsonb_array_length(paths),
|
||||
但 cascade() 的变长边 *1..N 会把 A→B 与 A→X→B 各返回一条,
|
||||
transmission_targets() 的 updown/supply/drives 三桶之间也不去重,
|
||||
于是同一 source 对同一 target 重复计入。改用 distinct source 数,
|
||||
也更贴合传导模块自述的"多源汇聚 = 传导逻辑更硬"。"""
|
||||
tr = db.read_pg(
|
||||
"SELECT scan_date, target, ts_code, n_sources, n_paths_raw, moved_ratio, "
|
||||
" members_total, n_quiet_stored, mkt_trade_date "
|
||||
"FROM v_factor_transmission WHERE scan_date BETWEEN %s AND %s",
|
||||
(start, end))
|
||||
if tr.empty:
|
||||
return _EMPTY
|
||||
|
||||
# ---- 上游失真自检:把三个"静默失真"变成显式告警(评审 硬伤1、§6.2)----
|
||||
if tr["mkt_trade_date"].notna().any():
|
||||
bad = tr[tr["mkt_trade_date"].astype(str) != tr["scan_date"].astype(str)]
|
||||
if not bad.empty:
|
||||
print(f" ⚠️ {bad['scan_date'].nunique()} 个 scan_date 的 movers 快照日"
|
||||
f"与 scan_date 不符(17:30 快照晚点)——这些日的传导项不可信")
|
||||
if (tr["members_total"] >= 30).any():
|
||||
n = tr.loc[tr["members_total"] >= 30, "target"].nunique()
|
||||
print(f" ⚠️ {n} 个环节 members_total>=30,撞上游 topic_context cap"
|
||||
f"——moved_ratio 建立在任意 ≤30 抽样上(先修基座再放量)")
|
||||
if (tr["n_quiet_stored"] >= 12).any():
|
||||
n = tr.loc[tr["n_quiet_stored"] >= 12, "target"].nunique()
|
||||
print(f" ⚠️ {n} 个环节 quiet 存满 12 条,撞 quiet[:12] 截断"
|
||||
f"——真实未动成员更多,因子覆盖被展示逻辑锁住")
|
||||
|
||||
tr["factor_value"] = (tr["n_sources"].astype(float)
|
||||
* (1.0 - pd.to_numeric(tr["moved_ratio"],
|
||||
errors="coerce").fillna(0.0)))
|
||||
g = (tr.groupby(["scan_date", "ts_code"])["factor_value"].max().reset_index()
|
||||
.rename(columns={"scan_date": "trade_date", "ts_code": "stock_code"}))
|
||||
return g[["trade_date", "stock_code", "factor_value"]]
|
||||
```
|
||||
|
||||
### 1.8 `factors.py` — 事件的年报污染防护(**S2 前必修,现在顺手做**)
|
||||
|
||||
`build_event` 里读完 `ev` 之后、算 `pol` 之前插入:
|
||||
|
||||
```python
|
||||
# ---- 年报污染防护(评审 §6.5)----
|
||||
# v_factor_events 的锚 documents.meta->>'company_ts_code' 年报也有,而一份年报
|
||||
# 能抽十几条 EVENT,且含**历史**诉讼/处罚 —— 会在年报披露日形成巨大负值尖峰。
|
||||
# hotspot._pick_event_anomalies 为此专门做了防刷屏(other 不进 / 同主体同类型
|
||||
# 去重 / 单主体≤2),桥侧原来零保护。三道,从强到弱:
|
||||
if "source_type" in ev.columns:
|
||||
# documents.source_type ∈ annual_report / research_report / announcement
|
||||
# / news / interactive_qa (init.sql:12 已核实)
|
||||
keep = ev["source_type"].astype(str).isin(config.EVENT_SOURCE_TYPES)
|
||||
if (~keep).any():
|
||||
drop_by = ev.loc[~keep, "source_type"].value_counts().to_dict()
|
||||
print(f" (事件:按 source_type 剔除 {int((~keep).sum())} 条 {drop_by})")
|
||||
ev = ev[keep]
|
||||
# 同主体+同类型+同披露日只留置信度最高的一条
|
||||
if "confidence" in ev.columns:
|
||||
ev = ev.sort_values("confidence", ascending=False, na_position="last")
|
||||
ev = ev.drop_duplicates(["ts_code", "event_type", "direction", "disclosure_date"])
|
||||
# 单文档封顶:一份文档最多贡献 EVENT_MAX_PER_DOC 条
|
||||
if "doc_id" in ev.columns:
|
||||
ev = ev.groupby("doc_id", group_keys=False).head(config.EVENT_MAX_PER_DOC)
|
||||
if ev.empty:
|
||||
return _EMPTY
|
||||
```
|
||||
|
||||
`build_event` 的 SQL 同步改成 `SELECT ts_code, disclosure_date, event_type, direction,
|
||||
confidence, doc_id, doc_type FROM v_factor_events WHERE ...`。
|
||||
|
||||
另外 `cal` 为空时别静默返回(1.2 已在 `trading_days` 里加了告警,这里再补一句):
|
||||
|
||||
```python
|
||||
cal = common.trading_days(start, end)
|
||||
if not cal:
|
||||
print(f" ⚠️ {start}~{end} 无交易日 → 事件因子空转(不是"没有事件")")
|
||||
return _EMPTY
|
||||
```
|
||||
|
||||
### 1.9 `freeze.py` — 新文件,直接放桥根目录(**评审 §5,最高优先级**)
|
||||
|
||||
见附件。这一条是**唯一有时间不可逆性的**:传导、池、Segment 边、前复权 close
|
||||
都是过期即不可复原,`§10` 里"GRU 复活需 ≥1 年 live 传导史"这个计时器只在
|
||||
开始存快照那天启动。它同时一次性满足判收标准里的"重跑不漂移"与"赛道成员可审计"。
|
||||
|
||||
`run.py` 三处小改:
|
||||
|
||||
```python
|
||||
# ① 顶部 subparser 加
|
||||
sub.add_parser("freeze").add_argument("--date")
|
||||
|
||||
# ② cmd_build 收集产物并在末尾冻结
|
||||
def cmd_build(which, mode, start, end, date, do_freeze=True):
|
||||
...
|
||||
frames = {}
|
||||
for code in codes:
|
||||
...
|
||||
df = factors.BUILDERS[code](start, end)
|
||||
frames[f"factor_{code}"] = df
|
||||
common.write_factor(factors.FACTORS[code], df, mode)
|
||||
if do_freeze and mode == "daily":
|
||||
import freeze
|
||||
freeze.snapshot(start, extra_frames=frames)
|
||||
|
||||
# ③ dispatch
|
||||
elif a.cmd == "freeze":
|
||||
import freeze
|
||||
freeze.snapshot(a.date)
|
||||
```
|
||||
|
||||
`build` 子命令加一个 `--no-freeze`(回填时不必每天冻结)。
|
||||
`docker-compose.yml` 的 `volumes` 已经挂了 `.:/app`,`data/frozen/` 天然落在宿主仓库里;
|
||||
记得 `.gitignore` 里决定要不要跟踪(我建议**跟踪 manifest.json、忽略数据文件**,
|
||||
manifest 才是可 diff 的审计线)。
|
||||
|
||||
### 1.10 `probe.py` — 加两节(G1 需要)
|
||||
|
||||
```python
|
||||
def probe_corr():
|
||||
"""池内三项相关矩阵(评审 §2)——若 corr(z_H, z_V) > 0.5,
|
||||
"三项加权"实际是两项,§9-8 的权重讨论要重开。"""
|
||||
_sec("corr · 池内 (z_T, z_H, z_V) 相关矩阵")
|
||||
# 取最新可算日:传导有值的最近 scan_date
|
||||
d = db.read_pg("SELECT max(scan_date) FROM v_factor_transmission").iloc[0, 0]
|
||||
... # 三路各取当日截面 → 外连接 → 标准化 → .corr(method="spearman")
|
||||
# 同时打印:|P| 规模、|P ∩ 传导>0|、传导为 0 的占比
|
||||
```
|
||||
|
||||
`SECTIONS` 里注册 `"corr": probe_corr`,`run.py` 的 `--section` choices 加 `corr`。
|
||||
(`tracks` 节等第三批的 yml 有草案了再加。)
|
||||
|
||||
---
|
||||
|
||||
## 第二批 · 基座侧(确定性缺陷修复,不算"新增因子计算")
|
||||
|
||||
> 这四处都是"让上游可复现/不撒谎",不涉及打分,不破 §4 铁律。
|
||||
> `graph_store` 那处我是通过子代理读到的 Cypher,落地前请你对一眼实际代码。
|
||||
|
||||
### 2.1 `graph_store.topic_context` — 加 `ORDER BY`、cap 参数化(**修硬伤 1 的根**)
|
||||
|
||||
```cypher
|
||||
MATCH (c)-[m:IN_SEGMENT {status:'active'}]->(t:Segment {segment_name:$id})
|
||||
RETURN coalesce(c.name, c.ts_code, c.entity_key) AS name, c.ts_code AS ts_code
|
||||
ORDER BY coalesce(c.ts_code, c.entity_key) -- ★ 新增:无序 → 同日重跑结果会变
|
||||
LIMIT $cap
|
||||
```
|
||||
|
||||
`cap` 默认从 30 提到 200(或让调用方传)。**这一条不改,硬伤 1 修不掉** ——
|
||||
`transmission` 的 `members_total / moved_ratio` 会一直建立在任意 ≤30 抽样上。
|
||||
|
||||
### 2.2 `transmission.scan` — 存全量 quiet + 新鲜度断言 + 记快照日
|
||||
|
||||
```python
|
||||
def scan(scan_date=None, annotate=True, max_candidates=12, member_cap=200):
|
||||
from app.analysis import hotspot
|
||||
from app.store import claim_store, graph_store
|
||||
|
||||
sd = date.fromisoformat(scan_date) if scan_date else date.today()
|
||||
|
||||
# ---- 0. 新鲜度记账(评审 §6.2)----
|
||||
# ★ 用户决定(2026-07-26):**照常落库,只打告警 + 记 mkt_trade_date**,不中止。
|
||||
# 理由:保覆盖。传导每日只有几十行,公告批量入库期间 beat 一旦被饿就整天空白,
|
||||
# 代价太大;改由桥侧按 mkt_trade_date 自行判断要不要采信(factors.py 已实现告警)。
|
||||
# 背景:movers/quiet 来自 mkt_daily 的 **最新** 快照,不是 sd 的快照。若 17:30
|
||||
# sync_market 晚点,会用 T−1 的 movers 写成 scan_date=T;反过来重跑历史日
|
||||
# 会用今天的快照 = 直接前视。落这一列后,下游至少看得见。
|
||||
with get_pool().connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT max(trade_date) FROM mkt_daily WHERE kind='stock'").fetchone()
|
||||
mkt_td = row[0] if row else None
|
||||
if mkt_td != sd:
|
||||
logger.warning(
|
||||
"传导扫描:mkt_daily 最新 stock 快照 %s ≠ scan_date %s —— movers 非当日,"
|
||||
"候选照常落库但已记 mkt_trade_date,下游(因子桥)会据此告警。"
|
||||
"若非预期,补跑 sync_market 后重扫本日即可(同日幂等)。", mkt_td, sd)
|
||||
...
|
||||
for t in targets.values():
|
||||
ctx = graph_store.topic_context(t["target_type"], t["target"], cap=member_cap)
|
||||
members = [m for m in ctx["members"] if m.get("ts_code")]
|
||||
if not members:
|
||||
continue
|
||||
moved = [m for m in members if m["ts_code"] in movers]
|
||||
quiet = [m for m in members if m["ts_code"] not in movers]
|
||||
# ★ 原来是 `for m in quiet[:12]` —— 那个 12 是给旁批/展示用的,
|
||||
# 却把因子的结构上限锁在 12×12=144 行/日(实测 83 吻合)。
|
||||
# 落库存全量,旁批仍只喂前 12(见下面 _annotate 的入参)。
|
||||
quiet_rows, upsides, over = [], [], 0
|
||||
for m in quiet: # ← 去掉 [:12]
|
||||
...
|
||||
```
|
||||
|
||||
`_annotate` 调用处把喂给 brain 的 quiet 截到 12:`_annotate(out, quiet_show=12)`,
|
||||
prompt 里只列前 12(旁批的信息密度需求和因子的覆盖需求本来就是两件事)。
|
||||
|
||||
落库 INSERT 加 `mkt_trade_date`:
|
||||
|
||||
```python
|
||||
conn.execute("ALTER TABLE transmission_candidates "
|
||||
"ADD COLUMN IF NOT EXISTS mkt_trade_date DATE") # 幂等,跟 _DDL 一起
|
||||
...
|
||||
"""INSERT INTO transmission_candidates
|
||||
(scan_date, rank, target, target_type, paths, members_total, moved,
|
||||
moved_ratio, quiet, pricing, annotation, mkt_trade_date)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)"""
|
||||
```
|
||||
|
||||
### 2.3 `hotspot._latest_mkt / _movers_set` — 接受 `trade_date`
|
||||
|
||||
```python
|
||||
def _latest_mkt(kind: str, trade_date: Optional[date] = None):
|
||||
"""trade_date 给定时取该日快照;不给才退回 max(trade_date)。
|
||||
(评审 §6.2:原来恒取最新,导致 scan_date 只是个标签。)"""
|
||||
with get_pool().connection() as conn:
|
||||
if trade_date is None:
|
||||
row = conn.execute(
|
||||
"SELECT max(trade_date) FROM mkt_daily WHERE kind=%s", (kind,)).fetchone()
|
||||
td = row[0] if row else None
|
||||
else:
|
||||
td = trade_date
|
||||
...
|
||||
|
||||
def _movers_set(trade_date: Optional[date] = None) -> set[str]:
|
||||
_, stocks = _latest_mkt("stock", trade_date)
|
||||
return {x["code"] for x in stocks if (x.get("pct_change") or 0) >= 3}
|
||||
```
|
||||
|
||||
`hotspot.scan` / `transmission.scan` 里的 `_movers_set()` 都传 `sd`。
|
||||
这样 2.2 的断言就成了双保险(断言拦"快照缺",传参拦"取错日")。
|
||||
|
||||
### 2.4 `sync_consensus` — 参数化 `asof`(**§9-6 甲案,约 5 行**)
|
||||
|
||||
```python
|
||||
def sync_consensus(lookback_days: int = 90,
|
||||
asof: Optional[str] = None) -> dict[str, Any]:
|
||||
"""...(原 docstring 保留)
|
||||
|
||||
asof(评审 §6.4 / 设计 §9-6 甲案):给定时按该日重放同一套聚合逻辑,
|
||||
live 与 history 共用一份代码、零漂移。不给 = today(原行为)。"""
|
||||
ref = date.fromisoformat(asof) if asof else date.today()
|
||||
pool_codes = _pool_ts_codes()
|
||||
if not pool_codes:
|
||||
return {"skipped": "无已注册股票池"}
|
||||
since = (ref - timedelta(days=lookback_days)).isoformat()
|
||||
...
|
||||
cur.execute(
|
||||
"""SELECT ts_code, report_date, org_name, quarter, eps, np,
|
||||
max_price, min_price, rating
|
||||
FROM gp_report_rc
|
||||
WHERE report_date >= %s AND report_date <= %s -- ★ 上界必加
|
||||
AND ts_code IN %s""",
|
||||
(since, ref.isoformat(), tuple(pool_codes)))
|
||||
...
|
||||
# today = date.today() → 改成用 ref
|
||||
```
|
||||
|
||||
**★ 上界 `report_date <= ref` 是这条改动里最关键的一行** —— 不加,历史回填会把
|
||||
未来研报算进当时的一致预期,就是教科书式前视。live 模式下 today 本就是上界,
|
||||
所以原代码没有它也对;参数化之后必须补。
|
||||
|
||||
两个已知残余(回填时在文档里写明,不影响本次改动):
|
||||
|
||||
- `_pool_ts_codes()` 用的是**今天的池** → 历史回填仍带成员性前视(同评审 §4);
|
||||
- `target_mid_avg` 是 90 天内全部研报行的简单平均,不按机构去重、不按时间加权 ——
|
||||
历史与 live 一致,所以不影响可比性,但要知道这个"中枢"的语义比字面弱。
|
||||
|
||||
回填跑法:
|
||||
|
||||
```bash
|
||||
# 逐日重放(一天约 800 行,500 天约 40 万行,consensus_daily 主键天然幂等)
|
||||
docker compose exec backend python -c "
|
||||
from app.store import market_snapshot as ms
|
||||
import pandas as pd
|
||||
for d in pd.bdate_range('2025-01-01','2026-07-24'):
|
||||
print(d.date(), ms.sync_consensus(asof=d.date().isoformat()))"
|
||||
```
|
||||
|
||||
### 2.5(可选,但强烈建议)`industry_pools` 开始存历史
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS industry_pools_history (
|
||||
snapshot_date DATE NOT NULL,
|
||||
theme TEXT NOT NULL,
|
||||
members JSONB NOT NULL,
|
||||
stats JSONB,
|
||||
PRIMARY KEY (snapshot_date, theme)
|
||||
);
|
||||
```
|
||||
|
||||
`claim_store.upsert_pool` 末尾顺手写一行(或 `refresh_pools` 跑完整表快照一次)。
|
||||
成本几乎为零,解开的是评审 §4 那一整类问题(universe 成员性前视 + z 统计量周一跳变)。
|
||||
桥侧 `freeze.py` 已经在冻结当日 universe,两边互为备份。
|
||||
|
||||
---
|
||||
|
||||
## 第三批 · 需要你拍板的两件(我给出两种拍法的代码形态)
|
||||
|
||||
### 3.1 赛道门槛 C 的数据源:三条路,我推荐 (c)
|
||||
|
||||
| | 做法 | 成本 | 代价 |
|
||||
|---|---|---|---|
|
||||
| (a) | 从 `claims` 重建(一条 SQL) | 最低,今天就能跑 | 融合前口径:无 status/supersede/tier 裁决,会捞到已被顶替的历史归属;object 侧无 norm,同义环节不合并 |
|
||||
| (b) | 桥直连 Neo4j | 中 | 破坏"桥只连三库",裁决逻辑复制进桥 |
|
||||
| (c) | **基座投影表 + 第五/六个只读视图** | 中,但一次性 | 无。是投影不是打分,不破 §4 |
|
||||
|
||||
**我的建议是 (a) 先用于 G1 体检、(c) 作为 S1 的正解。** 体检只需要回答"这个赛道在图谱里
|
||||
有没有料",(a) 的超集口径完全够用而且更保守(宁可高估覆盖,也不要因为口径太严把
|
||||
本来有料的赛道判成空)。而一旦进公式,成员性必须走裁决后的口径。
|
||||
|
||||
(a) 的 SQL(**列名待核实**,先跑第一段确认再跑第二段):
|
||||
|
||||
```sql
|
||||
-- 先确认列名
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name IN ('claims','entity_links','company_master')
|
||||
ORDER BY table_name, ordinal_position;
|
||||
|
||||
-- G1 体检用:环节 → 上市成员(融合前口径,仅供体检,不进公式)
|
||||
CREATE OR REPLACE VIEW v_probe_segment_members AS
|
||||
SELECT c.object_id AS segment_name,
|
||||
c.qualifiers->>'chain' AS chain,
|
||||
COALESCE(el.canonical_id, cm.ts_code) AS ts_code,
|
||||
c.tier,
|
||||
count(*) AS n_claims
|
||||
FROM claims c
|
||||
LEFT JOIN entity_links el ON el.alias_norm = c.subject_norm
|
||||
LEFT JOIN company_master cm ON cm.short_name = c.subject_id
|
||||
WHERE c.predicate = 'IN_SEGMENT' AND c.object_type = 'Segment'
|
||||
GROUP BY 1,2,3,4;
|
||||
|
||||
-- 环节上下游边(单表零 join)
|
||||
CREATE OR REPLACE VIEW v_probe_segment_edges AS
|
||||
SELECT DISTINCT c.subject_id AS up, c.object_id AS down, c.tier
|
||||
FROM claims c
|
||||
WHERE c.predicate = 'SEGMENT_UPSTREAM_OF' AND c.object_type = 'Segment';
|
||||
```
|
||||
|
||||
(c) 的形状:基座在 Neo4j 投影 beat 之后,把
|
||||
`MATCH (co:Company)-[e:IN_SEGMENT {status:'active'}]->(s:Segment)` 与
|
||||
`MATCH (a:Segment)-[e:SEGMENT_UPSTREAM_OF {status:'active'}]->(b:Segment)`
|
||||
两条 Cypher 的结果物化到 PG 两张表,视图名就叫
|
||||
`v_factor_segment_members(segment_name, chain, ts_code, tier, updated_at)` /
|
||||
`v_factor_segment_edges(up, down, tier, updated_at)` ——
|
||||
`freeze.py` 已经预留了对这两个视图的冻结(视图不存在时静默跳过,不报错)。
|
||||
|
||||
**顺带三件与 C 有关的定调建议:**
|
||||
|
||||
1. **`layer` 写在 yml 里人工指定,加一列 `layer_source`。** 四层枚举(材料→设备→
|
||||
制造→应用)系统里根本不存在,且 `enums._SEGMENT_BAD_SUBSTR` 把"上游/中游/下游/
|
||||
环节"列为环节名**禁用词素**(注释:"链位置由图上边表达")。S1 公式又不用 layer ——
|
||||
**降为可选元数据,别让它阻塞 G2**。
|
||||
2. **C 做两级并强制标 `source_rule`**:`graph`(强,可审计到具体 claim)/
|
||||
`fallback`(弱,概念标签或申万行业白名单兜底)。否则核聚变/低空经济/商业航天
|
||||
一旦查不到,C 要么塌成空集、要么退化成"电子+计算机"。
|
||||
3. **覆盖为空的赛道直接进采集清单** —— 这就是基座「需求闭环」的正用法,
|
||||
比放弃赛道有价值(`claim_store.open_demand("research_report", "Concept", <赛道>)`)。
|
||||
|
||||
### 3.2 权重结构:两段式(推荐)还是真混合
|
||||
|
||||
传导池内 95% 为 0,z-score 后 `0.5·z_T` 的组间落差压过另两项全幅
|
||||
(数值验证:200 只候选 / 10 只有传导 → top20 里传导票 9.8/10)。所以现在的
|
||||
0.5/0.3/0.2 事实上是"传导票优先,组内再比冷和便宜"。二选一:
|
||||
|
||||
**A(推荐)· 承认两段式,代码更短、行为与文字一致**
|
||||
|
||||
```python
|
||||
def akg_score(pool: pd.DataFrame) -> pd.Series:
|
||||
"""景气度漏斗的池内排序(评审 §2 方案 A)。
|
||||
|
||||
结构 = 传导档位(主键) + 组内「冷 & 便宜」(次键)。
|
||||
这不是"降低传导权重",恰恰是把"传导第一"写实:
|
||||
0.5/0.3/0.2 在 95% 稀疏下已经等价于此,写明比藏在 z-score 里好 ——
|
||||
可解释、可调试、一眼看出是哪一段在起作用。
|
||||
"""
|
||||
t = np.log1p(pool["transmission"].fillna(0.0))
|
||||
# 档位:0 = 无传导;1 = 有传导且强度在有传导组的下半;2 = 上半
|
||||
tier = pd.Series(0, index=pool.index, dtype=float)
|
||||
hit = t > 0
|
||||
if hit.any():
|
||||
tier[hit] = np.where(t[hit] >= t[hit].median(), 2.0, 1.0)
|
||||
|
||||
zH = -_robust_z(pool["heat"].fillna(pool["heat"].median()))
|
||||
zV = _robust_z(pool["upside"])
|
||||
tiebreak = 0.6 * zH + 0.4 * zV # 次序仍是「还没热」>「便宜」
|
||||
|
||||
# 档间不可逆(这正是"传导第一"的含义),档内连续排序
|
||||
span = 10.0 # > tiebreak 的理论幅度(约 ±6)
|
||||
return tier * span + tiebreak
|
||||
```
|
||||
|
||||
好处:档位与次键各自可独立观察;调试时 `groupby(tier)` 直接看出每档的表现;
|
||||
把 §9-9("还没热"该不该做条件项)一并解决了 —— 它现在天然是组内量。
|
||||
|
||||
**B · 真要连续混合**:`w_T` 降到 **0.10~0.15**,或对 `z_T` 做有界变换:
|
||||
|
||||
```python
|
||||
zT = np.clip((t - t.mean()) / (t.std() or 1.0), -3, 3) / 3 * 1.5 # 压到与另两项同量级
|
||||
score = 0.5 * zT + 0.3 * zH + 0.2 * zV # 实测能把 top20 命中从 9.8 拉到 7.5
|
||||
```
|
||||
|
||||
**无论选哪个,都建议同时注册 `akg_gate`**(0/1,全覆盖池出行)。否则平台的 IC/分层
|
||||
只看得到"池内排序"的价值,**完全看不到两道门槛的价值**——而门槛才是这套逻辑的主体。
|
||||
两个因子、两个仪表,各答一个问题,"哪一项在拖后腿"一眼可见。
|
||||
|
||||
---
|
||||
|
||||
## 落地顺序建议
|
||||
|
||||
```
|
||||
今天 1.1 视图替换 → 1.2/1.3/1.5/1.6/1.7/1.8 桥侧补丁 → 1.9 freeze.py
|
||||
跑一次:run.py views → run.py freeze → run.py build all --mode daily
|
||||
(freeze 的 warnings 就是硬伤 1 的实测量级,正好拿来定 2.1/2.2 的 cap)
|
||||
本周 2.1~2.3 基座三处(改完 replay 不需要,只影响新扫描)→ 2.5 池历史表
|
||||
1.10 probe 加 corr 节 → 3.1(a) 的两个体检视图 → 跑 run.py probe
|
||||
⇒ G1 体检报告(含三项相关矩阵 + 赛道命中量级)发我
|
||||
再定 3.1 的 C 口径与赛道清单、3.2 的权重结构 → G2/G3
|
||||
2.4 sync_consensus 参数化 + 逐日回填 → G4
|
||||
```
|
||||
|
||||
**如果只能做一件:`freeze.py`。** 别的都能补,快照不存就永久没了。
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# 第一批实机验证清单(2026-07-26)
|
||||
|
||||
> 已写入 `akg-factor-bridge`:改 7 个文件(+533/−121),新增 `freeze.py` / `probe.py` / `.gitignore`。
|
||||
> 容器内与设备端 `py_compile` 均通过;`build_event` 的向量化改写做过逐位等值回归;
|
||||
> `probe corr` 的边界情形(传导全 0、MAD=0、池内只剩 1~2 只)已跑过不炸。
|
||||
> **本批不动 astock-kg 代码**,只替换基座上的四个视图(`CREATE OR REPLACE` + 一个可空列 + 一个索引)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 先提交(改完再跑,出问题好回滚)
|
||||
|
||||
```bash
|
||||
cd ~/Documents/work/project/akg-factor-bridge
|
||||
git add -A
|
||||
git commit -m "fix: 交叉评审第一批——传导口径/事件回填/年报污染/分块与冻结
|
||||
|
||||
- 视图 v2:n_paths→n_sources(distinct 源数,修 cascade 变长边与三桶重复计数);
|
||||
暴露 target/members_total/n_quiet_stored/mkt_trade_date 供桥自检上游截断;
|
||||
v_factor_events 补 doc_id/source_type/tier
|
||||
- common.trading_days 改用 gp_day_data —— 原用热度表(最早2025-03-26)导致
|
||||
事件 history 回填静默返空
|
||||
- factors: 事件加年报污染防护(source_type 白名单+同键去重+单文档封顶)、
|
||||
衰减向量化(逐位等值);行情按月分块;传导上游截断/快照陈旧显式告警
|
||||
- common.write_factor 分块提交(热度全史约54万行)
|
||||
- 新增 freeze.py:每日冻结 universe/传导原始行/一致预期/前复权close/热度+manifest
|
||||
—— 上游不可复现(Neo4j无ORDER BY、前复权重写、池只存最新态),
|
||||
\"可复现\"只能定义为\"能从冻结输入重算出同一个值\"
|
||||
- probe 新增 corr 节:池内三项相关矩阵 + 权重体检(top-K 里几只是传导票)
|
||||
- 新增 .gitignore(原来没有,.env 会被 git add -A 带进库)
|
||||
- SUBFACTOR_UNIVERSE/EVENT_SOURCE_TYPES 等旋钮进 .env.example,默认保持原行为"
|
||||
```
|
||||
|
||||
> ⚠️ 顺带发现:**这个仓库原来没有 `.gitignore`**,一旦你在开发机建了 `.env`,
|
||||
> `git add -A` 会把三处数据库口令一起提交。已补上。
|
||||
> 另外 `.idea/` 已经被跟踪了,`.gitignore` 对它无效——想清出去要
|
||||
> `git rm -r --cached .idea && git commit -m "chore: .idea 移出版本控制"`(可选)。
|
||||
|
||||
`.review_backup_20260726/` 是改动前的原文件备份 + 补丁包,已在 `.gitignore` 里。
|
||||
验证通过后整目录删掉即可。
|
||||
|
||||
---
|
||||
|
||||
## 1. 应用视图(基座侧,只建视图 + 加一个可空列 + 加索引)
|
||||
|
||||
```bash
|
||||
docker exec -i akg-postgres psql -U akg -d akg < sql/astock_kg_slot_views.sql
|
||||
```
|
||||
|
||||
**预期**:`CREATE VIEW` ×4、`ALTER TABLE`、`CREATE INDEX`,无 ERROR。
|
||||
**不动任何一行数据**,`claims` / `documents` 一个字节不变。
|
||||
|
||||
---
|
||||
|
||||
## 2. 起容器 + 连通性与视图版本自检
|
||||
|
||||
```bash
|
||||
cd ~/Documents/work/project/akg-factor-bridge
|
||||
docker compose up -d --build
|
||||
docker compose exec akg-factor-bridge python run.py views
|
||||
```
|
||||
|
||||
**要看的三段**:
|
||||
|
||||
1. 连通性七项全 ✅;
|
||||
2. 新增的「视图版本」段——四项应该是
|
||||
`✅ n_sources` / `✅ mkt_trade_date` / `✅ source_type` / `⬜ v_factor_segment_members`
|
||||
(最后一个是 G2 的第五插槽视图,现在未就绪是对的);
|
||||
3. 末行打印当前口径:`SUBFACTOR_UNIVERSE=pool | EVENT_SOURCE_TYPES=['announcement'] | EVENT_MAX_PER_DOC=3`。
|
||||
|
||||
> 若 `mkt_trade_date` 是 ⬜,说明 `ALTER TABLE` 那段没跑到,回第 1 步。
|
||||
|
||||
---
|
||||
|
||||
## 3. ★ G1 体检(这一步的输出最关键,直接决定第三批怎么定)
|
||||
|
||||
```bash
|
||||
# 三节可分开跑;price 节全表聚合可能 1~3 分钟
|
||||
docker compose exec akg-factor-bridge python run.py probe --section corr
|
||||
docker compose exec akg-factor-bridge python run.py probe --section upside
|
||||
docker compose exec akg-factor-bridge python run.py probe --section pools
|
||||
docker compose exec akg-factor-bridge python run.py probe --section price
|
||||
```
|
||||
|
||||
**`corr` 节要回答的两个问题**(把整段输出发我):
|
||||
|
||||
- **① `corr(z_H, z_V)` 是多少?** 若 |值| > 0.5,「还没热」与「便宜」高度共线,
|
||||
「三项加权」实际是两项,§9-8 的权重讨论要重开(而且门槛② 与 `z_V` 本就是同一变量进两次)。
|
||||
- **② `w_T=0.50` 那行的 top-K 命中数。** 如果命中数 ≈ min(K, 传导票数),
|
||||
就实锤了「0.5/0.3/0.2 事实上是传导优先而非加权混合」。
|
||||
同一节还会打印**两段式与当前公式的 top20 重合度**——越接近 20/20,
|
||||
说明改成两段式零代价,那第三批 3.2 就直接选 A。
|
||||
|
||||
另外 `corr` 节末尾的 `U → 有 upside → 过门槛② → 其中有传导` 这条链,
|
||||
最后一个数就是真正决定榜首的量级。
|
||||
|
||||
---
|
||||
|
||||
## 4. 因子构建 + 冻结(daily 跑完会自动冻结)
|
||||
|
||||
```bash
|
||||
# 热度 T+1,构建日选有数据的那天(views 的「数据历史深度」段会告诉你前沿在哪)
|
||||
docker compose exec akg-factor-bridge python run.py build all --mode daily --date 2026-07-24
|
||||
```
|
||||
|
||||
**要看的**:
|
||||
|
||||
- 四路各自的写入行数,和上次(upside 795 / heat 1674 / event 195 / transmission 83)对比;
|
||||
- **event 行数大概率会掉**——新加了 `source_type='announcement'` 白名单 + 单文档封顶,
|
||||
日志会打印剔除了多少条、按来源分布是什么。**这个分布请一并发我**:
|
||||
如果剔除后接近 0,说明基座里公告的 `source_type` 不是 `announcement` 这个字符串,
|
||||
按实际值改 `.env` 的 `EVENT_SOURCE_TYPES` 即可;
|
||||
- **transmission 那里的 ⚠️ 告警**——`quiet 存满 12 条` / `members_total >= 30` /
|
||||
`mkt 快照日 ≠ scan_date` 各命中几个环节。这三个数就是硬伤 1 的实测量级,
|
||||
直接决定第二批里 `topic_context` 的 cap 该提到多少;
|
||||
- 末尾 `❄️ 冻结 ... → /app/data/frozen/2026-07-24`,各源行数 + warnings。
|
||||
|
||||
```bash
|
||||
# 看一眼冻结产物
|
||||
docker compose exec akg-factor-bridge cat /app/data/frozen/2026-07-24/manifest.json
|
||||
ls -la data/frozen/2026-07-24/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.(可选,但很值)事件回填冒烟——验证硬伤 3 真的修好了
|
||||
|
||||
```bash
|
||||
# 改之前这条命令会静默返回空(日历取自热度表,最早 2025-03-26)
|
||||
docker compose exec akg-factor-bridge python run.py build akg_event \
|
||||
--mode history --start 2024-01-01 --end 2024-03-31 --no-freeze
|
||||
```
|
||||
|
||||
**预期**:现在应该有真实行数(2024 年的公告若已抽取),而不是「无数据(跳过)」。
|
||||
若仍为空,看是不是被 `EVENT_SOURCE_TYPES` 过滤掉了(日志会说)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 顺带跑一条 SQL(回答一个对回填边界很关键的问题)
|
||||
|
||||
```bash
|
||||
docker exec -i akg-postgres psql -U akg -d akg <<'SQL'
|
||||
SELECT kind, min(trade_date), max(trade_date), count(DISTINCT trade_date) AS days
|
||||
FROM mkt_daily GROUP BY kind ORDER BY kind;
|
||||
SQL
|
||||
```
|
||||
|
||||
`mkt_daily` 是持久快照表(全库无删除),所以 `kind='stock'` 的天数决定了传导
|
||||
**理论上**能重扫到哪一天。若明显多于 3 天,设计 §7 里「传导只 live 累积」的边界
|
||||
可以往前推一段(图谱漂移仍在,但至少多一个带明确标注的近似区间可选)。
|
||||
|
||||
---
|
||||
|
||||
## 把这些发我
|
||||
|
||||
1. `run.py views` 全部输出
|
||||
2. `probe --section corr` 全部输出 ← **最重要**
|
||||
3. `probe --section upside` 的 q 档位表
|
||||
4. `build all --mode daily` 的日志(尤其 event 的剔除分布、transmission 的 ⚠️ 三项)
|
||||
5. 第 6 步那条 SQL 的结果
|
||||
|
||||
拿到这五样,第三批(赛道门槛 C 的口径 + 权重结构)就能用你的真实数据定,
|
||||
不用再靠我的模拟。
|
||||
|
||||
---
|
||||
|
||||
## 已记下的决定
|
||||
|
||||
- **传导新鲜度断言 → 照常落库,只打告警 + 记 `mkt_trade_date`**(不中止扫描)。
|
||||
理由:保覆盖,传导每日只有几十行,公告批量入库期间 beat 一旦被饿就整天空白,
|
||||
代价太大;改由桥侧按 `mkt_trade_date` 自行判断是否采信。
|
||||
第二批的 `transmission.scan` 已按此改写(`allow_stale` 参数取消)。
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
# 量化因子导出与合成设计 v2.0 · 交叉评审(2026-07-26)
|
||||
|
||||
> 评审范围:`akg-factor-bridge` 全部代码 + `量化因子导出与合成设计.md` v2.0 + astock-kg 供数侧
|
||||
> (`transmission.py` / `hotspot.py` / `chainmap.py` / `market_snapshot.py` / `themes.py` /
|
||||
> `graph_store.py` / `claim_store.py` / `enums.py` / `factor_coverage_probe.py`)+ 相关设计文档。
|
||||
> 结论按"必须修 / 需要决策 / 需要知道"三档排列,每条附代码出处。
|
||||
|
||||
---
|
||||
|
||||
## 0. 总体判断
|
||||
|
||||
**方向可行,我认同 v2.0 的转向。** §2.2 的「观察 vs 推断」类型学是全文最有价值的部分:
|
||||
传导 = 当日 movers × 当前态图谱,两个输入都无耐久历史,回填必然携带成员性前视 —— 这个论证
|
||||
成立,而且是三条否决理由里唯一决定性的一条(另两条只是"效果不好",这一条是"方法上不可能")。
|
||||
把回测从生死闸降为仪表盘,在一个自己承认"无法准确估值"的领域里是正确的取舍。
|
||||
|
||||
**但 S1 现在有 3 处会让因子"看起来在跑、实际不成立"的硬伤,1 处方法论表述与实际行为不符,
|
||||
以及 1 处架构级缺口(赛道门槛 C 在当前四视图上无法实现)。** 这 5 项都在 G3 之前必须处理,
|
||||
其中 1 项(快照冻结)每晚一天就永久少一天历史。
|
||||
|
||||
一句话版本:**代码质量不错、点时纪律做得比多数量化工程好;问题全部集中在"桥以外"
|
||||
—— 桥假设上游是确定的、可复现的、可查询的,而这三条上游都不完全满足。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 必须修:三处硬伤
|
||||
|
||||
### 硬伤 1 · 第一权重项不可复现,且被两道截断天花板锁住
|
||||
|
||||
`transmission.py:116` 在装配 `quiet` 落库字段时写的是:
|
||||
|
||||
```python
|
||||
for m in quiet[:12]: # ← 只有前 12 个未动成员进 quiet JSONB
|
||||
```
|
||||
|
||||
叠加 `scan()` 的 `max_candidates: int = 12`(`transmission.py:48`),
|
||||
`v_factor_transmission` 的**结构上限就是 12 × 12 = 144 行/日** —— 实测 83 行完全吻合,
|
||||
说明它不是"覆盖稀薄",而是**被一个为旁批/展示写的截断锁住了**。
|
||||
|
||||
更麻烦的是这 12 个是哪 12 个。`quiet` 来自 `graph_store.topic_context("Segment", name)`,
|
||||
其 Cypher 是:
|
||||
|
||||
```cypher
|
||||
MATCH (c)-[m:IN_SEGMENT {status:'active'}]->(t:Segment {segment_name:$id})
|
||||
RETURN ... LIMIT $cap -- cap 默认 30,且没有 ORDER BY
|
||||
```
|
||||
|
||||
三个后果:
|
||||
|
||||
1. **同一天重跑 `transmission_scan` 可能得到不同的 `quiet` 集合与不同的 `moved_ratio`**
|
||||
—— 顺序由 Neo4j 执行计划决定。桥内写入是幂等的,但**上游不是**,所以判收标准 §10
|
||||
「重跑不产生重复或漂移」按现状无法成立。
|
||||
2. 成员数 > 30 的大环节,`members_total` / `moved_ratio` 建立在一个**任意的 ≤30 抽样**上,
|
||||
而 `moved_ratio` 直接是 `factor_value` 的一个乘数因子。
|
||||
3. 哪只股票能进因子表,事实上由数据库返回序决定。
|
||||
|
||||
**修法(基座侧,属确定性缺陷修复,不算"新增计算",不破 §4 铁律)**
|
||||
|
||||
- `topic_context` 加 `ORDER BY coalesce(c.ts_code, c.entity_key)`,`cap` 提高或参数化;
|
||||
- `transmission.scan` 落库的 `quiet` 存**全量**未动成员,旁批 prompt 仍只喂前 12
|
||||
(两个用途本来就该分开);
|
||||
- 视图额外暴露 `members_total` / `moved`,让桥能自检 `members_total == 30` 这类可疑值。
|
||||
|
||||
### 硬伤 2 · `n_paths` 被重复计数系统性抬高
|
||||
|
||||
`v_factor_transmission` 用 `jsonb_array_length(t.paths)` 当路径数
|
||||
(`sql/astock_kg_slot_views.sql:56`),而 `paths` 是**没有去重的**:
|
||||
|
||||
- `graph_store.cascade()` 主干是变长边 `-[:SEGMENT_UPSTREAM_OF*1..N]->`,
|
||||
**变长匹配会把每种长度的路径各返回一条** —— `A→B` 与 `A→X→B` 同时出现、末节点都是 B;
|
||||
去重只做了路径内去环,跨路径不去重,且 `LIMIT 60` 无 `ORDER BY`。
|
||||
- `graph_store.transmission_targets()` 三个桶(updown / supply / drives)各自 DISTINCT,
|
||||
但桶间用 `out += ...` 简单拼接,**同一 target 可以从 supply 和 drives 各来一次**。
|
||||
- `transmission._add()` 对每条 `(source, target, via)` 都 append 一条。
|
||||
|
||||
于是 `n_paths` 既不是"多少个源指向它",也不是"多少条独立路径",而是"多少次被提及"。
|
||||
而 `factor_value = n_paths × (1 − moved_ratio)`,`n_paths` 是主量级 —— 排序偏差直接传导到因子。
|
||||
|
||||
**修法(只改视图,零基座改动,成本最低的一条)** 把它改成 distinct source 数,
|
||||
这也更贴合传导模块自己写的语义"多源汇聚 = 传导逻辑更硬":
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW v_factor_transmission AS
|
||||
SELECT t.scan_date,
|
||||
q->>'ts_code' AS ts_code,
|
||||
(SELECT count(DISTINCT p->>'source')
|
||||
FROM jsonb_array_elements(COALESCE(t.paths, '[]'::jsonb)) p) AS n_sources,
|
||||
jsonb_array_length(COALESCE(t.paths, '[]'::jsonb)) AS n_paths_raw,
|
||||
t.moved_ratio,
|
||||
t.members_total, -- 桥用来识别 ≤30 截断
|
||||
t.moved
|
||||
FROM transmission_candidates t
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(t.quiet, '[]'::jsonb)) AS q
|
||||
WHERE q->>'ts_code' IS NOT NULL AND q->>'ts_code' <> '';
|
||||
```
|
||||
|
||||
桥侧 `build_transmission` 改用 `n_sources`,`n_paths_raw` 留着当观察列。
|
||||
|
||||
### 硬伤 3 · 事件回填会静默返回空(README 里那条示例命令现在就是无效的)
|
||||
|
||||
`factors.build_event` 用 `common.trading_days(start, end)` 当交易日历(`factors.py:137`),
|
||||
而 `common.trading_days` 查的是**热度表**:
|
||||
|
||||
```python
|
||||
# common.py:24-30
|
||||
"SELECT DISTINCT trade_date FROM stock_fund_heat_scores WHERE trade_date BETWEEN %s AND %s"
|
||||
```
|
||||
|
||||
热度最早只有 **2025-03-26**(文档 §6.2 自己记着)。所以:
|
||||
|
||||
```bash
|
||||
python run.py build akg_event --mode history --start 2024-01-01 --end 2026-07-24 # README 第 58 行
|
||||
```
|
||||
|
||||
→ `cal` 为空 → `return _EMPTY` → 只打印「无数据(跳过)」,**不报错、不告警**。
|
||||
文档 §7 声称事件可回填到 2014 年,实际上 2025-03-26 之前一天都拿不到。
|
||||
|
||||
**修法**:日历改用 `gp_day_data` 的 `DISTINCT timestamp`(5584 天,覆盖全历史),
|
||||
热度表最多作为并集补充。顺带在 `cal` 为空时打印告警而不是静默跳过。
|
||||
|
||||
### 附带两条:放量时会炸,现在不炸
|
||||
|
||||
- **`_read_gp_price` 不带 universe 过滤也不分块**(`factors.py:64-67`):单日 ~5000 行没问题,
|
||||
但 `--mode history --start 2006-01-01` 会把千万级行拉进 pandas。加 `AND \`{col}\` IN (...)`
|
||||
+ 按月分块。
|
||||
- **`write_factor` 单事务 `executemany` 全量**(`common.py:69-75`):热度全史约
|
||||
322 × 1674 ≈ 54 万行一次提交,走 ShardingSphere 代理有风险。按日期分块提交。
|
||||
另外 `write_factor(mode)` 这个参数收了但没用到,可以删。
|
||||
|
||||
---
|
||||
|
||||
## 2. 需要决策:0.5 / 0.3 / 0.2 实际上是"传导优先",不是"加权混合"
|
||||
|
||||
这是我最想让你在 G3 开工前想清楚的一条,因为它是**逻辑选择而不是回测选择**。
|
||||
|
||||
传导在池内约 95% 为 0(实测 83/1675)。对一列 95% 是 0 的数做
|
||||
`(x − mean) / std`,`std` 完全由那 5% 决定,于是**非零值的 z 落在 +4~+5,零值落在 −0.2 附近**,
|
||||
`0.5 · z_T` 的组间落差约 2.2;而 `0.3 · z_H + 0.2 · z_V` 用稳健 z 之后全幅只有约 1.7。
|
||||
落差大于全幅 → 跨组不可逆。
|
||||
|
||||
我做了数值验证(200 只候选、其中 10 只有传导、其余口径按 §3.4 实现,600 次重复):
|
||||
|
||||
| w_T | top20 中传导票 | top20 中非传导票 |
|
||||
|---|---|---|
|
||||
| **0.50(当前)** | **9.8 / 10** | 10.2 |
|
||||
| 0.30 | 9.1 / 10 | 10.9 |
|
||||
| 0.20 | 7.8 / 10 | 12.2 |
|
||||
| 0.15 | 6.6 / 10 | 13.4 |
|
||||
| 0.10 | 4.7 / 10 | 15.3 |
|
||||
|
||||
也就是说:**当前参数下,有传导的票几乎必然全部进入前列;0.3 和 0.2 只在"有传导组内"
|
||||
和"无传导组内"各自排序,跨组不起作用。** 要让"很冷很便宜的非传导票"真有机会赢过
|
||||
"边际传导票",`w_T` 得降到 0.10~0.15。
|
||||
|
||||
两条路,选一条并写进文档:
|
||||
|
||||
- **A(我推荐,而且我认为它更贴合你原话"传导第一")**:承认它是两段式,直接写成
|
||||
`primary = 传导档位(0 / 1 / 2)`,`tiebreak = 0.6·z_H + 0.4·z_V`。
|
||||
好处:语义与实际行为一致、可解释、调试时能一眼看出是哪一段在起作用、没有隐藏非线性。
|
||||
代价:无。这同时把 §9-9 的「还没热该不该做成条件项」自然解决了 ——
|
||||
**现状在 95% 稀疏下已经等价于条件项了,不如写明**。
|
||||
- **B**:真要连续混合 → `w_T ≈ 0.12`,或对 `z_T` 做有界变换(如 `clip(z,±3)/3 × 1.5`,
|
||||
实测能把 9.8 拉到 7.5)。
|
||||
|
||||
**顺带一个共线性提醒**:门槛② (`upside ≥ θ_v`) 与 `z_V` (`upside`) 是同一个变量进两次;
|
||||
而"还没热"与"还便宜"在 A 股高度共线(涨上去了 → 又热又贵)。
|
||||
建议 G1 的 probe 直接加一节输出池内 `(z_T, z_H, z_V)` 的相关矩阵 ——
|
||||
**如果 `corr(z_H, z_V) > 0.5`,那"三项加权"实际是"两项",权重讨论要重开。** 这一节几乎零成本。
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构缺口:赛道门槛 C 在当前四视图上无法实现
|
||||
|
||||
**图谱在 Neo4j,不在 PostgreSQL**(`graph_store.py:17` 起全走 `neo4j` driver)。PG 侧
|
||||
`industry_pools` 的完整 DDL 只有 4 列(`claim_store.py:439-445`):
|
||||
|
||||
```sql
|
||||
theme TEXT PRIMARY KEY, members JSONB NOT NULL, stats JSONB, refreshed_at TIMESTAMPTZ
|
||||
```
|
||||
|
||||
`members` 元素只有 `ts_code / name / tier / confidence / supporting` ——
|
||||
**成员级既没有 segment,也没有 layer**。环节归属是读时从 Neo4j 重算的
|
||||
(`chainmap.py:61` → `graph_store.chain_view(theme)`)。
|
||||
|
||||
所以 `frontier_tracks.yml` 里规划的 `kg_segments` / `kg_concepts` 两个映射,
|
||||
**在现有四个只读视图上没有数据源**。三条路:
|
||||
|
||||
1. **从 claims 重建**(可行,一条 SQL:`WHERE predicate='IN_SEGMENT' AND object_type='Segment'`,
|
||||
再 join `entity_links` / `company_master` 取 ts_code)。但 `claims` 是**融合前的只追加表**,
|
||||
没有 `status` / `supersede` / tier 裁决(那些只长在 Neo4j 边上),会把已被顶替的历史归属
|
||||
一起捞出来;且 object 侧没有 norm 生成列,同义环节不会合并。**可以救急,不能当正解。**
|
||||
2. **桥直连 Neo4j** —— 破坏"桥只连三库"的设计,且把裁决逻辑复制进桥。不推荐。
|
||||
3. **推荐:基座加投影表 + 第五、第六个只读视图。**
|
||||
`v_factor_segment_members(segment_name, chain, ts_code, tier, status, updated_at)`
|
||||
与 `v_factor_segment_edges(up, down)`,由基座现有的 Neo4j 投影 beat 顺手物化到 PG。
|
||||
这是**投影**不是**打分**,不破 §4 的"零新增因子计算",桥仍然只连三库。
|
||||
`probe.py:12-13` 的注释其实已经想到了这件事("G2 把池清单固化成第五个插槽视图"),
|
||||
只是低估了它的必要性 —— 它不是优化,是 C 门槛能不能实现的前提。
|
||||
|
||||
### 3.1 四层枚举(材料→设备→制造→应用)在系统里根本不存在
|
||||
|
||||
全仓唯一的层级来源是 `chainmap._topo_layers()`(`chainmap.py:310-336`)的 Kahn 拓扑 depth:
|
||||
**per-theme、相对、孤点与环上节点为 `None`**,标签由 `_layer_label` 现算成上游/中游/下游,
|
||||
不落库。而且"不存层级"是刻意的 —— `enums._SEGMENT_BAD_SUBSTR` 把
|
||||
「上游 / 中游 / 下游 / 环节 / 场景 / 领域」列为环节名**禁用词素**,注释写明
|
||||
"链位置由图上边表达"。
|
||||
|
||||
结论:`track_members.layer` 只能**在 yml 里人工指定**,并记一列 `layer_source`
|
||||
(`yml` / `graph_depth` / `unknown`)。而且 **S1 的公式压根不用 layer** ——
|
||||
建议把它降级为可选元数据,**不要让它阻塞 G2**。
|
||||
|
||||
### 3.2 赛道覆盖风险的具体量级
|
||||
|
||||
`industry_pools` 的起点是人工建的两个池(光通信 / 消费电子,见 `涌现式应用层重构设计.md:30`),
|
||||
之后靠周一 `refresh_pools` 自动收录达标涌现主题(≥3 环节且 ≥3 上市成员,
|
||||
`graph_store.EMERGE_MIN_SEGMENTS/EMERGE_MIN_LISTED`)。theme 是 `IN_SEGMENT.chain` 修饰符原文,
|
||||
过 `segment_name_ok` 卫生闸(≤12 字中文裸名)。
|
||||
|
||||
**在这个生成机制下,核聚变 / 低空经济 / 商业航空航天大概率查不到** ——
|
||||
它们需要有人先投过对应的产业链研报。文档 §3.2 已经把这列为"关键前置风险",但给的对策
|
||||
("覆盖为空的赛道宁可先不放进来")会让 C 塌成「通信 + 算力 + AI」,也就是
|
||||
**退化成"电子 + 计算机",门槛几乎不筛东西**。
|
||||
|
||||
建议 C 做成**两级并强制标注来源**:
|
||||
|
||||
| source_rule | 证据强度 | 来源 |
|
||||
|---|---|---|
|
||||
| `graph` | 强,可审计到具体 claim | `IN_SEGMENT` / `HAS_CONCEPT` 边 |
|
||||
| `fallback` | 弱,仅作占位 | 概念标签命中 / 申万行业白名单 |
|
||||
|
||||
`track_members` 里逐行记 `source_rule`,仪表盘里把两级分开看。这样 C 既不会因为图谱覆盖
|
||||
不足而变空集,也不会把弱证据伪装成强证据。**同时,覆盖为空的赛道本身就是一张采集清单**
|
||||
—— 这正是基座「需求闭环」的用法,比放弃赛道有价值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 逻辑一致性:v2.0 把 v1.1 一个仍然成立的开放问题删掉了
|
||||
|
||||
v1.1 §8-2 问过「**覆盖池历史快照缺失**:回填期 universe 用当前池近似(有轻微前视)
|
||||
还是曾入池并集?」—— v2.0 的 §9 十个开放问题里**没有这一条**。
|
||||
|
||||
但按 §2.2 自己的论证,这一条现在**更重要**:
|
||||
|
||||
- `v_factor_universe` = 全部 `industry_pools` 成员并集,而 `industry_pools`
|
||||
**只存最新态、每周一自动长大**(beat 表:池刷新周一 6:00)。
|
||||
- §7 判定"可直接回填"的三路(heat / event / upside)**全部经过这个 universe 过滤**。
|
||||
于是它们的**值**是点时正确的,**成员性**却是前视的 —— 用今天的池去筛 2025 年的历史,
|
||||
而今天的池里有大量主题是 2026 年的研报才长出来的。**这就是 §2.2 批评传导时用的同一条论证,
|
||||
只是上升了一层。**
|
||||
- 更直接的工程后果:`z` 统计量(mean / std / median / MAD)在**每周一发生结构性跳变**,
|
||||
IC 序列和分层读数会带一个周期性伪影。
|
||||
|
||||
**建议(两条一起做,都很便宜):**
|
||||
|
||||
1. **子因子表去掉 universe 过滤**,全市场出行。理由:子因子表的定位是"可独立观察的仪表",
|
||||
过滤只该发生在消费端。行数代价很小(heat 池内 1674 → 全市场约 5000),
|
||||
换来的是三张点时干净的面板,且平台上的子因子 IC 读数才有市场级意义。
|
||||
2. **universe 只在 `akg_score` 侧当过滤器,并每日落快照**(见下节)。
|
||||
基座侧同步加一张 `industry_pools_history(snapshot_date, theme, members)`,
|
||||
由 `refresh_pools` 顺手写。
|
||||
|
||||
---
|
||||
|
||||
## 5. 今天最该做、也是唯一不可补救的一件事:开始冻结快照
|
||||
|
||||
传导、`industry_pools`、Segment 边、前复权 close —— 这四样都是"过期即不可复原":
|
||||
|
||||
- 传导:§2.2 已经论证清楚。
|
||||
- 池 / Segment 边:随研报持续生长,只存最新态。
|
||||
- **前复权 close 比文档写的更麻烦一层**:`gp_day_data` 是前复权、锚在最新日,
|
||||
所以每次有股票除权,**历史 close 会被整体重写** —— `akg_upside` 的历史值因此
|
||||
**不可复现**(今天回填的和三个月后回填的不是同一组数)。文档 §7 只提了
|
||||
"除权点系统性偏移",实际还叠加一个可复现性问题。
|
||||
|
||||
而 §10 写的「GRU 复活条件:传导积累出 ≥1 年 live 历史」——
|
||||
**这个计时器只有在开始存快照的那一天才启动。** 现在已经过去 3 天(07-11 起),
|
||||
每晚一天就永久少一天。
|
||||
|
||||
**建议在 G1 之前插一个 G0.5「冻结与修复」里程碑**,做三件事,都不依赖赛道清单:
|
||||
|
||||
1. **输入冻结**:每日把 `universe` / 四路原始值 / 门槛判定结果 / 最终分
|
||||
落一份 `data/frozen/YYYY-MM-DD.parquet`(桥自己的 `data/`,与赛道快照同一机制)。
|
||||
这一条**同时满足** §10 的「重跑不产生漂移」、「赛道成员快照可审计」、
|
||||
以及未来任何回溯需求 —— 是全文性价比最高的工程动作。
|
||||
2. 修掉第 1 节的三个硬伤。
|
||||
3. 基座开始积累 `industry_pools` / Segment 边的每日(或每周)快照。
|
||||
|
||||
---
|
||||
|
||||
## 6. 供数侧需要知道的事实(会影响读数怎么解释)
|
||||
|
||||
### 6.1 传导链条的上游有两张空表 —— 这才是 83 行/日的真正原因
|
||||
|
||||
`数据源盘点.md §1a` 记着:`gp_sector_daily` **当前空表**、`gp_market_sentiment` **当前空表**、
|
||||
`t_signal_daily_results` **05-15 停更**。于是 `hotspot._pick_anomalies` 的四路异动源里:
|
||||
|
||||
| 源 | 状态 |
|
||||
|---|---|
|
||||
| ① 板块相对强度(`gp_sector_daily`) | ❌ 空表 |
|
||||
| ② 概念 / 行业资金净流入 | ✅ |
|
||||
| ③ 池内个股(`pct_change ≥ 5` **或** `total_score ≥ 0.9`) | ⚠️ 只剩涨幅门(评分停更) |
|
||||
| ④ 事件 / 盘中流雷达 | ✅ |
|
||||
|
||||
**本来最该驱动"板块传导"的板块级异动源现在是缺的。**
|
||||
所以传导稀薄不主要是"图谱覆盖不够",而是「空表 + 12×12 截断」两件事叠加。
|
||||
**要提传导覆盖,优先补 `gp_sector_daily`,比调任何参数都有用。**
|
||||
|
||||
### 6.2 movers / quiet 不看 `scan_date`
|
||||
|
||||
`hotspot._movers_set()` → `_latest_mkt("stock")` → `SELECT max(trade_date) FROM mkt_daily`
|
||||
(`hotspot.py:45-55, 86-89`)。也就是用**最新快照**,不是 `scan_date` 当天的快照。
|
||||
|
||||
live 同日运行时两者一致,没问题。但如果 17:30 的 `sync_market` 失败或晚点,
|
||||
**18:15 的传导扫描会用 T−1 的 movers,写成 `scan_date = T`,而桥完全无从察觉** ——
|
||||
第一权重项拿到一份"陈旧数据贴着新鲜标签"。
|
||||
|
||||
建议:基座加一道 freshness 断言(`mkt_daily` 无 `scan_date` 当日 stock 行则 abort scan),
|
||||
并在视图里带上实际用到的 `mkt_trade_date` 让桥能自检。
|
||||
这也是**除 §2.2 之外,传导不该回填的第二个独立的、机械层面的理由** —— 值得补进文档。
|
||||
|
||||
### 6.3 一致预期的口径细节(影响门槛②怎么理解)
|
||||
|
||||
`sync_consensus`(`market_snapshot.py:493-588`):
|
||||
|
||||
- `asof_date = date.today()`,聚合窗口 `report_date >= today − 90d`
|
||||
→ 桥的 `merge_asof(backward)` **点时正确 ✅**,这块没问题。
|
||||
- `target_mid_avg` 是**90 天内全部研报行的简单平均**(`market_snapshot.py:548`
|
||||
遍历的是 `rows` 而不是 `qrows`),**不按机构去重、不按时间加权**。
|
||||
一家券商 90 天发 5 篇就有 5 倍权重;89 天前的目标价与今天的等权。
|
||||
- 实测 `max_price` 非空仅 0.4% / `min_price` 31%(docstring 记录),所以"目标价中枢"
|
||||
**主要由单值目标价构成**,"中值"的语义比文档写的弱。
|
||||
|
||||
**含义**:θ_v = 0 这道门槛在急涨阶段会自动收紧(分母是今天的价、分子是最长 90 天前的目标价),
|
||||
它**天然带反动量色彩**。这和你的漏斗逻辑同向(涨上去了就没容错度),
|
||||
但要知道它不是一道纯"估值"门槛 —— 这也加重了第 2 节说的三项共线性问题。
|
||||
|
||||
### 6.4 §9-6(upside 历史重建 甲 / 乙):甲案,成本远低于文档估计
|
||||
|
||||
`sync_consensus` 只要把 `date.today()`(`market_snapshot.py:529`)和
|
||||
`since`(`:507`)参数化成一个 `asof: date`,就能对任意历史日重放**同一套聚合逻辑**,
|
||||
live 与 history 共用一份代码、零漂移风险。改动量大约 3 行。
|
||||
|
||||
乙案(桥侧自行聚合 `gp_report_rc`)会让聚合逻辑在基座和桥各存一份 ——
|
||||
这正是文档自己指出的漂移风险,而甲案的"工作量在基座侧"这个缺点被高估了。
|
||||
|
||||
回填时记得把用到的 `close` 一并落进 `data/frozen/`(前复权可复现性问题,见第 5 节)。
|
||||
|
||||
### 6.5 事件因子的年报污染 —— S2 上否决闸之前必须处理
|
||||
|
||||
`v_factor_events` 的 `ts_code` 取 `documents.meta->>'company_ts_code'`。公告确实 100% 可靠,
|
||||
但**年报也有这个锚**,而**一份年报能抽出十几条 EVENT** —— hotspot 为此专门做了防刷屏
|
||||
(`hotspot.py:92-99` 的注释:"实测年报副产物把台账刷满的教训",对策是 other 不进、
|
||||
同主体同类型只取最新、单主体 ≤2 条)。**桥侧没有任何等价保护。**
|
||||
|
||||
后果:年报披露日会形成一个巨大的尖峰,且年报里提到的**历史**诉讼 / 处罚会被记成
|
||||
当日的负面事件。若 S2 按计划用 `akg_event < −θ_e` 做否决闸,
|
||||
**会因为一份年报提到历史诉讼而否决掉一只好股。**
|
||||
|
||||
修法(S2 之前):按文档类型只取公告,或按 `(ts_code, event_type, 自然日)` 去重 + 单日封顶。
|
||||
极性表(§9-5)在这个问题修掉之前,调得再准也没意义。
|
||||
|
||||
### 6.6 probe 与桥的事件口径不一致
|
||||
|
||||
`factor_coverage_probe.cov_event`(`:115-128`)用 `claims.subject_id` 去匹配池成员名/码,
|
||||
而桥用文档锚 `documents.meta->>'company_ts_code'`。**两个数不会对得上** ——
|
||||
读 G1 报告时别把它们当同一个指标。建议顺手把 probe 改成同一口径,否则会误判覆盖面。
|
||||
|
||||
### 6.7 一处枚举不一致(小,但会咬人)
|
||||
|
||||
`enums.EntityKind` 只有 6 个成员,**没有 Segment**;但 `_ID_KEYS`、`ALLOWED_OBJECT_TYPES`
|
||||
都把 Segment 当一等节点。类型闸门走的是 `ALLOWED_OBJECT_TYPES` 所以不炸,
|
||||
但 `EntityKind` 已经不是节点类型的可信清单了 —— 谁下次拿它做遍历会漏 Segment。
|
||||
|
||||
---
|
||||
|
||||
## 7. 判收标准的两处需要改写
|
||||
|
||||
**§10「每日调度幂等,重跑不产生重复或漂移」**
|
||||
桥内成立 ✅,上游不成立 ❌(硬伤 1 的 Neo4j 返回序、前复权重写)。建议拆成两条:
|
||||
|
||||
- 桥内写入幂等 —— 现状已满足;
|
||||
- **外部不可复现的部分靠输入冻结解决** —— 即第 5 节的 `data/frozen/`。
|
||||
把"可复现"重新定义为"可从冻结输入重算出同一个值",这是唯一诚实且可达的口径。
|
||||
|
||||
**§7-A「`akg_score` 只在当日传导表有数据的交易日出行」**
|
||||
应加强为 **当日「候选池内」传导非空**。传导 83 行/日是**全池**口径,
|
||||
过完 C ∩ V 之后池内可能一只都不剩 —— 那天照样会写出一张"第一权重项全为 0"的表,
|
||||
正是 §7-A 想避免的情况。
|
||||
|
||||
同时建议每日日志固定打两个数:`|P|`(候选池规模)与 `|P ∩ 传导>0|`。
|
||||
后者才是真正决定榜首的数量。若它长期是 0~3,那么"组合"实际上是 1~3 只票,
|
||||
且这 1~3 只是从一个 12 × 12 截断列表里出来的 —— 集中度需要在文档里明写。
|
||||
|
||||
---
|
||||
|
||||
## 8. 给平台评价层的一个建议(成本极低、收益很大)
|
||||
|
||||
按现在的设计,`akg_score` 只在候选池内出行 →
|
||||
**平台的 IC / 分层只能看到"池内排序"的价值,完全看不到两道门槛的价值**,
|
||||
而门槛才是这套逻辑的主体。仪表盘会系统性低估这个因子。
|
||||
|
||||
加一个因子就解决:
|
||||
|
||||
| 因子 | 出行范围 | 回答的问题 |
|
||||
|---|---|---|
|
||||
| `akg_gate` | **全覆盖池**,0/1(1 = 过 C ∩ V) | 门槛本身有没有价值(分层直接读出门槛内外的收益差) |
|
||||
| `akg_score` | 候选池内 | 池内排序有没有价值 |
|
||||
|
||||
两个仪表各答一个问题,"哪一项在拖后腿"一眼可见。这比在一个因子上纠结 IC 高低有用得多,
|
||||
而且完全符合"回测是仪表盘不是判官"的定位。
|
||||
|
||||
---
|
||||
|
||||
## 9. 开放问题(§9)我的建议答案
|
||||
|
||||
| # | 议题 | 建议 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 1 | 热度语义翻转 | **同意取负**,但别做成独立加性项 → 做成组内 tiebreak | 第 2 节 |
|
||||
| 2 | 完整赛道清单 | **先跑 G1 体检再定清单**,不要先定清单再体检;C 做两级(graph / fallback)并强制标 `source_rule` | 3.2 |
|
||||
| 3 | θ_v 的 q | 先留 q = 0,等 G1 分布定;但注意 upside 主要由单值目标价构成,**绝对水平的可信度低于相对排序** | 6.3 |
|
||||
| 4 | 无券商覆盖股 | S1 保持"不过门槛",但**单独输出一张「赛道内 + 无覆盖」观察名单** —— 这正是你论题里最该出现的标的,别让它无声消失 | §1 研报滞后论 |
|
||||
| 5 | 事件极性 / 半衰期 | 延后到 S2 没问题,但**先修年报污染** | 6.5 |
|
||||
| 6 | upside 重建 甲 / 乙 | **甲案**,只需给 `sync_consensus` 加 `asof` 参数(约 3 行) | 6.4 |
|
||||
| 7 | score 历史 A / B | **A**,且加强为"**池内**传导非空" | 第 7 节 |
|
||||
| 8 | 权重 0.5/0.3/0.2 | 次序认可;**建议改结构(两段式)而不是改数值** | 第 2 节 |
|
||||
| 9 | 还没热是否条件项 | **S1 就上**。现状在 95% 稀疏下已经等价于条件项,不如写明 | 第 2 节 |
|
||||
| 10 | 调度时点 | **T 日 18:40 算(热度用 T−1)**。漏斗赌的是左侧位置,热度差一天的信息损失远小于因子延迟一天的机会损失;且 T+1 早间补算会让"T 日因子"依赖 T+1 数据,语义上更难向下游解释 | §3.4 末 |
|
||||
| **11(新增)** | **universe 历史快照** | v1.1 §8-2 被 v2.0 删掉了,但它现在更重要 → 子因子去掉池过滤 + 池每日快照 | 第 4 节 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 对 G 系列里程碑的微调建议
|
||||
|
||||
```
|
||||
G0 文档评审(含本评审的取舍)
|
||||
G0.5 ★新增★ 冻结与修复 —— 不依赖赛道清单,且第 3 项每晚一天永久少一天历史
|
||||
① data/frozen/ 输入冻结机制
|
||||
② 修硬伤 1/2/3(其中硬伤 2 只改视图)
|
||||
③ 基座开始积累 industry_pools / Segment 边快照
|
||||
G1 赛道覆盖体检 + probe 加两节:
|
||||
corr(池内三项相关矩阵)· tracks(yml 草案在图谱里做成员性试算)
|
||||
+ 原有三件(factor_coverage_probe 补跑 / upside 分布定 q / gp_day_data 起始日复核)
|
||||
G2 赛道成员表落地(layer 降级为可选元数据,不阻塞)+ 第五/第六个插槽视图
|
||||
G3 漏斗实现 + 注册 akg_score **与 akg_gate**(第 8 节)
|
||||
G4 回填与仪表盘(upside 走甲案)
|
||||
G5 每日调度上线(T 日 18:40)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 提交提醒
|
||||
|
||||
已核对两个仓库的实际状态(`git status`):
|
||||
|
||||
**`akg-factor-bridge` 有 4 项未提交**(最后一次 commit 是 `42659b9 历史深度数据探查`):
|
||||
|
||||
```
|
||||
M README.md
|
||||
M run.py
|
||||
?? probe.py ← G1 体检脚本,全新未跟踪
|
||||
?? 量化因子导出与合成设计.md ← v2.0,全新未跟踪
|
||||
```
|
||||
|
||||
**`astock-kg` 工作区干净** —— `factor_coverage_probe.py` 已在 `32448c3` 入库,无需再提交。
|
||||
|
||||
**一处文档不同步需要处理**:`astock-kg/docs/量化因子导出与合成设计.md` 还是 **v1**
|
||||
(2026-07-24,四路对称 + 平台拟合合成),而桥仓库那份已经是 **v2.0**(自洽公式)。
|
||||
两份同名文档在两个仓库里讲相反的方案,下次翻文档一定踩坑。
|
||||
建议:**只留桥仓库这一份为唯一事实源**,`astock-kg/docs/` 那份改成一行指路
|
||||
("本设计已迁至 akg-factor-bridge 仓库,见该工程根目录同名文档"),并在提交信息里写明。
|
||||
|
||||
开发机执行(按硬规矩,push 由你做):
|
||||
|
||||
```bash
|
||||
cd ~/Documents/work/project/akg-factor-bridge
|
||||
git add -A
|
||||
git commit -m "docs: 量化因子设计 v2.0(推翻拟合合成,转自洽景气度漏斗)
|
||||
feat: G1 赛道覆盖体检 probe(pools/price/upside 三节,只读)
|
||||
docs: README 补 probe 用法与数据前沿自检"
|
||||
|
||||
# 文档去重后(把 astock-kg/docs 那份改成指路)
|
||||
cd ~/Documents/work/project/astock-kg
|
||||
git add -A
|
||||
git commit -m "docs: 量化因子设计迁至 akg-factor-bridge 仓库(v1 留指路,避免与 v2.0 并存)"
|
||||
```
|
||||
|
||||
本评审文档建议放 `akg-factor-bridge/评审_v2.0_2026-07-26.md` 一并提交。
|
||||
206
factors.py
206
factors.py
|
|
@ -1,10 +1,12 @@
|
|||
"""四路子因子构造。输入日期区间 [start, end](YYYY-MM-DD),输出
|
||||
DataFrame[trade_date, stock_code, factor_value],全部限制在 KG 覆盖池 universe 内。
|
||||
stock_code 输出形态不限,common.write_factor 统一转前缀式。
|
||||
DataFrame[trade_date, stock_code, factor_value]。stock_code 输出形态不限,
|
||||
common.write_factor 统一转前缀式。
|
||||
|
||||
覆盖范围由 config.SUBFACTOR_UNIVERSE 控制(pool=池内 / market=全市场,见 §4 评审);
|
||||
传导不受该开关影响——它天生就是池内语义。
|
||||
|
||||
建模参数(EVENT_POLARITY / *_HALF_LIFE / *_WINDOW)是**因子决策**,见设计文档
|
||||
§3.3 与 §8-3,此处取草案默认,待用户确认后调。方向(direction)统一在合成配置里声明,
|
||||
子因子表只存原始值——但 event 天然带符号,故此处保留极性。
|
||||
§5.3 与 §9-5,此处取草案默认,待用户确认后调。
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
|
@ -20,7 +22,7 @@ FACTORS = {
|
|||
"akg_transmission": "t_factor_akg_transmission",
|
||||
}
|
||||
|
||||
# ---- 事件极性草案(§3.3,待 §8-3 确认)----
|
||||
# ---- 事件极性草案(设计 §5.3,待 §9-5 确认)----
|
||||
EVENT_POLARITY = {
|
||||
"股份回购": 1.0, "重大合同中标": 1.0, "股权激励授予": 0.5,
|
||||
"诉讼仲裁": -1.0, "行政处罚": -1.0, "股权质押": -0.5,
|
||||
|
|
@ -37,7 +39,6 @@ _EMPTY = pd.DataFrame(columns=["trade_date", "stock_code", "factor_value"])
|
|||
# ---------------------------------------------------------------- 热度
|
||||
def build_heat(start, end):
|
||||
"""热度 = stock_fund_heat_scores 最新批次 score(0~1)。stock_code 已前缀式。"""
|
||||
uni = {common.to_prefix(x) for x in common.load_universe()}
|
||||
df = db.read_mysql("heat",
|
||||
"""SELECT s.trade_date, s.stock_code, s.score AS factor_value
|
||||
FROM stock_fund_heat_scores s
|
||||
|
|
@ -49,47 +50,81 @@ def build_heat(start, end):
|
|||
return _EMPTY
|
||||
df["stock_code"] = df["stock_code"].astype(str).str.strip()
|
||||
df["factor_value"] = pd.to_numeric(df["factor_value"], errors="coerce")
|
||||
df = df[df["stock_code"].isin(uni)]
|
||||
df = common.universe_filter(df, col="stock_code", as_prefix=True)
|
||||
return df[["trade_date", "stock_code", "factor_value"]]
|
||||
|
||||
|
||||
def _read_gp_price(start, end):
|
||||
"""gp_day_data 现价:代码列名不定(平台实测=symbol,非 ts_code)——照 rsi_14d_etl
|
||||
惯例按候选列逐个试,用第一个能查通的。"""
|
||||
def _price_code_col() -> str:
|
||||
"""gp_day_data 的代码列名(平台实测 = symbol,非 ts_code)。
|
||||
|
||||
用 LIMIT 1 探列名,而不是拿全区间查询去试错——原来那种写法在 history 模式下
|
||||
第一次试探就会拉一次全区间数据。"""
|
||||
cands = [config.PRICE_CODE_COL] + [c for c in ("symbol", "ts_code")
|
||||
if c != config.PRICE_CODE_COL]
|
||||
last = None
|
||||
for c in cands:
|
||||
try:
|
||||
df = db.read_mysql("price",
|
||||
f"SELECT `timestamp` AS trade_date, `{c}` AS ts_code, close "
|
||||
f"FROM gp_day_data WHERE `timestamp` BETWEEN %s AND %s", (start, end))
|
||||
print(f" (upside 现价用 gp_day_data.{c})")
|
||||
return df
|
||||
db.read_mysql("price", f"SELECT `{c}` FROM gp_day_data LIMIT 1")
|
||||
print(f" (现价用 gp_day_data.{c})")
|
||||
return c
|
||||
except Exception as e: # noqa: BLE001 —— 列名不对就换下一个候选
|
||||
last = e
|
||||
raise RuntimeError(f"gp_day_data 代码列都不行(试了 {cands}): {last!r}")
|
||||
|
||||
|
||||
def _read_gp_price(start, end):
|
||||
"""gp_day_data 现价,**按月分块**读取。
|
||||
|
||||
原来一次拉全区间:`--mode history --start 2006-01-01` 会把千万级行拉进 pandas。
|
||||
不在 SQL 里按代码过滤——代码形态(600000.SH / SH600000 / 600000)两边不一致,
|
||||
SQL 侧过滤容易全空且难排查,统一折前缀式后在 pandas 侧过滤。
|
||||
"""
|
||||
col = _price_code_col()
|
||||
parts, cur = [], pd.Timestamp(start)
|
||||
endts = pd.Timestamp(end)
|
||||
step = max(1, config.PRICE_CHUNK_DAYS)
|
||||
while cur <= endts:
|
||||
hi = min(cur + pd.Timedelta(days=step - 1), endts)
|
||||
parts.append(db.read_mysql(
|
||||
"price",
|
||||
f"SELECT `timestamp` AS trade_date, `{col}` AS ts_code, close "
|
||||
f"FROM gp_day_data WHERE `timestamp` BETWEEN %s AND %s",
|
||||
(cur.date().isoformat(), hi.date().isoformat())))
|
||||
cur = hi + pd.Timedelta(days=1)
|
||||
if not parts:
|
||||
return pd.DataFrame(columns=["trade_date", "ts_code", "close"])
|
||||
return pd.concat(parts, ignore_index=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 预期空间
|
||||
def build_upside(start, end):
|
||||
"""upside = 一致预期目标价中枢 / 当日现价 − 1(as-of:现价日取 asof<=当日最新一致预期)。
|
||||
现价来自平台 gp_day_data(代码列 config.PRICE_CODE_COL,待实机核实)。"""
|
||||
uni = common.load_universe()
|
||||
|
||||
⚠️ 两个已知口径特征(评审 §6.3,不是 bug,但读数时要知道):
|
||||
· consensus_daily 的 target_mid_avg 是**90 天内全部研报行的简单平均**
|
||||
(不按机构去重、不按时间加权),且实测 max_price 非空仅 0.4%、min_price 31%
|
||||
—— 目标价中枢主要由单值目标价构成;
|
||||
· gp_day_data 是**前复权**、锚在最新日,每次除权历史 close 会被整体重写,
|
||||
所以 upside 的历史值不可复现 —— 用 freeze.py 冻结当时用到的 close。
|
||||
"""
|
||||
cons = db.read_pg(
|
||||
"SELECT ts_code, asof_date, target_mid_avg FROM v_factor_consensus WHERE asof_date <= %s",
|
||||
(end,))
|
||||
cons = cons[cons["ts_code"].isin(uni)].copy()
|
||||
"SELECT ts_code, asof_date, target_mid_avg FROM v_factor_consensus "
|
||||
"WHERE asof_date <= %s", (end,))
|
||||
if cons.empty:
|
||||
return _EMPTY
|
||||
cons["k"] = cons["ts_code"].map(common.to_prefix)
|
||||
# 注:consensus_daily 本就只对池成员聚合(基座 _pool_ts_codes),
|
||||
# 故 market 模式下 upside 覆盖不会真的变宽——这里过滤只为口径一致。
|
||||
cons = common.universe_filter(cons, col="k", as_prefix=True)
|
||||
if cons.empty:
|
||||
return _EMPTY
|
||||
|
||||
price = _read_gp_price(start, end)
|
||||
if price.empty:
|
||||
return _EMPTY
|
||||
price["close"] = pd.to_numeric(price["close"], errors="coerce")
|
||||
# 归一到前缀式两边对齐(gp_day_data 代码形态不定 → 都折前缀式后 join)
|
||||
price["k"] = price["ts_code"].map(common.to_prefix)
|
||||
price = price[(price["close"] > 0)].dropna(subset=["close"])
|
||||
cons["k"] = cons["ts_code"].map(common.to_prefix)
|
||||
cons = cons[cons["k"].isin(set(price["k"]))]
|
||||
if cons.empty:
|
||||
return _EMPTY
|
||||
|
|
@ -119,59 +154,152 @@ def _polarity(event_type, direction):
|
|||
|
||||
def build_event(start, end):
|
||||
"""事件分 = Σ 近窗口内事件 极性 × 时间衰减(exp(-交易日龄·ln2/半衰期))。
|
||||
ts_code 取文档锚(v_factor_events 已解析)。无事件的股当天不出行(= 缺 → 合成侧填 0)。"""
|
||||
uni = common.load_universe()
|
||||
ts_code 取文档锚(v_factor_events 已解析)。无事件的股当天不出行(= 缺 → 合成侧填 0)。
|
||||
"""
|
||||
look = (pd.Timestamp(start) - pd.Timedelta(days=EVENT_WINDOW * 2)).date()
|
||||
try:
|
||||
ev = db.read_pg(
|
||||
"SELECT ts_code, disclosure_date, event_type, direction, "
|
||||
" confidence, doc_id, source_type "
|
||||
"FROM v_factor_events WHERE disclosure_date BETWEEN %s AND %s", (look, end))
|
||||
except Exception as e: # noqa: BLE001 —— 视图还是 v1(无 doc_id/source_type)时退回
|
||||
print(f" (v_factor_events 无 doc_id/source_type,退回旧列——建议先更新视图: {e!r})")
|
||||
ev = db.read_pg(
|
||||
"SELECT ts_code, disclosure_date, event_type, direction "
|
||||
"FROM v_factor_events WHERE disclosure_date BETWEEN %s AND %s", (look, end))
|
||||
ev = ev[ev["ts_code"].isin(uni)].copy()
|
||||
if ev.empty:
|
||||
return _EMPTY
|
||||
ev = common.universe_filter(ev, col="ts_code", as_prefix=False).copy()
|
||||
if ev.empty:
|
||||
return _EMPTY
|
||||
|
||||
# ---- 年报污染防护(评审 §6.5)------------------------------------------
|
||||
# v_factor_events 的锚 documents.meta->>'company_ts_code' 年报同样有,而一份年报
|
||||
# 能抽十几条 EVENT,且含**历史**诉讼/处罚 —— 会在年报披露日形成巨大负值尖峰。
|
||||
# 基座 hotspot._pick_event_anomalies 为此专门做了防刷屏(other 不进 / 同主体同
|
||||
# 类型只取最新 / 单主体≤2),桥侧原来零保护。三道,从强到弱:
|
||||
if "source_type" in ev.columns:
|
||||
keep = ev["source_type"].astype(str).isin(config.EVENT_SOURCE_TYPES)
|
||||
if (~keep).any():
|
||||
drop_by = ev.loc[~keep, "source_type"].value_counts().to_dict()
|
||||
print(f" (事件:按 source_type 剔除 {int((~keep).sum())} 条 {drop_by})")
|
||||
ev = ev[keep]
|
||||
if ev.empty:
|
||||
print(" ⚠️ 按 source_type 白名单过滤后为空——核对 EVENT_SOURCE_TYPES")
|
||||
return _EMPTY
|
||||
if "confidence" in ev.columns: # 同键取置信度最高的一条
|
||||
ev = ev.sort_values("confidence", ascending=False, na_position="last")
|
||||
ev = ev.drop_duplicates(["ts_code", "event_type", "direction", "disclosure_date"])
|
||||
if "doc_id" in ev.columns: # 单文档封顶
|
||||
before = len(ev)
|
||||
ev = ev.groupby("doc_id", group_keys=False).head(config.EVENT_MAX_PER_DOC)
|
||||
if len(ev) < before:
|
||||
print(f" (事件:单文档封顶 {config.EVENT_MAX_PER_DOC} 条,剔除 {before - len(ev)} 条)")
|
||||
|
||||
ev["event_type"] = ev["event_type"].fillna("")
|
||||
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN),先填空防 .strip 崩
|
||||
ev["direction"] = ev["direction"].fillna("") # 多数事件无 direction(NULL→NaN)
|
||||
ev["pol"] = [_polarity(t, d) for t, d in zip(ev["event_type"], ev["direction"])]
|
||||
ev = ev[ev["pol"] != 0.0]
|
||||
if ev.empty:
|
||||
return _EMPTY
|
||||
|
||||
cal = common.trading_days(start, end)
|
||||
if not cal:
|
||||
print(f" ⚠️ {start}~{end} 无交易日 → 事件因子空转"
|
||||
f"(这是「日历为空」,不是「没有事件」)")
|
||||
return _EMPTY
|
||||
cal = pd.DatetimeIndex(cal)
|
||||
cal_i = cal.values.astype("datetime64[ns]").astype("int64") # (D,)
|
||||
decay = np.log(2) / EVENT_HALF_LIFE
|
||||
rows = []
|
||||
for ts, g in ev.groupby("ts_code"):
|
||||
disc = pd.to_datetime(g["disclosure_date"]).values.astype("datetime64[ns]")
|
||||
disc = pd.to_datetime(g["disclosure_date"]).values \
|
||||
.astype("datetime64[ns]").astype("int64") # (E,)
|
||||
pol = g["pol"].to_numpy(dtype=float)
|
||||
for d in cal:
|
||||
# 自然日龄 → 交易日龄近似 ×(5/7)(v1 近似,见 README 待优化项)
|
||||
age_td = ((d.value - disc.astype("int64")) / 86_400e9) * (5.0 / 7.0)
|
||||
mask = (age_td >= 0) & (age_td <= EVENT_WINDOW)
|
||||
if not mask.any():
|
||||
age = (cal_i[:, None] - disc[None, :]) / 86_400e9 * (5.0 / 7.0) # (D,E)
|
||||
m = (age >= 0) & (age <= EVENT_WINDOW)
|
||||
if not m.any():
|
||||
continue
|
||||
val = float((pol[mask] * np.exp(-age_td[mask] * decay)).sum())
|
||||
if val != 0.0:
|
||||
rows.append((d.date(), ts, val))
|
||||
return pd.DataFrame(rows, columns=["trade_date", "stock_code", "factor_value"]) if rows else _EMPTY
|
||||
age_safe = np.where(m, age, 0.0) # 先夹再 exp,防 exp(超大正数) 溢出
|
||||
vals = np.where(m, pol[None, :] * np.exp(-age_safe * decay), 0.0).sum(axis=1)
|
||||
for i in np.nonzero(vals)[0]:
|
||||
rows.append((cal[i].date(), ts, float(vals[i])))
|
||||
return (pd.DataFrame(rows, columns=["trade_date", "stock_code", "factor_value"])
|
||||
if rows else _EMPTY)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 传导
|
||||
def build_transmission(start, end):
|
||||
"""传导分 = 指向该股所在环节的路径数 ×(1 − 已动比例);同股同日多候选取最大。"""
|
||||
uni = common.load_universe()
|
||||
"""传导分 = 指向该股所在环节的 **distinct 源数** ×(1 − 已动比例);
|
||||
同股同日多候选取最大。
|
||||
|
||||
口径修正(评审 硬伤2):原用 n_paths = jsonb_array_length(paths),但
|
||||
· graph_store.cascade() 的变长边 `*1..N` **每种长度各返回一条路径**
|
||||
(A→B 与 A→X→B 同时出现、末节点都是 B),跨路径不去重;
|
||||
· graph_store.transmission_targets() 的 updown/supply/drives 三桶之间也不去重。
|
||||
于是同一 source 对同一 target 重复计入,而 n_paths 是 factor_value 的主量级。
|
||||
改用 distinct source 数,也更贴合传导模块自述的语义「多源汇聚 = 传导逻辑更硬」。
|
||||
"""
|
||||
cols_new = ("scan_date, target, ts_code, n_sources, n_paths_raw, moved_ratio, "
|
||||
"members_total, n_quiet_stored, mkt_trade_date")
|
||||
try:
|
||||
tr = db.read_pg(
|
||||
f"SELECT {cols_new} FROM v_factor_transmission "
|
||||
f"WHERE scan_date BETWEEN %s AND %s", (start, end))
|
||||
col_val = "n_sources"
|
||||
except Exception as e: # noqa: BLE001 —— 视图还是 v1 时退回,但明确告警
|
||||
print(f" ⚠️ v_factor_transmission 还是旧版(无 n_sources)——退回 n_paths,"
|
||||
f"该口径把重复路径计入了强度,请尽快更新视图: {e!r}")
|
||||
tr = db.read_pg(
|
||||
"SELECT scan_date, ts_code, n_paths, moved_ratio "
|
||||
"FROM v_factor_transmission WHERE scan_date BETWEEN %s AND %s", (start, end))
|
||||
col_val = "n_paths"
|
||||
if tr.empty:
|
||||
return _EMPTY
|
||||
uni = common.load_universe() # 传导恒按池过滤(天生池内语义)
|
||||
tr = tr[tr["ts_code"].isin(uni)].copy()
|
||||
if tr.empty:
|
||||
return _EMPTY
|
||||
tr["factor_value"] = (tr["n_paths"].astype(float)
|
||||
* (1.0 - pd.to_numeric(tr["moved_ratio"], errors="coerce").fillna(0.0)))
|
||||
|
||||
_warn_upstream_truncation(tr)
|
||||
|
||||
tr["factor_value"] = (pd.to_numeric(tr[col_val], errors="coerce").fillna(0.0)
|
||||
* (1.0 - pd.to_numeric(tr["moved_ratio"],
|
||||
errors="coerce").fillna(0.0)))
|
||||
g = (tr.groupby(["scan_date", "ts_code"])["factor_value"].max().reset_index()
|
||||
.rename(columns={"scan_date": "trade_date", "ts_code": "stock_code"}))
|
||||
return g[["trade_date", "stock_code", "factor_value"]]
|
||||
|
||||
|
||||
def _warn_upstream_truncation(tr: pd.DataFrame) -> None:
|
||||
"""把三个上游「静默失真」变成显式告警(评审 硬伤1、§6.2)。
|
||||
|
||||
这些是基座侧的问题,桥修不了,但**必须看得见**——否则第一权重项在悄悄失真。
|
||||
"""
|
||||
if "mkt_trade_date" in tr.columns and tr["mkt_trade_date"].notna().any():
|
||||
bad = tr[tr["mkt_trade_date"].astype(str) != tr["scan_date"].astype(str)]
|
||||
if not bad.empty:
|
||||
print(f" ⚠️ {bad['scan_date'].nunique()} 个 scan_date 的 movers 快照日与 "
|
||||
f"scan_date 不符(17:30 sync_market 晚点)——这些日的传导项不可信")
|
||||
if "members_total" in tr.columns:
|
||||
hit = tr[pd.to_numeric(tr["members_total"], errors="coerce")
|
||||
>= config.UPSTREAM_MEMBER_CAP]
|
||||
if not hit.empty:
|
||||
n = hit["target"].nunique() if "target" in hit else len(hit)
|
||||
print(f" ⚠️ {n} 个环节 members_total >= {config.UPSTREAM_MEMBER_CAP},"
|
||||
f"撞上游 topic_context 的 LIMIT cap —— moved_ratio 建立在**任意顺序**的"
|
||||
f"抽样上(Cypher 无 ORDER BY),先修基座再放量")
|
||||
if "n_quiet_stored" in tr.columns:
|
||||
hit = tr[pd.to_numeric(tr["n_quiet_stored"], errors="coerce")
|
||||
>= config.UPSTREAM_QUIET_CAP]
|
||||
if not hit.empty:
|
||||
n = hit["target"].nunique() if "target" in hit else len(hit)
|
||||
print(f" ⚠️ {n} 个环节 quiet 存满 {config.UPSTREAM_QUIET_CAP} 条,"
|
||||
f"撞 transmission.py 的 quiet[:12] 截断 —— 真实未动成员更多,"
|
||||
f"因子覆盖被展示逻辑锁住")
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
"akg_upside": build_upside, "akg_heat": build_heat,
|
||||
"akg_event": build_event, "akg_transmission": build_transmission,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
"""输入冻结(G0.5 · 评审 §5)——把每日「不可复原的上游」落成不可变快照。
|
||||
|
||||
**为什么必须有这个模块。** 桥内写入是幂等的,但上游不是:
|
||||
|
||||
1. `transmission_candidates` 的 quiet 集合与 moved_ratio 依赖 Neo4j 返回序
|
||||
(基座 `topic_context` 的 Cypher 是 `LIMIT $cap` 且无 ORDER BY),同日重跑可能不同;
|
||||
2. `industry_pools` 只存最新态、每周一 refresh_pools 自动长大,没有时点版本;
|
||||
3. `gp_day_data` 是**前复权**、锚在最新日——每次有股票除权,历史 close 会被整体
|
||||
重写,于是 `akg_upside` 的历史值今天回填和三个月后回填不是同一组数;
|
||||
4. `consensus_daily` 的 asof 行会随 sync_consensus 重跑而覆盖。
|
||||
|
||||
于是「可复现」唯一诚实可达的定义是:**能从冻结的输入重算出同一个值**。
|
||||
这个模块就是那份冻结,一次性满足三件事——判收标准 §10 的「重跑不产生漂移」、
|
||||
「赛道成员快照可审计」、以及未来任何回溯需求(含 §10 里 GRU 复活所需的
|
||||
「≥1 年 live 传导史」——那个计时器只在开始存快照的那天启动)。
|
||||
|
||||
落点:桥工程自身 `data/frozen/<YYYY-MM-DD>/`(config.FROZEN_ROOT),
|
||||
不落基座、不落平台,守 §4 的三层边界。
|
||||
格式:装了 pyarrow 就 parquet,否则 csv.gz(零新增依赖)。
|
||||
每天一个 manifest.json 记录各源行数、告警与代码版本——**manifest 才是可 diff 的
|
||||
审计线**,建议 git 跟踪 manifest.json、忽略数据文件。
|
||||
|
||||
用法(全程 Docker):
|
||||
docker compose exec akg-factor-bridge python run.py freeze --date 2026-07-24
|
||||
docker compose exec akg-factor-bridge python run.py freeze # 默认今天
|
||||
# 日更链里 `build ... --mode daily` 跑完会自动冻结(--no-freeze 可关)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
import config
|
||||
import db
|
||||
|
||||
try: # parquet 更省更快,但不强制装 pyarrow
|
||||
import pyarrow # noqa: F401
|
||||
_FMT, _EXT = "parquet", ".parquet"
|
||||
except Exception: # noqa: BLE001
|
||||
_FMT, _EXT = "csv.gz", ".csv.gz"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 落盘
|
||||
def _write(df: pd.DataFrame, path: Path) -> int:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
df = pd.DataFrame() if df is None else df
|
||||
# 空也要留痕:0 行本身是信息("那天上游真的没有数据" ≠ "那天没跑")
|
||||
if _FMT == "parquet":
|
||||
df.to_parquet(path, index=False)
|
||||
else:
|
||||
df.to_csv(path, index=False, compression="gzip")
|
||||
return len(df)
|
||||
|
||||
|
||||
def _git_rev() -> str:
|
||||
"""记录冻结时的桥代码版本——快照可复算的前提是知道当时的口径。"""
|
||||
try:
|
||||
return subprocess.run(["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd="/app", capture_output=True, text=True,
|
||||
timeout=5).stdout.strip() or "unknown"
|
||||
except Exception: # noqa: BLE001 —— 无 git / 无 .git 都不该让冻结失败
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 各源抓取
|
||||
def _cap_universe(_day: str) -> pd.DataFrame:
|
||||
"""覆盖池当日成员(industry_pools 无历史版本 → 只能靠这里留时点)。"""
|
||||
return db.read_pg("SELECT ts_code FROM v_factor_universe ORDER BY ts_code")
|
||||
|
||||
|
||||
def _cap_transmission(day: str) -> pd.DataFrame:
|
||||
"""传导原始行。四路里唯一「过期即不可复原」的推断类信号,最该冻结。"""
|
||||
try:
|
||||
return db.read_pg(
|
||||
"SELECT * FROM v_factor_transmission WHERE scan_date = %s "
|
||||
"ORDER BY ts_code", (day,))
|
||||
except Exception: # noqa: BLE001 —— 旧视图无 rank/target 列时的兜底
|
||||
return db.read_pg(
|
||||
"SELECT * FROM v_factor_transmission WHERE scan_date = %s", (day,))
|
||||
|
||||
|
||||
def _cap_consensus(day: str) -> pd.DataFrame:
|
||||
"""当日可见的最新一致预期(as-of 口径与 build_upside 一致,每股留最新一行)。"""
|
||||
return db.read_pg(
|
||||
"SELECT DISTINCT ON (ts_code) ts_code, asof_date, target_mid_avg, "
|
||||
" eps_med, np_med, n_orgs, n_reports_90d "
|
||||
"FROM v_factor_consensus WHERE asof_date <= %s "
|
||||
"ORDER BY ts_code, asof_date DESC", (day,))
|
||||
|
||||
|
||||
def _cap_price(day: str) -> pd.DataFrame:
|
||||
"""当日收盘(**前复权**,会被后续除权整体重写 → 必须冻结当时看到的值)。"""
|
||||
import factors # 复用同一套代码列探测,口径不分叉
|
||||
col = factors._price_code_col() # noqa: SLF001
|
||||
return db.read_mysql(
|
||||
"price",
|
||||
f"SELECT `timestamp` AS trade_date, `{col}` AS code, close "
|
||||
f"FROM gp_day_data WHERE `timestamp` = %s", (day,))
|
||||
|
||||
|
||||
def _cap_heat(day: str) -> pd.DataFrame:
|
||||
"""当日最新批次热度(T+1 到达,故 day 当天常为空——空也留痕)。"""
|
||||
return db.read_mysql(
|
||||
"heat",
|
||||
"""SELECT s.trade_date, s.stock_code, s.batch_no, s.score
|
||||
FROM stock_fund_heat_scores s
|
||||
JOIN (SELECT trade_date, MAX(batch_no) bn FROM stock_fund_heat_scores
|
||||
WHERE trade_date = %s GROUP BY trade_date) m
|
||||
ON m.trade_date = s.trade_date AND m.bn = s.batch_no""", (day,))
|
||||
|
||||
|
||||
def _cap_optional(sql: str):
|
||||
"""可选视图(第五/六个插槽视图还没建时不该让冻结失败)。"""
|
||||
def _f(_day: str) -> pd.DataFrame:
|
||||
try:
|
||||
return db.read_pg(sql)
|
||||
except Exception: # noqa: BLE001
|
||||
return pd.DataFrame()
|
||||
return _f
|
||||
|
||||
|
||||
_SOURCES = {
|
||||
"universe": _cap_universe,
|
||||
"transmission": _cap_transmission,
|
||||
"consensus": _cap_consensus,
|
||||
"price_close": _cap_price,
|
||||
"heat": _cap_heat,
|
||||
# 第五/六个插槽视图建好后自动开始冻结(没建 → 空 DataFrame,不报错)
|
||||
"segment_members": _cap_optional("SELECT * FROM v_factor_segment_members"),
|
||||
"segment_edges": _cap_optional("SELECT * FROM v_factor_segment_edges"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 体检断言
|
||||
def _audit(day: str, tr: pd.DataFrame) -> list[str]:
|
||||
"""把评审里那几个「静默失真」写成显式告警,冻结当时就看见。"""
|
||||
warns: list[str] = []
|
||||
if tr is None or tr.empty:
|
||||
return warns
|
||||
if "mkt_trade_date" in tr.columns and tr["mkt_trade_date"].notna().any():
|
||||
bad = sorted({str(x) for x in tr["mkt_trade_date"].dropna()
|
||||
if str(x) != day})
|
||||
if bad:
|
||||
warns.append(f"传导用的 mkt 快照日 {bad} ≠ scan_date {day}"
|
||||
f"——movers 陈旧,该日传导项不可信(评审 §6.2)")
|
||||
if "members_total" in tr.columns:
|
||||
hit = tr[pd.to_numeric(tr["members_total"], errors="coerce")
|
||||
>= config.UPSTREAM_MEMBER_CAP]
|
||||
if not hit.empty:
|
||||
n = hit["target"].nunique() if "target" in hit.columns else len(hit)
|
||||
warns.append(f"{n} 个环节 members_total >= {config.UPSTREAM_MEMBER_CAP},"
|
||||
f"撞上游 topic_context cap——moved_ratio 建立在任意顺序的抽样上"
|
||||
f"(评审 硬伤1)")
|
||||
if "n_quiet_stored" in tr.columns:
|
||||
hit = tr[pd.to_numeric(tr["n_quiet_stored"], errors="coerce")
|
||||
>= config.UPSTREAM_QUIET_CAP]
|
||||
if not hit.empty:
|
||||
n = hit["target"].nunique() if "target" in hit.columns else len(hit)
|
||||
warns.append(f"{n} 个环节 quiet 存满 {config.UPSTREAM_QUIET_CAP} 条"
|
||||
f"=撞 quiet[:12] 截断——真实未动成员更多(评审 硬伤1)")
|
||||
return warns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 入口
|
||||
def snapshot(day: str | None = None, extra_frames: dict | None = None) -> dict:
|
||||
"""冻结 day 当日的全部上游输入 +(可选)当日算出的因子值。
|
||||
|
||||
extra_frames: {"factor_akg_heat": df, ...} —— run.py build 完顺手传进来,
|
||||
这样「输入 + 输出」在同一目录里,任何一行因子值都能被逐步复算。
|
||||
"""
|
||||
day = day or dt.date.today().isoformat()
|
||||
out = Path(config.FROZEN_ROOT) / day
|
||||
manifest = {"date": day, "format": _FMT, "git_rev": _git_rev(),
|
||||
"subfactor_universe": config.SUBFACTOR_UNIVERSE,
|
||||
"frozen_at": dt.datetime.now().astimezone().isoformat(),
|
||||
"rows": {}, "errors": {}, "warnings": []}
|
||||
frames: dict[str, pd.DataFrame] = {}
|
||||
|
||||
for name, fn in _SOURCES.items():
|
||||
try:
|
||||
df = fn(day)
|
||||
frames[name] = df
|
||||
manifest["rows"][name] = _write(df, out / f"{name}{_EXT}")
|
||||
except Exception as e: # noqa: BLE001 —— 单源失败不拖累其余源
|
||||
manifest["errors"][name] = repr(e)
|
||||
print(f" ❌ 冻结 {name} 失败: {e!r}")
|
||||
|
||||
for name, df in (extra_frames or {}).items():
|
||||
try:
|
||||
manifest["rows"][name] = _write(df, out / f"{name}{_EXT}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
manifest["errors"][name] = repr(e)
|
||||
|
||||
manifest["warnings"] = _audit(day, frames.get("transmission"))
|
||||
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f" ❄️ 冻结 {day} → {out}")
|
||||
for k, v in manifest["rows"].items():
|
||||
print(f" {k}: {v} 行")
|
||||
for w in manifest["warnings"]:
|
||||
print(f" ⚠️ {w}")
|
||||
return manifest
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"""G1 体检(只读探查,不写任何库)。设计依据:量化因子导出与合成设计.md §8-G1
|
||||
+ 2026-07-26 交叉评审。
|
||||
|
||||
python run.py probe # 四节全跑
|
||||
python run.py probe --section corr # 单跑:pools | price | upside | corr
|
||||
|
||||
四节:
|
||||
[pools ] industry_pools 列结构 + 逐池清单 + 成员元素键采样
|
||||
—— frontier_tracks.yml 的映射要对着真实主题名/字段起草,不猜 KG 命名
|
||||
[price ] gp_day_data 按年 distinct 天数 —— 复核 §6.2 起始日疑点(5584 天 vs 理论 ~7000)
|
||||
[upside] 覆盖池 upside 截面分布 + q 档位表 —— 定 §9-3 估值门槛分位 q
|
||||
[corr ] 池内 (z_T, z_H, z_V) 相关矩阵 + 权重体检 —— 定 §9-8 权重结构(评审 §2)
|
||||
|
||||
注意:pools 节直读基座 industry_pools 表——这是一次性诊断的例外(§4 的运行时路径
|
||||
只许走只读视图);看清 schema 后,G2 把池清单固化成第五个插槽视图再供 tracks.py 用。
|
||||
若连接账号只被授了视图权限,此节会报 permission denied——换基座 owner 账号跑一次即可。
|
||||
任一节失败不影响其余节。
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import common
|
||||
import db
|
||||
import factors
|
||||
|
||||
|
||||
def _sec(t):
|
||||
print(f"\n{'=' * 64}\n[{t}]\n{'=' * 64}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pools
|
||||
def probe_pools():
|
||||
_sec("pools · industry_pools 结构与主题清单")
|
||||
cols = db.read_pg(
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_name = 'industry_pools' "
|
||||
" AND table_schema NOT IN ('pg_catalog','information_schema') "
|
||||
"ORDER BY ordinal_position")
|
||||
if cols.empty:
|
||||
print("❌ 取不到 industry_pools 列信息(表不存在或无权限)")
|
||||
return
|
||||
print("列结构:")
|
||||
for c, t in zip(cols["column_name"], cols["data_type"]):
|
||||
print(f" {c}: {t}")
|
||||
|
||||
scalar = [c for c, t in zip(cols["column_name"], cols["data_type"])
|
||||
if t not in ("jsonb", "json", "ARRAY")]
|
||||
sel = ", ".join(f'"{c}"' for c in scalar) if scalar else "'(无标量列)' AS note"
|
||||
df = db.read_pg(
|
||||
f"SELECT {sel}, jsonb_array_length(COALESCE(members, '[]'::jsonb)) AS n_members "
|
||||
f"FROM industry_pools ORDER BY n_members DESC")
|
||||
print(f"\n逐池清单(共 {len(df)} 个池;标量列全给,赛道映射草案对着这个起):")
|
||||
with pd.option_context("display.max_rows", None, "display.max_columns", None,
|
||||
"display.width", 220, "display.max_colwidth", 80):
|
||||
print(df.to_string(index=False))
|
||||
|
||||
keys = db.read_pg(
|
||||
"SELECT k AS member_key, count(*) AS n FROM industry_pools p "
|
||||
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.members, '[]'::jsonb)) m "
|
||||
"CROSS JOIN LATERAL jsonb_object_keys(m) k GROUP BY k ORDER BY n DESC")
|
||||
print("\n成员元素键频次(看成员级带不带环节/层级/概念字段):")
|
||||
print(keys.to_string(index=False))
|
||||
print(" 注(评审 §3):实测成员级只有 ts_code/name/tier/confidence/supporting —— "
|
||||
"**没有 segment 也没有 layer**,赛道门槛 C 的 kg_segments 映射在四视图上无源,"
|
||||
"需第五/六个插槽视图或 claims 救急口径。")
|
||||
|
||||
smp = db.read_pg(
|
||||
"SELECT m::text AS s FROM industry_pools p "
|
||||
"CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.members, '[]'::jsonb)) m "
|
||||
"LIMIT 3")
|
||||
print("\n成员元素原样采样:")
|
||||
for s in smp["s"]:
|
||||
print(f" {s}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- price
|
||||
def probe_price():
|
||||
_sec("price · gp_day_data 按年 distinct 天数(§6.2 起始日复核)")
|
||||
print("(全表聚合,视服务器规格可能要 1~3 分钟……)")
|
||||
df = db.read_mysql("price",
|
||||
"SELECT YEAR(`timestamp`) AS y, COUNT(DISTINCT `timestamp`) AS days, "
|
||||
"COUNT(*) AS n_rows FROM gp_day_data GROUP BY YEAR(`timestamp`) ORDER BY y")
|
||||
print(df.to_string(index=False))
|
||||
full = df[df["days"] >= 230]
|
||||
if full.empty:
|
||||
print("\n⚠️ 没有任何年份 ≥230 天——行情比预想稀疏,§7 upside 回填深度需重议。")
|
||||
else:
|
||||
print(f"\n首个 ≥230 天(≈全年连续)的年份:{int(full['y'].iloc[0])} "
|
||||
f"—— §7 upside 理论回填上限以此为准;之前年份视为零星记录。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- upside
|
||||
def probe_upside():
|
||||
_sec("upside · 覆盖池截面分布与 q 档位表(§9-3 / §9-4)")
|
||||
d = db.read_mysql("price", "SELECT MAX(`timestamp`) AS d FROM gp_day_data").iloc[0, 0]
|
||||
if d is None or pd.isna(d):
|
||||
print("❌ gp_day_data 为空——先跑 views 看连通性。")
|
||||
return
|
||||
ds = pd.Timestamp(d).date().isoformat()
|
||||
print(f"截面日 = 最新行情日 {ds}(as-of 口径与 build_upside 完全一致)")
|
||||
df = factors.build_upside(ds, ds)
|
||||
n_uni = len(common.load_universe())
|
||||
if df is None or df.empty:
|
||||
print("❌ 当日无可算 upside(consensus/行情缺)——先跑 views 看数据前沿。")
|
||||
return
|
||||
v = pd.to_numeric(df["factor_value"], errors="coerce").dropna()
|
||||
print(f"覆盖池 {n_uni} 只:当日有 upside {len(v)} 只,"
|
||||
f"无券商覆盖 {n_uni - len(v)} 只(按 §3.3 全被门槛②挡掉——§9-4 张力的量级)")
|
||||
print("\n分位数:")
|
||||
for q in (0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95):
|
||||
print(f" P{int(q * 100):02d}: {float(v.quantile(q)):+.4f}")
|
||||
print(f" mean : {v.mean():+.4f}(均值显著为正 = 券商乐观偏差的直读,§3.3)")
|
||||
print(f" <0 占比: {(v < 0).mean():.1%}(θ_v=0 单靠绝对下限能挡掉的比例)")
|
||||
print("\nq 档位表(θ_v = max(0, 池内 q 分位) → 门槛②通过只数):")
|
||||
for q in (0.00, 0.10, 0.20, 0.25, 0.30, 0.40, 0.50):
|
||||
theta = max(0.0, float(v.quantile(q)))
|
||||
print(f" q={q:.2f} → θ_v={theta:+.4f} → 通过 {int((v >= theta).sum())} 只")
|
||||
print("\n注:本表基于覆盖池 U 全体;G2 赛道成员表落地后按 U∩C 重切一遍再定稿 q。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- corr
|
||||
def _robust_z(v: pd.Series) -> pd.Series:
|
||||
"""稳健标准化(中位数 / MAD),对齐平台 MathKernel.winsorize_mad 的处置口径。"""
|
||||
med = v.median()
|
||||
mad = (v - med).abs().median()
|
||||
if not mad or np.isnan(mad) or mad <= 0:
|
||||
return pd.Series(0.0, index=v.index)
|
||||
return (v - med) / (1.4826 * mad)
|
||||
|
||||
|
||||
def _latest_heat_day(on_or_before: str) -> str | None:
|
||||
"""热度是 T+1 到达的,故 akg_score(T) 实际用 T−1 热度(设计 §3.4 末)。
|
||||
这里如实复现该口径:取 <= 给定日的最新热度日。"""
|
||||
df = db.read_mysql(
|
||||
"heat", "SELECT MAX(trade_date) AS d FROM stock_fund_heat_scores "
|
||||
"WHERE trade_date <= %s", (on_or_before,))
|
||||
d = df.iloc[0, 0]
|
||||
return None if d is None or pd.isna(d) else pd.Timestamp(d).date().isoformat()
|
||||
|
||||
|
||||
def probe_corr():
|
||||
"""池内三项相关矩阵 + 权重体检。
|
||||
|
||||
回答两个问题(评审 §2):
|
||||
① corr(z_H, z_V) 有多高?若 > 0.5,「三项加权」实际是两项,§9-8 要重开;
|
||||
② 0.5/0.3/0.2 到底是「加权混合」还是「传导优先」?——直接在真实截面上数
|
||||
top-K 里有几只是传导票。这比任何模拟都有说服力。
|
||||
"""
|
||||
_sec("corr · 池内 (z_T, z_H, z_V) 相关矩阵与权重体检(评审 §2)")
|
||||
d = db.read_pg("SELECT MAX(scan_date) AS d FROM v_factor_transmission").iloc[0, 0]
|
||||
if d is None or pd.isna(d):
|
||||
print("❌ 无传导台账——先确认 transmission_scan 已跑。")
|
||||
return
|
||||
ds = pd.Timestamp(d).date().isoformat()
|
||||
hd = _latest_heat_day(ds)
|
||||
print(f"截面日 = 最新传导日 {ds};热度取 {hd}(T+1 到达 → 实际用 T−1,设计 §3.4 末)")
|
||||
if hd is None:
|
||||
print("❌ 无可用热度日。")
|
||||
return
|
||||
|
||||
up = factors.build_upside(ds, ds)
|
||||
ht = factors.build_heat(hd, hd)
|
||||
tr = factors.build_transmission(ds, ds)
|
||||
if up is None or up.empty:
|
||||
print("❌ 当日无 upside,无法构造候选池。")
|
||||
return
|
||||
|
||||
def _s(df, name):
|
||||
if df is None or df.empty:
|
||||
return pd.Series(dtype=float, name=name)
|
||||
x = df.copy()
|
||||
x["k"] = x["stock_code"].map(common.to_prefix)
|
||||
return (x.groupby("k")["factor_value"].max()
|
||||
.astype(float).rename(name))
|
||||
|
||||
panel = pd.concat([_s(up, "upside"), _s(ht, "heat"), _s(tr, "transmission")],
|
||||
axis=1)
|
||||
n_uni = len(common.load_universe())
|
||||
print(f"\n覆盖池 U = {n_uni} 只;当日面板 {len(panel)} 只"
|
||||
f"(upside {panel['upside'].notna().sum()} / "
|
||||
f"heat {panel['heat'].notna().sum()} / "
|
||||
f"transmission {panel['transmission'].notna().sum()})")
|
||||
|
||||
# ---- 候选池 P:S1 只有门槛② 可算(门槛① 赛道 C 待 G2 的成员表)----
|
||||
P = panel[panel["upside"].notna() & (panel["upside"] >= 0.0)].copy()
|
||||
print(f"候选池 P(仅门槛② upside>=0,赛道门槛 C 待 G2)= {len(P)} 只")
|
||||
if len(P) < 10:
|
||||
print("⚠️ 候选池过小,以下统计量意义有限。")
|
||||
if P.empty:
|
||||
return
|
||||
|
||||
# ---- 缺失处置(对齐设计 §3.4)----
|
||||
P["transmission"] = P["transmission"].fillna(0.0) # 传导缺 = 0(取基准值)
|
||||
P["heat"] = P["heat"].fillna(P["heat"].median()) # 热度缺 = 池内中位数
|
||||
|
||||
n_tr = int((P["transmission"] > 0).sum())
|
||||
print(f"P 内有传导的股票 = {n_tr} 只({n_tr / len(P):.1%})"
|
||||
f" ← 这个数才是真正决定榜首的量级(评审 §7)")
|
||||
|
||||
# ---- 三项标准化(传导不能用 MAD:池内多数为 0 → MAD=0 除零,见 §3.4)----
|
||||
lt = np.log1p(P["transmission"])
|
||||
sd = lt.std()
|
||||
P["z_T"] = (lt - lt.mean()) / sd if sd and sd > 0 else 0.0
|
||||
P["z_H"] = -_robust_z(P["heat"])
|
||||
P["z_V"] = _robust_z(P["upside"])
|
||||
|
||||
print("\n① 相关矩阵(Spearman,池内):")
|
||||
print(P[["z_T", "z_H", "z_V"]].corr(method="spearman").round(3).to_string())
|
||||
c_hv = float(P["z_H"].corr(P["z_V"], method="spearman"))
|
||||
if abs(c_hv) > 0.5:
|
||||
print(f"\n ⚠️ corr(z_H, z_V) = {c_hv:+.3f} —— 「还没热」与「便宜」高度共线,"
|
||||
f"\n 「三项加权」实际是两项,§9-8 的权重讨论应重开"
|
||||
f"(且门槛② 与 z_V 本就是同一变量进两次)。")
|
||||
else:
|
||||
print(f"\n corr(z_H, z_V) = {c_hv:+.3f} —— 共线性可接受,三项各自有独立信息。")
|
||||
|
||||
# ---- ② 权重体检:0.5/0.3/0.2 是混合还是词典序?----
|
||||
print("\n② 权重体检:不同 w_T 下 top-K 里有几只是传导票")
|
||||
print(f" (P 内共 {n_tr} 只传导票 / {len(P)} 只候选)")
|
||||
if n_tr == 0:
|
||||
print(" 当日无传导票,本节跳过。")
|
||||
else:
|
||||
hdr = " w_T " + "".join(f" top{k:<3}" for k in (10, 20, 30, 50))
|
||||
print(hdr)
|
||||
for wT in (0.50, 0.30, 0.20, 0.15, 0.10):
|
||||
wrest = 1.0 - wT
|
||||
s = wT * P["z_T"] + wrest * (0.6 * P["z_H"] + 0.4 * P["z_V"])
|
||||
line = f" {wT:.2f} "
|
||||
for k in (10, 20, 30, 50):
|
||||
top = s.nlargest(min(k, len(P))).index
|
||||
hit = int((P.loc[top, "transmission"] > 0).sum())
|
||||
line += f" {hit:>2}/{min(k, n_tr):<3}"
|
||||
print(line + (" ← 当前设计" if abs(wT - 0.50) < 1e-9 else ""))
|
||||
print("\n 读法:若 w_T=0.50 那行的命中数≈min(K, 传导票数),说明传导票"
|
||||
"\n 几乎必然占满前列 —— 0.3/0.2 只在组内排序、跨组不起作用,"
|
||||
"\n 即当前参数事实上是「传导优先」而非「加权混合」(评审 §2)。"
|
||||
"\n 若确实如此,建议改结构(两段式)而不是改数值。")
|
||||
|
||||
# 两段式对照:primary=传导档位, tiebreak=0.6z_H+0.4z_V
|
||||
tier = pd.Series(0.0, index=P.index)
|
||||
hit_m = P["transmission"] > 0
|
||||
if hit_m.any():
|
||||
med = lt[hit_m].median()
|
||||
tier[hit_m] = np.where(lt[hit_m] >= med, 2.0, 1.0)
|
||||
s2 = tier * 10.0 + (0.6 * P["z_H"] + 0.4 * P["z_V"])
|
||||
agree = len(set(s2.nlargest(min(20, len(P))).index)
|
||||
& set((0.5 * P["z_T"] + 0.3 * P["z_H"] + 0.2 * P["z_V"])
|
||||
.nlargest(min(20, len(P))).index))
|
||||
print(f"\n 两段式(档位+组内)与当前公式的 top20 重合度:{agree}/20"
|
||||
f" —— 越接近 20 越说明两者本就等价,改结构零代价。")
|
||||
|
||||
print("\n③ 门槛的量级(供 §9-3 定 q / 判断 C 的必要性):")
|
||||
print(f" U={n_uni} → 有 upside {int(panel['upside'].notna().sum())} "
|
||||
f"→ 过门槛② {len(P)} → 其中有传导 {n_tr}")
|
||||
print(" 若最后一个数长期是 0~3,组合层面就是 1~3 只票,且来自被 12×12 截断的"
|
||||
"\n 候选列表——集中度需要在文档里明写(评审 §7)。")
|
||||
|
||||
|
||||
SECTIONS = {"pools": probe_pools, "price": probe_price,
|
||||
"upside": probe_upside, "corr": probe_corr}
|
||||
|
||||
|
||||
def run(section="all"):
|
||||
picked = list(SECTIONS) if section == "all" else [section]
|
||||
print("G1 体检(只读)· 将跑节:" + ", ".join(picked))
|
||||
for name in picked:
|
||||
try:
|
||||
SECTIONS[name]()
|
||||
except Exception as e: # noqa: BLE001 —— 单节失败不拖累其余(体检尽量多产出)
|
||||
print(f"\n❌ [{name}] 失败: {e!r}")
|
||||
58
run.py
58
run.py
|
|
@ -1,12 +1,15 @@
|
|||
"""akg-factor-bridge CLI。
|
||||
|
||||
python run.py views # 连通性自检:打印视图/表行数
|
||||
python run.py probe # G1 体检(只读,详见 probe.py)
|
||||
python run.py freeze [--date D] # 输入冻结(G0.5,详见 freeze.py)
|
||||
python run.py register # 注册四子因子到 factor_metadata
|
||||
python run.py build all --mode history --start 2024-01-01 --end 2025-12-31
|
||||
python run.py build akg_heat --mode daily --date 2026-07-24
|
||||
python run.py build akg_event --mode history --start 2025-01-01 --end 2026-07-24
|
||||
|
||||
daily 模式:不给 --date 则取今天;start=end=date。history 模式:需 --start/--end。
|
||||
daily 模式:不给 --date 则取今天;start=end=date,跑完自动冻结(--no-freeze 可关)。
|
||||
history 模式:需 --start/--end,默认不逐日冻结。
|
||||
所有写入幂等(删涉及日期区间再插),可安全重跑。
|
||||
"""
|
||||
import argparse
|
||||
|
|
@ -17,6 +20,7 @@ import warnings
|
|||
warnings.filterwarnings("ignore", message=".*only supports SQLAlchemy.*")
|
||||
|
||||
import common
|
||||
import config
|
||||
import db
|
||||
import factors
|
||||
|
||||
|
|
@ -39,6 +43,23 @@ def cmd_views():
|
|||
except Exception as e: # noqa: BLE001
|
||||
print(f" ❌ {name}: {e!r}")
|
||||
|
||||
# 视图版本自检:v2 才有的列在不在(评审第一批是否已应用)
|
||||
print("\n视图版本(2026-07-26 评审 v2):")
|
||||
for label, sql in (
|
||||
("v_factor_transmission.n_sources",
|
||||
"SELECT n_sources FROM v_factor_transmission LIMIT 1"),
|
||||
("v_factor_transmission.mkt_trade_date",
|
||||
"SELECT mkt_trade_date FROM v_factor_transmission LIMIT 1"),
|
||||
("v_factor_events.source_type",
|
||||
"SELECT source_type FROM v_factor_events LIMIT 1"),
|
||||
("v_factor_segment_members(第五插槽,G2)",
|
||||
"SELECT 1 FROM v_factor_segment_members LIMIT 1")):
|
||||
try:
|
||||
db.read_pg(sql)
|
||||
print(f" ✅ {label}")
|
||||
except Exception: # noqa: BLE001
|
||||
print(f" ⬜ {label} —— 未就绪")
|
||||
|
||||
fronts = [
|
||||
("热度 heat", "heat", "trade_date", "stock_fund_heat_scores"),
|
||||
("一致预期 consensus", "pg", "asof_date", "v_factor_consensus"),
|
||||
|
|
@ -56,12 +77,16 @@ def cmd_views():
|
|||
except Exception as e: # noqa: BLE001
|
||||
print(f" {name}: ❌ {e!r}")
|
||||
|
||||
print(f"\n当前口径:SUBFACTOR_UNIVERSE={config.SUBFACTOR_UNIVERSE} "
|
||||
f"| EVENT_SOURCE_TYPES={sorted(config.EVENT_SOURCE_TYPES)} "
|
||||
f"| EVENT_MAX_PER_DOC={config.EVENT_MAX_PER_DOC}")
|
||||
|
||||
|
||||
_META = {
|
||||
"akg_upside": ("astock-kg 预期空间", "分析师一致预期目标价隐含收益率(target_mid/price-1)"),
|
||||
"akg_heat": ("astock-kg 热度", "生态日频资金热度分(0~1)"),
|
||||
"akg_event": ("astock-kg 事件", "利好利空事件时间衰减加权分"),
|
||||
"akg_transmission": ("astock-kg 传导", "板块传导未动成员传导强度(路径数×(1-已动比例))"),
|
||||
"akg_event": ("astock-kg 事件", "利好利空事件时间衰减加权分(仅公告来源,单文档封顶)"),
|
||||
"akg_transmission": ("astock-kg 传导", "板块传导未动成员传导强度(distinct源数×(1-已动比例))"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -71,22 +96,32 @@ def cmd_register():
|
|||
common.register(code, name, factors.FACTORS[code], ["astock-kg", code.split("_", 1)[1]], desc)
|
||||
|
||||
|
||||
def cmd_build(which, mode, start, end, date):
|
||||
def cmd_build(which, mode, start, end, date, do_freeze=True):
|
||||
if mode == "daily":
|
||||
d = date or dt.date.today().isoformat()
|
||||
start = end = d
|
||||
if not start or not end:
|
||||
raise SystemExit("history 模式需要 --start 与 --end")
|
||||
codes = list(factors.FACTORS) if which == "all" else [which]
|
||||
frames = {}
|
||||
for code in codes:
|
||||
if code not in factors.BUILDERS:
|
||||
raise SystemExit(f"未知因子: {code}(可选: {list(factors.FACTORS)} 或 all)")
|
||||
print(f"[{code}] {mode} {start} ~ {end}")
|
||||
try:
|
||||
df = factors.BUILDERS[code](start, end)
|
||||
frames[f"factor_{code}"] = df
|
||||
common.write_factor(factors.FACTORS[code], df, mode)
|
||||
except Exception as e: # noqa: BLE001 —— 一路失败不拖累其余(批量容错)
|
||||
print(f" ❌ {code} 失败: {e!r}")
|
||||
# 日更顺手冻结:输入与输出落在同一目录,任何一行因子值都能被逐步复算。
|
||||
# history 模式默认不冻结(逐日冻结应单独跑,避免一次回填写出几百个目录)。
|
||||
if do_freeze and mode == "daily":
|
||||
try:
|
||||
import freeze
|
||||
freeze.snapshot(start, extra_frames=frames)
|
||||
except Exception as e: # noqa: BLE001 —— 冻结失败不该让因子构建算失败
|
||||
print(f" ❌ 冻结失败(因子已落库): {e!r}")
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -94,20 +129,33 @@ def main():
|
|||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("views")
|
||||
sub.add_parser("register")
|
||||
p = sub.add_parser("probe")
|
||||
p.add_argument("--section", choices=["all", "pools", "price", "upside", "corr"],
|
||||
default="all",
|
||||
help="pools=池结构清单 price=行情年表 upside=分布与q档位 corr=三项相关矩阵")
|
||||
f = sub.add_parser("freeze")
|
||||
f.add_argument("--date", help="默认今天")
|
||||
b = sub.add_parser("build")
|
||||
b.add_argument("factor", help="akg_upside|akg_heat|akg_event|akg_transmission|all")
|
||||
b.add_argument("--mode", choices=["daily", "history"], default="daily")
|
||||
b.add_argument("--start")
|
||||
b.add_argument("--end")
|
||||
b.add_argument("--date")
|
||||
b.add_argument("--no-freeze", action="store_true", help="daily 模式下跳过输入冻结")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.cmd == "views":
|
||||
cmd_views()
|
||||
elif a.cmd == "probe":
|
||||
import probe # 按需加载:一次性诊断命令,不影响常规链路
|
||||
probe.run(a.section)
|
||||
elif a.cmd == "freeze":
|
||||
import freeze
|
||||
freeze.snapshot(a.date)
|
||||
elif a.cmd == "register":
|
||||
cmd_register()
|
||||
elif a.cmd == "build":
|
||||
cmd_build(a.factor, a.mode, a.start, a.end, a.date)
|
||||
cmd_build(a.factor, a.mode, a.start, a.end, a.date, do_freeze=not a.no_freeze)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
-- ============================================================================
|
||||
-- astock-kg 因子插槽接口:只读视图(供 akg-factor-bridge 消费)
|
||||
-- astock-kg 因子插槽接口:只读视图 v2(供 akg-factor-bridge 消费)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 应用到 astock-kg 的 PostgreSQL(akg 库): psql "$AKG_PG_DSN" -f astock_kg_slot_views.sql
|
||||
-- 只读投影、零新增计算;基座内部表可自由重构,只要这四个视图的列不变,桥不受影响。
|
||||
-- 幂等:CREATE OR REPLACE。列/JSON 键均已从 astock-kg 代码核准(见每条注释出处)。
|
||||
-- 相对 v1 的变更(2026-07-26 评审):
|
||||
-- ① v_factor_transmission:n_paths → n_sources(distinct 源数),修「变长边每种
|
||||
-- 长度各返回一条 + 三桶间不去重」导致的路径重复计数;n_paths_raw 保留作观察。
|
||||
-- ② v_factor_transmission:暴露 target / target_type / members_total / moved /
|
||||
-- mkt_trade_date,让桥能自检上游截断与快照新鲜度。
|
||||
-- ③ v_factor_events:补 doc_id / source_type / tier(供年报污染防护)。
|
||||
-- ④ v_factor_universe / v_factor_consensus 逻辑未变,只补注释。
|
||||
--
|
||||
-- 应用:docker exec -i akg-postgres psql -U akg -d akg < sql/astock_kg_slot_views.sql
|
||||
-- 幂等:全部 CREATE OR REPLACE。
|
||||
--
|
||||
-- ★ 数据安全性:本文件**只创建视图 + 加列 + 加索引**,不写、不改、不删任何一行数据。
|
||||
-- claims / documents 一个字节都不动,之前抽取的成果全部保留,无需 replay、无需重抽。
|
||||
-- 唯一的 DDL 变更是 transmission_candidates 加一个可空列(见文件末尾 ALTER 段),
|
||||
-- 已存行取值 NULL,桥侧按 NULL 容忍(见 factors.build_transmission)。
|
||||
-- ============================================================================
|
||||
|
||||
-- 0) 覆盖池 universe = 全部 industry_pools 成员 ts_code 并集
|
||||
-- (对齐 market_snapshot._pool_ts_codes;members 为 JSONB 数组,元素含 ts_code/name)
|
||||
-- ⚠️ 注意(评审 §4):industry_pools 只存最新态、每周一 refresh_pools 自动长大。
|
||||
-- 本视图是「当前态」,用它过滤历史子因子会引入成员性前视。
|
||||
-- 子因子表建议不过此过滤(桥侧 SUBFACTOR_UNIVERSE=market),只在 akg_score 侧用。
|
||||
CREATE OR REPLACE VIEW v_factor_universe AS
|
||||
SELECT DISTINCT m->>'ts_code' AS ts_code
|
||||
FROM industry_pools p
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.members, '[]'::jsonb)) AS m
|
||||
WHERE m->>'ts_code' IS NOT NULL AND m->>'ts_code' <> '';
|
||||
|
||||
|
||||
-- 1) 一致预期(供 upside):consensus_daily 全 asof 行投影,桥按 trade_date 做 as-of。
|
||||
-- 列源:market_snapshot._CONSENSUS_DDL。
|
||||
-- n_orgs / n_reports_90d 一并给出——可作 upside 的可信度权重(当前桥未用)。
|
||||
CREATE OR REPLACE VIEW v_factor_consensus AS
|
||||
SELECT ts_code,
|
||||
asof_date,
|
||||
|
|
@ -27,13 +42,14 @@ SELECT ts_code,
|
|||
FROM consensus_daily
|
||||
WHERE target_mid_avg IS NOT NULL;
|
||||
|
||||
|
||||
-- 2) 利好利空事件(供 event):EVENT 断言投影。
|
||||
-- ts_code 取【文档锚】documents.meta->>'company_ts_code'(公告单公司、100% 可靠;
|
||||
-- claim_store 多处以此为公司锚),不用 claims.subject_id(抽取原始名/码、未解析)。
|
||||
-- event_type / direction / scope 从 qualifiers 取,键名核自 pipeline._event_to_claim:
|
||||
-- qualifiers.event_type(EVENT_TYPES 或 other)
|
||||
-- qualifiers.direction = 预增/预减(业绩预告必填;其余多为空)
|
||||
-- qualifiers.scope = 累计/单次(回购、增减持)
|
||||
-- 评审 §6.5:补 doc_id + source_type。年报也带 company_ts_code 锚,而一份年报能抽
|
||||
-- 十几条 EVENT(且含**历史**诉讼/处罚),桥侧必须按来源过滤或封顶,
|
||||
-- 否则 S2 的负面否决闸会因一份年报否决掉一只好股。
|
||||
-- ✅ 列名已核实(db/postgres/init.sql:12):documents.source_type,取值
|
||||
-- annual_report / research_report / announcement / news / interactive_qa
|
||||
-- 另补 tier(fact/opinion/market)—— 桥侧若想只信事实层事件,这里就够用。
|
||||
CREATE OR REPLACE VIEW v_factor_events AS
|
||||
SELECT d.meta->>'company_ts_code' AS ts_code,
|
||||
c.disclosure_date,
|
||||
|
|
@ -41,20 +57,52 @@ SELECT d.meta->>'company_ts_code' AS ts_code,
|
|||
c.qualifiers->>'direction' AS direction,
|
||||
c.qualifiers->>'scope' AS scope,
|
||||
c.confidence,
|
||||
c.dedup_key
|
||||
c.tier,
|
||||
c.dedup_key,
|
||||
c.doc_id, -- 供「单文档封顶」用
|
||||
d.source_type -- 供「只取公告」用
|
||||
FROM claims c
|
||||
JOIN documents d ON d.doc_id = c.doc_id
|
||||
WHERE c.predicate = 'EVENT'
|
||||
AND d.meta->>'company_ts_code' IS NOT NULL;
|
||||
|
||||
-- 3) 板块传导(供 transmission):transmission_candidates 的未动成员(quiet)摊平成每股一行。
|
||||
-- 一只股当日可能出现在多个候选(属多个目标环节)→ 多行,桥侧聚合(取最大 n_paths)。
|
||||
-- 列源:transmission._DDL(paths/quiet 均 JSONB,moved_ratio NUMERIC)。
|
||||
|
||||
-- 3) 板块传导(供 transmission):transmission_candidates 的未动成员(quiet)摊平。
|
||||
-- ★ 核心修复:n_sources = 指向该环节的 distinct 源数。
|
||||
-- 原 n_paths = jsonb_array_length(paths) 是「被提及次数」——
|
||||
-- cascade() 的变长边 *1..N 会把 A→B 与 A→X→B 各返回一条,
|
||||
-- transmission_targets() 的 updown/supply/drives 三桶之间也不去重,
|
||||
-- 于是同一 source 对同一 target 重复计入,而 n_paths 是 factor_value 的主量级。
|
||||
-- 改成 distinct source 数,同时也更贴合传导模块自述的语义「多源汇聚=逻辑更硬」。
|
||||
CREATE OR REPLACE VIEW v_factor_transmission AS
|
||||
SELECT t.scan_date,
|
||||
t.rank,
|
||||
t.target,
|
||||
t.target_type,
|
||||
q->>'ts_code' AS ts_code,
|
||||
jsonb_array_length(COALESCE(t.paths, '[]'::jsonb)) AS n_paths, -- 指向该环节的传导路径数
|
||||
t.moved_ratio -- 该环节已动成员比例
|
||||
(SELECT count(DISTINCT p->>'source')
|
||||
FROM jsonb_array_elements(COALESCE(t.paths, '[]'::jsonb)) p) AS n_sources,
|
||||
jsonb_array_length(COALESCE(t.paths, '[]'::jsonb)) AS n_paths_raw,
|
||||
t.moved_ratio,
|
||||
t.members_total, -- 桥用:== 上游 topic_context cap 时 moved_ratio 不可信
|
||||
t.moved,
|
||||
jsonb_array_length(COALESCE(t.quiet, '[]'::jsonb)) AS n_quiet_stored,
|
||||
t.mkt_trade_date -- 桥用:≠ scan_date 说明用了陈旧 movers(可能为 NULL)
|
||||
FROM transmission_candidates t
|
||||
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(t.quiet, '[]'::jsonb)) AS q
|
||||
WHERE q->>'ts_code' IS NOT NULL AND q->>'ts_code' <> '';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 前置 ALTER(幂等,可反复跑)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- mkt_trade_date:记录本次扫描实际用到的 mkt_daily stock 快照日。
|
||||
-- 动因(评审 §6.2):hotspot._latest_mkt 用 `max(trade_date) FROM mkt_daily`,
|
||||
-- 不是 scan_date 的快照。17:30 sync_market 晚点时,18:15 的传导扫描会用 T−1 的
|
||||
-- movers 写成 scan_date=T,桥完全无从察觉。落这一列后,桥可以自检并拒收。
|
||||
-- ============================================================================
|
||||
ALTER TABLE transmission_candidates
|
||||
ADD COLUMN IF NOT EXISTS mkt_trade_date DATE;
|
||||
|
||||
-- 便于按环节回查与审计
|
||||
CREATE INDEX IF NOT EXISTS idx_tc_target ON transmission_candidates (target, scan_date);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,607 @@
|
|||
# 量化因子导出与合成设计(v2.0,2026-07-26)
|
||||
|
||||
> **一句话**:把 astock-kg 知识图谱的产业理解,做成一个**自洽的、有经济含义的**
|
||||
> 景气度选股因子 `akg_score`,托管在通用因子管理平台 `quant_factor_service`
|
||||
> 里(本文只用其**注册 / 存储 / 调度 / 评价**能力,**不用其合成能力**,理由见 §2);
|
||||
> 因子的合法性来自**产业逻辑**,不来自历史 IC 拟合。
|
||||
>
|
||||
> **本版是对 v1.1 的方向性修订,不是增补。** v1.1 的核心方案(四路子因子对称注册
|
||||
> → 平台 ElasticNet/贝叶斯/GRU 拟合出复合因子)已被推翻,理由见 §2。v1.1 中的
|
||||
> **数据契约、视图定义、子因子构造口径、平台接口细节**仍然有效,本版继承并更新(§5、§6)。
|
||||
>
|
||||
> **版本号约定(避免歧义)**:`v1.1 / v2.0` 指**本设计文档**的版本;因子自身的演进阶段
|
||||
> 一律用 **S1 / S2 / S3** 表示(S1 = 首个上线版本)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 版本变更与决策基线
|
||||
|
||||
### 0.1 v1.1 → v2.0 的三处推翻
|
||||
|
||||
| 项 | v1.1 | v2.0 | 触发原因 |
|
||||
|---|---|---|---|
|
||||
| 复合方式 | 平台**拟合合成**(ElasticNet/贝叶斯基线,GRU 三头为目标) | **桥内自洽公式**(漏斗),平台不参与合成 | 拟合要求全部输入可回填、且历史上与收益共变;传导两者都不满足 |
|
||||
| 四路地位 | 四路**对称**,权重由拟合决定 | **两道硬门槛 + 三项加权**,权重由投资逻辑指定 | 「c 应该是硬门槛……传导第一、还没热第二、便宜第三」 |
|
||||
| 回测角色 | **生死闸**(神经 IC-IR 超线性基线才判收) | **仪表盘**(持续观测,不做 go/no-go) | 「核心是这个值有其合理性和经济含义在,那么大方向肯定不会是错的」 |
|
||||
|
||||
### 0.2 v2.0 决策基线
|
||||
|
||||
| 议题 | 决定 | 理由摘要 |
|
||||
|---|---|---|
|
||||
| 承载形态 | **独立 `akg-factor-bridge` 工程**(Docker,可单独部署) | 基座只暴露只读视图,桥消费视图+热度、写平台;三方镜像都保持纯净 |
|
||||
| Universe | **astock-kg 覆盖池**(`industry_pools` 全主题成员并集),实测 **1675 只** | 池内信号厚;实测规模足够做截面统计 |
|
||||
| 复合因子 | **`akg_score` = 景气度漏斗**,桥内计算,注册为 `single`(平台视角它就是一个普通因子) | 自洽公式的产物不是"平台合成结果",不占 `multiple` 语义 |
|
||||
| 赛道范围 C | **十五五规划前沿赛道产业链**(核聚变、商业航空航天、通信、算力、人工智能、低空经济等;材料 → 设备/零部件 → 制造/系统集成 → 应用/服务,全链) | **划定范围,不做计算**——范围本身就是确定性来源 |
|
||||
| 传导 | **只 live 使用,不回填、不进拟合** | 传导是推断不是观察,回填必然引入前视(§2.2) |
|
||||
| 评价 | 复用平台 `ic_calculator` + 分层回测,**当仪表盘看** | 复用平台的目的就在评价与调度,但不让它当判官 |
|
||||
| 开发规范 | **全流程 Docker**(构建/运行/连库/psql 一律走容器) | 团队铁律 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 投资论题:为什么是"景气度漏斗"
|
||||
|
||||
这一节是全文的地基。因子的每一项都能追回到这里的某一句话;若论题变了,因子该重做。
|
||||
|
||||
**主线判断。** A 股乃至全球股市未来三五年的主线是科技。这条主线会在 AI 渗透率接近
|
||||
2015 年移动互联网渗透率、AI 变成某种公用服务或基础设施之后终结——那时它才会像新能源
|
||||
或制造业那样"可研究"(业绩稳定、能做确定性高的预测)。在此之前,它不可研究。
|
||||
|
||||
**不可研究意味着什么。** 具体到方法论,处在这个阶段的赛道有三个特征:
|
||||
|
||||
1. **大方向向上**——产业趋势本身没有分歧,政策与产业资本都在推;
|
||||
2. **无法准确估值**——收入曲线还没定型,DCF/PE 给不出可信中枢;
|
||||
3. **没有另类领先数据**——不像制造业有订单、排产、开工率可以先行跟踪。
|
||||
|
||||
三条同时成立时,自下而上的基本面精算是无效的(算不准),量价动量是滞后的(等确认
|
||||
就已经在右侧了)。**剩下唯一可行的办法是景气度投资**:不追求算准,只追求
|
||||
**方向对 + 位置早 + 价格不离谱**。
|
||||
|
||||
**落到选股上,就是找两类特征的交集:**
|
||||
|
||||
- **确定性高**——确定性不来自盈利预测,来自**产业逻辑**:它在不在国家级主线上、
|
||||
在产业链上占什么位置、这个位置有没有壁垒。
|
||||
- **相对较便宜**——不是绝对估值便宜,是**相对于预测的空间还在**。因为系统赌的是
|
||||
大方向正确,必须给自己留容错度;贵了就没有容错度了。
|
||||
|
||||
**热度在这里的真实作用。** 单一股票的热度本身没有太大意义。它的价值在于
|
||||
**辅助验证产业传导逻辑是否已经启动**——用来缩短左侧建仓的时间和风险。所以热度
|
||||
不是一个独立的 alpha 来源,它是传导逻辑的**确认器**与**位置指示器**:
|
||||
板块层面已启动(传导给出),个股层面还没热(热度给出)= 左侧位置还在。
|
||||
|
||||
> 注:S1 的实现把"还没热"落成了一个**独立加性项**而非条件项,这是一处有意的简化,
|
||||
> 代价与备选方案见 §3.4 与 §9-9。
|
||||
|
||||
**研报滞后的再解释。** 硬科技赛道的特征是壁垒高、投入大、业绩分化、生态站位分工
|
||||
明确。券商研报覆盖这类标的天然滞后。在一个看预期的市场里,**滞后本身反而是确定性的
|
||||
体现**——研报开始密集覆盖时,产业逻辑已经被验证到了能写进报告的程度。所以我们不把
|
||||
"研报少"当成信息缺失来惩罚,而是把"研报所指的赛道"当成一个已经通过验证的范围来使用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 方法论转向:从"拟合合成"到"自洽公式"
|
||||
|
||||
### 2.1 原方案为何走不通
|
||||
|
||||
v1.1 的设想是把四路信号当成对称的特征喂给平台的合成引擎(ElasticNet 滚动 /
|
||||
BayesianRidge / GRU 三头),让模型学权重。三条独立的理由否决了它:
|
||||
|
||||
**(一)实证否决。** 单纯的 heat 与其他因子的合成结果此前并不理想。这不是调参问题,
|
||||
是"把热度当 alpha 源"这个前提本身站不住——热度既可能是动量也可能是拥挤反转,符号不
|
||||
稳定,模型学到的权重会随窗口反复翻转。
|
||||
|
||||
**(二)数据本身有系统性偏差。** 上升空间本质上是**券商报喜不报忧**的预测。它作为
|
||||
一个"还有多少空间"的相对标尺是可用的(这也是为什么它留在漏斗里当门槛),但作为一个
|
||||
待拟合的自变量,它携带的偏差是结构性的、不随样本增加而消失的。
|
||||
|
||||
**(三)传导根本进不了拟合模型。** 这是决定性的,展开在 §2.2。
|
||||
|
||||
### 2.2 观察 vs 推断:一个必须区分的类型学
|
||||
|
||||
四路信号看起来同构(都是"每股每日一个数"),但它们的**认识论地位**不同:
|
||||
|
||||
| 维度 | **观察(observation)** | **推断(inference)** |
|
||||
|---|---|---|
|
||||
| 定义 | 世界上发生了某件事,被带时间戳地记录下来 | 系统在某一天,用当天的状态推出的一个判断 |
|
||||
| 本例 | 事件(`disclosure_date`)、热度(上游历史留存)、预期空间(研报 `asof_date`) | **板块传导**(`scan_date`) |
|
||||
| 历史 | 有耐久历史,重放可复现 | **无耐久历史**,过期即不可复原 |
|
||||
| 回填 | 可回填,点时正确 | **回填 = 前视** |
|
||||
|
||||
**传导为什么是推断。** 传导候选 = 当日异动集(movers)× 当时的产业链图谱。两个输入
|
||||
都没有耐久历史:历史每日的 hotspot 异动集没有留存;产业链图谱是**随研报持续生长的
|
||||
当前态**,`industry_pools` 只存最新快照、没有时点版本。
|
||||
|
||||
于是若要重建 2024 年某日的传导,只能用**今天的图谱**去套 2024 年的行情——而今天的
|
||||
图谱里,有大量关系是 2025、2026 年的研报才写进去的。**这是教科书式的前视偏差**:
|
||||
回填出来的历史传导会"提前知道"后来才被发现的产业链关系,其历史 IC 必然虚高,而这个
|
||||
虚高恰恰无法在样本外复现。
|
||||
|
||||
**推论。** 任何需要"历史上该特征与收益共变"的方法——线性回归、贝叶斯、GRU,无一例
|
||||
外——都无法把传导纳入。不是因为传导没用,恰恰相反,是因为**传导是我们唯一真正原创
|
||||
的信号,而它天生不可回测**。
|
||||
|
||||
> **神经网络能否豁免?** 不能。常被误解的一点是"神经网络不要求因子为正、有信息即可"
|
||||
> ——这句话本身对(A 股利好往往是大跌的开始,符号确实无所谓,模型可以学到负向使用)。
|
||||
> 但它要求的是**该特征在样本内相对结果有过变化**。传导没有可信的样本内历史,
|
||||
> 模型面对的是一列几乎全空、仅最近三天有值的特征,只会把它的权重压到零。
|
||||
|
||||
### 2.3 转向:自洽公式
|
||||
|
||||
既然最有价值的信号无法进拟合,就不要为了迁就方法而丢掉信号——**换方法**。
|
||||
|
||||
因子化仍然是必须的,也必须通过现有因子管理平台托管;但**未必要通过平台的合成方案**。
|
||||
如果系统自己能用自洽的方案算出这个值,那么:
|
||||
|
||||
- **合法性来源改变**:从"历史 IC 显著"改成"**这个值有其合理性和经济含义**"。
|
||||
- **回测不再是前置条件**:甚至不需要马上回测。核心是这个值经济含义成立,
|
||||
**那么大方向肯定不会是错的**——这是我们追求的。
|
||||
- **回测降为仪表盘**:仍然做、仍然看、仍然复用平台的 `ic_calculator` 和分层回测,
|
||||
但它输出的是"当前市场是否在奖励这套逻辑"的读数,不是"这个因子该不该活"的判决。
|
||||
|
||||
这个转向还有一个附带好处:公式的每一项都能被人读懂、被人质疑、被人调整。拟合出来的
|
||||
权重做不到这一点,而在一个我们明确承认"无法准确估值"的领域里,**可解释性比精度更重要**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 复合因子 `akg_score`:景气度漏斗
|
||||
|
||||
### 3.1 结构
|
||||
|
||||
```
|
||||
KG 覆盖池 U(industry_pools 全主题并集,实测 1675 只)
|
||||
│
|
||||
┌─────────────────▼─────────────────┐
|
||||
│ 硬门槛 ① 赛道 C │ 十五五前沿赛道产业链成员
|
||||
│ 材料→设备/零部件→制造/系统集成→应用 │ —— 划定范围,不计算
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌─────────────────▼─────────────────┐
|
||||
│ 硬门槛 ② 估值 V │ upside ≥ θ_v(贵了不买)
|
||||
│ (留容错度) │ —— 一票否决
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
候选池 P_t
|
||||
│
|
||||
┌─────────────────▼─────────────────┐
|
||||
│ 池内加权排序(截面标准化后加权) │
|
||||
│ w_T · z(传导) ← 第一,最重 │
|
||||
│ w_H · z(−热度) ← 第二,还没热 │
|
||||
│ w_V · z(便宜) ← 第三,再加权 │
|
||||
└─────────────────┬─────────────────┘
|
||||
▼
|
||||
akg_score(写平台因子表)
|
||||
```
|
||||
|
||||
**为什么是漏斗而不是加权和。** 加权和允许"某一项极好补偿另一项极差"——在这套逻辑里
|
||||
这恰恰是灾难:一个不在赛道上的股票传导分再高也不该买(传导的经济含义依附于产业链
|
||||
的真实性),一个贵到没有容错度的股票赛道再正也不该买。**硬门槛表达的是"不可交易",
|
||||
权重表达的是"更值得先买"**,两者不能混为一谈。
|
||||
|
||||
### 3.2 硬门槛 ① 赛道 C(定义域,非计算量)
|
||||
|
||||
**定义**:C(i) = 股票 i 属于十五五规划前沿赛道产业链的任一环节。
|
||||
|
||||
赛道清单以十五五规划确定的前沿领域为准,已明确的包括:**核聚变、商业航空航天、通信、
|
||||
算力、人工智能、低空经济**等(完整清单待补,见 §9-2)。每个赛道按**上下游全链**取,
|
||||
`layer` 统一四层枚举:**材料 → 设备/零部件 → 制造/系统集成 → 应用/服务**。
|
||||
|
||||
**这是本设计最重要的一步:C 是一个被划定的范围,不是一个被计算的分数。**
|
||||
|
||||
理由(用户裁定):所谓硬科技赛道本身就是**壁垒高、投入大、业绩分化、生态站位分工
|
||||
明确**。这些属性一旦成立,赛道内标的的"确定性"就已经由产业结构提供了,不需要再用
|
||||
数据去打分——**打分反而会把结构性的确定性稀释成一个可被其他项补偿的连续量**。
|
||||
这也与 astock-kg 的"不打分"铁律一致:系统负责把范围划清楚,不负责给股票评级。
|
||||
|
||||
**实现路径(桥内新增模块 `tracks.py` + 配置 `config/frontier_tracks.yml`):**
|
||||
|
||||
```yaml
|
||||
# 示意结构,实际清单待 §9-2 确认
|
||||
tracks:
|
||||
- name: 商业航空航天 # 正式名唯一,别名进 aliases
|
||||
aliases: [商业航天, 航天科技]
|
||||
kg_themes: [...] # 映射到 industry_pools 主题名
|
||||
kg_segments: [...] # 映射到环节(材料/设备/制造/应用)
|
||||
kg_concepts: [...] # 映射到概念标签
|
||||
- name: 低空经济
|
||||
...
|
||||
```
|
||||
|
||||
**成员表落在哪里(归属)**:yml 是**唯一事实源**,随桥仓库版本控制;映射产物物化为
|
||||
`track_members`(列:`ts_code, track, layer, source_rule, updated_at`),**落在桥工程
|
||||
自身的 `data/` 目录下的版本化快照文件**(CSV/Parquet,可 `git diff`),**不落基座、
|
||||
不落平台**——这样既不破坏 §4 的三层边界,又满足"必须能被人逐条 review、能被 diff、
|
||||
能追溯某只股票凭什么进来"的审计要求。若日后下游需要 SQL 查询,再另议是否落库。
|
||||
|
||||
**关键前置风险**:C 是一次图谱成员性测试,它成立的前提是 astock-kg 的图谱**真的覆盖
|
||||
了这些赛道**。通信 / 算力 / 半导体大概率已覆盖;**核聚变、低空经济、商业航空航天的
|
||||
覆盖情况未知**。因此 **G1 里程碑的第一件事是赛道覆盖体检**(§8),体检结果决定 S1 先
|
||||
上哪几个赛道——覆盖为空的赛道宁可先不放进来,也不要放一个空集合进公式。
|
||||
|
||||
### 3.3 硬门槛 ② 估值 V(贵了不买)
|
||||
|
||||
**定义**:V(i,t) = `akg_upside(i,t) ≥ θ_v`。
|
||||
|
||||
**θ_v 的口径**:`θ_v = max(0, 池内 upside 的 q 分位)`,默认 q = 0(即退化为
|
||||
`θ_v = 0`:一致预期目标价中枢不低于现价)。**绝对下限 0 不可让渡**——它是"容错度是否
|
||||
真的存在"的物理判据;分位数只用于**在此之上进一步收紧**,绝不用于放行 upside 为负的
|
||||
标的。q 的取值见 §9-3。
|
||||
|
||||
**为什么必须是硬门槛而不是扣分项**:系统的算法本质是"大方向正确",精度有限,
|
||||
**必须给自己留下容错度**。容错度的物理载体就是价格与预期中枢之间的空间。空间为负时,
|
||||
即便产业逻辑完全正确,也已经没有犯错的余地了——这时不是少买一点的问题,是不该买的问题。
|
||||
|
||||
**已知偏差**:upside 来自券商一致预期,天然报喜不报忧,绝对水平偏乐观。作为**截面
|
||||
相对标尺**它仍可用(乐观偏差在覆盖股之间大体同向);若实测发现 θ_v = 0 几乎筛不掉
|
||||
任何股,就靠上调 q 来收紧,而不是放弃这道门槛。
|
||||
|
||||
**缺失处理**:upside 为 NaN(无券商覆盖)= **不通过门槛**。这是保守选择:
|
||||
没有覆盖就没有容错度的度量,宁可漏不可错。代价是牺牲一部分真正冷门的早期标的
|
||||
——这个代价与"研报滞后是确定性体现"的论题有张力,列为 §9-4 待议。
|
||||
|
||||
### 3.4 门槛内的三项加权
|
||||
|
||||
在候选池 P_t **之内**(标准化只在池内做,池外样本不参与统计量估计):
|
||||
|
||||
| 项 | 构造 | 默认权重 | 语义 |
|
||||
|---|---|---|---|
|
||||
| **传导** z_T | `(log1p(akg_transmission) − mean) / std` | **0.50** | 产业逻辑正在向该环节传导——最硬的买入理由 |
|
||||
| **还没热** z_H | `−1 × robust_z(akg_heat)`(中位数/MAD) | **0.30** | 个股尚冷 = 左侧位置还在(见下方"退化说明") |
|
||||
| **便宜** z_V | `robust_z(akg_upside)`(中位数/MAD) | **0.20** | 过了门槛之后,空间越大越优先 |
|
||||
|
||||
$$\text{akg\_score}_t(i) = w_T \cdot z_T + w_H \cdot z_H + w_V \cdot z_V,\quad
|
||||
i \in P_t$$
|
||||
|
||||
**权重次序由投资逻辑指定**(传导第一、还没热第二、便宜第三),不由拟合决定。
|
||||
0.5/0.3/0.2 是满足该次序的一组默认值,是**旋钮不是结论**;调整它需要的是逻辑上的
|
||||
理由,不是回测上的理由。
|
||||
|
||||
**"还没热"的退化说明(必须写明的一处妥协)**:§1 里热度的定位是**条件性**的——
|
||||
只有在传导已确认板块启动的前提下,"个股尚冷"才等于"左侧位置还在"。但 S1 把它实现
|
||||
成了**无条件加性项**,后果是:传导为 0 的股票只要够冷,仍然能拿到 0.30 权重的正分,
|
||||
此时该项退化成一个**独立的反拥挤/低关注度因子**,与 §1 的"确认器"定位脱钩。
|
||||
|
||||
接受这个妥协的理由是:赛道硬门槛已经保证了标的的产业地位,在**已经确定是好赛道**的
|
||||
池子里,"冷"更可能意味着"还没被发现"而非"基本面差",所以退化后的语义仍然可辩护。
|
||||
备选实现(条件项 `z_H · 1[z_T>0]`、或交互项 `z_T · z_H`)列为 §9-9,S2 再评估。
|
||||
|
||||
**几个必要的技术处置:**
|
||||
|
||||
- **截面标准化是自洽性的前提,不是可选项**。三项量纲完全不同(传导是"路径数 × 空间
|
||||
比例"的乘积量、热度是 0~1 分、upside 是收益率),不标准化则权重毫无意义。
|
||||
**热度项与便宜项**采用池内**稳健标准化**(中位数 / MAD,对齐平台
|
||||
`MathKernel.winsorize_mad` 的处置口径),避免少数极值主导。
|
||||
- **传导项例外:不能用 MAD**。传导在池内约 **95% 为 0**(覆盖池内单日仅 83 行有值),
|
||||
此时 `median = 0` 且 **`MAD = 0` → 除零**。传导项改用非稳健口径
|
||||
`z_T = (log1p(x) − mean) / std`;`std = 0`(当日池内无任何传导)时整列置 0。
|
||||
先 `log1p` 是为压长尾,避免单只票的极端路径数吃掉整个排序。
|
||||
- **传导缺失 = 0,即"取基准值",不是剔除**。标准化后 0 会落在池内均值**之下**,
|
||||
等价于对有传导者的相对加分——这正是想要的:传导项实质是**稀疏加分**而非连续排序,
|
||||
少数被产业逻辑点名的票被显著抬升。若把传导设为必需项,候选池会塌缩到每日几十只
|
||||
以下,既失去组合意义也无法做任何截面统计。
|
||||
- **热度缺失 = 池内中位数**(视为"信息未知"而非"很冷");upside 已在门槛处保证非空。
|
||||
- **门槛外的股票不出行(不写入),而不是写 0**。写 0 会被平台的分层/IC 计算当成
|
||||
"中位水平",从而污染排序语义;不出行则由平台 Gen-2 的 Polars 外连接自然容忍。
|
||||
- **时点对齐**:热度是 **T+1 到达**的日频数据,因此 `akg_score(T)` 的热度项实际使用
|
||||
**T−1 日热度**(当日可得的最新一期)。这不是缺陷而是点时正确的必然结果,
|
||||
必须在因子描述与调度设计里写明(§6.4、§8-G5)。
|
||||
|
||||
### 3.5 事件因子在 S1 的位置
|
||||
|
||||
用户给定的权重次序里没有事件项,因此 **`akg_event` 在 S1 不进主公式**。
|
||||
|
||||
保留它的理由和未来用法:A 股"利好往往是股票大跌的开始",**正向事件不可靠**;
|
||||
但**负向事件(诉讼仲裁、行政处罚、股权质押)作为排除条件是稳健的**——它们不预测
|
||||
上涨,但确实标记不该碰的标的。因此规划 **S2 把事件做成第三道软否决闸**:
|
||||
`akg_event < −θ_e` 时剔除或大幅降权。这条不在 S1 上,先观察事件因子自身的分布与
|
||||
覆盖(实测每日仅 195 行,稀疏)再定 θ_e。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统边界:基座 / 桥 / 平台
|
||||
|
||||
**三层职责切分(已锁定,不再讨论):**
|
||||
|
||||
- **astock-kg 基座**:只**暴露数据**,不算因子。对外只给一组**只读视图**(插槽接口);
|
||||
基座内部随便重构、接口不变。**不新增任何因子计算**,守住"不打分"铁律。
|
||||
- **`akg-factor-bridge`(独立工程 / 插槽)**:承载全部因子建模——读基座视图 + 直连 153
|
||||
读热度 + 读平台行情,做子因子变换与**漏斗合成**,转前缀码,写平台因子表 + 注册。
|
||||
所有建模决策(赛道清单、门槛阈值、权重、半衰期、极性表)集中在桥里,归属清晰;
|
||||
赛道成员快照等桥自有中间产物落桥工程 `data/`,不外溢到基座或平台(§3.2)。
|
||||
- **因子平台 `quant_factor_service`**:保持通用、不感知 astock-kg。只把桥写进来的因子
|
||||
当普通因子,承担存储、注册、调度、**评价(仪表盘)**、下游服务。
|
||||
|
||||
> **为什么不合并进 astock-kg**:基座定位是"数据分析基座微内核",其他应用围绕它形成
|
||||
> **插槽架构**。因子导出理论上只需要拿到数据库连接方式,不需要动基座。独立工程还带来
|
||||
> 部署自由——桥落在哪台服务器只由"能否同时连通三处"决定。
|
||||
|
||||
```
|
||||
astock-kg 基座(PostgreSQL)── 只读视图(插槽接口,零新增计算)
|
||||
├ v_factor_universe (覆盖池,1675)
|
||||
├ v_factor_consensus (一致预期 → upside)
|
||||
├ v_factor_events (EVENT 断言,已解析 ts_code → event)
|
||||
└ v_factor_transmission (传导未动成员 → transmission)
|
||||
│ 热度不经基座:桥直连 153 代理 stock_fund_heat_scores
|
||||
│ 现价不经基座:桥直连平台 gp_day_data
|
||||
▼
|
||||
akg-factor-bridge(独立 Docker 工程,可部署于任意能连通三库的服务器)
|
||||
四路子因子变换 + 赛道成员快照(data/) + 【漏斗合成 akg_score】
|
||||
▼
|
||||
因子平台 quant_factor_service(通用、不感知 astock-kg)
|
||||
t_factor_akg_{upside,heat,event,transmission,score} + factor_metadata
|
||||
→ 评价(RankIC / IC-IR / 分层回测)= 仪表盘
|
||||
→ 每日调度 / 下游策略消费
|
||||
```
|
||||
|
||||
**因子表契约**(平台侧,不可改):
|
||||
`t_factor_{code}(trade_date DATE, stock_code VARCHAR(15), factor_value DOUBLE)`,
|
||||
主键 `(trade_date, stock_code)`;`stock_code` 为**前缀式** `SH600000`
|
||||
(`600000.SH → SH600000`)。注册表 `factor_metadata(factor_code, display_name,
|
||||
target_ds_name, target_table_name, category JSON, factor_type, frequency, status,
|
||||
author, description)`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 子因子口径(继承 v1.1 文档,按 v2.0 语义更新)
|
||||
|
||||
四路子因子**继续独立注册与落库**——它们是漏斗的原料,也是各自可独立观察的仪表。
|
||||
|
||||
### 5.1 `akg_upside` 预期空间 → **门槛② + 权重③**
|
||||
|
||||
- **源**:基座 `v_factor_consensus.target_mid_avg` ÷ 平台 `gp_day_data.close` − 1。
|
||||
- **as-of 口径**:现价日取 `asof_date <= 当日`的最新一致预期(`merge_asof` backward),
|
||||
杜绝前视。
|
||||
- **实测**:2026-07-23 单日 **795 行**;一致预期在基座内只有 2026-07-11 起 7 天历史。
|
||||
- **注意**:`gp_day_data` 的代码列是 **`symbol`**(前缀式,如 `SH688280`),
|
||||
非 `ts_code`;桥内 `_read_gp_price()` 按候选列自动探测。
|
||||
|
||||
### 5.2 `akg_heat` 热度 → **权重②(语义取负)**
|
||||
|
||||
- **源**:153 代理 `stock_fund_heat_scores.score`(0~1,**T+1 日频**,已是前缀码),
|
||||
取每日最新 batch。
|
||||
- **v2.0 语义变更**:**不再当动量/alpha 源用,改为"还没热"取负号使用**。
|
||||
子因子表 `t_factor_akg_heat` **仍存原始 score 不变**,取负只发生在漏斗内部
|
||||
(保持子因子表语义中立、可被其他消费方复用)。**该语义翻转待用户确认**(§9-1)。
|
||||
- **实测**:2026-07-23 单日 **1674 行**(覆盖最广);历史 2025-03-26 起 322 天。
|
||||
|
||||
### 5.3 `akg_event` 事件 → **S1 不入公式,S2 负面否决闸**
|
||||
|
||||
- **源**:基座 `v_factor_events`(`predicate='EVENT'`),ts_code 取**文档锚**
|
||||
`documents.meta->>'company_ts_code'`(公告是单公司文档,锚定 100% 可靠),
|
||||
**不用**未解析的 `claims.subject_id`。
|
||||
- **构造**:Σ 窗口内事件极性 × 时间衰减 `exp(−交易日龄·ln2/半衰期)`,
|
||||
半衰期 10 交易日、窗口 60 交易日(草案,见 §9-5)。
|
||||
- **实测**:2026-07-23 单日 **195 行**(稀疏);历史 2014-01-04 起 843 天(最深)。
|
||||
|
||||
### 5.4 `akg_transmission` 板块传导 → **权重①(最重)**
|
||||
|
||||
- **源**:基座 `v_factor_transmission`(`transmission_candidates` 的 `quiet` 成员摊平)。
|
||||
时点戳字段为 **`scan_date`**(传导扫描日,每日 18:15 后产出)。
|
||||
- **构造**:`路径数 × (1 − 已动比例)`,同股同日多候选取最大。
|
||||
对齐传导模块"多源汇聚=逻辑更硬、已动比例低=空间更大"的语义。
|
||||
- **实测**:2026-07-23 单日 **83 行**;历史仅 2026-07-11 起 **3 天**。
|
||||
- **使用约束**:**只 live 累积,不回填,不进任何拟合模型**(§2.2)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实现现状(截至 2026-07-24 实机验证)
|
||||
|
||||
### 6.1 已完成
|
||||
|
||||
- 基座四视图已建(`sql/astock_kg_slot_views.sql`,容器内 `psql` 应用)。
|
||||
- 桥工程 `akg-factor-bridge` 全量落地(Docker + compose + CLI),
|
||||
三库连通性全部打通:基座 PG ✅ / 153 热度 ✅ / 平台因子库 ✅
|
||||
(**平台因子库亦可经 153 同一 ShardingSphere 访问** —— v1.1 文档 §8.1 已回答)。
|
||||
- 四子因子**全部注册成功**(`factor_metadata` 含 `factor_type` 列)。
|
||||
- 四子因子**全部落库成功**(2026-07-23):upside 795 / heat 1674 / event 195 /
|
||||
transmission 83 行。
|
||||
- **未完成**:赛道成员表、漏斗合成 `akg_score`、`factor_coverage_probe.py` 实跑。
|
||||
|
||||
### 6.2 实测数据历史深度(回填范围据此定)
|
||||
|
||||
天数列 = 该源 `COUNT(DISTINCT 时点列)`,为**有数据的日期数**(非交易日历天数)。
|
||||
|
||||
| 源 | 时点列 | 起 | 止 | 天数 |
|
||||
|---|---|---|---|---|
|
||||
| 行情 `gp_day_data` | `timestamp` | 1997-08-29 | 2026-07-23 | 5584 ⚠️ |
|
||||
| 事件 events | `disclosure_date` | 2014-01-04 | 2026-07-20 | 843 |
|
||||
| 热度 heat | `trade_date` | 2025-03-26 | 2026-07-23 | 322 |
|
||||
| 一致预期 consensus | `asof_date` | 2026-07-11 | 2026-07-23 | 7 |
|
||||
| 传导 transmission | `scan_date` | 2026-07-11 | 2026-07-23 | **3** |
|
||||
|
||||
覆盖池 universe:**1675 只**。
|
||||
|
||||
> ⚠️ **待实机复核**:`gp_day_data` 区间跨约 28.9 年,按 A 股年均 ~242 交易日应在 7000
|
||||
> 上下,实测 5584 相当于年均 193 天——怀疑真实起始日晚于 1997(约在 2003~2006 之间,
|
||||
> 早期只有零星记录)。**§7 中 upside "理论可回填到 2006"依赖这一行,需先复核**。
|
||||
> 复核方式:按年统计 `COUNT(DISTINCT timestamp)`,看从哪一年起接近 242。
|
||||
>
|
||||
> 另注:consensus/transmission 的起始日 2026-07-11、事件起始日 2014-01-04 均为周六,
|
||||
> 因这三者的时点列是**披露日/扫描日等自然日**而非交易日,属正常。
|
||||
|
||||
### 6.3 已解决的实现陷阱(记录以免重踩)
|
||||
|
||||
| 现象 | 根因 | 处置 |
|
||||
|---|---|---|
|
||||
| `factor_metadata 无 factor_code 列` | **ShardingSphere 代理不支持 `information_schema` 内省** | 改用 `SELECT * FROM factor_metadata LIMIT 0` 读 `cur.description` 取列名,附硬编码兜底 |
|
||||
| `Unknown column 'ts_code'` on `gp_day_data` | 平台该表代码列实为 `symbol` | `_read_gp_price()` 按候选列 `symbol/ts_code` 逐个探测 |
|
||||
| upside `MergeError: incompatible merge keys dtype` | pandas 2.x 两侧 datetime 分辨率不一致(us vs ns) | 两侧强制 `astype("datetime64[ns]")` 后单次 `merge_asof(by="k")` |
|
||||
| event `'float' object has no attribute 'strip'` | SQL NULL direction → NaN 撞 `.strip()` | `event_type`/`direction` 先 `.fillna("")` |
|
||||
| `build akg_heat --date 2026-07-24` 无数据 | 热度 T+1,当日尚无 | `views` 子命令增加**数据前沿/历史深度**报告,构建日期据实选 |
|
||||
|
||||
### 6.4 运维口径(Docker 铁律)
|
||||
|
||||
```bash
|
||||
# 起容器
|
||||
docker compose up -d --build
|
||||
# 连通性 + 历史深度自检
|
||||
docker compose exec akg-factor-bridge python run.py views
|
||||
# 注册
|
||||
docker compose exec akg-factor-bridge python run.py register
|
||||
# 日更 / 回填
|
||||
docker compose exec akg-factor-bridge python run.py build all --mode daily
|
||||
docker compose exec akg-factor-bridge python run.py build akg_event \
|
||||
--mode history --start 2024-01-01 --end 2026-07-24
|
||||
# 基座建视图
|
||||
docker exec -i akg-postgres psql -U akg -d akg < sql/astock_kg_slot_views.sql
|
||||
```
|
||||
|
||||
所有写入**幂等**(删涉及日期区间 → 批插),可安全重跑。
|
||||
|
||||
**调度时点的已知约束**:传导 18:15 后落库,故 `akg_score(T)` 最早 T 日 18:40 可算;
|
||||
但热度 T+1 到达,该次计算的热度项用的是 **T−1 日热度**(§3.4 末)。若要让热度项对齐
|
||||
到 T 日,须改为 **T+1 早间补算 `akg_score(T)`**——两种方案的取舍见 §9-10。
|
||||
|
||||
---
|
||||
|
||||
## 7. 回填策略(分层,不追求全量对齐)
|
||||
|
||||
自洽公式路线**不再需要为了训练而凑齐历史**,回填的目的降为两条:
|
||||
让评价仪表盘有足够样本、让子因子各自的分布可被观察。因此按可行性分层推进即可。
|
||||
|
||||
| 子因子 | 可回填性 | 路径 | 深度 |
|
||||
|---|---|---|---|
|
||||
| `akg_heat` | ✅ 直接 | 取 `stock_fund_heat_scores` 历史区间 | 2025-03-26 起 |
|
||||
| `akg_event` | ✅ 直接 | EVENT claims 带真实 `disclosure_date` | 2014 起(依公告抽取进度) |
|
||||
| `akg_upside` | ⚠️ 需重建 | 由 `gp_report_rc` 按披露日重建一致预期聚合 + 历史收盘 | 待 §6.2 复核后定 |
|
||||
| `akg_transmission` | ❌ **不回填** | 回填=前视(§2.2) | 只 live 累积 |
|
||||
| `akg_score` | 随最短板 | 门槛②依赖 upside,权重①依赖传导 | 见下 |
|
||||
|
||||
**`akg_upside` 重建的两条路(待定,§9-6)**:
|
||||
|
||||
- **甲案(基座侧)**:在 astock-kg 内回填 `consensus_daily` 历史,桥不动。
|
||||
优点是基座产出一致、其他消费方同样受益;缺点是动基座、工作量在基座侧。
|
||||
- **乙案(桥侧)**:桥直连 `gp_report_rc` 自行聚合。优点是不动基座、迭代快;
|
||||
缺点是聚合逻辑在两处存在(基座有 live 版、桥有历史版),有漂移风险。
|
||||
|
||||
**已知陷阱(数据源盘点已记)**:`gp_report_rc.tp` 是**利润总额不是目标价**,
|
||||
目标价走 `max_price/min_price`;点时一律用披露日。另需注意行情为**前复权**,
|
||||
历史目标价(当时的名义价)除以前复权价会在除权点产生系统性偏移,重建时需做基准一致化。
|
||||
|
||||
**`akg_score` 的历史怎么办。** 因为传导不可回填,历史 `akg_score` 必然缺失第一权重项。
|
||||
两种诚实的做法,二选一并在因子描述里写明:
|
||||
|
||||
- **A. 只从传导有值的交易日起算(推荐)**。注意一个易被忽略的坑:live 区间内部
|
||||
也有空洞——2026-07-11~07-23 里 consensus 有 7 天而 transmission 只有 3 天,
|
||||
按 §3.4 的 `std=0 → 整列置 0` 规则,那 4 天的 `akg_score` 会**静默退化成没有传导项
|
||||
的版本**,却和真 live 值混在同一张表里,下游无从区分。
|
||||
**因此 S1 的落库规则是:`akg_score` 只在当日传导表有数据的交易日出行**
|
||||
(另在运行日志里记录被跳过的日期),保证表内每一行都真正包含了第一权重项。
|
||||
- **B. 出一个明确标注的历史近似版** `akg_score_hist`:传导项置 0(即历史上只有
|
||||
门槛+热度+便宜三项),**单独注册、单独命名**,绝不与 live 版混在一张表里。
|
||||
它的用途仅限于观察门槛与另外两项的历史分布,**不得用于宣称因子有效性**。
|
||||
|
||||
**可选:外溢代理(spillover proxy)。** 若确实需要一个可回填的传导替身,可用
|
||||
`A@R − R`(邻接矩阵 × 收益的一阶外溢减自身收益)构造。它可回填,但**携带成员性前视**
|
||||
(用今天的产业链图谱定义邻接),故其历史 IC 只能作为**乐观上界**读取,不能当作
|
||||
传导的历史业绩。列为 S3 可选,非必需。
|
||||
|
||||
---
|
||||
|
||||
## 8. 里程碑(G 系列,替代 v1.1 文档的 F 系列)
|
||||
|
||||
- **G0 · 文档评审**:本文评审通过。同步确认 **§9-1(热度语义)、§9-2(赛道清单)**。
|
||||
- **G1 · 赛道覆盖体检**(**当前阻塞项**):拿到完整赛道清单后,逐赛道统计图谱内
|
||||
可映射到的主题/环节/成员数,产出"哪些赛道有料、哪些是空的"的体检表。
|
||||
**覆盖为空的赛道不进 S1 公式。** 同期完成三件事:
|
||||
①补跑 `factor_coverage_probe.py`(v1.1 文档遗留的 F0 覆盖面诊断,只读,尚未执行);
|
||||
②统计池内 upside 分布以定 **§9-3 的 q**;③复核 **§6.2 的 `gp_day_data` 起始日**。
|
||||
- **G2 · 赛道成员表落地**:`config/frontier_tracks.yml` + `tracks.py` +
|
||||
`data/track_members.csv` 快照;人工 review 一遍成员,确认没有明显错配。
|
||||
- **G3 · 漏斗实现 + 注册 `akg_score`**:桥内实现两道门槛与三项加权,落
|
||||
`t_factor_akg_score`,注册。**开工前确认 §9-8(权重量值)**。
|
||||
同时输出**每日候选池规模**日志——若日均候选数长期低于 30,说明**组合层面无法分散
|
||||
成篮**(这是组合可行性约束,**不是 IC 可算性约束**),需回头看是赛道覆盖太窄
|
||||
(§9-2)、估值门槛太紧(§9-3)、还是券商覆盖率不足(§9-4)。
|
||||
- **G4 · 回填与仪表盘**:**开工前确认 §9-6(upside 重建路径)、§9-7(score 历史处理)**。
|
||||
按 §7 分层回填;挂上平台评价,产出第一版 RankIC / IC-IR / 分层读数。
|
||||
**读数不作为判收条件**,作为观察记录。
|
||||
- **G5 · 每日调度上线**:按 §9-10 定下的时点方案配置桥容器 cron 或平台 XXL-JOB;
|
||||
`akg_score` 进入下游消费。
|
||||
|
||||
---
|
||||
|
||||
## 9. 开放问题(待拍板)
|
||||
|
||||
1. **热度语义翻转确认**:v2.0 把热度从"越热越好"改成"**还没热更好**"(漏斗内取负)。
|
||||
这与"热度用于辅助验证传导是否启动、缩短左侧建仓时间"一致,但需要明确确认。
|
||||
2. **完整赛道清单**:已明确核聚变、商业航空航天、通信、算力、人工智能、低空经济;
|
||||
**需要补全"等领域"具体还包括哪些**(如生物制造、量子科技、脑机接口、
|
||||
氢能与新型储能、6G、先进材料……?)。清单直接决定 C 门槛的范围。
|
||||
3. **估值门槛的 q 分位**:`θ_v = max(0, 池内 q 分位)`,q 默认 0。若实测池内 upside
|
||||
普遍为正(券商乐观),q 应上调到多少(0.3?0.5?)——G1 统计分布后定。
|
||||
4. **无券商覆盖股的处置**:当前设计是 upside 缺失 → 不过门槛(保守)。
|
||||
但这与"研报滞后反而是确定性体现"的论题有张力——最早期、最冷门、最符合论题的
|
||||
标的可能恰恰没有覆盖。是否要为"赛道内 + 无覆盖"的股票开一条单独通道?
|
||||
5. **事件极性表与半衰期**(继承 v1.1 文档 §8.3):`发行上市 / 并购交割 / 股权质押` 的
|
||||
符号,以及半衰期 10 日 / 窗口 60 日是否合适。S1 事件不入公式,此题可延后到 S2。
|
||||
6. **upside 历史重建走甲案还是乙案**(§7)。
|
||||
7. **`akg_score` 历史处理走 A 还是 B**(§7 末)。建议 A。
|
||||
8. **权重旋钮的默认值** 0.5/0.3/0.2 是否认可(次序已定,量值待认)。
|
||||
9. **"还没热"是否要改成条件项**:S1 用无条件加性项(§3.4 退化说明)。
|
||||
备选:`z_H · 1[z_T>0]`(仅对有传导的股生效)或 `z_T · z_H`(交互)。S2 评估。
|
||||
10. **调度时点**:T 日 18:40 算(热度用 T−1)vs T+1 早间补算 `akg_score(T)`
|
||||
(热度对齐 T,但因子延迟一天可用)。取决于下游策略的下单时点。
|
||||
|
||||
---
|
||||
|
||||
## 10. 判收标准(v2.0 重写)
|
||||
|
||||
**明确取消**"神经 IC-IR 需优于线性基线"这条 v1.1 判收项——它属于被推翻的路线。
|
||||
|
||||
**必须满足(工程正确性):**
|
||||
|
||||
- 四子因子 + `akg_score` 建表/注册成功,每日调度幂等,重跑不产生重复或漂移。
|
||||
- **点时正确,全链无未来函数**:各源分别以 `disclosure_date` / `asof_date` /
|
||||
`scan_date` / T+1 热度盖章;`akg_score` 在 T 日算出、用于 T+1 买入;
|
||||
热度项使用 T−1 日值这一事实在因子描述中显式声明。
|
||||
- **传导不出现在任何历史区间**(除非走 §7 末 B 方案且单独命名),杜绝前视污染;
|
||||
且 `akg_score` 表内不含"传导项被静默置零"的行(§7 末 A 方案)。
|
||||
- 赛道成员快照可审计:每只股票能追溯到"因哪个赛道、哪个环节、哪条映射规则"入选。
|
||||
|
||||
**必须满足(逻辑自洽性):**
|
||||
|
||||
- 公式的每一项都能用 §1 的论题解释;出现无法解释的项,删掉而不是保留
|
||||
(已知的一处妥协——"还没热"退化为无条件项——已在 §3.4 显式记录并列入 §9-9)。
|
||||
- 门槛与权重的角色不混淆:门槛不可被权重补偿。
|
||||
- 截面标准化只在候选池内做,池外不出行。
|
||||
|
||||
**观察但不判死(仪表盘):**
|
||||
|
||||
- RankIC / IC-IR / 分层单调性 / 换手率 / 候选池规模。
|
||||
- 这些读数用于回答"当前市场是否在奖励这套逻辑",以及"哪一项在拖后腿"。
|
||||
**读数差不构成下线因子的理由**;构成的理由只有一个——**产业逻辑被证伪**。
|
||||
|
||||
**神经 GRU 的复活条件**(降级为"未来挑战者"):当且仅当
|
||||
(a)传导积累出足够长的 live 历史(≥ 1 年,且期间图谱结构相对稳定),
|
||||
(b)自洽公式已上线并有可对比的基准读数——此时才有必要让 GRU 去挑战公式,
|
||||
并且必须是**严格 OOS、只用 live 区间**。在此之前不开这条线。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 术语
|
||||
|
||||
| 词 | 含义 |
|
||||
|---|---|
|
||||
| 基座 | astock-kg,数据分析基座微内核,只暴露数据不算 alpha |
|
||||
| 桥 | `akg-factor-bridge`,独立 Docker 工程,全部因子建模在此 |
|
||||
| 平台 | `quant_factor_service`,通用因子管理平台;本文只用其注册/存储/调度/评价能力 |
|
||||
| 覆盖池 U | `industry_pools` 全主题成员并集,实测 1675 只 |
|
||||
| 候选池 P | U 中同时通过赛道门槛 C 与估值门槛 V 的子集 |
|
||||
| 观察 / 推断 | 有耐久时间戳记录 / 依赖当时系统状态的当日判断(§2.2) |
|
||||
| 漏斗 | 两道硬门槛 + 池内三项加权的复合结构 |
|
||||
| `scan_date` | 传导扫描日,`transmission_candidates` 的时点戳(每日 18:15 后产出) |
|
||||
| S1 / S2 / S3 | 因子自身的演进阶段(区别于文档版本 v1.1 / v2.0) |
|
||||
|
||||
## 附录 B · 关联文档
|
||||
|
||||
- astock-kg:`涌现式应用层重构设计.md`(四路信号即四条用户主线的数据面产物)、
|
||||
`数据源盘点.md`(热度/一致预期/资金语义、`gp_report_rc.tp` 陷阱)、
|
||||
`项目全景盘点_2026-07.md`。
|
||||
- 桥工程:`akg-factor-bridge/README.md`(Docker 用法、待实机核实项)、
|
||||
`sql/astock_kg_slot_views.sql`(四视图定义)。
|
||||
- 平台:`quant_factor_service/readme.md`(多因子合成引擎技术文档)与运维手册
|
||||
——本文只引用其**契约与评价能力**,不再引用其合成能力。
|
||||
|
||||
---
|
||||
|
||||
> **提交提醒**:本文档、`backend/scripts/factor_coverage_probe.py`,以及
|
||||
> `akg-factor-bridge` 整个工程,均为未提交状态,请在开发机 `git commit` 后同步到服务器。
|
||||
Loading…
Reference in New Issue