Compare commits

...

86 Commits

Author SHA1 Message Date
sakibcc 6219fb2e51 Merge pull request 'Develop' (#37) from develop into main
Deploy Production / deploy (push) Successful in 2m23s
Reviewed-on: #37
2026-09-26 01:19:47 +08:00
yuxuanhui be9c35eadd Implement structural updates and optimizations across multiple modules 2026-09-26 01:19:17 +08:00
yuxuanhui 4af96cee4b chore(task): 删除过时的选股页面迭代计划文档 2026-09-25 23:49:54 +08:00
yuxuanhui ca778fe38f chore: record journal 2026-09-25 23:49:18 +08:00
yuxuanhui 62c66f02ad chore(task): archive 09-07-api-performance-diagnosis 2026-09-25 23:48:22 +08:00
yuxuanhui 1197b78f9a chore(task): archive 09-06-capital-radar-daily-detail 2026-09-25 23:48:21 +08:00
yuxuanhui 39f8fe5fbf chore(task): archive 09-05-selection-layout-sector-filter 2026-09-25 23:48:20 +08:00
yuxuanhui d79e78a411 chore(task): archive 09-04-add-gold-brick-strategy 2026-09-25 23:48:20 +08:00
yuxuanhui a3e51e2dae chore(task): archive 09-01-optimize-stock-list-radar 2026-09-25 23:48:19 +08:00
sakibcc a67f32b45f Merge pull request 'Develop' (#36) from develop into main
Deploy Production / deploy (push) Has been cancelled
Reviewed-on: #36
2026-09-25 23:44:26 +08:00
yuxuanhui fad932b0de feat: Add OneChart score reconstruction research files and validation results
- Introduced new JSON files for normalized and raw inputs, source metadata, and public inputs.
- Added findings document detailing the methodology and results of the score reconstruction.
- Included member differences and worked examples for clarity on data discrepancies.
- Implemented a Python script for reproducing scores based on public inputs.
- Created SQL for raw reaggregation of data.
- Added task metadata for tracking the research completion and validation metrics.
2026-09-25 23:43:53 +08:00
yuxuanhui f020362fb0 fix(deploy): change runner from ubuntu-latest to tencent-prod 2026-09-25 23:43:40 +08:00
sakibcc bd84ddd2f2 Merge pull request 'Develop' (#35) from develop into main
Deploy Production / deploy (push) Successful in 1m2s
Reviewed-on: sakibcc/zhixing-system#35
2026-09-22 00:03:48 +08:00
yuxuanhui c460ba3524 chore: record journal 2026-09-21 23:58:39 +08:00
yuxuanhui 30bf94c908 chore(task): archive 09-21-radar-weighted-score-rank-change 2026-09-21 23:58:39 +08:00
yuxuanhui b9981aa48d feat(sector-radar): add weighted scores and rank-change views 2026-09-21 23:57:55 +08:00
yuxuanhui 669e89d3c3 feat(sector-radar): add IME-safe sector autocomplete and detail lookup 2026-09-21 22:37:07 +08:00
yuxuanhui 2f1c4f36c2 Support swing radar metrics and sector detail dialogs 2026-09-21 22:28:35 +08:00
sakibcc 6e39ae98d8 Merge pull request 'feat(docker-compose): add observability labels and logging configuration for services' (#34) from develop into main
Deploy Production / deploy (push) Successful in 1m7s
Reviewed-on: sakibcc/zhixing-system#34
2026-09-16 16:25:34 +08:00
yuxuanhui 3f50ca3076 feat(docker-compose): add observability labels and logging configuration for services 2026-09-16 16:25:10 +08:00
sakibcc 78b6915fff Merge pull request 'perf(sector_radar): narrow publication reads and index rank history' (#33) from develop into main
Deploy Production / deploy (push) Successful in 20s
Reviewed-on: sakibcc/zhixing-system#33
2026-09-07 14:46:27 +08:00
sakibcc bae5368da5 Merge pull request 'Develop' (#32) from develop into main
Deploy Production / deploy (push) Successful in 29s
Reviewed-on: sakibcc/zhixing-system#32
2026-09-07 13:25:58 +08:00
yuxuanhui d7586b27f6 perf(sector_radar): narrow publication reads and index rank history 2026-09-07 12:16:25 +08:00
yuxuanhui e567e5f717 feat(trellis): enhance bundled skills and workflow integration
- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths.
- Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions.
- Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context.
- Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence.
- Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
2026-09-07 11:23:15 +08:00
yuxuanhui ca3bd7a4b1 style(sector_radar): compact detail dialog header 2026-09-06 20:25:39 +08:00
sakibcc cd230bcb73 Merge pull request 'Develop' (#31) from develop into main
Deploy Production / deploy (push) Successful in 30s
Reviewed-on: sakibcc/zhixing-system#31
2026-09-06 16:19:48 +08:00
yuxuanhui 07c5b25043 refactor(selection): drop residual concept-sector surface from sub-industry path
- remove the sector_type parameter from the selection port, adapter, and
  use case; the adapter now passes SectorType.INDUSTRY directly, deleting
  the dead concept vocabulary mapping
- stop parsing concepts/concept_total/concept_limit from the sector-radar
  membership response in the web API layer and narrow the response type to
  industries only
- update tests and the task PRD accordingly; backend pyright errors drop
  from the 16 baseline to 14
2026-09-06 16:15:08 +08:00
yuxuanhui 7f93d6b0f5 feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities.
- Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights.
- Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure.
- Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members.
- Updated tests to validate the new functionality and ensure data integrity across various scenarios.
2026-09-06 16:06:17 +08:00
yuxuanhui 09d20b3b33 Merge branch 'develop'
Deploy Production / deploy (push) Successful in 27s
2026-09-05 21:55:47 +08:00
yuxuanhui 68282f5d46 refactor(selection): replace concept sector filter with sub-industry
- pin the selection sector vocabulary to industry: the /sectors endpoint
  no longer accepts sector_type, and the port, adapter, and use case all
  resolve counts and member codes with sector_type="industry"
- relabel the results filter to 细分行业 and drop concept-type plumbing
  from the frontend API, query keys, and types
- show only 细分行业 in the signal detail panel; remove the concept-board
  chips and keep the snapshot-date tooltip on the industry line
- update backend and frontend tests to the industry vocabulary and record
  the revised scope in the task PRD
2026-09-05 21:04:00 +08:00
yuxuanhui 4ee757dbce Merge branch 'develop'
Deploy Production / deploy (push) Successful in 28s
2026-09-05 19:49:02 +08:00
yuxuanhui ade1713e86 docs(trellis): record selection layout and sector filter task 2026-09-05 19:48:41 +08:00
yuxuanhui 3e0aa496f3 style(selection): apply ruff format to gold_brick module 2026-09-05 19:48:41 +08:00
yuxuanhui 12642f3c2d feat(selection): rework results layout and add sector filter
- move the filter bar out of the list column into a full-width top section;
  list and detail stay side by side below on md+, stacking search > list >
  detail on small screens
- add sector select fed by the run's concept-board aggregates; options show
  per-sector stock counts ordered by count desc, stale selections reset when
  the aggregate list changes
- persist sector in the route search, results/runs query keys, and refresh
  sector aggregates when a selection run finishes
2026-09-05 19:48:41 +08:00
yuxuanhui 7e0f13d678 feat(selection): expose run sector aggregates and sector filter on results API
- sector_radar: add batch sector-count aggregation and sector member lookup
  over the strict last-good membership snapshot (postgres + in-memory fakes)
- selection: add SelectionSectorReader port, list_sector_counts use case,
  and sector_stock_codes filtering via run identity resolution; queries stay
  inside the selection context per ADR 0001
- http: add GET /api/v1/selection/sectors and forward sector param on
  /results and /runs/{run_id}
- fix stale positional args in pattern-scoring run tests; cover new behavior
  with read-service, application, and HTTP contract tests
2026-09-05 19:48:30 +08:00
yuxuanhui cacaed08c1 Merge branch 'develop' into main
Deploy Production / deploy (push) Successful in 29s
2026-09-05 16:27:43 +08:00
yuxuanhui 41a9b4eb9a feat(selection): show stock industry and concept boards in detail panel
Add point-in-time stock membership lookup to the sector radar module
(GET /sector-radar/stocks/{ts_code}/membership) reading the existing
dc_index/dc_member snapshots, and surface industries plus concept chips
in the selection signal detail panel with graceful no-data hiding.
2026-09-05 16:23:50 +08:00
yuxuanhui 04e3e775a7 Merge pull request 'fix(selection): render gold brick chart as TDX brick blocks' from develop into main
Deploy Production / deploy (push) Successful in 29s
2026-09-05 14:58:05 +08:00
yuxuanhui 50cac3575a fix(selection): render gold brick chart as TDX brick blocks with strong-red flag
Match the ZXB1 sub-chart formula: each brick spans between yesterday's
and today's brick value via a stacked range bar — rising bricks are
hollow red (solid on strong_red), falling bricks solid green. The chart
API now also returns brick_strong_red per point.
2026-09-05 14:58:02 +08:00
yuxuanhui 58380d0ddd Merge pull request 'feat(selection): show gold brick chart below volume in stock detail panel' from develop into main
Deploy Production / deploy (push) Successful in 28s
2026-09-05 13:41:38 +08:00
yuxuanhui 3b50e75ef8 feat(selection): show gold brick chart below volume in stock detail panel
Add an optional brick_chart series to the selection chart API, computed
from the shared gold-brick formula when strategy=gold_brick, and render
a fourth grid with red/green brick bars between the volume and J grids.
2026-09-05 13:41:25 +08:00
sakibcc 3b106613d2 Merge pull request 'feat(selection): implement gold brick resonance strategy with evaluation and logging enhancements' (#30) from develop into main
Deploy Production / deploy (push) Successful in 31s
Reviewed-on: sakibcc/zhixing-system#30
2026-09-05 10:33:23 +08:00
yuxuanhui 79476252b1 feat(selection): implement gold brick resonance strategy with evaluation and logging enhancements 2026-09-05 10:22:08 +08:00
sakibcc bd9b7d65a8 Merge pull request 'feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component' (#29) from develop into main
Deploy Production / deploy (push) Successful in 34s
Reviewed-on: sakibcc/zhixing-system#29
2026-09-02 18:54:47 +08:00
yuxuanhui 9163590070 feat(selection): integrate yet-another-react-lightbox for enhanced image viewing in PatternCaseImage component 2026-09-02 18:50:13 +08:00
sakibcc 61aaed825d Merge pull request 'Develop' (#28) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#28
2026-09-02 18:39:32 +08:00
yuxuanhui 6b8c5bd33f feat(selection): enhance PatternScore component with detailed breakdown labels and update tests for accuracy 2026-09-02 18:38:57 +08:00
yuxuanhui 98ee1cc996 fix(selection): update chart labels and improve layout for clarity in selection components 2026-09-02 18:36:02 +08:00
sakibcc b44988aa81 Merge pull request 'refactor(selection): update component descriptions and improve layout for stock detail panel' (#27) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#27
2026-09-02 18:27:39 +08:00
yuxuanhui 629f933cf9 refactor(selection): update component descriptions and improve layout for stock detail panel 2026-09-02 18:26:57 +08:00
sakibcc 6030bf833d Merge pull request 'Develop' (#26) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#26
2026-09-01 22:58:23 +08:00
yuxuanhui f41ad0915e feat(selection): add Zhixing line indicators to selection chart and update related components 2026-09-01 22:57:58 +08:00
yuxuanhui 3da90f7df8 feat(codex): create initial workspace index and journal for AI development sessions 2026-09-01 22:47:17 +08:00
sakibcc 162ff5da97 Merge pull request 'refactor(web): adjust layout and styling for improved responsiveness and visual consistency' (#25) from develop into main
Deploy Production / deploy (push) Successful in 26s
Reviewed-on: sakibcc/zhixing-system#25
2026-09-01 22:42:12 +08:00
yuxuanhui ea2d0f75f1 refactor(web): adjust layout and styling for improved responsiveness and visual consistency 2026-09-01 22:40:24 +08:00
sakibcc ba33dda984 Merge pull request 'feat(web): 选股列表改为滚动加载并精简操作区' (#24) from develop into main
Deploy Production / deploy (push) Successful in 28s
Reviewed-on: sakibcc/zhixing-system#24
2026-09-01 19:29:15 +08:00
yuxuanhui af1e1eaa66 feat(web): 选股列表改为滚动加载并精简操作区
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 19:27:41 +08:00
sakibcc c592525087 Merge pull request 'Develop' (#23) from develop into main
Deploy Production / deploy (push) Successful in 27s
Reviewed-on: sakibcc/zhixing-system#23
2026-09-01 14:59:12 +08:00
yuxuanhui c944b219d9 feat(web): 用紧凑卡片替换选股表格并精简资金雷达列
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 14:57:27 +08:00
yuxuanhui 4b8a7bd484 feat(research): add OneChartLab radar metrics analysis and documentation 2026-09-01 14:48:57 +08:00
sakibcc 8cdb3be765 Merge pull request 'Develop' (#22) from develop into main
Deploy Production / deploy (push) Successful in 33s
Reviewed-on: sakibcc/zhixing-system#22
2026-09-01 13:48:46 +08:00
yuxuanhui 70bd766b8e Merge branch 'codex/chart' into develop
# Conflicts:
#	.trellis/workspace/yuxuanhui/index.md
2026-09-01 13:44:30 +08:00
yuxuanhui 66d417256f chore: record journal 2026-09-01 13:38:57 +08:00
yuxuanhui 3fc78ec9b1 chore(task): archive 09-01-stock-chart-display 2026-09-01 13:38:45 +08:00
yuxuanhui feb8fd4feb feat(web): 完善选股详情图表与案例图 2026-09-01 13:37:19 +08:00
yuxuanhui 9145fe4b2c feat(selection): 新增个股技术图表接口 2026-09-01 13:37:11 +08:00
yuxuanhui fe6d2b4fe5 feat(sector-radar): align dual ranking radar interactions 2026-09-01 13:24:43 +08:00
yuxuanhui 21ec0b2353 chore: record journal 2026-09-01 12:14:37 +08:00
yuxuanhui e2037f48f0 chore(task): archive 09-01-sector-radar-dual-ranking 2026-09-01 12:14:25 +08:00
sakibcc d5d162ccd8 Merge pull request 'Develop' (#21) from develop into main
Deploy Production / deploy (push) Successful in 55s
Reviewed-on: sakibcc/zhixing-system#21
2026-09-01 11:30:21 +08:00
yuxuanhui bed9cab437 chore: record journal 2026-09-01 11:16:58 +08:00
yuxuanhui ff028912e6 chore(task): archive 08-27-onechartlab-research 2026-09-01 11:16:57 +08:00
yuxuanhui 5f34be5395 chore(task): 更新 OneChartLab 页面迭代上下文 2026-09-01 11:16:19 +08:00
yuxuanhui be31235fc7 feat(sector-radar): 支持排名列表滚动加载 2026-09-01 11:16:18 +08:00
yuxuanhui c8b08add76 fix(selection): 列表仅展示单个 J 值 2026-09-01 11:12:08 +08:00
sakibcc 23dd398cd7 Merge pull request 'Develop' (#20) from develop into main
Deploy Production / deploy (push) Successful in 48s
Reviewed-on: sakibcc/zhixing-system#20
2026-08-31 17:16:06 +08:00
yuxuanhui 6b42bcfe15 chore: record journal 2026-08-31 16:27:36 +08:00
yuxuanhui 467aeab57b chore(task): archive 08-29-integrate-b1-scoring 2026-08-31 16:27:21 +08:00
yuxuanhui 58006615af fix(migrations): 顺延 B1 评分迁移版本 2026-08-31 16:26:39 +08:00
yuxuanhui 142dc5c3f7 Merge branch 'develop' into codex/point 2026-08-31 16:14:35 +08:00
yuxuanhui 6ce291e242 feat(selection): 集成 B1 FastDTW 图形评分 2026-08-31 16:14:16 +08:00
sakibcc 4e652596e5 Merge pull request 'Develop' (#19) from develop into main
Deploy Production / deploy (push) Successful in 18s
Reviewed-on: sakibcc/zhixing-system#19
2026-08-31 14:31:44 +08:00
yuxuanhui 1d4c317703 chore: record journal 2026-08-31 14:27:09 +08:00
yuxuanhui ca0405bffb chore(task): archive 08-31-sector-radar-listed-moneyflow-recovery 2026-08-31 14:27:09 +08:00
yuxuanhui 2ffd0163f2 fix(sector-radar): 支持当前上市股票资金流补拉 2026-08-31 14:26:28 +08:00
yuxuanhui 1cd7b5cb38 feat(tushare): add Tushare skill and demo scripts for fund and stock data retrieval 2026-08-31 11:31:49 +08:00
280 changed files with 25173 additions and 1805 deletions
@@ -31,30 +31,42 @@ The list is discovered at runtime, so adding a new directory under `bundled-skil
## Where Bundled Skills Land Per Platform
Each platform configurator calls `writeSkills(<root>, <workflowSkills>, resolveBundledSkills(ctx))` during `trellis init`. `resolveBundledSkills` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries. `writeSkills` then mirrors them under the platform's skill root.
A platform's whole file set — commands, workflow skills, agents, hooks, bundled skills — is described exactly once, by `collect<Platform>Templates()` in `packages/cli/src/configurators/<platform>.ts`. For bundled skills that description is two calls: `resolveBundledSkills(ctx)` reads every directory under `templates/common/bundled-skills/`, resolves placeholders, and returns a flat list of `{relativePath, content}` entries; `collectSkillTemplates(<skillsRoot>, <workflowSkills>, <bundledSkills>)` folds them into the platform's `Map<filePath, content>` under `<skillsRoot>/<skill>/<relativePath>`.
| Platform | Bundled skill root | Notes |
| --- | --- | --- |
| Claude Code | `.claude/skills/<skill>/` | `configureClaude` |
| Cursor | `.cursor/skills/<skill>/` | `configureCursor` |
| Codex | `.agents/skills/<skill>/` | `configureCodex` writes the shared `.agents/skills/` root, which Gemini CLI 0.40+ also reads |
| Gemini CLI | `.agents/skills/<skill>/` | Same shared root as Codex; the two configurators are required to produce byte-identical output |
| Kiro | `.kiro/skills/<skill>/` | `configureKiro` (skills-based platform — no commands) |
| Qoder | `.qoder/skills/<skill>/` | `configureQoder` |
| Codebuddy | `.codebuddy/skills/<skill>/` | `configureCodebuddy` |
| Copilot | `.github/skills/<skill>/` | `configureCopilot` |
| Droid | `.factory/skills/<skill>/` | `configureDroid` |
| Antigravity | `.agent/skills/<skill>/` | `configureAntigravity` |
| Devin | `.devin/skills/<skill>/` | `configureDevin` |
| Kilo | `.kilocode/skills/<skill>/` | `configureKilo` |
| ZCode | `.zcode/skills/<skill>/` | `configureZcode` |
| OpenCode | (handled by `collectOpenCodeTemplates`) | Uses the same `resolveBundledSkills(ctx)` output |
| Pi, Reasonix | (their own collectors) | Same `resolveBundledSkills(ctx)` output |
All 21 platforms receive the full bundled-skill set:
Two paths exercise the same data:
| Platform | Bundled skill root |
| --- | --- |
| Claude Code | `.claude/skills/<skill>/` |
| Cursor | `.cursor/skills/<skill>/` |
| OpenCode | `.opencode/skills/<skill>/` |
| Codex | `.agents/skills/<skill>/` |
| Gemini CLI | `.agents/skills/<skill>/` |
| Pi | `.agents/skills/<skill>/` |
| Kimi | `.agents/skills/<skill>/` |
| Kilo | `.kilocode/skills/<skill>/` |
| Kiro | `.kiro/skills/<skill>/` |
| Antigravity | `.agent/skills/<skill>/` |
| Devin | `.devin/skills/<skill>/` |
| Qoder | `.qoder/skills/<skill>/` |
| Codebuddy | `.codebuddy/skills/<skill>/` |
| Copilot | `.github/skills/<skill>/` |
| Droid | `.factory/skills/<skill>/` |
| Reasonix | `.reasonix/skills/<skill>/` |
| ZCode | `.zcode/skills/<skill>/` |
| Trae | `.trae/skills/<skill>/` |
| OMP | `.omp/skills/<skill>/` |
| Grok | `.grok/skills/<skill>/` |
| Snow | `.snow/skills/<skill>/` |
1. `configureX(cwd)` writes files during `trellis init`.
2. `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) returns a `Map<filePath, content>` that `trellis update` uses to detect drift and to populate `.trellis/.template-hashes.json`. Both must produce byte-identical output, so they both call `resolveBundledSkills(ctx)` and `collectSkillTemplates(root, …, resolveBundledSkills(ctx))`.
Codex, Gemini CLI, Pi and Kimi share the `.agents/skills/` root (the upstream Agent Skills workspace alias). Their collectors are required to emit byte-identical content for every file more than one of them writes there.
One description, two consumers:
1. `trellis init` → `configurePlatform(platformId, cwd)` → `writeTemplateMap(cwd, collect<Platform>Templates())`. For 18 of the 21 platforms the registry entry in `configurators/index.ts` is literally `fromTemplates(collect<Platform>Templates)`, which *is* that composition. Claude Code, Codex and ZCode spell out a `configure` of their own, each for work a `Map<path, content>` cannot express (an opt-in `--with-statusline` flag, an intentionally empty `.codex/skills/` directory, a one-shot console notice) — none of them restates the file list.
2. `trellis update` → `collectPlatformTemplates(platformId)` (in `configurators/index.ts`) → the same map, used to detect drift and to populate `.trellis/.template-hashes.json`.
Because both consumers read the one description, init and update cannot disagree about which files a bundled skill produces.
## Dispatch Wiring (Code Path)
@@ -67,10 +79,10 @@ The mechanism that auto-dispatches bundled skills to platform skill roots lives
2. `packages/cli/src/configurators/shared.ts`
- `resolveBundledSkills(ctx)` flattens that list into `ResolvedSkillFile[]` with `<skill>/<relativePath>` paths and resolved placeholders.
- `writeSkills(skillsRoot, workflowSkills, bundledSkills)` writes both workflow skills and bundled skill files under `skillsRoot`.
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns the same shape as a `Map<filePath, content>` for the update / hash pipeline.
- `collectSkillTemplates(skillsRoot, workflowSkills, bundledSkills)` returns workflow skills and bundled skill files together as a `Map<filePath, content>` rooted at `skillsRoot`.
- `writeTemplateMap(cwd, files)` is the single writer that puts a collected map on disk.
Every platform configurator that supports skills imports both helpers (see `claude.ts`, `cursor.ts`, `codex.ts`, `gemini.ts`, `kiro.ts`, `qoder.ts`, `codebuddy.ts`, `copilot.ts`, `droid.ts`, `antigravity.ts`, `devin.ts`, `kilo.ts`). The `index.ts` `PLATFORM_FUNCTIONS` registry also calls `resolveBundledSkills(ctx)` inside each `collectTemplates` closure so `trellis update` tracking stays consistent.
Every platform that supports skills reaches those two helpers from its own `collect<Platform>Templates()` — either directly (`claude.ts`, `codex.ts`, `copilot.ts`, `gemini.ts`, `grok.ts`, `kimi.ts`, `kiro.ts`, `omp.ts`, `opencode.ts`, `pi.ts`, `reasonix.ts`, `snow.ts`, `zcode.ts`) or through `collectBothTemplates(ctx, cmdPath, skillRoot)` in `shared.ts`, which makes the same two calls on behalf of platforms that have both a commands directory and a skills root (`antigravity.ts`, `codebuddy.ts`, `cursor.ts`, `devin.ts`, `droid.ts`, `kilo.ts`, `qoder.ts`, `trae.ts`).
## Adding a New Bundled Skill
@@ -137,7 +149,7 @@ There is no per-project opt-out flag for bundled skills. Two options:
2. **Pin a Trellis version that did not ship the skill.** The bundled-skill set is determined at build time, so installing an older release of the CLI is the only way to permanently exclude a skill that the current release ships.
A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional in every configurator. Adding such a flag would require changing `PLATFORM_FUNCTIONS` in `configurators/index.ts` and every `configureX` function.
A third option — globally disabling all bundled skills — is not supported. The dispatch is unconditional: `collect<Platform>Templates()` takes no arguments, so there is nowhere for a flag to enter. Adding one would mean changing that signature across all 21 platforms plus `collectPlatformTemplates` in `configurators/index.ts`.
## Operating Rules
@@ -19,7 +19,7 @@ Common files:
| Claude Code | `.claude/settings.json` |
| Cursor | `.cursor/hooks.json` |
| Codex | `.codex/hooks.json`, `.codex/config.toml` |
| OpenCode | `.opencode/package.json`, `.opencode/plugins/*` |
| OpenCode | `.opencode/package.json`, `.opencode/plugins/*`, `.opencode/hooks/inject-spec-context.py` |
| Kiro | `.kiro/hooks/` + platform config |
| Gemini CLI | `.gemini/settings.json` |
| Qoder | `.qoder/settings.json` |
@@ -40,6 +40,7 @@ Whether these files exist in a project depends on which `trellis init --<platfor
| `session-start.py` | Generates session-start context. |
| `inject-workflow-state.py` | Parses `[workflow-state:STATUS]` blocks in `.trellis/workflow.md` and emits the body matching the current task status. Falls back to `Refer to workflow.md for current step.` when no matching block exists. |
| `inject-subagent-context.py` | Injects PRD, JSONL context, and related spec/research into sub-agents. |
| `inject-spec-context.py` | Matches path-scoped specs and manages budgeted delivery state. |
| `inject-shell-session-context.py` | Lets shell commands inherit Trellis session identity. |
Not every platform has every hook. Do not copy files from another platform just because a platform lacks a hook; first confirm whether that platform supports the corresponding event.
+822
View File
@@ -0,0 +1,822 @@
---
name: tushare
description: 面向中文自然语言的 Tushare 数据研究技能。用于把“看看这只股票最近怎么样”“帮我查财报趋势”“最近哪个板块最强”“北向资金在买什么”“给我导出一份行情数据”这类请求,转成可执行的数据获取、清洗、对比、筛选、导出与简要分析流程。适用于 A 股、指数、ETF/基金、财务、估值、资金流、公告新闻、板块概念与宏观数据等研究场景。
author: tushare.pro
version: 1.1.12
credentials:
- name: TUSHARE_TOKEN
description: Tushare Token,用于认证和授权访问Tushare数据服务。
how_to_get: "https://tushare.pro/register"
requirements:
python: 3.7+
packages:
- name: tushare
environment_variables:
- name: TUSHARE_TOKEN
required: false
sensitive: true
network_access: true
---
# tushare
把自然语言财经数据请求,转成可执行的 Tushare 数据工作流。
这是一个面向自然语言的金融数据研究 skill。
## What this skill is for
使用这个 skill 的典型场景:
- 看某只股票、指数、ETF 最近走势
- 查公司基本资料、估值、财务趋势
- 做多标的横向对比
- 看资金流、北向资金、龙虎榜、板块强弱
- 梳理公告、新闻、研报、政策线索
- 查看 CPI / PPI / PMI / 社融 / 利率等宏观数据
- 导出 CSV / parquet 供后续分析或回测使用
- 生成简洁研究摘要,而不是只吐原始字段表
先理解用户要解决什么问题,再去选接口、取数、整理、解释、交付。
***
## When to use
当用户表达以下意图时,优先使用本 skill:
### 行情 / 趋势
- 看下 XX 最近怎么样
- XX 这段时间涨得怎么样
- 今年以来表现如何
- 最近有没有放量
- 这票最近强不强
### 财务 / 估值 / 公司质量
- 看下 XX 财报
- 最近几个季度利润趋势
- 财务质量怎么样
- 现金流好不好
- 现在估值算高吗
- 帮我看 PE / PB / ROE / 毛利率
### 对比 / 排行 / 筛选
- XX 和 YY 谁更强
- 帮我横向比较一下
- 哪些公司利润增长更快
- 帮我筛一下高 ROE 低负债
- 给我排个前十
### 板块 / 指数 / 主题
- 最近哪个板块最强
- 半导体最近怎么样
- 机器人为什么涨
- 指数成分股有哪些
- 哪些主题最热
### 资金流 / 情绪
- 最近资金在买什么
- 北向资金最近流向哪里
- 哪个板块最吸金
- 主力资金流入最多的是谁
- 龙虎榜上有什么看点
### 公告 / 新闻 / 研报 / 政策
- 最近有什么公告
- 帮我梳理下 XX 公告
- 最近有没有什么催化
- 最近新闻面怎么样
- 最近有什么重要政策
### 宏观 / 跨市场
- 最近宏观环境怎么样
- CPI / PMI 最近怎么看
- 当前市场风格偏什么
- 大盘环境偏多还是偏空
- 港股 / 美股 / 美债最近怎么样
### 数据导出 / 研究准备
- 给我导出一份行情数据
- 把近两年日线拉成 CSV
- 生成可回测的数据表
- 拉一个研究表供后续分析
***
## What this skill is NOT for
这个 skill 不适合:
- 直接给买卖建议或替代投资顾问
- 自动下单或执行交易
- 需要毫秒级实时交易决策的场景
- 复杂回测引擎、组合优化系统本身的实现(那是另一个工程)
- 在没有 Tushare 权限/积分支持的情况下强行伪造数据
如果数据权限不够、接口不可用或时间范围不合理,要明确说出限制,不要硬编。
***
## Natural-language trigger guide
即使用户完全不说 `tushare`、`financials`、`macro` 这些术语,只要意图符合以下含义,也应该触发本 skill。
### 常见口语触发
- 看看这个股票最近怎么样
- 给我快速研究一下 XX
- 上次说的那只票现在什么情况
- 帮我看下财报
- 最近哪个板块最强
- 北向最近在买什么
- 有什么催化消息
- 这个公司值不值得重点看
- 给我拉份数据
- 导出成 CSV
- 帮我筛一批票
- 把这几个公司对比一下
### 中文自然语言优先原则
用户说人话时,先理解任务,不要先回到接口名和字段名。
优先把:
- “最近” 解释成合理时间窗
- “财报” 解释成最近 8 个季度 / 最近年度
- “强不强” 解释成走势 + 相对强弱 + 活跃度
- “资金关注” 解释成净流入、活跃成交、龙虎榜/北向等可用口径
如果任务有多个合理解释,再做最小澄清。
***
## Environment check
在真正请求数据之前,先做前置校验:
1. 检查 Python 是否可用, 版本要求 3.7+
2. 检查 `tushare` 包是否已安装·
3. 检查 `TUSHARE_TOKEN` 是否存在.
4. 必要时做一次轻量接口冒烟测试(如交易日历 / 基础接口)
5. 如用户请求高权限接口,提前提示可能存在积分/权限限制
若缺失 token,直接提示最短修复路径,例如:
```bash
export TUSHARE_TOKEN=your_token
```
不要等到主查询跑失败了才暴露环境问题。
***
## Intent taxonomy
先识别任务类型,再决定接口组合。
### 1. 行情 / 趋势
典型问题:
- 最近走势怎么样
- 今年涨了多少
- 最近波动大不大
- 最近有没有放量
常用接口:
- `daily`
- `pro_bar`
- `weekly`
- `monthly`
- `stk_mins`
- `rt_k` / `rt_min`(如确需实时口径且权限允许)
- `daily_basic`
### 2. 基本资料 / 标的识别
典型问题:
- 这是什么公司 / 什么指数 / 什么基金
- 是创业板吗 / 是 ST 吗 / 什么时候上市
常用接口:
- `stock_basic`
- `fund_basic`
- `index_basic`
- `stock_company`
- `stock_st` / `st`
### 3. 财务 / 公司质量
典型问题:
- 最近几个季度利润趋势
- 最近几个季度营收和净利润趋势
- 财务质量怎么样
- ROE / 毛利率 / 现金流如何
常用接口:
- `income`(营收 / 净利润趋势优先)
- `fina_indicator`(ROE / 毛利率 / 净利率等质量指标补充)
- `balancesheet`
- `cashflow`
- `forecast`
- `express`
- `disclosure_date`
### 4. 估值 / 基本面指标
典型问题:
- 现在估值高不高
- 谁更便宜
- PE / PB / 股息率如何
常用接口:
- `daily_basic`
- `fina_indicator`
### 5. 资金流 / 市场行为
典型问题:
- 北向最近买什么
- 主力资金流向
- 龙虎榜情况
常用接口:
- `moneyflow`
- `moneyflow_hsgt`
- `hsgt_top10`
- `top_list`
- `top_inst`
- `moneyflow_ind_dc`
- `moneyflow_mkt_dc`
### 6. 板块 / 指数 / 主题
典型问题:
- 最近哪个板块最强
- 行业轮动如何
- 某板块有哪些成分股
常用接口:
- `index_basic`
- `index_daily`
- `index_classify`
- `index_member_all`
- `sw_daily`
- `ths_index`
- `ths_member`
- `dc_index`
- `dc_member`
### 7. 打板 / 情绪 / 活跃度
典型问题:
- 今天涨停梯队
- 连板结构
- 炸板率 / 情绪强弱
常用接口:
- `limit_list_d`
- `limit_step`
- `kpl_list`
- `dc_hot`
- `ths_hot`
### 8. 公告 / 新闻 / 研报 / 政策
典型问题:
- 最近有什么公告或催化
- 最近有什么研究报告
- 最近政策面发生了什么
常用接口:
- `anns_d`
- `news`
- `major_news`
- `research_report`
- `npr`
- `irm_qa_sh`
- `irm_qa_sz`
### 9. 宏观 / 跨市场
典型问题:
- CPI / PMI / 社融 / M2
- 利率与收益率曲线
- 港股 / 美股 / 美债数据
常用接口:
- `cn_cpi`
- `cn_ppi`
- `cn_pmi`
- `cn_gdp`
- `cn_m`
- `sf_month`
- `shibor`
- `shibor_lpr`
- `us_tycr`
- `us_daily`
- `hk_daily`
- `index_global`
### 10. 导出 / 研究准备
典型问题:
- 导出某标的一段时间行情
- 生成回测用数据表
- 输出 CSV / parquet
常用接口:
- 取决于上游任务,核心是统一输出规则与命名规范
***
## Entity resolution rules
### 标的解析
- 优先识别股票名、股票代码、指数名、ETF 名、基金名
- 对中文简称先尝试匹配标准对象
- 若重名或多解,列出候选并做最小澄清
- 证券代码内部统一为标准格式,如:`600519.SH`、`000001.SZ`
### 市场识别
- 默认先按 A 股理解,除非用户明确提到港股 / 美股 / 基金 / 债券 / 期货
- 指数、ETF、个股要分开判断,不要混用接口
### 时间默认值
若用户没有明确给时间范围,使用合理默认:
- “最近走势” → 默认近 20 个交易日
- “这段时间 / 最近一段时间” → 默认近 3 个月
- “财报 / 业绩” → 默认最近 8 个季度 + 最近年度
- “资金流最近如何” → 默认近 5~20 个交易日,按任务粒度调整
- “宏观最近如何” → 默认看最近 6~12 期
### 板块口径默认值
若用户只说“板块 / 行业 / 概念”但未指定分类体系:
- 行业优先用申万 / 中信等较稳定口径
- 概念优先同花顺 / 东方财富等主题口径
- 若结论依赖具体口径差异,要明确说明使用了哪种分类
***
## Input normalization rules
在请求数据前先做规范化:
- 日期统一为 `YYYYMMDD`
- 检查 `start_date <= end_date`
- 用户输入未来日期时,自动裁剪到最近可用日期并提示
- 裸代码如 `000001` 不要盲猜,能补全则说明补全规则,不能补全则澄清
- 对冲突参数(如 `trade_date` 与 `start_date/end_date` 同时给)要先裁决,不要直接乱传
***
## Data retrieval rules
### 文档先行
在写请求代码前,先确认:
- 接口名是否正确
- 必填参数
- 可选参数
- 返回字段
- 积分 / 频率限制
不要仅凭记忆硬写字段名。
### 字段确认
对 `fields` 参数,优先使用已知字段白名单或接口文档确认。
若用户要求字段不存在,应明确说明,而不是盲查。
### 默认分段拉取
长区间数据不要一次性全拉。
建议:
- 日线 / 周线 / 月线:按年或季度切片
- 财报:按年份 / 报告期切片
- 分钟数据:按月 / 周切片
- 大批量多标的:按标的分批 + 日期分段
### 重试与限流
- 仅对瞬时错误(网络抖动、超时、429)进行有限重试
- 参数错误、权限不足、字段错误不要盲重试
- 批量拉取时加入节流,避免高频撞限
### 分段合并
分段拉取后:
- 合并
- 去重
- 按主键排序
- 记录失败分段
- 若部分成功,要明确告诉用户哪些段失败了
***
## Output contract
除非用户明确只要原始表,否则优先按这个结构输出:
1. **一句话结论**
2. **数据范围与口径**
3. **关键指标 / 关键表格**
4. **异常点 / 风险点 / 解释限制**
5. **如有本地输出,给出文件路径**
### 结果交付形态
按任务复杂度选择:
- 小结果:Markdown 摘要 + 简短表格
- 中等数据表:CSV
- 大规模 / 后续分析:Parquet
- 需要可复用流程:附 Python 脚本
- 需要可视化时:输出图表 PNG 或说明可绘制图表
### 元信息
生成数据文件时,尽量同时记录:
- 接口名
- 请求参数
- 拉取时间
- 数据行数
- 字段列表
- 是否存在失败分段 / 缺失
***
## Workflow templates
下面这些模板,是本 skill 的核心。
不要直接从接口想起,而要从任务模板想起。
### 1. 单标的行情分析
适用:
- 看下 XX 最近怎么样
- 这票最近强不强
- 今年以来表现如何
默认流程:
1. 解析标的
2. 确定时间范围
3. 取行情 + 必要基础指标
4. 总结区间涨跌、成交活跃度、高低点、波动
5. 输出一句结论 + 关键数字
### 2. 多标的横向对比
适用:
- XX 和 YY 谁更强
- 把这几家公司对比一下
默认流程:
1. 锁定对象
2. 统一时间口径
3. 选 3~5 个关键指标
4. 输出对比表
5. 给出“谁在哪方面更强”的总结
### 3. 财务质量快照
适用:
- 看下 XX 财报
- 最近几个季度利润趋势
- 财务质量怎么样
默认流程:
1. 拉最近 8 个季度 + 最近年度财务核心数据
2. 区分营收、利润、毛利率、ROE、现金流
3. 标出改善 / 恶化 / 波动点
4. 说明累计值、单季值、同比口径
### 4. 估值分析 / 筛选
适用:
- 现在估值高不高
- 谁更便宜
- 筛低估值高股息
默认流程:
1. 明确标的池
2. 拉 `daily_basic` 等估值指标
3. 必要时联动财务质量
4. 输出排序、极值、口径说明
### 5. 资金流追踪
适用:
- 最近资金在买什么
- 北向最近流向哪里
- 主力资金流入最多的是谁
默认流程:
1. 明确资金口径(北向 / 主力 / 龙虎榜 / 板块资金)
2. 确定时间窗
3. 拉净流入 / 活跃成交 / 持续性
4. 和价格表现联动解释
5. 避免把单日噪声说成趋势
### 6. 板块 / 题材轮动分析
适用:
- 最近哪个板块最强
- 机器人最近强在哪
- 某概念板块里有哪些成分股
默认流程:
1. 确定分类口径
2. 拉板块区间表现
3. 必要时联动成分股、资金流、涨停梯队
4. 输出强势板块排行与代表标的
### 7. 公告 / 新闻 / 事件梳理
适用:
- 最近有什么公告
- 有没有什么催化
- 最近新闻面怎么样
默认流程:
1. 明确对象和时间窗
2. 拉公告 / 新闻 / 研报 / 政策数据
3. 去噪,提炼 3~5 条主线
4. 区分事实、公告、媒体解读
5. 必要时结合股价异动做弱因果解释
### 8. 数据导出与研究准备
适用:
- 拉一份 CSV
- 做回测数据表
- 导出某段时间的行情/财务数据
默认流程:
1. 明确数据范围、频率、字段
2. 采用分段策略取数
3. 清洗、去重、统一字段类型
4. 输出 CSV / parquet
5. 给出文件路径和元信息
### 9. 综合研究简报
适用:
- 给我快速研究一下 XX
- 做个投资者视角简报
- 先给个全景判断
默认流程:
1. 一句话结论
2. 行情表现
3. 财务趋势
4. 估值水平
5. 资金流情况
6. 公告 / 新闻催化
7. 风险点
8. 值得继续深挖的问题
***
## Data quality rules
拉取完成后,至少做这些检查:
- schema 校验
- 关键字段存在性检查
- 主键去重
- 固定排序
- 日期标准化
- 数值字段类型规范化
### 空结果处理
空表不一定是失败,要区分:
- 非交易日
- 区间无数据
- 股票未上市
- 参数错误
- 接口权限不足
不要把所有空结果都说成“接口坏了”。
***
## Cache and reuse rules
为了让 skill 可长期复用,应优先支持:
- 基础表缓存(如 `stock_basic`、交易日历、指数基础信息)
- 增量更新,而不是每次全量重拉
- 大任务断点续跑
- 结果文件规范命名
推荐命名格式:
- `daily_600519.SH_20230101_20231231_20260322.csv`
- `fina_indicator_300750.SZ_20260322.parquet`
缓存命中时,最好说明哪些来自缓存,哪些是新拉取的数据。
***
## Error handling
优先用“人话 + 调试细节分层”的方式输出错误。
### 用户可见层
- token 未配置
- 当前接口可能需要更高积分/权限
- 时间范围过大,已自动改为分段拉取
- 股票名称不唯一,请确认是哪一个
- 当前结果为空,可能因为该日期非交易日 / 标的未上市 / 无权限
### 调试层
必要时补:
- 接口名
- 参数
- 失败分段
- 异常原文
### 部分成功原则
如果部分分段失败,不要说“成功完成”。
应明确说:
- 哪些部分成功
- 哪些部分失败
- 是否已生成不完整结果
***
## Recommended minimal interface set
主 skill 正文不要塞几百个接口。
优先记住 80% 常用任务的核心接口集:
- `stock_basic`
- `trade_cal`
- `daily`
- `pro_bar`
- `daily_basic`
- `fina_indicator`
- `income`
- `balancesheet`
- `cashflow`
- `forecast`
- `express`
- `moneyflow`
- `moneyflow_hsgt`
- `hsgt_top10`
- `top_list`
- `index_basic`
- `index_daily`
- `index_classify`
- `sw_daily`
- `ths_index`
- `ths_member`
- `limit_list_d`
- `limit_step`
- `news`
- `major_news`
- `research_report`
- `anns_d`
- `cn_cpi`
- `cn_pmi`
- `us_tycr`
全部数据接口,请参考 `references/数据接口.md`。
***
## Best practices
- 先理解任务,再选接口
- 能少取就少取,先核心数据,再扩展
- 先给结论,再给证据
- 默认说人话,不堆字段名
- 对“最近 / 财报 / 强不强 / 资金关注”这类模糊中文表达,要有合理默认口径
- 大任务先给执行计划,再开跑
- 导出任务尽量保留脚本、元信息、文件路径,方便复用
***
## Examples
### 单票行情
- 看下宁德时代最近三个月走势
- 茅台今年以来涨了多少
- 招行这两年最大回撤大概多少
### 财务 / 估值
- 看下比亚迪最近 8 个季度营收和净利润趋势
- 茅台现在估值算高吗
- 帮我找高 ROE 低负债的公司
### 对比
- 比一下茅台、五粮液、泸州老窖近一年的涨幅和估值
- 对比一下沪深300、中证500、创业板今年表现
### 资金流 / 板块
- 今天北向资金流入最多的股票有哪些
- 最近哪个板块最强
- 半导体板块最近一个月强不强
### 公告 / 事件
- 帮我梳理下寒武纪最近的重要公告
- 最近机器人板块有什么消息面催化
### 宏观
- 看一下最近 CPI、PPI、PMI 变化
- 当前市场风格偏成长还是价值
### 导出
- 把沪深300成分股近两年日线导成 CSV
- 下载宁德时代 2020 到现在的复权行情
- 把最近 3 年 ROE、PE、PB、营收增速拉成一个表
***
## Quick rule
当用户在说:
- 看走势
- 查财报
- 比较公司
- 看板块
- 看资金流
- 梳理公告新闻
- 看宏观
- 拉数据导出
就不要先想“有哪些接口”。
先想:
**这是什么任务?默认该走哪条数据工作流?结果应该怎样交付才真正有用?**
@@ -0,0 +1,248 @@
# 接口列表
根据需求确定接口,然后访问在线链接,读取具体的使用说明,比如入参,出参等。
| 在线文档 | 接口名 | 标题 | 分类 | 描述 |
|:--------------------------------------------|:-------------------|:-----------------|:-------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| https://tushare.pro/wctapi/documents/386.md | etf_index | ETF跟踪指数 | ETF专题 | 获取ETF基准指数列表信息 |
| https://tushare.pro/wctapi/documents/472.md | etf_sz_cons | 每日篮子组合(深市PCF) | ETF专题 | 获取深交所场内所有ETF每日盘前披露的一篮子组合信息,包括成分股票数量、申赎现金折溢价比例等数据 |
| https://tushare.pro/wctapi/documents/471.md | etf_sh_cons | 每日篮子组合(沪市PCF) | ETF专题 | 获取上交所场内所有ETF每日盘前披露的的一篮子组合信息,包括成分股票数量、申赎现金折溢价比例等数据 |
| https://tushare.pro/wctapi/documents/470.md | rt_etf_min_daily | ETF实时分钟-日累计 | ETF专题 | 获取ETF实时分钟数据日累计,包括1~60min |
| https://tushare.pro/wctapi/documents/460.md | idx_anns | 指数公司公告 | ETF专题 | 获取指数公司披露的相关公告信息,包括中证指数、国证指数、恒生指数和华证指数的及时与历史公告信息,跟踪指数最新信息和发展方向。 |
| https://tushare.pro/wctapi/documents/454.md | rt_etf_sz_iopv | ETF实时参考 | ETF专题 | ETF实时净值和申购赎回数据参考,目前只提供深市 |
| https://tushare.pro/wctapi/documents/416.md | rt_etf_min | ETF实时分钟 | ETF专题 | 获取ETF实时分钟数据,包括1~60min |
| https://tushare.pro/wctapi/documents/408.md | etf_share_size | ETF份额规模 | ETF专题 | 获取沪深ETF每日份额和规模数据,能体现规模份额的变化,掌握ETF资金动向,同时提供每日净值和收盘价;数据指标是分批入库,交易所于次日早8点30左右更新上一交易日的数据;另外,涉及海外的ETF数据更新会晚一些属于正常情况。 |
| https://tushare.pro/wctapi/documents/400.md | rt_etf_k | ETF实时日线 | ETF专题 | 获取ETF实时日k线行情,支持按ETF代码或代码通配符一次性提取全部ETF实时日k线行情 |
| https://tushare.pro/wctapi/documents/387.md | etf_mins | ETF历史分钟 | ETF专题 | 获取ETF分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 |
| https://tushare.pro/wctapi/documents/385.md | etf_basic | ETF基本信息 | ETF专题 | 获取国内ETF基础信息,包括了QDII。数据来源与沪深交易所公开披露信息。 |
| https://tushare.pro/wctapi/documents/199.md | fund_adj | ETF复权因子 | ETF专题 | 获取基金复权因子,用于计算基金复权行情 |
| https://tushare.pro/wctapi/documents/127.md | fund_daily | ETF日线行情 | ETF专题 | 获取ETF行情每日收盘后成交数据,历史超过10年 |
| https://tushare.pro/wctapi/documents/187.md | cb_daily | 可转债行情 | 债券专题 | 获取可转债行情 |
| https://tushare.pro/wctapi/documents/186.md | cb_issue | 可转债发行 | 债券专题 | 获取可转债发行数据 |
| https://tushare.pro/wctapi/documents/185.md | cb_basic | 可转债基础信息 | 债券专题 | 获取可转债基本信息 |
| https://tushare.pro/wctapi/documents/392.md | cb_factor_pro | 可转债技术面因子(专业版) | 债券专题 | 获取可转债每日技术面因子数据,用于跟踪可转债当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,_qfq表示前复权 _hfq表示后复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 |
| https://tushare.pro/wctapi/documents/459.md | top10_cb_holders | 可转债十大持有人 | 债券专题 | 获取可转债前十大持有人 |
| https://tushare.pro/wctapi/documents/201.md | yc_cb | 国债收益率曲线 | 债券专题 | 获取中债收益率曲线,目前可获取中债国债收益率曲线即期和到期收益率曲线数据 |
| https://tushare.pro/wctapi/documents/458.md | cb_rating | 可转债债券评级 | 债券专题 | 获取可转债评级历史记录 |
| https://tushare.pro/wctapi/documents/233.md | eco_cal | 全球财经事件 | 债券专题 | 获取全球财经日历、包括经济事件数据更新 |
| https://tushare.pro/wctapi/documents/323.md | bc_bestotcqt | 柜台流通式债券最优报价 | 债券专题 | 柜台流通式债券最优报价 |
| https://tushare.pro/wctapi/documents/247.md | cb_share | 可转债转股结果 | 债券专题 | 获取可转债转股结果 |
| https://tushare.pro/wctapi/documents/256.md | repo_daily | 债券回购日行情 | 债券专题 | 债券回购日行情 |
| https://tushare.pro/wctapi/documents/269.md | cb_call | 可转债赎回信息 | 债券专题 | 获取可转债到期赎回、强制赎回等信息。数据来源于公开披露渠道,供个人和机构研究使用,请不要用于数据商业目的。 |
| https://tushare.pro/wctapi/documents/271.md | bond_blk | 大宗交易 | 债券专题 | 获取沪深交易所债券大宗交易数据 |
| https://tushare.pro/wctapi/documents/272.md | bond_blk_detail | 大宗交易明细 | 债券专题 | 获取沪深交易所债券大宗交易数据 |
| https://tushare.pro/wctapi/documents/246.md | cb_price_chg | 可转债转股价变动 | 债券专题 | 获取可转债转股价变动 |
| https://tushare.pro/wctapi/documents/305.md | cb_rate | 可转债票面利率 | 债券专题 | 获取可转债票面利率 |
| https://tushare.pro/wctapi/documents/322.md | bc_otcqt | 柜台流通式债券报价 | 债券专题 | 柜台流通式债券报价 |
| https://tushare.pro/wctapi/documents/19.md | fund_basic | 基金列表 | 公募基金 | 获取公募基金数据列表,包括场内和场外基金 |
| https://tushare.pro/wctapi/documents/462.md | mkt_idx_bmk | 基金业绩基准 | 公募基金 | 获取官方发布的ETF业绩比较基准列表信息,分为一类库、二类库 |
| https://tushare.pro/wctapi/documents/359.md | fund_factor_pro | 基金技术面因子(专业版) | 公募基金 | 获取场内基金每日技术面因子数据,用于跟踪场内基金当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 |
| https://tushare.pro/wctapi/documents/208.md | fund_manager | 基金经理 | 公募基金 | 获取公募基金经理数据,包括基金经理简历等数据 |
| https://tushare.pro/wctapi/documents/207.md | fund_share | 基金规模 | 公募基金 | 获取基金规模数据,包含上海和深圳ETF基金 |
| https://tushare.pro/wctapi/documents/121.md | fund_portfolio | 基金持仓 | 公募基金 | 获取公募基金持仓数据,季度更新 |
| https://tushare.pro/wctapi/documents/120.md | fund_div | 基金分红 | 公募基金 | 获取公募基金分红数据 |
| https://tushare.pro/wctapi/documents/119.md | fund_nav | 基金净值 | 公募基金 | 获取公募基金净值数据 |
| https://tushare.pro/wctapi/documents/118.md | fund_company | 基金管理人 | 公募基金 | 获取公募基金管理人列表 |
| https://tushare.pro/wctapi/documents/178.md | fx_obasic | 外汇基础信息(海外) | 外汇数据 | 获取海外外汇基础信息,目前只有FXCM交易商的数据 |
| https://tushare.pro/wctapi/documents/179.md | fx_daily | 外汇日线行情 | 外汇数据 | 获取外汇日线行情 |
| https://tushare.pro/wctapi/documents/143.md | news | 新闻快讯(短讯) | 大模型语料 | 获取主流新闻网站的快讯新闻数据,提供超过6年以上历史新闻。 |
| https://tushare.pro/wctapi/documents/154.md | cctv_news | 新闻联播文字稿 | 大模型语料 | 获取新闻联播文字稿数据,数据开始于2017年。 |
| https://tushare.pro/wctapi/documents/195.md | major_news | 新闻通讯(长篇) | 大模型语料 | 获取长篇通讯信息,覆盖主要新闻资讯网站,提供超过8年历史新闻。 |
| https://tushare.pro/wctapi/documents/366.md | irm_qa_sh | 上证e互动问答 | 大模型语料 | 获取上交所e互动董秘问答文本数据。上证e互动是由上海证券交易所建立、上海证券市场所有参与主体无偿使用的沟通平台,旨在引导和促进上市公司、投资者等各市场参与主体之间的信息沟通,构建集中、便捷的互动渠道。本接口数据记录了以上沟通问答的文本数据。 |
| https://tushare.pro/wctapi/documents/367.md | irm_qa_sz | 深证易互动问答 | 大模型语料 | 互动易是由深交所官方推出,供投资者与上市公司直接沟通的平台,一站式公司资讯汇集,提供第一手的互动问答、投资者关系信息、公司声音等内容。 |
| https://tushare.pro/wctapi/documents/406.md | npr | 国家政策库 | 大模型语料 | 获取国家行政机关公开披露的各类法规、条例政策、批复、通知等文本数据。 |
| https://tushare.pro/wctapi/documents/415.md | research_report | 券商研究报告 | 大模型语料 | 获取券商研究报告-个股、行业等,历史数据从20170101开始提供,增量每天两次更新 |
| https://tushare.pro/wctapi/documents/176.md | anns_d | 上市公司公告 | 大模型语料 | 获取全量公告数据,提供pdf下载URL |
| https://tushare.pro/wctapi/documents/465.md | monetary_policy | 央行货币政策执行报告 | 大模型语料 | 获取央行季度更新的货币政策执行报告,历史数据开始于2001年每年四篇,提供原始PDF下载链接,可用于分析过去20多年央行货币政策的动向、宏观以及金融市场的情况。 |
| https://tushare.pro/wctapi/documents/461.md | cn_schedule | 中国经济数据发布日程 | 宏观经济,国内宏观 | 获取国家统计局、中国人民银行等经济数据发布日程及对应tushare接口,持续更新中 |
| https://tushare.pro/wctapi/documents/245.md | cn_ppi | 工业生产者出厂价格指数(PPI) | 宏观经济,国内宏观,价格指数 | 获取PPI工业生产者出厂价格指数数据 |
| https://tushare.pro/wctapi/documents/228.md | cn_cpi | 居民消费价格指数(CPI) | 宏观经济,国内宏观,价格指数 | 获取CPI居民消费价格数据,包括全国、城市和农村的数据 |
| https://tushare.pro/wctapi/documents/149.md | shibor | Shibor利率 | 宏观经济,国内宏观,利率数据 | shibor利率 |
| https://tushare.pro/wctapi/documents/150.md | shibor_quote | Shibor报价数据 | 宏观经济,国内宏观,利率数据 | Shibor报价数据 |
| https://tushare.pro/wctapi/documents/151.md | shibor_lpr | LPR贷款基础利率 | 宏观经济,国内宏观,利率数据 | LPR贷款基础利率 |
| https://tushare.pro/wctapi/documents/152.md | libor | Libor利率 | 宏观经济,国内宏观,利率数据 | Libor拆借利率 |
| https://tushare.pro/wctapi/documents/174.md | gz_index | 广州民间借贷利率 | 宏观经济,国内宏观,利率数据 | 广州民间借贷利率 |
| https://tushare.pro/wctapi/documents/173.md | wz_index | 温州民间借贷利率 | 宏观经济,国内宏观,利率数据 | 温州民间借贷利率,即温州指数 |
| https://tushare.pro/wctapi/documents/153.md | hibor | Hibor利率 | 宏观经济,国内宏观,利率数据 | Hibor利率 |
| https://tushare.pro/wctapi/documents/227.md | cn_gdp | 国内生产总值(GDP) | 宏观经济,国内宏观,国民经济 | 获取国民经济之GDP数据 |
| https://tushare.pro/wctapi/documents/325.md | cn_pmi | 采购经理指数(PMI) | 宏观经济,国内宏观,景气度 | 采购经理人指数 |
| https://tushare.pro/wctapi/documents/310.md | sf_month | 社融增量(月度) | 宏观经济,国内宏观,金融,社会融资 | 获取月度社会融资数据 |
| https://tushare.pro/wctapi/documents/242.md | cn_m | 货币供应量(月) | 宏观经济,国内宏观,金融,货币供应量 | 获取货币供应量之月度数据 |
| https://tushare.pro/wctapi/documents/219.md | us_tycr | 国债收益率曲线利率 | 宏观经济,国际宏观,美国利率 | 获取美国每日国债收益率曲线利率 |
| https://tushare.pro/wctapi/documents/223.md | us_trltr | 国债长期利率平均值 | 宏观经济,国际宏观,美国利率 | 国债实际长期利率平均值 |
| https://tushare.pro/wctapi/documents/220.md | us_trycr | 国债实际收益率曲线利率 | 宏观经济,国际宏观,美国利率 | 国债实际收益率曲线利率 |
| https://tushare.pro/wctapi/documents/221.md | us_tbr | 短期国债利率 | 宏观经济,国际宏观,美国利率 | 获取美国短期国债利率数据 |
| https://tushare.pro/wctapi/documents/222.md | us_tltr | 国债长期利率 | 宏观经济,国际宏观,美国利率 | 国债长期利率 |
| https://tushare.pro/wctapi/documents/308.md | ci_daily | 中信行业指数日行情 | 指数专题 | 获取中信行业指数日线行情 |
| https://tushare.pro/wctapi/documents/469.md | sw_mins | SW历史分钟 | 指数专题 | 获取申万指数历史分钟数据 |
| https://tushare.pro/wctapi/documents/420.md | rt_idx_min | 指数实时分钟 | 指数专题 | 获取交易所指数实时分钟数据,包括1~60min |
| https://tushare.pro/wctapi/documents/419.md | idx_mins | 指数历史分钟 | 指数专题 | 获取交易所指数分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 |
| https://tushare.pro/wctapi/documents/417.md | rt_sw_k | 申万实时行情 | 指数专题 | 获取申万行业指数的最新截面数据 |
| https://tushare.pro/wctapi/documents/403.md | rt_idx_k | 指数实时日线 | 指数专题 | 获取交易所指数实时日线行情,支持按代码或代码通配符一次性提取全部交易所指数实时日k线行情 |
| https://tushare.pro/wctapi/documents/373.md | ci_index_member | 中信行业成分 | 指数专题 | 按三级分类提取中信行业成分,可提供某个分类的所有成分,也可按股票代码提取所属分类,参数灵活 |
| https://tushare.pro/wctapi/documents/94.md | index_basic | 指数基本信息 | 指数专题 | 获取指数基础信息。 |
| https://tushare.pro/wctapi/documents/358.md | idx_factor_pro | 指数技术面因子(专业版) | 指数专题 | 获取指数每日技术面因子数据,用于跟踪指数当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估,指数包括大盘指数 申万行业指数 中信指数 |
| https://tushare.pro/wctapi/documents/96.md | index_weight | 指数成分和权重 | 指数专题 | 获取各类指数成分和权重,**月度数据** ,建议输入参数里开始日期和结束日分别输入当月第一天和最后一天的日期。 |
| https://tushare.pro/wctapi/documents/128.md | index_dailybasic | 大盘指数每日指标 | 指数专题 | 目前只提供上证综指,深证成指,上证50,中证500,中小板指,创业板指的每日指标数据 |
| https://tushare.pro/wctapi/documents/171.md | index_weekly | 指数周线行情 | 指数专题 | 获取指数周线行情 |
| https://tushare.pro/wctapi/documents/172.md | index_monthly | 指数月线行情 | 指数专题 | 获取指数月线行情,每月更新一次 |
| https://tushare.pro/wctapi/documents/181.md | index_classify | 申万行业分类 | 指数专题 | 获取申万行业分类,可以获取申万2014年版本(28个一级分类,104个二级分类,227个三级分类)和2021年本版(31个一级分类,134个二级分类,346个三级分类)列表信息 |
| https://tushare.pro/wctapi/documents/211.md | index_global | 国际主要指数 | 指数专题 | 获取国际主要指数日线行情 |
| https://tushare.pro/wctapi/documents/335.md | index_member_all | 申万行业成分(分级) | 指数专题 | 按三级分类提取申万行业成分,可提供某个分类的所有成分,也可按股票代码提取所属分类,参数灵活 |
| https://tushare.pro/wctapi/documents/268.md | sz_daily_info | 深圳市场每日交易情况 | 指数专题 | 获取深圳市场每日交易概况 |
| https://tushare.pro/wctapi/documents/327.md | sw_daily | 申万日线行情 | 指数专题 | 获取申万行业日线行情(默认是申万2021版行情) |
| https://tushare.pro/wctapi/documents/215.md | daily_info | 沪深市场每日交易统计 | 指数专题 | 获取交易所股票交易统计,包括各板块明细 |
| https://tushare.pro/wctapi/documents/95.md | index_daily | 指数日线行情 | 指数专题 | 获取指数每日行情,还可以通过bar接口获取。由于服务器压力,目前规则是单次调取最多取8000行记录,可以设置start和end日期补全。指数行情也可以通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据。本接口不包含[申万行业指数行情数据](https://tushare.pro/document/2?doc_id=327)。 |
| https://tushare.pro/wctapi/documents/158.md | opt_basic | 期权合约信息 | 期权数据 | 获取期权合约信息 |
| https://tushare.pro/wctapi/documents/159.md | opt_daily | 期权日线行情 | 期权数据 | 获取期权日线行情 |
| https://tushare.pro/wctapi/documents/341.md | opt_mins | 期权分钟行情 | 期权数据 | 获取全市场期权合约分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式。 |
| https://tushare.pro/wctapi/documents/139.md | fut_holding | 每日持仓排名 | 期货数据 | 获取每日成交持仓排名数据,注意"上期所"涵盖"上海国际能源交易中心"合约数据 |
| https://tushare.pro/wctapi/documents/140.md | fut_wsr | 仓单日报 | 期货数据 | 获取仓单日报数据,了解各仓库/厂库的仓单变化 |
| https://tushare.pro/wctapi/documents/141.md | fut_settle | 每日结算参数 | 期货数据 | 获取每日结算参数数据,包括交易和交割费率等 |
| https://tushare.pro/wctapi/documents/216.md | fut_weekly_detail | 期货主要品种交易周报 | 期货数据 | 获取期货交易所主要品种每周交易统计信息,数据从2010年3月开始 |
| https://tushare.pro/wctapi/documents/313.md | ft_mins | 历史分钟行情 | 期货数据 | 获取全市场期货合约分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式,如果需要主力合约分钟,请先通过主力[mapping](https://tushare.pro/document/2?doc_id=189)接口(需要有至少2000积分)获取对应的合约代码后提取分钟。 |
| https://tushare.pro/wctapi/documents/337.md | fut_weekly_monthly | 期货周月线行情(每日更新) | 期货数据 | 期货周/月线行情(每日更新) |
| https://tushare.pro/wctapi/documents/340.md | rt_fut_min | 实时分钟行情 | 期货数据 | 获取全市场期货合约实时分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK、 http Restful API和websocket三种方式,如果需要主力合约分钟,请先通过主力[mapping](https://tushare.pro/document/2?doc_id=189)接口获取对应的合约代码后提取分钟。 |
| https://tushare.pro/wctapi/documents/368.md | ft_limit | 期货合约涨跌停价格 | 期货数据 | 获取所有期货合约每天的涨跌停价格及最低保证金率,数据开始于2005年。 |
| https://tushare.pro/wctapi/documents/467.md | fut_trade_cal | 期货交易日历 | 期货数据 | 获取各大期货交易所交易日历数据 |
| https://tushare.pro/wctapi/documents/468.md | fut_index_daily | 南华期货指数日线行情 | 期货数据 | 获取南华指数每日行情,指数行情也可以通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据. |
| https://tushare.pro/wctapi/documents/138.md | fut_daily | 日线行情 | 期货数据 | 期货日线行情数据 |
| https://tushare.pro/wctapi/documents/189.md | fut_mapping | 期货主力与连续合约 | 期货数据 | 获取期货主力(或连续)合约与月合约映射数据 |
| https://tushare.pro/wctapi/documents/135.md | fut_basic | 合约信息 | 期货数据 | 获取期货合约列表数据 |
| https://tushare.pro/wctapi/documents/388.md | hk_fina_indicator | 港股财务指标数据 | 港股数据 | 获取港股上市公司财务指标数据,为避免服务器压力,现阶段每次请求最多返回200条记录,可通过设置日期多次请求获取更多数据。 |
| https://tushare.pro/wctapi/documents/383.md | rt_hk_k | 港股实时日线 | 港股数据 | 获取港股实时日k线行情,支持按股票代码及股票代码通配符一次性提取全部股票实时日k线行情 |
| https://tushare.pro/wctapi/documents/390.md | hk_balancesheet | 港股资产负债表 | 港股数据 | 获取港股上市公司资产负债表 |
| https://tushare.pro/wctapi/documents/304.md | hk_mins | 港股分钟行情 | 港股数据 | 港股分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 |
| https://tushare.pro/wctapi/documents/250.md | hk_tradecal | 港股交易日历 | 港股数据 | 获取交易日历 |
| https://tushare.pro/wctapi/documents/192.md | hk_daily | 港股日线行情 | 港股数据 | 获取港股每日增量和历史行情,每日18点左右更新当日数据 |
| https://tushare.pro/wctapi/documents/389.md | hk_income | 港股利润表 | 港股数据 | 获取港股上市公司财务利润表数据 |
| https://tushare.pro/wctapi/documents/401.md | hk_adjfactor | 港股复权因子 | 港股数据 | 获取港股每日复权因子数据,每天滚动刷新 |
| https://tushare.pro/wctapi/documents/391.md | hk_cashflow | 港股现金流量表 | 港股数据 | 获取港股上市公司现金流量表数据 |
| https://tushare.pro/wctapi/documents/191.md | hk_basic | 港股基础信息 | 港股数据 | 获取港股列表信息 |
| https://tushare.pro/wctapi/documents/339.md | hk_daily_adj | 港股复权行情 | 港股数据 | 获取港股复权行情,提供股票股本、市值和成交及换手多个数据指标 |
| https://tushare.pro/wctapi/documents/284.md | sge_basic | 上海黄金基础信息 | 现货数据 | 获取上海黄金交易所现货合约基础信息 |
| https://tushare.pro/wctapi/documents/285.md | sge_daily | 上海黄金现货日行情 | 现货数据 | 获取上海黄金交易所现货合约日线行情 |
| https://tushare.pro/wctapi/documents/338.md | us_daily_adj | 美股复权行情 | 美股数据 | 获取美股复权行情,支持美股全市场股票,提供股本、市值、复权因子和成交信息等多个数据指标 |
| https://tushare.pro/wctapi/documents/254.md | us_daily | 美股日线行情 | 美股数据 | 获取美股行情(未复权),包括全部股票全历史行情,以及重要的市场和估值指标 |
| https://tushare.pro/wctapi/documents/253.md | us_tradecal | 美股交易日历 | 美股数据 | 获取美股交易日历信息 |
| https://tushare.pro/wctapi/documents/252.md | us_basic | 美股基础信息 | 美股数据 | 获取美股列表信息 |
| https://tushare.pro/wctapi/documents/393.md | us_fina_indicator | 美股财务指标数据 | 美股数据 | 获取美股上市公司财务指标数据,目前只覆盖主要美股和中概股。为避免服务器压力,现阶段每次请求最多返回200条记录,可通过设置日期多次请求获取更多数据。 |
| https://tushare.pro/wctapi/documents/394.md | us_income | 美股利润表 | 美股数据 | 获取美股上市公司财务利润表数据(目前只覆盖主要美股和中概股) |
| https://tushare.pro/wctapi/documents/395.md | us_balancesheet | 美股资产负债表 | 美股数据 | 获取美股上市公司资产负债表(目前只覆盖主要美股和中概股) |
| https://tushare.pro/wctapi/documents/396.md | us_cashflow | 美股现金流量表 | 美股数据 | 获取美股上市公司现金流量表数据(目前只覆盖主要美股和中概股) |
| https://tushare.pro/wctapi/documents/402.md | us_adjfactor | 美股复权因子 | 美股数据 | 获取美股每日复权因子数据,在每天美股收盘后滚动刷新 |
| https://tushare.pro/wctapi/documents/58.md | margin | 融资融券交易汇总 | 股票数据,两融及转融通 | 获取融资融券每日交易汇总数据,交易所于每天8点30左右更新上一日数据 |
| https://tushare.pro/wctapi/documents/331.md | slb_len | 转融资交易汇总 | 股票数据,两融及转融通 | 转融通融资汇总 |
| https://tushare.pro/wctapi/documents/334.md | slb_len_mm | 做市借券交易汇总(停) | 股票数据,两融及转融通 | 做市借券交易汇总 |
| https://tushare.pro/wctapi/documents/333.md | slb_sec_detail | 转融券交易明细(停) | 股票数据,两融及转融通 | 转融券交易明细 |
| https://tushare.pro/wctapi/documents/332.md | slb_sec | 转融券交易汇总(停) | 股票数据,两融及转融通 | 转融通转融券交易汇总 |
| https://tushare.pro/wctapi/documents/326.md | margin_secs | 融资融券标的(盘前) | 股票数据,两融及转融通 | 获取沪深京三大交易所融资融券标的(包括ETF),每天盘前更新 |
| https://tushare.pro/wctapi/documents/59.md | margin_detail | 融资融券交易明细 | 股票数据,两融及转融通 | 获取沪深两市每日融资融券明细,,交易所于每天8点30左右更新上一日数据 |
| https://tushare.pro/wctapi/documents/61.md | top10_holders | 前十大股东 | 股票数据,参考数据 | 获取上市公司前十大股东数据,包括持有数量和比例等信息 |
| https://tushare.pro/wctapi/documents/62.md | top10_floatholders | 前十大流通股东 | 股票数据,参考数据 | 获取上市公司前十大流通股东数据 |
| https://tushare.pro/wctapi/documents/110.md | pledge_stat | 股权质押统计数据 | 股票数据,参考数据 | 获取股票质押统计数据 |
| https://tushare.pro/wctapi/documents/160.md | share_float | 限售股解禁 | 股票数据,参考数据 | 获取限售股解禁 |
| https://tushare.pro/wctapi/documents/111.md | pledge_detail | 股权质押明细数据 | 股票数据,参考数据 | 获取股票质押明细数据 |
| https://tushare.pro/wctapi/documents/161.md | block_trade | 大宗交易 | 股票数据,参考数据 | 大宗交易 |
| https://tushare.pro/wctapi/documents/164.md | stk_account | 股票开户数据(停) | 股票数据,参考数据 | 获取股票账户开户数据,统计周期为一周 |
| https://tushare.pro/wctapi/documents/453.md | stk_alert | 交易所重点提示证券 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布重点提示证券 |
| https://tushare.pro/wctapi/documents/452.md | stk_high_shock | 个股严重异常波动 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布股票交易严重异常波动情况 |
| https://tushare.pro/wctapi/documents/451.md | stk_shock | 个股异常波动 | 股票数据,参考数据 | 根据证券交易所交易规则的有关规定,交易所每日发布股票交易异常波动情况 |
| https://tushare.pro/wctapi/documents/175.md | stk_holdertrade | 股东增减持 | 股票数据,参考数据 | 获取上市公司增减持数据,了解重要股东近期及历史上的股份增减变化 |
| https://tushare.pro/wctapi/documents/166.md | stk_holdernumber | 股东人数 | 股票数据,参考数据 | 获取上市公司股东户数数据,数据不定期公布 |
| https://tushare.pro/wctapi/documents/165.md | stk_account_old | 股票开户数据(旧) | 股票数据,参考数据 | 获取股票账户开户数据旧版格式数据,数据从2008年1月开始,到2015年5月29,新数据请通过[股票开户数据](https://tushare.pro/document/2?doc_id=164)获取。 |
| https://tushare.pro/wctapi/documents/124.md | repurchase | 股票回购 | 股票数据,参考数据 | 获取上市公司回购股票数据 |
| https://tushare.pro/wctapi/documents/194.md | stk_rewards | 管理层薪酬和持股 | 股票数据,基础数据 | 获取上市公司管理层薪酬和持股 |
| https://tushare.pro/wctapi/documents/262.md | bak_basic | 股票历史列表 | 股票数据,基础数据 | 获取备用基础列表,数据从2016年开始 |
| https://tushare.pro/wctapi/documents/329.md | stk_premarket | 每日股本(盘前) | 股票数据,基础数据 | 每日开盘前获取当日股票的股本情况,包括总股本和流通股本,涨跌停价格等。 |
| https://tushare.pro/wctapi/documents/375.md | bse_mapping | 北交所新旧代码对照 | 股票数据,基础数据 | 获取北交所股票代码变更后新旧代码映射表数据 |
| https://tushare.pro/wctapi/documents/397.md | stock_st | ST股票列表 | 股票数据,基础数据 | 获取ST股票列表,可根据交易日期获取历史上每天的ST列表 |
| https://tushare.pro/wctapi/documents/193.md | stk_managers | 上市公司管理层 | 股票数据,基础数据 | 获取上市公司管理层 |
| https://tushare.pro/wctapi/documents/423.md | st | ST风险警示板股票 | 股票数据,基础数据 | ST风险警示板股票列表 |
| https://tushare.pro/wctapi/documents/112.md | stock_company | 上市公司基本信息 | 股票数据,基础数据 | 获取上市公司基础信息,单次提取4500条,可以根据交易所分批提取 |
| https://tushare.pro/wctapi/documents/100.md | namechange | 股票曾用名 | 股票数据,基础数据 | 历史名称变更记录 |
| https://tushare.pro/wctapi/documents/25.md | stock_basic | 股票列表 | 股票数据,基础数据 | 获取基础信息数据,包括股票代码、名称、上市日期、退市日期等 |
| https://tushare.pro/wctapi/documents/26.md | trade_cal | 交易日历 | 股票数据,基础数据 | 获取各大交易所交易日历数据,默认提取的是上交所 |
| https://tushare.pro/wctapi/documents/398.md | stock_hsgt | 沪深港通股票列表 | 股票数据,基础数据 | 获取沪深港通股票列表 |
| https://tushare.pro/wctapi/documents/123.md | new_share | IPO新股上市 | 股票数据,基础数据 | 获取新股上市列表数据 |
| https://tushare.pro/wctapi/documents/261.md | ths_member | THS概念板块成分 | 股票数据,打板专题数据 | 获取概念板块成分列表 |
| https://tushare.pro/wctapi/documents/260.md | ths_daily | THS概念板块行情 | 股票数据,打板专题数据 | 获取板块指数行情 |
| https://tushare.pro/wctapi/documents/376.md | tdx_index | TDX概念板块分类 | 股票数据,打板专题数据 | 获取板块基础信息,包括概念板块、行业、风格、地域等 |
| https://tushare.pro/wctapi/documents/369.md | stk_auction | 开盘竞价成交(当日) | 股票数据,打板专题数据 | 获取当日个股和ETF的集合竞价成交情况,每天9点26~29分之间可以获取当日的集合竞价成交数据。本接口历史数据开始于2025年1月。 |
| https://tushare.pro/wctapi/documents/363.md | dc_member | DC概念板块成分 | 股票数据,打板专题数据 | 获取板块每日成分数据,可以根据概念板块代码和交易日期,获取历史成分 |
| https://tushare.pro/wctapi/documents/362.md | dc_index | DC概念板块分类 | 股票数据,打板专题数据 | 获取每个交易日的概念板块数据,支持按日期查询 |
| https://tushare.pro/wctapi/documents/259.md | ths_index | THS概念板块分类 | 股票数据,打板专题数据 | 获取板块指数,包括概念、行业、特色指数。 |
| https://tushare.pro/wctapi/documents/356.md | limit_step | 涨停股票连板天梯 | 股票数据,打板专题数据 | 获取每天连板个数晋级的股票,可以分析出每天连续涨停进阶个数,判断强势热度 |
| https://tushare.pro/wctapi/documents/377.md | tdx_member | TDX概念板块成分 | 股票数据,打板专题数据 | 获取各板块成分股信息 |
| https://tushare.pro/wctapi/documents/355.md | limit_list_ths | THS涨跌停榜单 | 股票数据,打板专题数据 | 获取同花顺每日涨跌停榜单数据,历史数据从20231101开始提供,增量每天16点左右更新,:分类(limit_type 涨停池、连扳池、冲刺涨停、炸板池、跌停池,默认:涨停池)不同,字段返回有值情况也不同,如仅有涨停池、连扳池 有最大封单 lu_limit_order返回值,其他分类为空 |
| https://tushare.pro/wctapi/documents/347.md | kpl_list | 榜单数据(KP) | 股票数据,打板专题数据 | 获取涨停、跌停、炸板等榜单数据 |
| https://tushare.pro/wctapi/documents/321.md | dc_hot | DC热榜 | 股票数据,打板专题数据 | 获取热榜数据,包括A股市场、ETF基金、港股市场、美股市场等等,每日盘中提取4次,收盘后4次,最晚22点提取一次。 |
| https://tushare.pro/wctapi/documents/320.md | ths_hot | THS热榜 | 股票数据,打板专题数据 | 获取热榜数据,包括热股、概念板块、ETF、可转债、港美股等等,每日盘中提取4次,收盘后4次,最晚22点提取一次。 |
| https://tushare.pro/wctapi/documents/312.md | hm_detail | 游资交易每日明细 | 股票数据,打板专题数据 | 获取每日游资交易明细,数据开始于2022年8。游资分类名录,请点击<a href="https://tushare.pro/document/2?doc_id=311">游资名录</a> |
| https://tushare.pro/wctapi/documents/311.md | hm_list | 市场游资最全名录 | 股票数据,打板专题数据 | 获取游资分类名录信息 |
| https://tushare.pro/wctapi/documents/298.md | limit_list_d | 涨跌停和炸板数据 | 股票数据,打板专题数据 | 获取A股每日涨跌停、炸板数据情况,数据从2020年开始(不提供ST股票的统计) |
| https://tushare.pro/wctapi/documents/351.md | kpl_concept_cons | 题材成分(KP) | 股票数据,打板专题数据 | 获取概念题材的成分股 |
| https://tushare.pro/wctapi/documents/378.md | tdx_daily | TDX概念板块行情 | 股票数据,打板专题数据 | 获取各板块行情,包括成交和估值等数据 |
| https://tushare.pro/wctapi/documents/382.md | dc_daily | DC概念板块行情 | 股票数据,打板专题数据 | 获取概念板块、行业指数板块、地域板块行情数据,历史数据开始于2020年 |
| https://tushare.pro/wctapi/documents/357.md | limit_cpt_list | 涨停最强板块统计 | 股票数据,打板专题数据 | 获取每天涨停股票最多最强的概念板块,可以分析强势板块的轮动,判断资金动向 |
| https://tushare.pro/wctapi/documents/422.md | dc_concept_cons | 题材成分(DC) | 股票数据,打板专题数据 | 获取概念题材的成分股,每天盘后更新 |
| https://tushare.pro/wctapi/documents/107.md | top_inst | 龙虎榜机构交易单 | 股票数据,打板专题数据 | 龙虎榜机构成交明细 |
| https://tushare.pro/wctapi/documents/106.md | top_list | 龙虎榜每日统计单 | 股票数据,打板专题数据 | 龙虎榜每日交易明细 |
| https://tushare.pro/wctapi/documents/421.md | dc_concept | 题材数据(DC) | 股票数据,打板专题数据 | 获取概念题材列表,每天盘后更新 |
| https://tushare.pro/wctapi/documents/267.md | broker_recommend | 券商月度金股 | 股票数据,特色数据 | 获取券商月度金股,一般1日~3日内更新当月数据 |
| https://tushare.pro/wctapi/documents/274.md | ccass_hold_detail | 中央结算系统持股明细 | 股票数据,特色数据 | 获取中央结算系统机构席位持股明细,数据覆盖**全历史**,根据交易所披露时间,当日数据在下一交易日早上9点前完成 |
| https://tushare.pro/wctapi/documents/275.md | stk_surv | 机构调研数据 | 股票数据,特色数据 | 获取上市公司机构调研记录数据 |
| https://tushare.pro/wctapi/documents/292.md | report_rc | 券商盈利预测数据 | 股票数据,特色数据 | 获取券商(卖方)每天研报的盈利预测数据,数据从2010年开始,每晚19~22点更新当日数据 |
| https://tushare.pro/wctapi/documents/293.md | cyq_perf | 每日筹码及胜率 | 股票数据,特色数据 | 获取A股每日筹码平均成本和胜率情况,每天18~19点左右更新,数据从2018年开始 |
| https://tushare.pro/wctapi/documents/294.md | cyq_chips | 每日筹码分布 | 股票数据,特色数据 | 获取A股每日的筹码分布情况,提供各价位占比,数据从2018年开始,每天18~19点之间更新当日数据 |
| https://tushare.pro/wctapi/documents/188.md | hk_hold | 沪深股通持股明细 | 股票数据,特色数据 | 获取沪深港股通持股明细,数据来源港交所。 |
| https://tushare.pro/wctapi/documents/328.md | stk_factor_pro | 股票技术面因子(专业版) | 股票数据,特色数据 | 获取股票每日技术面因子数据,用于跟踪股票当前走势情况,数据由Tushare社区自产,覆盖全历史;输出参数_bfq表示不复权,_qfq表示前复权 _hfq表示后复权,描述中说明了因子的默认传参,如需要特殊参数或者更多因子可以联系管理员评估 |
| https://tushare.pro/wctapi/documents/353.md | stk_auction_o | 股票开盘集合竞价数据 | 股票数据,特色数据 | 股票开盘9:30集合竞价数据,每天盘后更新 |
| https://tushare.pro/wctapi/documents/354.md | stk_auction_c | 股票收盘集合竞价数据 | 股票数据,特色数据 | 股票收盘15:00集合竞价数据,每天盘后更新 |
| https://tushare.pro/wctapi/documents/364.md | stk_nineturn | 神奇九转指标 | 股票数据,特色数据 | 神奇九转(又称“九转序列”)是一种基于技术分析的股票趋势反转指标,其思想来源于技术分析大师汤姆·迪马克(Tom DeMark)的TD序列。该指标的核心功能是通过识别股价在上涨或下跌过程中连续9天的特定走势,来判断股价的潜在反转点,从而帮助投资者提高抄底和逃顶的成功率,日线级别配合60min的九转效果更好,数据从20230101开始。 |
| https://tushare.pro/wctapi/documents/399.md | stk_ah_comparison | AH股比价 | 股票数据,特色数据 | AH股比价数据,可根据交易日期获取历史 |
| https://tushare.pro/wctapi/documents/295.md | ccass_hold | 中央结算系统持股统计 | 股票数据,特色数据 | 获取中央结算系统持股汇总数据,覆盖全部历史数据,根据交易所披露时间,当日数据在下一交易日早上9点前完成入库 |
| https://tushare.pro/wctapi/documents/296.md | stk_factor | 股票技术面因子 | 股票数据,特色数据 | 获取股票每日技术面因子数据,用于跟踪股票当前走势情况,数据由Tushare社区自产,覆盖全历史 |
| https://tushare.pro/wctapi/documents/27.md | daily | 历史日线 | 股票数据,行情数据 | 获取股票行情数据,或通过[**通用行情接口**]( https://tushare.pro/document/2?doc_id=109)获取数据,包含了前后复权数据 |
| https://tushare.pro/wctapi/documents/374.md | rt_min | 实时分钟 | 股票数据,行情数据 | 获取全A股票实时分钟数据,包括1~60min |
| https://tushare.pro/wctapi/documents/214.md | suspend_d | 每日停复牌信息 | 股票数据,行情数据 | 按日期方式获取股票每日停复牌信息 |
| https://tushare.pro/wctapi/documents/255.md | bak_daily | 备用行情 | 股票数据,行情数据 | 获取备用行情,包括特定的行情指标(数据从2017年中左右开始,早期有几天数据缺失,近期正常) |
| https://tushare.pro/wctapi/documents/336.md | stk_weekly_monthly | 周月线行情(每日更新) | 股票数据,行情数据 | 股票周/月线行情(每日更新) |
| https://tushare.pro/wctapi/documents/365.md | stk_week_month_adj | 周月线复权行情(每日更新) | 股票数据,行情数据 | 股票周/月线行情(复权--每日更新) |
| https://tushare.pro/wctapi/documents/370.md | stk_mins | 历史分钟 | 股票数据,行情数据 | 获取A股分钟数据,支持1min/5min/15min/30min/60min行情,提供Python SDK和 http Restful API两种方式 |
| https://tushare.pro/wctapi/documents/372.md | rt_k | 实时日线 | 股票数据,行情数据 | 获取实时日k线行情,支持按股票代码及股票代码通配符一次性提取全部股票实时日k线行情 |
| https://tushare.pro/wctapi/documents/146.md | pro_bar | 复权行情 | 股票数据,行情数据 | |
| https://tushare.pro/wctapi/documents/457.md | rt_min_daily | A股实时分钟-日累计 | 股票数据,行情数据 | 获取A股当日盘中历史分钟数据,可以提取单只股票当日开盘以来的所有分钟数据 |
| https://tushare.pro/wctapi/documents/196.md | ggt_daily | 港股通每日成交统计 | 股票数据,行情数据 | 获取港股通每日成交信息,数据从2014年开始 |
| https://tushare.pro/wctapi/documents/183.md | stk_limit | 每日涨跌停价格 | 股票数据,行情数据 | 获取全市场(包含A/B股和基金)每日涨跌停价格,包括涨停价格,跌停价格等,每个交易日8点40左右更新当日股票涨跌停价格。 |
| https://tushare.pro/wctapi/documents/145.md | monthly | 月线行情 | 股票数据,行情数据 | 获取A股月线数据 |
| https://tushare.pro/wctapi/documents/28.md | adj_factor | 复权因子 | 股票数据,行情数据 | 本接口由Tushare自行生产,获取股票复权因子,可提取单只股票全部历史复权因子,也可以提取单日全部股票的复权因子。 |
| https://tushare.pro/wctapi/documents/32.md | daily_basic | 每日指标 | 股票数据,行情数据 | 获取全部股票每日重要的基本面指标,可用于选股分析、报表展示等。单次请求最大返回6000条数据,可按日线循环提取全部历史。 |
| https://tushare.pro/wctapi/documents/48.md | hsgt_top10 | 沪深股通十大成交股 | 股票数据,行情数据 | 获取沪股通、深股通每日前十大成交详细数据,每天18~20点之间完成当日更新 |
| https://tushare.pro/wctapi/documents/49.md | ggt_top10 | 港股通十大成交股 | 股票数据,行情数据 | 获取港股通每日成交数据,其中包括沪市、深市详细数据,每天18~20点之间完成当日更新 |
| https://tushare.pro/wctapi/documents/109.md | pro_bar | 通用行情接口 | 股票数据,行情数据 | |
| https://tushare.pro/wctapi/documents/144.md | weekly | 周线行情 | 股票数据,行情数据 | 获取A股周线行情,本接口每周最后一个交易日更新,如需要使用每天更新的周线数据,请使用[日度更新的周线行情接口](https://tushare.pro/document/2?doc_id=336)。 |
| https://tushare.pro/wctapi/documents/81.md | fina_mainbz | 主营业务构成 | 股票数据,财务数据 | 获得上市公司主营业务构成,分地区和产品两种方式 |
| https://tushare.pro/wctapi/documents/80.md | fina_audit | 财务审计意见 | 股票数据,财务数据 | 获取上市公司定期财务审计意见数据 |
| https://tushare.pro/wctapi/documents/33.md | income | 利润表 | 股票数据,财务数据 | 获取上市公司财务利润表数据 |
| https://tushare.pro/wctapi/documents/36.md | balancesheet | 资产负债表 | 股票数据,财务数据 | 获取上市公司资产负债表 |
| https://tushare.pro/wctapi/documents/44.md | cashflow | 现金流量表 | 股票数据,财务数据 | 获取上市公司现金流量表 |
| https://tushare.pro/wctapi/documents/45.md | forecast | 业绩预告 | 股票数据,财务数据 | 获取业绩预告数据 |
| https://tushare.pro/wctapi/documents/103.md | dividend | 分红送股数据 | 股票数据,财务数据 | 分红送股数据 |
| https://tushare.pro/wctapi/documents/79.md | fina_indicator | 财务指标数据 | 股票数据,财务数据 | 获取上市公司财务指标数据,为避免服务器压力,现阶段每次请求最多返回100条记录,可通过设置日期多次请求获取更多数据。 |
| https://tushare.pro/wctapi/documents/162.md | disclosure_date | 财报披露日期表 | 股票数据,财务数据 | 获取财报披露计划日期 |
| https://tushare.pro/wctapi/documents/46.md | express | 业绩快报 | 股票数据,财务数据 | 获取上市公司业绩快报 |
| https://tushare.pro/wctapi/documents/371.md | moneyflow_cnt_ths | 板块资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺概念板块每日资金流向 |
| https://tushare.pro/wctapi/documents/47.md | moneyflow_hsgt | 沪深港通资金流向 | 股票数据,资金流向数据 | 获取沪股通、深股通、港股通每日资金流向数据,每次最多返回300条记录,总量不限制。 |
| https://tushare.pro/wctapi/documents/170.md | moneyflow | 个股资金流向 | 股票数据,资金流向数据 | 获取沪深A股票资金流向数据,分析大单小单成交情况,用于判别资金动向,数据开始于2010年。 |
| https://tushare.pro/wctapi/documents/343.md | moneyflow_ind_ths | 行业资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺行业资金流向,每日盘后更新 |
| https://tushare.pro/wctapi/documents/344.md | moneyflow_ind_dc | 板块资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富板块资金流向,每天盘后更新 |
| https://tushare.pro/wctapi/documents/345.md | moneyflow_mkt_dc | 大盘资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富大盘资金流向数据,每日盘后更新 |
| https://tushare.pro/wctapi/documents/348.md | moneyflow_ths | 个股资金流向(THS) | 股票数据,资金流向数据 | 获取同花顺个股资金流向数据,每日盘后更新 |
| https://tushare.pro/wctapi/documents/349.md | moneyflow_dc | 个股资金流向(DC) | 股票数据,资金流向数据 | 获取东方财富个股资金流向数据,每日盘后更新,数据开始于20230911 |
| https://tushare.pro/wctapi/documents/445.md | p_save | 组合保存 | 自选组合 | 创建或修改自选股组合 |
| https://tushare.pro/wctapi/documents/446.md | p_list | 组合列表 | 自选组合 | 自选股组合查询,不加参数查询出所以自定义组合 |
| https://tushare.pro/wctapi/documents/447.md | p_delete | 组合删除 | 自选组合 | 删除自选股组合 |
| https://tushare.pro/wctapi/documents/449.md | p_get | 成分查询 | 自选组合 | 查询组合的成分列表 |
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
基金数据获取示例脚本
"""
import tushare as ts
import pandas as pd
import os
# 读取环境变量中的token, 或者读取本地记录的token
token = os.getenv('TUSHARE_TOKEN') or ts.get_token()
# 初始化pro接口
pro = ts.pro_api(token)
def get_fund_list():
"""
获取基金列表
"""
try:
data = pro.fund_basic(market='E', status='L', fields='ts_code,fund_name,fund_type,found_date,issue_date,delist_date')
print("基金列表获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取基金列表失败:{e}")
return None
def get_fund_nav(ts_code, start_date, end_date):
"""
获取基金净值数据
"""
try:
data = pro.fund_nav(ts_code=ts_code, start_date=start_date, end_date=end_date)
print(f"{ts_code}基金净值数据获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取基金净值数据失败:{e}")
return None
def get_fund_manager():
"""
获取基金经理数据
"""
try:
data = pro.fund_manager(limit=10, fields='ts_code,fund_name,manager_name,begin_date,end_date')
print("基金经理数据获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取基金经理数据失败:{e}")
return None
def main():
"""
主函数
"""
print("===== tushare 基金数据获取示例 =====")
# 获取基金列表
fund_list = get_fund_list()
if fund_list is not None:
# 获取第一只基金的代码
ts_code = fund_list['ts_code'].iloc[0]
print(f"\n使用基金代码:{ts_code}")
# 获取基金净值数据(最近30天)
import datetime
end_date = datetime.datetime.now().strftime('%Y%m%d')
start_date = (datetime.datetime.now() - datetime.timedelta(days=30)).strftime('%Y%m%d')
print(f"\n获取基金净值数据:{start_date} 至 {end_date}")
get_fund_nav(ts_code, start_date, end_date)
# 获取基金经理数据
print("\n获取基金经理数据:")
get_fund_manager()
if __name__ == "__main__":
main()
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
股票数据获取示例脚本
"""
import tushare as ts
import pandas as pd
import os
# 读取环境变量中的token, 或者读取本地记录的token
token = os.getenv('TUSHARE_TOKEN') or ts.get_token()
# 初始化pro接口
pro = ts.pro_api(token)
def get_stock_list():
"""
获取股票列表
"""
try:
data = pro.stock_basic(exchange='', list_status='L', fields='ts_code,symbol,name,area,industry,list_date')
print("股票列表获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取股票列表失败:{e}")
return None
def get_daily_data(ts_code, start_date, end_date):
"""
获取股票日线数据
"""
try:
data = pro.daily(ts_code=ts_code, start_date=start_date, end_date=end_date)
print(f"{ts_code}日线数据获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取日线数据失败:{e}")
return None
def get_financial_data(ts_code, year, quarter):
"""
获取财务指标数据
"""
try:
data = pro.fina_indicator(ts_code=ts_code, year=year, quarter=quarter)
print(f"{ts_code}财务指标数据获取成功:")
print(data.head())
return data
except Exception as e:
print(f"获取财务指标数据失败:{e}")
return None
def main():
"""
主函数
"""
print("===== tushare 股票数据获取示例 =====")
# 获取股票列表
stock_list = get_stock_list()
if stock_list is not None:
# 获取第一只股票的代码
ts_code = stock_list['ts_code'].iloc[0]
print(f"\n使用股票代码:{ts_code}")
# 获取日线数据(最近30天)
import datetime
end_date = datetime.datetime.now().strftime('%Y%m%d')
start_date = (datetime.datetime.now() - datetime.timedelta(days=30)).strftime('%Y%m%d')
print(f"\n获取日线数据:{start_date} 至 {end_date}")
get_daily_data(ts_code, start_date, end_date)
# 获取财务数据(最近一年)
current_year = datetime.datetime.now().year
print(f"\n获取财务数据:{current_year-1}年 第4季度")
get_financial_data(ts_code, current_year-1, 4)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/tushare
+25
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "^(?:clear|compact)$",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
@@ -22,6 +34,19 @@
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/inject-spec-context.py",
"timeout": 15,
"additionalContextLimit": 0
}
]
}
]
}
}
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Path-Scoped Spec Context Injection Hook (ticket-refresh model)
When the agent touches a file, the specs that govern that file are surfaced
right then — small, relevant, budgeted — instead of everything up front or
nothing at all. Spec .md files under .trellis/spec/ declare which code paths
they govern via YAML frontmatter (`paths:` glob list); the matching engine
lives in .trellis/scripts/common/spec_match.py and the decision engine in
.trellis/scripts/common/spec_inject.py. This file is the IO shell: stdin,
config, identity, state files, locking, GC, one print.
Triggers:
* Claude Code PostToolUse (matcher "Read|Edit|Write|MultiEdit") receives one
structured file path after the tool runs.
* Codex PreToolUse (matcher "Edit|Write") receives an ``apply_patch`` command.
Every patch header is matched before the patch runs. When a FULL spec is
emitted, the patch is denied once so the model can read the injected rules
and retry; ticket-only reminders do not block.
* OpenCode ``tool.execute.before`` adapts ``write``, ``edit``, and
``apply_patch`` into the same PreToolUse payload. FULL delivery uses the same
deny-once decision before the JS plugin surfaces context as a tool error.
Behavior — per matched spec, per event (recency-decay aware):
h = sha256(spec bytes)
last = newest emission recorded for (identity, spec) # stateless → None
if stateless: emit TICKET # bounded cost, always
elif last is None: emit FULL # first time this session
elif last.sha256 != h: emit FULL # spec changed → re-teach
elif reset since last: emit FULL # the text is gone, re-teach
elif within window: silent # fixed window; no state append
else: emit TICKET # refresh attention cheaply
A FULL block inlines the (budgeted) spec body with a sha256 attr; a TICKET is
a short reminder pointing back at the spec. Both append a state record; silent
hits do not (fixed window, not sliding — continuous editing is exactly when
drift is worst).
Identity (misfire asymmetry: a collision that MISSES an injection is
unacceptable; drift that OVER-injects is fine): the session/window key is
delegated to common.active_task.resolve_context_key — the shared resolver
every other hook uses — called payload-first, environment-inclusive second,
so two live sessions can never collapse onto one exported env value. A
`+a-<agent_id>` suffix (appended after each part is sanitized) keeps a
subagent's state separate from its parent's. When the resolver is unavailable
(older installed scripts tree), a minimal payload-only ladder (session keys,
then transcript hash) keeps the hook working. No key from any source, or an
unwritable state dir → stateless: no state IO at all, every hit is a TICKET
(circuit breaker — never a FULL re-emission loop).
State: user-global, out of the repo, one append-only JSONL file per identity
under ${TRELLIS_SPEC_STATE_DIR:-~/.trellis/spec-inject}/<project16>/<identity>.jsonl.
SessionStart(clear|compact) appends an opaque reset marker to the base session
shard; parent and subagent emission histories stay separate but observe that
shared marker. A best-effort fcntl lock is held across read→decide→append;
where fcntl is unavailable (Windows) the worst case is a duplicate injection.
A once-per-hour GC prunes conforming shards older than 48 h.
Budget (config.yaml `spec_injection:`): per-spec cap `max_spec_chars`
(default 9400) with code-point truncation + in-body notice; per-event cap
`max_total_chars` (default 9500 — below Claude Code's documented
additionalContext ceiling, and enforced directly for Codex). Once the total
budget is exhausted, remaining FULL bodies degrade to one <spec-index> block;
tickets are counted last and dropped (with a stderr warning) only if even they
do not fit.
Refresh window (config.yaml `spec_injection:`): `refresh_window_seconds`
(default 2700; `0` = never refresh unchanged content solely because time
passed).
Fail-open on errors: non-matching events, malformed paths, no matches, or any
internal error → exit 0 with no stdout (stderr warnings allowed). The only
deliberate block is a Codex patch that just received a FULL governing spec.
"""
from __future__ import annotations
# IMPORTANT: Suppress all warnings FIRST
import warnings
warnings.filterwarnings("ignore")
import hashlib
import json
import os
import re
import sys
import time
import uuid
from pathlib import Path
# IMPORTANT: Force UTF-8 on Windows for the streams this hook uses.
# stdin carries the payload (non-ASCII file paths), stdout carries the spec
# bodies, stderr carries warnings that quote both; without this the default
# ANSI codepage raises UnicodeDecodeError / UnicodeEncodeError.
if sys.platform.startswith("win"):
import io as _io
for _stream_name in ("stdin", "stdout", "stderr"):
_stream = getattr(sys, _stream_name, None)
if _stream is None:
continue
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
except Exception:
pass # Optional Windows stream setup; keep hook startup non-fatal.
elif hasattr(_stream, "detach"):
try:
setattr(sys, _stream_name, _io.TextIOWrapper(_stream.detach(), encoding="utf-8", errors="replace"))
except Exception:
pass # Optional Windows stream setup; keep hook startup non-fatal.
# =============================================================================
# Constants
# =============================================================================
DIR_WORKFLOW = ".trellis"
DIR_SPEC = "spec"
# Tools whose events trigger spec matching (Claude Code tool names). Touching a
# file — even a Read — counts; the miss path stays a fast exit. Overridable via
# config `spec_injection.tools` (e.g. to drop "Read").
DEFAULT_EDIT_TOOLS = ("Read", "Edit", "Write", "MultiEdit")
# Budget defaults sized against Claude Code's documented 10,000-CHARACTER
# additionalContext ceiling — stay under with margin. The <spec-context>
# wrapper (~152 chars with typical rel paths) counts against the total, so a
# spec lands whole only up to ~9348 chars; at the per-spec cap the block is
# derived-truncated further to fit the event ceiling. `0` = unlimited.
DEFAULT_MAX_SPEC_CHARS = 9400
DEFAULT_MAX_TOTAL_CHARS = 9500
# Refresh-window default. `0` = never refresh solely because time passed.
DEFAULT_REFRESH_WINDOW_SECONDS = 2700
# State-file base dir (overridable for tests / hermeticity) and GC policy.
STATE_ENV_DIR = "TRELLIS_SPEC_STATE_DIR"
STATE_DEFAULT_DIR = "~/.trellis/spec-inject"
GC_MARKER = ".last-gc"
GC_INTERVAL_SECONDS = 60 * 60 # GC runs at most once per hour
STATE_MAX_AGE_SECONDS = 48 * 60 * 60 # shards older than this are pruned
# GC scope: exactly `<base>/<project16>/<identity>[.<pid>].jsonl`, never a
# recursive walk — a hostile or mistyped TRELLIS_SPEC_STATE_DIR must not turn
# this hook into an unlink loop over someone's files. The optional `.<pid>`
# alternative covers shards written by the pre-lock layout.
GC_PROJECT_DIR_RE = re.compile(r"^[0-9a-f]{16}$")
# `+` is part of the identity charset: subagent shards use the `+a-<agent_id>`
# suffix (contract amendment 2 — without it those shards were never pruned).
GC_SHARD_NAME_RE = re.compile(r"^[A-Za-z0-9_+-]+(\.[0-9]+)?\.jsonl$")
PATCH_PATH_RE = re.compile(
r"^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$"
)
def _warn(message: str) -> None:
print(f"[inject-spec-context] WARN: {message}", file=sys.stderr)
def _patch_paths(command: str) -> list[str]:
"""Return file paths from the shared apply_patch grammar."""
paths: list[str] = []
for line in command.splitlines():
match = PATCH_PATH_RE.fullmatch(line)
if match:
path = match.group(1).strip()
if path and path not in paths:
paths.append(path)
return paths
def _agent_id(payload: dict) -> str:
"""The subagent id carried by the event, or "" for a main-session event."""
raw = payload.get("agent_id")
return raw.strip() if isinstance(raw, str) else ""
def find_trellis_root(start: Path) -> Path | None:
"""Walk up from start to find the directory containing .trellis/.
Handles CWD drift: subdirectory launches, monorepo packages, etc.
Returns None if no .trellis/ found (silent no-op).
"""
cur = start.resolve()
while cur != cur.parent:
if (cur / DIR_WORKFLOW).is_dir():
return cur
cur = cur.parent
return None
def _scripts_dir_on_path(root: Path) -> None:
scripts_dir = root / DIR_WORKFLOW / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
# =============================================================================
# Config (.trellis/config.yaml `spec_injection:` section)
# =============================================================================
def _read_trellis_config(root: Path) -> dict:
"""Load .trellis/config.yaml via the bundled trellis_config helper.
The helper lives in .trellis/scripts/common; the hook lives outside the
scripts tree, so we extend sys.path before importing.
"""
_scripts_dir_on_path(root)
try:
from common.trellis_config import read_trellis_config # type: ignore[import-not-found]
except Exception:
return {}
try:
return read_trellis_config(root)
except Exception:
return {}
def _parse_tools(raw: object) -> tuple[str, ...] | None:
"""Parse `spec_injection.tools` into a tuple of tool names.
Two grammars, because the bundled YAML reader hands the value over in two
shapes: a block list (``- Edit`` items) arrives as a list, a flow sequence
(``tools: [Edit, Write]``) arrives as the raw string. ``[]`` in either
shape is a deliberate "never trigger" and is respected. Returns None for a
value that is neither (the caller warns and keeps the defaults).
"""
if isinstance(raw, list):
return tuple(t.strip() for t in raw if isinstance(t, str) and t.strip())
if isinstance(raw, str):
text = raw.strip()
if text.startswith("[") and text.endswith("]"):
items = (part.strip().strip("\"'").strip() for part in text[1:-1].split(","))
return tuple(item for item in items if item)
return None
def get_spec_injection_settings(
root: Path,
) -> tuple[bool, int, int, int, tuple[str, ...]]:
"""Return (enabled, max_spec_chars, max_total_chars,
refresh_window_seconds, tools).
Reads the ``spec_injection:`` section of ``.trellis/config.yaml``:
spec_injection:
enabled: true
max_spec_chars: 9400
max_total_chars: 9500
refresh_window_seconds: 2700
tools:
- Read
- Edit
- Write
- MultiEdit
Missing keys use their defaults; ``0`` disables the corresponding limit
(``max_spec_chars: 0`` = inline the whole body, ``max_total_chars: 0`` =
no per-event ceiling) or refresh (window keys). ``tools`` also accepts a
flow sequence (``tools: [Edit, Write]``), and ``tools: []`` disables every
trigger. Invalid values fall back to the default for that key with a
stderr warning; tool names outside the known set warn once.
"""
enabled = True
tools = DEFAULT_EDIT_TOOLS
numbers = {
"max_spec_chars": DEFAULT_MAX_SPEC_CHARS,
"max_total_chars": DEFAULT_MAX_TOTAL_CHARS,
"refresh_window_seconds": DEFAULT_REFRESH_WINDOW_SECONDS,
}
config = _read_trellis_config(root)
section = config.get("spec_injection") if isinstance(config, dict) else None
if isinstance(section, dict):
raw_enabled = section.get("enabled", True)
if isinstance(raw_enabled, bool):
enabled = raw_enabled
else:
s = str(raw_enabled).strip().lower()
if s in ("false", "no", "0", "off"):
enabled = False
elif s not in ("true", "yes", "1", "on"):
_warn(
f"invalid spec_injection.enabled value: {raw_enabled!r}; "
f"using true (default)"
)
# int() coercion stays local to this hook by decision (audit round,
# 2026-07-25): widening the shared common/config.py helpers has more
# blast radius than this small duplication costs.
for key, default_value in list(numbers.items()):
if key not in section:
continue
raw = section[key]
try:
value = int(raw)
except (TypeError, ValueError):
value = -1
if value < 0:
_warn(
f"invalid spec_injection.{key} value: {raw!r}; "
f"using default {default_value}"
)
continue
numbers[key] = value
if "tools" in section:
parsed_tools = _parse_tools(section["tools"])
if parsed_tools is None:
_warn(
f"invalid spec_injection.tools value: {section['tools']!r}; "
f"using default {list(DEFAULT_EDIT_TOOLS)}"
)
else:
tools = parsed_tools
unknown = [t for t in tools if t not in DEFAULT_EDIT_TOOLS]
if unknown:
_warn(
f"unknown spec_injection.tools entries {unknown} — "
f"they will never match; known tools: "
f"{list(DEFAULT_EDIT_TOOLS)}"
)
return (
enabled,
numbers["max_spec_chars"],
numbers["max_total_chars"],
numbers["refresh_window_seconds"],
tools,
)
# =============================================================================
# Identity ladder
# =============================================================================
def _sanitize(raw: str) -> str:
"""Map a session/agent id to a filename-safe, collision-free token.
A readable head (the first 80 characters, every character outside
``[A-Za-z0-9_-]`` replaced one-for-one by ``-``) plus, whenever anything
was replaced or the id was longer than 80 characters, ``-`` and 8 hex of
sha256(raw). The suffix is what makes the mapping injective: without it,
"a/b" and "a:b" — or two ids sharing an 80-character prefix — would fold
onto one state file, and a collision that MISSES an injection is the
unacceptable failure. Output stays inside the GC name class.
"""
raw = raw.strip()
head = raw[:80]
safe = re.sub(r"[^A-Za-z0-9_-]", "-", head)
if safe != head or len(raw) > 80:
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
return f"{safe}-{digest}"
return safe
def _shared_context_key(root: Path, payload: dict) -> str | None:
"""Session/window key from the shared resolver every other hook uses.
``common.active_task.resolve_context_key`` is the single source of truth
for session identity: payload keys in all casings (``session_id`` /
``sessionId`` / ``sessionID``, conversation and transcript variants),
nested payload shapes, the explicit ``TRELLIS_CONTEXT_ID`` override,
per-platform env fallbacks, and Cursor shell tickets — plus the platform
fixes accumulated behind them. Payload identity is preferred over
environment context so two live sessions can never collapse onto one
exported env value (collision → missed injection is the unacceptable
direction); the environment pass still runs when the payload carries
nothing.
"""
try:
_scripts_dir_on_path(root)
from common.active_task import resolve_context_key # type: ignore[import-not-found]
key = resolve_context_key(payload, allow_environment_context=False)
if key:
return key
return resolve_context_key(payload)
except Exception:
return None
def resolve_base_identity(root: Path, payload: dict) -> tuple[str, bool]:
"""Return the base session identity for reset and refresh state.
When the shared resolver is unavailable (older installed scripts tree), a
minimal payload-only ladder keeps the hook working. ``stateless=True``
means no state IO is possible.
"""
identity_payload = payload
if payload.get("hook_event_name") == "SessionStart":
# In this event `source` means startup/clear/compact, not platform.
# The shared resolver also accepts a generic `source` platform hint,
# so remove the lifecycle field to keep the same session identity as
# later PostToolUse events.
identity_payload = dict(payload)
identity_payload.pop("source", None)
key = _shared_context_key(root, identity_payload)
if not key:
# Minimal payload-only fallback for scripts trees that predate
# resolve_context_key. Mirrors its payload lookup order.
for k in ("session_id", "sessionId", "sessionID"):
value = payload.get(k)
if isinstance(value, str) and value.strip():
key = "s-" + value.strip()
break
if not key:
transcript = payload.get("transcript_path")
if isinstance(transcript, str) and transcript.strip():
digest = hashlib.sha256(
transcript.strip().encode("utf-8")
).hexdigest()
key = "t-" + digest[:16]
if not key:
return "", True
return _sanitize(key), False
# =============================================================================
# State (one append-only JSONL file per identity, locked, user-global)
# =============================================================================
def _state_base_dir() -> Path:
override = os.environ.get(STATE_ENV_DIR)
if override and override.strip():
return Path(override.strip())
return Path(os.path.expanduser(STATE_DEFAULT_DIR))
def _project_id(root: Path) -> str:
return hashlib.sha256(os.path.realpath(str(root)).encode("utf-8")).hexdigest()[:16]
def _maybe_gc(base_dir: Path) -> None:
"""Prune conforming shards older than 48 h, at most once per hour.
Scope is exact-depth (``<base>/<project16>/<shard>.jsonl``) and name-gated;
foreign files and directories are never touched. Containment is enforced
against symlinks on both levels — a symlinked project dir or shard is
skipped outright, and every unlink candidate must still be under the
resolved base after realpath — so a planted link cannot walk this GC out
of its own tree. Best-effort, errors ignored.
"""
try:
base_real = os.path.realpath(str(base_dir))
marker = base_dir / GC_MARKER
now = time.time()
try:
age = now - marker.stat().st_mtime
except OSError:
age = None
if age is not None and age < GC_INTERVAL_SECONDS:
return
try:
base_dir.mkdir(parents=True, exist_ok=True)
marker.touch()
except OSError:
return
try:
project_dirs = list(base_dir.iterdir())
except OSError:
return
for project_dir in project_dirs:
if not GC_PROJECT_DIR_RE.match(project_dir.name):
continue
try:
if project_dir.is_symlink() or not project_dir.is_dir():
continue
shards = list(project_dir.iterdir())
except OSError:
continue
for shard in shards:
if not GC_SHARD_NAME_RE.match(shard.name):
continue
try:
if shard.is_symlink() or not shard.is_file():
continue
shard_real = os.path.realpath(str(shard))
if not shard_real.startswith(base_real + os.sep):
continue
if now - shard.stat().st_mtime > STATE_MAX_AGE_SECONDS:
shard.unlink()
except OSError:
continue
except Exception:
pass
def open_shard(shard_path: Path) -> int | None:
"""Open (creating) the identity's shard for read+append.
Doubles as the writability probe: a failure here trips the circuit breaker
and the event runs stateless (ticket-only), which is bounded, instead of
re-emitting full specs on every event forever.
"""
try:
shard_path.parent.mkdir(parents=True, exist_ok=True)
except OSError:
_warn(f"state dir {shard_path.parent} unusable — running stateless")
return None
try:
return os.open(
str(shard_path),
os.O_RDWR | os.O_CREAT | os.O_APPEND,
0o644,
)
except OSError:
_warn(f"state shard {shard_path} unusable — running stateless")
return None
def lock_shard(fd: int) -> None:
"""Best-effort exclusive lock held across read→decide→append.
Closes the duplicate-injection race between concurrent hook processes on
POSIX. No fcntl (Windows) or an unsupported filesystem → no lock; the
worst case is a duplicate injection, never a lost one.
"""
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX)
except Exception:
pass
def unlock_shard(fd: int) -> None:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
except Exception:
pass
def load_state(
fd: int,
state_version: int,
) -> tuple[dict[str, dict], str | None] | None:
"""Read the shard through the already-open fd; newest record per spec wins
(``ts`` decides, and on an exact tie the later line in the file does —
appends are ordered, and two records one float apart must not resolve to
the older one). The latest reset marker is returned separately. Malformed
lines and foreign schema versions are skipped silently; read failures
return None so the caller uses stateless ticket mode."""
result: dict[str, dict] = {}
latest_reset: str | None = None
try:
os.lseek(fd, 0, os.SEEK_SET)
chunks: list[bytes] = []
while True:
chunk = os.read(fd, 1 << 20)
if not chunk:
break
chunks.append(chunk)
except OSError:
return None
text = b"".join(chunks).decode("utf-8", errors="replace")
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(record, dict):
continue
if record.get("v") != state_version:
continue
spec = record.get("spec")
reset = record.get("reset")
if not isinstance(spec, str):
if isinstance(reset, str) and reset:
latest_reset = reset
continue
ts = record.get("ts")
if not isinstance(ts, (int, float)):
continue
previous = result.get(spec)
if previous is None or ts >= previous.get("ts", float("-inf")):
result[spec] = record
return result, latest_reset
def append_records(fd: int, records: list[dict]) -> bool:
"""Append records as JSONL (O_APPEND) and report whether all bytes landed."""
if not records:
return True
try:
blob = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records)
encoded = blob.encode("utf-8")
if os.write(fd, encoded) != len(encoded):
_warn("could not write complete state shard — state may be incomplete")
return False
return True
except OSError:
_warn("could not write state shard — state may be incomplete")
return False
# =============================================================================
# Entry
# =============================================================================
def main() -> int:
if os.environ.get("TRELLIS_HOOKS") == "0" or os.environ.get("TRELLIS_DISABLE_HOOKS") == "1":
return 0
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError, UnicodeDecodeError):
return 0
if not isinstance(input_data, dict):
return 0
cwd = input_data.get("cwd") or os.getcwd()
root = find_trellis_root(Path(cwd))
if root is None:
return 0
# Bail out before any spec scan when the project has no spec directory.
if not (root / DIR_WORKFLOW / DIR_SPEC).is_dir():
return 0
(
enabled,
max_spec_chars,
max_total_chars,
win_seconds,
tools,
) = get_spec_injection_settings(root)
if not enabled:
return 0
_scripts_dir_on_path(root)
try:
from common.spec_inject import STATE_VERSION # type: ignore[import-not-found]
except Exception:
return 0
if input_data.get("hook_event_name") == "SessionStart":
if input_data.get("source") not in ("clear", "compact"):
return 0
base_identity, stateless = resolve_base_identity(root, input_data)
if stateless:
_warn("SessionStart reset has no stable session identity")
return 0
base_dir = _state_base_dir()
_maybe_gc(base_dir)
reset_path = base_dir / _project_id(root) / f"{base_identity}.jsonl"
reset_fd = open_shard(reset_path)
if reset_fd is None:
return 0
try:
lock_shard(reset_fd)
append_records(
reset_fd,
[{"v": STATE_VERSION, "reset": uuid.uuid4().hex, "ts": time.time()}],
)
finally:
unlock_shard(reset_fd)
try:
os.close(reset_fd)
except OSError:
pass
return 0
event_name = input_data.get("hook_event_name")
tool_name = input_data.get("tool_name", "") or input_data.get("toolName", "")
if not isinstance(tool_name, str) or not tool_name:
return 0
is_pre_tool_use = event_name == "PreToolUse"
is_patch_tool = event_name == "PreToolUse" and tool_name == "apply_patch"
logical_tool = "Edit" if is_patch_tool else tool_name
# An empty `tools` list is the documented "disable every trigger" switch.
if not tools or logical_tool not in tools:
return 0
# snake_case is Claude Code's shape; camelCase keeps parity with the
# sibling hooks that already accept both (other platforms emit toolInput).
tool_input = input_data.get("tool_input")
if not isinstance(tool_input, dict):
tool_input = input_data.get("toolInput")
if not isinstance(tool_input, dict):
return 0
try:
from common.spec_match import ( # type: ignore[import-not-found]
match_specs_for_file,
normalize_repo_relative,
)
from common.spec_inject import ( # type: ignore[import-not-found]
assemble_payload,
)
except Exception:
return 0 # matching/decision engine unavailable — degrade to nothing
if is_patch_tool:
command = tool_input.get("command")
if not isinstance(command, str):
return 0
raw_paths = _patch_paths(command)
else:
file_path = tool_input.get("file_path")
if not isinstance(file_path, str) or not file_path.strip():
return 0
raw_paths = [file_path.strip()]
file_paths: list[str] = []
for raw_path in raw_paths:
normalized = normalize_repo_relative(root, raw_path)
if normalized is not None and normalized not in file_paths:
file_paths.append(normalized)
if not file_paths:
return 0
matches = []
match_files: dict[str, str] = {}
for file_path in file_paths:
for match in match_specs_for_file(root, file_path):
if match.rel_path in match_files:
continue
matches.append(match)
match_files[match.rel_path] = file_path
if not matches:
return 0
base_identity, stateless = resolve_base_identity(root, input_data)
identity = base_identity
agent = _agent_id(input_data)
if agent:
identity += "+a-" + _sanitize(agent)
state_records: dict[str, dict] = {}
clock = {"reset": None, "ts": time.time()}
fd: int | None = None
if not stateless:
base_dir = _state_base_dir()
_maybe_gc(base_dir)
project_dir = base_dir / _project_id(root)
base_fd = open_shard(project_dir / f"{base_identity}.jsonl")
if base_fd is None:
# Circuit breaker: unwritable state → ticket-only for this event.
stateless = True
else:
lock_shard(base_fd)
base_snapshot = load_state(base_fd, STATE_VERSION)
if base_snapshot is None:
stateless = True
unlock_shard(base_fd)
os.close(base_fd)
else:
base_records, reset_id = base_snapshot
if identity == base_identity:
fd = base_fd
state_records = base_records
else:
unlock_shard(base_fd)
os.close(base_fd)
fd = open_shard(project_dir / f"{identity}.jsonl")
if fd is None:
stateless = True
else:
lock_shard(fd)
snapshot = load_state(fd, STATE_VERSION)
if snapshot is None:
stateless = True
unlock_shard(fd)
os.close(fd)
fd = None
else:
state_records, _ = snapshot
if not stateless:
clock = {
"reset": reset_id,
"ts": time.time(),
}
edited_rel = match_files[matches[0].rel_path]
records_persisted = True
try:
payload, records = assemble_payload(
edited_rel,
matches,
stateless,
state_records,
clock,
max_spec_chars,
max_total_chars,
win_seconds,
match_files=match_files,
)
if fd is not None and records:
records_persisted = append_records(fd, records)
finally:
if fd is not None:
unlock_shard(fd)
try:
os.close(fd)
except OSError:
pass
if not payload:
return 0
hook_specific_output = {
"hookEventName": "PreToolUse" if is_pre_tool_use else "PostToolUse",
"additionalContext": payload,
}
if (
is_pre_tool_use
and records_persisted
and any(record.get("mode") == "full" for record in records)
):
hook_specific_output.update(
{
"permissionDecision": "deny",
"permissionDecisionReason": (
"Trellis injected governing specs. Review them, then retry "
"this tool call."
),
}
)
output = {"hookSpecificOutput": hook_specific_output}
print(json.dumps(output, ensure_ascii=False))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# Hook failures must never break the tool result or the session.
sys.exit(0)
+13 -5
View File
@@ -215,11 +215,12 @@ def truncate_utf8(data: bytes, cap: int) -> bytes:
seq_len = 4
else:
seq_len = 1
# Drop the lead byte too if its full sequence didn't fit.
# Cut before the lead byte when its full sequence didn't fit;
# otherwise the trailing sequence is complete — keep it whole.
if (i - 1) + seq_len > len(truncated):
i -= 1
return truncated[: i - 1]
return truncated[:i]
return truncated
class _Budget:
@@ -877,8 +878,15 @@ def _handle_codex_subagent_start(input_data: dict) -> None:
if not subagent_type or not parent_session_id:
return
cwd = _string_value(input_data.get("cwd")) or os.getcwd()
repo_root = find_repo_root(cwd)
# Payload cwd first, then our own — some hosts (CodeBuddy IDE 4.10.4)
# report "/" for every hook event. See inject-workflow-state.py.
repo_root = None
for candidate in (_string_value(input_data.get("cwd")), os.getcwd()):
if not candidate:
continue
repo_root = find_repo_root(candidate)
if repo_root:
break
if not repo_root:
return
+52 -16
View File
@@ -10,19 +10,24 @@ The emitted ``hookEventName`` field is platform-aware: most hosts expect
CodeBuddy / Droid / Codex / Copilot wiring), but Gemini CLI 0.40.x renamed
its per-turn event to ``BeforeAgent`` and its schema validator rejects the
legacy name. ``_detect_platform`` picks the right value at runtime.
Breadcrumb text is pulled exclusively from workflow.md
[workflow-state:STATUS] tag blocks — workflow.md is the single source of
truth. There are no fallback dicts in this script: when workflow.md is
Breadcrumb text is pulled exclusively from the resolved workflow file's
[workflow-state:STATUS] tag blocks — the active task may select a
per-task variant (`.trellis/workflows/<id>.md` via task.json `workflow`),
otherwise personal, team, and global defaults are resolved in order.
There are no fallback dicts in this script: when the resolved workflow is
missing or a tag is absent, the breadcrumb degrades to a generic
"Refer to workflow.md for current step." line so users see (and fix)
the broken state instead of the hook silently masking it.
Shared across all hook-capable platforms (Claude, Cursor, Codex, Qoder,
CodeBuddy, Droid, Gemini, Copilot, Kiro). Kiro wires this via the CLI
Which platforms register this hook is decided by SHARED_HOOKS_BY_PLATFORM
in templates/shared-hooks/index.ts — currently Claude, Codex, Gemini,
Qoder, Copilot, CodeBuddy, Droid, Kiro, Trae and ZCode. That table is the
source of truth; each listed platform's collect<Platform>Templates() pulls
this file into its template map through collectSharedHooks(), and a single
writer puts that map on disk at init time. Kiro wires this via the CLI
custom agent's ``hooks.userPromptSubmit`` and the IDE ``.kiro.hook``
``promptSubmit`` event; its output branch emits a plain-text breadcrumb
(Kiro adds hook stdout directly to the conversation context). Written to
each platform's hooks directory via writeSharedHooks() at init time.
(Kiro adds hook stdout directly to the conversation context).
Silent exit 0 cases (no output):
- No .trellis/ directory found (not a Trellis project)
@@ -95,11 +100,16 @@ def find_trellis_root(start: Path) -> Optional[Path]:
def _detect_platform(input_data: dict) -> str | None:
if isinstance(input_data.get("cursor_version"), str):
return "cursor"
# CLAUDE_PROJECT_DIR is a compatibility alias that several hosts set
# alongside their own variable — CodeBuddy, ZCode and Trae all do. It must
# therefore be checked LAST, or every one of them is detected as claude and
# the context key becomes `claude_<their-session-id>`. That key does not
# match the session file `task.py start` wrote under the host's real name,
# so every turn reports no_task while the pointer exists on disk.
# Observed on CodeBuddy IDE 4.10.4: session file `codebuddy_ae54840e….json`
# alongside marker `update-check-claude_ae54840e….marker`, same id.
env_map = {
# ZCode may set both ZCODE_PROJECT_DIR and CLAUDE_PROJECT_DIR; check
# ZCODE first so ZCode sessions aren't misdetected as claude.
"ZCODE_PROJECT_DIR": "zcode",
"CLAUDE_PROJECT_DIR": "claude",
"CURSOR_PROJECT_DIR": "cursor",
"CODEBUDDY_PROJECT_DIR": "codebuddy",
"FACTORY_PROJECT_DIR": "droid",
@@ -108,6 +118,8 @@ def _detect_platform(input_data: dict) -> str | None:
"KIRO_PROJECT_DIR": "kiro",
"COPILOT_PROJECT_DIR": "copilot",
"TRAE_PROJECT_DIR": "trae",
# Last: the shared alias, only meaningful once no vendor key matched.
"CLAUDE_PROJECT_DIR": "claude",
}
for env_name, platform in env_map.items():
if os.environ.get(env_name):
@@ -183,16 +195,40 @@ _TAG_RE = re.compile(
re.DOTALL,
)
def load_breadcrumbs(root: Path) -> dict[str, str]:
"""Parse workflow.md for [workflow-state:STATUS] blocks.
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
Returns {status: body_text}. workflow.md is the single source of
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(
root, input_data, platform=_detect_platform(input_data)
)
except Exception:
return root / ".trellis" / "workflow.md"
def load_breadcrumbs(root: Path, input_data: dict) -> dict[str, str]:
"""Parse the resolved workflow file for [workflow-state:STATUS] blocks.
Returns {status: body_text}. The workflow file is the single source of
truth — there are no fallback dicts in this script. Missing tags
(or a missing/unreadable workflow.md) fall back to a generic line
(or a missing/unreadable workflow file) fall back to a generic line
in build_breadcrumb so users see the broken state and fix
workflow.md, rather than the hook silently masking the issue.
The active task's per-task workflow selection (task.json `workflow`
field) is honored via _resolve_workflow_md; without a selection this
reads the global .trellis/workflow.md exactly as before.
"""
workflow = root / ".trellis" / "workflow.md"
workflow = _resolve_workflow_md(root, input_data)
if not workflow.is_file():
return {}
try:
@@ -411,7 +447,7 @@ def main() -> int:
if prompt_has_skip_keyword(data.get("prompt", ""), _resolve_skip_keyword(config)):
return 0 # user opted out of the per-turn breadcrumb for this turn
templates = load_breadcrumbs(root)
templates = load_breadcrumbs(root, data)
platform = _detect_platform(data)
task = get_active_task(root, data)
if task is None:
Regular → Executable
+20 -1
View File
@@ -452,6 +452,25 @@ def _strip_breadcrumb_tag_blocks(content: str) -> str:
return re.sub(r"\n{3,}", "\n\n", stripped).strip()
def _resolve_workflow_md(root: Path, input_data: dict) -> Path:
"""Resolve the active task's workflow file, falling back to the global one.
The per-task resolution rule lives in common.workflow_selection inside
.trellis/scripts. Older installed projects may not ship that module, and
hooks must never crash the session — ANY failure (import error, old
scripts tree, resolver bug) falls back to the global workflow.md.
"""
try:
scripts_dir = root / ".trellis" / "scripts"
if str(scripts_dir) not in sys.path:
sys.path.insert(0, str(scripts_dir))
from common.workflow_selection import resolve_workflow_md # type: ignore[import-not-found]
return resolve_workflow_md(root, input_data, platform="codex")
except Exception:
return root / ".trellis" / "workflow.md"
def _build_workflow_toc(workflow_path: Path) -> str:
"""Inject only the compact Phase Index summary for SessionStart."""
content = read_file(workflow_path)
@@ -505,7 +524,7 @@ Trellis compact SessionStart context. Use it to orient the session; load details
output.write("\n</current-state>\n\n")
output.write("<trellis-workflow>\n")
output.write(_build_workflow_toc(trellis_dir / "workflow.md"))
output.write(_build_workflow_toc(_resolve_workflow_md(project_dir, hook_input)))
output.write("\n</trellis-workflow>\n\n")
output.write("<guidelines>\n")
+1
View File
@@ -30,4 +30,5 @@ ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS=0.2
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522
ZHIXING_SELECTION_MAX_WORKERS=4
ZHIXING_SELECTION_BATCH_SIZE=200
ZHIXING_SELECTION_PATTERN_SCORING_ENABLED=true
API_UPSTREAM=http://server:8000
+2 -2
View File
@@ -8,8 +8,8 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
runs-on: tencent-prod
timeout-minutes: 60
env:
COMPOSE_PROJECT_NAME: zhixing-system
+3
View File
@@ -20,3 +20,6 @@ node_modules/
.pnpm-store/
dist/
coverage/
# Playwright CLI session artifacts
.playwright-cli/
+61 -4
View File
@@ -772,10 +772,11 @@ function truncateUtf8(buf: Buffer, cap: number): Buffer {
if ((lead & 0xe0) === 0xc0) seqLen = 2;
else if ((lead & 0xf0) === 0xe0) seqLen = 3;
else if ((lead & 0xf8) === 0xf0) seqLen = 4;
// Drop the lead byte too if its full sequence didn't fit.
if (i - 1 + seqLen > cap) i--;
// Cut before the lead byte when its full sequence didn't fit;
// otherwise the trailing sequence is complete — keep it whole.
if (i - 1 + seqLen > cap) return buf.subarray(0, i - 1);
}
return buf.subarray(0, i);
return buf.subarray(0, cap);
}
function stripInlineComment(value: string): string {
@@ -1069,10 +1070,66 @@ function readTaskDir(root: string, key: string | null): string | null {
}
// ── Workflow State Breadcrumb ─────────────────────────────────────────
const WORKFLOW_ID_RE = /^[A-Za-z0-9_-]+$/;
const DEFAULT_WORKFLOW_RE =
/^default_workflow:\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/m;
function workflowVariant(root: string, workflowId: string): string {
if (!WORKFLOW_ID_RE.test(workflowId)) return "";
const path = join(root, ".trellis", "workflows", `${workflowId}.md`);
return exists(path) ? path : "";
}
function developerWorkflowId(root: string): string {
for (const line of readText(join(root, ".trellis", ".developer")).split(
/\r?\n/,
)) {
if (line.startsWith("workflow="))
return line.slice("workflow=".length).trim();
}
return "";
}
function configDefaultWorkflowId(root: string): string {
return (
readText(join(root, ".trellis", "config.yaml")).match(
DEFAULT_WORKFLOW_RE,
)?.[2] ?? ""
);
}
/** Mirrors common/workflow_selection.py without spawning another Python
* process on every Pi turn. Invalid or missing selections fall through. */
function resolveWorkflowMd(root: string, key: string | null): string {
const taskDir = readTaskDir(root, key);
let workflowId = "";
if (taskDir) {
try {
const task = JSON.parse(
readText(join(taskDir, "task.json")),
) as JsonObject;
workflowId = typeof task.workflow === "string" ? task.workflow : "";
} catch {}
}
if (workflowId) {
const pinned = workflowVariant(root, workflowId);
if (pinned) return pinned;
console.error(
`Warning: active task selects workflow ${JSON.stringify(workflowId)} but .trellis/workflows/ has no matching file; using default workflow resolution`,
);
}
const personal = workflowVariant(root, developerWorkflowId(root));
if (personal) return personal;
const team = workflowVariant(root, configDefaultWorkflowId(root));
if (team) return team;
return join(root, ".trellis", "workflow.md");
}
const WF_RE =
/\[workflow-state:([A-Za-z0-9_-]+)\]\s*\n([\s\S]*?)\n\s*\[\/workflow-state:\1\]/g;
function workflowBreadcrumb(root: string, key: string | null): string {
const wf = readText(join(root, ".trellis", "workflow.md"));
const wf = readText(resolveWorkflowMd(root, key));
if (!wf) return "";
const templates: Record<string, string> = {};
for (const m of wf.matchAll(WF_RE)) {
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/tushare
+22 -18
View File
@@ -24,7 +24,7 @@
".agents/skills/trellis-meta/references/customize-local/change-task-lifecycle.md": "60ff9efb93604b87a461a4af30322d76750402a51e40f31531a7ff88d309996d",
".agents/skills/trellis-meta/references/customize-local/change-workflow.md": "43fa780a2ca580de121b10893d49b99f978873deebbf45008c466e5ac6651519",
".agents/skills/trellis-meta/references/customize-local/overview.md": "ce8f09e9f93ce9a48500763fb3a4db2b3908a5fbf4f985ab71dacebb404cf8f4",
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "7a8d1a5dcc8d1140c4c6bd19d02949364cdd01801bd0825a975a308cf85b8f37",
".agents/skills/trellis-meta/references/local-architecture/bundled-skills.md": "aa6a0bf83060205ee4ea621c467fb900a7db06b4476a4ad472cc4e248c887389",
".agents/skills/trellis-meta/references/local-architecture/context-injection.md": "8497289bf333b3aa456f317039d1239b7ece79254aa0eb62cfc647714c866084",
".agents/skills/trellis-meta/references/local-architecture/generated-files.md": "7eb2d452eddb4f4226f7578c2ec6d5ee0434ed172ba4c36107cc8bdff7554dc6",
".agents/skills/trellis-meta/references/local-architecture/multi-agent-channel.md": "56e5070474aeca872e2d70c46feea5aaafecd3d3ec052c3f7b1877358dca62e9",
@@ -34,7 +34,7 @@
".agents/skills/trellis-meta/references/local-architecture/workflow.md": "cfcdc6e4468a5d9c816e929fcca01640cd41cfdaaa4824118b40a8e460c927b6",
".agents/skills/trellis-meta/references/local-architecture/workspace-memory.md": "e6427b46aba744563c2444b30df4043cd856561b7709ec2dece26095416421fd",
".agents/skills/trellis-meta/references/platform-files/agents.md": "9f41349b78f7ae64698a38490a317882561f3805b26a79bda587a13d03fda245",
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "154af08f7ee8afe7a704968ec0b9fc21e905b7cfb03871c9dbafcdd6cc654319",
".agents/skills/trellis-meta/references/platform-files/hooks-and-settings.md": "cb7c1c7dd976004e4981181ab9462f8eedc4e9c014721419bd666d6608f4dd72",
".agents/skills/trellis-meta/references/platform-files/overview.md": "1aec9087ccedd56a213af877b5db474131aa4b4789b6ac0e8da73b058aa43bd4",
".agents/skills/trellis-meta/references/platform-files/platform-map.md": "9e476e500f10b2a1a05278dd700837deb731a77e3f3993e90f8102528a9bdcca",
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
@@ -50,10 +50,10 @@
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609",
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
".codex/hooks/session-start.py": "91fbbd30ac974c3cd2b4db152aa38ac176a9c7e3d062cbaa1acacf07152b2592",
".codex/hooks/inject-subagent-context.py": "db413933ff30e1503f37f1d292fb421b1ced3f39f1890351a6621e499ec16b2c",
".codex/hooks/inject-workflow-state.py": "cda5888c29671035e7d2e033a0fcc631a9428a0543fb2dd071285b30c6ca4e23",
".codex/hooks.json": "c16c9af7f6010bf4fabb64fd4bd497201d34583b96c665ea1d5739eed48b3fb5",
".codex/config.toml": "9f2d20e28f0bc9c886312eca3ad3bba41533ef4615aaaafe25e98152302267bb",
".pi/prompts/trellis-start.md": "28af1eb6645d8b517cf705277d8405370b712926e6b01667d6698564002c6a9d",
".pi/prompts/trellis-continue.md": "12c2f0288ff67af3368c0b577a50027a11a25fec1d32ed34282a4dc84be08f1c",
@@ -61,40 +61,44 @@
".pi/agents/trellis-check.md": "1dbfedd3403f201fbfdbae8d810afba0a1f812b97f0f8e308908db7eaceea496",
".pi/agents/trellis-implement.md": "9bb1f70d09b7104a671ef9a0ba072b4500d45b8556a253126a003e2b1e7281a2",
".pi/agents/trellis-research.md": "ef77555f4c2c4ade36f1c23a076b6f4bb9d180ff24a2f00d7cdc2f8fd5af0b0a",
".pi/extensions/trellis/index.ts": "b4bfd740d517462f943aaff920dd39371d3e6bdc3823dfa8c2752314c57eaf9b",
".pi/extensions/trellis/index.ts": "770290e675fabfe0dd0889771876607927d36cb9ef68586552e6e2d56ca345fc",
".pi/settings.json": "b68f37c04a7007d2b52d5a87326e3786edbc903bfa518358830dd85727c13d7c",
"AGENTS.md": "6cacfe99748b435d0660c2463c697bc323d53798aecf3492283ca8eac1b29682",
".trellis/agents/check.md": "edb4f57361407249a53bf5998ebf91c40d2b969e826a2c5e1b4e813a08bcb175",
".trellis/agents/implement.md": "66e25ad046c94869442834bc3cdfbd5a9a7412d3ff54561d64d2886552c27e87",
".trellis/config.yaml": "a966e6d374e9e6ff283cf761ccd99631323ee1754856cef51ad154ca0afb9dfa",
".trellis/config.yaml": "eaba56c36fb07483fcbc96d74c4da0ab33b9739fb9ff10ea4cc1e1cd32bf23b2",
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
".trellis/scripts/common/active_task.py": "28a81f8828538fb70a15c88edd90eda9d685adbde8862f67f630bce5b27d9832",
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
".trellis/scripts/common/config.py": "43a22c4e88a06d6316d1bcd4730731bdecc22ae782e9f217b25ea53aa85cd416",
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
".trellis/scripts/common/git_context.py": "be0c68d4b566319484cf95a3ca6d6dee18e00f040cecadea6a98f10dc445f967",
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
".trellis/scripts/common/paths.py": "5f66eb073c296a8a920048c1c53d8236783ccb998c8c25a68a540534d30abd02",
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
".trellis/scripts/common/task_context.py": "4ea260a022f4122361eb0d9dd9200a9324aeafbdb20cadd2848bea1649938f1d",
".trellis/scripts/common/session_context.py": "3379ef1766e4e5ca77cbb7c040dbba3883fcca2548580299e3b38dbf22f4f7d5",
".trellis/scripts/common/task_context.py": "be5fa407f99c2400075194dbaa8b6ec996ce8aac2122d191bb9cb11e479a9476",
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
".trellis/scripts/common/task_store.py": "793b9e7863fade04b0d84c7668bacb4229c435f0fcdba008bd333421ec3e17b3",
".trellis/scripts/common/task_utils.py": "90c0a6d50bad502c3f01cb24c1ccfeb0eece5e2c69efaff8d65eb827fba43871",
".trellis/scripts/common/tasks.py": "4436a8b0b53c270a35989e26d9dbd92669408c6562d88c02083a404562da85fe",
".trellis/scripts/common/trellis_config.py": "e282e897183e3ec2f4e6e56349431946e5f98c1c31d3eca4de7fc44e1383a7bf",
".trellis/scripts/common/types.py": "9962081cc2608fb9d1deb32c6880e336f62cdca6b338e7ae813304701e155ee9",
".trellis/scripts/common/workflow_phase.py": "79ee522de20246acf1e2c222e8ad180ad25aaec7fec98214a93d9e81b350d9a8",
".trellis/scripts/common/workflow_phase.py": "c3d00011a4d8c3d958ec57cea50b97f4170dce7c28381ad3915dae0870167d6e",
".trellis/scripts/get_context.py": "ca5bf9e90bdb1d75d3de182b95f820f9d108ab28793d29097b24fd71315adcf5",
".trellis/scripts/get_developer.py": "84c27076323c3e0f2c9c8ed16e8aa865e225d902a187c37e20ee1a46e7142d8f",
".trellis/scripts/hooks/linear_sync.py": "e09cc4ce4699aada908808718698f33f705a3edf55c4dcf8f777ad892f80ca79",
".trellis/scripts/init_developer.py": "f9e6c0d882406e81c8cd6b1c5abb204b0befc0069ff89cf650cd536a80f8c60e",
".trellis/scripts/task.py": "e0ffed9f14994069f0c992141e3ec168524be5af32e3681e6ea30ba0a5da4bc4",
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76"
".trellis/scripts/task.py": "7790d9510311d55ed1f66c71b9ce0bafc8871f1b675c1251dcf6a1f4e823d2b3",
".trellis/workflow.md": "e2c5ab7004ff83a5a804b50df81746aa1d558dd4480463287622605f86a82a76",
".trellis/scripts/common/workflow_selection.py": "b136d6ae41aac95f4d5f425b641b62b7002f7cff2d26aa44b056cef9a2242753",
".trellis/scripts/common/spec_match.py": "baf3b17b15c279475f99699ac6e039bc68285d5dbfbda28f279aa8346cb0c59a",
".trellis/scripts/common/spec_inject.py": "9987d213eed3d2ec1b2c16adcad8b10a21d3b68dcce838f85c42d83c56e69e0b",
".codex/hooks/inject-spec-context.py": "4df3945be18163afe066ec3061d8b95a687f676e3a80dbf0aada4dec8c339f90"
}
}
+1 -1
View File
@@ -1 +1 @@
0.6.12
0.7.0-beta.3
+47
View File
@@ -76,6 +76,21 @@ max_journal_lines: 2000
# Default package used when --package is not specified.
# default_package: frontend
#-------------------------------------------------------------------------------
# Default workflow
#-------------------------------------------------------------------------------
# Team-shared default workflow for tasks that do not pin one. The id names a
# variant file in `.trellis/workflows/<id>.md` (populate it with
# `trellis workflow --save <id>`). This value is committed, so the whole team
# shares the same default. A per-developer override lives in the gitignored
# `.developer` file as a `workflow=<id>` line and takes precedence over this.
#
# Resolution precedence: per-task (task.json `workflow`) > personal
# (`.developer` `workflow=`) > this `default_workflow` > global
# `.trellis/workflow.md`.
#
# default_workflow: native
#-------------------------------------------------------------------------------
# Channel worker OOM guard
#-------------------------------------------------------------------------------
@@ -145,6 +160,38 @@ channel:
# max_artifact_bytes: 65536 # per task artifact (prd.md / design.md / implement.md)
# max_total_bytes: 131072 # whole injected payload; overflow degrades to index lines
#-------------------------------------------------------------------------------
# Path-scoped spec injection
#-------------------------------------------------------------------------------
# When the agent touches a file (Read/Edit/Write/MultiEdit), spec .md files
# under .trellis/spec/ whose frontmatter `paths:` globs match the touched path
# are surfaced into the session right then. The first time a spec matches it is
# injected in full; while its content is unchanged and the refresh window has
# not elapsed it stays silent; once the window elapses a short `<spec-ticket>`
# reminder is emitted to counter recency decay. Editing the spec itself — or a
# SessionStart after /clear or /compact — re-injects the full text.
# Oversized specs are truncated with a notice; once the per-event payload cap
# is reached, remaining full bodies degrade to index lines (path + description)
# instead of being inlined.
#
# Character values: the ceiling this budget respects is Claude Code's
# documented 10,000-CHARACTER additionalContext limit, so the caps count
# characters too (byte caps made CJK specs pay 3x for the same text).
# `0` disables the corresponding limit.
# The refresh window uses wall-clock seconds. `0` disables time-based reminders;
# SessionStart resets after /clear or /compact still force a full re-injection.
#
# spec_injection:
# enabled: true # false disables injection entirely
# max_spec_chars: 9400 # per matched spec file
# max_total_chars: 9500 # whole per-event payload; overflow degrades to index lines
# refresh_window_seconds: 2700 # touches past this interval re-emit a ticket
# tools: # tool events that trigger injection
# - Read
# - Edit
# - Write
# - MultiEdit
#-------------------------------------------------------------------------------
# Per-turn prompt injection
#-------------------------------------------------------------------------------
+112 -43
View File
@@ -23,8 +23,15 @@ DIR_WORKFLOW = ".trellis"
DIR_TASKS = "tasks"
DIR_RUNTIME = ".runtime"
DIR_SESSIONS = "sessions"
DIR_CURSOR_SHELL = "cursor-shell"
CURSOR_SHELL_TICKET_TTL_SECONDS = 30
DIR_SHELL_TICKETS = "shell-tickets"
# Pre-0.6.13 name, when the bridge was Cursor-only. Still read so a session that
# was mid-command across an upgrade does not silently degrade; never written.
# Tickets are 30-second ephemera, so the old directory ages out by itself —
# there is nothing to migrate, only a glob on a directory that is normally
# absent. The alternative (ignore it) would land its one lost command on the
# platform that works today.
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
SHELL_TICKET_TTL_SECONDS = 30
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
@@ -50,35 +57,75 @@ _KNOWN_PLATFORMS = {
"snow",
}
# Every name below records how it was checked. Do NOT add a name by analogy
# with a neighbour: a 2026-08-05 audit of all 21 platforms found 12 of the 21
# declared names had never existed anywhere — they were pattern-guessed from a
# `<PLATFORM>_SESSION_ID` shape no vendor agreed to, and the uniformity was the
# only "evidence" behind them. A platform with no verified name belongs in no
# table; it resolves through TRELLIS_CONTEXT_ID or its hook/plugin bridge.
_ENV_SESSION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude", ("CLAUDE_SESSION_ID", "CLAUDE_CODE_SESSION_ID")),
("codex", ("CODEX_SESSION_ID", "CODEX_THREAD_ID")),
("cursor", ("CURSOR_SESSION_ID",)),
("opencode", ("OPENCODE_SESSION_ID", "OPENCODE_SESSIONID", "OPENCODE_RUN_ID")),
# REAL, undocumented (verified 2026-08-05 in a live Claude Code 2.1.221 bash
# child; absent from code.claude.com/docs/en/env-vars). CLAUDE_SESSION_ID
# was removed here — verified absent from that same live environment.
("claude", ("CLAUDE_CODE_SESSION_ID",)),
# REAL, undocumented (verified 2026-08-05: injected by codex-cli 0.146.0
# into shell children, absent from the parent env; openai/codex#19937).
# CODEX_SESSION_ID was removed — absent from a live `codex exec` env.
("codex", ("CODEX_THREAD_ID",)),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): set by Gemini's
# hookRunner.ts. Its shell tool builds the child env in
# shellExecutionService.ts and adds only GEMINI_CLI/TERM/PAGER/GIT_PAGER, so
# this never reaches a bash child — it resolves only inside a hook process.
("gemini", ("GEMINI_SESSION_ID",)),
("droid", ("FACTORY_SESSION_ID", "DROID_SESSION_ID")),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): docs.qoder.com/zh/
# extensions/hooks documents it as injected during hook execution by the
# Qoder *IDE plugin*. Absent from the Qoder CLI hook docs and from Lingma.
("qoder", ("QODER_SESSION_ID",)),
("codebuddy", ("CODEBUDDY_SESSION_ID",)),
# UNVERIFIED (2026-08-05): absent from kiro.dev/docs/hooks/, but Dynatrace
# dtctl, oh-my-agent and gastown all key agent detection on it and one notes
# it is "set in both interactive and --no-interactive". Kept because that is
# absence of evidence, not evidence of absence. To settle: run
# `env | grep KIRO` from a Kiro shell-tool call on a machine with Kiro.
("kiro", ("KIRO_SESSION_ID",)),
# UNVERIFIED (2026-08-05): absent from docs.github.com/en/copilot/reference/
# hooks-reference and from the CLI programmatic reference. To settle: run
# `copilot help environment` (the authoritative list per those docs) — not
# runnable here, the CLI is not installed and copilot-cli ships no source.
("copilot", ("COPILOT_SESSION_ID", "COPILOT_SESSIONID")),
("pi", ("PI_SESSION_ID", "PI_SESSIONID")),
("trae", ("TRAE_SESSION_ID",)),
# ZCode reuses CLAUDE_SESSION_ID (it does not document a ZCODE_SESSION_ID).
# Platform-scoped lookup (_iter_env_keys filters by platform name), so this
# only fires when the resolver already detected "zcode" — no collision with
# REASONED, UNVERIFIED (2026-08-05): ZCode is closed-source and not
# installable here. It mirrors Claude's naming elsewhere (CLAUDE_PLUGIN_ROOT
# / CLAUDE_PLUGIN_DATA compat aliases are in its docs), and the previously
# declared CLAUDE_SESSION_ID does not exist on Claude Code either — so the
# name ZCode would actually reuse is CLAUDE_CODE_SESSION_ID. Try that first,
# keep the historical name as a fallback: if neither exists nothing changes.
# Platform-scoped lookup (_iter_env_keys filters by platform name), so the
# entry only fires once the resolver detected "zcode" — no collision with
# the claude entry above.
("zcode", ("CLAUDE_SESSION_ID",)),
# Snow CLI exports SNOW_SESSION_ID into hook/terminal/sub-agent children.
# TRELLIS_CONTEXT_ID remains the preferred override when present.
("zcode", ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")),
# REAL by vendor design (verified 2026-08-05): Snow's sessionIdentityEnv.ts
# exports SNOW_SESSION_ID into hook/terminal/sub-agent children and names
# Trellis in its source header. TRELLIS_CONTEXT_ID stays the preferred
# override — Snow sets that too.
("snow", ("SNOW_SESSION_ID",)),
)
_ENV_CONVERSATION_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
# REAL in cursor-agent (CLI), undocumented (verified 2026-08-05: the value
# matches ~/.cursor/chats/<ws>/<id>). The Cursor *IDE* is unverified — a
# 2026-05 forum request for it drew no staff reply. The invented
# CURSOR_SESSION_ID was removed from the session table: empty in a live
# cursor-agent shell. Cursor's other path is the shell ticket below
# (_lookup_shell_ticket_context_key), which is not Cursor-specific.
("cursor", ("CURSOR_CONVERSATION_ID", "CURSOR_CONVERSATIONID")),
)
_ENV_TRANSCRIPT_KEYS: tuple[tuple[str, tuple[str, ...]], ...] = (
("claude", ("CLAUDE_TRANSCRIPT_PATH",)),
("codex", ("CODEX_TRANSCRIPT_PATH",)),
# REAL but HOOK-SCOPE ONLY (verified 2026-08-05): documented for Cursor hook
# scripts; empty in the agent's own shell env.
("cursor", ("CURSOR_TRANSCRIPT_PATH",)),
# UNVERIFIED — never researched. The 2026-08-05 audit covered the session
# table only, so do not infer these are real *or* fake from that work
# (CLAUDE_/CODEX_TRANSCRIPT_PATH were removed because those two *were*
# checked: absent from docs and from live envs). To settle each: run
# `env | grep _TRANSCRIPT_PATH` inside a hook and inside a shell-tool call.
("gemini", ("GEMINI_TRANSCRIPT_PATH",)),
("droid", ("FACTORY_TRANSCRIPT_PATH", "DROID_TRANSCRIPT_PATH")),
("qoder", ("QODER_TRANSCRIPT_PATH",)),
@@ -90,11 +137,15 @@ _ENV_PLATFORM_ALIASES = {
"factory-ai": "droid",
"github-copilot": "copilot",
}
# ZCode intentionally reuses CLAUDE_SESSION_ID. Hooks know the host is ZCode,
# while later shell commands see only the shared env name and resolve it through
# the Claude entry. Canonicalize both paths to one runtime filename.
# ZCode intentionally reuses Claude's session env var name. Hooks know the host
# is ZCode, while later shell commands see only the shared env name and resolve
# it through the claude entry. Canonicalize both paths to one runtime filename.
_CONTEXT_KEY_PLATFORM_ALIASES = {
"zcode": "claude",
# Factory Droid's config directory is `.factory/`, so a hook that names its
# platform after the directory it was installed in reports "factory". Its
# sibling hooks report "droid". One runtime filename either way.
"factory": "droid",
}
@@ -222,6 +273,12 @@ def _iter_env_keys(
env_keys: tuple[tuple[str, tuple[str, ...]], ...],
platform_name: str | None,
) -> tuple[tuple[str, tuple[str, ...]], ...]:
"""Narrow an env-key table to one platform, or return all of it.
A platform with no entry yields an empty tuple, and the caller's `for` loop
simply does not run. That is the normal case, not an error: platforms with
no verified env var name are deliberately absent from these tables.
"""
if not platform_name:
return env_keys
matched = tuple((name, keys) for name, keys in env_keys if name == platform_name)
@@ -275,8 +332,12 @@ def _find_repo_root_from_cwd() -> Path | None:
current = current.parent
def _cursor_shell_ticket_dir(repo_root: Path) -> Path:
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_CURSOR_SHELL
def _shell_ticket_dirs(repo_root: Path) -> tuple[Path, ...]:
runtime_dir = repo_root / DIR_WORKFLOW / DIR_RUNTIME
return (
runtime_dir / DIR_SHELL_TICKETS,
runtime_dir / DIR_LEGACY_CURSOR_SHELL_TICKETS,
)
def _remove_file(path: Path) -> bool:
@@ -334,7 +395,7 @@ def _ticket_is_fresh(ticket: dict[str, Any], ticket_path: Path, now: float) -> b
created_at = ticket.get("created_at_epoch")
if isinstance(created_at, (int, float)):
if now - created_at <= CURSOR_SHELL_TICKET_TTL_SECONDS:
if now - created_at <= SHELL_TICKET_TTL_SECONDS:
return True
_remove_file(ticket_path)
return False
@@ -352,13 +413,18 @@ def _ticket_cwd_matches_repo(ticket: dict[str, Any], repo_root: Path) -> bool:
return True
def _matching_cursor_ticket_context_key(
def _matching_ticket_context_key(
ticket_path: Path,
repo_root: Path,
now: float,
) -> str | None:
"""Accept a ticket on its merits, never on which platform wrote it.
The `platform` field a ticket carries is debugging metadata; gating on it
was what kept this bridge invisible to every platform but Cursor.
"""
ticket = _read_json(ticket_path)
if ticket is None or ticket.get("platform") != "cursor":
if ticket is None:
return None
if not _ticket_is_fresh(ticket, ticket_path, now):
return None
@@ -369,29 +435,30 @@ def _matching_cursor_ticket_context_key(
return _string_value(ticket.get("context_key"))
def _lookup_cursor_shell_ticket_context_key() -> str | None:
"""Resolve Cursor conversation identity from a short-lived shell ticket.
def _lookup_shell_ticket_context_key() -> str | None:
"""Resolve session identity from a short-lived shell ticket.
Cursor exposes `conversation_id` to `beforeShellExecution`, but does not
export it into the shell command environment. The Cursor hook writes a
short-lived ticket just before `task.py` runs. We accept a ticket only when
the current `task.py` subcommand matches and exactly one fresh context key
matches, which avoids cross-window pointer contamination.
No researched platform exports its session id into a shell child, but every
hook-capable one hands that id to a hook. So the hook that runs just before
a shell command writes a ticket, and this reads it back. A ticket counts
only when it is fresh, was written for this repo, and matches the `task.py`
subcommand now running — and only when exactly one fresh context key
matches. Two concurrent windows therefore both degrade rather than one
inheriting the other's pointer.
"""
repo_root = _find_repo_root_from_cwd()
if repo_root is None:
return None
ticket_dir = _cursor_shell_ticket_dir(repo_root)
if not ticket_dir.is_dir():
return None
now = time.time()
candidates: set[str] = set()
for ticket_path in ticket_dir.glob("*.json"):
context_key = _matching_cursor_ticket_context_key(ticket_path, repo_root, now)
if context_key:
candidates.add(context_key)
for ticket_dir in _shell_ticket_dirs(repo_root):
if not ticket_dir.is_dir():
continue
for ticket_path in ticket_dir.glob("*.json"):
context_key = _matching_ticket_context_key(ticket_path, repo_root, now)
if context_key:
candidates.add(context_key)
if len(candidates) == 1:
return next(iter(candidates))
@@ -435,8 +502,10 @@ def resolve_context_key(
if env_context_key:
return env_context_key
if allow_environment_context and platform_name in (None, "session", "cursor"):
return _lookup_cursor_shell_ticket_context_key()
# Last in the chain on purpose: a platform that genuinely exports identity
# into the shell outranks a ticket, and no platform name gates the lookup.
if allow_environment_context:
return _lookup_shell_ticket_context_key()
return None
+18
View File
@@ -281,6 +281,24 @@ def get_codex_dispatch_mode(repo_root: Path | None = None) -> str:
return "inline"
def get_default_workflow(repo_root: Path | None = None) -> str | None:
"""Return the team-shared default workflow id from config.yaml.
Reads the top-level ``default_workflow`` key — a slug naming a variant in
``.trellis/workflows/<id>.md``. Returns None when unset or blank. This is
the git-tracked, team-shared default; a per-developer override lives in the
gitignored ``.developer`` file (``workflow=<id>``, see
``paths.get_developer_workflow``) and outranks it. Fail-open: a missing or
non-string value simply means "no team default" — no warning (this is read
on every turn by hooks and must not add per-turn noise).
"""
config = _load_config(repo_root)
raw = config.get("default_workflow")
if not isinstance(raw, str):
return None
return raw.strip() or None
DEFAULT_CONTEXT_INJECTION_MAX_FILE_BYTES = 32768
DEFAULT_CONTEXT_INJECTION_MAX_ARTIFACT_BYTES = 65536
DEFAULT_CONTEXT_INJECTION_MAX_TOTAL_BYTES = 131072
+34 -2
View File
@@ -27,6 +27,8 @@ from .packages_context import (
get_context_packages_text,
get_context_packages_json,
)
from .paths import get_repo_root
from .spec_match import match_specs_for_file
from .trellis_config import read_trellis_config
from .workflow_phase import (
filter_platform,
@@ -57,9 +59,9 @@ def main() -> None:
parser.add_argument(
"--mode",
"-m",
choices=["default", "record", "packages", "phase"],
choices=["default", "record", "packages", "phase", "spec"],
default="default",
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction)",
help="Output mode: default (full context), record (for record-session), packages (package info only), phase (workflow step extraction), spec (specs governing a file)",
)
parser.add_argument(
"--step",
@@ -69,6 +71,10 @@ def main() -> None:
"--platform",
help="Platform name for --mode phase, e.g. cursor, claude-code. Filters platform-tagged blocks.",
)
parser.add_argument(
"--file",
help="File path (absolute or repo-relative) for --mode spec. Lists spec files whose frontmatter paths match it.",
)
args = parser.parse_args()
@@ -95,6 +101,32 @@ def main() -> None:
)
content = filter_platform(content, effective)
print(content, end="")
elif args.mode == "spec":
if not args.file:
parser.error("--file is required with --mode spec")
matches = match_specs_for_file(get_repo_root(), args.file)
if args.json:
print(
json.dumps(
{
"file": args.file,
"matches": [
{
"path": match.rel_path,
"description": match.description,
}
for match in matches
],
},
indent=2,
ensure_ascii=False,
)
)
elif matches:
for match in matches:
print(f"{match.rel_path} — {match.description or '(no description)'}")
else:
print(f"No spec files declare paths matching {args.file}.")
else:
if args.json:
output_json()
+28
View File
@@ -94,6 +94,34 @@ def get_developer(repo_root: Path | None = None) -> str | None:
return None
def get_developer_workflow(repo_root: Path | None = None) -> str | None:
"""Get the personal workflow override from the .developer file.
Reads an optional ``workflow=<id>`` line from the gitignored ``.developer``
file — the per-developer, git-excluded override that outranks the
team-shared ``default_workflow`` in config.yaml. Returns None when the file
or the line is absent/blank. Never raises. Additive to ``get_developer``:
the ``name=`` reader ignores this line and vice versa.
"""
if repo_root is None:
repo_root = get_repo_root()
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
if not dev_file.is_file():
return None
try:
content = dev_file.read_text(encoding="utf-8")
for line in content.splitlines():
if line.startswith("workflow="):
return line.split("=", 1)[1].strip() or None
except (OSError, IOError):
pass
return None
def check_developer(repo_root: Path | None = None) -> bool:
"""Check if developer is initialized.
+26 -8
View File
@@ -9,6 +9,7 @@ Provides:
get_context_text_record - Text for record mode
output_json - Print JSON
output_text - Print text
get_update_hint - Once-per-session "update available" line
"""
from __future__ import annotations
@@ -417,8 +418,16 @@ def _compare_versions(left: str, right: str) -> int | None:
return _compare_prerelease(left_prerelease, right_prerelease)
def _update_marker_path(repo_root: Path) -> Path:
context_key = resolve_context_key()
def _update_marker_path(repo_root: Path, context_key: str | None = None) -> Path:
"""Path of the once-per-session marker that throttles the update check.
`context_key` lets a caller that already resolved session identity pass it
in — the SessionStart hook reads the session id from hook stdin, which is
more reliable than this function's environment-only fallback chain. Shell
entry points leave it None and keep the previous behavior.
"""
if not context_key:
context_key = resolve_context_key()
if not context_key:
terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
context_key = terminal_key or f"ppid-{os.getppid()}"
@@ -433,8 +442,11 @@ def _update_marker_path(repo_root: Path) -> Path:
)
def _mark_update_check_attempted(repo_root: Path) -> bool:
marker_path = _update_marker_path(repo_root)
def _mark_update_check_attempted(
repo_root: Path,
context_key: str | None = None,
) -> bool:
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists():
return False
try:
@@ -445,8 +457,14 @@ def _mark_update_check_attempted(repo_root: Path) -> bool:
return True
def _get_update_hint(repo_root: Path) -> str | None:
marker_path = _update_marker_path(repo_root)
def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | None:
"""Return the "update available" line for this session, at most once.
Public because the SessionStart hook imports it: the text-mode CLI path
(`get_context.py`) used to be the only caller, so hook-driven platforms —
Claude Code included — never saw the reminder at all.
"""
marker_path = _update_marker_path(repo_root, context_key)
if marker_path.exists():
return None
@@ -458,7 +476,7 @@ def _get_update_hint(repo_root: Path) -> str | None:
if not latest_version:
return None
_mark_update_check_attempted(repo_root)
_mark_update_check_attempted(repo_root, context_key)
comparison = _compare_versions(current_version, latest_version)
if comparison is None or comparison >= 0:
return None
@@ -867,7 +885,7 @@ def output_text(repo_root: Path | None = None) -> None:
"""
if repo_root is None:
repo_root = get_repo_root()
update_hint = _get_update_hint(repo_root)
update_hint = get_update_hint(repo_root)
if update_hint:
print(update_hint)
print("")
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Decision logic for path-scoped spec injection (ticket-refresh model).
Pure logic only: the per-spec decision engine, block rendering and budgeted
payload assembly. Importing this module has no side effects; every piece of IO
orchestration (stdin, config, identity, state files, locking, GC) lives in the
platform hook that calls it. Unit tests import this module directly.
Clock
The periodic refresh window uses epoch seconds. Context resets are
explicit lifecycle events: the hook records an opaque reset identifier,
and a mismatch with the last emission re-teaches the spec in full.
Budget
All caps are in characters, because the platform's ``additionalContext``
ceiling is 10,000 *characters* (counting bytes made CJK specs pay 3x).
Truncation slices code points, which can never split a multi-byte
sequence. The per-event cap is enforced on the assembled payload string —
wrappers, ``\\n\\n`` separators, index block and tickets all counted — so
nothing is ever appended unchecked.
"""
from __future__ import annotations
import hashlib
import sys
from typing import Any, Sequence
from .spec_match import SpecMatch
# Bound on the size of a spec file we are willing to read and hash. A spec
# larger than this degrades to an index line (warned) — an inlined body that
# big could never fit the budget anyway, and the read+hash would be unbounded.
MAX_SPEC_SOURCE_BYTES = 10 * 1024 * 1024
# Upper bound on the room reserved for named index lines while FULL blocks are
# still being packed. Beyond it the reserve falls back to the summary line
# alone: a big fan-out must not starve the specs that can still be taught.
INDEX_RESERVE_MAX_CHARS = 900
# State record schema version. Records with any other version are ignored
# (safe direction: an ignored record re-injects rather than stays silent).
STATE_VERSION = 2
def _warn(message: str) -> None:
print(f"[WARN] spec_inject: {message}", file=sys.stderr)
# =============================================================================
# Clock
# =============================================================================
def within_window(
clock: dict[str, Any],
last: dict[str, Any],
win_seconds: int,
) -> bool:
"""True when the last emission is still inside the refresh window (→ stay
silent).
A window of ``0`` means never refresh (infinite window → always True).
Missing timestamps or a negative delta are past-window (False → refresh),
the safe side of the misfire asymmetry.
"""
cur_ts = clock.get("ts")
last_ts = last.get("ts")
if isinstance(cur_ts, (int, float)) and isinstance(last_ts, (int, float)):
if win_seconds == 0:
return True
delta = cur_ts - last_ts
return 0 <= delta < win_seconds
return False
def decide(
stateless: bool,
last: dict[str, Any] | None,
sha256_hex: str,
clock: dict[str, Any],
win_seconds: int,
) -> str:
"""Return one of ``"full"`` | ``"ticket"`` | ``"silent"`` for a spec.
Order is contractual: statelessness first (bounded cost, no state to
consult), then first sight, content change, context reset, the refresh
window, and finally completeness. A ticket says "you were shown this spec",
which is a lie when the recorded FULL was truncated, so an incomplete
record is re-taught in full instead.
"""
if stateless:
return "ticket"
if last is None:
return "full"
if last.get("sha256") != sha256_hex:
return "full"
if clock.get("reset") != last.get("reset"):
return "full"
if within_window(clock, last, win_seconds):
return "silent"
# "complete" is optional and absent means a whole body was shown.
if last.get("complete") is False:
return "full"
return "ticket"
# =============================================================================
# Rendering
# =============================================================================
def truncate_chars(text: str, cap: int) -> str:
"""Slice ``text`` to at most ``cap`` code points. ``cap <= 0`` = no limit."""
if cap <= 0 or len(text) <= cap:
return text
return text[:cap]
def truncation_notice(rel_path: str, cap: int) -> str:
return (
f"\n[Trellis: truncated at {cap} characters — "
f"read {rel_path} for the full content]"
)
def render_full(edited_rel: str, spec_rel: str, sha12: str, body: str) -> str:
return (
f'<spec-context file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-context>"
)
def render_ticket(
edited_rel: str,
spec_rel: str,
sha12: str,
stateless: bool,
) -> str:
"""Render a ticket block.
``stateless=True`` covers both the no-identity and circuit-breaker paths:
there is no record of a prior emission, so the wording must not claim one.
"""
if stateless:
body = (
"This spec governs the file you just touched. If you have not read it in\n"
f"this session, Read {spec_rel} before continuing."
)
else:
body = (
"You were shown this spec earlier in this session and its content is unchanged.\n"
"It still governs edits to matching files. If you no longer remember it, Read\n"
f"{spec_rel} before continuing."
)
return (
f'<spec-ticket file="{edited_rel}" spec="{spec_rel}" sha256="{sha12}">\n'
f"{body}\n"
f"</spec-ticket>"
)
def _index_block(lines: Sequence[str]) -> str:
return "<spec-index>\n" + "\n".join(lines) + "\n</spec-index>"
# =============================================================================
# State records
# =============================================================================
def make_record(
rel_path: str,
sha256_hex: str,
mode: str,
clock: dict[str, Any],
complete: bool = True,
) -> dict[str, Any]:
"""Build a state record. ``complete=False`` marks a FULL whose body was
truncated below the whole spec — an absent flag means whole."""
record: dict[str, Any] = {
"v": STATE_VERSION,
"spec": rel_path,
"sha256": sha256_hex,
"mode": mode,
"ts": clock.get("ts"),
}
if isinstance(clock.get("reset"), str):
record["reset"] = clock["reset"]
if not complete:
record["complete"] = False
return record
# =============================================================================
# Payload assembly
# =============================================================================
def _derive_fitting_full(
edited_rel: str,
spec_rel: str,
sha12: str,
text: str,
max_spec_chars: int,
fits,
) -> tuple[str, bool] | None:
"""Largest truncated FULL block that fits the remaining total budget.
Binary search over the body cap: the rendered block's length is monotone
non-decreasing in the cap, so the largest cap whose block still ``fits``
is found in ~log2(len(text)) renders (this also absorbs the digit-length
wobble of the notice text, which a closed-form estimate cannot).
The search ceiling is ``max_spec_chars`` when set and the whole body when
it is ``0`` (unlimited) — with a ceiling of 1, as an unguarded
``max(1, 0)`` would give, nothing but a one-character spec could ever be
derived. Returns ``(block, complete)`` — ``complete`` is True only when
the winning cap covered the whole body — or None when no non-empty prefix
fits (the caller degrades to an index line).
"""
def candidate_for(cap: int) -> str:
body = truncate_chars(text, cap)
if len(body) < len(text):
body += truncation_notice(spec_rel, cap)
return render_full(edited_rel, spec_rel, sha12, body)
ceiling = len(text) if max_spec_chars <= 0 else min(max_spec_chars, len(text))
lo, hi = 1, max(1, ceiling)
best: tuple[str, bool] | None = None
while lo <= hi:
mid = (lo + hi) // 2
candidate = candidate_for(mid)
if fits(candidate):
best = (candidate, mid >= len(text))
lo = mid + 1
else:
hi = mid - 1
return best
def _index_line(match: SpecMatch) -> str:
return f"- {match.rel_path} — {match.description or 'no description'}"
def assemble_payload(
edited_rel: str,
matches: Sequence[SpecMatch],
stateless: bool,
state_records: dict[str, dict[str, Any]],
clock: dict[str, Any],
max_spec_chars: int,
max_total_chars: int,
win_seconds: int,
match_files: dict[str, str] | None = None,
) -> tuple[str, list[dict[str, Any]]]:
"""Assemble the additionalContext payload from the matched specs.
Returns ``(payload, records)`` where ``records`` are the state lines to
append for the emissions that actually made it into the payload (silent
hits and budget-dropped emissions record nothing — they stay eligible).
Every candidate block is measured against the *assembled* payload string
(``"\\n\\n".join(...)``), so the per-event character ceiling holds for the
exact string that is emitted.
``match_files`` maps a governing spec to the first matching file in a
multi-file tool call. Single-file callers omit it and retain the original
``edited_rel`` behavior.
"""
blocks: list[str] = []
def file_for(match: SpecMatch) -> str:
return (match_files or {}).get(match.rel_path, edited_rel)
def fits(candidate: str, reserve: int = 0) -> bool:
"""Does ``candidate`` fit the per-event ceiling, keeping ``reserve``
characters free for what still has to be appended after it?"""
if max_total_chars <= 0:
return True
return len("\n\n".join([*blocks, candidate])) + reserve <= max_total_chars
# Reserve while candidates are still pending: the index lines those
# candidates would actually need (true strings, not estimates) plus the
# summary line — so a derived-cap FULL cannot eat the budget and starve
# the specs behind it (measured: 10-spec fan-out at max_total_chars 3000
# emitted one 3000-char FULL and dropped the other nine silently). The
# named part is only guaranteed within INDEX_RESERVE_MAX_CHARS; beyond
# that the reserve falls back to the summary line alone.
_all_index_lines = [_index_line(m) for m in matches]
_summary_upper = (
f"- (+{len(matches)} more governing specs over budget — run "
f"python3 ./.trellis/scripts/get_context.py --mode spec "
f"--file {edited_rel} to list them)"
)
_summary_reserve = len("\n\n" + _index_block([_summary_upper]))
def reserve_for(pending: Sequence[str]) -> int:
if not pending:
return 0 # Nothing can follow this block — nothing to reserve.
named = len("\n\n" + _index_block([*pending, _summary_upper]))
if named > INDEX_RESERVE_MAX_CHARS:
return _summary_reserve
return named
index_lines: list[str] = []
ticket_pending: list[tuple[str, str, str]] = [] # (file, spec, sha256)
records: list[dict[str, Any]] = []
for match_idx, match in enumerate(matches):
try:
size = match.spec_path.stat().st_size
except OSError:
size = 0
if size > MAX_SPEC_SOURCE_BYTES:
# Too big to read+hash, let alone inline: name it and move on.
_warn(
f"{match.rel_path} is {size} bytes (over "
f"{MAX_SPEC_SOURCE_BYTES}) — degraded to an index line"
)
index_lines.append(_index_line(match))
continue
try:
data = match.spec_path.read_bytes()
except OSError:
_warn(f"cannot read {match.rel_path} — skipped")
continue
sha256_hex = hashlib.sha256(data).hexdigest()
sha12 = sha256_hex[:12]
last = None if stateless else state_records.get(match.rel_path)
decision = decide(stateless, last, sha256_hex, clock, win_seconds)
if decision == "silent":
continue
if decision == "ticket":
# Deferred: tickets are counted against the budget last.
ticket_pending.append((file_for(match), match.rel_path, sha256_hex))
continue
pending = [*index_lines, *_all_index_lines[match_idx + 1 :]]
reserve = reserve_for(pending)
_fits = (lambda c: fits(c, reserve))
text = data.decode("utf-8", errors="replace")
body = truncate_chars(text, max_spec_chars)
complete = len(body) >= len(text)
if not complete:
body += truncation_notice(match.rel_path, max_spec_chars)
matching_file = file_for(match)
block = render_full(matching_file, match.rel_path, sha12, body)
if not _fits(block):
# Contract amendment 1: before degrading, truncate FURTHER to the
# largest body prefix that fits the remaining total budget
# (wrapper + notice counted). Without this, the frozen defaults
# made the truncation path unreachable (body cap + notice +
# wrapper > total cap) and long specs fell straight to an index
# line — the rejected index-only mode by another route.
derived = _derive_fitting_full(
matching_file,
match.rel_path,
sha12,
text,
max_spec_chars,
_fits,
)
if derived is not None:
derived_block, derived_complete = derived
blocks.append(derived_block)
records.append(
make_record(
match.rel_path, sha256_hex, "full", clock, derived_complete
)
)
continue
# No usable prefix fits — degrade to an index line, never drop
# silently. Not recorded: stays eligible for a later event.
index_lines.append(_index_line(match))
continue
blocks.append(block)
records.append(
make_record(match.rel_path, sha256_hex, "full", clock, complete)
)
if index_lines:
# The index block is budget-bounded too: lines that do not fit collapse
# into one summary line (count + how to list them via pull mode) so the
# ceiling is honored without silently dropping a governing spec.
chosen: list[str] = []
dropped = 0
for line in index_lines:
if fits(_index_block([*chosen, line])):
chosen.append(line)
else:
dropped += 1
if dropped:
# Contract amendment 3: the summary must actually be reachable.
# Greedy packing rarely leaves a summary-sized gap, so pop chosen
# lines (re-counting them as dropped) until the summary fits —
# only an absurdly small total budget can drop it entirely.
while True:
noun = "spec" if dropped == 1 else "specs"
summary = (
f"- (+{dropped} more governing {noun} over budget — run "
f"python3 ./.trellis/scripts/get_context.py --mode spec "
f"--file {edited_rel} to list them)"
)
if fits(_index_block([*chosen, summary])):
chosen.append(summary)
break
if not chosen:
_warn(
f"spec index summary for {edited_rel} dropped — "
f"per-event budget exhausted"
)
break
chosen.pop()
dropped += 1
if chosen:
blocks.append(_index_block(chosen))
for matching_file, spec_rel, sha256_hex in ticket_pending:
ticket = render_ticket(
matching_file, spec_rel, sha256_hex[:12], stateless
)
if not fits(ticket):
_warn(f"ticket for {spec_rel} dropped — per-event budget exhausted")
continue
blocks.append(ticket)
records.append(make_record(spec_rel, sha256_hex, "ticket", clock))
return "\n\n".join(blocks), records
+395
View File
@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
Path-scoped spec matching for on-demand spec injection.
Spec files under `.trellis/spec/**/*.md` MAY start with a YAML-like
frontmatter block declaring which repo paths they govern:
---
name: commands-workflow
description: workflow command conventions
paths:
- packages/cli/src/commands/workflow.ts
- packages/cli/src/utils/workflow-resolver.ts
---
The parser is hand-rolled (house pattern, modeled on
``trellis_config.parse_simple_yaml`` — no YAML dependency) and reads only a
bounded head of each file (16 KiB / 200 lines, whichever ends first). Only
files whose first line is exactly ``---`` are considered. ``name:`` /
``description:`` single-line strings are recognized (description is reused in
index lines). ``paths:`` accepts both a block list (``- <glob>`` items) and a
flow sequence (``paths: [a, b]``).
The parser is deliberately tolerant — a spec is prose that happens to carry a
routing hint, not a config file. Unknown keys, unrecognized line shapes and
stray ``- item`` lines are ignored; block scalars (``key: >`` / ``key: |``)
consume their more-indented continuation lines, so a SKILL.md-style
``description: >`` paragraph does not disqualify the file. An opening ``---``
with no recognized key before the closing marker is not frontmatter at all
(a Markdown horizontal rule opening the prose) and is ignored silently. Two
things are errors, and both warn + skip the whole file rather than route on a
half-read block: a malformed ``paths:`` (a scalar where a list belongs — that
key is the one thing the rest of the pipeline depends on), and a frontmatter
block that is still open when the head bound is reached.
Glob grammar (repo-relative, POSIX separators):
- ``*`` matches within a single path segment (never crosses ``/``)
- ``?`` matches exactly one character within a segment
- ``**`` as a whole segment matches zero or more segments
- a trailing ``/`` is sugar for ``/**``
- ``**`` embedded in a segment with other characters degrades to ``*``
Validation rejects only what is unsafe or meaningless: empty globs, a leading
``/`` (globs are repo-relative), ``..`` segments, backslashes (POSIX
separators only) and control characters. Everything else is legal — real
repositories carry ``@scope`` packages, ``[slug]`` routes, ``(marketing)``
groups and non-ASCII directories, and the translation escapes literals
character by character. An invalid glob is skipped with a stderr warning; the
rest of the file's globs still apply.
Translation examples (glob → matches / non-matches):
packages/cli/src/commands/update.ts
matches only that exact file
packages/cli/src/commands/*.ts
matches packages/cli/src/commands/update.ts
not packages/cli/src/commands/channel/spawn.ts
packages/cli/src/templates/**
matches packages/cli/src/templates/trellis/index.ts (any depth)
not packages/cli/src/templates (the directory itself)
packages/**/index.ts
matches packages/index.ts and packages/cli/src/index.ts
src/util?.py
matches src/utils.py, not src/util.py or src/utilXY.py
packages/cli/
same as packages/cli/**
Provides:
SpecMatch - frozen match record (spec_path, rel_path, description)
match_specs_for_file - map an edited file to the specs that govern it
normalize_repo_relative - the canonical repo-relative path normalization
parse_spec_frontmatter - parse the optional frontmatter head block
glob_to_regex - deterministic glob → compiled regex translation
"""
from __future__ import annotations
import re
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from .paths import DIR_SPEC, DIR_WORKFLOW
from .trellis_config import _strip_inline_comment, _unquote
# Bounded head-read limits for frontmatter scanning (design contract).
HEAD_MAX_BYTES = 16384
HEAD_MAX_LINES = 200
# Recognized frontmatter keys. An opening `---` block that declares none of
# them is prose under a horizontal rule, not frontmatter.
_KNOWN_KEYS = ("paths", "name", "description")
_GLOB_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(.*)$")
# YAML block-scalar introducers; the value lives in the indented lines below.
_BLOCK_SCALARS = ("|", ">", "|-", ">-", "|+", ">+")
# macOS and Windows filesystems are case-insensitive: the very same file can
# be handed to us in a case the glob author never wrote. Match case-insensitively
# there — over-injecting a spec is the safe side of the asymmetry.
_CASE_INSENSITIVE_FS = sys.platform == "darwin" or sys.platform.startswith("win")
_GLOB_FLAGS = re.IGNORECASE if _CASE_INSENSITIVE_FS else 0
@dataclass(frozen=True)
class SpecFrontmatter:
"""Parsed frontmatter head. ``paths`` is None when the key is absent."""
paths: tuple[str, ...] | None
name: str | None
description: str | None
@dataclass(frozen=True)
class SpecMatch:
spec_path: Path
"""Absolute path to the spec file."""
rel_path: str
"""Repo-relative POSIX path, for display."""
description: str | None
"""Frontmatter ``description:`` value, if declared."""
def _warn(message: str) -> None:
print(f"[WARN] spec_match: {message}", file=sys.stderr)
def _parse_flow_sequence(value: str) -> list[str]:
"""Split a YAML flow sequence body (``[a, b]``) into unquoted items.
Commas separate; each item is trimmed and unquoted. Empty items (a
trailing comma, ``[]``) collapse away.
"""
inner = value[1:-1]
items = (_unquote(part.strip()).strip() for part in inner.split(","))
return [item for item in items if item]
def _read_head(path: Path) -> str:
"""Read at most HEAD_MAX_BYTES from the file, decoded as UTF-8."""
with open(path, "rb") as f:
data = f.read(HEAD_MAX_BYTES)
return data.decode("utf-8", errors="replace")
def parse_spec_frontmatter(head_text: str) -> SpecFrontmatter | None:
"""Parse the optional frontmatter block from a spec file's head.
Returns None when the file has no frontmatter: either the first line is not
``---``, or the block declares no recognized key before its closing marker
(a horizontal rule opening a prose file — silent, not an error).
Raises ValueError on a malformed ``paths:`` key (a scalar where a list
belongs) and on a block that is still open when the head bound
(HEAD_MAX_LINES / HEAD_MAX_BYTES) is reached — routing on a half-read
frontmatter would be worse than skipping the file loudly. Every other line
shape is tolerated and ignored.
"""
lines = head_text.splitlines()[:HEAD_MAX_LINES]
if not lines:
return None
first = lines[0].lstrip("\ufeff") # tolerate a UTF-8 BOM
if first != "---":
return None
paths: list[str] | None = None
name: str | None = None
description: str | None = None
pending_key: str | None = None
block_indent: int | None = None
saw_known_key = False
closed = False
for line in lines[1:]:
stripped = line.strip()
indent = len(line) - len(line.lstrip())
if block_indent is not None:
# Inside a block scalar: everything more indented (and blank lines)
# is its value. A dedent ends the block; that line still counts.
if not stripped or indent > block_indent:
continue
block_indent = None
if stripped == "---":
closed = True
break
if not stripped or stripped.startswith("#"):
continue
if stripped == "-" or stripped.startswith("- "):
if pending_key == "paths" and paths is not None:
item = _unquote(_strip_inline_comment(stripped[1:].strip()).strip())
paths.append(item)
# List items outside `paths:` are tolerated and ignored.
continue
key_match = _KEY_RE.match(stripped)
if key_match is None:
continue # Unrecognized line shape — tolerated and ignored.
key = key_match.group(1)
saw_known_key = saw_known_key or key in _KNOWN_KEYS
raw_value = key_match.group(2).strip()
if raw_value in _BLOCK_SCALARS:
if key == "paths":
raise ValueError("'paths' must be a list of globs")
pending_key = None
block_indent = indent
continue
value = _unquote(_strip_inline_comment(raw_value).strip())
if value:
pending_key = None
if key == "paths":
if not (value.startswith("[") and value.endswith("]")):
raise ValueError("'paths' must be a list of globs")
paths = _parse_flow_sequence(value)
elif key == "name":
name = value
elif key == "description":
description = value
# Unknown scalar keys are tolerated and ignored.
else:
pending_key = key
if key == "paths":
paths = []
if not saw_known_key:
# An opening `---` with no recognized key is a horizontal rule, not a
# frontmatter block. Silent by design: prose files are not malformed.
return None
if not closed:
raise ValueError(
f"frontmatter block never closed within the head bound "
f"({HEAD_MAX_BYTES} bytes / {HEAD_MAX_LINES} lines)"
)
return SpecFrontmatter(
paths=tuple(paths) if paths is not None else None,
name=name,
description=description,
)
def validate_glob(glob: str) -> str | None:
"""Return an error message for an invalid glob, or None when valid.
Deny-list, not allow-list: only what is unsafe or meaningless is rejected
(see module docstring). Everything else — ``@scope``, ``[slug]``,
``(marketing)``, non-ASCII directory names — is a legal path in a real
repository and translates fine.
"""
if not glob:
return "empty glob"
if glob.startswith("/"):
return "absolute paths are not allowed (globs are repo-relative)"
if ".." in glob.split("/"):
return "'..' segments are not allowed"
if "\\" in glob:
return "backslashes are not allowed (globs use POSIX '/' separators)"
if _GLOB_CONTROL_RE.search(glob):
return "contains control characters"
return None
def glob_to_regex(glob: str) -> re.Pattern[str]:
"""Translate a validated glob to a compiled full-match regex.
Deterministic, segment-based translation (see module docstring for the
grammar and examples): ``**`` as a whole segment spans zero or more
segments; ``*`` becomes ``[^/]*``; ``?`` becomes ``[^/]``; everything
else is escaped literally. A trailing ``/`` is expanded to ``/**`` first.
On case-insensitive filesystems (macOS, Windows) the pattern compiles with
``re.IGNORECASE`` — see ``_CASE_INSENSITIVE_FS``.
"""
if glob.endswith("/"):
glob += "**"
segments = glob.split("/")
parts: list[str] = []
for i, seg in enumerate(segments):
is_last = i == len(segments) - 1
if seg == "**":
# Last: consume the rest of the path (at least the separator
# boundary is already emitted by the previous segment). Not last:
# zero or more whole segments including their separators.
parts.append(".*" if is_last else r"(?:[^/]+/)*")
continue
piece = "".join(
"[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
for ch in seg
)
parts.append(piece if is_last else piece + "/")
return re.compile("^" + "".join(parts) + "$", _GLOB_FLAGS)
def normalize_repo_relative(repo_root: Path, file_path: str | Path) -> str | None:
"""Canonical repo-relative POSIX path — the one normalization in the
pipeline, used both for matching and for display.
Root and file are fully resolved (``strict=False``, so a file that no
longer exists still normalizes): symlinked repo roots, macOS's
``/tmp`` → ``/private/tmp`` and ``..`` segments cannot make one file look
like two different paths. The result is NFC-normalized (macOS hands out
NFD filenames). Relative inputs are taken as repo-relative. Returns None
when the file resolves outside the repo.
"""
try:
root = Path(repo_root).resolve(strict=False)
candidate = Path(file_path)
if not candidate.is_absolute():
text = str(file_path).replace("\\", "/")
while text.startswith("./"):
text = text[2:]
if not text:
return None
candidate = root / text
rel = candidate.resolve(strict=False).relative_to(root).as_posix()
except (OSError, ValueError):
return None
return unicodedata.normalize("NFC", rel)
def match_specs_for_file(repo_root: Path, file_path: str | Path) -> list[SpecMatch]:
"""Return specs whose frontmatter ``paths:`` globs match file_path.
``file_path`` may be absolute or repo-relative. More specific matching
globs are returned first; ``rel_path`` is the deterministic tie-break.
Scans ``.trellis/spec/**/*.md`` with bounded head-reads only. Never raises;
unreadable or malformed spec files are skipped with a stderr warning.
"""
try:
repo_root = Path(repo_root).resolve()
spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC
if not spec_dir.is_dir():
return []
rel = normalize_repo_relative(repo_root, file_path)
if rel is None:
return []
matches: list[SpecMatch] = []
specificity: dict[str, tuple[int, int, int, int]] = {}
for spec_file in spec_dir.rglob("*.md"):
spec_rel = spec_file.relative_to(repo_root).as_posix()
try:
head = _read_head(spec_file)
except OSError as exc:
_warn(f"cannot read {spec_rel}: {exc}")
continue
try:
frontmatter = parse_spec_frontmatter(head)
except ValueError as exc:
_warn(f"malformed frontmatter in {spec_rel}: {exc}")
continue
if frontmatter is None or not frontmatter.paths:
continue
for glob in frontmatter.paths:
error = validate_glob(glob)
if error is not None:
_warn(f"invalid glob {glob!r} in {spec_rel}: {error}")
continue
if glob_to_regex(glob).match(rel):
scored_glob = glob + "**" if glob.endswith("/") else glob
wildcard_count = scored_glob.count("*") + scored_glob.count("?")
segments = scored_glob.split("/")
literal_segments = sum(
"*" not in segment and "?" not in segment
for segment in segments
)
specificity[spec_rel] = (
0 if wildcard_count == 0 else 1,
-literal_segments,
wildcard_count,
-(len(scored_glob) - wildcard_count),
)
matches.append(
SpecMatch(
spec_path=spec_file,
rel_path=spec_rel,
description=frontmatter.description,
)
)
break
# Payload assembly spends its budget in this order. Exact and narrowly
# scoped matches must therefore outrank broad tree globs; alphabetic
# order is only a deterministic tie-break.
matches.sort(key=lambda m: (*specificity[m.rel_path], m.rel_path))
return matches
except Exception as exc: # Never raise — callers are hooks/context tools.
_warn(f"spec scan failed: {exc}")
return []
+57 -4
View File
@@ -25,7 +25,7 @@ from .config import get_context_injection_limits
from .git import branch_exists_locally
from .io import read_json
from .log import Colors, colored
from .paths import FILE_TASK_JSON, get_repo_root
from .paths import DIR_ARCHIVE, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_repo_root
from .task_utils import resolve_task_dir
# Extensions that look like code rather than spec/research docs. Entries with
@@ -170,6 +170,59 @@ def _is_exempt_from_code_file_warning(file_path: str, task_rel: str) -> bool:
return False
def _resolve_context_entry_path(
file_path: str, repo_root: Path, task_dir: Path | None
) -> Path | None:
"""Resolve a JSONL entry, binding archived self-references to the archive copy.
Exact historical self-references are remapped only for archived tasks.
``None`` means the remapped path traversed or resolved outside that archive.
"""
repo_path = repo_root / file_path
if task_dir is None:
return repo_path
try:
task_parts = task_dir.resolve().relative_to(repo_root.resolve()).parts
except ValueError:
return repo_path
archive_prefix = (DIR_WORKFLOW, DIR_TASKS, DIR_ARCHIVE)
if len(task_parts) != 5 or task_parts[:3] != archive_prefix:
return repo_path
year_month = task_parts[3]
if (
len(year_month) != 7
or year_month[4] != "-"
or not year_month[:4].isdigit()
or not year_month[5:].isdigit()
):
return repo_path
historical_root = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_dir.name}"
posix_path = file_path.replace("\\", "/")
if posix_path == historical_root:
relative_parts: tuple[str, ...] = ()
elif posix_path.startswith(f"{historical_root}/"):
relative_path = posix_path[len(historical_root) + 1 :]
if relative_path.endswith("/"):
relative_path = relative_path[:-1]
relative_parts = tuple(relative_path.split("/")) if relative_path else ()
if any(part in ("", ".", "..") for part in relative_parts):
return None
else:
return repo_path
try:
archive_root = task_dir.resolve()
resolved_path = task_dir.joinpath(*relative_parts).resolve()
resolved_path.relative_to(archive_root)
except (OSError, RuntimeError, ValueError):
return None
return resolved_path
def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = None) -> int:
"""Validate a single JSONL file.
@@ -220,14 +273,14 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
continue
real_entries += 1
full_path = repo_root / file_path
full_path = _resolve_context_entry_path(file_path, repo_root, task_dir)
if entry_type == "directory":
if not full_path.is_dir():
if full_path is None or not full_path.is_dir():
print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
errors += 1
continue
if not full_path.is_file():
if full_path is None or not full_path.is_file():
print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
errors += 1
continue
+30
View File
@@ -56,6 +56,7 @@ from .task_utils import (
resolve_task_dir,
run_task_hooks,
)
from .workflow_selection import DIR_WORKFLOWS, WORKFLOW_ID_RE
# =============================================================================
@@ -254,6 +255,31 @@ def cmd_create(args: argparse.Namespace) -> int:
# Inferred: default_package → None (no task.json yet for create)
package = resolve_package(repo_root=repo_root)
# Validate --workflow (CLI source: fail-fast on invalid id; a missing
# library file only warns — it may be saved later via `trellis workflow --save`)
workflow_id: str | None = getattr(args, "workflow", None)
if workflow_id:
if not WORKFLOW_ID_RE.match(workflow_id):
print(
colored(
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
Colors.RED,
),
file=sys.stderr,
)
return 1
workflow_md = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
if not workflow_md.is_file():
print(
colored(
f"Warning: {DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md does not exist yet; "
"default workflow resolution is used until it is saved "
"(trellis workflow --save).",
Colors.YELLOW,
),
file=sys.stderr,
)
# Default assignee to current developer
assignee = args.assignee
if not assignee:
@@ -385,6 +411,10 @@ def cmd_create(args: argparse.Namespace) -> int:
"notes": "",
"meta": meta,
}
# Optional per-task workflow selection: key present only when opted in,
# so tasks without a selection keep today's task.json shape byte-for-byte.
if workflow_id:
task_data["workflow"] = workflow_id
write_json(task_json_path, task_data)
+3 -2
View File
@@ -22,11 +22,12 @@ from __future__ import annotations
import re
from .paths import DIR_WORKFLOW, get_repo_root
from . import workflow_selection
from .paths import get_repo_root
def _workflow_md_path():
return get_repo_root() / DIR_WORKFLOW / "workflow.md"
return workflow_selection.resolve_workflow_md(get_repo_root())
# Match a line that *is* a platform marker: "[A, B, C]" or "[/A, B, C]"
_MARKER_RE = re.compile(r"^\[(/?)([A-Za-z][^\[\]]*)\]\s*$")
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Per-task workflow selection.
Resolves which workflow markdown file consumers should read. A task may pin
a workflow variant by storing `"workflow": "<id>"` in its task.json; the
variant body lives at `.trellis/workflows/<id>.md` (user-managed library).
Resolution precedence (single source of truth for all consumers), highest
to lowest — each layer resolves an id to `.trellis/workflows/<id>.md` and
falls through when unset, invalid, or pointing at a missing file:
1. Per-task pin - active task's task.json `workflow` (session-bound,
explicit; a bad id or missing file warns once on stderr, then falls
through rather than aborting).
2. Personal - `.developer` `workflow=<id>` (gitignored, per-developer;
outranks the team default). Silent on miss.
3. Team default - config.yaml `default_workflow` (git-tracked, shared).
Silent on miss.
4. Global - `.trellis/workflow.md`.
With neither a per-task pin nor the personal/team keys set, this is identical
to reading the global `.trellis/workflow.md`. Never raises.
Provides:
workflow_md_for_task - Full precedence for an already-resolved task dir
resolve_workflow_md - Session-aware wrapper via the active task resolver
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from .paths import DIR_WORKFLOW, FILE_TASK_JSON
# Workflow variant library directory under .trellis/ (plural on purpose:
# `.trellis/workflow/` is reserved by the YAML-manifest migration).
DIR_WORKFLOWS = "workflows"
# Workflow ids must be plain slugs; anything else (path separators, dots)
# is rejected so a task.json value can never escape .trellis/workflows/.
WORKFLOW_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _global_workflow_md(repo_root: Path) -> Path:
return repo_root / DIR_WORKFLOW / "workflow.md"
def _library_variant(repo_root: Path, workflow_id: str | None) -> Path | None:
"""Map a workflow id to its library file if valid and present, else None.
Shared by every layer (per-task pin, personal, team). An id with path
separators/dots/blanks, or one whose `.trellis/workflows/<id>.md` file does
not exist, returns None so the caller falls through. Never raises.
"""
if not isinstance(workflow_id, str) or not workflow_id:
return None
if not WORKFLOW_ID_RE.match(workflow_id):
return None
variant = repo_root / DIR_WORKFLOW / DIR_WORKFLOWS / f"{workflow_id}.md"
return variant if variant.is_file() else None
def _developer_workflow_id(repo_root: Path) -> str | None:
"""Personal override id from the gitignored .developer file (fail-open)."""
try:
from .paths import get_developer_workflow
return get_developer_workflow(repo_root)
except Exception:
return None
def _config_default_id(repo_root: Path) -> str | None:
"""Team-shared default id from config.yaml `default_workflow` (fail-open)."""
try:
from .config import get_default_workflow
return get_default_workflow(repo_root)
except Exception:
return None
def _default_workflow_md(repo_root: Path) -> Path:
"""Resolve the non-per-task default: personal -> team -> global.
Personal (`.developer` `workflow=`) outranks the team-shared
(config.yaml `default_workflow`) layer; both fall through to the global
`.trellis/workflow.md` when unset, invalid, or naming a missing file. These
layers are silent on miss (they are defaults, not an explicit per-task
choice — a per-turn warning would be noise).
"""
for get_id in (_developer_workflow_id, _config_default_id):
variant = _library_variant(repo_root, get_id(repo_root))
if variant is not None:
return variant
return _global_workflow_md(repo_root)
def _task_pin_variant(repo_root: Path, task_dir: Path | None) -> Path | None:
"""Return the per-task pinned variant path, or None to fall through.
Emits a stderr warning on an invalid id or a missing variant file (an
explicit per-task choice that cannot be honored), then returns None so
resolution continues with the personal/team defaults. Never raises.
"""
if task_dir is None:
return None
try:
raw = json.loads((task_dir / FILE_TASK_JSON).read_text(encoding="utf-8"))
if not isinstance(raw, dict):
return None
workflow_id = raw.get("workflow")
if not isinstance(workflow_id, str) or not workflow_id:
return None
if not WORKFLOW_ID_RE.match(workflow_id):
print(
f"Warning: task '{task_dir.name}' has invalid workflow id "
f"{workflow_id!r}; using default workflow resolution",
file=sys.stderr,
)
return None
variant = _library_variant(repo_root, workflow_id)
if variant is not None:
return variant
print(
f"Warning: task '{task_dir.name}' selects workflow '{workflow_id}' but "
f"{DIR_WORKFLOW}/{DIR_WORKFLOWS}/{workflow_id}.md is missing; "
f"using default workflow resolution",
file=sys.stderr,
)
return None
except Exception:
return None
def workflow_md_for_task(repo_root: Path, task_dir: Path | None) -> Path:
"""Return the workflow.md path for an already-resolved task dir (or None).
Applies the full precedence documented in the module docstring:
per-task pin -> personal (.developer) -> team (config.yaml) -> global.
Never raises; any failure falls through toward the global workflow path.
"""
pin = _task_pin_variant(repo_root, task_dir)
if pin is not None:
return pin
return _default_workflow_md(repo_root)
def resolve_workflow_md(
repo_root: Path,
input_data: dict | None = None,
platform: str | None = None,
) -> Path:
"""Resolve the session-aware active task, then apply the resolution rule.
``input_data`` is the raw hook payload (session/conversation identity);
CLI callers may omit it — the active-task resolver then falls back to
environment context. Never raises; any failure resolves to the global
`.trellis/workflow.md`.
"""
try:
from .active_task import resolve_active_task, resolve_task_ref
active = resolve_active_task(repo_root, input_data, platform)
task_dir: Path | None = None
if active.task_path:
task_dir = resolve_task_ref(active.task_path, repo_root)
return workflow_md_for_task(repo_root, task_dir)
except Exception:
return _global_workflow_md(repo_root)
+82
View File
@@ -11,6 +11,7 @@ Usage:
python3 task.py start <dir> # Set active task
python3 task.py current [--source] [--json] # Show active task
python3 task.py finish # Clear active task
python3 task.py workflow <id>|--clear # Set/clear per-task workflow selection
python3 task.py set-branch <dir> <branch> # Set git branch
python3 task.py set-base-branch <dir> <branch> # Set PR target branch
python3 task.py set-scope <dir> <scope> # Set scope for PR title
@@ -47,6 +48,7 @@ from common.active_task import (
from common.io import read_json, write_json
from common.task_utils import resolve_task_dir, run_task_hooks
from common.tasks import iter_active_tasks, children_progress
from common.workflow_selection import WORKFLOW_ID_RE, workflow_md_for_task
# Import command handlers from split modules (also re-exports for plan.py compatibility)
from common.task_store import (
@@ -204,6 +206,72 @@ def cmd_current(args: argparse.Namespace) -> int:
return 1
# =============================================================================
# Command: workflow
# =============================================================================
def cmd_workflow(args: argparse.Namespace) -> int:
"""Set or clear the workflow selection on the current session's active task."""
repo_root = get_repo_root()
if args.clear and args.id:
print(colored("Error: pass either <id> or --clear, not both", Colors.RED))
return 1
if not args.clear and not args.id:
print(colored("Error: workflow id required (or --clear)", Colors.RED))
print("Usage: python3 task.py workflow <id> | --clear")
return 1
active = resolve_active_task(repo_root)
if not active.task_path:
print(colored("Error: No current task set", Colors.RED))
print("Hint: run task.py start <dir> first")
return 1
task_dir = repo_root / active.task_path
task_json_path = task_dir / FILE_TASK_JSON
if not task_json_path.is_file():
print(colored(f"Error: task.json not found at {task_dir}", Colors.RED))
return 1
data = read_json(task_json_path)
if not data:
print(colored(f"Error: failed to read {task_json_path}", Colors.RED))
return 1
if args.clear:
if data.pop("workflow", None) is None:
print(colored("No workflow selection set on this task", Colors.YELLOW))
else:
if not write_json(task_json_path, data):
print(colored("Error: failed to update task.json", Colors.RED))
return 1
print(colored("✓ Workflow selection cleared", Colors.GREEN))
else:
workflow_id = args.id
if not WORKFLOW_ID_RE.match(workflow_id):
print(colored(
f"Error: invalid workflow id '{workflow_id}' (allowed: letters, digits, '-', '_')",
Colors.RED,
))
return 1
data["workflow"] = workflow_id
if not write_json(task_json_path, data):
print(colored("Error: failed to update task.json", Colors.RED))
return 1
print(colored(f"✓ Workflow set to: {workflow_id}", Colors.GREEN))
# workflow_md_for_task warns on stderr itself when the selected variant
# file is missing (it can be saved later via `trellis workflow --save`).
effective = workflow_md_for_task(repo_root, task_dir)
try:
effective_display = effective.relative_to(repo_root).as_posix()
except ValueError:
effective_display = str(effective)
print(f"Effective workflow: {effective_display}")
return 0
# =============================================================================
# Command: list
# =============================================================================
@@ -382,12 +450,15 @@ Usage:
python3 task.py create <title> --package <pkg> Create task for a specific package
python3 task.py create <title> --parent <dir> Create task as child of parent
python3 task.py create <title> --no-start Create without making it active in this session
python3 task.py create <title> --workflow <id> Create task pinned to a workflow variant
python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
python3 task.py validate <dir> Validate jsonl files
python3 task.py list-context <dir> List jsonl entries
python3 task.py start <dir> Set active task
python3 task.py current [--source] Show active task
python3 task.py finish Clear active task
python3 task.py workflow <id> Select workflow variant for active task
python3 task.py workflow --clear Clear selection (use default resolution)
python3 task.py set-branch <dir> <branch> Set git branch
python3 task.py set-base-branch <dir> <branch> Set PR target branch
python3 task.py set-scope <dir> <scope> Set scope for PR title
@@ -490,6 +561,10 @@ def main() -> int:
action="store_true",
help="Create the task without making it active in this session",
)
p_create.add_argument(
"--workflow",
help="Workflow variant id for this task (.trellis/workflows/<id>.md)",
)
# add-context
p_add = subparsers.add_parser("add-context", help="Add context entry")
@@ -520,6 +595,12 @@ def main() -> int:
# finish
subparsers.add_parser("finish", help="Clear active task")
# workflow
p_workflow = subparsers.add_parser("workflow", help="Set/clear per-task workflow selection")
p_workflow.add_argument("id", nargs="?", help="Workflow id (.trellis/workflows/<id>.md)")
p_workflow.add_argument("--clear", action="store_true",
help="Remove the workflow selection (use default resolution)")
# set-branch
p_branch = subparsers.add_parser("set-branch", help="Set git branch")
p_branch.add_argument("dir", help="Task directory")
@@ -580,6 +661,7 @@ def main() -> int:
"start": cmd_start,
"current": cmd_current,
"finish": cmd_finish,
"workflow": cmd_workflow,
"set-branch": cmd_set_branch,
"set-base-branch": cmd_set_base_branch,
"set-scope": cmd_set_scope,
+2
View File
@@ -9,6 +9,7 @@
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
| [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 |
| [Tushare 当前上市股票范围](./tushare-listed-stock-universe.md) | 所有股票型功能统一只使用构建时 `stock_basic(list_status=L)` 母集 |
| [历史选股](./selection.md) | selection bounded context、目标交易日、qfq 读取和信号结果契约 |
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
@@ -17,6 +18,7 @@
## 开发前检查
- 先阅读 `docs/adr/0001-bounded-context-first-modular-monolith.md`,确认新业务是否有清晰的语言和所有权边界。
- 涉及 Tushare 个股数据时先阅读 `tushare-listed-stock-universe.md`,所有新功能都必须从当前 `L` 股票母集继续缩小范围,禁止重新引入 `D/P/G/UN`。
- 先阅读目标上下文的 `modules/<bounded_context>/README.md`(如已存在),再决定 domain、application、infrastructure、presentation 的位置。
- 变更 HTTP 字段时同时检查 `zhixing-server/tests/`、前端 feature API 类型以及 `docs/adr/0002-use-a-same-origin-browser-api.md`。
- 不要为了“未来可能需要”创建空的数据库、服务或日志层;当前仓库没有这些实现。
@@ -0,0 +1,103 @@
# Tushare 当前上市股票范围
## Scenario: 所有股票型功能统一使用当前 `L` 股票池
### 1. Scope / Trigger
- 触发:新增或修改任何通过 Tushare 获取个股基础资料、行情、资金流、板块成员、财务或估值数据的后端功能。
- 目标:所有功能统一以构建时 `stock_basic(list_status="L")` 返回的当前上市股票为证券母集,禁止为了历史回溯获取 `D/P/G/UN`。
- 历史语义:功能上线日视为最早业务历史日期;以后重跑旧日期仍使用重跑当时的当前 `L` 股票池,不保证还原目标日的退市证券。
- 边界:指数、基金、期货、宏观等非个股数据不适用本股票状态契约;若未来产品必须恢复历史时点证券生命周期,必须先显式修改本规格及对应任务设计,不能在单个 adapter 内局部绕过。
### 2. Signatures
所有直接读取股票基础档案的 Tushare adapter 必须显式传入 `list_status="L"`:
```python
client.query(
"stock_basic",
exchange="",
list_status="L",
fields="ts_code,symbol,name,market,exchange,list_status,list_date,delist_date",
)
```
应用层不得通过 adapter 隐式缓存推断股票范围;需要候选股票的端口必须显式接收已经排序、去重并与当前 `L` 股票池相交的代码集合,例如:
```python
def fetch_moneyflow_dc(
trade_date: date,
candidate_codes: Sequence[str],
) -> SourceResult[MoneyflowDcRow]: ...
```
### 3. Contracts
- `stock_basic` 请求必须显式设置 `list_status="L"`,不能依赖供应商默认值,也不能循环请求 `D/P/G/UN`。
- 当前股票母集至少以 `ts_code` 唯一;返回的非 `L` 记录不得进入业务目标集合。严格 source adapter 应将与请求分区不符的状态视为来源契约错误,已有宽松同步边界至少必须在领域过滤时排除。
- 股票型功能可以继续执行自身既有的市场边界,例如沪深 A 股、B 股、北交所、ST 或风险警示过滤;这些过滤只能缩小 `L` 母集,不能重新引入其他上市状态。
- `daily`、`moneyflow_dc`、`dc_member` 等不支持 `list_status` 的接口可以按其最有效的方式获取原始响应,但进入计算、排名、覆盖率、缺口补拉或持久化业务事实前,候选代码必须与当前 `L` 母集取交集。
- 为审计保存的全市场原始 snapshot 可以包含非 `L` 行;非 `L` 行不得进入规范化事实、策略计算或“应覆盖股票数”。
- 当前 `L` 股票池必须带有构建时来源快照或等价审计信息。重试若复用旧下游 snapshot,必须确认它仍覆盖本轮候选集合;候选扩大时应在同一次重试中刷新相应下游来源。
- 本契约不要求各 bounded context 共享数据库表、缓存或 Tushare client;共享的是证券范围语义,而不是运行时耦合。
### 4. Validation & Error Matrix
| 条件 | 必须行为 |
| --- | --- |
| `stock_basic` 请求未显式传 `list_status="L"` | 测试失败;不得发布该功能 |
| `L` 分区返回 `D/P/G/UN` | 严格 adapter 抛来源契约错误,或在既有宽松边界明确排除;非 `L` 不得进入业务集合 |
| 板块成员包含非当前 `L` 股票 | 保留原始成员审计,计算候选与当前 `L` 集合取交集 |
| 目标日期早于当前 `L` 股票的 `list_date` | 从该目标日候选集合排除 |
| 行情或资金流全市场响应包含非 `L` 股票 | 原始 snapshot 可保留,规范化事实和覆盖率忽略这些股票 |
| 缺失补拉收到不在请求候选集合内的代码 | 按来源契约错误 fail closed,禁止合并 |
| 重试时成员恢复导致当前候选集合扩大 | 检查旧下游 snapshot 覆盖;不足时同轮刷新,不能先发布一次可预见的 `partial` |
| 新需求要求历史退市股票或历史时点生命周期 | 先修改本规格并完成独立设计评审,禁止直接请求 `D/P/G/UN` |
### 5. Good/Base/Bad Cases
- Good:资金雷达只请求一次 `stock_basic(list_status="L")`,将有效板块成员与当前沪深 A 股交集传给资金流 source;全市场原始资金流即使含额外股票,也只补拉和计算交集内代码。
- Base:普通行情同步从 `L` 股票池再排除 ST、北交所或不属于目标市场的证券;这是允许的模块级缩小,不改变全局母集。
- Good:重试刷新成员后发现新增两个当前 `L` 候选,旧资金流 checkpoint 少两只,于是同一次重试只刷新资金流来源组并恢复成功。
- Bad:为了回填旧日期,将 `stock_basic` 改为循环获取 `L/D/P/G/UN`,或者直接把 `dc_member` 的全部代码作为资金流覆盖分母。
- Bad:看到全市场原始 snapshot 含非 `L` 股票便将它们写入策略事实,造成候选数量、覆盖率或排名口径漂移。
### 6. Tests Required
- Tushare adapter 测试必须断言 `stock_basic` 的调用参数包含且只包含 `list_status="L"`,并断言非 `L` 返回记录不会进入结果。
- 应用编排测试必须构造板块成员、未来上市记录和当前 `L` 记录,断言传给下游 source 的候选集合是稳定排序后的交集。
- 规范化或策略测试必须断言非 `L`、目标日尚未上市、B 股或模块已排除市场不会贡献金额、覆盖率或排名。
- 重试测试必须覆盖“成员刷新后候选扩大但旧下游 checkpoint 不完整”,断言同一次重试刷新必要来源组。
- 新增股票型 bounded context 时,至少有一个边界测试证明它没有请求或引入 `D/P/G/UN`。
### 7. Wrong vs Correct
#### Wrong
```python
# 禁止:为历史回填循环获取全部生命周期状态。
rows = tuple(
client.query("stock_basic", list_status=status)
for status in ("L", "D", "P", "G", "UN")
)
candidate_codes = tuple(member.stock_code for member in memberships)
```
#### Correct
```python
# 正确:当前 L 是唯一母集,模块规则只能继续缩小它。
listed = client.query("stock_basic", list_status="L")
listed_codes = {
row.ts_code
for row in listed
if row.list_status == "L" and is_module_eligible(row, target_trade_date)
}
candidate_codes = tuple(
sorted(
member.stock_code
for member in memberships
if member.stock_code in listed_codes
)
)
```
@@ -0,0 +1,8 @@
{"file":".trellis/spec/backend/selection.md","reason":"复核七个子信号、历史截断、批次状态和重跑语义未被评分改变。"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"复核新增评分响应与后端 HTTP 测试。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"复核评分失败隔离、去敏原因和选股失败语义。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行后端格式、lint、strict type-check 与全量测试。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"复核评分 TypeScript 契约无 any 或不安全断言。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、类型、测试和构建门禁。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查数据库到 UI 的评分字段与状态全链路一致。"}
{"file":".trellis/tasks/08-29-integrate-b1-scoring/research/scoring-analysis.md","reason":"核对实际代码权重、十案例、阈值、缓存风险和 parity 目标。"}
@@ -0,0 +1,162 @@
# 知行 B1 图形相似度评分集成设计
## 目标与边界
在不改变知行 B1 七个子信号公式、命中状态和稳定身份的前提下,为每只已命中的股票计算一次 0–100 完美图形相似度。评分使用原项目实际代码中的十个案例、25 日窗口、四维特征、权重、容忍参数和 60 分阈值,并修正为真正生效的 FastDTW 曲线对齐,在选股结果页展示匹配案例与分项。
本设计不包含图片生成、视觉模型、1–5 主观评分、`PASS/WATCH/FAIL`、自动交易、评分独立重跑、多评分器并存或跨策略通用评分平台。评分只属于 `selection` bounded context。
## 当前与目标数据流
当前执行链:
```text
POST selection run
-> load qfq histories in batches
-> evaluate zhixing_b1 masks
-> SelectionRunItem + category signals
-> PostgreSQL
-> GET results
-> stocks[].signals[]
```
目标执行链:
```text
POST selection run
-> load the ten versioned case windows once for this run
-> build an immutable in-memory case library
-> load candidate qfq histories in existing batches
-> evaluate zhixing_b1 masks
-> if selected: score the stock once against all cases
-> SelectionRunItem(score) + unchanged category signals
-> PostgreSQL
-> GET results with stocks[].score + stocks[].signals[]
```
评分在公式评估之后执行。`no_signal`、`insufficient_history`、`missing_target_bar` 和 `data_error` 不运行评分;评分异常只影响该股票的评分状态,不改变 `SelectionRunItem.status`、signals 或批次的选股成功状态。
## 领域模型与模块边界
在 `modules/selection/domain/` 增加纯领域评分模块,负责案例定义、特征提取、四维匹配和结果值对象。该模块只依赖 NumPy/Pandas 与显式注入的评分配置,不导入 FastAPI、PostgreSQL 或 infrastructure。
建议领域类型包括:
- `PatternCaseDefinition`:案例 ID、名称、规范化 `ts_code`、突破日和窗口长度。
- `PatternFeatures`:趋势、KDJ、量能和价格形态四组不可变特征。
- `PatternScoreBreakdown`:四个 0–100 有限分项。
- `PatternScore`:状态、原始总分、阈值、最佳案例、breakdown、版本和安全原因。
- `ZhixingB1PatternScorer`:对一个 `StockHistory` 与不可变案例库执行确定性评分。
评分状态与选股状态分离,使用 `not_executed`、`matched`、`below_threshold` 和 `failed`。`matched` 表示最高分大于等于 60;`below_threshold` 表示计算成功但原 pipeline 不会 enrichment;`failed` 表示评分实际执行但输入、案例库或算法失败。选股失败仍只使用已有 evaluation status。
应用层增加评分用例或端口,由 `RunZhixingB1` 注入。每次 run 开始时加载一次案例库,每批复用已有候选 `StockHistory`,只给 `selected` 股票评分。相同股票命中的多个 category 共享一个股票级评分,不重复计算。
infrastructure 负责从 PostgreSQL 读取十个案例在各自 `breakout_date` 之前的 qfq 行情。查询必须参数化、升序、严格 `< breakout_date`,每个案例取最后 25 条。生产运行不访问旧项目 CSV、旧缓存或 Tushare。
## 算法兼容契约
版本一使用固定标识 `zhixing_b1_pattern_fastdtw_v1`。以下任何变化都必须升级版本:案例集合或突破日、窗口长度、特征公式、权重、容忍参数、FastDTW 半径或距离函数、阈值或非有限值处理。
版本一保留原运行代码的事实值:
| 项目 | 契约 |
| --- | --- |
| 案例数 | 10,保持缺少 `case_005` 的既有定义 |
| 候选/案例窗口 | 25 个升序交易日;案例不包含突破日 |
| 分项 | `trend_structure`、`kdj_state`、`volume_pattern`、`price_shape` |
| 权重 | 0.10、0.20、0.25、0.45 |
| 总分 | `round(weighted_sum * 100, 2)` |
| 阈值 | 60.0,比较使用 `>=` |
| 曲线距离 | 真正生效的 FastDTW;一维曲线使用标量欧氏距离,显式 `radius=1` |
| 最佳案例 | 十个案例中总分最高者;稳定同分时按案例定义顺序 |
原文档中的 30% 趋势/25% 价格权重和 YAML 中未生效的动态权重不进入 v1。实现应把实际生效常量集中在版本化配置中,不能继续保留“配置看似可改但运行时忽略”的状态。
实施门禁已验证原代码的 `fastdtw(one_dimensional_curve, ..., dist=scipy.spatial.distance.euclidean)` 稳定抛出 `AxisError`,随后由 `_shape()` 回退 `_simple_dtw`。用户明确选择修正为真正生效的 FastDTW,因为允许局部时间对齐更符合评分要求。实现使用适配一维标量的欧氏距离并显式固定 `radius=1`,不依赖 SciPy 的向量函数;这会改变旧历史分数和阈值命中集合,因此必须使用新的 `zhixing_b1_pattern_fastdtw_v1` 版本,并以新 golden 锁定结果。
所有领域输出必须是有限数。对原 25 日窗口造成的非有限中间特征,通过 compatibility helper 复现旧 matcher 的最终比较结果,但不允许 `NaN`/`Infinity` 进入 dataclass、JSONB 或 HTTP。固定 fixture 必须覆盖该路径;没有证据证明兼容时,评分返回 `failed`,不伪造分数。
## 案例库构建与一致性
只迁移十条案例定义,不迁移原 `data/cache/b1_pattern_library_cache.json`。该缓存未被 Git 跟踪、没有失效协议且已与行情漂移,不能作为部署事实源。
每次 selection run 从 PostgreSQL 构建一次小型内存案例库,读取规模约为 250 行,避免跨 run 的磁盘缓存失效问题。十个案例必须全部成功、各有 25 条有效 qfq OHLCV,才将案例库标记为 ready;缺任一案例时本 run 的评分统一不可用,但选股照常执行。这个原子完整性检查是对旧项目“静默使用部分案例库”的有意收紧,避免同一个版本标识对应不同分母和结果。
案例特征使用数据库中当前最新修订的 qfq,符合现有历史分析语义。已落盘评分不会因后续 qfq 修订自动改变;用户显式重跑后允许得到基于最新修订数据的新分数。`market_sync_batch_id`、评分版本和案例定义共同提供解释上下文。
固定案例是评分模板,而不是目标交易日当时可知的市场事实。历史日期可能使用后来定义的案例,因此该分数解释为“使用 `zhixing_b1_pattern_fastdtw_v1` 模板对历史候选做相似度评价”,不能解释为无前视偏差的历史交易信号。
## 持久化设计
评分是每股一次的结果,存入 `selection_run_item`,不复制到 `selection_signal.details`。新增列建议为:
- `score_status VARCHAR(32) NOT NULL DEFAULT 'not_executed'`
- `score_value NUMERIC(5,2) NULL`
- `score_threshold NUMERIC(5,2) NULL`
- `score_version VARCHAR(64) NULL`
- `match_case_id VARCHAR(32) NULL`
- `match_case_name VARCHAR(128) NULL`
- `match_case_breakout_date DATE NULL`
- `match_breakdown JSONB NULL`
- `score_reason TEXT NULL`
约束保证总分和四个分项位于 0–100,`matched` 必须具备完整分数、案例、breakdown、阈值和版本;`below_threshold` 可以保留内部原始分数用于审计,但 HTTP 默认只表达“未达到 60”而不把它当作匹配结果;`failed` 不保存数值或案例,只保存去敏后的有限长度原因。旧 run 通过默认 `not_executed` 与空字段保持兼容。
增加 `(run_id, score_value DESC, ts_code)` 索引,为数据库级评分排序提供稳定分页。重跑仍删除旧 `selection_run` 并依赖级联清除 item/signal;不新增独立评分表,也不双写 signal details。
若未来需要同股多评分器、多个评分版本同时存在或评分独立重跑,再把 item 上的单份结果迁移到 `(run_id, ts_code, scorer, version)` 的独立表;当前需求不提前引入该复杂度。
## HTTP 与前端契约
`SelectionStockResponse` 增加可空的股票级 `score`:
```json
{
"status": "matched",
"value": 86.4,
"threshold": 60.0,
"version": "zhixing_b1_pattern_fastdtw_v1",
"case": {
"id": "case_001",
"name": "华纳药厂",
"breakout_date": "2025-05-12"
},
"breakdown": {
"trend_structure": 71.2,
"kdj_state": 83.0,
"volume_pattern": 88.0,
"price_shape": 90.1
},
"reason": null
}
```
旧 run、未执行评分或字段全空时返回 `score: null`。评分失败返回 `status: failed` 与安全原因,但现有 signals 仍完整显示;不得把评分失败放入顶层 `failures[]`,该列表继续只表示选股评估失败。
结果查询增加可选 `sort=code|score_desc|score_asc`,默认 `code` 保持当前行为。排序和分页必须在 PostgreSQL 完成,稳定次级键为 `ts_code`;前端不能只排序当前页。评分筛选、只导出高分代码和独立排名暂不纳入 MVP。
前端在每只股票卡片/行的股票级区域展示总分、最佳案例和四个分项,七个 signal 继续展示各自原有 details。`below_threshold` 显示“未匹配到 60 分以上案例”,`failed` 显示“评分暂不可用”,两者都不能遮挡选股信号。页面提供按评分升降序的可访问控件,并保留默认代码排序。
## 失败、性能与并发
评分复用已加载的候选历史,只额外读取一次十个案例窗口。复杂度约为 `命中股票数 × 10 × 25` 的特征比较,且只对 selected 股票执行;不得为每个 category 或每个候选单独查询案例数据。
案例库初始化失败是 run 级评分不可用,不是选股批次失败。单股评分异常只将该股 `score_status` 置为 `failed`,其他股票继续。边界日志只记录 run ID、股票代码、评分版本和异常类型,不输出数据库连接、凭据或原始异常对象。
现有 FastAPI 进程内 background task 仍是执行边界;本任务不引入队列。实现必须测量新增评分耗时并写入结构化 run 日志,确认没有显著放大现有批次时长或连接池使用。
## 发布与回滚
迁移为向后兼容的可空列与索引。增加 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 配置,默认启用;紧急情况下可关闭评分,选股链恢复原行为,新 run 的 score 为 `not_executed`。
发布顺序为先执行数据库 upgrade,再发布同时理解新列的后端,最后发布前端。旧前端会忽略新增 JSON 字段;新前端对 `score: null` 安全降级。回滚应用时保留新增列不会影响旧代码,只有确认不再需要已保存评分时才执行 destructive downgrade。
## 主要风险与控制
- 原项目没有数值 golden:先冻结最小旧数据 fixture 和期望值,再实现迁移。
- 原缓存漂移:不迁移缓存,每次 run 从 PostgreSQL 构建完整案例库。
- 25 日窗口与 114 日指标产生非有限中间值:兼容 helper + 有限值断言 + golden 覆盖。
- FastDTW 语义:使用标量欧氏距离与固定 `radius=1`,通过新 golden 锁定;不得把旧 `_simple_dtw` 期望值当作兼容目标,也不能在异常时静默退回另一种算法。
- 同股多 category:评分只存 item 并在股票级响应展示,signal 身份和详情不变。
- 历史模板前视解释:在 UI/文档中明确分数是当前版本模板相似度,不是历史收益承诺。
@@ -0,0 +1,12 @@
{"file":".trellis/spec/backend/index.md","reason":"后端规格入口与开发前检查。"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"保持 selection bounded context 的 domain/application/infrastructure/presentation 边界。"}
{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"评分开关必须通过 Settings 与 ZHIXING_ 配置注入。"}
{"file":".trellis/spec/backend/selection.md","reason":"保护 B1 目标交易日、qfq、七子信号、批次持久化和重跑契约。"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"新增 stocks[].score 时同步稳定 Pydantic 与同源 API 契约。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"评分失败需隔离并在边界安全表达,不能吞掉选股错误。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Python 3.12、Ruff、Pyright strict 与 pytest 实施要求。"}
{"file":".trellis/spec/frontend/index.md","reason":"前端 selection feature 与跨层字段变更入口。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"为评分响应定义严格 TypeScript 类型并同步 API 契约。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"在现有股票结果 UI 中以可访问方式展示评分状态和分项。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"评分字段贯穿后端、API、query、类型和页面测试。"}
{"file":".trellis/tasks/08-29-integrate-b1-scoring/research/scoring-analysis.md","reason":"原评分算法、案例资产、缓存风险与当前集成接缝的源码证据。"}
@@ -0,0 +1,105 @@
# 知行 B1 图形相似度评分实施计划
## 实施前门禁
- [ ] 用户明确批准本次最终规划摘要;批准前不运行 `task.py start`,不修改产品代码。
- [ ] 使用 `trellis-before-dev` 加载 backend、frontend 与跨层规格。
- [ ] 确认当前工作区只包含用户已有修改和本任务规划文件,记录不可覆盖的改动。
- [x] 在 Python 3.12 下点验原 FastDTW 调用:依赖可安装/导入,但一维曲线配合 SciPy 欧氏距离稳定抛出 `AxisError`,原实现实际回退 `_simple_dtw`。
- [x] 用户确认版本一采用真正生效的 FastDTW,接受与旧 `_simple_dtw` 分数不兼容;版本固定为 `zhixing_b1_pattern_fastdtw_v1`、标量欧氏距离、`radius=1`。
## 1. 冻结兼容基线
- [ ] 从原项目十个案例 CSV 中提取严格早于 breakout date 的最小 25 日窗口,并选取代表性的候选窗口,写入 `zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/`;不复制完整生产数据。
- [ ] 使用原项目实际特征、权重和容忍参数,以及修正后的 FastDTW 路径离线生成期望的案例特征、四个分项、最佳案例和总分 JSON;测试运行时不导入原项目。
- [ ] fixture 覆盖最高分大于等于 60、低于 60、同分稳定顺序、窗口不足、空案例、非有限中间特征和十案例完整性。
- [ ] 记录原实现中被保留的行为及有意收紧的行为:实际权重优先于过时文档;完整案例库失败时不使用部分库;持久化和 HTTP 禁止非有限值。
回滚点:如果无法生成稳定的有限期望值,停止实现并回到规划,不猜测算法结果。
## 2. 实现纯领域评分
- [ ] 在 `modules/selection/domain/` 增加版本化案例定义、评分值对象、特征提取器、经确认的 DTW matcher 与 `ZhixingB1PatternScorer`;公开类型写完整 docstring、参数、返回值、异常与设计原因。
- [ ] 迁移十个案例、25 日窗口、四维特征、`0.10/0.20/0.25/0.45` 权重、原容忍参数、60 分阈值和稳定 best-match 规则。
- [ ] 集中实现有限值兼容 helper,保证领域对象从不包含 `NaN` 或 `Infinity`。
- [ ] 增加领域单元/golden 测试,证明固定输入与旧实现期望一致且多次运行确定。
- [ ] 更新 `pyproject.toml` 与 `uv.lock`,只引入实际运行所需依赖。
验证:
```bash
cd zhixing-server
uv run pytest tests/unit/selection -q
uv run pyright
uv run ruff check .
```
## 3. 构建 PostgreSQL 案例库适配器
- [ ] 定义 selection application/domain 所需的 case history port,不让领域层依赖 psycopg。
- [ ] 在 selection infrastructure 中实现参数化批量查询:规范化 `ts_code`、`source_adj='qfq'`、严格 `< breakout_date`、升序、每案例最后 25 行。
- [ ] 每个 run 只读取和构建一次完整案例库;验证十个案例各有 25 条有效 OHLCV,禁止静默部分成功。
- [ ] 用 fake connection 测试 SQL 参数、日期边界、排序、代码映射、缺失案例和数据库错误转换;有测试库时补 PostgreSQL 集成测试。
回滚点:案例库 adapter 独立合入前不得改变现有 selection run 结果。
## 4. 接入选股应用编排
- [ ] 给 `RunZhixingB1` 注入 scorer/case-library loader;在 run 开始时准备库,在已有 evaluator 返回 `selected` 后复用对应 `StockHistory` 评分一次。
- [ ] 扩展 `SelectionRunItem` 承载股票级 score;保留 signals、`signal_count`、选股 status 和 reason 的原语义。
- [ ] 评分 `failed` 或 `below_threshold` 不进入现有失败计数,不改变 run 的 `success/partial_success/failed` 聚合。
- [ ] 单元测试 selected/no-signal/评估失败/案例库失败/单股评分失败/同股七 category 只评分一次/批次继续执行。
- [ ] 增加 feature flag,并通过 `Settings`、依赖注入和 Compose 环境变量统一配置;业务代码不直接读取环境。
## 5. 扩展数据库与仓储
- [ ] 新建 Alembic migration,为 `selection_run_item` 增加评分状态、数值、版本、案例、breakdown、原因及排序索引;同时更新声明式 schema。
- [ ] 增加数据库 check constraints,拒绝越界或不完整 matched 结果;旧行安全回填 `not_executed`。
- [ ] 更新 batch upsert、run loader、重跑级联和查询对象,保持 item 与 signal 同事务落盘。
- [ ] 增加 `code|score_desc|score_asc` 的白名单排序,数据库分页使用 `score_value` 与 `ts_code` 稳定排序;不得拼接用户原始 SQL。
- [ ] 仓储测试覆盖 round-trip breakdown、旧行空 score、排序分页、category 过滤仍返回全部 signals、重跑清理和 migration upgrade/downgrade SQL。
回滚点:迁移为 additive;应用回滚时保留列。执行 downgrade 前必须确认已保存评分允许删除。
## 6. 扩展 HTTP 与前端
- [ ] 后端增加具名 Pydantic score/case/breakdown 响应模型,在 `stocks[].score` 返回股票级结果;`failures[]` 继续只表示选股评估失败。
- [ ] HTTP 测试覆盖 matched、below-threshold、failed、旧 run `score: null`、多 category、三种排序和分页稳定性。
- [ ] 同步更新 `selection.types.ts`、API query 参数和 React Query key,保持同源 `/api/v1` 请求。
- [ ] 在 selection workbench 的股票级区域展示总分、案例、分项、低于阈值与评分失败状态;signals 原详情不变。
- [ ] 增加可访问的评分排序控件,默认仍为代码排序;测试用户可见文本、控件行为和分页请求参数。
## 7. 全量验证与发布检查
- [ ] 后端执行格式、lint、strict type-check、全量测试、migration offline SQL;设置 `ZHIXING_TEST_DATABASE_URL` 时执行 PostgreSQL 集成测试。
- [ ] 前端执行格式、lint、type-check、测试和 build。
- [ ] 根目录执行完整门禁,并记录实际结果,不能用计划命令冒充已验证。
- [ ] 用固定 run fixture 或本地测试库核对:选中股票与七个 signals 在开关前后完全一致,只有股票级评分字段新增。
- [ ] 核对一次 run 只读取一次案例库、每股只评分一次、没有按 category 重复计算;记录评分耗时和数据库查询数。
- [ ] 验证关闭 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 后旧流程仍成功、HTTP 安全返回空 score。
```bash
cd zhixing-server
uv run ruff format --check .
uv run ruff check .
uv run pyright
uv run pytest
uv run alembic upgrade head --sql
uv run alembic downgrade -1 --sql
cd ../zhixing-web
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm build
cd ..
./dev.sh check
./dev.sh test
```
## 交付与后续
- [ ] 交付时报告算法版本、案例完整性、数值 parity、测试结果、性能数据和是否运行真实 PostgreSQL 集成测试。
- [ ] 将“评分筛选/代码导出”“独立评分重跑”“多评分器/版本并存”“1–5 主观视觉评分”保留为独立后续需求,不在本任务顺带实现。
@@ -0,0 +1,50 @@
# 知行 B1 集成原项目评分
## Goal
在不改变知行 B1 选股语义的前提下,复用原项目的案例、特征、权重和阈值,并修正曲线距离为真正生效的 FastDTW,为每只 B1 命中股票提供可解释、可持久化、可验证的 0–100 最佳案例匹配结果。
## Background
当前系统已具备 `POST /api/v1/selection/runs`、后台批量评估、PostgreSQL 结果持久化、结果查询与前端轮询展示;策略固定为 `zhixing_b1`,按显式目标交易日读取 qfq OHLCV,并独立保留七种子信号(`.trellis/spec/backend/selection.md:12-49,85-152`,`docs/adr/0005-selection-formula-semantics-and-independent-subsignals.md:7-23`)。评分尚未接入。
用户已明确本任务只迁移 Python 可执行的 0–100 图形相似度评分,不迁移 prompt 中依赖图片和大模型的 1–5 主观视觉评分。
实施门禁发现原源码的一维 FastDTW 调用实际抛错并回退 `_simple_dtw`;用户进一步确认版本一直接修正为真正生效的 FastDTW,因为允许局部时间对齐更符合评分要求。新分数使用独立版本,不承诺兼容旧 `_simple_dtw` 历史结果。
原可执行评分在候选信号产生后运行,对候选最近 25 个交易日与十个固定案例比较趋势结构、KDJ、量能和价格形态,实际权重为 `0.10/0.20/0.25/0.45`,总分为加权和乘以 100;每股只保留最高分案例,达到 `60.0` 才 enrichment(`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/config.py:8-38`,`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/matcher.py:19-128`,`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/application/pipeline.py:168-220`)。原文档权重与运行代码不一致,YAML 动态权重也未真正注入,迁移以实际运行代码为兼容基线。
原案例行情与缓存均未被 Git 跟踪,缓存没有版本或失效校验且已与当前行情漂移;原项目也没有评分数值 golden 测试。完整证据记录在 `research/scoring-analysis.md`。
## Requirements
- R1:评分必须是 B1 命中后的 enrichment,不参与七个 mask 的判断,不改变股票是否选中、同股多 category、signals 顺序或 `(ts_code, target_trade_date, strategy, category)` 稳定身份。
- R2:版本一固定使用原运行代码的十个案例、25 日升序窗口、四维特征、`0.10/0.20/0.25/0.45` 权重、容忍参数、最佳案例规则和 `>= 60.0` 阈值;曲线距离使用真正生效、显式半径的 FastDTW,版本标识为 `zhixing_b1_pattern_fastdtw_v1`。算法、案例、FastDTW 半径或阈值变化必须升级评分版本。
- R3:案例定义属于代码中的版本化业务规则;案例特征在每次 run 中从 PostgreSQL 最新 qfq 行情完整构建,严格使用突破日前最后 25 个交易日,不依赖旧项目、本地 CSV、Tushare 或旧磁盘缓存。
- R4:每个 run 只加载一次完整案例库,每只 `selected` 股票只评分一次;同股七个 category 共享股票级评分,不能复制成 category 级规则。
- R5:评分结果存入 `selection_run_item`,与选股 evaluation status 分离;结果包含状态、有限的 0–100 总分、60 分阈值、评分版本、最佳案例、四个有限分项和安全原因。旧 run 保持可读。
- R6:案例库缺失、单股评分异常、低于阈值或关闭评分都不得使选股失败,也不得进入现有选股失败计数;状态必须能区分 `not_executed`、`matched`、`below_threshold` 和 `failed`。
- R7:HTTP 在 `stocks[].score` 返回可空的股票级评分,现有 `stocks[].signals[]` 与 `failures[]` 语义不变;后端支持稳定的代码、评分升序和评分降序数据库分页。
- R8:前端在股票级区域展示匹配分数、案例、四个分项以及低于阈值/评分失败状态,并提供评分排序;任何评分状态都不能遮挡已命中的 signals。
- R9:所有持久化和 HTTP 数值必须有限;原 25 日窗口产生的非有限中间特征必须通过离线兼容 fixture 锁定最终行为,不能把 `NaN` 或 `Infinity` 写入数据库或响应。
- R10:提供 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 运行开关;关闭后选股链维持原行为,新结果不产生评分。
## Acceptance Criteria
- [ ] 离线 fixture 不依赖原项目或网络,数值 golden 覆盖十案例最佳匹配、四分项、总分、阈值边界、稳定同分、窗口不足和非有限中间值,并锁定修正后 FastDTW 版本一的确定结果。
- [ ] 对同一固定 B1 run,开启和关闭评分得到完全相同的选中股票、七个 category、signal details 和选股批次状态,差异只在股票级评分字段。
- [ ] 一只同时命中多个 category 的股票只调用一次 scorer,只保存和返回一个 `stocks[].score`,全部 signals 仍按既有顺序返回。
- [ ] 十个案例均存在时,最高分 `>= 60` 的股票返回完整 matched score、案例、版本和四个分项;低于 60 时返回明确的 below-threshold 状态而不伪装成匹配。
- [ ] 任一案例缺失或单股评分抛错时,选股继续并保留 signals;评分返回 failed/不可用状态,现有 `failed_count` 与 `failures[]` 不增加。
- [ ] 旧 run 和关闭评分产生的 run 可由新后端与前端安全读取,`score` 为空或 not-executed,不影响原页面功能。
- [ ] `code`、`score_desc` 和 `score_asc` 排序在 PostgreSQL 分页前执行,并以 `ts_code` 作为稳定次级键;前端不会只重排当前页。
- [ ] 一次 run 只读取一次案例库且不按股票/category 重复查询;验证记录包含评分耗时和查询/调用次数。
- [ ] Alembic upgrade/downgrade SQL、后端 Ruff/Pyright/pytest、前端 format/lint/typecheck/test/build 和根目录门禁全部通过;未配置 PostgreSQL 测试库时明确报告跳过项。
## Out of Scope
- prompt 中的 1–5 主观视觉评分、图片生成、视觉模型调用和 `PASS/WATCH/FAIL`。
- 改写知行 B1 公式、合并七个子信号、让评分反向决定是否入选或用于自动交易。
- 评分独立重跑、同股多评分器或多版本并存、通用评分平台。
- 评分阈值筛选、只导出高分股票代码和独立排名页面;MVP 只提供结果展示与排序。
- 兼容原项目实际回退的 `_simple_dtw` 历史分数;FastDTW v1 是用户明确选择的新评分版本。
@@ -0,0 +1,52 @@
# 原项目 B1 图形相似度评分调研
## 结论
本任务迁移的是原项目 Python 已执行并持久化的 0–100 B1 完美图形相似度评分,不包含 `prompt/b1.md` 定义的 1–5 主观视觉评分。相似度评分属于 B1 命中后的 enrichment,不参与七个子信号的命中判断。
原执行链为 `SelectionPipeline._enrich_with_pattern_match()` 调用 `B1PatternLibrary.find_b1_best_match()`,对每只候选股票计算一次结果,再把同一结果写入该股票的信号详情。关键源码位于:
- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/application/pipeline.py:168-220`
- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/library.py:22-101`
- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/feature_extractor.py:22-154`
- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/matcher.py:19-128`
- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/config.py:8-38`
## 算法事实
候选与案例都取最近 25 个升序交易日,提取四组特征:趋势结构、KDJ 状态、量能形态和价格形态。四组实际代码权重分别为 `0.10`、`0.20`、`0.25` 和 `0.45`,总分为分项相似度加权和乘以 100,保留两位小数。文档中 `0.30/0.20/0.25/0.25` 的权重与当前运行代码不一致,不能作为迁移基线。
价格曲线源码先尝试 `fastdtw` 和 SciPy 欧氏距离,异常时回退 `_simple_dtw`;原项目把 `fastdtw>=0.3.4` 与 `scipy>=1.10.0` 声明为正式依赖。实施门禁在 Python 3.12 上用相同的一维数组调用点验,`scipy.spatial.distance.euclidean` 接收到标量后稳定抛出 `AxisError: axis -1 is out of bounds for array of dimension 0`,因此原 `_shape()` 实际捕获异常并使用 `_simple_dtw`。这说明旧项目落地运行结果的曲线分数来自 simple-DTW fallback,而不是 FastDTW 成功路径。匹配十个固定案例后只保留最高分案例,最高分达到 `60.0` 才向外提供 `similarity_score`、`match_case` 和四个分项。
案例窗口严格使用 `breakout_date` 之前的数据,不包含突破日。十个案例为 `688799.SH`、`600366.SH`、`688321.SH`、`600601.SH`、`002074.SZ`、`605378.SH`、`600184.SH`、`301076.SZ`、`002940.SZ` 和 `000547.SZ`;原编号缺少 `case_005`,迁移时保持既有十条定义,不自行补案例。
## 案例资产与兼容风险
原项目 `data/raw/` 行情和 `data/cache/b1_pattern_library_cache.json` 都被 `.gitignore` 排除,不属于可部署资产。缓存没有算法版本、案例定义哈希、行情修订或完整性校验;本机缓存与当前 CSV 重算结果已有八个案例发生差异。因此新系统不能复制该缓存作为事实源,应迁移案例定义并从 PostgreSQL 最新 qfq 行情构建案例特征。
原特征提取器先截取 25 行,再计算最长 114 日均线,导致部分趋势字段为非有限值。迁移必须通过固定 fixture 锁定原 matcher 对这些中间值的最终有限分数行为,禁止把 `NaN` 写入 PostgreSQL 或 HTTP。若无法得到有限、确定的结果,应将评分标记为失败,但不得改变选股结果。
原项目没有案例特征、窗口截断或评分数值 golden 测试,只测试了字段透传与排序。新系统必须把从旧 CSV 提取的最小窗口和离线期望结果纳入测试 fixture;测试运行时不得依赖原项目、本机缓存、Tushare 或生产数据库。
## FastDTW 决策
用户确认版本一不兼容旧 `_simple_dtw` fallback,而是直接修正为真正生效的 FastDTW,因为允许局部时间轴对齐更符合业务期望。新版本使用一维标量欧氏距离、显式 `radius=1` 和版本标识 `zhixing_b1_pattern_fastdtw_v1`;不得在 FastDTW 异常时静默切回 simple-DTW。原项目的十案例、特征、权重、容忍参数和 60 分阈值继续复用,数值 golden 以修正后的新算法为准。
## 当前系统接缝
当前 B1 执行链为 HTTP 创建 run、批量读取 `StockHistory`、并发评估、写入 `selection_run_item` 与 `selection_signal`、查询并按股票聚合到 `stocks[].signals[]`。评分是每股一次的结果,最合适的持久化位置是 `selection_run_item`,而不是每条 `selection_signal.details`。
关键依据:
- `.trellis/spec/backend/selection.md`
- `zhixing-server/src/zhixing_server/modules/selection/application/run.py:91-159`
- `zhixing-server/src/zhixing_server/modules/selection/domain/runs.py:19-58`
- `zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py:184-229,355-454`
- `zhixing-server/src/zhixing_server/modules/selection/presentation/http.py:97-137,273-323`
- `zhixing-web/src/features/selection/api/selection.types.ts:36-83`
当前结果以股票为分页实体,一股可以拥有多个 category。把 score 复制到 signal details 会造成重复与 category 语义混淆,也不利于数据库级排序。为 `selection_run_item` 增加可空、版本化的评分列能复用现有主键、批量 upsert、重跑级联与股票聚合读取。
## 已验证基线
调研阶段后端全量测试基线为 `83 passed, 2 skipped`,两个跳过项需要 `ZHIXING_TEST_DATABASE_URL`;B1 相关单元、golden 与 HTTP 测试为 `42 passed`。原项目运行点验因环境导入名不匹配失败,过程中临时产生的 `.venv` 与 `uv.lock` 已移至系统废纸篓,没有保留对原项目的改动。
@@ -0,0 +1,26 @@
{
"id": "integrate-b1-scoring",
"name": "integrate-b1-scoring",
"title": "知行 B1 集成原项目评分",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-29",
"completedAt": "2026-08-31",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,7 @@
{"file":".trellis/spec/backend/index.md","reason":"核验后端模块边界与开发规范"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"核验共享 coordinator 未改变 market-data 语义"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"核验所有业务候选与当前 L 股票母集相交"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行完整后端质量门禁"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"核验共享能力没有越界或重复实现"}
{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md","reason":"核验 source group、候选集与 retry/checkpoint 契约"}
{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md","reason":"核验两路 worker、共享启动间隔及确定性测试覆盖"}
@@ -0,0 +1,29 @@
# 设计:当前上市股票池与 `moneyflow_dc` 缺口补拉
## 边界与契约
应用层在完成板块成员与 `stock_basic(L)` 采集后,先规范化成员并计算“有效成员代码与当前 L 股票代码的交集”,再调用扩展后的 `SectorRadarSource.fetch_moneyflow_dc(trade_date, candidate_codes)`。候选集合通过端口显式传递;adapter 不缓存先前 `fetch_stock_basics` 的响应,因此 publication replay 和 retry 仍是无隐式状态的。
`stock_basic` source 由五分区请求收敛为单一 `L` 分区。领域层继续用现有代码、市场和 `list_date` 规则处理当前上市候选,不新增 ST 过滤或历史退市语义。
## 资金流数据流
adapter 首先保存按 `trade_date` 获取的全市场 snapshot。它对 rows 执行 typed parsing、目标日期和 `ts_code` 唯一性校验;初始 snapshot 达到 6000 行只表示全市场可能截断,不再单独构成失败。随后计算 `candidate_codes - returned_codes`。
缺失集合为空时返回首批 snapshot 与 rows。存在缺失时,按排序后的 `ts_code` 使用固定两路 executor 请求 `moneyflow_dc(trade_date=..., ts_code=...)`。每个成功分片形成独立、带 `partition_key=ts_code` 的 snapshot;分片只允许为空或返回所请求股票在目标日期的唯一记录。空分片以及重试耗尽的普通 provider 异常不产生伪造 snapshot/row,并保留为覆盖缺口;来源 schema、日期、代码、唯一键或 row-limit 契约错误立即上浮。
主线程按输入代码顺序汇总 future,保证 `source_order=0` 始终是全市场 snapshot,后续分片按 `ts_code` 稳定排列。合并 rows 后再次验证 `(trade_date, ts_code)` 唯一,防止首批与分片重叠。所有 snapshot 继续归入 `PublicationSourceGroup.MONEYFLOW_DC`,现有数据库模型无需迁移。
## 并发与限流
共享 `RequestCoordinator` 增加默认值为 0 的 `request_interval_seconds` 和受现有 `threading.Condition` 保护的下次启动时刻。每次 attempt 在调用 provider 前原子等待 cooldown 并预约请求启动槽,预约完成后释放锁,再执行真实请求。资金雷达默认 coordinator 接收现有的 0.2 秒配置,移除 adapter 请求完成后的独立 sleep;因此两个 worker可重叠网络等待,但同一 adapter 中任意两次请求的启动时间仍至少相隔 0.2 秒。
当前锁定的 Tushare 1.4.29 `DataApi.query` 只读取 client 的 token、URL 和 timeout,在局部变量中构造参数并调用模块级 `requests.post`,未维护单次请求可变状态。两路 worker 共享该 client 的风险可接受,并由并发单元测试约束;该结论不扩展为 Tushare SDK 的通用线程安全保证。
## 兼容性、失败与回滚
`RequestCoordinator` 的新参数默认关闭,market-data bounded context 行为不变。端口签名变化同步更新 fake source 和 CLI/build 测试。普通补拉调用在 coordinator 的有限 retry 后仍失败时记录安全日志并留下覆盖缺口;契约错误保持 hard failure。日志不得包含 token 或完整 payload。
publication retry 只有在已保存的 `MONEYFLOW_DC` rows 仍覆盖本轮候选代码时才重放该来源组。若 `MEMBERS` 刷新后候选集合扩大,旧资金流 checkpoint 不足以覆盖新增候选,则在同一次 retry 中刷新 `MONEYFLOW_DC`,避免先发布一次可预见的 partial 再要求第二次重试。
回滚只需恢复 adapter 的单次 `moneyflow_dc` 请求、旧端口签名和协调器调用方式,不涉及 schema 或数据迁移。已生成的分片 snapshots 使用现有通用存储格式,旧版本即使不能主动生成,也仍可按 source group replay。
@@ -0,0 +1,9 @@
{"file":".trellis/spec/backend/index.md","reason":"后端模块边界、开发前检查和质量入口"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"共享协调器与 sector_radar bounded context 的所有权边界"}
{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"复用现有请求间隔配置并避免新增环境读取"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"现有 Tushare coordinator、并发与 checkpoint 相邻契约"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"所有股票型功能只使用构建时当前 L 股票母集"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"后端 Ruff、Pyright 和 pytest 门禁"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"评估 RequestCoordinator 共享原语扩展"}
{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/radar-build-tushare-call-chain.md","reason":"资金雷达调用顺序、候选集与 checkpoint 证据"}
{"file":".trellis/tasks/08-31-sector-radar-listed-moneyflow-recovery/research/tushare-global-start-interval.md","reason":"两路 worker 与全局启动间隔的适配点和测试模式"}
@@ -0,0 +1,25 @@
# 实施计划
1. 创建 `codex/sector-radar-listed-moneyflow-recovery` 分支并读取目标 backend/shared、sector radar 代码及相关规格。
2. 扩展 `RequestCoordinator`,实现线程安全的共享请求启动间隔,保留默认关闭与现有 cooldown/retry 行为;补充确定性单元测试。
3. 将 `stock_basic` 收敛为单一 `L` 分区,并调整 source 测试与返回状态契约。
4. 前移候选股票形成步骤,扩展 `SectorRadarSource.fetch_moneyflow_dc` 端口,向 adapter 显式传递稳定的当前 L 候选代码。
5. 在 Tushare adapter 内实现全市场首拉、候选覆盖检查、两路缺失代码补拉、分片契约校验、稳定 snapshot/row 汇总和安全错误日志。
6. 更新 FakeRadarSource、build/retry 测试和 CLI 组合测试,覆盖成功、6000 行、空分片、瞬时失败、错误日期/代码、重复键、分片触顶、两路 worker 与 checkpoint 重试。
7. 更新必要的运维说明,明确当前 L 股票池、候选覆盖语义、同一 adapter 的 0.2 秒共享间隔以及不同定时任务不得重叠。
8. 依次运行定向 pytest、Ruff format/lint、Pyright、完整 pytest,并由独立 Trellis check 代理核验规格和实现;修复所有本任务引入的问题后提交本地分支。
## 风险点与回滚检查
- `RequestCoordinator` 是共享模块,必须证明默认参数不改变 market-data 并发。
- worker 完成顺序不能进入 publication source order 或 input hash。
- 不能把普通 provider 异常与来源契约错误混为一类,也不能用空行伪造成功分片。
- 端口签名变化必须同步所有 fake/replay 路径,完整测试前不得仅凭 source 单测判定完成。
- 无数据库迁移;若验证失败,可按步骤分别回滚协调器启动槽和资金流分片逻辑。
## 验证结果
- `uv lock --check`、Ruff format/check、Pyright strict 全部通过。
- 后端完整测试 `159 passed, 3 skipped`;跳过项均要求显式设置 `ZHIXING_TEST_DATABASE_URL`。
- 根目录 `./dev.sh check` 与 `./dev.sh test` 通过;前端 `64 passed`。
- 未执行真实 Tushare 账号并发调用与真实 PostgreSQL 集成测试,留待部署后的 capability/生产批次验证。
@@ -0,0 +1,40 @@
# 资金雷达当前上市股票池与资金流缺口补拉
## Goal
让板块资金雷达以“构建时当前上市股票”为唯一证券范围,并在 Tushare `moneyflow_dc` 单日响应触及 6000 行上限时,仍能安全验证和补齐雷达候选股票,而不是直接失败或接受可能截断的数据。
## Background
- 生产构建目标日 `2026-08-28` 已在 `moneyflow_dc` 来源组因响应达到供应商 6000 行上限而失败。
- Tushare 官方接口说明确认 `moneyflow_dc` 单次最多返回 6000 条,并支持按日期或股票代码循环提取。
- 用户明确不要求历史时点证券生命周期还原;功能上线日视为最早历史日期,当前及未来均只研究构建时 `stock_basic(list_status=L)` 返回的股票。
- Tushare 账号频率限制为 500 次/分钟;用户批准资金流缺口补拉使用 2 个 worker,但两个 worker必须共享同一请求启动限流器。
## Requirements
1. `stock_basic` 只请求 `list_status=L`,不再请求 `D/P/G/UN`。保留资金雷达既有的沪深 A 股、B 股/北交所排除和上市日期校验,不额外引入 `market-data-sync` 的 ST 过滤语义。
2. 资金流完整性只针对有效板块成员与当前 `L` 股票的交集。应用层必须在请求 `moneyflow_dc` 前形成稳定、去重的候选代码集合,并通过显式端口参数传给 source adapter,禁止依赖 adapter 内部调用顺序或缓存状态。
3. `moneyflow_dc` 首次仍按目标交易日请求全市场。首次响应即使达到 6000 行,也必须先校验目标日期和业务唯一键,再检查候选股票覆盖率,不能直接接受或直接报截断。
4. 首次响应缺少候选股票时,只按稳定排序后的缺失 `ts_code` 补拉。每个分片必须同时传入 `trade_date` 和 `ts_code`,并校验返回日期、返回代码、唯一键以及供应商是否忽略了分片参数。
5. 缺口补拉固定使用 2 个 worker。同一 adapter 的所有首次请求、补拉请求及 retry 共享请求启动间隔,默认相邻请求启动至少间隔 0.2 秒;普通 provider 调用允许重叠,不得把整个请求放在协调器锁内。
6. 空分片或重试耗尽的瞬时请求失败保留为真实缺口,不补零;构建继续走现有覆盖率逻辑并可发布 `partial`。日期错误、返回错误股票代码、重复业务键或分片再次触及供应商上限属于来源契约错误,必须 fail closed。
7. 全市场首批 snapshot 与每个成功分片 snapshot 都属于现有 `MONEYFLOW_DC` source group,并按确定性顺序保存。重试继续复用已完成来源组,只刷新资金流来源组,不新增数据库表或 publication group。
8. `RequestCoordinator` 的请求启动间隔默认关闭,只有资金雷达通过现有 `sector_radar_request_interval_seconds` 启用,不能改变 `market-data-sync` 当前八路并发语义。
## Acceptance Criteria
- [x] 资金雷达构建只发出一次 `stock_basic(list_status=L)` 请求,并拒绝该分区返回非 `L` 状态。
- [x] `moneyflow_dc` 首批低于或等于 6000 行且覆盖全部候选股票时均可成功解析;达到 6000 行本身不再导致 `SourceTruncatedError`。
- [x] 首批未覆盖候选股票时,仅补拉缺失代码,调用总数为 `1 + 缺失代码数`,最终 snapshot 顺序与 worker 完成顺序无关。
- [x] 两个补拉请求可以处于并发等待状态,但共享协调器记录的请求启动时间间隔不小于配置值;默认配置下理论总速率不超过约 300 次/分钟。
- [x] 空补拉和瞬时请求失败不会被补零或伪装成完整覆盖;错误日期、错误代码、重复键和分片触顶会阻止发布错误结果。
- [x] failed/partial publication 重试仍复用既有 source checkpoints,并只刷新需要重取的 `MONEYFLOW_DC` group。
- [x] 共享协调器、sector radar source/build/CLI 相关单元测试、Ruff、Pyright 和完整后端 pytest 通过;需要真实 PostgreSQL 的测试若未配置,必须明确报告跳过状态。
## Out of Scope
- 不保证历史日期按当时上市状态精确重建,也不保留已退市股票进入未来重跑结果。
- 不复用 `market-data-sync` 的数据库股票池、行情或 Tushare client。
- 不实现跨进程或跨定时任务的分布式限流;运维上仍要求 `market-data-sync` 与 `sector-radar-build` 不重叠运行。
- 不改变板块评分公式、前端展示、数据库 schema 或其他 Tushare 来源组的请求策略。
@@ -0,0 +1,85 @@
# Research: 板块资金雷达 build 到 Tushare source 调用链
- Query: 定位板块资金雷达从 application build 到 Tushare source 的完整调用链,解释 `stock_basic` 为什么请求 `L/D/P/G/UN`、`moneyflow_dc` 在哪里按 6000 行拒绝、候选股票集合何时形成,以及重试时 publication/source checkpoint 如何复用。
- Scope: internal
- Date: 2026-08-31
## Findings
### 1. 完整调用链
生产入口由 `zhixing-server/pyproject.toml:36-38` 将 `sector-radar-build` 绑定到 `presentation.cli:main`。CLI 在 `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py:42-47` 构造 `BuildSectorRadarCommand`,在同文件 `:62-77` 用 token 创建 `TushareSectorRadarAdapter`、创建 PostgreSQL repository,并调用 `BuildSectorRadar(...).execute(command)`。
application 层从 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:199-222` 的 `BuildSectorRadar.execute` 开始。普通单日/区间模式先由 `_resolve_targets` 调 `source.fetch_trade_calendar` 解析目标交易日(`:224-247`);retry 模式则直接读取原 publication 并复用其目标交易日(`:225-231`)。随后 `_build_target` 获取按交易日的 advisory lock(`:257-271`),`_build_locked` 恢复遗留 running publication、创建新的 running publication、加载可复用来源组,再进入 `_collect`(`:285-310`)。
`_collect` 以固定顺序调用 `_fetch_group`:calendar、concept indices、industry indices、members、stock basics、suspensions、daily、moneyflow_dc,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:498-563`。application 依赖的 source port 定义在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py:24-47`;生产实现是 `TushareSectorRadarAdapter`。每个 adapter 方法最终进入 `TushareSectorRadarAdapter._fetch_snapshot`,它组装显式 fields 后优先调用 `client.query(api_name, fields=..., **params)`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:368-405`。因此资金流主链是 `BuildSectorRadar.execute -> _build_target -> _build_locked -> _collect -> _fetch_group(MONEYFLOW_DC) -> SectorRadarSource.fetch_moneyflow_dc -> TushareSectorRadarAdapter.fetch_moneyflow_dc -> _fetch_snapshot -> client.query("moneyflow_dc", trade_date=..., fields=...)`。
`_fetch_group` 不只是调用 source:新拉或重放成功后,它立即保存 content-addressed raw snapshot,并以 `(publication_id, source_group, source_order)` 建立 publication checkpoint 链接,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:457-496`。这意味着失败发生前已完成的每一组都已经具备可恢复检查点。
### 2. `stock_basic` 为什么请求 `L/D/P/G/UN`
adapter 明确说明不能依赖 Tushare 默认只返回 `L`,并逐一请求五个文档化生命周期分区,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:267-285`。每个响应还校验返回 `list_status` 必须与请求分区一致(`:280-282`),最后跨分区校验 `ts_code` 唯一(`:284`)。现有回归测试固定了五次请求顺序与五种状态均被汇总,见 `zhixing-server/tests/unit/sector_radar/test_tushare_source.py:308-333`。
业务原因是 radar 需要按目标交易日判断 point-in-time 生命周期,而不是只看“当前仍上市”的默认集合。`StockBasicRow` 保存 `list_date`/`delist_date`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:338-364`;真正的生命周期判断在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py:200-210`,要求沪深 A 股、非 B 股/北交所、`list_date <= target` 且目标日不晚于 `delist_date`。因此完整状态分区主要用于避免历史目标日漏掉目前已退市/暂停等股票,并使未上市/过会等记录由日期规则明确排除。`list_status` 本身目前不直接决定资格,资格由代码、市场及上市/退市日期决定。
### 3. `moneyflow_dc` 的 6000 行拒绝点
`ROW_LIMITS["moneyflow_dc"]` 在 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:73-81` 固定为 `6_000`。`_fetch_snapshot` 把该上限传给 `build_source_snapshot`(同文件 `:397-405`);snapshot builder 在 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:147-160` 计算 `row_count`,并以 `row_count >= row_limit` 标记 `limit_reached=True`,所以恰好返回 6000 行也视为可能截断。
具体拒绝发生在 `TushareSectorRadarAdapter.fetch_moneyflow_dc`:取到 snapshot 后立刻调用 `_reject_limit`,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:318-330`;`_reject_limit` 在同文件 `:465-468` 抛出 `SourceTruncatedError("moneyflow_dc reached its provider row limit")`。这里没有像 `dc_member` 那样的分区补拉逻辑;错误经 `_fetch_group` 和 `_build_locked` 上浮,最终 publication 被记为 failed(`application/build.py:391-421`)。
### 4. 候选股票集合形成时点
候选集不是在请求 `moneyflow_dc` 之前形成。`_collect` 先完成全部八个来源组,包括 full-market `daily` 与 `moneyflow_dc`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:550-563`),然后才调用 `normalize_memberships`,从状态为 `AVAILABLE` 的板块成员记录中取非空 `stock_code`、去重并排序为 `candidate_codes`(`:565-574`)。随后 `candidate_codes` 才传入 `normalize_stock_facts`(`:575-582`)。
这个集合此时只是“当日概念/行业成员股票并集”,尚未完成生命周期过滤。`normalize_stock_facts` 在遍历候选代码时才逐只调用生命周期规则;不合法者被保留为 `LIFECYCLE_INVALID` fact,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py:159-170`。所以当前调用顺序无法用候选集缩小或分片本轮 `moneyflow_dc` 请求,这是本次“当前上市股票池与资金流缺口补拉”设计需要显式调整的结构性边界。
### 5. publication/source checkpoint 的重试复用
retry 命令只接受 `partial` 或 `failed` publication,并复用旧 publication 的目标交易日,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:224-231`。实际重试不会继续写旧 publication,而是在持有日期锁后新建一个 running publication,再从旧 publication 加载 source checkpoints(`:285-310`)。
checkpoint 的领域模型是八个稳定的 `PublicationSourceGroup` 和有序 `PublicationSourceRecord`,后者持有 raw `SourceSnapshot` 与 `refresh_on_retry` 标志,见 `zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py:131-160`。数据库表以 `(publication_id, source_group, source_order)` 为主键,raw snapshot 外键采用 `RESTRICT`,见 `zhixing-server/migrations/versions/0005_radar_daily_aggregate.py:17-55`;repository 加载时 join raw snapshot 并按 group/order 返回,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py:192-222`。
`_reusable_sources` 会忽略 `refresh_on_retry=True` 的记录;其余记录按 `source_order` 排序,并要求编号从 0 连续,否则拒绝重放,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:433-455`。`_fetch_group` 命中 reusable group 时不访问 Tushare,而是从保存的 snapshot rows 重新执行 typed parser;未命中时才调用 source。无论重放还是新拉,snapshot 都会再次链接到新的 running publication,见同文件 `:457-496`。
两类失败的复用语义不同。对于完整采集后因覆盖率不足形成的 partial,`_retry_source_groups` 根据 `membership_unknown`、缺失/空 `daily`、缺失/空 `moneyflow` 精确选择需刷新的来源组,见 `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:683-704`;`finalize_publication` 在同一事务中把这些组标记为 `refresh_on_retry=TRUE` 后再结束 publication,见 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py:562-571`。对于采集中途 hard failure,成功组已经由 `_fetch_group` 即时 checkpoint,失败组及其后的组没有记录;因此 retry 自动重放所有已完成组并从首个未完成组继续。测试证明 partial 资金缺口只再次调用 `moneyflow_dc`(`zhixing-server/tests/unit/sector_radar/test_build.py:389-406`),而 daily hard failure 后会复用此前六组,只再次调用 `daily` 和尚未执行的 `moneyflow_dc`(`:409-426`)。
收集完成后,所有 snapshot id 与指标版本参与 `input_hash`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:706-716`);若已存在同目标日、同 input hash 的 publication,新 running publication 会被丢弃并返回 existing publication,见同文件 `:310-327`。这是 publication 级幂等复用,与 source-group 级断点重放互补。
## Files Found
- `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py`:生产 CLI 组合根,创建 Tushare adapter、PostgreSQL repository 与 application use case。
- `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py`:目标日期解析、来源组采集顺序、候选集生成、publication 生命周期及重试复用核心。
- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py`:application 到 source adapter 的端口契约。
- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py`:七类 Tushare 接口、状态分区、行数上限与 `client.query` 边界。
- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py`:raw snapshot 的上限标记、typed row 解析与 source contract errors。
- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/normalize.py`:成员候选并集之后的生命周期、停牌、行情与资金流事实归一化。
- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/persistence.py`:source checkpoint 分组和值对象契约。
- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/postgres.py`:checkpoint 的保存、加载与 partial 刷新标记事务。
- `zhixing-server/migrations/versions/0005_radar_daily_aggregate.py`:publication-source checkpoint 表结构及完整性约束。
- `zhixing-server/tests/unit/sector_radar/test_build.py`:partial/failed 重试只刷新未完成来源组的可执行证据。
- `zhixing-server/tests/unit/sector_radar/test_tushare_source.py`:五种 `stock_basic` 状态分区请求的回归证据。
## Code Patterns
- Port/adapter:application 只依赖 `SectorRadarSource`,CLI 注入 `TushareSectorRadarAdapter`(`domain/ports.py:24-47`;`presentation/cli.py:62-77`)。
- Point-in-time master data:显式拉取所有生命周期状态,再按目标日 `list_date`/`delist_date` 判定(`infrastructure/tushare.py:267-285`;`domain/normalize.py:200-210`)。
- Fail closed on provider limit:snapshot 以 `>=` 标记触顶,不能将潜在截断当成功(`domain/source.py:158-160`;`infrastructure/tushare.py:465-468`)。
- Immediate source checkpoint:每个来源组一成功就保存 raw snapshot 及 publication link,而不是等待整个 publication 完成(`application/build.py:486-496`)。
- Selective retry:partial 显式标记缺口组;failed 依赖已完成组存在、未完成组缺席来恢复(`application/build.py:433-475,683-704`)。
## External References
- 本次为内部调用链研究,未新增外部资料检索。既有已归档研究 `.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/research/tushare-radar-contract.md:7-15` 记录了原实现采用的 Tushare 接口边界:`stock_basic` 默认只返回 `L`,`moneyflow_dc` 单次上限 6000;上线前仍应以目标账号 capability probe 和当时官方文档为准。
## Related Specs
- `.trellis/spec/backend/market-data-sync.md`:一次性 Tushare Job、可恢复 snapshot、失败保留旧发布的相邻上下文规范;sector radar 有独立 bounded context,不能直接套用选股/ST 股票池规则。
- `.trellis/spec/backend/selection.md`:selection 的当前沪深非 ST 股票池契约不等于 radar 的 point-in-time 板块成员 universe。
- `.trellis/tasks/archive/2026-08/08-28-sector-capital-radar/design.md:36-44`:原 radar 设计要求全部上市状态、目标日生命周期、沪深 A 股过滤及行数触顶时不得接受截断响应。
## Caveats / Not Found
- 当前 `moneyflow_dc` 没有按候选股票或代码分片的实现;达到 6000 行只会 hard fail。`dc_member` 有按板块分区补拉,可作为模式参考,但不能直接证明 Tushare `moneyflow_dc` 支持同样的参数或批量行为。
- 当前候选集形成得晚于 `moneyflow_dc` 请求,并且成员并集与“生命周期有效股票池”是两个阶段;讨论修复时必须明确要前移哪一个集合,避免误把所有板块成员都视为当前上市股票。
- 代码中的 `ROW_LIMITS` 是本地契约常量,不是运行时从供应商元数据发现;若要改变请求策略,需要重新核对当前 Tushare `moneyflow_dc` 的可用过滤参数、单次限制及积分权限。
@@ -0,0 +1,92 @@
# Research: Tushare 两路补拉的全局请求启动间隔
- Query: 检索仓库现有 Tushare 限流、并发 worker、线程安全、测试夹具与 `sector_radar` source 测试模式,定位实现“2 个 worker 共享全局 0.2 秒请求启动间隔”的最小适配点。
- Scope: internal
- Date: 2026-08-31
## Findings
### 结论与最小适配面
最小且边界清晰的实现是扩展共享的 `RequestCoordinator`,让它可选地协调“请求启动槽”,然后仅让 `TushareSectorRadarAdapter` 启用现有的 `request_interval_seconds=0.2`。两路 `moneyflow_dc` 补拉 worker 共享同一个 adapter,而该 adapter 已经只持有一个 `_coordinator`,因此不需要新建进程级 singleton,也不需要新增环境变量。
具体适配点如下。
1. 在 `zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-66` 的 `RequestCoordinator` 增加默认关闭的启动间隔参数及 `_next_request_at` 状态;继续复用现有 `threading.Condition`,在同一临界区内读取单调时钟、计算 `max(_cooldown_until, _next_request_at)`、等待并预约下一启动时刻。只有“预约”需要持锁,真实 provider 请求必须在锁外执行,才能保持两个 worker 的请求重叠能力。
2. 在 `zhixing-server/src/zhixing_server/shared/request_coordinator.py:75-82` 的每次 attempt 开始前,把当前只等待 cooldown 的 `_wait_for_cooldown` 收敛成“等待 cooldown 并原子预约启动槽”。预约完成时令 `_next_request_at = actual_start + interval`。这样初次请求和 retry 都服从同一个启动间隔;当 403/429 创建 cooldown 后,等待中的 worker 还会在醒来时重新检查 cooldown。
3. 新参数必须默认 `0.0`。`RequestCoordinator` 还被 market-data 使用,而且它的现有契约明确是“普通请求不串行,只共享命中限流后的 cooldown”(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-41`;`docs/market-data-sync.md:65-69`)。默认关闭可避免顺带改变八路行情同步语义。
4. 在 `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:92-116` 构造默认 coordinator 时,把已经存在的 `request_interval_seconds` 传入协调器;删除或停用 `_fetch_snapshot` 成功返回后的逐线程休眠(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:368-389`)。当前休眠发生在请求完成后,两个线程可以同时启动,不能表达“全局请求启动间隔”。
5. `request_interval_seconds` 的配置链已经完整:`Settings.sector_radar_request_interval_seconds` 默认 0.2(`zhixing-server/src/zhixing_server/bootstrap/config.py:27-31`),CLI 将它传给 `TushareSectorRadarAdapter.from_token`(`zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py:62-67`)。因此不需要改 `.env`、Compose 或配置模型。
这里的“全局”只能可靠地解释为“同一 adapter/coordinator 实例覆盖的两个 worker”。现有 coordinator 不是模块 singleton,也不能跨进程协调;market-data job、sector-radar job 或两个独立进程各自创建 coordinator。若需求是全系统或跨进程的 5 requests/s,则本方案不满足,需要外部/分布式限流器,这会明显扩大范围。
### 两个 worker 的落点
`moneyflow_dc` 的当前全市场入口完全串行,只请求一次并校验结果(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:318-330`)。补拉属于 Tushare 响应分区细节,最小落点是该 infrastructure adapter 内部:先保留全市场快照,再对缺失的当前上市股票代码使用 `ThreadPoolExecutor(max_workers=2)` 发起按 `ts_code` 分区请求。worker 共享 `self._coordinator`,所以每一个 `_fetch_snapshot` 最终都经过同一个启动槽。
仓库已有 worker 写法可复用:`SyncMarketData` 在 `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:342-361` 使用具名的 `ThreadPoolExecutor` 和 future-to-business-key 映射;并发测试用带 `threading.Lock` 的 fake 统计 active/max-active(`zhixing-server/tests/unit/market_data/test_sync_concurrency.py:22-59`),并断言两路上限(`zhixing-server/tests/unit/market_data/test_sync_concurrency.py:156-180`)。sector-radar 不宜照搬其数据库副作用模型,只应复用“有界 executor + 主线程汇总”的形状。
如果补拉需要由当前上市股票池驱动,应用层已经先得到 `stock_basics`、后取 `moneyflow`(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:536-563`)。最小跨层契约是从 `stock_basics.rows` 中取 `list_status == "L"` 的代码并传给 `fetch_moneyflow_dc`;这会同步影响 `SectorRadarSource.fetch_moneyflow_dc`(`zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py:24-47`)和测试 fake。不要让 adapter 缓存上一次 `fetch_stock_basics` 的结果,否则 retry/replay 和调用顺序会形成隐式状态。
worker 完成顺序不得直接决定 snapshot 顺序。publication checkpoint 会按 `result.snapshots` 的枚举顺序持久化(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:486-495`),重放又要求 `source_order` 从 0 连续并按序恢复(`zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py:433-455`)。因此 future 结果应按输入股票代码或明确排序后汇总;虽然单个 snapshot 内部的 hash 已对 rows 做顺序稳定化(`zhixing-server/src/zhixing_server/modules/sector_radar/domain/source.py:134-163`),snapshot 元组自身仍需稳定。
固定“两路”不要求新增 `Settings`。最小做法是在 adapter 内使用命名常量或默认值为 2 的构造参数;只有产品要求运行时可调时,才需要扩展 config、CLI、`.env.example` 和 Compose。仓库现有可调 worker 的完整链路可参考 `Settings.market_data_max_workers`(`zhixing-server/src/zhixing_server/bootstrap/config.py:20-25`)和 `SyncMarketData(max_workers=...)`(`zhixing-server/src/zhixing_server/modules/market_data/application/sync.py:131-143`)。
### 现有限流与线程安全证据
共享协调器已经用 `threading.Condition` 保护 `_cooldown_until` 和 `_rate_limit_count`(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:43-66`),读取、创建 cooldown 和成功后清理也都在该条件锁内(同文件 `:68-73`、`:127-152`)。403、429 和稳定中英文提示的分类位于同文件 `:13-28`、`:154-163`,cooldown 阶梯为 60/120/180 秒(`:13`)。这正是承载全 worker 启动槽的现有线程安全原语。
当前 `call` 明确允许普通请求并发(`zhixing-server/src/zhixing_server/shared/request_coordinator.py:35-41`),而 `_wait_for_cooldown` 在锁外调用 `wait_fn`(`:127-138`),不会把 provider 调用包在全局锁中。新增启动间隔也应保持这一点;如果把整次 `client.query` 放进锁中,虽然间隔成立,但会把两个 worker 退化为串行请求。
`TushareSectorRadarAdapter` 对同一个 SDK client 调用 `client.query` 或接口方法(`zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py:378-385`)。仓库内没有 Tushare SDK client 线程安全保证,也没有为 `_client` 加锁。锁定版本是 Tushare 1.4.29(`zhixing-server/uv.lock:731-739`)。因此两路并发是否可共享同一 SDK client 是实现前仍需确认的风险;启动间隔只保护频率状态,不等于保证 SDK client 内部线程安全。若无法确认,选择独立 client 会需要 token/factory 生命周期改造,选择锁住整个 client 请求则无法获得网络调用并发收益。
### 测试模式与建议入口
仓库没有 `tests/**/conftest.py` 或 sector-radar pytest fixture。`zhixing-server/tests/unit/sector_radar/test_tushare_source.py:23-44` 采用文件内 `QueryClient` 和 `make_adapter`:响应按 `(api_name, ts_code/list_status)` 分区,adapter 关闭 retry 和真实 sleep,并固定 `now_fn`。`dc_member` 达上限后按分区补拉的测试(同文件 `:216-274`)是 moneyflow 缺失补拉最接近的现有测试模板;当前上市状态查询测试在 `:308-333`。
启动间隔的直接先例是 `zhixing-server/tests/unit/market_data/test_tushare.py:53-87`:用可注入 fake monotonic clock 和 wait 函数验证一个请求触发的 cooldown 会阻塞后续请求。新增测试应延续 fake clock,而不是用真实 `sleep(0.2)` 和宽松 wall-clock 断言,以避免并发测试抖动。
建议最少覆盖两层行为:
- 在共享 coordinator 的单元测试中让两个线程共享一个 coordinator,用 `threading.Event` 保持首个 request 未完成,fake clock/wait 将第二个启动推进到 0.2;记录两个真实 request callback 的开始时刻并断言差值为 0.2。该形状能证明“请求可重叠,但启动槽不重叠”,也能避免单纯顺序调用掩盖线程竞态。
- 在 `test_tushare_source.py` 增加 moneyflow 缺失回补测试:全市场响应遗漏若干 `list_status=L` 代码,按 `ts_code` 的 fake 分区返回补拉结果,断言 executor 最大 active 不超过 2、最终 rows 和 snapshots 顺序稳定、非上市状态不补拉。并发 fake 的 `calls`、`active` 和 `max_active` 必须用 `threading.Lock`;现有 `QueryClient.calls.append`(`:23-34`)只适合串行测试。
现有测试入口为:
```bash
cd zhixing-server
uv run pytest tests/unit/market_data/test_tushare.py
uv run pytest tests/unit/sector_radar/test_tushare_source.py
uv run pytest tests/unit/sector_radar/test_build.py
uv run pytest tests/unit/sector_radar/test_cli.py
```
共享 coordinator 改动至少应运行前两个入口;若 `fetch_moneyflow_dc` 端口增加上市代码参数,还必须运行后两个入口以覆盖 `FakeRadarSource`、应用编排和 CLI 组合。完整后端门禁由 `.trellis/spec/backend/quality-guidelines.md:3-20` 和 `zhixing-server/pyproject.toml:40-49` 定义,包括 Ruff format/lint、Pyright strict 和完整 pytest。
### Files found
- `zhixing-server/src/zhixing_server/shared/request_coordinator.py`:跨 bounded context 的 retry、限流识别和共享 cooldown 协调器,是全局启动槽的最小所有权位置。
- `zhixing-server/src/zhixing_server/modules/sector_radar/infrastructure/tushare.py`:sector-radar Tushare adapter、source 分区与当前逐请求休眠位置。
- `zhixing-server/src/zhixing_server/modules/sector_radar/application/build.py`:stock basics 到 moneyflow 的调用顺序、source checkpoint 稳定顺序契约。
- `zhixing-server/src/zhixing_server/modules/sector_radar/domain/ports.py`:`fetch_moneyflow_dc` 的应用端口签名。
- `zhixing-server/src/zhixing_server/modules/market_data/application/sync.py`:仓库现有有界 `ThreadPoolExecutor` 模式。
- `zhixing-server/tests/unit/market_data/test_tushare.py`:fake clock/wait 的 coordinator 测试模式。
- `zhixing-server/tests/unit/market_data/test_sync_concurrency.py`:两路 worker 上限与加锁 fake 的测试模式。
- `zhixing-server/tests/unit/sector_radar/test_tushare_source.py`:source fake、分区响应、禁用真实 sleep 及 schema/limit 测试入口。
- `zhixing-server/tests/unit/sector_radar/test_build.py`:应用端口 fake 和 source-group replay/retry 覆盖。
- `zhixing-server/src/zhixing_server/bootstrap/config.py`、`zhixing-server/src/zhixing_server/modules/sector_radar/presentation/cli.py`:现有 0.2 秒配置传递链。
### Related specs
- `.trellis/spec/backend/directory-structure.md`:无业务归属的小型跨上下文能力应放在 `shared/`;Tushare 请求启动协调符合这一边界。
- `.trellis/spec/backend/market-data-sync.md`:Tushare client、共享限流和后端测试门禁的既有契约。
- `.trellis/spec/backend/configuration-and-runtime.md`:运行时配置只能通过 `Settings` 注入;本最小方案复用既有配置,不新增环境读取。
- `.trellis/spec/backend/quality-guidelines.md`:Pyright strict、pytest 严格模式和后端质量命令。
- `.trellis/spec/guides/code-reuse-thinking-guide.md`:跨上下文且无业务所有权的原语才进入 `shared/`,并要求复用前先核对生命周期与错误语义。
## Caveats / Not Found
- 未在仓库中找到 Tushare 1.4.29 对 `pro_api` client 的线程安全声明;不能仅凭 Python 对 `list.append` 或对象读取的实现细节宣称 SDK client 可安全并发。
- 未找到 sector-radar 专用 `conftest.py`、pytest fixture 或现成的 request-start 间隔测试;需要沿用文件内 fake 和 coordinator fake clock 模式。
- 当前 PRD 仍为 TBD,未定义“全局”是否跨 adapter/进程,也未定义单只股票补拉失败是整组失败还是保留部分回补。以上结论按“一个 sector-radar adapter 内两路 worker、任一补拉失败则 source group 失败”的最小解释给出。
- 本次只读研究未运行 pytest;研究代理只写入本文件,未修改产品代码或测试代码。
@@ -0,0 +1,26 @@
{
"id": "sector-radar-listed-moneyflow-recovery",
"name": "sector-radar-listed-moneyflow-recovery",
"title": "资金雷达当前上市股票池与资金流缺口补拉",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-31",
"completedAt": "2026-08-31",
"branch": null,
"base_branch": "develop",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -5,3 +5,7 @@
{"file":".trellis/tasks/08-27-onechartlab-research/prd.md","reason":"逐条核对两份独立报告、复现深度、证据定位和范围边界的验收条件。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"核对板块报告的已确认事实、推断边界和排名复算结果。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"核对宏观报告的阈值、状态、贡献恒等式和核心公式缺口。"}
{"file": ".trellis/spec/frontend/index.md", "reason": "核对本轮前端实现是否符合项目分层和门禁。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对无限查询、query key、取消信号和服务器状态归属。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对滚动表格、状态提示、语义和可访问性。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
@@ -32,3 +32,11 @@
## 验证与回滚
最终检查逐条核对 PRD 验收标准、报告内引用的可访问性、事实/推断分级、两模块的一致术语和复现说明完整性。报告是新增 Markdown 文件,回滚只需移除新增报告和任务底稿,不影响运行时系统。
## 板块排名页交互迭代
排名接口继续沿用现有 `page` / `page_size` 服务端契约,前端 query 层新增无限查询封装,以首屏页为起点,根据已加载条数和响应 `total` 推导下一页。页面只消费 React Query 管理的分页集合,不把服务端响应复制到 Zustand 或组件本地状态;筛选条件参与 query key,条件变化自然切换到新的首批数据。
表格自身保持唯一纵向滚动容器和 sticky 表头,在滚动位置接近底部时调用 `fetchNextPage`。加载下一批时在表格底部提供紧凑状态,加载失败时保留已加载行并提供可重试操作,全部加载后不显示传统页码或每页条数控制。
移除 `RadarStatusSummary` 及其静态标题、免责声明、版本和发布元数据块。部分发布、后台刷新、刷新失败、首次加载失败和无数据仍属于操作性状态,继续以紧凑提示或状态容器呈现,不删除关键异常反馈。
@@ -5,3 +5,7 @@
{"file":".trellis/tasks/08-27-onechartlab-research/design.md","reason":"提供证据等级、两路页面调研分工、视频暂缓边界、复现链路和访问限制。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"提供板块页面、公开分片、排名算法、失败边界和主会话复算证据。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"提供宏观页面、状态/贡献协议、阈值、证据缺口和主会话复算证据。"}
{"file": ".trellis/spec/frontend/index.md", "reason": "板块资金雷达页面是 React/TanStack Query 前端改动,需遵守前端目录、交互和质量门禁。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "无限列表继续由 TanStack Query 管理服务端分页和取消信号。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "表格滚动、状态提示和控件需遵守现有组件、Tailwind 与可访问性约定。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现后需更新行为测试并通过前端质量命令。"}
@@ -10,6 +10,10 @@
- [x] 编写 `docs/research/onechartlab-sector-capital-radar.md`,覆盖输入、加工链路、排序/打分/映射、输出信号、更新和异常边界、复现伪代码及证据置信度。
- [x] 编写 `docs/research/onechartlab-macro-timing.md`,覆盖输入、状态定义、阈值/状态机、滞后与前视风险、资产解释、复现伪代码及证据置信度。
- [x] 执行最终内容检查,确认每条关键技术结论有出处或推断标签,两份报告可以独立阅读且不包含收益承诺或未经确认的精确参数。
- [x] 在 sector radar query 层加入基于现有分页接口的无限查询,并用单元测试覆盖 query key、取消信号和下一页判定。
- [x] 将板块排名表改为滚动接近底部时加载下一页,移除共享分页器接入,确保筛选变化时列表重置且下一批失败不会清空已加载行。
- [x] 删除 `RadarStatusSummary` 大块描述内容,把部分发布等必要状态保留为紧凑提示,并更新页面行为测试。
- [x] 运行前端 format、lint、typecheck、相关 Vitest、完整测试与 build,随后进行可见页面检查。
## 验证命令与人工检查
@@ -23,6 +27,15 @@ git diff --check
人工核对报告中的关键 URL 和公开请求字段,检查证据等级是否与正文措辞一致,并确认视频暂缓状态及其影响已写清楚。
页面迭代额外执行:
```bash
cd zhixing-web
pnpm exec vitest run src/features/sector-radar/api/sector-radar.query.test.ts src/features/sector-radar/pages/sector-radar-page.test.tsx
pnpm check
pnpm build
```
## 风险与停止条件
如果网站需要绕过登录、付费、验证码、地域限制或反爬机制才能继续,停止该取证路径并记录缺口。若压缩/混淆脚本只能支持候选实现而不能证明算法,降级为推断。若公开数据只给出最终分值,报告提供可实施的验证实验,而不虚构原始公式。
@@ -35,10 +35,14 @@
- [x] 两份报告不存在投资收益承诺,并明确其用途是技术与产品逻辑研究,不构成投资建议。
- [x] 生成一份 Tushare 数据需求研究文档,能够作为后续采集、落库和独立重算的接口清单与实施边界。
- [x] Tushare 文档明确区分页面已暴露的确定接口、为复现建议补充的接口和仍需实测/开通权限确认的接口;关键字段及权限信息引用官方来源。
- [x] 板块资金雷达排名页取消底部分页器,首批数据展示后可在表格滚动区接近底部时继续加载下一批,直到当前筛选条件下全部结果加载完成。
- [x] 切换交易日、板块类型、指标视角、榜单范围或搜索条件后,无限列表从首批结果重新开始,不混入旧筛选条件的数据。
- [x] 移除排名表上方包含标题、免责声明、实现标签、版本号和发布元数据的整块描述性卡片,不再用大面积静态说明挤占榜单空间。
- [x] 刷新失败、后台刷新、部分发布、加载失败和无数据等操作性状态仍能以紧凑且可访问的方式向用户表达。
## Out of Scope
- 绕过登录、会员、验证码、地域限制、反爬机制或其他访问控制。
- 对未公开的后端源码、私有数据源或专有算法作确定性归因。
- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议。
- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议;本轮仅迭代既有板块资金雷达页面的列表交互与信息密度。
- 修改 OneChartLab 网站或向其作者、用户发送消息。
@@ -0,0 +1,40 @@
# OneChartLab Radar 三个指标补充核验(2026-09-01)
## 当前版本
- 首页:<https://onechartlab.com/>
- Manifest:<https://onechartlab.com/radar_manifest.json>
- 观测到 `LATEST_DATE=2026-08-31`、31 个交易日、最新日期分片 `radar_data/dates/2026-08-31.c094ac3098c9.json`。
- 最新分片 791 条:概念 414、行业 377;每条 35 个字段,含 `pct_change`、`Swing_Ratio_Val`、`Swing_Amount_Val`、`Swing_Score`、`Ratio_Raw_Pct`、`Ratio_Score`、`Amount_Raw_BN`、`Amount_Score` 及各类排名字段。
## 页面证据
当前首页内联 Vue 模板(本次保存为 `/tmp/onechartlab-index-20260901.html`)显示:
- `pct_change` 直接用于左右榜涨跌幅展示和表头排序,格式为带符号两位小数百分比;非有限值显示 `-`。
- `Swing`/`Ratio` 模式的加权评分分别读取 `Swing_Score`/`Ratio_Score`,保留 1 位小数;tooltip 是“后端特征算法算出的综合加权值”。前端没有计算 score 的代码。
- `Swing` 模式的波段流入率读取 `Swing_Ratio_Val * 100`,保留两位小数百分比;`Ratio` 模式读取 `Ratio_Raw_Pct * 100`。
- 普通榜候选先按 `type` 分池,再按对应 `RankPct >= 90` 或 `<= 10` 取前后 10%;`Swing` 和 `Ratio` 默认按各自 `*_Score` 排序,`Amount` 默认按 `Amount_Raw_BN` 排序。`Amount_Score` 存在但不参与 `Amount` 榜默认排序。
## 数值关系
2026-08-31 的概念样例:
| 板块 | `pct_change` | `Swing_Ratio_Val` | `Swing_Score` | `Ratio_Raw_Pct` | `Ratio_Score` | `Amount_Raw_BN` |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 知识产权 | 4.03 | 0.0320857 | 985.0391 | 0.1494700 | 988.6650 | 25.0232 |
| 盲盒经济 | 4.04 | 0.0465369 | 899.3116 | 0.2279457 | 952.2123 | 16.0316 |
| 元宇宙概念 | 2.83 | 0.0152171 | 976.3810 | 0.0695193 | 1032.4349 | 61.7966 |
概念与行业两个排名池中,`Swing_RankPos` 与 `Swing_Score`、`Ratio_RankPos` 与 `Ratio_Score`、`Amount_RankPos` 与 `Amount_Raw_BN` 均零不匹配。该结果只能确认排序键,不能推出 score 公式。
横截面统计(Pearson 相关系数):`pct_change` 与 `Ratio_Score` 在概念/行业分别约为 0.714/0.584,与 `Swing_Score` 约为 0.392/0.259;对应 raw 值与 score 的相关性更高但仍非恒等关系。因此涨跌幅可能是后端特征之一,也可能只是与资金强弱共同受行情驱动,不能据此声明“score=涨跌幅+资金”的公式。
将连续日期的 `Ratio_Raw_Pct` 与当前 `Swing_Ratio_Val` 对齐,在可匹配样本上,最近 3 日简单均值是最相近候选(概念/行业 `R²` 约 0.886/0.882),但拟合存在斜率和截距,4—10 日候选更弱,故不能确认波段值就是 3 日平均。
## 证据边界
- 作者视频 `01:36—01:58`:单日流入率概念上是主力流入相对当天成交额的比例,并警告小样本、低活跃度和低成交量失真。
- 作者视频 `01:58—02:23`:波段流入率是 3—10 日加权的单日流入率并排名;靠前代表资金留存比例较高,后续延续性只是可能更好。
- 页面成分 manifest:`pct_change` 来源 `tushare.daily.pct_chg`,主力净额来源 `tushare.moneyflow_dc.net_amount`,聚合为 `sum`,多日涨跌幅使用 `compound_return`。
- 公开日期分片没有逐股成交额、成员快照和资金流明细,故无法从当前公开 payload 直接重算 `Ratio_Raw_Pct`、`Swing_Ratio_Val` 或任一 score。
@@ -3,7 +3,7 @@
"name": "onechartlab-research",
"title": "OneChartLab 板块资金雷达与宏观择时技术调研",
"description": "",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-27",
"completedAt": null,
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量门禁与测试规范"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件可访问性与列表交互规范"}
{"file": ".trellis/tasks/09-01-optimize-stock-list-radar/prd.md", "reason": "用户需求与验收标准"}
@@ -0,0 +1,19 @@
# 技术设计
## 边界与组件结构
选股模块保留 `SelectionResultsWorkbench` 的筛选、分页、选中状态和详情面板结构,将当前桌面 `SignalTable` 与移动 `SignalRecordList` 的双实现收敛为一套响应式紧凑卡片列表。列表继续位于左侧结果区内部滚动;条目用语义化 `article` 和真实按钮承载选择、展开交互,不改变路由搜索参数或 API 类型。
资金雷达继续使用语义化表格与现有无限滚动容器,只删除展示层中的资金覆盖率和质量表头/单元格,并同步空状态 `colSpan`。通过缩小表头和数据行的垂直 padding、减少板块名称与代码间距来提高密度,不改动返回数据类型或质量计算。
## 数据流与兼容性
两处改动均消费现有响应字段,不改变 query、路由和后端契约。选股卡片仍用 `getStockKey`、`getStockJValue`、`PatternScoreSummary`、`SignalDetails` 和现有 category 样式函数,确保内容与详情行为保持一致。资金雷达保留 `quality` 和 `moneyflow_coverage` 在 TypeScript 响应类型中,仅不展示,以避免形成不必要的接口破坏。
## 取舍
选股列表采用单一响应式组件,消除桌面/移动两套 DOM 和测试分叉;卡片通过横向信息区和较小间距兼顾密度与窄屏换行。资金雷达保留 table,因为用户仅要求缩减列和行高,表格仍最适合对齐排名数据。
## 回滚
改动局限于选股展示组件、工作台装配、资金雷达页面及对应测试;如出现视觉或交互回归,可逐文件回退,不涉及数据迁移。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "前端开发入口与检查清单"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件、样式与可访问性规范"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试与质量门禁,约束卡片列表与雷达表格的断言方式"}
@@ -0,0 +1,10 @@
# 实施计划
1. 将选股结果收敛为单一紧凑卡片列表,保留选中、键盘、展开详情、空状态与内部滚动。
2. 更新工作台装配,移除 table 展示路径和重复组件引用,并按需要删除不再使用的文件。
3. 更新选股页面测试,改为断言卡片列表结构、核心字段和交互,不再依赖 table 表头。
4. 从资金雷达表格移除资金覆盖率与质量两列,修正 `colSpan` 并缩小表头、数据行间距。
5. 更新资金雷达测试,明确断言精简列不存在而保留百分位和样本;保留无限滚动与排名变化回归覆盖。
6. 在 `zhixing-web/` 运行目标 Vitest,然后运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`;必要时运行 `pnpm build` 验证产物。
风险点是选股卡片按钮嵌套和键盘行为、断点下滚动高度链,以及资金雷达 rank-change 模式的动态列数。实现时以语义结构和现有页面布局约束为准,出现回归时优先回滚对应组件的局部改动。
@@ -0,0 +1,29 @@
# 优化选股列表与资金雷达看板布局
## Goal
提升选股结果和资金雷达的浏览密度与可读性,让用户在同一屏看到更多条目,并减少不必要的辅助指标占用。
## Background and confirmed facts
当前选股结果在桌面端由 `features/selection/components/signal-table.tsx` 以 `<table>` 展示,移动端另有 `signal-record-list.tsx` 卡片列表;工作台同时挂载两者并通过断点切换。资金雷达页面的 `RadarTable` 表头和行包含“排名百分位、样本、资金覆盖率、质量”等列,行使用较大的垂直 padding。
## Requirements
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和右侧详情面板。列表容器应继续支持内部滚动和空结果提示;筛选、排序保留,不再展示底部分页,改为滚动加载更多。
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
3. 现有筛选、排序和选中股票详情的数据契约不变;仅调整展示层结构和样式。执行状态入口放到工具栏“重新执行”按钮右侧。
## Acceptance Criteria
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。卡片上不再出现“查看详情”。
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项。
- [ ] 选股结果不再渲染底部分页控件;列表滚到底部会加载下一页,加载中和失败重试提示保持可见。
- [ ] 执行状态入口(如“部分成功”)出现在工具栏“重新执行/重试执行/执行策略”按钮右侧。
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
## Out of scope
不调整后端接口、指标计算、筛选/排序语义、详情面板字段或全局主题 token。
@@ -0,0 +1,26 @@
{
"id": "optimize-stock-list-radar",
"name": "optimize-stock-list-radar",
"title": "优化选股列表与资金雷达看板布局",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "codex",
"assignee": "codex",
"createdAt": "2026-09-01",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "核对 feature 边界、URL 状态和质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对 Tab 语义、表格列对齐、滚动、状态提示和可访问性。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对双榜 query key、取消信号、分页停止条件和错误状态归属。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "确认未把原站未知的评分、涨跌幅或在榜天数伪造成已实现字段。"}
@@ -0,0 +1,39 @@
# 板块资金雷达双榜交互技术设计
## 范围与边界
本轮只修改 `zhixing-web` 的板块资金雷达页面、相关 query 组合和测试,不改变 FastAPI 参数、响应模型、数据库表或指标构建逻辑。后端现有 `GET /api/v1/sector-radar/rankings` 已支持 `side=top` 与 `side=bottom`,前端用两条独立无限查询组合双榜;两侧仍共享交易日、板块类型、指标视角、排名变化参数、搜索词和 `page_size`。
路由继续兼容既有 `side` 查询参数,但双榜页面不再用它决定可见榜单,也不提供“榜单范围”控件。旧链接无论携带 `side=all/top/bottom` 都进入同一双榜视图,避免为本轮视觉迭代扩大路由类型迁移范围。
## Tab 与工具栏
板块类型和指标视角改为 feature 内的语义化按钮 Tab,使用 `role="tablist"`、`role="tab"` 与 `aria-selected`,沿用现有 `updateSearch` 写回 URL,并在切换时重置 `page=1`。不新增共享 Tabs primitive或第三方依赖;交易日、排名变化指标和对比区间继续使用现有 Select,搜索继续使用 Input。
指标 Tab 顺序按目标截图组织为波段资金率、单日资金率、单日净额、排名变化。排名变化激活时,附属的“变化指标”和“对比区间”控件紧凑显示,不改变主 Tab 层级。
## 双查询与滚动数据流
页面从同一基础筛选分别构造 `side="top"` 和 `side="bottom"` 的 `RadarRankingsQuery`,调用两次现有 `useSectorRadarRankings`。现有 query key 已包含 `side` 且排除 `page`,因此两侧缓存和分页相互独立,无需修改后端契约。
每侧分别展平 `InfiniteData.pages`,按数组索引把强榜第 N 行和弱榜第 N 行配成一个视觉行。任一侧行数不足时对应单元格留空,另一侧继续正常展示。唯一滚动容器接近底部时,只为仍有下一页、未请求中且未处于加载更多错误的侧调用 `fetchNextPage`;请求级互斥覆盖同一滚动周期,避免连续事件重复取数。两侧都没有下一页时停止。
加载更多失败按侧记录并保留已加载行;提示区说明强榜或弱榜哪一侧失败,并只重试失败侧。首次请求、后台刷新、刷新失败、部分发布、无发布和搜索无匹配继续保持明确分支,不用一侧的下一页错误覆盖另一侧现有数据。
## 镜像表格结构
使用一张语义化 `<table>` 和显式列宽布局承载左右镜像双榜。第一层表头分为“资金进攻榜 TOP 10%”、当前指标和“BOTTOM 10% 资金撤离榜”;第二层列从左到右为:
`排名 / 板块 | 排名百分位 | 样本 | 资金覆盖率 | 质量 | 流入 | 流出 | 质量 | 资金覆盖率 | 样本 | 排名百分位 | 排名 / 板块`
右侧文本和列标题采用与阅读方向匹配的对齐方式,中间两列增加稳定分隔线。表头继续 sticky,表格保留足够 `min-width`,窄视口通过单一横向滚动容器查看,不压缩成多行或错位结构。
普通资金指标使用 `metric_value`:`CNY_100M` 以亿元格式化,`ratio` 以百分比格式化;强榜显示显式流入方向,弱榜显示显式流出方向,并使用现有主题中的红/绿语义和低饱和背景条增强对比。排名变化视角中间列改为“排名上升 / 排名下降”,值使用 `rank_change`,参考指标不伪装成独立流入/流出字段。
## 延期字段
涨跌幅、加权评分和在榜天数明确延期。当前 ranking row 只有单一 `metric_value`、排名、百分位、样本、覆盖率、质量与 1—5 日 `rank_change`;虽然数据源局部出现涨跌幅,聚合和排名表并未持久化该字段,加权评分公式也未公开,在榜天数没有历史轨迹契约。后续讨论必须先定义三项字段的业务语义、point-in-time 输入、公式、历史深度、异常处理与迁移兼容,不能在本轮 UI 中用现有字段代替或推算。
## 兼容、验证与回滚
本轮不改 HTTP 与数据库,可回滚为原单查询表格而不影响数据。验证包括 query 双实例参数、Tab 的 URL 更新、双榜配对、两侧独立续页、一侧结束或失败时另一侧继续、表头/行列数一致、正负格式化、可访问性、完整前端质量门禁和真实浏览器桌面布局检查。
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "确认本轮属于 sector-radar feature,遵守前端分层、URL 状态与质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Tab、镜像表格、状态提示和滚动容器需遵守现有组件、Tailwind 与可访问性约定。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "双榜继续由 TanStack Query 管理两侧独立无限查询、缓存和取消信号。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现需补齐用户可见行为测试并通过完整前端质量命令。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "提供原站四种指标、TOP/BOTTOM 分层、公开字段和未知评分公式的证据边界。"}
@@ -0,0 +1,33 @@
# 板块资金雷达双榜交互执行计划
## 执行清单
- [ ] 更新页面筛选区,将板块类型和指标视角改为语义化 Tab;移除榜单范围 Select,保留交易日、搜索和排名变化附属控制,并验证 URL 状态重置。
- [ ] 从共享筛选构造强榜与弱榜两条无限查询,分别展平分页结果,组合双侧加载、刷新、错误和重试状态。
- [ ] 将单侧 `RadarTable` 重构为左右镜像双榜表格,建立稳定列宽、双层 sticky 表头、中间流入/流出(排名变化时为排名升降)以及双侧空位规则。
- [ ] 为净额、比例和排名变化实现方向明确的格式化与低饱和条形表达,保留质量、覆盖率和样本等知行已有字段,不实现延期字段。
- [ ] 更新页面行为测试,覆盖 Tab、双查询参数、行配对、双侧分页、一侧提前结束、一侧加载失败重试、格式化语义以及旧分页/榜单范围控件消失。
- [ ] 运行相关 Vitest、`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build` 和 `git diff --check`,随后在真实浏览器中检查桌面布局、滚动加载和控制台。
## 重点文件
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx`
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx`
- 如双侧组合需要抽取 query helper,再最小修改 `zhixing-web/src/features/sector-radar/api/sector-radar.query.ts` 及其测试;不修改后端、迁移和共享 Pagination。
## 验证命令
```bash
cd zhixing-web
pnpm exec vitest run src/features/sector-radar/api/sector-radar.query.test.ts src/features/sector-radar/pages/sector-radar-page.test.tsx
pnpm check
pnpm build
cd ..
git diff --check
```
## 回滚点与风险
双榜由两条独立 HTTP 请求组成,不保证同一数据库事务快照;两条请求使用同一交易日与查询条件,且排名发布不可变时结果一致。若后续服务允许发布过程中变更,需要再引入 publication id 固定或后端双榜原子响应,本轮不扩大契约。
镜像表格列数多,桌面窄视口不可避免横向滚动;验收重点是列宽与表头稳定、文本不换行和单一滚动容器,而不是压缩所有列到任意宽度。
@@ -0,0 +1,47 @@
# 板块资金雷达双榜交互对齐
## Goal
把知行项目的板块资金雷达从“筛选器 + 单边普通表格”进一步对齐 OneChartLab 原站的核心浏览方式,让用户在同一屏内通过 Tab 切换板块类型和排序指标,并横向对照强榜与弱榜的流入、流出表现。
用户价值是减少筛选与翻页操作,在一个连续滚动榜单中直接比较资金进入端和撤离端,同时保持现有知行独立指标及数据质量边界。
## Background
- 当前页面已经支持服务端分页上的无限滚动,但仍以 Select 选择板块类型、指标视角和榜单范围,并只展示单侧榜单。
- 用户提供的目标截图显示:搜索框后使用“概念 / 行业”Tab,右侧使用指标 Tab;主体表格同时呈现左侧强榜和右侧弱榜,中间用“流入 / 流出”两列形成视觉对照。
- 本轮不使用 `lark-design-prototype`,以用户截图、现有项目视觉体系和实际数据契约为准。
## Requirements
- 将“概念板块 / 行业板块”从 Select 改为可直接点击的 Tab,并与 URL 状态、数据查询及刷新行为保持同步。
- 将指标视角改为 Tab 交互,至少覆盖现有的波段资金率、单日资金率、单日净额和排名变化;排名变化所需的指标与对比区间仍需有紧凑的附属控制。
- 强榜与弱榜必须在同一个连续滚动列表中成对展示,不再要求用户通过“榜单范围”筛选器切换。
- 表头结构、左右方向和列对齐参照目标截图:左侧为资金进攻榜,右侧为资金撤离榜,中间明确区分流入和流出;强榜从左向中间阅读,弱榜从中间向右阅读。
- 双榜外围只使用知行已有且可验证的排名百分位、样本、资金覆盖率和质量字段;左右按镜像顺序排列,并以固定列宽或等价布局保证表头和行数据对齐。
- 流入和流出数值使用方向明确、可比较的正负与色彩表达,并保持文本语义和可访问性,不只依赖颜色区分。
- 波段资金率、单日资金率和单日净额视角的中间列使用“流入 / 流出”;排名变化视角使用与排名升降一致的表头和数值语义,不把排名变化伪装成资金流量。
- 保留表头吸附、连续滚动加载、首次加载、后台刷新、部分发布、加载更多失败重试、空数据和致命错误等现有行为。
- 不新增与目标交互无关的大块说明卡、指标墙或辅助面板。
## Acceptance Criteria
- [ ] 用户可通过“概念 / 行业”Tab 切换板块类型,激活状态清晰且 URL 查询状态同步更新。
- [ ] 用户可通过指标 Tab 切换波段资金率、单日资金率、单日净额和排名变化,当前指标与中间流入/流出表头一致。
- [ ] 每个可见数据行同时展示一个强榜板块和一个弱榜板块,左右榜单分别保持各自排名顺序。
- [ ] 表头和数据列在桌面视口下稳定对齐,左右镜像列均展示排名/板块、排名百分位、样本、资金覆盖率和质量,中间流入/流出区域与两侧之间有明确边界,横向滚动时结构不塌陷。
- [ ] 中间指标值按当前视角正确格式化:净额以亿元显示,比例以百分比显示,排名变化以升降名次数显示;强弱方向有显式正负号或文字语义。
- [ ] 滚动接近底部时强榜与弱榜继续加载下一批,任一侧提前结束时另一侧仍可继续展示,直到两侧都加载完成。
- [ ] 加载更多失败时已加载的两侧数据不丢失,并提供可操作的重试入口。
- [ ] 不再显示“榜单范围”Select、底部分页器或大块纯描述卡。
- [ ] 相关前后端契约测试、前端行为测试、格式检查、lint、类型检查、完整测试和构建通过,并完成真实浏览器交互与布局检查。
## Out of Scope
- 复刻原站未在知行数据契约中存在的股票勾选、加权评分或自选功能。
- 更改板块指标公式、排序定义、数据构建任务或历史数据口径。
- 引入与本轮交互无关的新页面模块。
## Deferred Items
- 原站外围的“涨跌幅、加权评分、在榜天数”本轮不实现。当前知行排名持久化与 HTTP row 均没有这三个字段,其中加权评分的公开证据不足以确认公式;后续需分别讨论指标语义、输入时点、计算公式、持久化和历史兼容后再立项。
@@ -0,0 +1,26 @@
{
"id": "sector-radar-dual-ranking",
"name": "sector-radar-dual-ranking",
"title": "板块资金雷达双榜交互对齐",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,10 @@
{"file":".trellis/spec/backend/selection.md","reason":"核验 qfq、目标日截断、KDJ 和 selection 语义"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"核验新增 HTTP 响应与错误契约"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验 Ruff、Pyright、pytest 和后端代码边界"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"核验布局、状态、可访问性与组件边界"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"核验 React Query、AbortSignal 和同源请求"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"核验前端 API 类型与 strict TypeScript"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核验 Vitest 行为覆盖和完整前端门禁"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"核验跨层字段、状态和测试没有遗漏"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"核验 ECharts 三轴缩放、按需模块和生命周期"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"核验图片映射、below-threshold 和详情接口缺口均已闭环"}
@@ -0,0 +1,73 @@
# 选股模块图表展示技术设计
## 1. 边界与总体方案
本任务继续使用现有 `selection` bounded context,不新建业务上下文。分页结果接口继续只承载列表与目标日摘要;新增独立只读详情接口按 `ts_code + target_trade_date` 返回最多 250 个升序 qfq 日线点及对应 KDJ,避免每页股票都携带大数组。
前端 selection feature 用独立 React Query 缓存详情图表。从现有 `md` 双栏断点起,工作台统一使用左 `1fr`、右 `3fr`,删除更大断点的旧比例覆盖;移动端沿用现有记录卡和展开交互,不在本次增加移动端复合图表。右侧详情依次展示股票摘要、复合图表、最佳案例 JPG、评分分解和信号指标。
## 2. 后端数据流与契约
新增 application 用例 `GetSelectionChart`,依赖已有 `MarketDataReader`。用例接收 `ts_code`、`target_trade_date` 和固定上限 250,读取目标日前完整 qfq 历史,在完整历史上复用 `compute_kdj` 计算递归 K/D/J,再把 OHLCV 与指标对齐并截取末尾最多 250 条。这样返回量受控,同时 KDJ 初值与策略计算保持一致。
新增接口:
```text
GET /api/v1/selection/stocks/{ts_code}/chart?target_trade_date=YYYY-MM-DD
```
成功响应:
```json
{
"ts_code": "603259.SH",
"name": "药明康德",
"target_trade_date": "2026-08-31",
"source_adj": "qfq",
"points": [
{
"trade_date": "2026-08-31",
"open": 1.0,
"high": 1.0,
"low": 1.0,
"close": 1.0,
"volume": 1.0,
"k": 50.0,
"d": 50.0,
"j": 50.0
}
]
}
```
OHLCV 或 KDJ 不可用的点用 `null` 保留日期对齐,不制造数值。前端遇到 OHLC 不完整的日期时保留 category 但不绘制该蜡烛,volume/K/D/J 也保持空点,KDJ 禁止跨空点连线。没有任何目标日前行情时返回 `404 chart_data_not_found`;数据库读取失败返回 `503 selection_storage_unavailable`。接口继续使用 FastAPI Pydantic response model 和同源 `/api/v1` 路径。
## 3. 最佳案例评分与图片
不改变十案例评分算法、阈值、排序或持久化。调整 HTTP 映射:只要评分状态是 `matched` 或 `below_threshold` 且内部结果完整,都返回实际 `value`、`threshold`、`version`、`case` 和 `breakdown`;状态仍保留原值,因此 UI 可以区分“达到阈值”和“最接近但低于阈值”。评分失败返回 `status="failed"` 且无 case;评分未执行沿用现有 `score: null` 契约,前端明确显示“本次运行未执行图形评分”,两者均不展示案例图。契约测试锁定 `null` 的既有语义,避免把字段遗漏误当成功结果。
把用户提供的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/` 并保留原文件名。selection feature 维护穷举的 `case.id -> public URL` 映射;案例 ID 是稳定业务键,中文名只用于展示。若响应出现未知 case id,页面显示“案例图片暂不可用”,不猜测文件名。
## 4. 前端数据与组件
新增 `SelectionChart`、`SelectionChartPoint` 类型,API 函数通过 `requestJson` 调用详情接口并传递 `AbortSignal`,query key 包含股票代码和目标交易日。仅当存在选中股票时启用查询;切换股票时 React Query 取消或隔离旧请求,详情分别呈现加载、失败、空数据和成功状态。
新增 selection feature 内的复合图表组件,直接使用 `echarts/core`,按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom 和 Canvas renderer。三组 category x 轴共享相同交易日数组,candlestick、volume、K/D/J series 分别绑定三个 grid;inside 与 slider dataZoom 同时控制三组 x 轴。后端最多返回 250 点,前端通过 category `startValue`/`endValue` 精确选择末尾 120 点,不足 120 点时展示全部,并允许缩放查看返回的全部数据。
ECharts 初始化只发生在图表 DOM 挂载后;数据变化调用 `setOption`,容器变化通过 `ResizeObserver` 调用 `resize`,卸载时 `dispose`。option 构造与 API 数据转换保持为纯函数,避免在 jsdom 中依赖 Canvas 像素渲染。
最佳案例组件从 `score.case.id` 查找静态 JPG,使用语义标题、描述性 `alt`、固定 2:1 比例和 `loading="lazy"`。图像加载失败时保留案例名称和可读错误状态。
## 5. 兼容性与取舍
- 不把历史序列扩展到 `/selection/results`,避免分页和轮询响应膨胀。
- 不新增图片数据库、上传接口或动态生成逻辑;案例库固定,因此静态资源映射是最小充分方案。
- 不引入 React ECharts wrapper,减少 React 19 兼容面;只增加 `echarts` 一个运行时依赖。
- 移动端暂不加载大图表,维持现有详情展开行为;桌面端按需求提供完整详情。
- 不新增迁移。最佳案例字段已经持久化,图表读取复用现有 market data。
## 6. 风险、回滚与验证重点
主要风险是 KDJ 窗口被错误截断、股票切换显示上一只股票的数据、ECharts 容器未 resize/dispose、缺失行情点错误连线、未知案例 ID 产生错误图片,以及低于阈值的最佳案例仍被 HTTP 丢弃。测试分别锁定完整历史计算后截断、query key、加载/错误/空状态、三轴同步 option、缺失点处理、静态映射完整性和 below-threshold 契约。案例图片行为测试必须断言详情中恰有一张 `<img>`,其 `src` 由返回的 `case.id` 映射,案例名称和评分与同一响应一致;低于阈值也执行同一断言。
回滚可以按层撤销:删除新增详情接口与用例、恢复评分 HTTP 映射、移除 ECharts 组件/依赖和静态 JPG,再恢复原 grid class;没有数据库迁移或不可逆数据写入。
@@ -0,0 +1,14 @@
{"file":".trellis/spec/backend/index.md","reason":"后端 selection bounded context、HTTP 契约与质量入口"}
{"file":".trellis/spec/backend/selection.md","reason":"qfq 历史读取、目标日边界、KDJ 与选股结果契约"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"新增详情接口的 Pydantic、路由和同源 API 约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"图表空数据与存储失败的错误映射"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Python 类型、文档字符串与测试门禁"}
{"file":".trellis/spec/frontend/index.md","reason":"React feature 分层和质量入口"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"详情组件、Tailwind、页面状态与可访问性"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"requestJson、AbortSignal、React Query key 与状态归属"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"跨层响应类型与 strict TypeScript 约束"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Vitest 行为测试和前端质量门禁"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"复用 reader、requestJson、query key 和 UI primitive"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"后端响应到前端 query/页面的完整契约链"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"ECharts 复合金融图、dataZoom 和生命周期方案"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"现有数据能力、最佳案例图片和契约缺口"}
@@ -0,0 +1,36 @@
# 选股模块图表展示实施计划
## 1. 后端图表详情用例与契约
- [x] 在 selection application 层新增 `GetSelectionChart` 及具名返回模型,复用 `MarketDataReader.load_history` 和 `compute_kdj`,在完整目标日前历史上计算后截取末尾最多 250 点。
- [x] 为用例补充单元测试,覆盖升序输出、目标日截断、超过 250 点、KDJ 对齐、空历史和可空值。
- [x] 在 selection HTTP 层新增 `GET /stocks/{ts_code}/chart` 的 Pydantic response model、依赖组合和错误映射。
- [x] 扩展 HTTP 测试,覆盖成功、404 空行情、503 存储错误、日期参数校验和完整响应字段。
## 2. 最佳案例契约与静态资源
- [x] 调整 `_pattern_score_response`,让 `below_threshold` 与 `matched` 一样返回真实 value/case/breakdown,同时保留状态与阈值语义;补充后端 HTTP 测试。
- [x] 将用户提供目录中的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/`,保留文件名,并核对十个 case id 均有且仅有一个资源。
- [x] 在 selection feature 中新增穷举案例图片映射和展示组件;测试断言匹配和低于阈值时详情恰有一张 `<img>`,其 `src`、案例名称和分数来自同一个 `case.id`,并覆盖未知案例、图片加载失败、`score: null` 未评分和评分失败状态。
## 3. 前端详情数据链路
- [x] 在 `selection.types.ts` 定义图表响应类型,在 `selection.api.ts` 增加同源详情请求,在 `selection.query.ts` 增加包含股票代码和目标日的 query key/hook,并传递 `AbortSignal`。
- [x] 为 API URL 编码、查询参数和 query 启用条件补充窄测试。
- [x] 在工作台/详情组件中接入图表 query,保证选择切换不会显示旧股票数据,并呈现加载、错误、空数据和成功状态。
## 4. ECharts 复合图表与布局
- [x] 用 pnpm 添加 `echarts` 运行时依赖并更新 lockfile,不引入 React wrapper。
- [x] 新增 option 纯构造函数和图表组件;按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom、Canvas renderer,绑定三组 grid/axis/series。
- [x] 实现最多 250 点、用 category `startValue`/`endValue` 精确显示最后 120 点(不足时全量)的 inside + slider 同步缩放,以及 ResizeObserver resize 和卸载 dispose。
- [x] 用纯函数测试锁定 `[open, close, low, high]` 顺序、volume/KDJ 对齐、缺失 OHLC 不绘制蜡烛、空 KDJ 不跨点连线、三个 xAxis 索引和精确 120 点初始范围;页面行为测试断言可见状态与可访问名称。
- [x] 从 `md` 双栏断点起将 grid 统一改为左 `1fr`、右 `3fr`,移除 `lg` 的旧比例覆盖并保持移动端现有记录卡行为;在图表正下方展示最佳案例 JPG,再展示评分分解和关键指标。
## 5. 验证与回滚点
- [x] 运行后端窄测试:`uv run --directory zhixing-server pytest tests/unit/selection tests/test_selection_http.py -q`。
- [x] 运行前端窄测试:`pnpm --dir zhixing-web test -- selection`,并运行 `pnpm --dir zhixing-web typecheck`。
- [x] 运行完整质量门禁:`./dev.sh check`、`./dev.sh test`,再运行 `pnpm --dir zhixing-web build` 验证产物。
- [x] 使用本地页面核对 1:3 布局、药明康德/方正科技案例图、股票切换、缩放同步和详情滚动;当前数据库未用于验收,已使用确定性 Mock API 与真实浏览器核对。
- [x] 回滚前先确认无数据库迁移;按“HTTP/用例、前端 query/组件、静态资产与依赖、grid class”顺序撤销即可恢复旧行为。
@@ -0,0 +1,37 @@
# 选股模块图表展示迭代
## Goal
提升选股模块详情页的信息密度和技术分析能力,使用户可以在查看选股结果时直接核对股票的日线走势、成交量、KDJ 指标,并查看该股票最高相似度案例对应的参考图。
## Background
当前需求由用户明确提出,包含列表与详情页布局调整、详情页技术图表扩展,以及最佳匹配案例图片展示。当前桌面工作台实际是左宽右窄的两列布局,详情响应只有目标交易日标量,不包含历史序列。PostgreSQL 已保存按交易日升序的 qfq 日线 OHLCV,selection 领域已有 KDJ 计算,因此完整技术图表的数据基础已经具备,但需要新增只读详情契约。
仓库另有固定的十个图形匹配案例,评分器会把选中股票逐一与十个案例比较并保留最高分案例。对应的十张 `2800x1400` JPG 位于 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases`,文件中的 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 与当前代码案例定义一一对应。用户已明确:每只股票只展示最高相似度案例对应的一张 JPG;例如药明康德最高匹配方正科技时,展示方正科技 JPG。
## Requirements
- 选股模块从现有 `md` 桌面双栏断点起,左侧列表与右侧详情区域的宽度比例调整为 `1:3`,更小视口保持现有移动布局。
- 右侧详情区域增加一张完整技术图表,至少包含日线 K 线、成交量和 KDJ 三部分。
- 技术图表下方展示当前股票最高相似度案例对应的一张 JPG,并同时保留可读的案例名称和评分信息。
- 每个已完成评分的股票都应返回最高相似度分数和最佳案例,即使该分数低于业务匹配阈值;评分失败或未执行时不伪造案例图片。
- 可以引入与现有前端技术栈兼容的第三方图表库,但应控制新增依赖和改动范围。
- 图表行情必须使用目标交易日及之前的 qfq 日线,不能读取目标日之后的数据。
- 详情接口最多返回目标交易日前 250 个交易日;图表默认聚焦最近 120 个交易日,并允许用户缩放查看接口返回的全部数据。
## Acceptance Criteria
- [x] 从 `md` 双栏断点起,左侧列表和右侧详情区域呈现 `1:3` 的可用宽度比例,且不存在更大断点覆盖回旧比例。
- [x] 选中一只股票后,右侧能够展示与该股票对应的日线 K 线、成交量和 KDJ 指标,三部分时间轴或数据窗口保持一致。
- [x] 图表数据不超过目标交易日前 250 个交易日,首屏聚焦最近 120 个交易日;缩放操作同步作用于日线、成交量和 KDJ 三个区域。
- [x] 图表下方只展示当前股票最高相似度案例对应的一张 JPG;图片、案例名称和评分属于同一个最佳案例,药明康德匹配方正科技的场景会展示方正科技 JPG。
- [x] 低于阈值但评分成功的股票仍展示实际最高分、最佳案例及对应 JPG;未执行或失败时展示明确状态且不显示错误图片。
- [x] 页面在加载、空数据和接口失败时不会崩溃,并给出符合现有页面风格的状态反馈。
- [x] 相关前后端静态检查和自动化测试通过,且没有破坏现有选股列表与详情交互。
## Out of Scope
- 暂不改变选股算法、图形相似度评分规则或股票筛选逻辑。
- 暂不增加用户自定义技术指标、画线工具或交易下单能力。
- 暂不动态生成案例图片,也不展示最佳案例之外的其他九张 JPG。
@@ -0,0 +1,36 @@
# Apache ECharts 复合行情图研究
## 结论
本任务使用 `echarts` 本体并通过 `echarts/core` 按需注册,不引入 React 包装库。一个 ECharts 实例通过三个 `grid`、三组 category `xAxis`/value `yAxis`,以及分别绑定索引的 candlestick、bar、line series 组合日线、成交量和 KDJ。`inside` 与 `slider` 两个 `dataZoom` 同时控制三个 x 轴,使三个区域保持同一数据窗口。
前端组件在 DOM 挂载后调用 `echarts.init` 和 `setOption`,容器尺寸变化时调用 `resize`,卸载时调用 `dispose`。配置与数据转换应提取为纯函数,便于在 Vitest/jsdom 中验证 series、axis、数据顺序和初始缩放,而不依赖 Canvas 像素结果。
## 所需模块
- charts:`CandlestickChart`、`BarChart`、`LineChart`
- components:`GridComponent`、`TooltipComponent`、`LegendComponent`、`DataZoomComponent`
- renderer:`CanvasRenderer`
- 类型:使用 ECharts 提供的 `ComposeOption` 组合已注册组件的 option 类型
## 数据约定
- K 线数据顺序为 `[open, close, low, high]`。
- 日线、成交量、K、D、J 共用升序交易日 category 数据。
- 后端最多返回 250 点;前端用 category 的 `startValue` 和 `endValue` 精确选择最后 120 个点,不足 120 点时展示全部。
- 三组 series 通过 `xAxisIndex`/`yAxisIndex` 绑定各自 grid;两个 dataZoom 的 `xAxisIndex` 同时包含 `[0, 1, 2]`。
- OHLC 不完整的日期使用 ECharts 缺失数据占位,不绘制蜡烛;volume/K/D/J 的空值保持空点,KDJ series 设置 `connectNulls: false`,禁止跨缺失日期连线。
## 资料来源
2026-09-01 通过 Context7 查询官方 Apache ECharts 文档 `/apache/echarts-doc`。相关官方资料包括:
- `en/tutorial/data-zoom.md`:slider 与 inside dataZoom 组合,以及用 axis index 控制指定轴。
- `en/option/component/data-zoom.md`:一个 dataZoom 同时控制多个轴。
- `slides/arch-brief/asset/ec-demo/list-sample1.html`:candlestick、volume、多 grid、多 xAxis/yAxis 和 dataZoom 的金融图表示例。
- `en/tutorial/basic-concepts-overview.md`:`echarts.init`、option、多个 axis/series 与 `setOption` 的基本实例生命周期。
- `zh/tutorial/whats-new-in-echarts-v5.md`:按需引入时通过 `ComposeOption` 获得严格 TypeScript 类型。
## 本仓库约束
前端为 React 19、TypeScript strict、Vite 8;服务器状态应通过 TanStack Query 获取,API 走同源 `/api/v1`。图表属于 `selection` feature,不提升为 shared primitive。
@@ -0,0 +1,22 @@
# 选股详情图表仓库证据
## 当前页面与契约
- `zhixing-web/src/features/selection/components/selection-results-workbench.tsx` 当前桌面布局左宽右窄,目标应改为左 `1fr`、右 `3fr`。
- `zhixing-web/src/features/selection/components/signal-detail-panel.tsx` 当前只展示目标日价格、信号、评分和指标,没有历史图表或案例图片。
- `zhixing-web/src/features/selection/api/selection.types.ts` 与后端 `SelectionStockResponse` 只有目标日标量,没有历史 OHLCV/KDJ 序列。
- 现有前端依赖没有图表库。
## 行情与指标能力
- `PostgresMarketDataReader.load_history` 已按 `source_adj = 'qfq'`、`trade_date <= target_trade_date` 升序读取 OHLCV。
- `StockHistory`/`SelectionBar` 已描述存储无关的升序日线。
- `compute_kdj` 已实现与策略一致的 K、D、J 计算。本任务应在完整目标日前历史上计算后再截取末尾最多 250 点,避免只用返回窗口重新初始化 KDJ 状态。
- 现有 selection HTTP 只有 runs/results;复合图表需要独立的股票详情只读接口,避免把大序列塞进分页列表响应。
## 最佳案例图片
- 当前 `ZHIXING_B1_PATTERN_CASES` 是 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 共十个案例。
- 用户提供目录 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases` 含对应的十张 `2800x1400` JPG;文件名中的 case id、股票代码、名称与日期与当前代码定义一一对应。
- 评分结果已持久化最高案例。HTTP 对 `matched` 返回 `case.id/name/breakout_date`,但 `below_threshold` 当前丢弃实际 value/case/breakdown;本任务需要让所有成功评分状态保留这些真实结果。
- 图片作为本前端固定静态资产按 `case.id` 映射,不需要数据库表、图片上传或后端文件服务。
@@ -0,0 +1,26 @@
{
"id": "stock-chart-display",
"name": "stock-chart-display",
"title": "选股模块图表展示迭代",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,5 @@
{"file":".trellis/spec/backend/selection.md","reason":"复核策略语义、目标日边界、批量读取和持久化契约"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"复核数据来源、qfq 口径和目标日 daily_basic 完整性"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"复核沪深非 ST 股票范围未扩大"}
{"file":".trellis/spec/backend/error-handling.md","reason":"复核日志可诊断且不泄露敏感配置"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"复核前后端策略标识和展示类型一致"}
@@ -0,0 +1,6 @@
{"file":".trellis/spec/backend/selection.md","reason":"历史选股策略、qfq 读取、运行持久化与策略扩展约束"}
{"file":".trellis/spec/backend/market-data-sync.md","reason":"目标日行情与 daily_basic 数据可用性契约"}
{"file":".trellis/spec/backend/tushare-listed-stock-universe.md","reason":"当前上市沪深非 ST 股票范围约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"可复制诊断日志与错误边界约束"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"selection bounded context 分层与导入方向"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"新增策略标识的前端联合类型与接口契约"}
@@ -0,0 +1,40 @@
# 新增金砖共振选股策略
## Goal
在现有历史选股能力中增加“金砖共振”独立策略,按收盘后、Tushare qfq 日线、当前上市沪深非 ST A 股口径运行,让用户能够在线上完整数据上执行并通过日志定位失败股票和数据问题。
## Background
- 原始公式来自 `../zgnb-project/docs/references/formulas/金砖共振选gu(通达信).txt`。
- 公式使用日线 OHLCV、股票代码、通达信 B1 七类子信号、KDJ、3 日 RSI、趋势线、砖型图、动能和目标日换手率。
- 项目已同步六年 qfq 日线和 `daily_basic.turnover_rate`,并已实现 B1 七类子信号及通达信风格指标。
- 通达信 `DYNAINFO(37) >= 0.0099` 对应百分数口径换手率至少 `0.99`;Tushare `turnover_rate` 直接使用百分数口径。
## Requirements
1. 新增独立、可执行、可持久化和可查询的金砖共振策略,不改变现有 `zhixing_b1` 语义和结果。
2. 金砖策略复用现有 B1 指标与七类子信号计算,并补充原公式中的砖型图、黄柱、X 动能、强红、趋势、上影线、换手率和两类共振条件。
3. 策略只读取目标交易日及之前的数据,价格口径固定为 qfq,股票范围继续使用当前上市沪深非 ST A 股。
4. 批量选股读取必须为每只股票提供目标交易日的 `turnover_rate`;缺失换手率时该股票不得产生金砖信号,并记录可定位原因。
5. 金砖策略至少要求 200 根升序日线;目标日行情缺失、历史不足、换手率缺失、公式结果无效或单股计算异常时,必须形成明确状态或错误原因。
6. 在策略准备、批量读取、批量计算、结果持久化和失败收敛位置增加包含策略名、目标交易日、批次、股票代码、历史行数、换手率状态、结果状态及异常类型的结构化日志。日志不得包含 Token、数据库连接串或完整异常敏感上下文。
7. 前后端沿用现有策略选择、执行、进度和结果查看流程,并向用户展示“金砖共振”策略名称。
8. 本次不执行测试、lint、type-check、构建或线上数据请求;由用户部署到线上后提供日志进行后续排查。
## Acceptance Criteria
- [ ] 用户能在现有选股入口选择并执行“金砖共振”,运行记录和查询接口使用稳定的独立策略标识。
- [ ] 符合原公式最终 `买入条件` 的股票被选中,不符合、缺少目标日换手率或历史少于 200 根的股票不会被误选。
- [ ] 全市场批量执行能够读取目标日 `turnover_rate`,并将通达信阈值正确换算为 Tushare 的 `0.99` 百分数口径。
- [ ] 现有 `zhixing_b1` 仍走原有公式和结果契约,不因新增换手率读取或策略路由发生语义变化。
- [ ] 线上运行发生准备失败、读取失败、批次失败或单股失败时,日志能够通过策略名、目标交易日、批次与股票代码关联完整路径,且不泄露敏感配置。
- [ ] 本地未运行任何测试或验证命令,最终交付明确列出未验证风险和建议复制的日志范围。
## Out of Scope
- 盘中实时行情和实时预警。
- 北交所、ST、退市股票或无幸存者偏差历史股票池。
- 未复权、后复权或多复权口径切换。
- 新增 Tushare 数据接口、财务数据、资金流、板块或涨跌停条件。
- 调整原始公式参数或优化策略收益表现。
@@ -0,0 +1,26 @@
{
"id": "add-gold-brick-strategy",
"name": "add-gold-brick-strategy",
"title": "新增金砖共振选股策略",
"description": "按收盘后、qfq、沪深非ST口径新增金砖共振策略,复用B1指标并增加可复制诊断日志。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P1",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-04",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,44 @@
# 选股页面迭代:布局重构与细分行业筛选
## 需求
1. 选股页面从左右布局改为「上搜索栏 + 左列表 / 右详情」三段式布局。
2. 搜索模块增加细分行业筛选:下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序。
3. 选股模块不展示「板块」(概念板块):列表筛选与详情页均去掉;详情页「行业」口径改称「细分行业」。(2026-09-05 修订,原口径为概念板块)
## 关键决策
- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器方法签名不再携带 sector_type,适配器固定传 `SectorType.INDUSTRY`;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。
- 详情面板 membership 消费面收窄到 industries:前端 `sector-radar.api.ts` / 类型不再解析 concepts 字段(后端 `/sector-radar/stocks/{ts_code}/membership` 契约仍返回 concepts,供板块雷达等其余消费方使用)。
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
## 实现
后端(zhixing-server):
- sector_radar:`domain/persistence.py` 新增 `SectorCountEntry` + 2 个协议方法;`infrastructure/postgres.py` / `infrastructure/memory.py` 实现 `load_sector_counts` / `load_sector_member_codes`;`application/read.py` 新增 `sector_counts` / `sector_member_codes`(先解析 last-good publication)。
- selection:`domain/runs.py` 新增 `SelectionSectorReader` 端口、`SelectionSectorCount` / `SelectionSectorMembership` / `SelectionRunIdentity`,`SelectionResultQuery.sector`;`application/run.py` 新增 `list_sector_counts`(固定 industry),`get_run`/`get_latest` 经 identity → 成员代码 → `sector_stock_codes` 过滤;`infrastructure/postgres_runs.py` `_stock_filter` 支持 `ts_code = ANY(...)` / FALSE;`infrastructure/sector_membership.py` 桥接适配器;`presentation/http.py` 新增 `GET /sectors`,results/runs 增加 `sector` 参数。
前端(zhixing-web):
- `selection.types.ts` / `selection.api.ts` / `selection.query.ts`:`SelectionSectors`(sector_type 收窄为 "industry")、`getSelectionResultSectors`、`useSelectionResultSectors`(不传 sector_type),query key 加 sector,结果失效同时失效 sectors。
- `route-tree.tsx`:selection 路由 search 增加 `sector`。
- `selection-results-page.tsx`:结果查询带 sector,页面调用 sectors hook 并下传 workbench;切策略重置 sector。
- `selection-results-workbench.tsx`:布局重构为上搜索栏 + 下方 320px 列表/详情两栏;细分行业下拉(全部细分行业 + 聚合选项带数量徽标);聚合加载后自动清掉失效 sector。
- `signal-detail-panel.tsx`:展示「细分行业:…」(title 提示成分快照日期),不再展示概念板块 chips。
## 测试
- 后端:`test_read.py` +8、`test_sector_filter.py` 新建 9 个(口径 industry)、`test_selection_http.py` +3(不再有 sector_type 转发/422 用例)。
- 前端:页面测试新增「filters by sub-industry and shows per-industry counts」;详情面板测试改为细分行业断言并删除概念 chips 用例。
## 验收
- [x] 页面布局为上搜索栏 + 左列表右详情(移动端纵向堆叠 搜索→列表→详情)
- [x] 细分行业下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序
- [x] 选择细分行业后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
- [x] 详情面板只展示细分行业,不展示概念板块
- [x] 后端 ruff/pyright/pytest 与前端 lint/typecheck/test/build 门禁通过(遗留项均为基线原有)
## 遗留(均为基线原有,非本次引入)
- 后端 pyright 16 个错误(chart.py/gold_brick.py pandas 相关、test_run.py 旧 fake 类型);前端 4 个执行状态抽屉测试失败;前端 format:check 有基线未格式化文件(.playwright-cli、sector-radar 部分文件;signal-detail-panel.tsx 已随本次格式化移除)。
@@ -0,0 +1,26 @@
{
"id": "selection-layout-sector-filter",
"name": "selection-layout-sector-filter",
"title": "选股页面迭代:布局重构与板块筛选",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-05",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,6 @@
{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"}
{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"}
@@ -0,0 +1,21 @@
# 资金雷达单日详情设计
## 数据边界
继续使用 sector_radar bounded context。Tushare 原始响应先进入现有 snapshot 持久化链路,再扩展事实与查询投影;浏览器只访问同源 API,不直接请求 Tushare 或参考站点。扩展板块日事实保存 pct_change 与领涨标识,股票事实保存 daily.pct_chg 和独立 active_buy_net_amount_yuan。新字段可空,旧发布保持可读。moneyflow 作为详情可选来源,不将其失败当成主力净额为零;与原始 snapshot、发布日期和输入版本关联。
## 查询契约
rankings 在原字段上增加 pct_change、daily_net_amount_yuan、daily_ratio 和对应侧的 30 日在榜次数;领域 percentile/coverage 保留,仅移除两种视角的展示列。避免逐板块查询历史,按日期和类型批量加载历史排名,按每日池的 TOP/BOTTOM 阈值计数。
新增板块 history 与 detail 读取接口,键包含 sector_type、sector_code、trade_date;返回解析后的 publication 标识和实际日期。history 含日期、三指标排名、当日池规模/百分位和缺失状态,严格截至目标日,最多 30 个交易日;同日选最新成功发布,历史版本不兼容时不混用。详情包含当日指标摘要、成员三种强弱数据、成员列表和相似板块。共享历史读取实现,避免重复计算。
## 指标
沿用现有主力净额/成交额流入率及波段策略。板块涨跌幅优先使用 dc_index 源字段;成员 daily.pct_chg 独立于资金流是否缺失。主买净额来自 moneyflow.net_mf_amount,须核对官方单位后转为元,与主力 net_amount 分开。重合度为交集数量/并集数量,使用同日可确认的当前上市母集成员;未知成员不参与,空并集不计算。同分稳定按代码排列。
## 前端
将两种单日榜列配置与其他视角隔离;拆分历史格子和详情弹窗组件,沿用项目 UI primitive 和 ECharts。详情请求按需触发,query key 包含类型/代码/目标日,切换日期时不显示前一天的详情。格子按日期从近到远,曲线按时间从早到晚且排名 1 在上方,缺失处断线。导出及复制仅使用 API 返回成员,生成本地 CSV/文本,CSV 对不可信字段进行转义与公式注入防护。
## 兼容与回滚
采用增量 nullable 数据迁移,不删除原字段。旧发布缺详情时显式显示暂无数据;可通过重新构建目标日补齐,不在读取路径触发网络取数。新增来源失败保留最近有效发布并体现详情缺失。真实自测使用隔离的本地数据库或受控测试批次,不覆盖完整发布。若迁移/验证失败,停止新构建并保留原始快照,回退代码前确认旧代码可读取新增 nullable 列。
## 实施前核验
确认 moneyflow 官方单位、当前采集权限和现有 source 失败策略;确定具体 migration 编号与可复用 UI primitive。原站私有算法不构成本次完成条件。真实全池历史不足仅报告覆盖,不人为补齐。
@@ -0,0 +1,6 @@
{"file": ".trellis/spec/backend/market-data-sync.md", "reason": "Tushare 落库及同步契约"}
{"file": ".trellis/spec/backend/tushare-listed-stock-universe.md", "reason": "当前上市母集约束"}
{"file": ".trellis/spec/backend/http-api-contracts.md", "reason": "详情与排名API"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "弹窗与榜单组件"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "按日期的查询状态"}
{"file": ".trellis/tasks/09-06-capital-radar-daily-detail/design.md", "reason": "本次数据及交互设计"}
@@ -0,0 +1,17 @@
# 实施与验证计划
- [x] 用户审核当前规划后,加载 Phase 1.3/1.4,校验上下文清单并进入 in_progress。
- [x] 读取受影响层规范和确切修改代码,确认 Tushare moneyflow 字段单位、权限及本地隔离数据库方案。
- [x] 扩展 source、事实和 PostgreSQL migration,保存板块涨跌幅与成分行情/主买净额,保持旧发布可读;更新 memory repository。
- [x] 增加批量历史/详情读取和 HTTP 响应,补充单日榜附加字段、在榜次数、三指标历史、前后5名、成员及重合度。
- [x] 调整前端 API/types/query,完成两种单日列、名称入口、在榜展开、详情弹窗与复制/导出。
- [x] 后端测试重点:单位转换、来源缺失不归零、同日发布去重、30交易日边界、无未来数据、每天排名池变化、相似度与历史成员;运行 `cd zhixing-server && uv run pytest tests/unit/sector_radar tests/test_sector_radar_http.py`,有数据库时补集成测试 `tests/integration/test_sector_radar_repository.py`。
- [x] 前端测试重点:视角切换列名、左右板块指标、展开收起、日期切换、详情指标切换、少量样本、关闭/焦点和导出内容;运行 `cd zhixing-web && pnpm test src/features/sector-radar`。
- [x] 使用一个真实板块完成 Tushare 采集、落库、API核对,记录请求日期、条数、缺失及核对值,不记录密钥;不得将单板块测试发布为全市场榜单。
- [x] 后端运行 `uv run ruff format --check .`、`uv run ruff check .`、`uv run pyright`;前端运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm build`。按变更范围扩大回归,不重复无关检查。
- [x] 浏览器验证本地两个面板与弹窗、滚动及窄屏。核对 API 无请求时取 Tushare 的行为。
- [x] 主会话完成最终验证并汇报实际证据;不自动提交用户改动,不更新全局知识或 specs。
风险位置:sector_radar/application/build.py 的成功发布门、infrastructure/postgres.py 的快照/事实事务与新迁移、application/read.py 的日期回退。新增可选详情来源不应改变旧榜成功条件。
执行结果与全局既有检查例外详见 verification.md;勾选代表本步骤已执行,不表示全仓检查全部通过。
@@ -0,0 +1,23 @@
# 资金雷达:单日榜单与板块详情
## 目标与边界
尽可能复刻 OneChartLab 单日榜单和截图中的板块详情交互,支持解释当日资金强弱与历史持续性。所有业务数据通过 Tushare 采集落库,再由本地分析和 API 提供;参考站点仅用于交互研究。保留现有独立指标,不反演或新增原站未公开的加权评分,不改动波段榜及排名变化榜的算法,不提交已有用户改动。
## 已确认背景
当前排名接口没有涨跌幅及附加指标,见 `zhixing-server/src/zhixing_server/modules/sector_radar/presentation/http.py:83`。归一化事实只存成交额和主力净额,见同模块 `domain/persistence.py:101`。但采集已请求 `dc_index.pct_change/leading_code` 和 `daily.pct_chg`,见同模块 `infrastructure/tushare.py:40`。现有 3—10 日聚合及三指标排名可复用,30 日详情接口和前端交互尚未实现。
## 需求与验收
R1:单日流入率移除排名百分位和样本列,增加涨跌幅、净额、在榜;板块仅显示名称。按用户截图将“净值”解释为主力净额,金额以亿元显示。左右榜字段镜像排列,保留现有独立流入率排序。验收检查列名、数据、正负颜色和名称点击。
R2:单日净额移除排名百分位和样本列,增加涨跌幅、单日流入率;同样只展示板块名称。验收核对附加指标属于同一板块、同一交易日和同一发布版本。
R3:在榜为截至所选日近 30 个交易日内进入对应 TOP/BOTTOM 10% 的次数,不是连续天数。点击展开日期和当日排名格子,支持收起;分别用每日同类型排名池确定强弱。缺失记录显示缺失,不补零,不借用未来数据;不足 30 日标明覆盖范围。
R4:点击板块打开可滚动详情弹窗,包含名称、类型、代码、日期、领涨股、当日涨跌幅、波段/单日率/单日额排名摘要;三指标近 30 交易日轨迹可切换,悬停显示日期和排名;成分强弱支持涨跌幅、主力净额、主买净额和前后 5 名;提供相似板块、复制及导出成员。少于 5 个有效成员按实际数量展示;缺失字段不伪装为 0。弹窗支持关闭按钮、Escape、焦点返回和加载/失败/空状态。
R5:优先使用落库的 dc_index 板块涨跌幅,不以成分平均值静默替代。成分涨跌幅来自 daily,主力与主买净额分别保存。相似度使用同日成员集合 Jaccard 重合度,明确为本系统口径;最多显示四个非自身板块,允许概念与行业交叉比较。
R6:真实 Tushare 单板块自测先采集落库,再通过 API 读取并核对金额、涨跌幅和成员。单板块数据不能验证全市场排名或相似度,不得发布为完整排名池;全池排名/历史边界使用受控数据验证。缺权限或历史数据不足时记录实际限制,不伪造通过。
## 验证与风险
后端验证单位、成员日期、历史截止日、独立发布版本、缺失和在榜计数;前端验证两面板、展开、弹窗、切换和导出交互。真实调用前核实环境与权限,密钥不进入日志。新增详情来源失败不得让已有完整榜单丢失;旧发布缺字段可读且明确为空。当前仍处规划阶段,尚未进行产品修改或真实采集。
@@ -0,0 +1,62 @@
"""Collect one real sector into an isolated PostgreSQL snapshot store.
Run with the server's uv environment from zhixing-server. No production
publication is created; facts must be read back from this store for validation.
"""
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import json
import time
import tushare as ts
from zhixing_server.bootstrap.config import Settings
from zhixing_server.modules.sector_radar.domain.source import build_source_snapshot
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
def main():
"""Persist provider responses before inspecting their values; redact errors."""
settings = Settings(_env_file='../.env')
url = urlsplit(settings.database_url)
host = url.netloc.rsplit('@', 1)[0] + '@127.0.0.1:5433'
database = urlunsplit((url.scheme, host, '/radar_detail_selftest_0906', url.query, ''))
repository = PostgresSectorRadarRepository(database, max_connections=2)
client = ts.pro_api(settings.tushare_token)
target = date(2026, 9, 4)
counts = []
def collect(api, fields, **params):
"""Store each raw response atomically and return persisted rows."""
time.sleep(0.25)
frame = client.query(api, fields=fields, **params)
snapshot = build_source_snapshot(api_name=api, params=params,
rows=frame.to_dict('records'), target_trade_date=target)
repository.save_source_snapshots((snapshot,))
counts.append({'api':api, 'rows':snapshot.row_count, 'snapshot':snapshot.snapshot_id})
return snapshot.rows
try:
collect('dc_index','ts_code,trade_date,name,idx_type,level,pct_change,leading_code',
trade_date='20260904', ts_code='BK1147.DC')
members = collect('dc_member','trade_date,ts_code,con_code,name',
trade_date='20260904', ts_code='BK1147.DC')
collect('stock_basic','ts_code,symbol,name,market,exchange,list_status,list_date,delist_date',list_status='L')
collect('suspend_d','ts_code,trade_date,suspend_timing,suspend_type',trade_date='20260904')
collect('trade_cal','exchange,cal_date,is_open,pretrade_date',exchange='SSE',start_date='20260720',end_date='20260904')
for member in members:
code = member['con_code']
collect('daily','ts_code,trade_date,close,pre_close,pct_chg,vol,amount',trade_date='20260904',ts_code=code)
collect('moneyflow_dc','trade_date,ts_code,name,net_amount,net_amount_rate,pct_change,close',trade_date='20260904',ts_code=code)
collect('moneyflow','trade_date,ts_code,net_mf_amount',trade_date='20260904',ts_code=code)
result={'status':'collected','sector':'BK1147.DC','trade_date':str(target),'members':len(members),'snapshots':counts}
except Exception as exc:
result={'status':'partial','snapshots':counts,'error_type':type(exc).__name__,
'message':str(exc).replace(settings.tushare_token,'[redacted]')[:300]}
finally:
repository.close()
Path('../.trellis/tasks/09-06-capital-radar-daily-detail/research/collection-result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps({k:v for k,v in result.items() if k!='snapshots'},ensure_ascii=False))
print('Stored snapshots:',len(counts))
if __name__ == '__main__':
main()
@@ -0,0 +1,243 @@
{
"status": "collected",
"sector": "BK1147.DC",
"trade_date": "2026-09-04",
"members": 14,
"snapshots": [
{
"api": "dc_index",
"rows": 1,
"snapshot": "73f94be4b5c94238b611396b7cdf2e314f17cdd23532c3773febf3e3277f1674"
},
{
"api": "dc_member",
"rows": 14,
"snapshot": "2046df68966d3eda9a392b0254b84576842c42c487ea0a2f1044b6cc75de3afa"
},
{
"api": "stock_basic",
"rows": 5556,
"snapshot": "1c87ce65e081e670add1c1167473f798e97e8c9cf41db3e4ac71df0e3de5bb90"
},
{
"api": "suspend_d",
"rows": 8,
"snapshot": "b91d0981345538463407939a7ed863b8fb8de5e447b8ec731b8767b7ae13aa5d"
},
{
"api": "trade_cal",
"rows": 47,
"snapshot": "afff3cba955380cc0af9cdaac1d8861c17a70b089c70ea95aadb1cc568f8a26e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "4f2c4f92685180b3de23a26c9d0602a8acf3fedb7dbc2413ac92bda76d8fa65a"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "7c1907d19c2db8134a807edba87fe165b44a1e96d6b3731e65a26f52715f6f12"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "521254cf598c28608571973eeec1e52c56c856ee547bbd2d9ceacc4e1a29727e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "9bf1ca69c3be480113c933c25681dab1379e0a068bdf9a31452794de8c4bff94"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "8b81006f266edabf8ac1e2aa292e9b278ca9131eea47f86fbef89373197215f6"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "e90138a791bf7e3d458d3865838ac3aee394fe491ebf93cb363425852d1bca92"
},
{
"api": "daily",
"rows": 1,
"snapshot": "81ca417096a073698375daa03f0a660398d53c3943b16c9598a8faa918ff177f"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "40fb4ad0d2bbe8413e6dfcc213e8efe7f3367f84f492f68e430f1a9d08e9cc51"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "47d32d618d5d8a2118253b1e3d7b354628bae22a7b10880f4cfdd3ee370dae22"
},
{
"api": "daily",
"rows": 1,
"snapshot": "2418e65c6800ef5633e6f260cbe617eb3b5bbda4d71e16e56589cd1603827b6b"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "d3559b1413d931dfe882fa64f501b75c76c91125fa0fbe616b385e9a3b7c9e15"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "76a6e0cbe16e92c707af828a9936d87bd810844c357256c18e4d9ef10d86e40d"
},
{
"api": "daily",
"rows": 1,
"snapshot": "a3307443f9e89ff5523043055509dc309c6766067a5d9eb83bdef5d19e6e44c1"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "8d4c401bf8ade3dae6c0eab35069d59e61d4c6383b190e21a420d7735d0ab5a2"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "ee3a90e0e406940f60358edfa3688f8774687bc768c4e9c069ca6d8337a39bcc"
},
{
"api": "daily",
"rows": 1,
"snapshot": "bf21400564f20c771905d46be0dd225eb9d0579393ab5734038ba93db63042f4"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "66611bdd15e87cd5ccec0f48d7e94282cedb210f0393a313519b6896abf0de1f"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "eb98e9d138478b7ec2ae57557a1e61d34f825fd9faf8f30ef530c4d54f5e391e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "550627fe30dccb760e4dbc73dd0ecee85676f781dac1ba059483e7d83b701091"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "5d1e6b1222b37896ad79d759e8fb8a240fb438bf6d50eda687c56181648c3a12"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7bc22506f40cd9c550a48699c93bee28f00c8717e304cd26761b33d9e66dbf35"
},
{
"api": "daily",
"rows": 1,
"snapshot": "39dda855afcfcf4e32ccd67e4cd28942f8eed13a171b82fa438000bfcff87252"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "b9042adf9d7c2823a7155691c6c349f451b6d84fe7eb2ca83ab749a51eaa6c6e"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "aed26a777f4d4eae9236b677d6ed7180a093aa49076eeee3b796325db3ce2e9f"
},
{
"api": "daily",
"rows": 1,
"snapshot": "26d7d7beb26a8318f9b1104b5bc7446562663bba3e7142d9957562f5dce16c64"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "560d0e8e0a1d1220acab7a4d22ba6aadb4d47555344f75bbdbaf7650d62d95bf"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7f6759aea68eb193c271c6f8230de9237bcd25bb445eef786adc5b39d5527e08"
},
{
"api": "daily",
"rows": 1,
"snapshot": "467d8fa44adf1aeecb81bb970a3239db4f48c75ec680bd802310ba0a40b9a92f"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "cc80509ebf2c47e85cb1eebe0735db7c84facd01eba73212db218fbabcf6c980"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "7f145408f62626e955b83617821e4c41ce0af3353533de790de22211f02ba36c"
},
{
"api": "daily",
"rows": 1,
"snapshot": "e515db0257399b1433fc14c4062a020a4837dfe6ff968cd70ab2b60f1035794e"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "31aa693eb092cf03a7b1e0da65ffd46e79389831f93809e6738789bfa6e8d619"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "b4073fdbd1e5f73c52d64aae07ea3dd8a9613f12cdea316abcbbaf4bdff6f28e"
},
{
"api": "daily",
"rows": 1,
"snapshot": "ffd0d7d09fee0dc7e5a035c9131220042563a8d5c852ff5ca8d0a4d8b7ba8004"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "7e9bff54c0b00afc12bbcc7a98dfcd7e504ce780e47ac7a3c3443e989d9da031"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "fd045165e72b1127df96bd73487a99d2b561c641783a0dfa3b0ed8b470e4abda"
},
{
"api": "daily",
"rows": 1,
"snapshot": "30ccd5069b7a8c361cc5408ecb9e0013f36d314cf5dde4c6d79b9e10f8942315"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "bbcc7b25b1e2d51487dcb76b28404966db7e83c9153af8f3119e1653f20f8618"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "c210e7f840c5baecc94d6fd333f73fd98b994b65575f2ce38faed428e59b8f9f"
},
{
"api": "daily",
"rows": 1,
"snapshot": "bbb69dfcf4862e214b3a47265ba3607476695cb9147eed3d9f8199a0e7501ff1"
},
{
"api": "moneyflow_dc",
"rows": 1,
"snapshot": "fce61a39b30de56dc3b898f6d8e4bf156504bedc001c5c85abfceaecbb490f36"
},
{
"api": "moneyflow",
"rows": 1,
"snapshot": "2e0739949d25df4a56631c761e064121ff8f99ead4e95306c1c1f1bfa44111ae"
}
]
}
@@ -0,0 +1,20 @@
{
"trade_date": "2026-09-04",
"sector": "BK1147.DC",
"members": 14,
"pct_change": 1.82,
"main_net_yuan": "485456500.00",
"turnover_yuan": "2615472881.48000",
"daily_ratio": "0.1856094564915916866216729052",
"active_net_yuan": "-255455700.00",
"rows_by_api": {
"dc_index": 1,
"dc_member": 14,
"stock_basic": 5556,
"suspend_d": 8,
"trade_cal": 47,
"daily": 14,
"moneyflow_dc": 14,
"moneyflow": 14
}
}
@@ -0,0 +1,77 @@
"""Replay the persisted real SPD sample in an isolated database, with no network.
The one-sector publication is exclusively a local integration fixture; its ranks
must never be interpreted as a market-wide ranking.
"""
import os
import json
from datetime import date
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import psycopg
from alembic import command
from alembic.config import Config
from zhixing_server.bootstrap.config import Settings, get_settings
from zhixing_server.modules.sector_radar.application.build import BuildSectorRadar, BuildSectorRadarCommand
from zhixing_server.modules.sector_radar.domain.models import SectorType
from zhixing_server.modules.sector_radar.domain.source import (
SourceSnapshot, SourceResult, TradeCalendarRow, SectorIndexRow, SectorMemberRow,
StockBasicRow, SuspendRow, DailyRow, MoneyflowDcRow, MoneyflowRow, build_source_snapshot,
)
from zhixing_server.modules.sector_radar.infrastructure.postgres import PostgresSectorRadarRepository
def database_url():
"""Resolve only the named localhost fixture database without printing secrets."""
s=Settings(_env_file='../.env'); u=urlsplit(s.database_url)
return urlunsplit((u.scheme,u.netloc.rsplit('@',1)[0]+'@127.0.0.1:5433','/radar_detail_selftest_0906',u.query,''))
class StoredSource:
"""Implement provider port using already committed raw snapshots only."""
def __init__(self, dsn):
self.by_api={}
with psycopg.connect(dsn) as c:
for r in c.execute('SELECT id,api_name,normalized_params,target_trade_date,partition_key,observed_at,payload,row_count,returned_fields,content_sha256,row_limit,limit_reached FROM sector_radar_source_snapshot').fetchall():
snap=SourceSnapshot(r[0],r[1],tuple(sorted(r[2].items())),r[3],r[4],r[5],tuple(r[6]),r[7],tuple(r[8]),r[9],r[10],r[11])
self.by_api.setdefault(r[1],[]).append(snap)
def result(self, api, parser):
snaps=tuple(self.by_api[api])
return SourceResult(snaps,tuple(parser(row) for s in snaps for row in s.rows))
def fetch_trade_calendar(self,start,end):
return self.result('trade_cal',TradeCalendarRow.from_mapping)
def fetch_sector_indices(self,target,kind):
if kind is SectorType.INDUSTRY:
# Explicitly empty industry scope for this one-concept test fixture.
snap=build_source_snapshot(api_name='dc_index',params={'test_scope':'empty_industry'},rows=(),target_trade_date=target)
return SourceResult((snap,),())
return self.result('dc_index',lambda row:SectorIndexRow.from_mapping(row,kind))
def fetch_sector_members(self,target,codes):
return self.result('dc_member',SectorMemberRow.from_mapping)
def fetch_stock_basics(self):
return self.result('stock_basic',StockBasicRow.from_mapping)
def fetch_suspensions(self,target):
return self.result('suspend_d',SuspendRow.from_mapping)
def fetch_daily(self,target):
return self.result('daily',DailyRow.from_mapping)
def fetch_moneyflow_dc(self,target,codes):
return self.result('moneyflow_dc',MoneyflowDcRow.from_mapping)
def fetch_moneyflow(self,target):
return self.result('moneyflow',MoneyflowRow.from_mapping)
def main():
"""Run actual build and HTTP reads, checking independently recomputed facts."""
dsn=database_url()
os.environ['ZHIXING_DATABASE_URL']=dsn
get_settings.cache_clear()
command.upgrade(Config('alembic.ini'),'head')
repository=PostgresSectorRadarRepository(dsn,max_connections=2)
result=BuildSectorRadar(StoredSource(dsn),repository,today=date(2026,9,6)).execute(BuildSectorRadarCommand(trade_date=date(2026,9,4)))
print(json.dumps(result.as_dict(),ensure_ascii=False))
repository.close()
if __name__=='__main__':
main()
@@ -0,0 +1,27 @@
"""Verify API facts against independently calculated persisted real data."""
import json
from decimal import Decimal
from pathlib import Path
from urllib.request import urlopen
root='http://127.0.0.1:8016/api/v1/sector-radar/'
d=json.load(urlopen(root+'sectors/concept/BK1147.DC/detail?trade_date=2026-09-04'))
assert d['status']=='success'
assert len(d['members'])==14
assert Decimal(d['pct_change'])==Decimal('1.82')
assert sum(Decimal(m['net_amount_yuan']) for m in d['members'])==Decimal('485456500')
assert sum(Decimal(m['active_buy_net_amount_yuan']) for m in d['members'])==Decimal('-255455700')
assert Decimal(d['summary']['amount']['metric_value'])==Decimal('4.854565')
# Storage uses 12 fractional digits for persisted metric observations.
assert abs(Decimal(d['summary']['ratio']['metric_value'])-Decimal('0.18560945649159168662'))<Decimal('1e-12')
assert d['history']['available_days']==1
assert len(d['history']['points'])==30
assert d['summary']['swing']['missing'] is True
assert len(d['leaders']['pct_change']['top'])==5
for view in ['amount','ratio']:
r=json.load(urlopen(root+'rankings?sector_type=concept&view='+view+'&side=top&trade_date=2026-09-04'))['rows'][0]
assert Decimal(r['pct_change'])==Decimal('1.82')
assert Decimal(r['daily_net_amount_yuan'])==Decimal('485456500')
assert r['on_list_count']==1
Path(__file__).with_name('verified-detail.json').write_text(json.dumps(d,ensure_ascii=False,indent=2))
print('PASS: real persisted SPD data matches detail and both ranking APIs; 14 members, 30 slots / 1 available day, absent swing remains null.')
@@ -0,0 +1,26 @@
{
"id": "capital-radar-daily-detail",
"name": "capital-radar-daily-detail",
"title": "资金雷达:单日榜单与板块详情",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-06",
"completedAt": "2026-09-25",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "实施及本地验证完成,详见 verification.md。代码按用户边界保持未提交,未自动归档;正常业务库尚未应用本次迁移。",
"meta": {}
}
@@ -0,0 +1,25 @@
# 本地实施验证 · 2026-09-06
本次已实现单日流入率/净额两种表头、板块名称入口、30 交易日在榜展开、详情三指标排名曲线、成员三指标前后 5 名切换、Jaccard 相似板块和成员复制/CSV 导出。沿用当前项目主题,参考站点私有加权评分未作为此次范围。前端读取同源 API;详情和历史仅从已持久化的发布及原始快照分析,不在读请求中调用 Tushare。
## 真实数据核验
使用独立数据库 `radar_detail_selftest_0906`,未覆盖正常业务库。采集 SPD概念 `BK1147.DC` 在 2026-09-04 的 Tushare 数据,14 只成员,原始响应先入库,再从已保存快照重放构建。数据库已升级至 `0009_radar_sector_detail`。采集、独立计算和 API 核对脚本在本任务 `research/`,不包含凭据。
数据库独立汇总与最新 HTTP 返回一致:板块涨跌幅 1.82%,主力净额 485456500 元,成交额 2615472881.48 元,单日流入率 18.560945649159…%,主买净额 -255455700 元。两种资金指标分别保留,未互相替代。`research/verify_http.py` 在重启最新服务后通过,验证 14 个成员、三指标摘要及两种榜单附加字段。
该自测只有 1 个板块、1 日有效发布;近 30 个交易日中的其余日期显式缺失,波段指标保持空值。单板块第 1 名仅验证功能链路,不代表全市场排名,也不能用于检验参考站点全池历史的数值一致性。
## 检查结果
- 后端相关单元、HTTP、PostgreSQL 测试:96 passed。隔离审查库 `radar_detail_review_0906` 从空库完成迁移;覆盖最新发布、30 日边界、未来隔离、每日池变化、版本隔离、可选来源失败、历史成员、Jaccard 和字段持久化。
- 后端全量 Ruff lint/format:通过,129 个文件格式符合。资金雷达源码及相关测试 Pyright:0 errors。
- 前端资金雷达测试:41 passed。全量 ESLint、TypeScript、生产构建通过;资金雷达范围 Prettier 通过。
- 浏览器已核验两个面板、名称入口、在榜展开、默认指标跟随当前面板、成员指标与前后 5 切换、复制/导出 14 名成员及弹窗下部。390px 窄屏发现的 grid/canvas 撑宽已修复,弹窗 clientWidth 和 scrollWidth 均为 358px。单点历史保留可见圆点。
- `git diff --check` 通过。代码未提交,未执行自动归档或修改 specs。
## 已有全局检查问题与使用边界
全量后端 Pyright 仍报告 14 个错误,范围仅在未修改的 selection/application/chart.py、selection/domain/gold_brick.py 和 tests/unit/selection/test_run.py。全量前端 format:check 仍被两份已有 `.playwright-cli/page-2026-09-01T14-54-32-480Z.yml`、`page-2026-09-05T07-35-10-548Z.yml` 阻断。未扩大修复这些文件。构建另有大 chunk 提示,Alembic 有既有 path_separator 弃用提示。
正式业务数据库使用前需应用新迁移并按原构建流程采集/构建目标日;既有发布缺少可选主买净额时显示缺失,不通过实时请求补值。当前本地预览为 `http://127.0.0.1:5516/sector-radar`,连接上述单板块隔离库,后端端口 8016。最新截图在 `output/playwright/radar-detail.png`。

Some files were not shown because too many files have changed in this diff Show More