feat(factors): industry-neutralized CN value alphas + PIT SW2021 sector tag (csi300) (#1802)
* feat(bench): add horizon parameter for monthly-frequency IC revalidation
h-bar forward returns (close[t+h]/close[t]-1, no ffill — a NaN close never
bridges) sampled non-overlapping end-anchored, so the naive-iid t-stat and
the alive/dead thresholds stay honest without new statistics. Threaded
through run_bench / run_bench_strict / the alpha_bench tool + MCP wrapper /
CLI --horizon (argparse type + handler guard, exit 2 on bad input).
horizon=1 is byte-identical to the previous next-bar formula.
Follow-up #1 of issue #1789 (monthly-horizon revalidation of the 13
fundamental factors — the conventional reading for fundamental factors).
* fix(bench): exclude SEC-fundamental alphas off sp500 instead of failing the bench
--zoo fundamental --universe csi300 (the repro command printed in issue
#1789) hard-failed with 'Choose the S&P 500 universe' because the
validator rejects fund:* inputs on any non-sp500 universe, while the
mirror case (CN val:* alphas on sp500) skips cleanly per alpha. The
runners now split the selection up front, disclose the excluded ids in
the result envelope, and error only when nothing is left to bench; the
validator stays as the backstop for direct callers.
The CLI/tool JSON envelopes also state their own horizon,
forward_return_sampling and universe_excluded — a saved summary must
show the configuration its numbers came from.
* fix(bench): close review findings on horizon/exclusion disclosure
- multi-zoo CLI aggregation threads horizon / forward_return_sampling /
universe_excluded from the sub-runs into the aggregate envelope (a saved
all-zoos JSON now states its own horizon and reconciles its exclusions)
- alpha compare surfaces universe-excluded shortlist members as skipped
entries with the exclusion reason instead of silently dropping them
- horizon upper bound (1.._MAX_HORIZON) enforced consistently at the CLI
parser, the handler, and both runner entries BEFORE any panel load —
previously only the tool/MCP capped it and an unbounded CLI run fetched
the full panel before failing late
- non-int horizon can no longer TypeError in the entry schema construction
- categorise docstring notes the daily calibration of the |IC|>0.02 floor
at h>1; the sampling comment no longer overclaims (residual
autocorrelation from persistent factor exposures is not modeled)
- MCP wrapper uses _MAX_HORIZON instead of a hardcoded duplicate
Pinned by new tests: parser/handler/runner cap rejection (fetch not
called), multi-zoo disclosure aggregation, compare exclusion disclosure.
* feat(factors): industry-neutralized CN value alphas + PIT SW2021 sector tag
Three within-industry z-scored variants of the CN value factors
(fund_pb/fund_ps_ttm/fund_dividend_yield -> *_ind_neutral, zoo 475 -> 478):
raw A-share PB/PS/dy are heavily industry-confounded, so each name is
z-scored against its Shenwan L1 peers instead of the whole cross-section.
- industry_loader.py: tushare index_member_all (SW2021) membership with
in_date/out_date -> point-in-time labels per trading day; deterministic
overlap resolution; 2000-row truncation guard refetches per L1; degrades
to an unmapped frame on provider failure, never invents a label.
- csi300 bench attaches panel['sector'] outside the pickled price cache,
masked to the PIT membership cross-section, behind the same 0.9 coverage
gate the sp500 GICS tag uses; meta discloses sector_source/coverage.
- base.zscore_by_group: the z-scoring sibling of alpha101's _ind_neutralize
demean - no global fallback (an unlabelled cell stays NaN rather than
wearing an industry-neutral label it did not earn).
- README x7 + SKILL.md counts (incl. the stale 462 prose sites), i18n zh/en.
Follow-up #2 of issue #1789.
* fix(loaders): index_member_all takes no src param; fetch PIT history via is_new Y+N
Live-verified endpoint contract (pinned by test): index_member_all accepts
only the documented l1/l2/l3_code, ts_code and is_new filters — an
undocumented src param silently returns an empty frame (HTTP 200, 0 rows),
which degraded the first csi300 sector attach to coverage 0.0. src now goes
to index_classify (truncation fallback) only.
The server also defaults is_new to 'Y' (current members, no out_date rows),
so the historical half ('N', ~2000 rows with out_date back to the 1990s) is
fetched separately and concatenated — without it every label would be
today's classification and the loader's PIT claim false.
Live smoke: 350/350 csi300 PIT constituents covered (100%), 0.9 gate passes.
* docs(factors): surface the SW2021 taxonomy-retroactivity caveat; pin cache immutability
Review follow-ups: the loader docstring disclosed that SW2021
classifications are applied retroactively, but no user-facing surface
did — one sentence added to each ind-neutral alpha's notes, the i18n
en/zh entries and SKILL.md (membership is point-in-time; the taxonomy
version itself is not). The sector-attach test now pins that the
original pickled panel dict is never mutated by the attach, and the
sector_coverage meta semantics (labelled-somewhere fraction, not
per-date) are documented at the attach site.
* fix(factors): align ind-neutral PB/PS with the upstream nonpositive-multiple guard
The maintenance batch (d3c76819) added a guard to fund_pb/fund_ps_ttm:
nonpositive multiples are excluded rather than ranked as cheap. The
industry-neutral variants follow suit — the within-group z-score sees
the same values.where(values > 0) pre-filter, the guard sentence leads
the notes/i18n entries, and the upstream parametrized test is mirrored
for the neutral stems.
This commit is contained in:
1 parent
cf19d98b96
commit
ee03474268
33 files changed
+2815
-74
No files matched your search
@@ -379,7 +379,7 @@ It is designed for research, simulation, and backtesting — and, when you choos
|
||||
| **Run analyst teams** | Multi-agent research reviews for investment, quant, crypto, macro, and risk workflows. |
|
||||
| **Put research into IM channels** | Run the same session runtime through WebSocket, Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat, WeChat/WeCom, Feishu/Lark, DingTalk, Teams, email, and Mochat with CLI, REST, and Web UI controls. |
|
||||
| **Ship usable artifacts** | Reports, TradingView Pine Script, TDX, MetaTrader 5, MCP tools, and later research sessions. |
|
||||
| **Bench a pre-built alpha zoo** | One-line IC + alive/reversed/dead categorisation across 475 alphas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental) on your universe. |
|
||||
| **Bench a pre-built alpha zoo** | One-line IC + alive/reversed/dead categorisation across 478 alphas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental) on your universe. |
|
||||
| **Spot correlation regimes** | An edge-density + hysteresis timeline on the `/correlation` surface showing when markets fuse into one bloc — descriptive risk context, not a signal. Undefined pair correlations keep edge density unknown, so constant-price histories cannot claim a measured zero density. |
|
||||
|
||||
Daily correlation and regime requests align each source by its local calendar date, including mixed timezone-aware and timezone-naive histories.
|
||||
@@ -798,9 +798,9 @@ alongside the bundled roster (same-name files override it, like user skills) and
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>475 pre-built quant alphas across 5 families</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>478 pre-built quant alphas across 5 families</sub></summary>
|
||||
|
||||
- 🧬 475 cross-sectional alphas, lookahead-banned at the operator layer
|
||||
- 🧬 478 cross-sectional alphas, lookahead-banned at the operator layer
|
||||
- 📈 IC + IR + alive/reversed/dead categorisation in one CLI command
|
||||
- 🔬 AST purity gate + 300-row lookahead sentinel test + `pytest-socket` network kill-switch
|
||||
- 📦 Apache-2 attribution for Qlib; per-zoo `LICENSE.md` declaring formulas as mathematical content
|
||||
@@ -1201,7 +1201,7 @@ The interactive TUI (`vibe-trading`) now uses a terminal-native transcript: a st
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # browse 475 pre-built alphas; show / bench / compare / export-manifest sub-commands available
|
||||
vibe-trading alpha list # browse 478 pre-built alphas; show / bench / compare / export-manifest sub-commands available
|
||||
vibe-trading playbook list # five scheduled-research templates; show / create sub-commands available
|
||||
vibe-trading channels status --local # inspect IM channel config and install hints
|
||||
vibe-trading provider doctor # print redacted provider/proxy/package diagnostics
|
||||
@@ -2126,7 +2126,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 475 alphas across 5 families
|
||||
│ │ ├── factors/ # Alpha Zoo — 478 alphas across 5 families
|
||||
│ │ │ ├── base.py # 19 operators (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST-only metadata load + lazy compute + sanity gates
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead categorisation
|
||||
@@ -2209,7 +2209,7 @@ Vibe-Trading is part of the **[HKUDS](https://github.com/HKUDS)** agent ecosyste
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | Local loader Shipped |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Analytic payoff/scenario tool **Shipped**; surface/dashboard Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Turnover-aware optimizer **Shipped 0.1.11**; rest Planned |
|
||||
| **Alpha Zoo** | 475 pre-built alphas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental) with one-line bench, agent integration, and Web UI | **Shipped 0.1.8**, extended through 0.1.12 |
|
||||
| **Alpha Zoo** | 478 pre-built alphas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental) with one-line bench, agent integration, and Web UI | **Shipped 0.1.8**, extended through 0.1.12 |
|
||||
| **Strategy Development Manager** | Register papers / broker research as factors & strategies with a persistent store + automated IC/Sharpe decay lifecycle | **Shipped 0.1.11** |
|
||||
| **Correlation Regime** | Edge-density + hysteresis regime timeline layered on `/correlation` — spot when markets fuse into one bloc | **Shipped 0.1.12** |
|
||||
| **Research Delivery** | Scheduled briefs and live research sessions through Slack / Telegram / email-style IM channels | Scheduler + IM Runtime Shipped |
|
||||
|
||||
+6
-6
@@ -380,7 +380,7 @@ Vibe-Trading مساحة عمل بحثية مفتوحة المصدر تحول ا
|
||||
| **تشغيل فرق محللين** | مراجعات بحث متعددة الوكلاء لتدفقات الاستثمار والكم والكريبتو والماكرو والمخاطر. |
|
||||
| **وصل الأبحاث بقنوات IM** | إدارة بيئة جلسة واحدة عبر WebSocket وTelegram وSlack وDiscord وMatrix وWhatsApp وSignal وQQ/NapCat وWeChat/WeCom وFeishu/Lark وDingTalk وTeams وemail وMochat من CLI وREST وWeb UI. |
|
||||
| **إنتاج مخرجات قابلة للاستخدام** | تقارير، وTradingView Pine Script، وTDX، وMetaTrader 5، وأدوات MCP، وجلسات بحث لاحقة. |
|
||||
| **bench ألفا zoo جاهزة** | تشغيل IC + IR + تصنيف alive/reversed/dead عبر 475 ألفا (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental) بسطر أوامر واحد على universe الخاص بك. |
|
||||
| **bench ألفا zoo جاهزة** | تشغيل IC + IR + تصنيف alive/reversed/dead عبر 478 ألفا (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental) بسطر أوامر واحد على universe الخاص بك. |
|
||||
| **رصد أنظمة الارتباط** | جدول زمني قائم على كثافة الحواف + التباطؤ (hysteresis) على واجهة `/correlation` يُظهر متى تندمج الأسواق في كتلة واحدة — سياق مخاطر وصفي، لا إشارة تداول. |
|
||||
|
||||
---
|
||||
@@ -667,9 +667,9 @@ LONGBRIDGE_ACCESS_TOKEN=...
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>475 ألفا كمّي جاهز عبر 5 families</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>478 ألفا كمّي جاهز عبر 5 families</sub></summary>
|
||||
|
||||
- 🧬 475 ألفا cross-sectional، مع منع lookahead على طبقة العوامل (operators)
|
||||
- 🧬 478 ألفا cross-sectional، مع منع lookahead على طبقة العوامل (operators)
|
||||
- 📈 IC + IR + تصنيف alive/reversed/dead بأمر CLI واحد
|
||||
- 🔬 بوابة نقاء AST + اختبار حماية lookahead بـ 300 صف + قاطع شبكة عبر `pytest-socket`
|
||||
- 📦 إسناد Apache-2 لـ Qlib؛ ملف `LICENSE.md` لكل zoo يصرّح بأن الصيغ محتوى رياضي
|
||||
@@ -977,7 +977,7 @@ Vibe-Trading وكيل كثيف الأدوات؛ المهارات والاختب
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # استعرض 475 ألفا جاهز؛ متاح show / bench / compare / export-manifest
|
||||
vibe-trading alpha list # استعرض 478 ألفا جاهز؛ متاح show / bench / compare / export-manifest
|
||||
vibe-trading playbook list # خمسة قوالب بحث مجدولة؛ متاح show / create
|
||||
vibe-trading channels status --local # فحص إعدادات قنوات IM وتلميحات التثبيت
|
||||
vibe-trading provider doctor # طباعة تشخيصات المزود/الوكيل/الحزم بعد إخفاء الأسرار
|
||||
@@ -1731,7 +1731,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 475 ألفا عبر 5 families
|
||||
│ │ ├── factors/ # Alpha Zoo — 478 ألفا عبر 5 families
|
||||
│ │ │ ├── base.py # 19 عاملاً (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # تحميل بيانات وصفية AST فقط + حساب كسول + بوابات سلامة
|
||||
│ │ │ ├── bench_runner.py # IC + تصنيف alive/reversed/dead
|
||||
@@ -1814,7 +1814,7 @@ Vibe-Trading جزء من نظام وكلاء **[HKUDS](https://github.com/HKUDS)
|
||||
| **Data Bridge** | أحضر بياناتك: موصلات CSV/Parquet/SQL محلية مع schema mapping | المُحمِّل المحلي شُحن |
|
||||
| **Options Lab** | سطح تقلب، ولوحة Greeks، ومستكشف payoff/scenario | مخطط |
|
||||
| **Portfolio Studio** | أشعة مخاطر، وقيود، ومحسن يراعي الدوران، وملاحظات إعادة توازن | محسن يراعي الدوران **تم الإطلاق 0.1.11**؛ الباقي مخطط |
|
||||
| **Alpha Zoo** | 475 ألفا كمّي جاهز (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental)، سطر أوامر واحد للـ bench، تكامل agent، وواجهة Web | **تم الإطلاق 0.1.8**، موسّع حتى 0.1.12 |
|
||||
| **Alpha Zoo** | 478 ألفا كمّي جاهز (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental)، سطر أوامر واحد للـ bench، تكامل agent، وواجهة Web | **تم الإطلاق 0.1.8**، موسّع حتى 0.1.12 |
|
||||
| **Strategy Development Manager** | تسجيل الأوراق البحثية / أبحاث الوسطاء كعوامل واستراتيجيات مع مخزن دائم + دورة حياة آلية لاضمحلال IC/Sharpe | **تم الإطلاق 0.1.11** |
|
||||
| **Correlation Regime** | جدول زمني لنظام الارتباط قائم على كثافة الحواف + التباطؤ (hysteresis) فوق `/correlation` — رصد متى تندمج الأسواق في كتلة واحدة | **تم الإطلاق 0.1.12** |
|
||||
| **Research Delivery** | موجزات مجدولة وجلسات بحث حي عبر Slack / Telegram / قنوات IM شبيهة بالبريد | المُجدوِل + IM Runtime شُحنا |
|
||||
|
||||
+6
-6
@@ -379,7 +379,7 @@ Está diseñado para investigación, simulación y backtesting, y, cuando tú lo
|
||||
| **Ejecutar equipos de analistas** | Revisiones de investigación multiagente para flujos de trabajo de inversión, cuantitativo, cripto, macro y riesgo. |
|
||||
| **Llevar la investigación a canales de mensajería** | Ejecuta el mismo runtime de sesión a través de WebSocket, Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat, WeChat/WeCom, Feishu/Lark, DingTalk, Teams, correo electrónico y Mochat, con controles de CLI, REST y Web UI. |
|
||||
| **Entregar artefactos utilizables** | Informes, TradingView Pine Script, TDX, MetaTrader 5, herramientas MCP y sesiones de investigación posteriores. |
|
||||
| **Evaluar un zoológico de alfas preconstruido** | Clasificación IC + viva/invertida/muerta en una línea, en 475 alfas (Qlib 158 + Kakushadze 101 + GTJA 191 + académicas + fundamentales PIT-safe) sobre tu universo. |
|
||||
| **Evaluar un zoológico de alfas preconstruido** | Clasificación IC + viva/invertida/muerta en una línea, en 478 alfas (Qlib 158 + Kakushadze 101 + GTJA 191 + académicas + fundamentales PIT-safe) sobre tu universo. |
|
||||
| **Detectar regímenes de correlación** | Una línea de tiempo de densidad de aristas + histéresis en la superficie `/correlation` que muestra cuándo los mercados se fusionan en un solo bloque: contexto de riesgo descriptivo, no una señal. |
|
||||
|
||||
---
|
||||
@@ -673,9 +673,9 @@ junto al catálogo incluido (los archivos con el mismo nombre lo sobrescriben, i
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>475 alphas cuantitativos preconstruidos en 5 familias</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>478 alphas cuantitativos preconstruidos en 5 familias</sub></summary>
|
||||
|
||||
- 🧬 475 alphas cross-sectional, con prohibición de lookahead a nivel de la capa de operadores
|
||||
- 🧬 478 alphas cross-sectional, con prohibición de lookahead a nivel de la capa de operadores
|
||||
- 📈 Categorización de IC + IR + vivo/invertido/muerto en un solo comando de la CLI
|
||||
- 🔬 Puerta de pureza AST + test centinela de lookahead de 300 filas + interruptor de apagado de red `pytest-socket`
|
||||
- 📦 Atribución Apache-2 para Qlib; `LICENSE.md` por zoo que declara las fórmulas como contenido matemático
|
||||
@@ -1015,7 +1015,7 @@ La TUI interactiva (`vibe-trading`) ahora usa una transcripción nativa de termi
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # explora 475 alphas preconstruidos; subcomandos show / bench / compare / export-manifest disponibles
|
||||
vibe-trading alpha list # explora 478 alphas preconstruidos; subcomandos show / bench / compare / export-manifest disponibles
|
||||
vibe-trading playbook list # cinco plantillas de investigación programada; subcomandos show / create disponibles
|
||||
vibe-trading channels status --local # inspecciona la configuración de canales IM y sugerencias de instalación
|
||||
vibe-trading provider doctor # imprime diagnósticos redactados de provider/proxy/paquetes
|
||||
@@ -1819,7 +1819,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # búsqueda web con DuckDuckGo
|
||||
│ │ │ └── ... # bash, E/S de archivos, análisis de factores, opciones, navegador de alpha + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 475 alphas en 5 familias
|
||||
│ │ ├── factors/ # Alpha Zoo — 478 alphas en 5 familias
|
||||
│ │ │ ├── base.py # 19 operadores (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # carga de metadatos solo por AST + cómputo diferido + puertas de sanidad
|
||||
│ │ │ ├── bench_runner.py # IC + categorización alive/reversed/dead
|
||||
@@ -1902,7 +1902,7 @@ Vibe-Trading forma parte del ecosistema de agentes de **[HKUDS](https://github.c
|
||||
| **Data Bridge** | Trae tus propios datos: conectores locales CSV/Parquet/SQL con mapeo de esquema | Cargador local publicado |
|
||||
| **Options Lab** | Superficie de volatilidad, panel de Greeks, explorador de payoff/escenarios | Herramienta analítica de payoff/escenarios **publicada**; superficie/panel planificados |
|
||||
| **Portfolio Studio** | Risk x-ray, restricciones, optimizador con reducción de turnover, notas de rebalanceo | Optimizador con reducción de turnover **publicado en 0.1.11**; el resto planificado |
|
||||
| **Alpha Zoo** | 475 alphas preconstruidas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental) con benchmark en una línea, integración con el agente y Web UI | **Publicado en 0.1.8**, ampliado hasta 0.1.12 |
|
||||
| **Alpha Zoo** | 478 alphas preconstruidas (Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental) con benchmark en una línea, integración con el agente y Web UI | **Publicado en 0.1.8**, ampliado hasta 0.1.12 |
|
||||
| **Strategy Development Manager** | Registra papers / research de brokers como factores y estrategias con un almacén persistente + lifecycle automatizado de decaimiento de IC/Sharpe | **Publicado en 0.1.11** |
|
||||
| **Correlation Regime** | Línea temporal de régimen por edge-density + histéresis, superpuesta en `/correlation` — detecta cuándo los mercados se fusionan en un solo bloque | **Publicado en 0.1.12** |
|
||||
| **Research Delivery** | Briefs programados y sesiones de investigación en vivo a través de canales IM tipo Slack / Telegram / correo | Scheduler + runtime de IM publicados |
|
||||
|
||||
+6
-6
@@ -502,7 +502,7 @@ Vibe-Trading dirancang untuk riset, simulasi, dan backtest — serta, jika Anda
|
||||
| **Jalankan tim analis** | Review riset multi-agent untuk workflow investasi, quant, crypto, makro, dan risiko. |
|
||||
| **Bawa riset ke channel IM** | Jalankan runtime sesi yang sama melalui WebSocket, Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat, WeChat/WeCom, Feishu/Lark, DingTalk, Teams, email, dan Mochat dengan kontrol CLI, REST, dan Web UI. |
|
||||
| **Hasilkan artefak yang siap digunakan** | Laporan, TradingView Pine Script, TDX, MetaTrader 5, tool MCP, dan sesi riset lanjutan. |
|
||||
| **Benchmark alpha zoo siap pakai** | Satu baris untuk IC + kategorisasi alive/reversed/dead pada 475 alpha (Qlib 158 + Kakushadze 101 + GTJA 191 + akademik + fundamental PIT-safe) di universe Anda. |
|
||||
| **Benchmark alpha zoo siap pakai** | Satu baris untuk IC + kategorisasi alive/reversed/dead pada 478 alpha (Qlib 158 + Kakushadze 101 + GTJA 191 + akademik + fundamental PIT-safe) di universe Anda. |
|
||||
| **Deteksi regime korelasi** | Timeline edge-density + hysteresis pada surface `/correlation` yang menunjukkan kapan market menyatu menjadi satu blok — konteks risiko deskriptif, bukan sinyal. |
|
||||
|
||||
---
|
||||
@@ -833,9 +833,9 @@ bersama roster bawaan (file dengan nama sama akan override, seperti user skill)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>475 alpha quant siap pakai dalam 5 keluarga</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>478 alpha quant siap pakai dalam 5 keluarga</sub></summary>
|
||||
|
||||
- 🧬 475 alpha cross-sectional, lookahead dilarang pada layer operator
|
||||
- 🧬 478 alpha cross-sectional, lookahead dilarang pada layer operator
|
||||
- 📈 IC + IR + kategorisasi alive/reversed/dead dalam satu perintah CLI
|
||||
- 🔬 AST purity gate + test sentinel lookahead 300 baris + network kill-switch `pytest-socket`
|
||||
- 📦 Atribusi Apache-2 untuk Qlib; `LICENSE.md` per-zoo menyatakan formula sebagai konten matematis
|
||||
@@ -1202,7 +1202,7 @@ TUI interaktif (`vibe-trading`) kini memakai transcript native terminal: startup
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # browse 475 pre-built alphas; show / bench / compare / export-manifest sub-commands available
|
||||
vibe-trading alpha list # browse 478 pre-built alphas; show / bench / compare / export-manifest sub-commands available
|
||||
vibe-trading playbook list # five scheduled-research templates; show / create sub-commands available
|
||||
vibe-trading channels status --local # inspect IM channel config and install hints
|
||||
vibe-trading provider doctor # print redacted provider/proxy/package diagnostics
|
||||
@@ -2064,7 +2064,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 475 alphas across 5 families
|
||||
│ │ ├── factors/ # Alpha Zoo — 478 alphas across 5 families
|
||||
│ │ │ ├── base.py # 19 operators (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST-only metadata load + lazy compute + sanity gates
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead categorisation
|
||||
@@ -2148,7 +2148,7 @@ Vibe-Trading adalah bagian dari ekosistem agent **[HKUDS](https://github.com/HKU
|
||||
| **Data Bridge** | Bawa data sendiri: connector CSV/Parquet/SQL lokal dengan schema mapping | Loader lokal Dirilis |
|
||||
| **Options Lab** | Vol surface, dashboard Greeks, explorer payoff/scenario | Tool analitik payoff/scenario **Dirilis**; surface/dashboard Direncanakan |
|
||||
| **Portfolio Studio** | Risk x-ray, constraint, optimizer turnover-aware, catatan rebalance | Optimizer turnover-aware **Dirilis 0.1.11**; sisanya Direncanakan |
|
||||
| **Alpha Zoo** | 475 alpha siap pakai (Qlib 158 + Kakushadze 101 + GTJA 191 + akademik + fundamental) dengan bench satu baris, integrasi agent, dan Web UI | **Dirilis 0.1.8**, diperluas hingga 0.1.12 |
|
||||
| **Alpha Zoo** | 478 alpha siap pakai (Qlib 158 + Kakushadze 101 + GTJA 191 + akademik + fundamental) dengan bench satu baris, integrasi agent, dan Web UI | **Dirilis 0.1.8**, diperluas hingga 0.1.12 |
|
||||
| **Strategy Development Manager** | Daftarkan paper / riset broker sebagai faktor & strategi dengan store persisten + lifecycle decay IC/Sharpe otomatis | **Dirilis 0.1.11** |
|
||||
| **Correlation Regime** | Timeline regime edge-density + hysteresis di `/correlation` — deteksi saat market menyatu menjadi satu blok | **Dirilis 0.1.12** |
|
||||
| **Research Delivery** | Brief terjadwal dan sesi riset live melalui Slack / Telegram / channel IM bergaya email | Scheduler + IM Runtime Dirilis |
|
||||
|
||||
+6
-6
@@ -380,7 +380,7 @@ Vibe-Trading は、金融に関する問いを実行可能な分析へ変換す
|
||||
| **アナリストチームを走らせる** | 投資、クオンツ、暗号資産、マクロ、リスクのワークフロー向けマルチエージェント・リサーチレビュー。 |
|
||||
| **リサーチを IM チャンネルへ接続する** | WebSocket、Telegram、Slack、Discord、Matrix、WhatsApp、Signal、QQ/NapCat、WeChat/WeCom、Feishu/Lark、DingTalk、Teams、email、Mochat から同じ session runtime を CLI、REST、Web UI で管理。 |
|
||||
| **使える artifacts を出力する** | レポート、TradingView Pine Script、TDX、MetaTrader 5、MCP tools、後続リサーチセッション。 |
|
||||
| **事前構築 alpha zoo をベンチ** | 475 個の alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental)に対し、1 行 CLI で IC + IR + alive/reversed/dead 分類を実行 |
|
||||
| **事前構築 alpha zoo をベンチ** | 478 個の alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental)に対し、1 行 CLI で IC + IR + alive/reversed/dead 分類を実行 |
|
||||
| **相関レジームを見抜く** | `/correlation` 面上のエッジ密度 + ヒステリシスのタイムライン。市場が 1 つのブロックに融合するタイミングを示す —— シグナルではなく記述的なリスクコンテキスト。 |
|
||||
|
||||
---
|
||||
@@ -667,9 +667,9 @@ Paper-vs-live is a **structural per-broker runtime guard** (account-id format, h
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>5 つのファミリーに渡る 475 個の事前構築 quant alpha</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>5 つのファミリーに渡る 478 個の事前構築 quant alpha</sub></summary>
|
||||
|
||||
- 🧬 475 個のクロスセクショナル alpha、オペレーター層でルックアヘッドを禁止
|
||||
- 🧬 478 個のクロスセクショナル alpha、オペレーター層でルックアヘッドを禁止
|
||||
- 📈 IC + IR + alive/reversed/dead 分類を 1 つの CLI コマンドで
|
||||
- 🔬 AST 純関数ゲート + 300 行のルックアヘッド sentinel テスト + `pytest-socket` によるネットワーク遮断
|
||||
- 📦 Qlib には Apache-2 帰属表示、各 zoo ごとに `LICENSE.md` で formula を数学的内容として宣言
|
||||
@@ -982,7 +982,7 @@ Vibe-Trading は tool-heavy agent です。skills、backtests、memory、swarms
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 475 個の事前構築 alpha を閲覧;show / bench / compare / export-manifest サブコマンド利用可
|
||||
vibe-trading alpha list # 478 個の事前構築 alpha を閲覧;show / bench / compare / export-manifest サブコマンド利用可
|
||||
vibe-trading playbook list # 定期リサーチのテンプレート 5 本;show / create サブコマンド利用可
|
||||
vibe-trading channels status --local # IM チャンネル設定と install hints を確認
|
||||
vibe-trading provider doctor # 秘匿処理済みの provider/proxy/package 診断を出力
|
||||
@@ -1739,7 +1739,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo Web 検索
|
||||
│ │ │ └── ... # bash、file I/O、factor analysis、options、alpha browser + bench など
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 5 つのファミリーにまたがる 475 個の alpha
|
||||
│ │ ├── factors/ # Alpha Zoo — 5 つのファミリーにまたがる 478 個の alpha
|
||||
│ │ │ ├── base.py # 19 個のオペレーター (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST 限定のメタデータ読み込み + 遅延計算 + sanity gate
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分類
|
||||
@@ -1822,7 +1822,7 @@ Vibe-Trading は **[HKUDS](https://github.com/HKUDS)** agent ecosystem の一部
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | ローカルローダー出荷済み |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Turnover を考慮したオプティマイザは **0.1.11 でリリース済み**;残りは Planned |
|
||||
| **Alpha Zoo** | 475 個の事前構築 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental)、1 行 CLI でベンチ、agent 統合、Web UI | **0.1.8 でリリース済み**、0.1.12 まで拡張 |
|
||||
| **Alpha Zoo** | 478 個の事前構築 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental)、1 行 CLI でベンチ、agent 統合、Web UI | **0.1.8 でリリース済み**、0.1.12 まで拡張 |
|
||||
| **Strategy Development Manager** | Register papers / broker research as factors & strategies with a persistent store + automated IC/Sharpe decay lifecycle | **0.1.11 でリリース済み** |
|
||||
| **Correlation Regime** | Edge-density + hysteresis regime timeline layered on `/correlation` — spot when markets fuse into one bloc | **0.1.12 でリリース済み** |
|
||||
| **Research Delivery** | Slack / Telegram / email-style IM channels 経由の scheduled briefs と live research sessions | スケジューラ + IM Runtime 出荷済み |
|
||||
|
||||
+6
-6
@@ -380,7 +380,7 @@ Vibe-Trading은 금융 질문을 실행 가능한 분석으로 바꾸는 오픈
|
||||
| **애널리스트 팀 실행하기** | 투자, 퀀트, 크립토, 매크로, 리스크 워크플로를 위한 멀티 에이전트 리서치 리뷰. |
|
||||
| **리서치를 IM 채널에 연결하기** | WebSocket, Telegram, Slack, Discord, Matrix, WhatsApp, Signal, QQ/NapCat, WeChat/WeCom, Feishu/Lark, DingTalk, Teams, email, Mochat에서 같은 session runtime을 CLI, REST, Web UI로 관리. |
|
||||
| **사용 가능한 artifacts 만들기** | 리포트, TradingView Pine Script, TDX, MetaTrader 5, MCP tools, 이후 리서치 세션. |
|
||||
| **사전 빌드된 alpha zoo 벤치** | 475개의 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental)에 대해 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행 |
|
||||
| **사전 빌드된 alpha zoo 벤치** | 478개의 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental)에 대해 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행 |
|
||||
| **상관관계 국면 포착하기** | `/correlation` 화면의 엣지 밀도 + 히스테리시스 타임라인으로 시장이 한 덩어리로 움직이기 시작하는 시점을 보여줍니다 — 시그널이 아니라 서술적 리스크 컨텍스트입니다. |
|
||||
|
||||
---
|
||||
@@ -667,9 +667,9 @@ Paper-vs-live는 **구조적 브로커별 런타임 가드**(account-id 형식,
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>5개 패밀리에 걸친 475개 사전 빌드된 quant alpha</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>5개 패밀리에 걸친 478개 사전 빌드된 quant alpha</sub></summary>
|
||||
|
||||
- 🧬 operator 계층에서 lookahead가 금지된 475개 cross-sectional alpha
|
||||
- 🧬 operator 계층에서 lookahead가 금지된 478개 cross-sectional alpha
|
||||
- 📈 한 줄 CLI로 IC + IR + alive/reversed/dead 분류 수행
|
||||
- 🔬 AST 순수성 게이트 + 300-row lookahead sentinel 테스트 + `pytest-socket` 네트워크 kill-switch
|
||||
- 📦 Qlib에 대한 Apache-2 출처 표기, zoo별 `LICENSE.md`에서 수식을 수학적 콘텐츠로 명시
|
||||
@@ -980,7 +980,7 @@ Vibe-Trading은 tool-heavy agent입니다. skills, backtests, memory, swarms가
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 사전 빌드된 475개 alpha 탐색; show / bench / compare / export-manifest 서브커맨드 사용 가능
|
||||
vibe-trading alpha list # 사전 빌드된 478개 alpha 탐색; show / bench / compare / export-manifest 서브커맨드 사용 가능
|
||||
vibe-trading playbook list # 예약 리서치 템플릿 5개; show / create 서브커맨드 사용 가능
|
||||
vibe-trading channels status --local # IM 채널 설정과 설치 힌트 확인
|
||||
vibe-trading provider doctor # 마스킹된 provider/proxy/패키지 진단 출력
|
||||
@@ -1736,7 +1736,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo web search
|
||||
│ │ │ └── ... # bash, file I/O, factor analysis, options, alpha browser + bench, etc.
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo — 5개 패밀리에 걸친 475개 alpha
|
||||
│ │ ├── factors/ # Alpha Zoo — 5개 패밀리에 걸친 478개 alpha
|
||||
│ │ │ ├── base.py # 19개 operator (rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # AST-only 메타데이터 로딩 + lazy compute + sanity gate
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 분류
|
||||
@@ -1819,7 +1819,7 @@ Vibe-Trading은 **[HKUDS](https://github.com/HKUDS)** agent ecosystem의 일부
|
||||
| **Data Bridge** | Bring-your-own data: local CSV/Parquet/SQL connectors with schema mapping | 로컬 로더 출시 |
|
||||
| **Options Lab** | Vol surface, Greeks dashboard, payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray, constraints, turnover-aware optimizer, rebalance notes | Turnover-aware optimizer **0.1.11 출시 완료**; 나머지 Planned |
|
||||
| **Alpha Zoo** | 5개 패밀리에 걸친 475개의 사전 빌드된 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental), 한 줄 CLI 벤치, agent 통합, Web UI | **0.1.8 출시 완료**, 0.1.12까지 확장 |
|
||||
| **Alpha Zoo** | 5개 패밀리에 걸친 478개의 사전 빌드된 alpha 인자(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental), 한 줄 CLI 벤치, agent 통합, Web UI | **0.1.8 출시 완료**, 0.1.12까지 확장 |
|
||||
| **Strategy Development Manager** | 논문 / 브로커 리서치를 영속 store + 자동 IC/Sharpe decay 라이프사이클과 함께 팩터 & 전략으로 등록 | **0.1.11 출시 완료** |
|
||||
| **Correlation Regime** | `/correlation` 위에 얹은 엣지 밀도 + 히스테리시스 레짐 타임라인 — 시장이 하나의 블록으로 융합되는 시점 포착 | **0.1.12 출시 완료** |
|
||||
| **Research Delivery** | Slack / Telegram / email-style IM channels를 통한 예약 brief와 live research sessions | 스케줄러 + IM Runtime 출시 |
|
||||
|
||||
+6
-6
@@ -379,7 +379,7 @@ Vibe-Trading 是一个开源研究工作台,用于把金融问题转化为可
|
||||
| **运行分析师团队** | 面向投资、量化、加密、宏观和风控工作流的多智能体研究评审。 |
|
||||
| **把研究接入 IM 通道** | 通过 WebSocket、Telegram、Slack、Discord、Matrix、WhatsApp、Signal、QQ/NapCat、微信/企业微信、飞书/Lark、钉钉、Teams、email、Mochat,在 CLI、REST 和 Web UI 中管理同一套 session runtime。 |
|
||||
| **交付可用成果** | 报告、TradingView Pine Script、TDX、MetaTrader 5、MCP tools,以及可延续的研究 sessions。 |
|
||||
| **跑预置 alpha zoo 横评** | 475 个 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental),一行 CLI 在你选的 universe 上算 IC + IR + alive/reversed/dead 分类 |
|
||||
| **跑预置 alpha zoo 横评** | 478 个 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + PIT-safe fundamental),一行 CLI 在你选的 universe 上算 IC + IR + alive/reversed/dead 分类 |
|
||||
| **识别相关性状态** | `/correlation` 界面上的边密度 + 迟滞时间线,显示市场何时融合为一个板块——属于描述性风险上下文,而非交易信号。 |
|
||||
|
||||
---
|
||||
@@ -680,9 +680,9 @@ LONGBRIDGE_ACCESS_TOKEN=...
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Alpha Zoo</b> <sub>475 个预置 alpha,覆盖 5 个家族</sub></summary>
|
||||
<summary><b>Alpha Zoo</b> <sub>478 个预置 alpha,覆盖 5 个家族</sub></summary>
|
||||
|
||||
- 🧬 475 个横截面 alpha,算子层即禁用 lookahead
|
||||
- 🧬 478 个横截面 alpha,算子层即禁用 lookahead
|
||||
- 📈 一条 CLI 命令完成 IC + IR + alive/reversed/dead 分类
|
||||
- 🔬 AST 纯函数门禁 + 300 行 lookahead 哨兵测试 + `pytest-socket` 网络阻断
|
||||
- 📦 Qlib 部分附 Apache-2 出处声明;每个 zoo 一份 `LICENSE.md`,声明公式属于数学内容
|
||||
@@ -987,7 +987,7 @@ Vibe-Trading 是高度依赖工具的智能体:skills、backtests、memory 和
|
||||
vibe-trading # interactive TUI
|
||||
vibe-trading run -p "..." # single run
|
||||
vibe-trading serve # API server
|
||||
vibe-trading alpha list # 浏览 475 个预置 alpha;支持 show / bench / compare / export-manifest 子命令
|
||||
vibe-trading alpha list # 浏览 478 个预置 alpha;支持 show / bench / compare / export-manifest 子命令
|
||||
vibe-trading playbook list # 五个定时研究模板;支持 show / create 子命令
|
||||
vibe-trading channels status --local # 检查 IM 通道配置和依赖安装提示
|
||||
vibe-trading provider doctor # 打印脱敏后的 provider/proxy/依赖诊断
|
||||
@@ -1734,7 +1734,7 @@ Vibe-Trading/
|
||||
│ │ │ ├── web_search_tool.py # DuckDuckGo 网络搜索
|
||||
│ │ │ └── ... # bash、文件 I/O、因子分析、期权、alpha 浏览 + 横评等
|
||||
│ │ │
|
||||
│ │ ├── factors/ # Alpha Zoo —— 5 个家族共 475 个 alpha
|
||||
│ │ ├── factors/ # Alpha Zoo —— 5 个家族共 478 个 alpha
|
||||
│ │ │ ├── base.py # 19 个算子(rank/scale/ts_*/delta/decay_linear/safe_div/vwap)
|
||||
│ │ │ ├── registry.py # 纯 AST 元数据加载 + 惰性计算 + sanity 校验
|
||||
│ │ │ ├── bench_runner.py # IC + alive/reversed/dead 分类
|
||||
@@ -1817,7 +1817,7 @@ Vibe-Trading 是 **[HKUDS](https://github.com/HKUDS)** 智能体生态的一部
|
||||
| **Data Bridge** | 自带数据:本地 CSV/Parquet/SQL connectors 与 schema mapping | 本地加载器已发布 |
|
||||
| **Options Lab** | Vol surface、Greeks dashboard、payoff/scenario explorer | Planned |
|
||||
| **Portfolio Studio** | Risk x-ray、constraints、turnover-aware optimizer、rebalance notes | Turnover-aware optimizer **已发布 0.1.11**;其余 Planned |
|
||||
| **Alpha Zoo** | 475 个预置 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental),一行 CLI 跑横评,agent 集成,Web UI 浏览 | **已发布 0.1.8**,延续至 0.1.12 |
|
||||
| **Alpha Zoo** | 478 个预置 alpha 因子(Qlib 158 + Kakushadze 101 + GTJA 191 + academic + fundamental),一行 CLI 跑横评,agent 集成,Web UI 浏览 | **已发布 0.1.8**,延续至 0.1.12 |
|
||||
| **Strategy Development Manager** | 把论文 / 券商研报注册为因子与策略,配持久化 store + 自动化 IC/Sharpe 衰减生命周期 | **已发布 0.1.11** |
|
||||
| **Correlation Regime** | 叠加在 `/correlation` 上的边密度 + 迟滞状态时间线——识别市场何时融合为一个板块 | **已发布 0.1.12** |
|
||||
| **Research Delivery** | 通过 Slack / Telegram / email-style IM channels 发送定时 briefs 与实时研究 sessions | 调度器 + IM Runtime 已发布 |
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: vibe-trading
|
||||
version: 0.1.16
|
||||
description: Professional finance research toolkit — backtesting (11 engines + benchmark comparison panel), factor analysis, Alpha Zoo (475 pre-built alphas across qlib158/alpha101/gtja191/academic/fundamental), options pricing, 90 finance skills, 30 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 29 market-data sources (tushare, yfinance, okx, binance, akshare, baostock, tencent, mootdx, ccxt, futu, mt5, tickerall, local, eastmoney, sina, stooq, yahoo, pykrx, india_broker, qveris, longbridge, nobitex, wallex, plus optional-key finnhub/alphavantage/tiingo/fmp/gildata).
|
||||
description: Professional finance research toolkit — backtesting (11 engines + benchmark comparison panel), factor analysis, Alpha Zoo (478 pre-built alphas across qlib158/alpha101/gtja191/academic/fundamental), options pricing, 90 finance skills, 30 multi-agent swarm teams, Trade Journal analyzer, and Shadow Account (extract → backtest → render) across 29 market-data sources (tushare, yfinance, okx, binance, akshare, baostock, tencent, mootdx, ccxt, futu, mt5, tickerall, local, eastmoney, sina, stooq, yahoo, pykrx, india_broker, qveris, longbridge, nobitex, wallex, plus optional-key finnhub/alphavantage/tiingo/fmp/gildata).
|
||||
dependencies:
|
||||
python: ">=3.11"
|
||||
pip:
|
||||
@@ -23,7 +23,7 @@ mcp:
|
||||
|
||||
# Vibe-Trading
|
||||
|
||||
Professional finance research toolkit with AI-powered backtesting (11 engines), multi-agent teams, 90 specialized skills, the **Alpha Zoo** (475 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic / fundamental with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
Professional finance research toolkit with AI-powered backtesting (11 engines), multi-agent teams, 90 specialized skills, the **Alpha Zoo** (478 pre-built quantitative alphas across qlib158 / alpha101 / gtja191 / academic / fundamental with one-line CLI benchmarking), and the Shadow Account loop — extract your implicit trading rules from a journal, backtest them across A股/港股/美股/crypto, then see where they would have served you better.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -53,7 +53,7 @@ Add to your agent's MCP config:
|
||||
|
||||
### API Key Requirements
|
||||
|
||||
Core research MCP tools work with zero API keys for HK/US/Canada/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (475 pre-built alphas), and all 90 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
|
||||
Core research MCP tools work with zero API keys for HK/US/Canada/crypto. After `pip install`, backtesting, market data, factor analysis, options pricing, chart patterns, web search, document reading, trade journal analysis, shadow-account extraction/backtest/report, the Alpha Zoo (478 pre-built alphas), and all 90 skills are ready to use. IBKR tools require a local TWS / IB Gateway session; `run_swarm` requires an LLM key.
|
||||
|
||||
| Feature | Key needed | When |
|
||||
|---------|-----------|------|
|
||||
@@ -110,13 +110,13 @@ Example workflow:
|
||||
|
||||
Use `list_swarm_presets()` to see all teams, then `run_swarm()` to execute.
|
||||
|
||||
### Alpha Zoo (475 pre-built alphas)
|
||||
### Alpha Zoo (478 pre-built alphas)
|
||||
One-line cross-sectional IC / IR / alive-reversed-dead categorisation across five bundled zoos:
|
||||
- **qlib158** (154 alphas) — Microsoft Qlib's `Alpha158` feature handler, Apache-2.0 with pinned commit SHA.
|
||||
- **alpha101** (101 alphas) — Kakushadze (2015) "101 Formulaic Alphas" (arXiv:1601.00991), written from the paper appendix.
|
||||
- **gtja191** (191 alphas) — Guotai Junan 2014 "191 Short-period Trading Alpha Factors" research report.
|
||||
- **academic** (12 factors) — Fama-French 5 + Carhart momentum + Jegadeesh reversal + George-Hwang 52-week-high + Amihud illiquidity + Harvey-Siddique skew + Frazzini-Pedersen betting-against-beta (price-based proxies) + a correlation-rewiring stability score (from the in-repo correlation-regime skill).
|
||||
- **fundamental** (17 factors) — PIT-safe earnings yield, ROE, gross profitability and asset growth, plus ten SEC XBRL additions: book-to-market, FCF yield, accruals, ROA, CFO yield, operating margin, cash-to-assets, leverage, revenue growth and earnings growth, from daily fundamental panels; and three CN A-share daily-valuation factors (PB, PS_TTM, dividend yield) fed by the new ``val:*`` panel namespace from tushare daily_basic.
|
||||
- **fundamental** (20 factors) — PIT-safe earnings yield, ROE, gross profitability and asset growth, plus ten SEC XBRL additions: book-to-market, FCF yield, accruals, ROA, CFO yield, operating margin, cash-to-assets, leverage, revenue growth and earnings growth, from daily fundamental panels; three CN A-share daily-valuation factors (PB, PS_TTM, dividend yield) fed by the ``val:*`` panel namespace from tushare daily_basic; and three industry-neutralized variants of those CN value factors, z-scored within Shenwan L1 peers against a PIT SW2021 industry tag from tushare index_member_all (membership is point-in-time; the SW2021 taxonomy itself is retroactive).
|
||||
|
||||
Each alpha ships with `__alpha_meta__` (formula LaTeX + theme + universe + warmup + columns required), guarded by an AST purity gate + 300-row lookahead sentinel test. Use the `vibe-trading alpha {list,show,bench,compare,export-manifest}` CLI, the `/alpha/*` REST routes (browser at `/alpha-zoo`), or compose multi-factor signals via `ZooSignalEngine.from_zoo(...)`.
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""CN industry classification loader from Tushare ``index_member_all`` (SW2021).
|
||||
|
||||
Supplies the wide ``sector`` label frame the industry-neutralized CN value
|
||||
alphas z-score against (issue #1789 follow-up #2): one label per symbol per
|
||||
trading day, point-in-time via each membership row's ``in_date``/``out_date``.
|
||||
|
||||
PIT semantics: symbol ``s`` carries label ``L`` on date ``d`` iff a membership
|
||||
row exists with ``in_date <= d`` and (``out_date`` is null OR ``d < out_date``)
|
||||
— ``in_date`` inclusive, ``out_date`` exclusive. If overlapping rows ever claim
|
||||
the same ``(s, d)``, the LATER ``in_date`` wins; on equal ``in_date`` the row
|
||||
with the later (or null = current) ``out_date`` wins. Both rules are fixed so
|
||||
the frame is deterministic regardless of API row order.
|
||||
|
||||
Taxonomy caveat: SW2021 classifications are applied retroactively to history —
|
||||
a name reclassified in 2021 carries its 2021 industry back through the whole
|
||||
window. This is the same class of caveat the csi300 bench already discloses
|
||||
for PIT index membership, not a lookahead in the price/valuation data.
|
||||
|
||||
Endpoint contract (pinned by tests, verified live 2026-10-12):
|
||||
``index_member_all`` accepts ONLY the documented ``l1_code``/``l2_code``/
|
||||
``l3_code``/``ts_code``/``is_new`` filters — passing ``src`` (undocumented)
|
||||
silently returns an empty 200-row-columns frame, so ``src`` is used for
|
||||
``index_classify`` in the truncation fallback only. ``is_new`` defaults to
|
||||
"Y" server-side (current members, no out_date rows), so the historical
|
||||
half ("N") is fetched separately and concatenated — without it every label
|
||||
would be today's classification and the PIT claim above false.
|
||||
|
||||
Truncation guard: ``index_member_all`` documents a 2000-row cap per call.
|
||||
A live full-permission token returns the whole snapshot in one call (~5900
|
||||
current + ~2000 historical rows), but a response of exactly 2000 rows is
|
||||
treated as truncated and refetched per L1 industry (``index_classify`` then
|
||||
one ``index_member_all(l1_code=..., is_new=...)`` call per industry).
|
||||
|
||||
Degradation: a provider failure after backoff warns and returns an all-None
|
||||
frame aligned to ``index`` x ``symbols`` — the caller benches without the
|
||||
sector tag (``requires_sector`` alphas SkipAlpha) instead of crashing. A
|
||||
missing/placeholder ``TUSHARE_TOKEN`` raises ``RuntimeError`` like every
|
||||
sibling loader; no label is ever invented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from backtest.loaders.tushare import (
|
||||
TUSHARE_TOKEN_PLACEHOLDERS,
|
||||
_call_with_backoff,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Shenwan classification levels ``index_member_all`` serves name columns for.
|
||||
INDUSTRY_LEVELS: tuple[str, ...] = ("L1", "L2", "L3")
|
||||
|
||||
_LEVEL_NAME_COLUMN = {"L1": "l1_name", "L2": "l2_name", "L3": "l3_name"}
|
||||
|
||||
#: Documented per-call row cap of ``index_member_all``; a snapshot of exactly
|
||||
#: this size is treated as truncated and triggers the per-L1 refetch.
|
||||
_SNAPSHOT_ROW_CAP = 2000
|
||||
|
||||
#: Sentinel standing in for a null ``out_date`` (still a member). Inside the
|
||||
#: ``datetime64[ns]`` range so int64 comparisons against trading days work.
|
||||
_OPEN_ENDED = pd.Timestamp("2262-01-01")
|
||||
|
||||
|
||||
def validate_industry_params(level: str, src: str) -> str:
|
||||
"""Validate the requested classification level and source.
|
||||
|
||||
Args:
|
||||
level: Shenwan level, one of :data:`INDUSTRY_LEVELS`.
|
||||
src: Classification source, e.g. ``"SW2021"``; must be non-empty.
|
||||
|
||||
Returns:
|
||||
The ``index_member_all`` name column serving ``level``.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``level`` is unknown or ``src`` is empty.
|
||||
"""
|
||||
if level not in INDUSTRY_LEVELS:
|
||||
raise ValueError(
|
||||
f"unsupported industry level {level!r}; expected one of {list(INDUSTRY_LEVELS)}"
|
||||
)
|
||||
if not isinstance(src, str) or not src.strip():
|
||||
raise ValueError(f"src must be a non-empty string, got {src!r}")
|
||||
return _LEVEL_NAME_COLUMN[level]
|
||||
|
||||
|
||||
def _build_api() -> Any:
|
||||
"""Build a Tushare pro API client from the configured token.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ``TUSHARE_TOKEN`` is unset or a placeholder.
|
||||
"""
|
||||
import tushare as ts
|
||||
|
||||
from src.config.accessor import get_env_config
|
||||
|
||||
token = get_env_config().data.tushare_token.strip()
|
||||
if token in TUSHARE_TOKEN_PLACEHOLDERS:
|
||||
raise RuntimeError(
|
||||
"TUSHARE_TOKEN not configured; required for industry classification"
|
||||
)
|
||||
return ts.pro_api(token)
|
||||
|
||||
|
||||
def _fetch_membership_part(api: Any, src: str, is_new: str) -> pd.DataFrame | None:
|
||||
"""Fetch one ``is_new`` half of the snapshot, refetching per L1 if truncated."""
|
||||
frame = _call_with_backoff(api.index_member_all, is_new=is_new)
|
||||
if frame is None or len(frame) != _SNAPSHOT_ROW_CAP:
|
||||
return frame
|
||||
logger.warning(
|
||||
"index_member_all(is_new=%s) returned exactly %d rows (documented per-call cap); "
|
||||
"refetching per L1 industry",
|
||||
is_new,
|
||||
_SNAPSHOT_ROW_CAP,
|
||||
)
|
||||
classify = _call_with_backoff(api.index_classify, level="L1", src=src)
|
||||
if classify is None or classify.empty or "index_code" not in classify.columns:
|
||||
raise RuntimeError(
|
||||
"index_classify served no L1 codes for the per-industry refetch"
|
||||
)
|
||||
parts = []
|
||||
for code in classify["index_code"].astype(str):
|
||||
part = _call_with_backoff(api.index_member_all, l1_code=code, is_new=is_new)
|
||||
if part is not None and not part.empty:
|
||||
parts.append(part)
|
||||
if not parts:
|
||||
return None
|
||||
return pd.concat(parts, ignore_index=True)
|
||||
|
||||
|
||||
def _fetch_membership(api: Any, src: str) -> pd.DataFrame | None:
|
||||
"""Fetch the full PIT membership snapshot (current "Y" + historical "N").
|
||||
|
||||
Args:
|
||||
api: Tushare pro API (real or injected fake).
|
||||
src: Classification source — passed to ``index_classify`` only;
|
||||
``index_member_all`` rejects an undocumented ``src`` with a
|
||||
silently empty response (see the module docstring).
|
||||
|
||||
Returns:
|
||||
The concatenated membership rows, or None if nothing was served.
|
||||
|
||||
Raises:
|
||||
Exception: The endpoint's own exception after backoff — the caller
|
||||
decides whether that degrades or propagates.
|
||||
"""
|
||||
parts = []
|
||||
for is_new in ("Y", "N"):
|
||||
part = _fetch_membership_part(api, src, is_new)
|
||||
if part is not None and not part.empty:
|
||||
parts.append(part)
|
||||
if not parts:
|
||||
return None
|
||||
return pd.concat(parts, ignore_index=True)
|
||||
|
||||
|
||||
def load_industry_frame_with_coverage(
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
start: str,
|
||||
end: str,
|
||||
index: pd.DatetimeIndex,
|
||||
api: Any | None = None,
|
||||
level: str = "L1",
|
||||
src: str = "SW2021",
|
||||
) -> tuple[pd.DataFrame, float]:
|
||||
"""Load PIT industry labels as a wide frame plus a coverage fraction.
|
||||
|
||||
Args:
|
||||
symbols: Panel symbols; only names present in the membership data get
|
||||
a label, the rest stay NaN.
|
||||
start: Period start (YYYY-MM-DD). Accepted for loader-contract
|
||||
symmetry with ``valuation_loader``; PIT evaluation uses the actual
|
||||
``index`` dates, so an index reaching outside the window is still
|
||||
labelled correctly.
|
||||
end: Period end (YYYY-MM-DD), same contract note as ``start``.
|
||||
index: Price-panel trading-day index the frame aligns to.
|
||||
api: Optional pre-built Tushare pro API (tests inject fakes here).
|
||||
level: Shenwan level, one of :data:`INDUSTRY_LEVELS`.
|
||||
src: Classification source, e.g. ``"SW2021"``.
|
||||
|
||||
Returns:
|
||||
``(frame, coverage)`` — ``frame`` is ``dates x symbols`` with the
|
||||
industry NAME (str) where a membership row covers the date and NaN
|
||||
where none does (never an invented label); ``coverage`` is the
|
||||
fraction of ``symbols`` with at least one label anywhere in the frame.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``level`` or ``src`` is invalid.
|
||||
RuntimeError: If no API is injected and ``TUSHARE_TOKEN`` is missing.
|
||||
"""
|
||||
name_column = validate_industry_params(level, src)
|
||||
columns = list(symbols)
|
||||
empty = pd.DataFrame(np.nan, index=index, columns=columns, dtype=object)
|
||||
if not columns or len(index) == 0:
|
||||
return empty, 0.0
|
||||
logger.debug("industry frame requested for %s..%s (%d symbols)", start, end, len(columns))
|
||||
|
||||
if api is None:
|
||||
api = _build_api()
|
||||
try:
|
||||
membership = _fetch_membership(api, src)
|
||||
except Exception as exc: # noqa: BLE001 - degrade, never crash the caller
|
||||
logger.warning(
|
||||
"index_member_all industry fetch failed (%s); returning unmapped frame", exc
|
||||
)
|
||||
return empty, 0.0
|
||||
|
||||
if (
|
||||
membership is None
|
||||
or membership.empty
|
||||
or "ts_code" not in membership.columns
|
||||
or name_column not in membership.columns
|
||||
# Without the PIT interval columns no membership can be established —
|
||||
# degrade rather than guess a mapping from names alone.
|
||||
or "in_date" not in membership.columns
|
||||
or "out_date" not in membership.columns
|
||||
):
|
||||
logger.warning(
|
||||
"index_member_all served no usable membership rows (src=%s, level=%s)",
|
||||
src,
|
||||
level,
|
||||
)
|
||||
return empty, 0.0
|
||||
|
||||
rows = membership[membership["ts_code"].isin(columns)].copy()
|
||||
in_dt = pd.to_datetime(rows.get("in_date"), format="%Y%m%d", errors="coerce")
|
||||
out_dt = pd.to_datetime(rows.get("out_date"), format="%Y%m%d", errors="coerce")
|
||||
labels = rows[name_column]
|
||||
# A row with no in_date can never satisfy in_date <= d, and a row with no
|
||||
# label has nothing to serve — dropped, never defaulted.
|
||||
usable = in_dt.notna() & labels.notna() & (labels.astype(str).str.strip() != "")
|
||||
rows, in_dt, labels = rows[usable], in_dt[usable], labels[usable]
|
||||
out_dt = out_dt[usable].fillna(_OPEN_ENDED)
|
||||
if rows.empty:
|
||||
return empty, 0.0
|
||||
|
||||
# Later in_date wins, then later/open out_date: a fixed sort makes the
|
||||
# per-date winner independent of API row order (dict(zip) keeps the last).
|
||||
order = np.lexsort((out_dt.to_numpy(dtype="int64"), in_dt.to_numpy(dtype="int64")))
|
||||
codes = rows["ts_code"].to_numpy()[order]
|
||||
ordered_labels = labels.to_numpy()[order]
|
||||
in_ns = in_dt.to_numpy(dtype="int64")[order]
|
||||
out_ns = out_dt.to_numpy(dtype="int64")[order]
|
||||
|
||||
position = {symbol: j for j, symbol in enumerate(columns)}
|
||||
values = np.full((len(index), len(columns)), np.nan, dtype=object)
|
||||
for i, day_ns in enumerate(index.asi8):
|
||||
applicable = (in_ns <= day_ns) & (day_ns < out_ns)
|
||||
if not applicable.any():
|
||||
continue
|
||||
for symbol, label in dict(zip(codes[applicable], ordered_labels[applicable])).items():
|
||||
values[i, position[symbol]] = label
|
||||
|
||||
frame = pd.DataFrame(values, index=index, columns=columns)
|
||||
coverage = float(frame.notna().any(axis=0).mean())
|
||||
if coverage < 1.0:
|
||||
logger.warning(
|
||||
"industry coverage %.1f%% of %d requested symbols (src=%s, level=%s)",
|
||||
coverage * 100,
|
||||
len(columns),
|
||||
src,
|
||||
level,
|
||||
)
|
||||
return frame, coverage
|
||||
|
||||
|
||||
def load_industry_frame(
|
||||
*,
|
||||
symbols: Sequence[str],
|
||||
start: str,
|
||||
end: str,
|
||||
index: pd.DatetimeIndex,
|
||||
api: Any | None = None,
|
||||
level: str = "L1",
|
||||
src: str = "SW2021",
|
||||
) -> pd.DataFrame:
|
||||
"""Load PIT industry labels as a wide ``dates x symbols`` frame.
|
||||
|
||||
Thin wrapper over :func:`load_industry_frame_with_coverage` for callers
|
||||
that do not need the coverage fraction; same contract, same degradation.
|
||||
"""
|
||||
frame, _ = load_industry_frame_with_coverage(
|
||||
symbols=symbols, start=start, end=end, index=index, api=api, level=level, src=src
|
||||
)
|
||||
return frame
|
||||
+16
-1
@@ -896,6 +896,7 @@ def alpha_bench(
|
||||
alpha_id: str | None = None,
|
||||
zoo: str | None = None,
|
||||
top: int = 20,
|
||||
horizon: int = 1,
|
||||
output_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Benchmark one Alpha Zoo alpha or a complete zoo on a universe.
|
||||
@@ -906,6 +907,10 @@ def alpha_bench(
|
||||
alpha_id: Optional single alpha id; mutually exclusive with ``zoo``.
|
||||
zoo: Optional zoo id; mutually exclusive with ``alpha_id``.
|
||||
top: Number of top-ranked alphas to include in the report.
|
||||
horizon: Forward-return horizon in bars (default 1 = next-bar).
|
||||
``h > 1`` computes h-bar forward returns and samples IC rows
|
||||
non-overlapping (end-anchored); 21 ≈ monthly, the conventional
|
||||
horizon for fundamental factors. Range 1-252.
|
||||
output_dir: Optional directory for the generated HTML report.
|
||||
"""
|
||||
if not alpha_id and not zoo:
|
||||
@@ -928,7 +933,7 @@ def alpha_bench(
|
||||
try:
|
||||
from datetime import date
|
||||
|
||||
from src.tools.alpha_bench_tool import _parse_period
|
||||
from src.tools.alpha_bench_tool import _MAX_HORIZON, _parse_period
|
||||
|
||||
start_raw, end_raw = _parse_period(period)
|
||||
start_date = date.fromisoformat(start_raw)
|
||||
@@ -957,10 +962,20 @@ def alpha_bench(
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
if isinstance(horizon, bool) or not isinstance(horizon, int) or not 1 <= horizon <= _MAX_HORIZON:
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"error": f"MCP alpha_bench horizon must be an integer between 1 and {_MAX_HORIZON}",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"universe": universe,
|
||||
"period": period,
|
||||
"top": top,
|
||||
"horizon": horizon,
|
||||
}
|
||||
if alpha_id is not None:
|
||||
params["alpha_id"] = alpha_id
|
||||
|
||||
@@ -96,6 +96,75 @@ def zscore(df: pd.DataFrame) -> pd.DataFrame:
|
||||
return result.replace([np.inf, -np.inf], np.nan)
|
||||
|
||||
|
||||
def zscore_by_group(df: pd.DataFrame, groups: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Per-row within-group z-score (axis=1 groups, sample std, ddof=1).
|
||||
|
||||
The z-scoring sibling of the per-row sector demean the industry-neutralized
|
||||
alpha101 alphas use: instead of one cross-sectional mean/std per date, each
|
||||
label group in the row gets its own. One difference is deliberate — there is
|
||||
NO global fallback. A cell whose group cannot produce a statistic stays NaN
|
||||
rather than silently degrading to a whole-cross-section z-score wearing an
|
||||
industry-neutral label.
|
||||
|
||||
Contract:
|
||||
* ``groups`` is a wide label frame with the SAME shape as ``df``,
|
||||
positionally aligned (same precedent as the alpha101 ``sector`` tag);
|
||||
labels may be any hashable value (str, int) and are compared by
|
||||
position, never by index/column join.
|
||||
* Per row: within each label group, ``(x - group_mean) / group_std``
|
||||
with ``ddof=1`` over the group's finite observations.
|
||||
* A group with fewer than two finite observations on that row → NaN for
|
||||
that group's cells (no statistic exists); a zero-variance group → NaN
|
||||
(never inf, never silent zero).
|
||||
* NaN input cells stay NaN; cells whose label is None/NaN → NaN; ±inf
|
||||
inputs are scrubbed to NaN before grouping.
|
||||
* Output keeps ``df``'s shape, index and columns exactly.
|
||||
|
||||
Args:
|
||||
df: Wide numeric frame (dates x symbols).
|
||||
groups: Wide label frame, same shape as ``df``, positionally aligned.
|
||||
|
||||
Returns:
|
||||
Wide frame of within-group z-scores, NaN wherever the contract above
|
||||
refuses to produce a number.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``groups`` does not have the same shape as ``df``.
|
||||
"""
|
||||
if df.shape != groups.shape:
|
||||
raise ValueError(
|
||||
f"zscore_by_group: groups shape {groups.shape} != df shape {df.shape}; "
|
||||
"the label frame must be positionally aligned with the value frame"
|
||||
)
|
||||
values = _as_float(df).to_numpy(dtype=np.float64, na_value=np.nan).copy()
|
||||
# ±inf is not an observation: scrub before it poisons a group mean/std.
|
||||
values[~np.isnan(values) & ~np.isfinite(values)] = np.nan
|
||||
labels = groups.to_numpy()
|
||||
# Per-row python/numpy loop, same mechanics as the alpha101 precedent —
|
||||
# correctness over cleverness; cross-sections are a few hundred cells wide.
|
||||
for i in range(values.shape[0]):
|
||||
row = values[i]
|
||||
label_row = labels[i]
|
||||
unlabeled = pd.isna(label_row)
|
||||
if unlabeled.any():
|
||||
row[unlabeled] = np.nan
|
||||
for tag in pd.unique(label_row):
|
||||
if tag is None or (isinstance(tag, float) and np.isnan(tag)) or pd.isna(tag):
|
||||
continue # handled by the unlabeled mask above
|
||||
mask = label_row == tag
|
||||
vals = row[mask]
|
||||
finite = vals[~np.isnan(vals)]
|
||||
if finite.size < 2:
|
||||
row[mask] = np.nan
|
||||
continue
|
||||
std = finite.std(ddof=1)
|
||||
if not np.isfinite(std) or std <= 0:
|
||||
row[mask] = np.nan
|
||||
continue
|
||||
row[mask] = (vals - finite.mean()) / std
|
||||
return pd.DataFrame(values, index=df.index, columns=df.columns)
|
||||
|
||||
|
||||
def scale(df: pd.DataFrame, a: float = 1.0) -> pd.DataFrame:
|
||||
"""Per-row L1 normalize so sum of absolute values equals ``a``.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ from src.factors.registry import (
|
||||
SkipAlpha,
|
||||
get_default_registry,
|
||||
)
|
||||
from src.tools.alpha_bench_tool import _compute_forward_returns, _load_universe_panel, _prepare_bench_panel, _validate_bench_data_requirements
|
||||
from src.tools.alpha_bench_tool import _MAX_HORIZON, _compute_forward_returns, _load_universe_panel, _prepare_bench_panel, _split_alphas_by_universe_support, _validate_bench_data_requirements
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,7 +108,12 @@ def t_stat(ic_mean: float, ic_std: float, n: int) -> float:
|
||||
|
||||
|
||||
def categorise(row: dict[str, Any]) -> str:
|
||||
"""Bucket a per-alpha row into alive / reversed / dead."""
|
||||
"""Bucket a per-alpha row into alive / reversed / dead.
|
||||
|
||||
Thresholds are calibrated on daily next-bar ICs; at horizon h > 1 the
|
||||
|IC| > 0.02 floor is a looser bar (h-bar ICs are naturally larger in
|
||||
magnitude). The run envelope states its own horizon.
|
||||
"""
|
||||
ic_mean = row["ic_mean"]
|
||||
pos = row["ic_positive_ratio"]
|
||||
t = t_stat(ic_mean, row["ic_std"], row["ic_count"])
|
||||
@@ -143,6 +148,7 @@ def run_bench(
|
||||
registry: Registry | None = None,
|
||||
only: Iterable[str] | None = None,
|
||||
on_stage: Callable[[str], None] | None = None,
|
||||
horizon: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a bench end-to-end and return the API-shaped summary.
|
||||
|
||||
@@ -161,11 +167,17 @@ def run_bench(
|
||||
alpha list is restricted to this set — used by ``alpha compare`` to
|
||||
bench just a handful of named alphas instead of the whole zoo. Ids
|
||||
not registered under ``zoo`` are silently dropped from the subset.
|
||||
horizon: Forward-return horizon in bars (default 1 = next-bar,
|
||||
unchanged behaviour). ``h > 1`` computes h-bar forward returns
|
||||
sampled non-overlapping (end-anchored) so the naive-iid t-stat
|
||||
stays honest; 21 ≈ monthly, the conventional horizon for
|
||||
fundamental factors.
|
||||
|
||||
Returns:
|
||||
Dict with keys: ``status``, ``zoo``, ``universe``, ``period``,
|
||||
``n_alphas_tested``, ``n_skipped``, ``alive``, ``reversed``, ``dead``,
|
||||
``by_theme``, ``top5_by_ir``, ``dead_examples``, ``wall_seconds`` —
|
||||
``horizon``, ``forward_return_sampling``, ``n_alphas_tested``,
|
||||
``n_skipped``, ``alive``, ``reversed``, ``dead``, ``by_theme``,
|
||||
``top5_by_ir``, ``dead_examples``, ``wall_seconds`` —
|
||||
and on failure: ``status="error"``, ``error``.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
@@ -174,8 +186,24 @@ def run_bench(
|
||||
"zoo": zoo,
|
||||
"universe": universe,
|
||||
"period": period,
|
||||
"horizon": horizon,
|
||||
"forward_return_sampling": (
|
||||
"non_overlapping"
|
||||
if isinstance(horizon, int) and not isinstance(horizon, bool) and horizon > 1
|
||||
else "daily"
|
||||
),
|
||||
}
|
||||
|
||||
# Fail fast before any panel load (network): same 1.._MAX_HORIZON contract
|
||||
# the tool and MCP wrapper enforce.
|
||||
if isinstance(horizon, bool) or not isinstance(horizon, int) or not 1 <= horizon <= _MAX_HORIZON:
|
||||
entry["status"] = "error"
|
||||
entry["error"] = (
|
||||
f"horizon must be an integer between 1 and {_MAX_HORIZON}, got {horizon!r}"
|
||||
)
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
reg = registry if registry is not None else get_default_registry()
|
||||
alpha_ids = reg.list(zoo=zoo)
|
||||
if not alpha_ids:
|
||||
@@ -193,6 +221,25 @@ def run_bench(
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
alpha_ids, universe_excluded = _split_alphas_by_universe_support(reg, alpha_ids, universe)
|
||||
if universe_excluded:
|
||||
entry["universe_excluded"] = universe_excluded
|
||||
logger.info(
|
||||
"bench: %d alpha(s) excluded on %s — fund:* inputs need SEC US filings (sp500): %s",
|
||||
len(universe_excluded),
|
||||
universe,
|
||||
", ".join(universe_excluded[:10]),
|
||||
)
|
||||
if not alpha_ids:
|
||||
entry["status"] = "error"
|
||||
entry["error"] = (
|
||||
f"all {len(universe_excluded)} selected alphas require SEC US filings "
|
||||
f"(fund:* inputs); the {universe!r} universe cannot serve them. "
|
||||
"Choose the S&P 500 universe."
|
||||
)
|
||||
entry["wall_seconds"] = round(time.monotonic() - start, 2)
|
||||
return entry
|
||||
|
||||
try:
|
||||
_validate_bench_data_requirements(reg, alpha_ids, universe)
|
||||
if on_stage:
|
||||
@@ -208,7 +255,7 @@ def run_bench(
|
||||
panel = _prepare_bench_panel(reg, alpha_ids, panel, universe, period)
|
||||
if on_stage:
|
||||
on_stage("preparing_returns")
|
||||
return_df = _compute_forward_returns(panel)
|
||||
return_df = _compute_forward_returns(panel, horizon=horizon)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
entry["status"] = "error"
|
||||
entry["error"] = f"forward returns failed: {exc}"
|
||||
|
||||
@@ -63,7 +63,7 @@ from src.factors.registry import (
|
||||
SkipAlpha,
|
||||
get_default_registry,
|
||||
)
|
||||
from src.tools.alpha_bench_tool import _compute_forward_returns, _load_universe_panel
|
||||
from src.tools.alpha_bench_tool import _MAX_HORIZON, _compute_forward_returns, _load_universe_panel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -325,6 +325,7 @@ def run_bench_strict(
|
||||
training_cutoff: str | None = None,
|
||||
thresholds: StrictThresholds | None = None,
|
||||
top: int = 20,
|
||||
horizon: int = 1,
|
||||
on_progress: ProgressCb | None = None,
|
||||
registry: Registry | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -352,6 +353,11 @@ def run_bench_strict(
|
||||
Harvey-Liu-Zhu (2016) multiple-testing correction when bench
|
||||
covers a large zoo.
|
||||
top: How many top-IR alphas to keep in summary lists.
|
||||
horizon: Forward-return horizon in bars (default 1 = next-bar,
|
||||
unchanged behaviour). ``h > 1`` computes h-bar forward returns
|
||||
sampled non-overlapping (end-anchored) so the naive-iid t-stats
|
||||
stay honest; 21 ≈ monthly, the conventional horizon for
|
||||
fundamental factors.
|
||||
on_progress: Optional ``(n_done, n_total, alpha_id)`` callback.
|
||||
registry: Optional pre-built registry for tests.
|
||||
|
||||
@@ -395,6 +401,12 @@ def run_bench_strict(
|
||||
"zoo": zoo,
|
||||
"universe": universe,
|
||||
"period": period,
|
||||
"horizon": horizon,
|
||||
"forward_return_sampling": (
|
||||
"non_overlapping"
|
||||
if isinstance(horizon, int) and not isinstance(horizon, bool) and horizon > 1
|
||||
else "daily"
|
||||
),
|
||||
"random_control": random_control,
|
||||
"n_random_seeds": effective_seeds,
|
||||
"oos_split": effective_oos_split,
|
||||
@@ -430,6 +442,13 @@ def run_bench_strict(
|
||||
if training_cutoff is not None and oos_split is not None:
|
||||
return _finish_error("pass either training_cutoff or oos_split, not both")
|
||||
|
||||
# Fail fast before any panel load (network): same 1.._MAX_HORIZON contract
|
||||
# the tool and MCP wrapper enforce.
|
||||
if isinstance(horizon, bool) or not isinstance(horizon, int) or not 1 <= horizon <= _MAX_HORIZON:
|
||||
return _finish_error(
|
||||
f"horizon must be an integer between 1 and {_MAX_HORIZON}, got {horizon!r}"
|
||||
)
|
||||
|
||||
reg = registry if registry is not None else get_default_registry()
|
||||
alpha_ids = reg.list(zoo=zoo)
|
||||
if not alpha_ids:
|
||||
@@ -441,7 +460,7 @@ def run_bench_strict(
|
||||
return _finish_error(f"universe load failed: {exc}")
|
||||
|
||||
try:
|
||||
return_df = _compute_forward_returns(panel)
|
||||
return_df = _compute_forward_returns(panel, horizon=horizon)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _finish_error(f"forward returns failed: {exc}")
|
||||
|
||||
|
||||
@@ -492,6 +492,8 @@ def _run_all_zoos_with_progress(
|
||||
total_alphas = sum(len(reg.list(zoo=z)) for z in zoos)
|
||||
all_rows: list[dict[str, Any]] = []
|
||||
all_skipped: list[dict[str, Any]] = []
|
||||
all_excluded: list[str] = []
|
||||
run_config: dict[str, Any] = {}
|
||||
|
||||
def _run_zoo(z: str, base: int, update: Callable[[int, str], None] | None) -> None:
|
||||
cb: Callable[[int, int, str], None] | None = None
|
||||
@@ -506,6 +508,10 @@ def _run_all_zoos_with_progress(
|
||||
on_progress=cb,
|
||||
registry=reg,
|
||||
)
|
||||
if not run_config and "horizon" in sub:
|
||||
run_config["horizon"] = sub["horizon"]
|
||||
run_config["forward_return_sampling"] = sub.get("forward_return_sampling")
|
||||
all_excluded.extend(sub.get("universe_excluded", []) or [])
|
||||
if sub.get("status") == "ok":
|
||||
all_rows.extend(sub.get("rows", []) or [])
|
||||
all_skipped.extend(sub.get("skipped", []) or [])
|
||||
@@ -549,6 +555,8 @@ def _run_all_zoos_with_progress(
|
||||
"skipped": all_skipped,
|
||||
"n_alphas_tested": len(all_rows),
|
||||
"n_skipped": len(all_skipped),
|
||||
**run_config,
|
||||
**({"universe_excluded": all_excluded} if all_excluded else {}),
|
||||
"wall_seconds": round(time.monotonic() - start_ts, 2),
|
||||
**({"error": "no alphas produced IC across any zoo"} if not all_rows else {}),
|
||||
}
|
||||
@@ -563,6 +571,17 @@ def cmd_alpha_bench(args: argparse.Namespace) -> int:
|
||||
"""
|
||||
_load_cli_env()
|
||||
try:
|
||||
from src.tools.alpha_bench_tool import _MAX_HORIZON
|
||||
|
||||
horizon = getattr(args, "horizon", 1)
|
||||
horizon = 1 if horizon is None else int(horizon)
|
||||
if not 1 <= horizon <= _MAX_HORIZON:
|
||||
# Usage error → argparse's exit-code convention (2), not a traceback.
|
||||
_err(
|
||||
f"alpha bench: --horizon must be an integer in 1..{_MAX_HORIZON} "
|
||||
f"(got {horizon})."
|
||||
)
|
||||
return 2
|
||||
if (
|
||||
getattr(args, "oos_split", None)
|
||||
or getattr(args, "training_cutoff", None)
|
||||
@@ -583,9 +602,12 @@ def cmd_alpha_bench(args: argparse.Namespace) -> int:
|
||||
n_random_seeds=max(1, int(getattr(args, "random_seeds", 5) or 5)),
|
||||
oos_split=getattr(args, "oos_split", None),
|
||||
training_cutoff=getattr(args, "training_cutoff", None),
|
||||
horizon=horizon,
|
||||
)
|
||||
else:
|
||||
from src.factors.bench_runner import run_bench
|
||||
from src.factors.bench_runner import run_bench as _run_bench_plain
|
||||
|
||||
run_bench = functools.partial(_run_bench_plain, horizon=horizon)
|
||||
except ImportError as exc:
|
||||
_err(f"alpha bench failed: bench_runner unavailable ({exc})")
|
||||
return 1
|
||||
@@ -780,6 +802,12 @@ def cmd_alpha_bench(args: argparse.Namespace) -> int:
|
||||
"top": top_rows,
|
||||
"wall_seconds": result.get("wall_seconds"),
|
||||
}
|
||||
# Run-config disclosure: a saved envelope must state its own horizon,
|
||||
# sampling and any universe-based exclusions — numbers that show
|
||||
# where they came from.
|
||||
for key in ("horizon", "forward_return_sampling", "universe_excluded"):
|
||||
if key in result:
|
||||
envelope[key] = result[key]
|
||||
if getattr(args, "strict", False):
|
||||
envelope["strict"] = True
|
||||
for key in ("confirmed_alive", "train_only", "reversed_strict", "noise"):
|
||||
@@ -944,6 +972,27 @@ def cmd_alpha_export_manifest(args: argparse.Namespace) -> int:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _horizon_arg(value: str) -> int:
|
||||
"""argparse type for ``--horizon``: integer in 1.._MAX_HORIZON.
|
||||
|
||||
Raising ``ArgumentTypeError`` makes argparse exit with its usage-error
|
||||
code (2) instead of letting a bad value reach the bench engine.
|
||||
"""
|
||||
from src.tools.alpha_bench_tool import _MAX_HORIZON
|
||||
|
||||
try:
|
||||
ivalue = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"--horizon expects an integer in 1..{_MAX_HORIZON}, got {value!r}"
|
||||
) from None
|
||||
if not 1 <= ivalue <= _MAX_HORIZON:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"--horizon must be in 1..{_MAX_HORIZON} (got {ivalue})"
|
||||
)
|
||||
return ivalue
|
||||
|
||||
|
||||
_DISPATCH: dict[str, Callable[[argparse.Namespace], int]] = {
|
||||
"list": cmd_alpha_list,
|
||||
"show": cmd_alpha_show,
|
||||
@@ -1036,6 +1085,17 @@ def add_subparser(subparsers: Any) -> argparse.ArgumentParser:
|
||||
help="Period spec: YYYY-YYYY or YYYY-MM-DD/YYYY-MM-DD (e.g. 2020-2025)",
|
||||
)
|
||||
p_bench.add_argument("--top", type=int, default=20, help="Top-N alphas to keep (default: 20)")
|
||||
p_bench.add_argument(
|
||||
"--horizon",
|
||||
type=_horizon_arg,
|
||||
default=1,
|
||||
help=(
|
||||
"Forward-return horizon in bars, range 1-252 (default: 1 = next-bar). "
|
||||
"h>1 computes h-bar forward returns and samples IC non-overlapping "
|
||||
"(end-anchored); 21 ≈ monthly, the conventional horizon for "
|
||||
"fundamental factors."
|
||||
),
|
||||
)
|
||||
p_bench.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
|
||||
@@ -166,6 +166,13 @@ def compare_alphas(
|
||||
row.setdefault("zoo", zoo)
|
||||
rows.append(row)
|
||||
skipped.extend(sub.get("skipped", []) or [])
|
||||
skipped.extend(
|
||||
{
|
||||
"id": aid,
|
||||
"reason": f"excluded on {universe}: fund:* inputs need SEC US filings (sp500)",
|
||||
}
|
||||
for aid in sub.get("universe_excluded", []) or []
|
||||
)
|
||||
else:
|
||||
reason = sub.get("error", "bench failed")
|
||||
skipped.extend({"id": aid, "reason": reason} for aid in zids)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Industry-neutralized fundamental dividend-yield (TTM) value factor (CN daily market data)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.factors.base import zscore_by_group
|
||||
|
||||
__alpha_meta__ = {
|
||||
"id": "fund_dividend_yield_ind_neutral",
|
||||
"nickname": "Dividend yield TTM industry-neutral - within-industry dv_ttm (daily market data)",
|
||||
"theme": ["value"],
|
||||
"formula_latex": r"\mathrm{zscore}_{x\mid\mathrm{industry}}(\mathrm{DY_{TTM}})",
|
||||
"columns_required": ["val:dv_ttm"],
|
||||
"requires_sector": True,
|
||||
"universe": ["equity_cn"],
|
||||
"frequency": ["1d"],
|
||||
"decay_horizon": 252,
|
||||
"min_warmup_bars": 1,
|
||||
"notes": (
|
||||
"Industry-neutralized trailing dividend yield: each name is z-scored "
|
||||
"against its Shenwan level-1 industry peers instead of the whole "
|
||||
"cross-section (sector tag from tushare index_member_all SW2021 "
|
||||
"membership, point-in-time via in_date/out_date). This removes the "
|
||||
"industry confounding that dominates raw A-share dividend yield — "
|
||||
"mature banks and utilities pay structurally high dividends while "
|
||||
"growth industries pay almost none, so a raw high-yield screen is "
|
||||
"largely an industry bet. High dividend yield relative to industry "
|
||||
"peers scores high; dv_ttm is reported in percent units and "
|
||||
"cross-sectional z-scoring is scale-invariant, so no unit "
|
||||
"conversion is applied. A name with no industry label on a date "
|
||||
"stays NaN and is excluded from that date's cross-section; an "
|
||||
"industry group with fewer than two labelled names cannot form a "
|
||||
"within-group z-score and stays NaN. Keyed by trade_date, so "
|
||||
"PIT-safe by construction with no filing lag. Membership is "
|
||||
"point-in-time but the SW2021 taxonomy itself is retroactive — a "
|
||||
"name reclassified in 2021 carries its 2021 industry back through "
|
||||
"history. Lineage: "
|
||||
"industry-neutral variant of fund_dividend_yield (#1789 follow-up)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute(panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Return within-industry z-scored trailing dividend yield."""
|
||||
return zscore_by_group(panel["val:dv_ttm"], panel["sector"])
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Industry-neutralized fundamental price-to-book value factor (CN daily market data)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.factors.base import zscore_by_group
|
||||
|
||||
__alpha_meta__ = {
|
||||
"id": "fund_pb_ind_neutral",
|
||||
"nickname": "P/B industry-neutral - inverse within-industry price-to-book (daily market data)",
|
||||
"theme": ["value"],
|
||||
"formula_latex": r"-\mathrm{zscore}_{x\mid\mathrm{industry}}(\mathrm{PB})",
|
||||
"columns_required": ["val:pb"],
|
||||
"requires_sector": True,
|
||||
"universe": ["equity_cn"],
|
||||
"frequency": ["1d"],
|
||||
"decay_horizon": 252,
|
||||
"min_warmup_bars": 1,
|
||||
"notes": (
|
||||
"Nonpositive multiples are excluded rather than ranked as cheap. "
|
||||
"Industry-neutralized inverse price-to-book: each name is z-scored "
|
||||
"against its Shenwan level-1 industry peers instead of the whole "
|
||||
"cross-section (sector tag from tushare index_member_all SW2021 "
|
||||
"membership, point-in-time via in_date/out_date). This removes the "
|
||||
"industry confounding that dominates raw A-share PB — banks and "
|
||||
"cyclicals are structurally cheap on book value while technology "
|
||||
"names are structurally expensive, so a raw low-PB screen is largely "
|
||||
"an industry bet. Low PB relative to industry peers scores high. A "
|
||||
"name with no industry label on a date stays NaN and is excluded "
|
||||
"from that date's cross-section; an industry group with fewer than "
|
||||
"two labelled names cannot form a within-group z-score and stays "
|
||||
"NaN. Keyed by trade_date, so PIT-safe by construction with no "
|
||||
"filing lag. Membership is point-in-time but the SW2021 taxonomy "
|
||||
"itself is retroactive — a name reclassified in 2021 carries its "
|
||||
"2021 industry back through history. Lineage: industry-neutral "
|
||||
"variant of fund_pb (#1789 "
|
||||
"follow-up)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute(panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Return within-industry z-scored inverse price-to-book."""
|
||||
values = panel["val:pb"]
|
||||
return -zscore_by_group(values.where(values > 0), panel["sector"])
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Industry-neutralized fundamental price-to-sales (TTM) value factor (CN daily market data)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.factors.base import zscore_by_group
|
||||
|
||||
__alpha_meta__ = {
|
||||
"id": "fund_ps_ttm_ind_neutral",
|
||||
"nickname": "P/S TTM industry-neutral - inverse within-industry price-to-sales (daily market data)",
|
||||
"theme": ["value"],
|
||||
"formula_latex": r"-\mathrm{zscore}_{x\mid\mathrm{industry}}(\mathrm{PS_{TTM}})",
|
||||
"columns_required": ["val:ps_ttm"],
|
||||
"requires_sector": True,
|
||||
"universe": ["equity_cn"],
|
||||
"frequency": ["1d"],
|
||||
"decay_horizon": 252,
|
||||
"min_warmup_bars": 1,
|
||||
"notes": (
|
||||
"Nonpositive multiples are excluded rather than ranked as cheap. "
|
||||
"Industry-neutralized inverse trailing price-to-sales: each name is "
|
||||
"z-scored against its Shenwan level-1 industry peers instead of the "
|
||||
"whole cross-section (sector tag from tushare index_member_all "
|
||||
"SW2021 membership, point-in-time via in_date/out_date). This "
|
||||
"removes the industry confounding that dominates raw A-share PS — "
|
||||
"software and platform names carry structurally high margins and "
|
||||
"high PS while distributors and manufacturers are structurally "
|
||||
"low, so a raw low-PS screen is largely an industry bet. Low PS "
|
||||
"relative to industry peers scores high, and PS stays informative "
|
||||
"for loss-making names where earnings yield fails. A name with no "
|
||||
"industry label on a date stays NaN and is excluded from that "
|
||||
"date's cross-section; an industry group with fewer than two "
|
||||
"labelled names cannot form a within-group z-score and stays NaN. "
|
||||
"Keyed by trade_date, so PIT-safe by construction with no filing "
|
||||
"lag. Membership is point-in-time but the SW2021 taxonomy itself "
|
||||
"is retroactive — a name reclassified in 2021 carries its 2021 "
|
||||
"industry back through history. Lineage: industry-neutral variant "
|
||||
"of fund_ps_ttm (#1789 "
|
||||
"follow-up)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute(panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Return within-industry z-scored inverse price-to-sales (TTM)."""
|
||||
values = panel["val:ps_ttm"]
|
||||
return -zscore_by_group(values.where(values > 0), panel["sector"])
|
||||
@@ -465,6 +465,11 @@ def _load_csi300_panel(start: str, end: str) -> dict[str, pd.DataFrame]:
|
||||
# Prices are corporate-action adjusted; raw pro.daily is not.
|
||||
"price_adjustment": "qfq",
|
||||
"dropped_unadjustable": len(dropped),
|
||||
# Sector labels attach later, outside the pickled price panel (see
|
||||
# _attach_sector_column); null/0.0 until then, and they stay that way
|
||||
# when the loader degrades or the coverage gate refuses the tag.
|
||||
"sector_source": None,
|
||||
"sector_coverage": 0.0,
|
||||
}
|
||||
return panel
|
||||
|
||||
@@ -699,6 +704,40 @@ def _validate_bench_data_requirements(registry: Any, alpha_ids: list[str], unive
|
||||
return columns
|
||||
|
||||
|
||||
def _split_alphas_by_universe_support(
|
||||
registry: Any, alpha_ids: list[str], universe: str
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Split the selection into (benchable, excluded) for this universe.
|
||||
|
||||
``fund:*`` inputs are served from SEC US filings, which only the sp500
|
||||
panel carries; on any other universe the declaring alphas are excluded
|
||||
with disclosure instead of failing the whole bench — the mirror of the
|
||||
sp500 path, where CN ``val:*`` alphas SkipAlpha cleanly on missing
|
||||
columns. ``_validate_bench_data_requirements`` remains the backstop for
|
||||
callers that bypass this split.
|
||||
|
||||
Args:
|
||||
registry: Registry holding the selected factors.
|
||||
alpha_ids: Factors selected for the bench.
|
||||
universe: Requested stock universe.
|
||||
|
||||
Returns:
|
||||
``(kept, excluded)`` — alphas benchable on this universe, and alphas
|
||||
excluded because they declare ``fund:*`` columns off sp500.
|
||||
"""
|
||||
if universe == "sp500":
|
||||
return list(alpha_ids), []
|
||||
kept: list[str] = []
|
||||
excluded: list[str] = []
|
||||
for aid in alpha_ids:
|
||||
columns = (registry.get(aid).meta or {}).get("columns_required", [])
|
||||
if any(str(column).startswith("fund:") for column in columns):
|
||||
excluded.append(aid)
|
||||
else:
|
||||
kept.append(aid)
|
||||
return kept, excluded
|
||||
|
||||
|
||||
def _val_columns_required(registry: Any, alpha_ids: list[str], panel: dict) -> list[str]:
|
||||
"""Collect declared ``val:*`` columns missing from the panel.
|
||||
|
||||
@@ -757,6 +796,72 @@ def _attach_valuation_columns(
|
||||
return enriched
|
||||
|
||||
|
||||
# Same gate as _SP500_MIN_SECTOR_COVERAGE, same reasoning: a mostly-unlabelled
|
||||
# panel would z-score one big bucket, which is the global cross-section wearing
|
||||
# an industry-neutral label. Below the gate the tag stays off and the
|
||||
# requires_sector alphas SkipAlpha cleanly.
|
||||
_CSI300_MIN_SECTOR_COVERAGE = 0.9
|
||||
|
||||
|
||||
def _attach_sector_column(enriched: dict, panel: dict, start: str, end: str) -> dict:
|
||||
"""Attach PIT SW2021 L1 industry labels as ``sector`` for a CN universe.
|
||||
|
||||
The csi300 counterpart of the sp500 Wikipedia-GICS attach: labels come
|
||||
from tushare ``index_member_all`` (point-in-time via in_date/out_date),
|
||||
align to the price panel and inherit its PIT membership mask through
|
||||
``.where(close.notna())`` — the same trick the ``val:*`` attach uses.
|
||||
Attached here, not in ``_load_csi300_panel``, so the labels never enter
|
||||
the pickled price-panel cache. ``sector_coverage`` in ``_meta`` is the
|
||||
fraction of panel columns with at least one non-null label anywhere in
|
||||
the window — per-date coverage can be lower; unlabelled cells stay NaN
|
||||
through ``zscore_by_group``.
|
||||
|
||||
Never raises: no token or a provider failure leaves the tag off (the
|
||||
loader degrades to an all-None frame; ``RuntimeError`` on a missing token
|
||||
is caught here) and ``_meta`` keeps ``sector_source=None`` as disclosure.
|
||||
|
||||
Args:
|
||||
enriched: Panel copy being extended.
|
||||
panel: Observed price panel (source of the cross-section mask).
|
||||
start: Period start (YYYY-MM-DD).
|
||||
end: Period end (YYYY-MM-DD).
|
||||
|
||||
Returns:
|
||||
The enriched panel, with ``sector`` and updated ``_meta`` when the
|
||||
coverage gate passes.
|
||||
"""
|
||||
from backtest.loaders.industry_loader import load_industry_frame
|
||||
|
||||
close = panel["close"]
|
||||
try:
|
||||
labels = load_industry_frame(
|
||||
symbols=list(close.columns), start=start, end=end, index=close.index
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — enrichment, never fatal to the bench
|
||||
logger.warning(
|
||||
"csi300: industry loader failed (%s); benching without the sector tag", exc
|
||||
)
|
||||
return enriched
|
||||
|
||||
sector = labels.reindex(index=close.index, columns=close.columns).where(close.notna())
|
||||
coverage = float(sector.notna().any(axis=0).mean()) if sector.shape[1] else 0.0
|
||||
if coverage < _CSI300_MIN_SECTOR_COVERAGE:
|
||||
logger.warning(
|
||||
"csi300: sector coverage %.1f%% below %.0f%% — leaving the tag off so "
|
||||
"industry-neutralized alphas skip rather than z-score one bucket",
|
||||
coverage * 100,
|
||||
_CSI300_MIN_SECTOR_COVERAGE * 100,
|
||||
)
|
||||
else:
|
||||
enriched["sector"] = sector
|
||||
enriched["_meta"] = {
|
||||
**enriched.get("_meta", {}),
|
||||
"sector_source": "tushare index_member_all SW2021 L1" if "sector" in enriched else None,
|
||||
"sector_coverage": round(coverage, 4),
|
||||
}
|
||||
return enriched
|
||||
|
||||
|
||||
def _prepare_bench_panel(registry: Any, alpha_ids: list[str], panel: dict, universe: str, period: str) -> dict:
|
||||
"""Load only the declared PIT fundamental inputs for the selected factors.
|
||||
|
||||
@@ -765,12 +870,15 @@ def _prepare_bench_panel(registry: Any, alpha_ids: list[str], panel: dict, unive
|
||||
alpha_ids: Selected factor identifiers.
|
||||
panel: Observed price panel; never mutated or filled with invented values.
|
||||
universe: Stock universe; SEC fundamentals currently cover US stocks,
|
||||
``val:*`` daily valuation columns cover the CN universe only —
|
||||
other universes skip them and declaring alphas SkipAlpha.
|
||||
``val:*`` daily valuation columns and the PIT ``sector`` tag cover
|
||||
the CN universe only — other universes skip them and declaring
|
||||
alphas SkipAlpha.
|
||||
period: Requested historical period.
|
||||
|
||||
Returns:
|
||||
A panel including the required fundamental columns.
|
||||
A panel including the required fundamental columns and, when a
|
||||
selected csi300 alpha declares ``requires_sector``, the ``sector``
|
||||
label frame.
|
||||
|
||||
Raises:
|
||||
ValueError: If SEC inputs are requested for a non-US universe, or a
|
||||
@@ -779,7 +887,12 @@ def _prepare_bench_panel(registry: Any, alpha_ids: list[str], panel: dict, unive
|
||||
"""
|
||||
columns = [column for column in _validate_bench_data_requirements(registry, alpha_ids, universe) if column not in panel]
|
||||
val_columns = _val_columns_required(registry, alpha_ids, panel) if universe == "csi300" else []
|
||||
if not columns and not val_columns:
|
||||
needs_sector = (
|
||||
universe == "csi300"
|
||||
and "sector" not in panel
|
||||
and any((registry.get(aid).meta or {}).get("requires_sector") for aid in alpha_ids)
|
||||
)
|
||||
if not columns and not val_columns and not needs_sector:
|
||||
return panel
|
||||
|
||||
start, end = _parse_period(period)
|
||||
@@ -800,16 +913,71 @@ def _prepare_bench_panel(registry: Any, alpha_ids: list[str], panel: dict, unive
|
||||
enriched["_meta"] = {**enriched.get("_meta", {}), "fundamental_source": "sec", "fundamental_pit": True, "fundamental_pit_lag_bars": 1}
|
||||
if val_columns:
|
||||
enriched = _attach_valuation_columns(enriched, panel, val_columns, start, end)
|
||||
if needs_sector:
|
||||
enriched = _attach_sector_column(enriched, panel, start, end)
|
||||
return enriched
|
||||
|
||||
|
||||
def _compute_forward_returns(panel: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Next-bar forward simple returns from close, aligned to factor timestamp."""
|
||||
# Cap for the tool-level bench horizon parameter: 252 bars ≈ one trading year.
|
||||
# Beyond that a typical bench period leaves almost no non-overlapping IC
|
||||
# samples, so the t-stat stops meaning anything.
|
||||
_MAX_HORIZON = 252
|
||||
|
||||
|
||||
def _validate_horizon(value: Any) -> int:
|
||||
"""Validate the bench ``horizon`` parameter (integer, 1.._MAX_HORIZON).
|
||||
|
||||
``None`` is treated as "not supplied" and defaults to 1, matching how
|
||||
``run_alpha_bench`` tolerates absent optional kwargs.
|
||||
"""
|
||||
if value is None:
|
||||
return 1
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"horizon must be an integer between 1 and {_MAX_HORIZON}, got {value!r}"
|
||||
)
|
||||
if not 1 <= value <= _MAX_HORIZON:
|
||||
raise ValueError(
|
||||
f"horizon must be an integer between 1 and {_MAX_HORIZON}, got {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _compute_forward_returns(panel: dict[str, pd.DataFrame], horizon: int = 1) -> pd.DataFrame:
|
||||
"""Forward simple returns over ``horizon`` bars, aligned to the factor timestamp.
|
||||
|
||||
``horizon=1`` keeps the historical next-bar formula byte-for-byte. For
|
||||
``horizon=h > 1`` the return at row t is ``close[t+h]/close[t] - 1``,
|
||||
sampled non-overlapping and end-anchored (see the inline comment) so the
|
||||
naive-iid t-stat downstream stays honest.
|
||||
"""
|
||||
if isinstance(horizon, bool) or not isinstance(horizon, int) or horizon < 1:
|
||||
raise ValueError(f"horizon must be an integer >= 1, got {horizon!r}")
|
||||
close = panel.get("close")
|
||||
if close is None:
|
||||
raise ValueError("panel missing 'close' — cannot derive forward returns")
|
||||
# Next-period return aligned to current row (use t+1 close, shift back).
|
||||
fwd = close.pct_change(fill_method=None).shift(-1)
|
||||
if horizon == 1:
|
||||
# Next-period return aligned to current row (use t+1 close, shift back).
|
||||
fwd = close.pct_change(fill_method=None).shift(-1)
|
||||
return fwd
|
||||
# h-bar return aligned to row t: close[t+h]/close[t] - 1. Deliberately no
|
||||
# pct_change/ffill anywhere: a NaN close at t or t+h must yield a NaN
|
||||
# return, so halted names never bridge a return across a missing bar.
|
||||
fwd = (close.shift(-horizon) / close) - 1
|
||||
# Sample non-overlapping windows anchored to the END of the frame: keep
|
||||
# row t iff (n_rows - 1 - t) % h == 0, so the most recent complete window
|
||||
# is always sampled. Overlapping h-bar windows share h-1 bars of price
|
||||
# data, which autocorrelates consecutive ICs and inflates the naive-iid
|
||||
# t-stat by ~sqrt(h); keeping every h-th row removes that dominant
|
||||
# mechanical inflation, so the existing t_stat/categorise math is a
|
||||
# defensible approximation (standard monthly-IC practice). Residual
|
||||
# autocorrelation from persistent factor exposures is not modeled.
|
||||
n_rows = len(fwd.index)
|
||||
if n_rows:
|
||||
positions = np.arange(n_rows)
|
||||
drop = ((n_rows - 1 - positions) % horizon) != 0
|
||||
if drop.any():
|
||||
fwd.iloc[drop] = np.nan
|
||||
return fwd
|
||||
|
||||
|
||||
@@ -1104,6 +1272,11 @@ def run_alpha_bench(**kwargs: Any) -> dict[str, Any]:
|
||||
if top_n <= 0:
|
||||
return {"status": "error", "error": "top must be > 0"}
|
||||
|
||||
try:
|
||||
horizon = _validate_horizon(kwargs.get("horizon", 1))
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "error": str(exc)}
|
||||
|
||||
output_dir_raw = kwargs.get("output_dir") or str(_default_output_dir())
|
||||
output_dir = Path(output_dir_raw).expanduser().resolve()
|
||||
|
||||
@@ -1135,6 +1308,25 @@ def run_alpha_bench(**kwargs: Any) -> dict[str, Any]:
|
||||
"error": "no alphas matched the selection (registry empty or filters too narrow)",
|
||||
}
|
||||
|
||||
alpha_ids, universe_excluded = _split_alphas_by_universe_support(registry, alpha_ids, universe)
|
||||
if not alpha_ids:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
f"all {len(universe_excluded)} selected alphas require SEC US filings "
|
||||
f"(fund:* inputs); the {universe!r} universe cannot serve them. "
|
||||
"Choose the S&P 500 universe."
|
||||
),
|
||||
"universe_excluded": universe_excluded,
|
||||
}
|
||||
if universe_excluded:
|
||||
logger.info(
|
||||
"alpha_bench: %d alpha(s) excluded on %s — fund:* inputs need SEC US filings (sp500): %s",
|
||||
len(universe_excluded),
|
||||
universe,
|
||||
", ".join(universe_excluded[:10]),
|
||||
)
|
||||
|
||||
# Load panel — W4 universe loader fetches constituents + OHLCV (+ amount, vwap).
|
||||
try:
|
||||
_validate_bench_data_requirements(registry, alpha_ids, universe)
|
||||
@@ -1151,7 +1343,7 @@ def run_alpha_bench(**kwargs: Any) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
panel = _prepare_bench_panel(registry, alpha_ids, panel, universe, period)
|
||||
return_df = _compute_forward_returns(panel)
|
||||
return_df = _compute_forward_returns(panel, horizon=horizon)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "error": f"forward returns failed: {exc}"}
|
||||
|
||||
@@ -1194,13 +1386,18 @@ def run_alpha_bench(**kwargs: Any) -> dict[str, Any]:
|
||||
except OSError as exc:
|
||||
return {"status": "error", "error": f"failed to write report: {exc}"}
|
||||
|
||||
return {
|
||||
envelope: dict[str, Any] = {
|
||||
"status": "ok",
|
||||
"report_path": str(report_path),
|
||||
"n_alphas_tested": len(results),
|
||||
"n_skipped": len(failures),
|
||||
"horizon": horizon,
|
||||
"forward_return_sampling": "non_overlapping" if horizon > 1 else "daily",
|
||||
"top": top,
|
||||
}
|
||||
if universe_excluded:
|
||||
envelope["universe_excluded"] = universe_excluded
|
||||
return envelope
|
||||
|
||||
|
||||
class AlphaBenchTool(BaseTool):
|
||||
@@ -1236,6 +1433,16 @@ class AlphaBenchTool(BaseTool):
|
||||
"default": 20,
|
||||
"description": "Report the top-N alphas ranked by IR.",
|
||||
},
|
||||
"horizon": {
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": (
|
||||
"Forward-return horizon in bars. 1 = next-bar (default). "
|
||||
"h>1 computes h-bar forward returns and samples IC rows "
|
||||
"non-overlapping (end-anchored); 21 ≈ monthly, the "
|
||||
"conventional horizon for fundamental factors. Range 1-252."
|
||||
),
|
||||
},
|
||||
"output_dir": {
|
||||
"type": "string",
|
||||
"description": "Where to write the HTML report; default ~/.vibe-trading/reports/.",
|
||||
|
||||
@@ -76,7 +76,7 @@ def _stub_panel(monkeypatch: pytest.MonkeyPatch, n_rows: int = 80, n_cols: int =
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner._compute_forward_returns",
|
||||
lambda panel_in: panel_in["close"].pct_change().shift(-1),
|
||||
lambda panel_in, horizon=1: panel_in["close"].pct_change().shift(-1),
|
||||
)
|
||||
|
||||
|
||||
@@ -350,3 +350,29 @@ def test_core_invalid_sort_falls_back_to_ir(monkeypatch) -> None:
|
||||
)
|
||||
assert env["sort"] == "ir"
|
||||
assert env["winner"] == "b"
|
||||
|
||||
|
||||
def test_compare_discloses_universe_excluded_alphas(monkeypatch, capsys) -> None:
|
||||
"""A compare shortlist must not silently swallow universe-excluded alphas."""
|
||||
id_to_zoo = {"fund_us": "fundamental", "fund_cn": "fundamental"}
|
||||
metrics = {"fund_cn": _metrics(0.01, 0.05, 0.20)}
|
||||
|
||||
def fake_run_bench(*, zoo, universe, period, top, only, registry, on_progress=None, on_stage=None): # noqa: ARG001
|
||||
rows = [{"id": aid, **metrics[aid]} for aid in only if aid in metrics]
|
||||
excluded = [aid for aid in only if aid not in metrics]
|
||||
return {
|
||||
"status": "ok",
|
||||
"zoo": zoo,
|
||||
"rows": rows,
|
||||
"skipped": [],
|
||||
"universe_excluded": excluded,
|
||||
}
|
||||
|
||||
_wire(monkeypatch, id_to_zoo, fake_run_bench)
|
||||
rc, env = _run(capsys, _args(alpha_ids=["fund_us", "fund_cn"]))
|
||||
|
||||
assert rc == 0
|
||||
assert env["n_compared"] == 1
|
||||
assert env["n_skipped"] == 1
|
||||
assert env["skipped"][0]["id"] == "fund_us"
|
||||
assert "SEC US filings" in env["skipped"][0]["reason"]
|
||||
@@ -235,7 +235,7 @@ def _stub_panel(monkeypatch: pytest.MonkeyPatch, n_rows: int = 80, n_cols: int =
|
||||
lambda universe, period: panel, # noqa: ARG005
|
||||
)
|
||||
|
||||
def _fwd(panel_in: dict[str, pd.DataFrame]) -> pd.DataFrame:
|
||||
def _fwd(panel_in: dict[str, pd.DataFrame], horizon: int = 1) -> pd.DataFrame:
|
||||
c = panel_in["close"]
|
||||
return c.pct_change().shift(-1)
|
||||
|
||||
@@ -613,7 +613,7 @@ def test_run_bench_strict_catches_planted_alive_signal(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner_strict._compute_forward_returns",
|
||||
lambda p: p["close"].pct_change().shift(-1),
|
||||
lambda p, horizon=1: p["close"].pct_change().shift(-1),
|
||||
)
|
||||
|
||||
class PlantedReg:
|
||||
@@ -651,7 +651,7 @@ def test_run_bench_strict_catches_planted_reversed_signal(
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner_strict._compute_forward_returns",
|
||||
lambda p: p["close"].pct_change().shift(-1),
|
||||
lambda p, horizon=1: p["close"].pct_change().shift(-1),
|
||||
)
|
||||
|
||||
class ReversedReg:
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Registration + numeric regression for the three industry-neutral CN value factors.
|
||||
|
||||
Covers ``fund_pb_ind_neutral``, ``fund_ps_ttm_ind_neutral`` and
|
||||
``fund_dividend_yield_ind_neutral`` in ``zoo/fundamental/`` (issue #1789
|
||||
follow-up #2): the ``requires_sector`` gate, within-industry z-score values
|
||||
recomputed here with independent numpy math, the sign convention (PB/PS
|
||||
negated, dividend yield positive), and the NaN contract — no label, a
|
||||
singleton industry group or a blanked dependency all stay NaN, never a
|
||||
global-fallback number.
|
||||
|
||||
Assertions are membership-based, never an exact registry total: the zoo grows
|
||||
concurrently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.factors.registry import Registry, SkipAlpha
|
||||
|
||||
_DATES = pd.to_datetime(["2024-01-01", "2024-01-02"])
|
||||
_CODES = ["BANK1", "BANK2", "TECH1", "TECH2"]
|
||||
_SECTORS = ["银行", "银行", "科技", "科技"]
|
||||
|
||||
# alpha_id -> (columns_required, module stem, sign multiplier on the z-score)
|
||||
_EXPECTED: dict[str, tuple[list[str], str, float]] = {
|
||||
"fund_pb_ind_neutral": (["val:pb"], "pb_ind_neutral", -1.0),
|
||||
"fund_ps_ttm_ind_neutral": (["val:ps_ttm"], "ps_ttm_ind_neutral", -1.0),
|
||||
"fund_dividend_yield_ind_neutral": (["val:dv_ttm"], "dividend_yield_ind_neutral", 1.0),
|
||||
}
|
||||
|
||||
_DEPENDENCY: dict[str, str] = {
|
||||
"fund_pb_ind_neutral": "val:pb",
|
||||
"fund_ps_ttm_ind_neutral": "val:ps_ttm",
|
||||
"fund_dividend_yield_ind_neutral": "val:dv_ttm",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def registry() -> Registry:
|
||||
return Registry()
|
||||
|
||||
|
||||
def _frame(rows: list[list[float]]) -> pd.DataFrame:
|
||||
return pd.DataFrame(rows, index=_DATES, columns=_CODES, dtype="float64")
|
||||
|
||||
|
||||
def _sector_frame(labels: list[list] | None = None) -> pd.DataFrame:
|
||||
rows = [list(row) for row in (labels or [_SECTORS, _SECTORS])]
|
||||
return pd.DataFrame(rows, index=_DATES, columns=_CODES, dtype=object)
|
||||
|
||||
|
||||
def _build_panel() -> dict[str, pd.DataFrame]:
|
||||
"""Hand-built 2-date x 4-symbol panel, two industries of two names each."""
|
||||
return {
|
||||
# Within 银行: BANK2 cheaper; within 科技: TECH2 cheaper.
|
||||
"val:pb": _frame([[0.5, 1.5, 8.0, 12.0], [0.6, 1.4, 9.0, 11.0]]),
|
||||
"val:ps_ttm": _frame([[1.0, 3.0, 10.0, 30.0], [1.2, 2.8, 12.0, 28.0]]),
|
||||
"val:dv_ttm": _frame([[4.0, 2.0, 0.5, 1.5], [3.8, 2.2, 0.6, 1.4]]),
|
||||
"sector": _sector_frame(),
|
||||
}
|
||||
|
||||
|
||||
def _numpy_group_zscore(values: np.ndarray, labels: np.ndarray) -> np.ndarray:
|
||||
"""Independent per-row within-group z-score: (x - mean) / sample-std."""
|
||||
out = np.full(values.shape, np.nan, dtype=np.float64)
|
||||
for i in range(values.shape[0]):
|
||||
for tag in {label for label in labels[i] if label is not None}:
|
||||
mask = labels[i] == tag
|
||||
finite = values[i][mask & ~np.isnan(values[i])]
|
||||
if finite.size < 2:
|
||||
continue
|
||||
std = finite.std(ddof=1)
|
||||
if std <= 0:
|
||||
continue
|
||||
out[i, mask] = (values[i, mask] - finite.mean()) / std
|
||||
return out
|
||||
|
||||
|
||||
def _expected_output(panel: dict[str, pd.DataFrame], alpha_id: str) -> pd.DataFrame:
|
||||
columns, _, sign = _EXPECTED[alpha_id]
|
||||
values = panel[columns[0]].to_numpy(dtype=np.float64)
|
||||
labels = panel["sector"].to_numpy()
|
||||
return pd.DataFrame(
|
||||
sign * _numpy_group_zscore(values, labels), index=_DATES, columns=_CODES
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------- (a) gate + registry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_id", sorted(_EXPECTED))
|
||||
def test_registry_loads_factor_with_expected_meta(
|
||||
registry: Registry, alpha_id: str
|
||||
) -> None:
|
||||
columns, stem, _ = _EXPECTED[alpha_id]
|
||||
alpha = registry.get(alpha_id)
|
||||
assert alpha.zoo == "fundamental"
|
||||
assert alpha.module_path == f"src.factors.zoo.fundamental.{stem}"
|
||||
assert alpha.meta["theme"] == ["value"]
|
||||
assert alpha.meta["columns_required"] == columns
|
||||
assert alpha.meta["requires_sector"] is True
|
||||
assert alpha.meta["universe"] == ["equity_cn"]
|
||||
assert alpha.meta["frequency"] == ["1d"]
|
||||
assert alpha.meta["decay_horizon"] == 252
|
||||
assert alpha.meta["min_warmup_bars"] == 1
|
||||
assert alpha.meta["notes"].strip()
|
||||
assert alpha.meta["formula_latex"].strip()
|
||||
|
||||
|
||||
def test_the_three_raw_siblings_stay_unsectorized(registry: Registry) -> None:
|
||||
"""The neutral variants are additions; the raw factors keep their contract."""
|
||||
for alpha_id in ("fund_pb", "fund_ps_ttm", "fund_dividend_yield"):
|
||||
assert not registry.get(alpha_id).meta.get("requires_sector")
|
||||
|
||||
|
||||
# ------------------------------------------------- (b) numeric correctness
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_id", sorted(_EXPECTED))
|
||||
def test_compute_matches_independent_numpy_math(
|
||||
registry: Registry, alpha_id: str
|
||||
) -> None:
|
||||
panel = _build_panel()
|
||||
result = registry.compute(alpha_id, panel)
|
||||
expected = _expected_output(panel, alpha_id)
|
||||
|
||||
assert list(result.columns) == _CODES
|
||||
assert list(result.index) == list(_DATES)
|
||||
np.testing.assert_allclose(
|
||||
result.to_numpy(dtype=np.float64),
|
||||
expected.to_numpy(dtype=np.float64),
|
||||
rtol=1e-9,
|
||||
atol=1e-12,
|
||||
equal_nan=True,
|
||||
err_msg=f"{alpha_id} diverged from independent numpy expectation",
|
||||
)
|
||||
|
||||
|
||||
def test_pb_ranks_within_industry_not_across_the_row(registry: Registry) -> None:
|
||||
"""Industry level no longer dominates the score, only relative cheapness.
|
||||
|
||||
A raw -zscore(PB) puts every bank above every tech purely because banks
|
||||
carry structurally lower PB. The neutral variant scores each name against
|
||||
its own group: the cheap name of each industry gets the same top score
|
||||
(+1/sqrt(2) in a two-member group), so TECH1 — expensive in absolute
|
||||
terms yet cheap among techs — is no longer ranked below both banks.
|
||||
"""
|
||||
out = registry.compute("fund_pb_ind_neutral", _build_panel())
|
||||
date = _DATES[0]
|
||||
# Within 银行: cheaper BANK1 scores high. Within 科技: cheaper TECH1 scores high.
|
||||
assert out.loc[date, "BANK1"] > out.loc[date, "BANK2"]
|
||||
assert out.loc[date, "TECH1"] > out.loc[date, "TECH2"]
|
||||
# Two-member groups z-score to exactly ±1/sqrt(2) regardless of level.
|
||||
s = 1.0 / np.sqrt(2.0)
|
||||
np.testing.assert_allclose(
|
||||
out.loc[date].to_numpy(dtype=np.float64), [s, -s, s, -s], rtol=1e-12
|
||||
)
|
||||
|
||||
|
||||
def test_dividend_yield_sign_direction(registry: Registry) -> None:
|
||||
"""High dv_ttm scores high within the industry group (no inversion)."""
|
||||
out = registry.compute("fund_dividend_yield_ind_neutral", _build_panel())
|
||||
for date in _DATES:
|
||||
assert out.loc[date, "BANK1"] > out.loc[date, "BANK2"]
|
||||
assert out.loc[date, "TECH2"] > out.loc[date, "TECH1"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------ (c) NaN contract
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_id", sorted(_EXPECTED))
|
||||
def test_missing_sector_tag_skips_cleanly(registry: Registry, alpha_id: str) -> None:
|
||||
panel = _build_panel()
|
||||
del panel["sector"]
|
||||
|
||||
with pytest.raises(SkipAlpha, match="missing sector tag"):
|
||||
registry.compute(alpha_id, panel)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_id", sorted(_EXPECTED))
|
||||
def test_blank_dependency_bar_yields_all_nan(registry: Registry, alpha_id: str) -> None:
|
||||
panel = _build_panel()
|
||||
panel[_DEPENDENCY[alpha_id]].loc[_DATES[1]] = np.nan
|
||||
|
||||
out = registry.compute(alpha_id, panel)
|
||||
|
||||
assert out.loc[_DATES[1]].isna().all(), (
|
||||
f"{alpha_id}: emitted a value on a bar whose declared dependency was NaN"
|
||||
)
|
||||
assert out.loc[_DATES[0]].notna().all()
|
||||
|
||||
|
||||
def test_unlabelled_name_is_nan_not_globally_zscored(registry: Registry) -> None:
|
||||
"""A name with no industry label stays NaN — no global fallback."""
|
||||
panel = _build_panel()
|
||||
labels = [list(_SECTORS), list(_SECTORS)]
|
||||
labels[1][2] = None # TECH1 unlabelled on date 2
|
||||
panel["sector"] = _sector_frame(labels)
|
||||
|
||||
out = registry.compute("fund_pb_ind_neutral", panel)
|
||||
|
||||
assert pd.isna(out.loc[_DATES[1], "TECH1"])
|
||||
# Its former group peer loses the second observation → singleton → NaN too.
|
||||
assert pd.isna(out.loc[_DATES[1], "TECH2"])
|
||||
# The 银行 group is untouched by the tech-side unlabelling: a two-member
|
||||
# group z-scores to ±1/sqrt(2) on both dates.
|
||||
assert out.loc[_DATES[1], "BANK1"] == pytest.approx(out.loc[_DATES[0], "BANK1"])
|
||||
assert out.loc[_DATES[0]].notna().all()
|
||||
|
||||
|
||||
def test_singleton_industry_group_is_nan(registry: Registry) -> None:
|
||||
panel = _build_panel()
|
||||
panel["sector"] = _sector_frame([["银行", "银行", "科技", "医药"]] * 2)
|
||||
|
||||
out = registry.compute("fund_pb_ind_neutral", panel)
|
||||
|
||||
# TECH1 (科技) and TECH2 (医药) are each a one-name industry → NaN.
|
||||
assert out.loc[:, "TECH1"].isna().all()
|
||||
assert out.loc[:, "TECH2"].isna().all()
|
||||
assert out.loc[:, ["BANK1", "BANK2"]].notna().all().all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_id", sorted(_EXPECTED))
|
||||
def test_no_inf_on_degenerate_group_values(
|
||||
registry: Registry, alpha_id: str
|
||||
) -> None:
|
||||
"""A constant industry group degrades to NaN, never to inf or silent zero."""
|
||||
panel = _build_panel()
|
||||
panel[_DEPENDENCY[alpha_id]].loc[_DATES[0]] = [7.0, 7.0, 0.0, -3.0]
|
||||
|
||||
out = registry.compute(alpha_id, panel)
|
||||
arr = out.to_numpy(dtype=np.float64, na_value=np.nan)
|
||||
|
||||
assert not np.isinf(arr).any()
|
||||
assert out.loc[_DATES[0], ["BANK1", "BANK2"]].isna().all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stem,column",
|
||||
[("pb_ind_neutral", "pb"), ("ps_ttm_ind_neutral", "ps_ttm")],
|
||||
)
|
||||
def test_nonpositive_multiples_are_not_ranked_as_cheap(stem: str, column: str) -> None:
|
||||
"""Mirrors the raw siblings' upstream guard: PB/PS <= 0 is excluded, never 'cheapest'."""
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module(f"src.factors.zoo.fundamental.{stem}")
|
||||
frame = pd.DataFrame([[-1.0, 0.0, 1.0, 2.0, 3.0]], columns=list("ABCDE"))
|
||||
sector = pd.DataFrame([["G1"] * 5], columns=list("ABCDE"))
|
||||
|
||||
actual = mod.compute({f"val:{column}": frame, "sector": sector})
|
||||
|
||||
assert actual[["A", "B"]].isna().all().all()
|
||||
np.testing.assert_allclose(actual[["C", "D", "E"]].to_numpy(), [[1.0, 0.0, -1.0]])
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit contract for ``src.factors.base.zscore_by_group``.
|
||||
|
||||
The shared within-group z-score the industry-neutralized CN value factors run
|
||||
on. Expected values are hand-computed (or recomputed with independent numpy
|
||||
math) rather than derived from the helper itself, so a regression in the
|
||||
helper cannot validate itself. The refusal cases matter as much as the numbers:
|
||||
a singleton group, a zero-variance group, a missing label and a ±inf input all
|
||||
stay NaN — there is no global fallback wearing an industry-neutral label.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.factors.base import zscore_by_group
|
||||
|
||||
_DATES = pd.to_datetime(["2024-01-01", "2024-01-02"])
|
||||
_COLS = ["A", "B", "C", "D"]
|
||||
|
||||
|
||||
def _frame(rows: list[list], cols: list[str] | None = None) -> pd.DataFrame:
|
||||
return pd.DataFrame(rows, index=_DATES, columns=cols or _COLS, dtype=object)
|
||||
|
||||
|
||||
def _labels(rows: list[list]) -> pd.DataFrame:
|
||||
return pd.DataFrame(rows, index=_DATES, columns=_COLS, dtype=object)
|
||||
|
||||
|
||||
def test_two_group_row_matches_hand_computation() -> None:
|
||||
"""Row [1,3 | 10,20] in two groups: every cell is ±1/sqrt(2)."""
|
||||
values = pd.DataFrame(
|
||||
[[1.0, 3.0, 10.0, 20.0], [2.0, 4.0, 4.0, 2.0]],
|
||||
index=_DATES, columns=_COLS, dtype="float64",
|
||||
)
|
||||
groups = _labels([["G1", "G1", "G2", "G2"]] * 2)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
s = 1.0 / np.sqrt(2.0) # (x - mean) / sample-std of a two-point group
|
||||
np.testing.assert_allclose(
|
||||
out.to_numpy(dtype=np.float64),
|
||||
np.array([[-s, s, -s, s], [-s, s, s, -s]]),
|
||||
rtol=1e-12, atol=1e-15,
|
||||
)
|
||||
|
||||
|
||||
def test_group_stats_use_only_that_group() -> None:
|
||||
"""A three-member group's mean/std come from its own cells, not the row."""
|
||||
values = pd.DataFrame(
|
||||
[[1.0, 2.0, 3.0, 100.0]], index=_DATES[:1], columns=_COLS, dtype="float64"
|
||||
)
|
||||
groups = pd.DataFrame(
|
||||
[["G1", "G1", "G1", "G2"]], index=_DATES[:1], columns=_COLS, dtype=object
|
||||
)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
# G1: mean 2, sample std 1 → [-1, 0, 1]; G2 singleton → NaN (not 100's z vs the row).
|
||||
np.testing.assert_allclose(
|
||||
out.to_numpy(dtype=np.float64)[0, :3], [-1.0, 0.0, 1.0], rtol=1e-12
|
||||
)
|
||||
assert np.isnan(out.to_numpy(dtype=np.float64)[0, 3])
|
||||
|
||||
|
||||
def test_singleton_group_is_nan() -> None:
|
||||
values = pd.DataFrame([[1.0, 3.0, 7.0, 9.0]], index=_DATES[:1], columns=_COLS, dtype="float64")
|
||||
groups = pd.DataFrame([["G1", "G1", "G2", "G3"]], index=_DATES[:1], columns=_COLS, dtype=object)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
row = out.to_numpy(dtype=np.float64)[0]
|
||||
assert np.isfinite(row[0]) and np.isfinite(row[1])
|
||||
assert np.isnan(row[2]) and np.isnan(row[3])
|
||||
|
||||
|
||||
def test_zero_variance_group_is_nan_never_inf_never_zero() -> None:
|
||||
values = pd.DataFrame([[5.0, 5.0, 1.0, 2.0]], index=_DATES[:1], columns=_COLS, dtype="float64")
|
||||
groups = pd.DataFrame([["G1", "G1", "G2", "G2"]], index=_DATES[:1], columns=_COLS, dtype=object)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
row = out.to_numpy(dtype=np.float64)[0]
|
||||
assert np.isnan(row[0]) and np.isnan(row[1])
|
||||
assert not np.isinf(row).any()
|
||||
assert np.isfinite(row[2]) and np.isfinite(row[3])
|
||||
|
||||
|
||||
def test_nan_cell_stays_nan_and_is_excluded_from_group_stats() -> None:
|
||||
"""The NaN cell does not enter the mean/std; the survivors still z-score."""
|
||||
values = pd.DataFrame(
|
||||
[[1.0, np.nan, 3.0, 10.0]], index=_DATES[:1], columns=_COLS, dtype="float64"
|
||||
)
|
||||
groups = pd.DataFrame(
|
||||
[["G1", "G1", "G1", "G2"]], index=_DATES[:1], columns=_COLS, dtype=object
|
||||
)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
row = out.to_numpy(dtype=np.float64)[0]
|
||||
# G1 finite {1, 3}: mean 2, std sqrt(2) → ±1/sqrt(2); the NaN cell stays NaN.
|
||||
s = 1.0 / np.sqrt(2.0)
|
||||
assert row[0] == pytest.approx(-s)
|
||||
assert np.isnan(row[1])
|
||||
assert row[2] == pytest.approx(s)
|
||||
|
||||
|
||||
def test_none_and_nan_labels_are_nan_with_no_global_fallback() -> None:
|
||||
values = pd.DataFrame([[1.0, 2.0, 3.0, 4.0]], index=_DATES[:1], columns=_COLS, dtype="float64")
|
||||
groups = pd.DataFrame(
|
||||
[[None, np.nan, "G1", "G1"]], index=_DATES[:1], columns=_COLS, dtype=object
|
||||
)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
row = out.to_numpy(dtype=np.float64)[0]
|
||||
assert np.isnan(row[0]) and np.isnan(row[1])
|
||||
assert np.isfinite(row[2]) and np.isfinite(row[3])
|
||||
|
||||
|
||||
def test_inf_inputs_are_scrubbed_to_nan() -> None:
|
||||
values = pd.DataFrame(
|
||||
[[np.inf, 1.0, 3.0, -np.inf]], index=_DATES[:1], columns=_COLS, dtype="float64"
|
||||
)
|
||||
groups = pd.DataFrame([["G1"] * 4], index=_DATES[:1], columns=_COLS, dtype=object)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
row = out.to_numpy(dtype=np.float64)[0]
|
||||
assert not np.isinf(row).any()
|
||||
assert np.isnan(row[0]) and np.isnan(row[3])
|
||||
# G1 finite {1, 3} → ±1/sqrt(2), the infinities excluded from mean/std.
|
||||
s = 1.0 / np.sqrt(2.0)
|
||||
assert row[1] == pytest.approx(-s)
|
||||
assert row[2] == pytest.approx(s)
|
||||
|
||||
|
||||
def test_shape_index_columns_preserved() -> None:
|
||||
values = pd.DataFrame(
|
||||
[[1.0, 2.0, 3.0, 4.0], [4.0, 3.0, 2.0, 1.0]], index=_DATES, columns=_COLS, dtype="float64"
|
||||
)
|
||||
groups = _labels([["G1", "G1", "G2", "G2"]] * 2)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
assert out.shape == values.shape
|
||||
assert list(out.index) == list(_DATES)
|
||||
assert list(out.columns) == _COLS
|
||||
|
||||
|
||||
def test_groups_align_positionally_not_by_label() -> None:
|
||||
"""A groups frame with different index/column labels gives the same result."""
|
||||
values = pd.DataFrame(
|
||||
[[1.0, 3.0, 10.0, 20.0]], index=_DATES[:1], columns=_COLS, dtype="float64"
|
||||
)
|
||||
aligned = pd.DataFrame(
|
||||
[["G1", "G1", "G2", "G2"]], index=_DATES[:1], columns=_COLS, dtype=object
|
||||
)
|
||||
shifted_labels = pd.DataFrame(
|
||||
[["G1", "G1", "G2", "G2"]],
|
||||
index=pd.to_datetime(["1999-12-31"]),
|
||||
columns=["W", "X", "Y", "Z"],
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
a = zscore_by_group(values, aligned)
|
||||
b = zscore_by_group(values, shifted_labels)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
a.to_numpy(dtype=np.float64), b.to_numpy(dtype=np.float64), equal_nan=True
|
||||
)
|
||||
assert list(b.columns) == _COLS # output keeps df's columns, not groups'
|
||||
|
||||
|
||||
def test_integer_labels_work() -> None:
|
||||
"""The sweep panels carry integer sector buckets; labels need not be str."""
|
||||
values = pd.DataFrame([[1.0, 3.0, 10.0, 20.0]], index=_DATES[:1], columns=_COLS, dtype="float64")
|
||||
groups = pd.DataFrame([[0, 0, 1, 1]], index=_DATES[:1], columns=_COLS)
|
||||
|
||||
out = zscore_by_group(values, groups)
|
||||
|
||||
s = 1.0 / np.sqrt(2.0)
|
||||
np.testing.assert_allclose(
|
||||
out.to_numpy(dtype=np.float64)[0], [-s, s, -s, s], rtol=1e-12
|
||||
)
|
||||
|
||||
|
||||
def test_shape_mismatch_raises() -> None:
|
||||
values = pd.DataFrame([[1.0, 2.0, 3.0, 4.0]], index=_DATES[:1], columns=_COLS, dtype="float64")
|
||||
groups = pd.DataFrame([["G1", "G1", "G2"]], index=_DATES[:1], columns=_COLS[:3], dtype=object)
|
||||
|
||||
with pytest.raises(ValueError, match=r"groups shape .* != df shape"):
|
||||
zscore_by_group(values, groups)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""The csi300 bench attaches a PIT SW2021 sector tag for requires_sector alphas.
|
||||
|
||||
csi300 was the one universe with no ``sector`` frame: the 19 alpha101
|
||||
industry-neutralized alphas and the three ``fund_*_ind_neutral`` CN value
|
||||
alphas SkipAlpha'd on every run. These tests extend the existing csi300 fake
|
||||
pro with ``index_member_all`` and pin the attach contract: labels land outside
|
||||
the pickled price panel, inherit the PIT membership mask, disclose source and
|
||||
coverage in ``_meta``, stay off below the coverage gate, and never take the
|
||||
bench down when the loader fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.config.accessor import reset_env_config
|
||||
from src.tools import alpha_bench_tool as tool
|
||||
|
||||
_ALPHA_ID = "fund_pb_ind_neutral"
|
||||
_META = {"columns_required": ["val:pb"], "requires_sector": True}
|
||||
_PLAIN_META = {"columns_required": ["val:pb"]}
|
||||
|
||||
|
||||
class _StubRegistry:
|
||||
"""Registry surface ``_prepare_bench_panel`` uses: ``get(aid).meta``."""
|
||||
|
||||
def __init__(self, metas: dict[str, dict]) -> None:
|
||||
self._metas = metas
|
||||
|
||||
def get(self, alpha_id: str) -> Any:
|
||||
return SimpleNamespace(meta=self._metas[alpha_id])
|
||||
|
||||
|
||||
def _member_row(ts_code: str, l1_name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"l1_code": f"800-{l1_name}",
|
||||
"l1_name": l1_name,
|
||||
"l2_code": "801-x",
|
||||
"l2_name": "x",
|
||||
"l3_code": "802-y",
|
||||
"l3_name": "y",
|
||||
"ts_code": ts_code,
|
||||
"name": ts_code.split(".")[0],
|
||||
"in_date": "20200101",
|
||||
"out_date": None,
|
||||
"is_new": "Y",
|
||||
}
|
||||
|
||||
|
||||
class _FakeTusharePro:
|
||||
"""The csi300 fake from test_alpha_bench_universe_metadata, plus industry
|
||||
membership and daily_basic so the whole _prepare_bench_panel path runs."""
|
||||
|
||||
def __init__(self, members: list[dict[str, Any]] | Exception) -> None:
|
||||
self.members = members
|
||||
self.industry_calls: list[dict[str, Any]] = []
|
||||
|
||||
def index_weight(self, **_kwargs) -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"trade_date": ["20240102"] * 3,
|
||||
"con_code": ["600000.SH", "000001.SZ", "000002.SZ"],
|
||||
}
|
||||
)
|
||||
|
||||
def daily(self, **_kwargs) -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"trade_date": ["20240102"],
|
||||
"open": [10.0],
|
||||
"high": [11.0],
|
||||
"low": [9.0],
|
||||
"close": [10.5],
|
||||
"vol": [100.0],
|
||||
"amount": [105.0],
|
||||
}
|
||||
)
|
||||
|
||||
def adj_factor(self, **_kwargs) -> pd.DataFrame:
|
||||
return pd.DataFrame({"trade_date": ["20240102"], "adj_factor": [1.0]})
|
||||
|
||||
def daily_basic(self, **kwargs) -> pd.DataFrame:
|
||||
code = kwargs["ts_code"]
|
||||
return pd.DataFrame(
|
||||
{"ts_code": [code], "trade_date": ["20240102"], "pb": [1.5]}
|
||||
)
|
||||
|
||||
def index_member_all(self, **kwargs) -> pd.DataFrame:
|
||||
self.industry_calls.append(kwargs)
|
||||
if isinstance(self.members, Exception):
|
||||
raise self.members
|
||||
return pd.DataFrame(self.members)
|
||||
|
||||
|
||||
def _install(
|
||||
monkeypatch: pytest.MonkeyPatch, pro: _FakeTusharePro
|
||||
) -> None:
|
||||
monkeypatch.setenv("TUSHARE_TOKEN", "ts-test-token")
|
||||
monkeypatch.setattr(
|
||||
tool,
|
||||
"get_env_config",
|
||||
lambda: SimpleNamespace(data=SimpleNamespace(tushare_token="ts-test-token")),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "tushare", SimpleNamespace(pro_api=lambda _token: pro)
|
||||
)
|
||||
|
||||
|
||||
def _prepare(panel: dict, metas: dict[str, dict] | None = None) -> dict:
|
||||
metas = metas if metas is not None else {_ALPHA_ID: _META}
|
||||
registry = _StubRegistry(metas)
|
||||
return tool._prepare_bench_panel(
|
||||
registry, list(metas), panel, "csi300", "2024-01-01/2024-01-31"
|
||||
)
|
||||
|
||||
|
||||
def test_sector_attaches_with_pit_labels_and_meta_disclosure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pro = _FakeTusharePro(
|
||||
[
|
||||
_member_row("600000.SH", "银行"),
|
||||
_member_row("000001.SZ", "非银金融"),
|
||||
_member_row("000002.SZ", "房地产"),
|
||||
]
|
||||
)
|
||||
_install(monkeypatch, pro)
|
||||
|
||||
panel = tool._load_csi300_panel("2024-01-01", "2024-01-31")
|
||||
# The tag must not ride inside the pickled price panel: at load time the
|
||||
# meta keys sit at their null/0.0 defaults and no sector frame exists.
|
||||
assert "sector" not in panel
|
||||
assert panel["_meta"]["sector_source"] is None
|
||||
assert panel["_meta"]["sector_coverage"] == 0.0
|
||||
|
||||
enriched = _prepare(panel)
|
||||
|
||||
assert "sector" in enriched
|
||||
close = enriched["close"]
|
||||
sector = enriched["sector"]
|
||||
assert sector.shape == close.shape
|
||||
assert list(sector.columns) == list(close.columns)
|
||||
assert sector.index.equals(close.index)
|
||||
assert sector.loc["2024-01-02", "600000.SH"] == "银行"
|
||||
assert sector.loc["2024-01-02", "000001.SZ"] == "非银金融"
|
||||
assert sector.loc["2024-01-02", "000002.SZ"] == "房地产"
|
||||
# The original panel (the dict that was pickled into the cache) is never
|
||||
# mutated: the attach adds keys to its own copy only.
|
||||
assert "sector" not in panel
|
||||
assert panel["_meta"]["sector_source"] is None
|
||||
assert panel["_meta"]["sector_coverage"] == 0.0
|
||||
# Inherits the PIT membership mask: a label exists only where close does.
|
||||
assert (sector.notna() <= close.notna()).all().all()
|
||||
assert enriched["_meta"]["sector_source"] == "tushare index_member_all SW2021 L1"
|
||||
assert enriched["_meta"]["sector_coverage"] == 1.0
|
||||
# The val: attach still ran first, same stage.
|
||||
assert "val:pb" in enriched
|
||||
|
||||
|
||||
def test_low_coverage_leaves_the_tag_off_and_discloses(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""1 of 3 columns labelled < 0.9: attaching would z-score one bucket."""
|
||||
pro = _FakeTusharePro([_member_row("600000.SH", "银行")])
|
||||
_install(monkeypatch, pro)
|
||||
panel = tool._load_csi300_panel("2024-01-01", "2024-01-31")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=tool.logger.name):
|
||||
enriched = _prepare(panel)
|
||||
|
||||
assert "sector" not in enriched
|
||||
assert enriched["_meta"]["sector_source"] is None
|
||||
assert enriched["_meta"]["sector_coverage"] == pytest.approx(0.3333, abs=1e-4)
|
||||
assert any("sector coverage" in message for message in caplog.messages)
|
||||
# The rest of the bench prep is untouched: val: columns still attach.
|
||||
assert "val:pb" in enriched
|
||||
|
||||
|
||||
def test_loader_failure_never_takes_the_bench_down(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Placeholder token → loader RuntimeError → warn, no sector, bench proceeds."""
|
||||
pro = _FakeTusharePro(
|
||||
[
|
||||
_member_row("600000.SH", "银行"),
|
||||
_member_row("000001.SZ", "非银金融"),
|
||||
_member_row("000002.SZ", "房地产"),
|
||||
]
|
||||
)
|
||||
_install(monkeypatch, pro)
|
||||
panel = tool._load_csi300_panel("2024-01-01", "2024-01-31")
|
||||
# Pre-attach val:pb so the token pull below isolates the sector loader
|
||||
# (the valuation loader would raise its own RuntimeError on the empty token).
|
||||
panel["val:pb"] = panel["close"].notna().astype(float).where(panel["close"].notna()) * 1.5
|
||||
|
||||
monkeypatch.setenv("TUSHARE_TOKEN", "")
|
||||
reset_env_config()
|
||||
with caplog.at_level(logging.WARNING, logger=tool.logger.name):
|
||||
enriched = _prepare(panel)
|
||||
|
||||
assert "sector" not in enriched
|
||||
assert enriched["_meta"]["sector_source"] is None
|
||||
assert enriched["_meta"]["sector_coverage"] == 0.0
|
||||
assert any("industry loader failed" in message for message in caplog.messages)
|
||||
assert "val:pb" in enriched
|
||||
|
||||
|
||||
def test_provider_exception_degrades_to_no_sector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""index_member_all raising after backoff: the loader itself degrades."""
|
||||
pro = _FakeTusharePro(RuntimeError("provider down"))
|
||||
_install(monkeypatch, pro)
|
||||
panel = tool._load_csi300_panel("2024-01-01", "2024-01-31")
|
||||
|
||||
enriched = _prepare(panel)
|
||||
|
||||
assert "sector" not in enriched
|
||||
assert enriched["_meta"]["sector_source"] is None
|
||||
assert enriched["_meta"]["sector_coverage"] == 0.0
|
||||
|
||||
|
||||
def test_no_sector_fetch_when_nothing_declares_requires_sector(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The membership endpoint is only paid for when an alpha needs the tag."""
|
||||
pro = _FakeTusharePro([_member_row("600000.SH", "银行")])
|
||||
_install(monkeypatch, pro)
|
||||
panel = tool._load_csi300_panel("2024-01-01", "2024-01-31")
|
||||
|
||||
enriched = _prepare(panel, metas={_ALPHA_ID: _PLAIN_META})
|
||||
|
||||
assert pro.industry_calls == []
|
||||
assert "sector" not in enriched
|
||||
assert enriched["_meta"]["sector_source"] is None
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Regression tests for alpha-bench forward-return missing-data handling."""
|
||||
"""Regression tests for alpha-bench forward-return missing-data handling
|
||||
and monthly-horizon support (issue #1789 follow-up #1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.tools.alpha_bench_tool import _compute_forward_returns
|
||||
|
||||
@@ -16,3 +19,90 @@ def test_forward_returns_preserve_missing_close_boundaries() -> None:
|
||||
forward = _compute_forward_returns({"close": close})
|
||||
|
||||
assert forward["AAA"].isna().all()
|
||||
|
||||
|
||||
def _close(values: list[float]) -> pd.DataFrame:
|
||||
return pd.DataFrame(
|
||||
{"AAA": list(values)},
|
||||
index=pd.date_range("2026-01-01", periods=len(values), freq="D"),
|
||||
)
|
||||
|
||||
|
||||
def test_horizon_default_is_byte_identical_to_legacy_formula() -> None:
|
||||
close = _close([100.0, 102.0, np.nan, 105.0, 104.0, 108.0, 110.0])
|
||||
legacy = close.pct_change(fill_method=None).shift(-1)
|
||||
|
||||
assert _compute_forward_returns({"close": close}).equals(legacy)
|
||||
assert _compute_forward_returns({"close": close}, horizon=1).equals(legacy)
|
||||
|
||||
|
||||
def test_horizon_three_samples_every_third_row_end_anchored() -> None:
|
||||
values = [100.0, 101.0, 103.0, 106.0, 110.0, 115.0, 121.0, 128.0, 136.0, 145.0]
|
||||
close = _close(values)
|
||||
|
||||
forward = _compute_forward_returns({"close": close}, horizon=3)
|
||||
|
||||
# n=10, h=3 → kept positions satisfy (9 - pos) % 3 == 0 → 0, 3, 6, 9.
|
||||
for pos in (0, 3, 6):
|
||||
expected = values[pos + 3] / values[pos] - 1.0
|
||||
assert forward["AAA"].iloc[pos] == pytest.approx(expected)
|
||||
# The most recent sampled row has no t+h close → NaN, never bridged.
|
||||
assert np.isnan(forward["AAA"].iloc[9])
|
||||
# Non-sampled rows are NaN so consecutive IC windows never overlap.
|
||||
for pos in (1, 2, 4, 5, 7, 8):
|
||||
assert np.isnan(forward["AAA"].iloc[pos])
|
||||
|
||||
|
||||
def test_horizon_samples_the_most_recent_complete_window() -> None:
|
||||
# n=9, h=2 → sampled positions 0, 2, 4, 6, 8 (end-anchored); the row at
|
||||
# position 6 carries the return over the most recent complete window [6→8].
|
||||
values = [100.0, 102.0, 101.0, 104.0, 107.0, 106.0, 110.0, 112.0, 115.0]
|
||||
close = _close(values)
|
||||
|
||||
forward = _compute_forward_returns({"close": close}, horizon=2)
|
||||
|
||||
assert forward["AAA"].iloc[6] == pytest.approx(values[8] / values[6] - 1.0)
|
||||
assert np.isnan(forward["AAA"].iloc[8]) # sampled but no future close
|
||||
assert np.isnan(forward["AAA"].iloc[7]) # not sampled
|
||||
|
||||
|
||||
def test_horizon_nan_close_at_t_plus_h_never_bridges() -> None:
|
||||
# n=6, h=3 → sampled positions 2 and 5. close[5] is NaN (halted), so the
|
||||
# return at position 2 must be NaN — no bridging off close[4].
|
||||
values = [100.0, 101.0, 103.0, 106.0, 110.0, np.nan]
|
||||
close = _close(values)
|
||||
|
||||
forward = _compute_forward_returns({"close": close}, horizon=3)
|
||||
|
||||
assert np.isnan(forward["AAA"].iloc[2])
|
||||
|
||||
|
||||
def test_horizon_nan_close_at_t_never_bridges() -> None:
|
||||
# Same geometry, NaN at the sampled row t itself.
|
||||
values = [100.0, 101.0, np.nan, 106.0, 110.0, 115.0]
|
||||
close = _close(values)
|
||||
|
||||
forward = _compute_forward_returns({"close": close}, horizon=3)
|
||||
|
||||
assert np.isnan(forward["AAA"].iloc[2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, -21])
|
||||
def test_horizon_below_one_raises(bad: int) -> None:
|
||||
close = _close([100.0, 101.0, 102.0])
|
||||
|
||||
with pytest.raises(ValueError, match="horizon"):
|
||||
_compute_forward_returns({"close": close}, horizon=bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["3", 1.5, True, None])
|
||||
def test_horizon_non_int_raises(bad: object) -> None:
|
||||
close = _close([100.0, 101.0, 102.0])
|
||||
|
||||
with pytest.raises(ValueError, match="horizon"):
|
||||
_compute_forward_returns({"close": close}, horizon=bad)
|
||||
|
||||
|
||||
def test_horizon_missing_close_still_raises() -> None:
|
||||
with pytest.raises(ValueError, match="close"):
|
||||
_compute_forward_returns({}, horizon=3)
|
||||
@@ -0,0 +1,521 @@
|
||||
"""Threading tests for monthly-horizon IC support (issue #1789 follow-up #1).
|
||||
|
||||
Covers the ``horizon`` parameter end to end without touching the network:
|
||||
run_bench / run_bench_strict result meta, run_alpha_bench parameter
|
||||
validation, and the CLI ``--horizon`` surface. Engine internals are
|
||||
monkeypatched with fakes following the existing bench-test patterns
|
||||
(test_bench_multiple_testing.py / test_alpha_bench_strict_cli.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.factors import cli_handlers
|
||||
from src.factors.bench_runner import run_bench
|
||||
from src.factors.bench_runner_strict import run_bench_strict
|
||||
from src.tools.alpha_bench_tool import _validate_horizon, run_alpha_bench
|
||||
|
||||
|
||||
# ── run_bench meta ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_panel(n_symbols: int = 6, n_days: int = 60) -> dict[str, pd.DataFrame]:
|
||||
rng = np.random.default_rng(42)
|
||||
dates = pd.date_range("2020-01-01", periods=n_days, freq="B")
|
||||
symbols = [f"SYM{i:03d}" for i in range(n_symbols)]
|
||||
close = pd.DataFrame(
|
||||
np.cumsum(rng.standard_normal((n_days, n_symbols)), axis=0) + 100,
|
||||
index=dates,
|
||||
columns=symbols,
|
||||
)
|
||||
return {"close": close, "open": close, "high": close, "low": close, "volume": close}
|
||||
|
||||
|
||||
def _setup_bench(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> MagicMock:
|
||||
panel = _make_panel()
|
||||
rng = np.random.default_rng(7)
|
||||
|
||||
reg = MagicMock()
|
||||
reg.list.return_value = ["alpha_0", "alpha_1"]
|
||||
reg.get.return_value = MagicMock(meta={"theme": ["test"], "formula_latex": ""})
|
||||
reg.compute.side_effect = lambda aid, p: pd.DataFrame(
|
||||
rng.standard_normal(p["close"].shape),
|
||||
index=p["close"].index,
|
||||
columns=p["close"].columns,
|
||||
)
|
||||
|
||||
def fake_fwd(p: dict[str, pd.DataFrame], horizon: int = 1) -> pd.DataFrame:
|
||||
captured["horizon"] = horizon
|
||||
return p["close"].pct_change(fill_method=None).shift(-1)
|
||||
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", lambda u, p: panel)
|
||||
monkeypatch.setattr("src.factors.bench_runner._compute_forward_returns", fake_fwd)
|
||||
monkeypatch.setattr("src.factors.bench_runner.get_default_registry", lambda: reg)
|
||||
return reg
|
||||
|
||||
|
||||
def test_run_bench_default_meta_says_daily(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
reg = _setup_bench(monkeypatch, captured)
|
||||
|
||||
result = run_bench("test_zoo", "mock", "2020-2021", registry=reg)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["horizon"] == 1
|
||||
assert result["forward_return_sampling"] == "daily"
|
||||
assert captured["horizon"] == 1
|
||||
|
||||
|
||||
def test_run_bench_horizon_meta_says_non_overlapping(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
reg = _setup_bench(monkeypatch, captured)
|
||||
|
||||
result = run_bench("test_zoo", "mock", "2020-2021", registry=reg, horizon=21)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["horizon"] == 21
|
||||
assert result["forward_return_sampling"] == "non_overlapping"
|
||||
assert captured["horizon"] == 21
|
||||
|
||||
|
||||
def test_run_bench_invalid_horizon_errors_without_crashing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# No fake for _compute_forward_returns here: the real engine validation
|
||||
# must turn horizon=0 into a clean error entry, not a traceback.
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner._load_universe_panel", lambda u, p: _make_panel()
|
||||
)
|
||||
reg = MagicMock()
|
||||
reg.list.return_value = ["alpha_0"]
|
||||
reg.get.return_value = MagicMock(meta={"theme": ["test"], "formula_latex": ""})
|
||||
|
||||
result = run_bench("test_zoo", "mock", "2020-2021", registry=reg, horizon=0)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "horizon" in result["error"]
|
||||
|
||||
|
||||
# ── run_bench_strict meta ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _StubRegistry:
|
||||
def list(self, *, zoo: str) -> list[str]: # noqa: ARG002
|
||||
return ["test_signal"]
|
||||
|
||||
def get(self, aid: str) -> Any: # noqa: ARG002
|
||||
class _Handle:
|
||||
meta = {"theme": ["test"], "formula_latex": "stub"}
|
||||
|
||||
return _Handle()
|
||||
|
||||
def compute(self, aid: str, panel: dict[str, pd.DataFrame]) -> pd.DataFrame: # noqa: ARG002
|
||||
return panel["close"].pct_change(5)
|
||||
|
||||
|
||||
def _setup_strict(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None:
|
||||
rng = np.random.default_rng(0)
|
||||
idx = pd.date_range("2024-01-01", periods=80, freq="D")
|
||||
cols = [f"S{i}" for i in range(8)]
|
||||
close = pd.DataFrame(
|
||||
100.0 + np.cumsum(rng.normal(size=(80, 8)), axis=0), index=idx, columns=cols
|
||||
)
|
||||
panel = {
|
||||
"close": close, "high": close * 1.01, "low": close * 0.99,
|
||||
"open": close, "volume": close * 0 + 1_000_000,
|
||||
"vwap": close, "amount": close * 1_000_000,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner_strict._load_universe_panel",
|
||||
lambda universe, period: panel, # noqa: ARG005
|
||||
)
|
||||
|
||||
def fake_fwd(p: dict[str, pd.DataFrame], horizon: int = 1) -> pd.DataFrame:
|
||||
captured["horizon"] = horizon
|
||||
return p["close"].pct_change(fill_method=None).shift(-1)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner_strict._compute_forward_returns",
|
||||
fake_fwd,
|
||||
)
|
||||
|
||||
|
||||
def test_run_bench_strict_accepts_horizon_and_carries_meta(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
_setup_strict(monkeypatch, captured)
|
||||
|
||||
result = run_bench_strict(
|
||||
zoo="alpha101", universe="csi300", period="2024-2024",
|
||||
random_control=False, registry=_StubRegistry(), horizon=21,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["horizon"] == 21
|
||||
assert result["forward_return_sampling"] == "non_overlapping"
|
||||
assert captured["horizon"] == 21
|
||||
|
||||
|
||||
def test_run_bench_strict_default_meta_says_daily(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
_setup_strict(monkeypatch, captured)
|
||||
|
||||
result = run_bench_strict(
|
||||
zoo="alpha101", universe="csi300", period="2024-2024",
|
||||
random_control=False, registry=_StubRegistry(),
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["horizon"] == 1
|
||||
assert result["forward_return_sampling"] == "daily"
|
||||
assert captured["horizon"] == 1
|
||||
|
||||
|
||||
# ── Tool-level parameter validation ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, 253, "21", 1.5, True])
|
||||
def test_run_alpha_bench_rejects_invalid_horizon_before_touching_the_engine(bad: object) -> None:
|
||||
# Validation must fire before registry init / panel load so this stays
|
||||
# offline-safe with a real-looking universe and period.
|
||||
envelope = run_alpha_bench(
|
||||
universe="sp500", period="2020-2024", zoo="alpha101", horizon=bad
|
||||
)
|
||||
|
||||
assert envelope["status"] == "error"
|
||||
assert "horizon" in envelope["error"]
|
||||
|
||||
|
||||
def test_validate_horizon_accepts_in_range_values() -> None:
|
||||
assert _validate_horizon(1) == 1
|
||||
assert _validate_horizon(21) == 21
|
||||
assert _validate_horizon(252) == 252
|
||||
assert _validate_horizon(None) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, 253, "21", 1.5, True])
|
||||
def test_validate_horizon_rejects_out_of_range_or_non_int(bad: object) -> None:
|
||||
with pytest.raises(ValueError, match="horizon"):
|
||||
_validate_horizon(bad)
|
||||
|
||||
|
||||
# ── CLI surface ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _ns(**overrides: Any) -> argparse.Namespace:
|
||||
base = dict(
|
||||
zoo="alpha101",
|
||||
universe="csi300",
|
||||
period="2020-2025",
|
||||
top=20,
|
||||
yes=True,
|
||||
strict=False,
|
||||
oos_split=None,
|
||||
training_cutoff=None,
|
||||
random_seeds=5,
|
||||
horizon=1,
|
||||
)
|
||||
base.update(overrides)
|
||||
return argparse.Namespace(**base)
|
||||
|
||||
|
||||
def _legacy_result() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"alive": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": "alpha001",
|
||||
"ic_mean": 0.05,
|
||||
"ic_std": 0.01,
|
||||
"ir": 0.9,
|
||||
"ic_positive_ratio": 0.7,
|
||||
"ic_count": 100,
|
||||
"theme": ["momentum"],
|
||||
"formula_latex": "x",
|
||||
"_category": "alive",
|
||||
}
|
||||
],
|
||||
"skipped": [],
|
||||
"wall_seconds": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _strict_result() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"confirmed_alive": 1,
|
||||
"train_only": 0,
|
||||
"reversed_strict": 0,
|
||||
"noise": 0,
|
||||
"rows": [
|
||||
{
|
||||
"id": "alpha001",
|
||||
"ic_mean": 0.05,
|
||||
"ic_std": 0.01,
|
||||
"ir": 0.9,
|
||||
"ic_positive_ratio": 0.7,
|
||||
"ic_count": 100,
|
||||
"theme": ["momentum"],
|
||||
"formula_latex": "x",
|
||||
"_category": "confirmed_alive",
|
||||
"alpha_t_full": 2.7,
|
||||
"alpha_t_train": None,
|
||||
"alpha_t_test": None,
|
||||
"random_ic_mean": 0.0,
|
||||
}
|
||||
],
|
||||
"skipped": [],
|
||||
"wall_seconds": 1.0,
|
||||
}
|
||||
|
||||
|
||||
class _FakeReg:
|
||||
def list(self, zoo=None): # noqa: ARG002
|
||||
return ["alpha001"]
|
||||
|
||||
def get(self, aid): # noqa: ARG002
|
||||
class _Entry:
|
||||
zoo = "alpha101"
|
||||
meta = {"theme": ["momentum"], "formula_latex": "x"}
|
||||
|
||||
return _Entry()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _reg(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(cli_handlers, "Registry", _FakeReg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _no_report(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
import src.tools.alpha_bench_tool as tool
|
||||
|
||||
monkeypatch.setattr(tool, "_default_output_dir", lambda: tmp_path)
|
||||
|
||||
|
||||
def test_cli_horizon_reaches_legacy_runner(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
import src.factors.bench_runner as legacy_mod
|
||||
|
||||
called: dict[str, Any] = {}
|
||||
|
||||
def fake_legacy(**kwargs):
|
||||
called.update(kwargs)
|
||||
return _legacy_result()
|
||||
|
||||
monkeypatch.setattr(legacy_mod, "run_bench", fake_legacy)
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(horizon=21))
|
||||
|
||||
assert rc == 0
|
||||
assert called["horizon"] == 21
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
def test_cli_horizon_default_reaches_legacy_runner_as_one(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
import src.factors.bench_runner as legacy_mod
|
||||
|
||||
called: dict[str, Any] = {}
|
||||
|
||||
def fake_legacy(**kwargs):
|
||||
called.update(kwargs)
|
||||
return _legacy_result()
|
||||
|
||||
monkeypatch.setattr(legacy_mod, "run_bench", fake_legacy)
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns())
|
||||
|
||||
assert rc == 0
|
||||
assert called["horizon"] == 1
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
def test_cli_horizon_reaches_strict_runner(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
import src.factors.bench_runner_strict as strict_mod
|
||||
|
||||
called: dict[str, Any] = {}
|
||||
|
||||
def fake_strict(zoo, universe, period, **kwargs): # noqa: ARG001
|
||||
called.update(kwargs)
|
||||
return _strict_result()
|
||||
|
||||
monkeypatch.setattr(strict_mod, "run_bench_strict", fake_strict)
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(strict=True, horizon=21))
|
||||
|
||||
assert rc == 0
|
||||
assert called["horizon"] == 21
|
||||
assert called["random_control"] is True
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
def test_cli_rejects_horizon_below_one_with_usage_exit_code(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg
|
||||
) -> None:
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(horizon=0))
|
||||
|
||||
assert rc == 2
|
||||
assert "--horizon" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_horizon_namespace_without_attribute_defaults_to_one(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
# Pre-existing callers/tests build Namespaces without a horizon attribute;
|
||||
# the handler must fall back to 1 rather than raise AttributeError.
|
||||
import src.factors.bench_runner as legacy_mod
|
||||
|
||||
called: dict[str, Any] = {}
|
||||
|
||||
def fake_legacy(**kwargs):
|
||||
called.update(kwargs)
|
||||
return _legacy_result()
|
||||
|
||||
monkeypatch.setattr(legacy_mod, "run_bench", fake_legacy)
|
||||
ns = _ns()
|
||||
del ns.horizon
|
||||
rc = cli_handlers.cmd_alpha_bench(ns)
|
||||
|
||||
assert rc == 0
|
||||
assert called["horizon"] == 1
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
def test_horizon_argparse_parses_and_rejects() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers()
|
||||
cli_handlers.add_subparser(sub)
|
||||
|
||||
args = parser.parse_args(["alpha", "bench", "--zoo", "alpha101", "--horizon", "21"])
|
||||
assert args.horizon == 21
|
||||
|
||||
args_default = parser.parse_args(["alpha", "bench", "--zoo", "alpha101"])
|
||||
assert args_default.horizon == 1
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
parser.parse_args(["alpha", "bench", "--zoo", "alpha101", "--horizon", "0"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
parser.parse_args(["alpha", "bench", "--zoo", "alpha101", "--horizon", "abc"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
def test_json_envelope_still_parses_with_horizon(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
import src.factors.bench_runner as legacy_mod
|
||||
|
||||
monkeypatch.setattr(legacy_mod, "run_bench", lambda **kw: _legacy_result())
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(horizon=21))
|
||||
assert rc == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
start = out.find("\n{")
|
||||
assert start >= 0
|
||||
envelope = json.loads(out[start + 1 :])
|
||||
assert envelope["status"] == "ok"
|
||||
|
||||
|
||||
def test_json_envelope_discloses_horizon_and_exclusions(
|
||||
capsys, monkeypatch: pytest.MonkeyPatch, _reg, _no_report
|
||||
) -> None:
|
||||
import src.factors.bench_runner as legacy_mod
|
||||
|
||||
result = _legacy_result()
|
||||
result.update(
|
||||
{
|
||||
"horizon": 21,
|
||||
"forward_return_sampling": "non_overlapping",
|
||||
"universe_excluded": ["fund_roa"],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(legacy_mod, "run_bench", lambda **kw: result)
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(horizon=21))
|
||||
assert rc == 0
|
||||
|
||||
out = capsys.readouterr().out
|
||||
envelope = json.loads(out[out.find("\n{") + 1 :])
|
||||
assert envelope["horizon"] == 21
|
||||
assert envelope["forward_return_sampling"] == "non_overlapping"
|
||||
assert envelope["universe_excluded"] == ["fund_roa"]
|
||||
|
||||
|
||||
def test_parser_rejects_horizon_above_the_cap() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
cli_handlers.add_subparser(sub)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
parser.parse_args(["alpha", "bench", "--zoo", "alpha101", "--horizon", "253"])
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
def test_cli_rejects_horizon_above_cap_with_usage_exit_code(capsys, _reg) -> None:
|
||||
rc = cli_handlers.cmd_alpha_bench(_ns(horizon=253))
|
||||
|
||||
assert rc == 2
|
||||
assert "--horizon" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [253, 10_000, True, "21"])
|
||||
def test_run_bench_rejects_out_of_range_horizon_before_panel_load(
|
||||
monkeypatch: pytest.MonkeyPatch, bad: object
|
||||
) -> None:
|
||||
from unittest.mock import Mock
|
||||
|
||||
fetch = Mock()
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", fetch)
|
||||
reg = MagicMock()
|
||||
reg.list.return_value = ["alpha_0"]
|
||||
reg.get.return_value = MagicMock(meta={"theme": ["test"], "formula_latex": ""})
|
||||
|
||||
result = run_bench("test_zoo", "mock", "2020-2021", registry=reg, horizon=bad)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "horizon" in result["error"]
|
||||
fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_multi_zoo_aggregate_discloses_horizon_and_exclusions() -> None:
|
||||
"""The all-zoos aggregation must not drop the per-sub-run disclosure keys."""
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
reg = SimpleNamespace(
|
||||
get=lambda aid: SimpleNamespace(zoo="fundamental"),
|
||||
list=lambda zoo=None: ["fund_cn"],
|
||||
)
|
||||
|
||||
def fake_run_bench(**_kw: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"rows": [{"id": "fund_cn", "ir": 0.5}],
|
||||
"skipped": [],
|
||||
"horizon": 21,
|
||||
"forward_return_sampling": "non_overlapping",
|
||||
"universe_excluded": ["fund_us"],
|
||||
}
|
||||
|
||||
out = cli_handlers._run_all_zoos_with_progress(
|
||||
target_ids=["fund_cn"],
|
||||
universe="csi300",
|
||||
period="2020-2021",
|
||||
top=5,
|
||||
start_ts=time.monotonic(),
|
||||
reg=reg,
|
||||
run_bench=fake_run_bench,
|
||||
)
|
||||
|
||||
assert out["status"] == "ok"
|
||||
assert out["horizon"] == 21
|
||||
assert out["forward_return_sampling"] == "non_overlapping"
|
||||
assert out["universe_excluded"] == ["fund_us"]
|
||||
@@ -91,6 +91,11 @@ def test_csi300_reports_point_in_time_membership(
|
||||
"constituent_count": 3,
|
||||
"price_adjustment": "qfq",
|
||||
"dropped_unadjustable": 0,
|
||||
# Sector labels attach later in _prepare_bench_panel, outside the
|
||||
# pickled price panel; the load-time defaults are the disclosure for
|
||||
# a run where the attach never happens or degrades.
|
||||
"sector_source": None,
|
||||
"sector_coverage": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +146,8 @@ def test_csi300_reports_hand_picked_fallback(
|
||||
"constituent_count": 2,
|
||||
"price_adjustment": "qfq",
|
||||
"dropped_unadjustable": 0,
|
||||
"sector_source": None,
|
||||
"sector_coverage": 0.0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ def _setup(monkeypatch, n_alphas=8, n_days=120):
|
||||
)
|
||||
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", lambda u, p: panel)
|
||||
monkeypatch.setattr("src.factors.bench_runner._compute_forward_returns", lambda p: return_df)
|
||||
monkeypatch.setattr("src.factors.bench_runner._compute_forward_returns", lambda p, horizon=1: return_df)
|
||||
monkeypatch.setattr("src.factors.bench_runner.get_default_registry", lambda: reg)
|
||||
return reg
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestParallelBench:
|
||||
mock_reg.compute.side_effect = lambda aid, p: p["close"] * np.random.randn(*p["close"].shape)
|
||||
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", lambda u, p: panel)
|
||||
monkeypatch.setattr("src.factors.bench_runner._compute_forward_returns", lambda p: return_df)
|
||||
monkeypatch.setattr("src.factors.bench_runner._compute_forward_returns", lambda p, horizon=1: return_df)
|
||||
monkeypatch.setattr("src.factors.bench_runner.get_default_registry", lambda: mock_reg)
|
||||
return mock_reg
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Universe-support selection: fund:* alphas exclude with disclosure off sp500.
|
||||
|
||||
``--zoo fundamental --universe csi300`` used to hard-fail the whole bench
|
||||
("Choose the S&P 500 universe") because the SEC-fed ``fund:*`` alphas cannot
|
||||
be served there, while the mirror case — CN ``val:*`` alphas on sp500 —
|
||||
skips cleanly per alpha. The runners now exclude the unservable alphas up
|
||||
front, disclose them in the result envelope, and only error when nothing is
|
||||
left to bench. ``_validate_bench_data_requirements`` stays as the backstop
|
||||
for direct callers (its own pinned tests are untouched).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.tools.alpha_bench_tool import _split_alphas_by_universe_support
|
||||
|
||||
_SPECS = {
|
||||
"fund_us": {"columns_required": ["fund:roe"], "theme": ["quality"], "formula_latex": ""},
|
||||
"fund_cn": {"columns_required": ["val:pb"], "theme": ["value"], "formula_latex": ""},
|
||||
"px": {"columns_required": ["close"], "theme": ["momentum"], "formula_latex": ""},
|
||||
}
|
||||
|
||||
|
||||
def _registry(specs: dict[str, dict]) -> MagicMock:
|
||||
reg = MagicMock()
|
||||
reg.list.return_value = list(specs)
|
||||
reg.get.side_effect = lambda aid: SimpleNamespace(meta=specs[aid])
|
||||
return reg
|
||||
|
||||
|
||||
# ── splitter ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_splitter_keeps_everything_on_sp500() -> None:
|
||||
kept, excluded = _split_alphas_by_universe_support(_registry(_SPECS), list(_SPECS), "sp500")
|
||||
|
||||
assert kept == list(_SPECS)
|
||||
assert excluded == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("universe", ["csi300", "btc-usdt"])
|
||||
def test_splitter_excludes_fund_alphas_off_sp500(universe: str) -> None:
|
||||
kept, excluded = _split_alphas_by_universe_support(_registry(_SPECS), list(_SPECS), universe)
|
||||
|
||||
assert kept == ["fund_cn", "px"]
|
||||
assert excluded == ["fund_us"]
|
||||
|
||||
|
||||
def test_splitter_treats_missing_columns_required_as_benchable() -> None:
|
||||
reg = _registry({"bare": {"theme": ["test"], "formula_latex": ""}})
|
||||
|
||||
kept, excluded = _split_alphas_by_universe_support(reg, ["bare"], "csi300")
|
||||
|
||||
assert kept == ["bare"]
|
||||
assert excluded == []
|
||||
|
||||
|
||||
# ── run_bench ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _panel() -> dict[str, pd.DataFrame]:
|
||||
rng = np.random.default_rng(7)
|
||||
dates = pd.date_range("2020-01-01", periods=120, freq="B")
|
||||
close = pd.DataFrame(
|
||||
np.cumsum(rng.standard_normal((120, 6)), axis=0) + 100.0,
|
||||
index=dates,
|
||||
columns=[f"SYM{i}" for i in range(6)],
|
||||
)
|
||||
return {"close": close}
|
||||
|
||||
|
||||
def test_run_bench_excludes_with_disclosure_and_benches_the_rest(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.factors.bench_runner import run_bench
|
||||
|
||||
panel = _panel()
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", lambda u, p: panel)
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner._prepare_bench_panel", lambda reg, ids, pnl, u, p: pnl
|
||||
)
|
||||
monkeypatch.setenv("VIBE_TRADING_BENCH_WORKERS", "1")
|
||||
reg = _registry(_SPECS)
|
||||
rng = np.random.default_rng(11)
|
||||
reg.compute.side_effect = lambda aid, p: pd.DataFrame(
|
||||
rng.standard_normal(p["close"].shape), index=p["close"].index, columns=p["close"].columns
|
||||
)
|
||||
|
||||
result = run_bench(zoo="fundamental", universe="csi300", period="2020-2021", registry=reg)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["universe_excluded"] == ["fund_us"]
|
||||
assert result["n_alphas_tested"] == 2
|
||||
|
||||
|
||||
def test_run_bench_all_excluded_errors_before_loading_the_panel(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.factors.bench_runner import run_bench
|
||||
|
||||
fetch = Mock()
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", fetch)
|
||||
reg = _registry({"fund_us": _SPECS["fund_us"]})
|
||||
|
||||
result = run_bench(zoo="fundamental", universe="csi300", period="2020-2021", registry=reg)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "SEC US filings" in result["error"]
|
||||
assert result["universe_excluded"] == ["fund_us"]
|
||||
fetch.assert_not_called()
|
||||
|
||||
|
||||
def test_run_bench_sp500_selection_is_untouched(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from src.factors.bench_runner import run_bench
|
||||
|
||||
panel = _panel()
|
||||
monkeypatch.setattr("src.factors.bench_runner._load_universe_panel", lambda u, p: panel)
|
||||
monkeypatch.setattr(
|
||||
"src.factors.bench_runner._prepare_bench_panel", lambda reg, ids, pnl, u, p: pnl
|
||||
)
|
||||
monkeypatch.setenv("VIBE_TRADING_BENCH_WORKERS", "1")
|
||||
reg = _registry(_SPECS)
|
||||
rng = np.random.default_rng(13)
|
||||
reg.compute.side_effect = lambda aid, p: pd.DataFrame(
|
||||
rng.standard_normal(p["close"].shape), index=p["close"].index, columns=p["close"].columns
|
||||
)
|
||||
|
||||
result = run_bench(zoo="fundamental", universe="sp500", period="2020-2021", registry=reg)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert "universe_excluded" not in result
|
||||
assert result["n_alphas_tested"] == 3
|
||||
|
||||
|
||||
# ── tool path ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_tool_all_excluded_returns_error_envelope(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
import src.tools.alpha_bench_tool as tool
|
||||
|
||||
reg = _registry({"fund_us": _SPECS["fund_us"]})
|
||||
monkeypatch.setattr("src.factors.registry.get_default_registry", lambda: reg)
|
||||
monkeypatch.setattr(tool, "_default_output_dir", lambda: tmp_path)
|
||||
load = Mock()
|
||||
monkeypatch.setattr(tool, "_load_universe_panel", load)
|
||||
|
||||
result = tool.run_alpha_bench(universe="csi300", period="2020-2021", zoo="fundamental")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "SEC US filings" in result["error"]
|
||||
assert result["universe_excluded"] == ["fund_us"]
|
||||
load.assert_not_called()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""PIT contract for ``backtest.loaders.industry_loader``.
|
||||
|
||||
Every test injects a fake tushare API — no network. The behaviours pinned here
|
||||
are the ones a silent bug would ruin: ``in_date`` inclusive / ``out_date``
|
||||
exclusive membership boundaries, later-``in_date``-wins on overlapping rows,
|
||||
the 2000-row truncation guard's per-L1 refetch, placeholder-token rejection,
|
||||
graceful degradation on provider failure, and the never-invent-a-label rule
|
||||
(unmapped symbols stay NaN).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backtest.loaders.industry_loader import (
|
||||
load_industry_frame,
|
||||
load_industry_frame_with_coverage,
|
||||
validate_industry_params,
|
||||
)
|
||||
|
||||
_INDEX = pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03", "2024-01-04"])
|
||||
_SYMBOLS = ["600000.SH", "000001.SZ"]
|
||||
|
||||
|
||||
def _row(
|
||||
ts_code: str,
|
||||
l1_name: str,
|
||||
in_date: str,
|
||||
out_date: str | None = None,
|
||||
*,
|
||||
l2_name: str = "L2-default",
|
||||
l3_name: str = "L3-default",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"l1_code": f"800-{l1_name}",
|
||||
"l1_name": l1_name,
|
||||
"l2_code": f"801-{l2_name}",
|
||||
"l2_name": l2_name,
|
||||
"l3_code": f"802-{l3_name}",
|
||||
"l3_name": l3_name,
|
||||
"ts_code": ts_code,
|
||||
"name": ts_code.split(".")[0],
|
||||
"in_date": in_date,
|
||||
"out_date": out_date,
|
||||
"is_new": "Y" if out_date is None else "N",
|
||||
}
|
||||
|
||||
|
||||
def _frame(rows: list[dict[str, Any]]) -> pd.DataFrame:
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
class _FakeApi:
|
||||
"""Serves a fixed membership snapshot; records every call.
|
||||
|
||||
Mirrors the real endpoint's ``is_new`` filtering (server default "Y"):
|
||||
rows carry is_new = "Y" when out_date is null, "N" otherwise.
|
||||
"""
|
||||
|
||||
def __init__(self, snapshot: pd.DataFrame | Exception) -> None:
|
||||
self.snapshot = snapshot
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def index_member_all(self, **kwargs: Any) -> pd.DataFrame:
|
||||
self.calls.append(kwargs)
|
||||
if isinstance(self.snapshot, Exception):
|
||||
raise self.snapshot
|
||||
return _filter_is_new(self.snapshot, kwargs)
|
||||
|
||||
|
||||
def _filter_is_new(frame: pd.DataFrame, kwargs: dict[str, Any]) -> pd.DataFrame:
|
||||
is_new = kwargs.get("is_new")
|
||||
if is_new is not None and "is_new" in getattr(frame, "columns", []):
|
||||
return frame[frame["is_new"] == is_new]
|
||||
return frame
|
||||
|
||||
|
||||
def _load(api: Any, symbols: list[str] | None = None, **kwargs: Any) -> pd.DataFrame:
|
||||
return load_industry_frame(
|
||||
symbols=symbols or _SYMBOLS,
|
||||
start="2024-01-01",
|
||||
end="2024-01-04",
|
||||
index=_INDEX,
|
||||
api=api,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- PIT boundaries
|
||||
|
||||
|
||||
def test_in_date_is_inclusive() -> None:
|
||||
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240102")]))
|
||||
|
||||
frame = _load(api)
|
||||
|
||||
assert pd.isna(frame.loc["2024-01-01", "600000.SH"])
|
||||
assert frame.loc["2024-01-02", "600000.SH"] == "银行"
|
||||
assert frame.loc["2024-01-04", "600000.SH"] == "银行" # null out_date = current
|
||||
|
||||
|
||||
def test_out_date_is_exclusive() -> None:
|
||||
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101", "20240103")]))
|
||||
|
||||
frame = _load(api)
|
||||
|
||||
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
||||
assert frame.loc["2024-01-02", "600000.SH"] == "银行"
|
||||
assert pd.isna(frame.loc["2024-01-03", "600000.SH"])
|
||||
assert pd.isna(frame.loc["2024-01-04", "600000.SH"])
|
||||
|
||||
|
||||
def test_historical_membership_applies_only_inside_its_interval() -> None:
|
||||
"""A closed row labels the past; the open row labels the present."""
|
||||
api = _FakeApi(
|
||||
_frame(
|
||||
[
|
||||
_row("600000.SH", "旧行业", "20230101", "20240103"),
|
||||
_row("600000.SH", "新行业", "20240103"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
frame = _load(api)
|
||||
|
||||
assert frame.loc["2024-01-01", "600000.SH"] == "旧行业"
|
||||
assert frame.loc["2024-01-02", "600000.SH"] == "旧行业"
|
||||
assert frame.loc["2024-01-03", "600000.SH"] == "新行业"
|
||||
assert frame.loc["2024-01-04", "600000.SH"] == "新行业"
|
||||
|
||||
|
||||
def test_overlapping_rows_resolve_to_the_later_in_date() -> None:
|
||||
api = _FakeApi(
|
||||
_frame(
|
||||
[
|
||||
_row("600000.SH", "A", "20240101"),
|
||||
_row("600000.SH", "B", "20240103"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
frame = _load(api)
|
||||
|
||||
assert frame.loc["2024-01-02", "600000.SH"] == "A"
|
||||
assert frame.loc["2024-01-03", "600000.SH"] == "B"
|
||||
assert frame.loc["2024-01-04", "600000.SH"] == "B"
|
||||
|
||||
|
||||
def test_row_order_does_not_change_the_winner() -> None:
|
||||
rows = [_row("600000.SH", "A", "20240101"), _row("600000.SH", "B", "20240103")]
|
||||
forward = _load(_FakeApi(_frame(rows)))
|
||||
reversed_ = _load(_FakeApi(_frame(list(reversed(rows)))))
|
||||
|
||||
pd.testing.assert_frame_equal(forward, reversed_)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- never invent
|
||||
|
||||
|
||||
def test_unmapped_symbols_and_dates_stay_nan() -> None:
|
||||
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101")]))
|
||||
|
||||
frame, coverage = load_industry_frame_with_coverage(
|
||||
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
||||
)
|
||||
|
||||
assert frame["000001.SZ"].isna().all()
|
||||
assert list(frame.columns) == _SYMBOLS
|
||||
assert list(frame.index) == list(_INDEX)
|
||||
assert coverage == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_rows_with_a_blank_label_are_dropped_not_defaulted() -> None:
|
||||
api = _FakeApi(
|
||||
_frame(
|
||||
[
|
||||
_row("600000.SH", None, "20240101"),
|
||||
{"ts_code": "000001.SZ", "l1_name": "银行", "in_date": None, "out_date": None},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
frame, coverage = load_industry_frame_with_coverage(
|
||||
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
||||
)
|
||||
|
||||
assert frame.isna().all().all()
|
||||
assert coverage == 0.0
|
||||
|
||||
|
||||
def test_level_selects_the_name_column() -> None:
|
||||
api = _FakeApi(
|
||||
_frame([_row("600000.SH", "L1x", "20240101", l2_name="L2x", l3_name="L3x")])
|
||||
)
|
||||
|
||||
assert _load(api, level="L1").loc["2024-01-01", "600000.SH"] == "L1x"
|
||||
assert _load(api, level="L2").loc["2024-01-01", "600000.SH"] == "L2x"
|
||||
assert _load(api, level="L3").loc["2024-01-01", "600000.SH"] == "L3x"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ validation gate
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", ["L0", "l1", "L4", ""])
|
||||
def test_invalid_level_raises(level: str) -> None:
|
||||
with pytest.raises(ValueError, match="unsupported industry level"):
|
||||
validate_industry_params(level, "SW2021")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("src", ["", " "])
|
||||
def test_empty_src_raises(src: str) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
validate_industry_params("L1", src)
|
||||
|
||||
|
||||
def test_invalid_level_raises_through_the_loader() -> None:
|
||||
with pytest.raises(ValueError, match="unsupported industry level"):
|
||||
_load(_FakeApi(_frame([])), level="L9")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ truncation guard
|
||||
|
||||
|
||||
def test_exactly_2000_rows_triggers_the_per_l1_refetch() -> None:
|
||||
"""A snapshot at the documented cap is treated as truncated and refetched."""
|
||||
truncated = _frame([_row(f"99999{i}.SZ", "填充", "20240101") for i in range(2000)])
|
||||
real_a = _frame([_row("600000.SH", "银行", "20240101")])
|
||||
real_b = _frame([_row("000001.SZ", "非银金融", "20240101")])
|
||||
|
||||
class _TruncatingApi:
|
||||
def __init__(self) -> None:
|
||||
self.member_calls: list[dict[str, Any]] = []
|
||||
self.classify_calls: list[dict[str, Any]] = []
|
||||
|
||||
def index_member_all(self, **kwargs: Any) -> pd.DataFrame:
|
||||
self.member_calls.append(kwargs)
|
||||
if "l1_code" not in kwargs:
|
||||
return _filter_is_new(truncated, kwargs)
|
||||
return _filter_is_new(
|
||||
{"801780.SI": real_a, "801790.SI": real_b}[kwargs["l1_code"]], kwargs
|
||||
)
|
||||
|
||||
def index_classify(self, **kwargs: Any) -> pd.DataFrame:
|
||||
self.classify_calls.append(kwargs)
|
||||
return pd.DataFrame(
|
||||
{"index_code": ["801780.SI", "801790.SI"], "industry_name": ["银行", "非银金融"]}
|
||||
)
|
||||
|
||||
api = _TruncatingApi()
|
||||
frame = _load(api)
|
||||
|
||||
assert api.classify_calls == [{"level": "L1", "src": "SW2021"}]
|
||||
assert [call.get("l1_code") for call in api.member_calls] == [
|
||||
None,
|
||||
"801780.SI",
|
||||
"801790.SI",
|
||||
None,
|
||||
]
|
||||
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
||||
assert frame.loc["2024-01-01", "000001.SZ"] == "非银金融"
|
||||
|
||||
|
||||
def test_below_the_cap_snapshot_is_used_as_is() -> None:
|
||||
snapshot = _frame([_row("600000.SH", "银行", "20240101")])
|
||||
api = _FakeApi(snapshot)
|
||||
|
||||
frame = _load(api)
|
||||
|
||||
assert api.calls == [{"is_new": "Y"}, {"is_new": "N"}]
|
||||
assert frame.loc["2024-01-01", "600000.SH"] == "银行"
|
||||
|
||||
|
||||
def test_src_is_never_passed_to_index_member_all() -> None:
|
||||
"""Pins the live-verified endpoint quirk: an undocumented ``src`` param
|
||||
silently empties the index_member_all response (0 rows, HTTP 200)."""
|
||||
api = _FakeApi(_frame([_row("600000.SH", "银行", "20240101")]))
|
||||
|
||||
_load(api, src="SW2021")
|
||||
|
||||
assert api.calls, "expected index_member_all to be called"
|
||||
for call in api.calls:
|
||||
assert "src" not in call
|
||||
|
||||
|
||||
# ------------------------------------------------------------- failure paths
|
||||
|
||||
|
||||
def test_provider_exception_degrades_to_an_all_none_frame() -> None:
|
||||
api = _FakeApi(RuntimeError("provider down"))
|
||||
|
||||
frame, coverage = load_industry_frame_with_coverage(
|
||||
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX, api=api
|
||||
)
|
||||
|
||||
assert frame.shape == (len(_INDEX), len(_SYMBOLS))
|
||||
assert frame.isna().all().all()
|
||||
assert coverage == 0.0
|
||||
|
||||
|
||||
def test_empty_snapshot_degrades_to_an_all_none_frame() -> None:
|
||||
frame = _load(_FakeApi(_frame([])))
|
||||
|
||||
assert frame.isna().all().all()
|
||||
|
||||
|
||||
def test_snapshot_missing_pit_columns_degrades_to_an_all_none_frame() -> None:
|
||||
"""A payload without in_date/out_date cannot establish membership — degrade, never guess."""
|
||||
frame = _load(_FakeApi(pd.DataFrame({"ts_code": ["600000.SH"], "l1_name": ["银行"]})))
|
||||
|
||||
assert frame.isna().all().all()
|
||||
|
||||
|
||||
def test_placeholder_token_raises_runtime_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TUSHARE_TOKEN", "")
|
||||
monkeypatch.setitem(sys.modules, "tushare", SimpleNamespace(pro_api=lambda _token: None))
|
||||
|
||||
with pytest.raises(RuntimeError, match="TUSHARE_TOKEN not configured"):
|
||||
load_industry_frame(
|
||||
symbols=_SYMBOLS, start="2024-01-01", end="2024-01-04", index=_INDEX
|
||||
)
|
||||
@@ -92,6 +92,7 @@ def test_alpha_bench_forwards_selection(monkeypatch) -> None:
|
||||
"universe": "sp500",
|
||||
"period": "2018-2025",
|
||||
"top": 10,
|
||||
"horizon": 1,
|
||||
"zoo": "alpha101",
|
||||
"output_dir": str(output_dir.resolve()),
|
||||
},
|
||||
@@ -187,6 +188,50 @@ def test_alpha_bench_rejects_unbounded_top(monkeypatch) -> None:
|
||||
assert registry.calls == []
|
||||
|
||||
|
||||
def test_alpha_bench_forwards_horizon(monkeypatch) -> None:
|
||||
registry = _RecordingRegistry()
|
||||
monkeypatch.setattr(mcp_server, "_get_registry", lambda: registry)
|
||||
|
||||
_ALPHA_BENCH(
|
||||
universe="sp500",
|
||||
period="2018-2025",
|
||||
zoo="fundamental",
|
||||
horizon=21,
|
||||
)
|
||||
|
||||
assert registry.calls == [
|
||||
(
|
||||
"alpha_bench",
|
||||
{
|
||||
"universe": "sp500",
|
||||
"period": "2018-2025",
|
||||
"top": 20,
|
||||
"horizon": 21,
|
||||
"zoo": "fundamental",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, 253, 21.5, "21", True])
|
||||
def test_alpha_bench_rejects_invalid_horizon(monkeypatch, bad) -> None:
|
||||
registry = _RecordingRegistry()
|
||||
monkeypatch.setattr(mcp_server, "_get_registry", lambda: registry)
|
||||
|
||||
result = json.loads(
|
||||
_ALPHA_BENCH(
|
||||
universe="sp500",
|
||||
period="2018-2025",
|
||||
zoo="alpha101",
|
||||
horizon=bad,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "between 1 and 252" in result["error"]
|
||||
assert registry.calls == []
|
||||
|
||||
|
||||
def test_alpha_bench_rejects_output_outside_allowed_roots(monkeypatch) -> None:
|
||||
registry = _RecordingRegistry()
|
||||
monkeypatch.setattr(mcp_server, "_get_registry", lambda: registry)
|
||||
@@ -230,7 +275,7 @@ def test_alpha_bench_does_not_follow_preexisting_report_symlink(monkeypatch, tmp
|
||||
monkeypatch.setattr(alpha_bench_module, "datetime", _FixedDateTime)
|
||||
monkeypatch.setattr(alpha_bench_module.secrets, "token_hex", lambda length: "fixed")
|
||||
monkeypatch.setattr(alpha_bench_module, "_load_universe_panel", lambda *args, **kwargs: {})
|
||||
monkeypatch.setattr(alpha_bench_module, "_compute_forward_returns", lambda panel: {})
|
||||
monkeypatch.setattr(alpha_bench_module, "_compute_forward_returns", lambda panel, horizon=1: {})
|
||||
monkeypatch.setattr(
|
||||
alpha_bench_module,
|
||||
"_bench_one_alpha",
|
||||
|
||||
@@ -180,6 +180,10 @@
|
||||
"en": "Trailing dividend yield from the val: namespace (tushare daily_basic dv_ttm, CN A-share only), reported in percent units — cross-sectional z-scoring is scale-invariant, so no unit conversion is applied. High dividend yield scores high. Keyed by trade_date, so PIT-safe by construction with no filing lag. Missing dates or non-CN symbols stay NaN and are excluded from that date's cross-section. Lineage: JoinQuant style-pro divyild; akshare/Eastmoney dv_ttm convention.",
|
||||
"zh": "来自 val: 命名空间的滚动股息率(tushare daily_basic dv_ttm,仅 CN A 股),以百分比单位报告——截面 z 打分具有尺度不变性,因此不做单位换算。高股息率得分高。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。缺失日期或非 CN 标的保持 NaN,并被排除在当日截面之外。血缘:JoinQuant style-pro divyild;akshare/东方财富 dv_ttm 口径。"
|
||||
},
|
||||
"fund_dividend_yield_ind_neutral": {
|
||||
"en": "Industry-neutralized trailing dividend yield: each name is z-scored against its Shenwan level-1 industry peers instead of the whole cross-section (sector tag from tushare index_member_all SW2021 membership, point-in-time via in_date/out_date). This removes the industry confounding that dominates raw A-share dividend yield — mature banks and utilities pay structurally high dividends while growth industries pay almost none, so a raw high-yield screen is largely an industry bet. High dividend yield relative to industry peers scores high; dv_ttm is reported in percent units and cross-sectional z-scoring is scale-invariant, so no unit conversion is applied. A name with no industry label on a date stays NaN and is excluded from that date's cross-section; an industry group with fewer than two labelled names cannot form a within-group z-score and stays NaN. Keyed by trade_date, so PIT-safe by construction with no filing lag. Membership is point-in-time but the SW2021 taxonomy itself is retroactive — a name reclassified in 2021 carries its 2021 industry back through history. Lineage: industry-neutral variant of fund_dividend_yield (#1789 follow-up).",
|
||||
"zh": "行业中性化的滚动股息率:每只股票在其申万一级行业同类中做 z 打分,而非在全截面上(sector 标签来自 tushare index_member_all SW2021 成分数据,通过 in_date/out_date 实现时点正确 PIT)。这消除了主导 A 股原始股息率的行业混淆——成熟的银行与公用事业结构性高分红,而成长行业几乎不分红,因此原始高股息筛选在很大程度上是行业押注。相对行业同类股息率高者得分高;dv_ttm 以百分比单位报告,截面 z 打分具有尺度不变性,因此不做单位换算。某日无行业标签的股票保持 NaN,并被排除在当日截面之外;标签成员不足两只的行业组无法构成组内 z 分数,保持 NaN。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。成分归属是时点正确的(PIT),但 SW2021 分类体系本身是回溯性的——2021 年被重新分类的股票会将其 2021 行业标签带回历史。血缘:fund_dividend_yield 的行业中性变体(#1789 后续)。"
|
||||
},
|
||||
"fund_earnings_growth": {
|
||||
"en": "PIT-safe earnings growth from the fundamental panel's derived field ``earnings_growth``. The schema-side formula is sign-safe: delta net_income / abs(lag_365d net_income), so a negative year-ago base does not flip the growth sign (the proposal #6 footnote resolution; the alternative NaN-on-negative-base was not chosen). Calendar-day lag per the #1564 lesson. High earnings growth scores high, matching the JoinQuant net_profit_growth_rate convention. Missing statements or a zero/missing year-ago base remain NaN and are excluded from that date's cross-section.",
|
||||
"zh": "基于基本面面板派生字段 ``earnings_growth`` 的时点正确(PIT)盈利增长。schema 侧公式符号安全:净利润变化量 / abs(滞后 365 天净利润),因此上年基数为负不会翻转增长符号(提案 #6 脚注的决议;未选择负基数置 NaN 的替代方案)。按 #1564 教训使用日历日滞后。高盈利增长得分高,与 JoinQuant net_profit_growth_rate 约定一致。缺失的财务报表或为零/缺失的上年基数保持 NaN,并被排除在当日截面之外。"
|
||||
@@ -208,10 +212,18 @@
|
||||
"en": "Nonpositive multiples are excluded rather than ranked as cheap. Inverse price-to-book from the val: namespace (tushare daily_basic pb, CN A-share only): low PB (cheap relative to book value) scores high, the daily-market-data counterpart of fund_book_to_market. Keyed by trade_date, so PIT-safe by construction with no filing lag. Missing dates or non-CN symbols stay NaN and are excluded from that date's cross-section. Lineage: JoinQuant style book_to_price_ratio; akshare/Eastmoney pb convention.",
|
||||
"zh": "非正估值倍数不参与排名,不能当作低估。来自 val: 命名空间的反向市净率(tushare daily_basic pb,仅 CN A 股):低 PB(相对账面价值便宜)得分高,是 fund_book_to_market 的日频市场口径对应因子。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。缺失日期或非 CN 标的保持 NaN,并被排除在当日截面之外。血缘:JoinQuant style book_to_price_ratio;akshare/东方财富 pb 口径。"
|
||||
},
|
||||
"fund_pb_ind_neutral": {
|
||||
"en": "Nonpositive multiples are excluded rather than ranked as cheap. Industry-neutralized inverse price-to-book: each name is z-scored against its Shenwan level-1 industry peers instead of the whole cross-section (sector tag from tushare index_member_all SW2021 membership, point-in-time via in_date/out_date). This removes the industry confounding that dominates raw A-share PB — banks and cyclicals are structurally cheap on book value while technology names are structurally expensive, so a raw low-PB screen is largely an industry bet. Low PB relative to industry peers scores high. A name with no industry label on a date stays NaN and is excluded from that date's cross-section; an industry group with fewer than two labelled names cannot form a within-group z-score and stays NaN. Keyed by trade_date, so PIT-safe by construction with no filing lag. Membership is point-in-time but the SW2021 taxonomy itself is retroactive — a name reclassified in 2021 carries its 2021 industry back through history. Lineage: industry-neutral variant of fund_pb (#1789 follow-up).",
|
||||
"zh": "非正估值倍数不参与排名,不能当作低估。行业中性化的反向市净率:每只股票在其申万一级行业同类中做 z 打分,而非在全截面上(sector 标签来自 tushare index_member_all SW2021 成分数据,通过 in_date/out_date 实现时点正确 PIT)。这消除了主导 A 股原始 PB 的行业混淆——银行与周期股在账面价值口径上结构性便宜,而科技股结构性昂贵,因此原始低 PB 筛选在很大程度上是行业押注。相对行业同类 PB 低者得分高。某日无行业标签的股票保持 NaN,并被排除在当日截面之外;标签成员不足两只的行业组无法构成组内 z 分数,保持 NaN。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。成分归属是时点正确的(PIT),但 SW2021 分类体系本身是回溯性的——2021 年被重新分类的股票会将其 2021 行业标签带回历史。血缘:fund_pb 的行业中性变体(#1789 后续)。"
|
||||
},
|
||||
"fund_ps_ttm": {
|
||||
"en": "Nonpositive multiples are excluded rather than ranked as cheap. Inverse trailing price-to-sales from the val: namespace (tushare daily_basic ps_ttm, CN A-share only): low PS scores high. PS stays informative for loss-making names where earnings yield fails. Keyed by trade_date, so PIT-safe by construction with no filing lag. Missing dates or non-CN symbols stay NaN and are excluded from that date's cross-section. Lineage: JoinQuant style-pro sales-to-price; akshare/Eastmoney ps_ttm convention.",
|
||||
"zh": "非正估值倍数不参与排名,不能当作低估。来自 val: 命名空间的反向滚动市销率(tushare daily_basic ps_ttm,仅 CN A 股):低 PS 得分高。PS 对亏损股依然有效(盈利收益率失效的场景)。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。缺失日期或非 CN 标的保持 NaN,并被排除在当日截面之外。血缘:JoinQuant style-pro sales-to-price;akshare/东方财富 ps_ttm 口径。"
|
||||
},
|
||||
"fund_ps_ttm_ind_neutral": {
|
||||
"en": "Nonpositive multiples are excluded rather than ranked as cheap. Industry-neutralized inverse trailing price-to-sales: each name is z-scored against its Shenwan level-1 industry peers instead of the whole cross-section (sector tag from tushare index_member_all SW2021 membership, point-in-time via in_date/out_date). This removes the industry confounding that dominates raw A-share PS — software and platform names carry structurally high margins and high PS while distributors and manufacturers are structurally low, so a raw low-PS screen is largely an industry bet. Low PS relative to industry peers scores high, and PS stays informative for loss-making names where earnings yield fails. A name with no industry label on a date stays NaN and is excluded from that date's cross-section; an industry group with fewer than two labelled names cannot form a within-group z-score and stays NaN. Keyed by trade_date, so PIT-safe by construction with no filing lag. Membership is point-in-time but the SW2021 taxonomy itself is retroactive — a name reclassified in 2021 carries its 2021 industry back through history. Lineage: industry-neutral variant of fund_ps_ttm (#1789 follow-up).",
|
||||
"zh": "非正估值倍数不参与排名,不能当作低估。行业中性化的反向滚动市销率:每只股票在其申万一级行业同类中做 z 打分,而非在全截面上(sector 标签来自 tushare index_member_all SW2021 成分数据,通过 in_date/out_date 实现时点正确 PIT)。这消除了主导 A 股原始 PS 的行业混淆——软件与平台类公司毛利率结构性高、PS 也高,而分销与制造商结构性低,因此原始低 PS 筛选在很大程度上是行业押注。相对行业同类 PS 低者得分高,且 PS 对亏损股依然有效(盈利收益率失效的场景)。某日无行业标签的股票保持 NaN,并被排除在当日截面之外;标签成员不足两只的行业组无法构成组内 z 分数,保持 NaN。按 trade_date 键控,因此构造上即时点正确(PIT),无财报滞后。成分归属是时点正确的(PIT),但 SW2021 分类体系本身是回溯性的——2021 年被重新分类的股票会将其 2021 行业标签带回历史。血缘:fund_ps_ttm 的行业中性变体(#1789 后续)。"
|
||||
},
|
||||
"fund_revenue_growth": {
|
||||
"en": "PIT-safe revenue growth from the fundamental panel's derived field ``revenue_growth`` (revenue / lag_365d(revenue) - 1, calendar-day lag per the #1564 lesson rather than a row-wise shift). High top-line growth scores high, matching the JoinQuant operating_revenue_growth_rate convention. Missing statements or a missing year-ago base remain NaN and are excluded from that date's cross-section.",
|
||||
"zh": "基于基本面面板派生字段 ``revenue_growth`` 的时点正确(PIT)营收增长(revenue / lag_365d(revenue) - 1,按 #1564 教训使用日历日滞后而非按行位移)。高顶线增长得分高,与 JoinQuant operating_revenue_growth_rate 约定一致。缺失的财务报表或缺失的上年基数保持 NaN,并被排除在当日截面之外。"
|
||||
@@ -1294,6 +1306,10 @@
|
||||
"en": "Dividend yield TTM - dv_ttm (daily market data)",
|
||||
"zh": "股息率 TTM - dv_ttm(日频市场数据)"
|
||||
},
|
||||
"fund_dividend_yield_ind_neutral": {
|
||||
"en": "Dividend yield TTM industry-neutral - within-industry dv_ttm (daily market data)",
|
||||
"zh": "股息率行业中性 - 行业内 dv_ttm(日频市场数据)"
|
||||
},
|
||||
"fund_earnings_growth": {
|
||||
"en": "Earnings growth - sign-safe YoY net income expansion",
|
||||
"zh": "盈利增长 - 符号安全的同比净利润扩张"
|
||||
@@ -1322,10 +1338,18 @@
|
||||
"en": "P/B - inverse price-to-book (daily market data)",
|
||||
"zh": "市净率 P/B - 反向市净率(日频市场数据)"
|
||||
},
|
||||
"fund_pb_ind_neutral": {
|
||||
"en": "P/B industry-neutral - inverse within-industry price-to-book (daily market data)",
|
||||
"zh": "市净率行业中性 - 行业内反向市净率(日频市场数据)"
|
||||
},
|
||||
"fund_ps_ttm": {
|
||||
"en": "P/S TTM - inverse price-to-sales (daily market data)",
|
||||
"zh": "市销率 P/S TTM - 反向滚动市销率(日频市场数据)"
|
||||
},
|
||||
"fund_ps_ttm_ind_neutral": {
|
||||
"en": "P/S TTM industry-neutral - inverse within-industry price-to-sales (daily market data)",
|
||||
"zh": "市销率行业中性 - 行业内反向滚动市销率(日频市场数据)"
|
||||
},
|
||||
"fund_revenue_growth": {
|
||||
"en": "Revenue growth - YoY top-line expansion",
|
||||
"zh": "营收增长 - 同比顶线扩张"
|
||||
|
||||
Reference in new issue
Block a user