量化选股与打分体系
概述
本仓库的"选股"不是一条算法,而是四套互补、各不越界的体系:
| 体系 | 输入 | 产出 | 入口 |
|---|---|---|---|
| 因子打分 | fox_stock_wide 单表列 + 指标表富化列 | 11 个 0~100 具名因子分 + 加权综合分 | /factor-pick、POST /factor/screen |
| 条件 DSL | 字段 + 比较算子 | 布尔筛选 | /screener、/nl-pick(自然语言→DSL) |
| 形态识别 | fox_kline_daily 复权序列 | 方向列(±100)+ 度量列 | /patterns、GET /quant/patterns/{code} |
| LLM 研判 | 上述全部 + 联网搜索 | 五维分析 / 策略批量跑 | /dimension/:dimKey、/rule-pick |
共同纪律:规则在前 · LLM 在后——确定性门控先否决,再谈打分;打分前先过 gates,
被拦标的进 rejected 并写明原因,而不是拿一个高分混进榜单。
因子引擎
实现:backend/app/services/scoring/factor_engine.py。数据源是单表 fox_stock_wide
(零 HTTP),另有 6 个富化列(vol_accel_5_5 + 涨停基因 5 列)由
quant/limit_gene.attach_genes 从 fox_stock_indicators_daily 最新日回填
(源头同样是本地库 em_limit_board 聚合,仍零 HTTP),每个因子由若干列做批内百分位
(横截面,无绝对阈值)后取秩均值 ×100。
"A 股今天涨 2% 算强吗"取决于当天分布。横截面分位让因子分在不同市场环境下可比, 代价是它是相对分不是绝对好坏——榜单读法必须配合门控与战绩一起看。
| key | 中文 | 标注 | 权重 | 输入指标(方向) |
|---|---|---|---|---|
momentum | 动量 | real | 0.15 | 20/60/一年涨幅 (high) |
quality | 质量 | real | 0.13 | ROE/毛利率/净利率/ROA (high) |
value | 价值 | partial | 0.11 | PE/PB/PS (low) |
growth | 成长 | real | 0.10 | 营收与净利同比 (high) |
trend | 趋势结构 | proxy | 0.09 | MA5 乖离/YTD/10 日涨幅 (high) |
capital | 资金 | real | 0.09 | 主力净流入占比与净额 (high) |
stability | 稳健低波 | proxy | 0.09 | 日内振幅 (low) |
shortTerm | 短期异动 | real | 0.08 | 3/5 日涨幅 + 涨速 + 量能加速度 5/5 (high) |
liquidity | 流动性 | real | 0.08 | 成交额/流通市值/换手 (high) |
limitGene | 涨停基因 | real | 0.04 | 近 20 日涨停次数/今日连板 (high) + 近 5 日炸板未回封 (low) |
size | 规模 | real | 0.04 | 总市值 (low,小盘溢价) |
proxy 意味着该因子用可得列代理了真正想要的量:趋势结构本应看均线排列,
宽表没有该列,故用乖离 + YTD 代理;稳健低波本应看 ATR,宽表无列,故用振幅反向。
partial 表示口径不完整(价值因子缺 EV/EBITDA 与现金流口径)。
把 proxy 因子当真实因子解读会得出错误结论,前端图例必须原样呈现这个标注。
补充约定:缺失维度从分母剔除并归一化权重(不是补 0 分,补 0 会系统性惩罚数据缺失的票);
估值类列 ≤0 清洗为 None(_NONPOS_KEYS);low 方向取 1 - rank。
排行/展示随行携带的原始字段集中在 DISPLAY_FIELDS(18 项:code name industry
market price change_pct turnover_pct volume_ratio pe_ttm pb total_market_cap
float_market_cap vol_accel_5_5 limit_up_count_20d limit_days_today zt_stat_latest
seal_fund_latest break_fail_days_5d)。
🔴 榜单行只能取自 DISPLAY_FIELDS:amount 不在其中,r.get("amount") 恒为 0
(多因子榜曾因此满屏「0.0 亿」,改用 turnover_pct)。
硬门控
实现:backend/app/services/scoring/gates.py::check_gates,返回 {passed, blocked, code, reason, warnings}。
| 规则 | 阈值 | 处置 |
|---|---|---|
| ST / *ST | 名称含 "ST" | 否决 |
| ROE < 0 | ROE_HARD_LIMIT = 0.0 | 否决(持续亏损) |
| 资产负债率 > 90% | DEBT_RATIO_HARD_LIMIT = 90.0 | 否决(高杠杆) |
| 财务完全缺失 | ROE/负债率/营收/利润全 None | 默认警告;严格调用传 block_on_missing_financials=True 才否决 |
| 营收 + 利润双负增长 | 两者 < 0 | 警告 |
| 资产负债率 70%~90% | DEBT_RATIO_WARN = 70.0 | 警告 |
| 缺 ROE 或缺负债率 | — | 警告 |
财务缺失默认降级为警告而不是否决,原因是实时打分常常拿不到财务列(次新股/盘中),
一律否决会让榜单在大半天里是空的。结果随 compute_stock_score 的 gate 字段返回,
stock_scoring 的 level 对被拦标的置「否决」。
量化预设
实现:backend/app/services/quant/presets.py::PRESETS,14 套。
每条结构:key / label / desc / rationale(为什么有效)/ watchOut(最容易亏在哪) /
filters({factor, min, max} 因子门槛)/ sort / limit,可选 universeHint.industries 与 minComposite。
| key | 中文 | key | 中文 |
|---|---|---|---|
momentumCore | 动量核心 | valueDeep | 深度价值 |
breakoutVol | 放量突破 | growthMomentum | 成长动量 |
volumeIgnition | 放量启动 | capitalFlow | 资金驱动 |
oversoldQuality | 超卖反弹(好公司坏日子) | blueChipLiquidity | 大盘蓝筹 |
smallCapElastic | 小盘高弹性 | qualityCompounder | 高质量长牛 |
aiComputeRotation | 算力链轮动 | multiFactorResonance | 多因子共振 |
lowVolQuality | 低波质量(防御) | limitUpGene | 涨停基因(短线强势) |
预设的设计约束是"每条必须诚实说明它最容易亏在哪":breakoutVol 写明假突破是主要亏损
来源(等收盘确认、不追盘中冲高,高位爆量可能是出货);smallCapElastic 写明流动性风险
最大(冲击成本吃收益,须卡成交额门槛)。新增预设不写 watchOut 视为未完成。
用户在同一因子上的手改覆盖预设值(_merge_filters,与预设 filters 按 factor 合并)。
形态识别:三层基元优先
不为每个形态手搓一个探测器,而是分三层(🔴 依赖方向单向,禁止反向):
chart_geometry只提供基元——"取哪些枢轴、突破哪条线"是形态层的事STRUCTURAL_FIELDS方向列 16 个:duck_buyduck_sellarc_bottomarc_topcup_handle+ 图形形态 11 项(双顶/双底/三重顶/三重底/头肩顶/头肩底/矩形/三角/楔形/旗形/岛形)STRUCTURAL_METRICS度量列 9 个:duck_stageduck_neck_daysduck_pullback_pctduck_vol_shrink_pctduck_breakout_pct+rectangle_height_pcttriangle_kindwedge_kindflag_kind
四条硬约定:
| 约定 | 原因 |
|---|---|
写行门槛 = has_signal(pat) or has_structural_signal(struct)(quant/engine.py:180) | duck_stage 等度量列是筛选条件不是信号,但处于"鸭颈/鸭头回落"的股票必须落行,否则选股器(FILTER_SPECS 读本表)永远查不到 |
| 无未来函数 | fractal_* 只返回右侧已有 k 根确认的枢轴;鸭头取"当日之前"的最高点——否则突破当日会被自己的新高顶掉,买点②永不成立 |
| 突破首日才置方向值 | 枢轴类图形形态用 _first_breakout(今日破线且昨日仍在线内),否则同一只票天天推;arc_bottom/arc_top/cup_handle 是状态判定,按"当前成立"消费 |
| 字段三处同改 | STRUCTURAL_FIELDS/STRUCTURAL_METRICS → registry.py 的 pattern_* → api/quant.py 的 structural·duck·metrics 三块输出。度量列不得混进 signals(否则"回调 8%"会被当成买入信号播报) |
vma 是含形态本段的滚动均量,顶部区间的均量被自身峰值抬高(实测下破候选日 vol/vma
中位 0.86 vs 上破 1.42)。两向共用一个倍数会让全部顶部形态恒零信号,而"永远不报顶"
比报错更难发现。故上破用 breakout_vol_mult=1.5、下破用 breakout_vol_mult_down=1.0。
阈值集中在 DuckParams / SwingParams 等 dataclass,可经 SysConfig
pattern.{group}.{field} 覆盖(60s TTL,改后须重跑 quant_calc)。
边界线取"被最多枢轴触及的那条"(best_line,平局取跨度更长),不是拟合首尾枢轴——
后者会被边缘平台枢轴带偏(实测把矩形斜率顶到 4.17%,越过 4% 门槛而漏报)。
指标层产出(quant_calc 17:30)
| 表 | 内容 | 列族 |
|---|---|---|
fox_stock_indicators_daily | 技术指标 | 均线 ma5..ma250、MACD、KDJ、BOLL、RSI、CCI/WR/VR/CR/ATR、乖离、OBV、位置 pos_5..180 与共振、贯穿突破 |
fox_stock_pattern_daily | 单根组合 + 结构形态 | 方向列 ±100、度量列 |
fox_stock_cyq_daily | 筹码分布 | profit_ratio、avg_cost、90%/70% 区间上下沿与集中度 |
写入者统一为 quant/engine.py::run_quant_calc(先按 date 删目标日行再批量插入,
因此重跑即覆盖——改完 quant 代码重启 worker 后要手动重跑一次当日)。
🔴 必须在 fox_kline_sync(15:40)之后执行:它读 fox_kline_daily。
战绩闭环
统一前向收益口径(唯一实现)
backend/app/services/forward_return.py:
| 要素 | 口径 |
|---|---|
| 买入 | 入选日次一交易日开盘价(缺失退化为收盘) |
| 卖出 | 持有 HOLD_DAYS=5 个交易日收盘 |
| 胜 | 收益 > 0;形态榜按方向对齐 aligned = ret_5d × sign(+100 看涨 / −100 看跌),aligned>0 即走对 |
builtin_track、pattern_review、scorecard 都调它而不各写一份——
同一批信号用两套口径算出的胜率无法互相解释。
gate(硬门控排雷榜)的 semantics="avoid":被否决的标的跌得多才说明拦得对。
聚合文案反向表述,不硬套"胜率"。命中率刻意不报浮亏——同一份 max_drawdown_pct
在 −100 的桶里含义翻转(成了最大浮盈)。
每日留痕
| 任务 | 时点 | 落表 | 底表 |
|---|---|---|---|
builtin_track | 18:15(须晚于 dragon_score_sync 17:40,VPA 候选池取自当日龙头评分) | app_builtin_board_pick(三榜 top 20) | vpa / factor / gate |
preset_track | 18:25 | app_preset_pick_daily(14 预设 top 20) | fox_stock_wide |
🔴 两条共同的硬约定:重跑只重建本日 pending 行,已复盘的行连同买点与收益原样保留
(否则每天重算会把历史战绩洗掉)。底表 fox_stock_wide 是覆盖式快照、无业务日期列,
所以 as_of 取 max(updated_at) 并经 trading_calendar.latest_trade_date() 归一到
「截至该时刻已收盘的最近交易日」——写入时刻不是业务日期;拿不到 as_of 就跳过,
不许拿今天的排名冒充历史。窗口未满保持 pending,长期停牌按现有根数记 partial,
都不提前结账。榜单是收盘后落库口径,刻意不做盘中实时化(横截面分掺进实时价
会让排名半天内反复横跳),as_of 在卡片与面板上如实显示。
只读卡片与跨体系成绩单
services/strategy_audit.py:把既有三套打分(龙头五维app_dragon_score/ 量价 VPA / 多因子)+ 硬门控聚合成只读卡片与榜单,与用户配置的/strategies刻意分家 (卡片无参数、点开即用)。端点走独立前缀/builtin-strategies(/strategies/builtin会被/{strategy_id}吞掉)。同一份进程缓存服务三处 (体检/因子快照/榜单各 300s,整榜缓存后切片——limit=1 与 limit=50 不许各算一遍); 每套体系独立 try/except,故障降级neutral并附原因,不适用如实标naservices/scorecard.py:9 个来源统一成同一行契约 + 同一套汇总 (dragon5vpafactorgatepatterntacticquant_strategyquant_presetstrategy_run), 端点/scorecard/{sources,picks,breakdown,overview,stock/{code}},前端四个入口共用
① 汇总与表格取自同一批行——后端按筛选后的行算汇总,口径文案(buy_model/note)
也由后端下发,前端一个收益数字都不自己算;
② has_hit_rate=false 的来源不显示那个假数字:龙头五维的买入价取断板日最低价、
收益窗口含买入日 → max_return_pct 结构性 ≥0、胜率恒 100%,是构造产物非战绩,只给 win_rate_na 的理由;
③ 明细被等距抽样时(truncated)面板顶部黄条 + coverage 显示实际覆盖日期范围,
并如实说明汇总基于抽样后的行。
形态战绩(GET /quant/pattern-performance)不另起留痕表——
fox_stock_pattern_daily 本身就是留痕(PK (code,date)、方向列只写 ±100)。
只评测 STRUCTURAL_FIELDS ∩ ORM 列:显式传 duck_stage 这类度量列必须被拒
(它是筛选条件,当信号回测等于把"处于鸭颈期的票"当买点)。
端点速查
| 前缀 | 文件 | 主要端点 |
|---|---|---|
/factor | api/factor.py | GET /factors(图例)、GET /presets、GET /strategy-hits/{code}、POST /screen(打分 + 筛选分页) |
/quant | api/quant.py | GET /indicators/{code}、/patterns/{code}、/cyq/{code}、/signal-pool、POST /recalc、/strategies 与其 picks/validation、/screener 与字段、VPA、/pattern-performance、/pattern-catalog、/relative-strength、公式 |
/screener | api/screener.py | POST /find(DSL)、GET /filters、/presets、POST /parse-dsl、POST /nl-find |
/scorecard | api/scorecard.py | GET /sources、/overview、/picks、/breakdown、/stock/{code} |
/builtin-strategies | api/builtin_strategies.py | GET ""(卡片清单)、/{key}/board、/{key}/performance |