Compare commits

...
Author SHA1 Message Date
施工员andmultica-agent dcd155b410 feat(HEL-84): 手机端列表排序 + 详情页双图/自选 + 顶栏七项数据栏
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 17:05:58 +08:00
施工员andmultica-agent a6e8ad242d fix(HEL-79): Sheet 关闭后不再拦截指针(pointer-events 修复)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 12:16:23 +08:00
施工员andmultica-agent 38f307d591 feat(HEL-79): 手机端 P2a 表格引擎+六个池/榜列表页
- 新增 StickyHScrollTable:冻结表头/首列、横滑、pan-x 触控
- 涨停池/炸板池/跌停池/昨日涨停/涨停表现/人气榜接真数据(沿用电脑接口)
- 功能页顶栏返回+标题+日期按钮;底部日期抽屉(日历网格+快捷胶囊)
- 点名称/代码弹底部 DetailSheet(走 /api/stock/{code}/preview)
- 加载骨架/空态/错误态按动效规范;列配置进 nav.config
- 其余 6 个行情页仍为占位(归 P2b)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 11:59:07 +08:00
施工员andmultica-agent a92caa4f3f feat(HEL-76): 手机端丝滑度整改②——换页过渡与启动登录反馈
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 11:11:17 +08:00
施工员andmultica-agent 7752648e4b feat(HEL-75): 手机端丝滑度整改①——动效Token/按压反馈/日夜平滑/空态入场
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 10:35:50 +08:00
施工员andmultica-agent 6174efaee5 fix(HEL-72): [hidden] 规则强制 display:none,修复 tabbar/返回键被 display 顶掉
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 09:54:55 +08:00
施工员andmultica-agent 574a375292 feat(HEL-72): 手机端底部五入口导航栏改版
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 09:22:03 +08:00
施工员andmultica-agent 7bf5d8c2b6 feat(HEL-70): 手机端 P1 四大图标页(行情/工具/复盘/系统)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 08:47:34 +08:00
6144a480c7 fix(HEL-69): serve_static 目录 index 兜底,闸门改跳 /m/
审核通过后部署侧小改:目录请求返回 index.html,避免 /m/ 回退桌面页;分流闸门同步改用干净 /m/ 地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 01:47:53 +08:00
施工员andmultica-agent b213b4362a feat: HEL-68 手机端 P0 同网址分流闸门与手机壳
在电脑端 index.html head 加极早分流闸门(?ui 覆盖优先,720px/移动 UA 判定,首屏前跳转 /m/);新建 frontend/m/ 手机壳(index.html、tokens/shell CSS、api/session/router/boot JS、nav.config),首页五入口、占位图标页/功能页、登录页、日/夜主题与栈式返回。未改电脑 DOM/样式,未碰问天。

Co-authored-by: multica-agent <github@multica.ai>
2026-08-24 01:34:07 +08:00
施工员andmultica-agent 1b333b6b93 docs: 修正交接手册当前提交号并更新任务清单状态
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:12:23 +08:00
施工员andmultica-agent 224e2a7f21 docs: 整理 app/docs 交接手册(需求/进度/任务/首页)并标记旧版 HANDOFF
Co-authored-by: multica-agent <github@multica.ai>
2026-08-23 14:07:07 +08:00
施工员andmultica-agent ed9858e330 fix: align auction/themes/popularity header right-side icons with other pages
Remove the stale body[data-active-view] .overview-strip { flex: 0 0 auto }
override from the three market-insight page stylesheets. Since the merged
header (B-147) moved the market tape into .app-header, this rule no longer
targets the old in-main overview strip and instead overrides the shared
shell rule (.app-header .overview-strip { flex: 1 0 auto }), which pushed the
header-actions icons off the right edge on auction, theme library and
popularity pages. Deleting the override lets the shared shell owner drive the
tape growth so the right-side icons sit flush like every other page.

Regenerate architecture-inventory.json line metrics for the CSS change.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 23:31:37 +08:00
3ae07b8aae B-221: keep header tape readable and commands inside the viewport
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 15:24:38 +08:00
5a507239a3 B-217: show full header username without 108px truncation
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 13:39:22 +08:00
152c0000ce B-214: keep desktop header commands visible instead of behind ellipsis
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 12:39:22 +08:00
cf7183d0c0 fix: restore B-147 dialog tokens and unclip screener headers
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 08:18:05 +08:00
22d4dcd339 feat: apply B-147 visual tokens to screener, review and account surfaces
Unify intelligent screener, review workspace, and account/admin chrome with shared tokens, table density, and left-aligned first columns.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 06:39:25 +08:00
ee37223722 fix: left-align theme rank numbers and bind aux font size
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 05:13:40 +08:00
ad08d309c6 feat: apply B-147 visual tokens to remaining market data pages
Unify ladder, themes, rotation, auction, dragon-tiger and popularity with shared tokens, left-aligned first columns, and table density.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 04:34:36 +08:00
6ab910eeea fix: isolate limit-pool fixed layout to 1440+ breakpoints
Keep the 1600 no-overflow table, but restore natural column widths
and horizontal scrolling at 1280 and 390 so cells are readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 04:04:59 +08:00
f67100929b fix: fit 13 limit-pool columns at 1600 and restyle leftover tokens
Keep the limit-up table inside the card without horizontal scroll,
raise header/row metrics to the B-147 spec, restore night sort accent,
and switch the day-mode repair badge to the shared blue token.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 03:35:40 +08:00
df45638edd fix: keep tape date visible and finish dual-review shell polish
Give the overview date its own class so mobile cannot hide it, keep mentor
subtitles fully readable, and align sentiment table header tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 02:31:52 +08:00
bb67085dd4 fix: merge B-157 and B-158 shell review blockers
Keep 1024-1439 broken/seal metrics in the detail panel, fold desktop admin actions into the ellipsis menu, and fit the 13-column limit pool at 1600 without hiding the data date.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 02:19:04 +08:00
e778c883db feat: apply B-147 visual tokens to shell and three representative pages
Closes B-156

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-20 00:43:09 +08:00
MS-01-Codexandmultica-agent b60a4cb682 fix: reload sentiment cycle history after backfill
backfillData() only refreshed system settings after a successful backfill,
leaving state.sentimentHistory/sentimentHistoryKey populated with the stale
pre-backfill cache, so an already-open sentiment cycle page kept showing the
old one-day history.

After a successful backfill, clear both cache fields and, when the sentiment
cycle view is active, force loadSentimentHistory(true) so the history renders
immediately. Add a frontend contract test covering the refresh path.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 21:52:15 +08:00
MS-01-Codexandmultica-agent 74304795ec test: cover m0004 add_mentor_note migration paths
Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 20:12:18 +08:00
MS-01-Codexandmultica-agent 0fba71f62c fix: register m0004 add_mentor_note migration
Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 18:18:45 +08:00
MS-01-Codexandmultica-agent e0cba74f8e fix: reconnect mentor page into the project shell
Remove the mentor immersive rules in shared/shell.css that hid the module
nav, header actions, market tape, overview strip and status bar while
mentorView was active, which made the page look like an independent site.
Mentor now stays inside the canonical 小白复盘 shell and owns its own
"问师 + 数据日期" title header inside #mentorView (B-92 content intact).

Also fix the shell's latent 1024px overflow: .main min-width 1080 now only
applies at >=1280, and the overview strip scrolls internally instead of
clipping its right side when it is wider than the content column.

Update the contract and e2e baselines to assert the shell stays visible
and usable on mentor, page switching leaves no residue, and add a
dedicated shell-integration e2e test.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 17:19:03 +08:00
MS-01-Codexandmultica-agent 39b6f71443 fix: align mentor page with final day/night visual review
Resolve B-98 visual review deviations and B-97 atomicity finding:
- Scoped mentorView header to title/subtitle + theme toggle only (hides
  date, refresh, background refresh, system management and account menus).
- Map contact avatars by stable id to the final day/night palette (52科比
  and self stay blue; others use the final violet/green/orange/red/teal/
  purple/yellow tones).
- Grade badges read A级/B级/C级; pinned contacts render a pin + 置顶
  badge; row-side pin controls are removed (chat header keeps pinning).
- Chat header drops grade badges; subtitle shows the active contact
  description/tagline.
- Assistant message column caps at 900px and centers on wide screens;
  user bubble keeps its 60% cap.
- Widen the directory search field (~218px target), keep filter/sort on
  the same row, and make the filter button icon-only (no chevron).
- Follow-up links drop the leading icon; no '日期 · 回答完成' meta line;
  follow-up divider is 382px and left-aligned with the text column.
- Own-message meta shows time only; composer height converges to ~85px
  while keeping auto-grow and the 168px cap.
- Selected contact right inset ~10px, header action radius 8px, and a
  thin list scrollbar instead of the reserved gutter.
- Remove the iFinD settings switch fragment from index.html so the mentor
  commit no longer carries half of the parallel iFinD work; the id
  contract test now tolerates dangling references introduced only by
  uncommitted working-tree edits.
- Add contract + e2e assertions for the header visibility, avatar tone
  mapping, badge copy, chat subtitle, message max-width, composer height,
  and follow-up area.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 13:15:10 +08:00
MS-01-Codexandmultica-agent 8ac3adbb5d feat: restore mentor page to final day/night spec
Rebuild the mentor workspace per the approved final design (day.png/night.png):
- mentorView gets a dedicated full-width immersive shell (independent 56px
  top bar with page title slot + theme mode text, hidden module-nav/market
  tape/overview/status bar) scoped to body[data-active-view=mentorView].
- Assistant messages become borderless body text with name/time above; only
  user messages use a blue bubble; keep loading/error/streaming caret states.
- 4 quick topics stay visible with history; composer restored to a framed
  ~94px card with bottom-left shortcut hint and bottom-right send button.
- Directory tools merged into one row (search + filter menu + sort); the
  filter menu still offers all/A/B/C; list selected state is an inset rounded
  fill; contact rows are borderless 75px items.
- Chat header always shows pin/note/profile/clear; pin reuses /api/mentors/
  preferences; theme toggle stays the single #themeToggle.
- Night tokens match the spec including the two distinct blues (#316FEF link,
  #5B8DEF quote/selected icon). Mobile (<768) stacks panels with no horizontal
  overflow; 1024+ follows the desktop spec.
- Update stale test baselines (300px sidebar, 94px composer, hint presence,
  night bubble color, centered disclaimer) and regenerate the architecture
  inventory.

Co-authored-by: multica-agent <github@multica.ai>
2026-08-18 11:54:57 +08:00
leefer 33f9db43b1 chore: create Multica handoff checkpoint 2026-08-06 22:54:46 +08:00
leefer bd97ba1829 feat: unify trading workspace visual system 2026-08-06 02:48:46 +08:00
leefer b3a21d05b7 feat: complete heaven readings and screener publication 2026-08-05 23:44:17 +08:00
leefer 6a058c2929 fix: correct mentor user bubble in dark mode 2026-08-03 22:34:13 +08:00
leefer e1e76cd51e refactor: establish standalone application boundary 2026-08-03 21:42:25 +08:00
leefer cc5fb8d73e refactor: remove exact same-file css duplicates 2026-08-02 14:42:44 +08:00
leefer 9f691a47a0 fix: use page scrolling for dragon tiger 2026-08-02 14:08:48 +08:00
leefer 104b267627 refactor: remove exact nested css duplicates 2026-08-02 12:43:30 +08:00
leefer 86227cec37 refactor: remove exact cross-layer css duplicates 2026-08-02 11:54:00 +08:00
leefer 728cc48f90 fix: restore heaven interpretation persistence dependency 2026-08-02 11:34:48 +08:00
leefer c32873b3d4 fix: restore llm model validation dependency 2026-08-02 08:49:57 +08:00
leefer 346b76bc00 refactor: govern background job lifecycle 2026-08-02 03:40:53 +08:00
leefer 2cab4b9cdf refactor: move sector phase persistence to heaven repository 2026-08-02 03:00:39 +08:00
leefer 9028cb342d refactor: move ifind pool helpers to feature service 2026-08-02 02:23:28 +08:00
leefer 8d43f4c372 refactor: centralize ndjson streaming transport 2026-08-02 01:53:47 +08:00
384 changed files with 74150 additions and 45169 deletions
+10 -18
View File
@@ -1,23 +1,15 @@
# 小白复盘仓库执行约束
# 小白复盘仓库过渡期约束
本文件对仓库内所有后续编码任务生效。任何智能体在修改文件前必须完整读取
`app/`是已完成人工验收的正式源码,也是后续开发的唯一实现。修改`app/`前必须完整读取
`app/AGENTS.md``app/ARCHITECTURE.md`及与任务有关的测试和注册表。
1. `docs/migration/原版保真迁移总纲.md`
2. `docs/migration/保真迁移状态.json`
3. `docs/migration/next失败冻结记录.md`
4. 与本次功能有关的原版源码、页面和测试
根目录旧程序和`next/`只用于本次最终清理前的Git回档,不得继续开发、部署或被`app/`导入。
永久产品、治理、迁移和维护文档已经归入`app/docs/`
## 不可违反
- 当前根目录原版是唯一功能、视觉、交互、动画和计算基线
- `next/`是失败冻结实现,禁止部署、继续开发或作为新迁移代码来源
- 后续迁移是原代码保真式整理,不是重写、重新设计或更换技术栈
- 不得根据规格说明书重新实现已经存在的功能;规格书只用于盘点,冲突必须交给用户裁决
- 不得改变用户可观察行为。源码可以移动、拆分和调整引用,但输出必须等价
- 不确定是否有用的代码默认保留。没有引用扫描、运行证据和新旧对比,不得删除。
- 每次只处理一个完整纵向功能切片,并同步更新迁移账本和状态文件。
- 每个切片必须具有原版基线、新版结果、API/数据库对比、页面与交互对比及Git回档点。
- 不以新实现自身测试通过、目录更整齐或代码行数减少证明迁移成功。
- 未经用户人工确认,不得宣称视觉等价、完成迁移、切换Docker/NAS或删除原版。
如果任务要求与以上约束冲突,停止迁移并向用户说明冲突,不自行选择新产品行为。
- 不得从根目录旧程序或`next/`复制实现覆盖`app/`
- 不得改变用户已经验收的功能、视觉、交互、动画、计算和数据语义
- 不得提交Token、密码、`.env`、数据库、私有Skill、日志、缓存或测试产物
- 删除旧目录前必须先完成`app/`独立验证并建立可推送的Git回档提交
- 根目录清理只删除已经被`app/`替代且没有剩余消费者的内容,不顺带修改产品行为
+1
View File
@@ -7,6 +7,7 @@
__pycache__/
*.py[cod]
*.log
runtime/
data/cache/
data/private-mentor-skills/
data/*.db
+2 -7
View File
@@ -15,11 +15,6 @@ data/*.backup
.coverage
htmlcov/
.pytest_cache/
test-results/
playwright-report/
runtime/*
!runtime/.gitignore
node_modules/
next/.venv/
next/data/
next/frontend/dist/
next/frontend/.vite/
next/frontend/coverage/
+37
View File
@@ -0,0 +1,37 @@
# 小白复盘维护约束
本目录是小白复盘唯一正式源码。任何修改开始前必须读取:
1. `ARCHITECTURE.md`
2. `docs/product/小白复盘-完整产品规格说明书.md`
3. 与任务有关的`config/*.json`、源码和测试
`docs/migration/`保存迁移事实与历史证据,不是第二套产品实现。发生冲突时,依次以用户当前明确
决定、当前正式程序的真实行为、产品规格说明书为准。
## 产品边界
- 保持已经验收的功能、视觉、布局、动画、交互、响应式行为和日夜主题。
- 保持API路径、字段、状态码、流式协议、数据库兼容和账户隔离。
- 保持数据来源、日期、单位、复权、新鲜度、覆盖率和禁止静默降级规则。
- LLM只通过`backend/llm/`调用;浏览器请求只通过`frontend/shared/api.js`发出。
- 不得读取、导入或运行本目录父级的旧源码、静态资源、配置、测试或数据。
## 结构边界
- 保持模块化单体技术栈:一个Python进程、一个SQLite数据库、无构建前端。
- 业务代码进入`backend/features/<feature>/`,数据适配进入`backend/data/`,后台任务进入
`backend/jobs/`HTTP公共能力进入`backend/http/`
- 页面结构、行为和样式分别由`frontend/pages/<feature>/``frontend/shared/`的唯一所有者维护。
- 不建立根级兼容转发文件、第二套路由、第二套数据客户端或晚加载CSS补丁层。
- 不确定代码默认保留;删除前必须有引用扫描、测试和真实浏览器证据。
## 最低验收
1. 运行相关领域测试。
2. 运行`python tools/verify_baseline.py`
3. 涉及运行时或前端时运行`python tools/verify_baseline.py --e2e`
4. 检查`git diff --check`,并确认没有密钥、数据库和运行产物进入Git。
5. 用户可观察行为发生变化时,必须说明并由用户验收。
数据和`.env`必须成对备份。`data/private-mentor-skills/``data/*.db``runtime/``.env`不得提交。
+137 -33
View File
@@ -1,9 +1,8 @@
# Candidate architecture
# Application architecture
`app/` is the behavior-preserving modular source tree accepted by the user on 2026-08-01.
The original `webapp/` runtime remains the deployment rollback baseline until an explicitly
approved switch. `next/` is a rejected, frozen implementation and is not a source for this
directory.
`app/` is the standalone, behavior-preserving modular source tree accepted by the user on
2026-08-01. It is the only production source boundary and must not read or import a parent
checkout, a retired baseline, or a failed implementation.
The application deliberately remains a modular monolith: one Python process, one SQLite WAL
database, and a build-free HTML/CSS/JavaScript client. The migration changed source ownership
@@ -26,56 +25,161 @@ background scheduler
## Source ownership
- `server.py` is the stable command/import facade. Runtime composition lives in
`backend/application.py` and `backend/bootstrap/`.
- `server.py` is the stable command/import facade. `backend/application.py` is the narrow
composition root for `DashboardService`, `RequestHandler`, and the process-wide service
instance; dependency construction remains in `backend/bootstrap/`.
- `backend/bootstrap/` owns process configuration, dependency construction, startup, and
shared input/display-format contracts. It does not own feature behavior.
- `backend/http/` owns common authentication, request IDs, responses, static delivery, and
error normalization. Feature-specific transport handlers live beside their feature.
Exact POST endpoints that only delegate to one of those handlers use the explicit maps in
`backend/application.py`; endpoints with path parameters, body handling, or special error
semantics remain visible control flow in `RequestHandler`.
- `backend/http/` owns common authentication, request IDs, JSON/NDJSON responses, static
delivery, streaming connection lifecycle, and error normalization. Feature-specific
transport handlers live beside their feature. `backend/http/dispatch.py` owns only public
versus authenticated guard order, named POST dispatch, feature-route traversal, static
fallback, and final 404 responses. Exact POST maps live there; endpoint parsing, response
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
- `backend/data/providers/tushare_client.py` is the stable public `TushareClient` facade and
owns only its dataclass fields and shared cache state. Tushare HTTP transport belongs to
`tushare_transport.py`; market overview and realtime breadth belong to
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
`tushare_sectors.py`; hot-money and dragon-tiger data belong to
`tushare_dragon_tiger.py`; stock detail and intraday data belong to `tushare_stocks.py`;
trading-calendar, daily, and limit-list access belong to `tushare_daily.py`; small shared
deterministic conversions belong to `tushare_helpers.py`.
- `backend/database/` owns connection management, ordered migrations, and narrow repository
adapters. Root `database.py` remains the legacy schema/composition anchor and combines the
feature repository mixins; do not add feature queries to it.
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
`backend/jobs/service.py` is the application-facing owner of scheduler start/stop, manual
refresh submission, and periodic refresh coordination.
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
streaming rules, and call audit. Feature agents only prepare messages and interpret
feature-specific results.
- `frontend/shared/` is the only browser API/state/Shell/component boundary.
- `frontend/pages/` owns page-local behavior. The original runtime was split mechanically;
source markers and preservation tests prove that the pieces reassemble to the audited
original, apart from explicitly registered trial retirements.
- `frontend/styles/`, `frontend/shared/tokens.css`, and the Wentian page stylesheet preserve
the approved cascade and light/dark/mobile behavior.
- `frontend/index.html` owns only the login layer, application Shell, overview strip, status
bar, global dialogs, and the single page-fragment mount point. `frontend/bootstrap.js`
loads the registered page fragments before the unchanged application runtime starts.
- `frontend/pages.config.js` is the only runtime owner of page-fragment paths and script
execution order. Do not add page scripts directly to `index.html` or create another loader.
- `frontend/app.js` is only the browser startup coordinator: initialize controls, resolve the
initial route, start the authenticated application, and invoke registered binding owners.
It must not own feature event handlers, dashboard rendering, account/admin behavior, theme
behavior, table behavior, or application state definitions.
- `frontend/shared/` is the only browser data-API/state/Shell/component boundary. Within it,
`context.js` owns application state and DOM handles, `application.js` owns API/Shell/page
lifecycle composition, `feedback.js` owns common feedback and motion, `dashboard.js` owns
market-dashboard refresh and date coordination, `session.js` owns authentication/account
access, `admin.js` owns system administration, `theme.js` owns theme switching, and
`table.js` owns generic table behavior. The Bootstrap fetch is limited to registered
same-origin static HTML fragments.
- `frontend/pages/` owns page-local markup, behavior, and styles through `page.html`,
`page.js`, and `foundation.css`. Each feature registers its own one-time control binder with
the page runtime; feature selectors and event handlers must not be added to `app.js`. The
original DOM and runtime were split mechanically during migration. Current maintenance is
governed by the runtime registry, unique symbol owners, DOM/API contracts, JavaScript syntax
checks, and Playwright behavior rather than embedded historical source ranges.
- `frontend/pages/market/` owns cross-page market presentation through narrow runtime modules:
`breadth.js`, `charts.js`, `entity-detail.js`, `stock-detail.js`, `preview.js`, `search.js`,
and `bindings.js`. `runtime.js` is retired; do not recreate a combined market runtime or a
compatibility loader. `pages.config.js` is the sole owner of their execution order.
- `backend/features/screener/engine.py` is the stable screener compatibility facade only.
Screener declarations belong to `catalog.py`; external factor synchronization belongs to
`data_sync.py`; deterministic technical and statistical helpers belong to `indicators.py`;
factor construction belongs to `factors.py`; formula validation, scoring, and local strategy
compilation belong to `formula.py`; market-phase identification belongs to `regime.py`;
screening execution and result persistence belong to `selection.py`; historical evaluation
belongs to `backtest.py`.
- `backend/features/heaven/service.py` is the stable Wentian service facade only. Manual
six-line input validation and safety gates belong to `manual.py`; trend setup, market mode,
source disclosure, and quality checks belong to `trend.py`; stock, index, and sector context
collection belongs to `market_context.py`; personal fields, hexagrams, saved readings, and
interpretation orchestration belong to `readings.py`; deterministic Jing Fang Na Jia, eight
palaces, six relatives, self/response, six spirits, calendar relations, and hidden spirits
belong to `six_yao.py`; source-traceable Wentian knowledge retrieval and the only LLM-bound
context projection belong to `knowledge.py`; prompt construction and answer validation remain
in `agent.py`. These owners cooperate through the composed service object and do not duplicate
or delegate method bodies through the facade.
- Application-facing system credentials, data/LLM status, and administrator settings belong
to `backend/features/system/service.py`; account-context delegation belongs to
`backend/features/accounts/application.py`. They are composed into `DashboardService` and
must not return to the composition root.
- `backend/features/market/insights.py` is the stable public `MarketInsightsService` facade
only. Shared construction, trading context, stock master access, and concept parsing belong
to `insights_context.py`; auction scoring and candidate construction belong to
`insights_auction_scoring.py`; auction session, amount history, watchlist enrichment, and
live snapshots belong to `insights_auction_data.py`; auction result orchestration belongs to
`insights_auction.py`; theme library/detail behavior belongs to `insights_themes.py`; and hot
ranking behavior belongs to `insights_popularity.py`.
- `frontend/shared/tokens.css` owns global design semantics. Shared foundations live in
`frontend/shared/*.css` and `frontend/shared/components/*.css`; page foundations live beside
their page in `frontend/pages/<feature>/foundation.css`. These 22 files replace the retired
`frontend/styles/styles.css`, four historical refinement layers, and the former Wentian
page stylesheet. Production loads only this canonical stack: every selector/context pair has
one owner, shared roots stay in shared files, and page-scoped rules stay beside their page.
- `config/` is the versioned registry for pages, features, APIs, datasets, quality rules,
jobs, and the generated candidate architecture inventory.
Root modules such as `screener.py`, `tushare_client.py`, and `mentor_agent.py` are compatibility
aliases to canonical modules. They contain no second implementation and remain only because
the original public import surface is part of the preservation contract. Canonical backend
modules must import other canonical modules directly rather than routing through these aliases.
The remaining `api_access` import in `backend/application.py` and preserved lazy
`sentiment_engine` import in the screener repository are registered transition boundaries;
the root `database.py` remains the documented schema/composition anchor.
The source root has four Python entry modules only: `server.py` starts and exports the process
surface, `database.py` remains the documented schema/composition anchor, `api_access.py` owns
the route-access registry entry, and `sync_data.py` is the manual synchronization command.
The 19 migration-only import aliases were retired after all internal and test consumers moved
to canonical `backend/` owners. Do not recreate root-level feature import shims.
Generated local artifacts belong under `runtime/`: server output in `runtime/logs`, Python
cache in `runtime/cache`, and browser artifacts in `runtime/test-results`. Docker continues to
emit logs through its configured logging driver instead of writing into the source tree.
## Non-negotiable maintenance rules
1. Preserve account ownership in every user-private query and test it with two accounts.
2. Browser requests go through `frontend/shared/api.js`; provider calls go through the data
boundary; model calls go through `backend/llm/`.
2. Browser business-data requests go through `frontend/shared/api.js`; only
`frontend/bootstrap.js` may fetch registered static page fragments. Provider calls go
through the data boundary; model calls go through `backend/llm/`.
3. Calculation datasets fail closed when required source, date, unit, freshness, or coverage
evidence is missing. Display fallbacks do not silently enter calculations.
4. Do not implement logic in both a root compatibility module and a canonical module.
5. Do not remove compatibility or uncertain code without reference scanning, old/new
differential evidence, browser checks, and manual acceptance.
4. Do not create root-level feature compatibility modules; import the canonical `backend/`
owner directly.
5. Do not remove uncertain code without reference scanning, old/new differential evidence,
browser checks, and manual acceptance.
6. Run `python tools/verify_baseline.py` for every change and add `--e2e` when runtime or
frontend behavior can be affected.
7. Do not recreate late-loading `legacy.css`, `override.css`, `fix.css`, or page-wide patch
layers. Change the canonical shared or page owner and keep the CSS ownership tests green.
8. Do not put `workspace-view` roots back into `frontend/index.html`. Add or change page DOM
only in its registered `frontend/pages/<feature>/page.html`, without introducing a second
fragment or runtime-script registry.
9. Do not add feature selectors, feature event listeners, shared state declarations, or
shared service implementations to `frontend/app.js`; extend the existing unique owner and
keep the startup-entry boundary tests green.
10. Do not merge market charts, previews, search, stock details, entity details, breadth, and
event binding back into one runtime file. Keep each definition in its registered owner and
keep the market runtime ownership test green.
11. Do not merge screener catalogs, data synchronization, indicators, factor construction,
formulas, regime detection, selection, and backtesting back into one engine. Keep
`backend/features/screener/engine.py` as a compatibility facade and preserve one canonical
owner for each responsibility.
12. Do not merge Tushare transport, dashboard, indices, Shenwan industries, sectors,
dragon-tiger data, stock detail, and daily-market access back into one client. Keep
`backend/data/providers/tushare_client.py` as the single public class facade, and do not
duplicate provider method bodies in that facade or another compatibility module.
13. Do not merge Wentian manual validation, trend orchestration, market-context collection,
and reading/LLM behavior back into one service. Keep
`backend/features/heaven/service.py` as a method-free composition facade and preserve one
canonical owner for every Wentian service method.
14. Do not merge auction scoring, auction data preparation, auction orchestration, themes,
popularity, and shared insight context back into one market-insights service. Keep
`backend/features/market/insights.py` as a method-free public facade and preserve one
canonical owner for every market-insight method.
15. Do not put feature route bodies, system settings behavior, account delegation, or job
lifecycle methods back into `backend/application.py`. Keep it as a composition root; keep
common HTTP guard/404 behavior in `backend/http/dispatch.py`; and keep endpoint-specific
parsing and responses in the corresponding `backend/features/<feature>/routes.py`.
16. Do not add preservation source-range markers, copied historical CSS fragments, or a tool
that reconstructs the retired monolithic frontend. Historical maps remain evidence only;
current owners and behavior tests are the maintenance boundary.
The authoritative migration constraints and handoff procedure are in
`../docs/migration/原版保真迁移总纲.md` and
`../docs/migration/人工维护与本地切换指南.md`.
Current maintenance rules are in `AGENTS.md` and
`docs/maintenance/人工维护指南.md`. Historical migration constraints and evidence remain under
`docs/migration/` for audit only.
+9 -9
View File
@@ -44,8 +44,8 @@ docker compose version
## 3. 迁移现有数据
迁移前先停止当前 Windows 上的 `8765` 服务,避免复制过程中 SQLite 继续写入。
然后在 `webapp` 目录执行一次 WAL 检查点:
迁移前先停止当前 Windows 服务,避免复制过程中 SQLite 继续写入。
然后在应用目录执行一次 WAL 检查点:
```powershell
python -c "import sqlite3; c=sqlite3.connect('data/review.db'); print(c.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()); c.close()"
@@ -54,11 +54,11 @@ python -c "import sqlite3; c=sqlite3.connect('data/review.db'); print(c.execute(
结果第一项应为 `0`。必须迁移以下内容:
```text
webapp/data/
webapp/.env
webapp/Dockerfile
webapp/compose.yaml
webapp/其余程序文件
data/
.env
Dockerfile
compose.yaml
其余程序文件
```
不要重新生成 `APP_ENCRYPTION_KEY`。部署已有数据库时,目标服务器 `.env` 中的
@@ -67,8 +67,8 @@ webapp/其余程序文件
可以在项目目录生成迁移包:
```powershell
tar --exclude='__pycache__' --exclude='*.log' --exclude='data/cache' -czf xiaobai-review.tar.gz -C webapp .
scp .\xiaobai-review.tar.gz 用户名@服务器IP:/tmp/
tar --exclude='__pycache__' --exclude='*.log' --exclude='data/cache' -czf ..\xiaobai-review.tar.gz .
scp ..\xiaobai-review.tar.gz 用户名@服务器IP:/tmp/
```
迁移包包含数据库和密钥,传输完成后应及时删除两端的压缩包。
+11 -5
View File
@@ -2,9 +2,8 @@
一个面向 A 股盘后复盘的本地 Web 工作台。后端使用 Python 访问 Tushare Pro,前端不依赖构建工具。
本目录是从原版源码逐项移动、机械拆分并完成差分验证与用户人工验收的模块化正式源码,
不是依据规格书重新开发的第二套产品。正式部署切换前,`webapp/`根目录继续作为当前部署与
回档基线;冻结的`next/`不得用于部署或后续开发。目录职责见[ARCHITECTURE.md](ARCHITECTURE.md)。
本目录是经过保真迁移、结构治理和用户人工验收的唯一正式源码,不依赖父目录旧程序或失败版本。
目录职责见[ARCHITECTURE.md](ARCHITECTURE.md),产品与维护文档见[docs/README.md](docs/README.md)。
当前包含集合竞价、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、题材库、人气热榜、龙虎榜和个人复盘工作区。交易日快照与同步记录保存在本地 SQLite 数据库 `data/review.db`
@@ -31,13 +30,20 @@
## 启动
```powershell
cd webapp\app
cd app
python -m pip install -r requirements.txt
python server.py
```
浏览器打开 `http://127.0.0.1:8765`,首次使用先注册账号。首个账号自动成为管理员,后续账号默认为普通用户。主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时提示等待管理员完成首次同步。
需要后台启动本地验收端口时,使用`tools/start_local.ps1`。该工具把日志、进程号和Python缓存
统一写入`runtime/`,不在源码根目录产生运行文件:
```powershell
powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 -Port 8797
```
局域网 Docker 部署使用 `Dockerfile``compose.yaml`,完整的迁移、持久化、
防火墙、备份和恢复步骤见 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)。
@@ -59,7 +65,7 @@ Tushare 各接口有独立积分权限。程序优先使用 `limit_list_d` 获
## 隔离实时聚合验证
`realtime_aggregator.py` 用于验证东方财富、同花顺和选股宝网页数据源。它不写入 SQLite 主行情快照,也不参与情绪评分或智能选股;当 Tushare 实时指数权限不可用时,观势会使用东方财富三大指数和板块外显,并继续使用 Tushare 的板块成分内核与个股数据。
`backend/data/realtime.py`用于验证东方财富、同花顺和选股宝网页数据源。它不写入 SQLite 主行情快照,也不参与情绪评分或智能选股;当 Tushare 实时指数权限不可用时,观势会使用东方财富三大指数和板块外显,并继续使用 Tushare 的板块成分内核与个股数据。
登录后可调用:
-7
View File
@@ -1,7 +0,0 @@
"""Compatibility alias for the canonical curated strategy library."""
import sys
from backend.features.screener import strategies as _implementation
sys.modules[__name__] = _implementation
-3
View File
@@ -1,3 +0,0 @@
from backend.features.alerts.service import AlertService
__all__ = ["AlertService"]
-3
View File
@@ -1,3 +0,0 @@
"""Compatibility imports for code that still uses the original configuration module."""
from backend.bootstrap.config import * # noqa: F401,F403
-7
View File
@@ -1,7 +0,0 @@
"""Compatibility alias for the canonical review-assistant implementation."""
import sys
from backend.features.review import agent as _implementation
sys.modules[__name__] = _implementation
+55 -1006
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -16,12 +16,13 @@ def main(handler_class: type[Any] | None = None, service: Any | None = None) ->
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), handler_class)
print(f"Xiaobai Review Web is running at http://{args.host}:{args.port}")
print("Press Ctrl+C to stop.")
try:
service.start_background_jobs()
print(f"Xiaobai Review Web is running at http://{args.host}:{args.port}")
print("Press Ctrl+C to stop.")
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
service._background_stop.set()
service.stop_background_jobs()
server.server_close()
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_helpers import _display_time, _prices_equal
class DailyMarketMixin:
def resolve_trade_context(self, requested: str) -> tuple[str, str]:
requested_rows = self.query(
"trade_cal",
{"exchange": "SSE", "start_date": requested, "end_date": requested},
"cal_date,is_open,pretrade_date",
)
if not requested_rows:
trade_date = requested
else:
row = requested_rows[0]
trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested)
resolved_rows = self.query(
"trade_cal",
{"exchange": "SSE", "start_date": trade_date, "end_date": trade_date},
"cal_date,is_open,pretrade_date",
)
previous = resolved_rows[0].get("pretrade_date") if resolved_rows else ""
return trade_date, previous or trade_date
def _load_daily(self, trade_date: str) -> list[dict[str, Any]]:
return self.query(
"daily",
{"trade_date": trade_date},
"ts_code,trade_date,open,high,low,close,pct_chg,amount",
)
def _load_limit_type(self, trade_date: str, limit_type: str) -> list[dict[str, Any]]:
fields = (
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
"open_times,up_stat,limit_times"
)
rows = self.query(
"limit_list_d",
{"trade_date": trade_date, "limit_type": limit_type},
fields,
)
for row in rows:
row["limit_type"] = limit_type
row["amount_unit"] = "yuan"
return rows
def _load_limit_lists(self, trade_date: str) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for limit_type in ("U", "D", "Z"):
rows.extend(self._load_limit_type(trade_date, limit_type))
return rows
def _derive_limits(
self,
trade_date: str,
daily: list[dict[str, Any]],
price_limits: list[dict[str, Any]] | None = None,
basic_rows: list[dict[str, Any]] | None = None,
previous_limit_rows: list[dict[str, Any]] | None = None,
capital_rows: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
if price_limits is None:
price_limits = self.query(
"stk_limit",
{"trade_date": trade_date},
"ts_code,trade_date,up_limit,down_limit",
)
limit_map = {row["ts_code"]: row for row in price_limits}
if basic_rows is None:
basic_rows = self.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry",
)
basic_map = {row["ts_code"]: row for row in basic_rows}
previous_limit_map = {
str(row.get("ts_code") or ""): row for row in (previous_limit_rows or [])
}
capital_map = {
str(row.get("ts_code") or ""): row for row in (capital_rows or [])
}
result: list[dict[str, Any]] = []
for row in daily:
bounds = limit_map.get(row.get("ts_code"))
if not bounds or row.get("close") is None:
continue
limit_type = ""
if _prices_equal(row["close"], bounds.get("up_limit")):
limit_type = "U"
elif _prices_equal(row["close"], bounds.get("down_limit")):
limit_type = "D"
elif _prices_equal(row.get("high"), bounds.get("up_limit")):
limit_type = "Z"
if not limit_type:
continue
basic = basic_map.get(row["ts_code"], {})
previous_limit = previous_limit_map.get(str(row.get("ts_code") or ""), {})
streak = (
max(1, int(_number(previous_limit.get("limit_times"), 1)) + 1)
if limit_type == "U" and previous_limit
else 1
)
item = {
**row,
"name": basic.get("name", "--"),
"industry": basic.get("industry") or "其他",
"limit_type": limit_type,
"limit_times": streak,
"open_times": 1 if limit_type == "Z" else 0,
"amount_unit": row.get("amount_unit") or "thousand_yuan",
}
if row.get("amount_unit") == "yuan":
capital = capital_map.get(str(row.get("ts_code") or ""), {})
if not capital and capital_rows is None:
capital = self._latest_capital(str(row.get("ts_code") or ""), trade_date)
float_share = _number(capital.get("float_share"))
item["turnover_ratio"] = (
_number(row.get("vol")) / float_share / 100 if float_share else 0
)
item["turnover_source"] = (
"rt_volume/latest_float_share" if float_share else "unavailable"
)
item["capital_trade_date"] = str(capital.get("trade_date") or "")
result.append(item)
return result
@staticmethod
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
amount = _number(row.get("amount"))
if row.get("amount_unit") == "thousand_yuan":
amount_billion = amount / 100000
else:
amount_billion = amount / 100000000
return {
"code": str(row.get("ts_code", "")).split(".")[0],
"ts_code": row.get("ts_code", ""),
"name": row.get("name") or "--",
"price": _number(row.get("close")),
"change": _number(row.get("pct_chg")),
"sector": row.get("industry") or "其他",
"reason": row.get("industry") or "待补充",
"first_time": _display_time(row.get("first_time")),
"last_time": _display_time(row.get("last_time")),
"open_times": int(_number(row.get("open_times"))),
"streak": max(1, int(_number(row.get("limit_times"), 1))),
"turnover_rate": _number(row.get("turnover_ratio")),
"turnover_source": row.get("turnover_source") or "provider",
"capital_trade_date": row.get("capital_trade_date") or "",
"amount_billion": round(amount_billion, 2),
"seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0),
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
"status": status,
}
@@ -0,0 +1,644 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime, time as dt_time, timedelta
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
from backend.features.sentiment.engine import apply_sentiment_to_dashboard
from backend.data.providers.tushare_helpers import (
_realtime_market_status,
_trading_session_progress,
_value_percentile,
)
from backend.data.providers.tushare_transport import TushareError
class DashboardMixin:
def dashboard(self, requested_date: str) -> dict[str, Any]:
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
if self.should_use_realtime(requested_date, trade_date):
return self._realtime_dashboard(
requested_date,
trade_date,
previous_trade_date,
)
daily = self._load_daily(trade_date)
if (
not daily
and requested_date == datetime.now().astimezone().strftime("%Y%m%d")
and trade_date == requested_date
and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15)
):
return self._realtime_dashboard(
requested_date,
trade_date,
previous_trade_date,
)
if not daily:
raise TushareError(f"No daily data returned for {trade_date}")
notices: list[str] = []
try:
limit_rows = self._load_limit_lists(trade_date)
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
if not limit_rows:
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
limit_rows = self._derive_limits(trade_date, daily)
except TushareError as exc:
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
limit_rows = self._derive_limits(trade_date, daily)
previous_daily = self._load_daily(previous_trade_date)
previous_limit_rows = [
row for row in self._derive_limits(previous_trade_date, previous_daily)
if row.get("limit_type") == "U"
]
up_rows = [row for row in limit_rows if row.get("limit_type") == "U"]
down_rows = [row for row in limit_rows if row.get("limit_type") == "D"]
broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"]
limits = [self._normalize_limit(row, "涨停") for row in up_rows]
broken = [self._normalize_limit(row, "炸板") for row in broken_rows]
down_limits = [self._normalize_limit(row, "跌停") for row in down_rows]
previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows]
yesterday_limits = _build_yesterday_performance(
previous_limits,
daily,
limits,
broken,
down_limits,
)
sectors = _build_sectors(limits)
previous_sectors = _build_sectors(previous_limits)
dashboard = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date),
"source": "tushare",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(notices),
},
"overview": _build_overview(daily, up_rows, down_rows, broken_rows),
"limits": limits,
"broken": broken,
"down_limits": down_limits,
"yesterday_limits": yesterday_limits,
"limit_performance": _build_limit_performance(yesterday_limits),
"ladders": _build_ladders(limits),
"sectors": sectors,
"sector_rotation": _build_sector_rotation(sectors, previous_sectors),
}
return apply_sentiment_to_dashboard(dashboard)
@staticmethod
def should_use_realtime(requested_date: str, trade_date: str) -> bool:
"""Use rt_k for today's open market until end-of-day datasets settle."""
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d")
return (
requested_date == today
and trade_date == today
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30)
)
def _realtime_dashboard(
self,
requested_date: str,
trade_date: str,
previous_trade_date: str,
) -> dict[str, Any]:
reference = self._load_realtime_reference(trade_date, previous_trade_date)
basic_rows = list(reference["basic_rows"])
codes = ",".join(
str(row.get("ts_code") or "") for row in basic_rows if row.get("ts_code")
)
if not codes:
raise TushareError("No active stock codes available for rt_k")
quotes = self.query("rt_k", {"ts_code": codes})
if not quotes:
raise TushareError(f"No realtime data returned for {trade_date}")
basic_map = {str(row.get("ts_code") or ""): row for row in basic_rows}
daily: list[dict[str, Any]] = []
for quote in quotes:
close = _number(quote.get("close"))
previous_close = _number(quote.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
basic = basic_map.get(str(quote.get("ts_code") or ""), {})
daily.append(
{
**quote,
"trade_date": trade_date,
"name": str(quote.get("name") or basic.get("name") or "--").strip(),
"industry": basic.get("industry") or "其他",
"pct_chg": round((close / previous_close - 1) * 100, 4),
"amount_unit": "yuan",
}
)
with self._realtime_reference_lock:
self._latest_realtime_market[trade_date] = {
"rows": daily,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
if len(self._latest_realtime_market) > 3:
oldest = next(iter(self._latest_realtime_market))
self._latest_realtime_market.pop(oldest, None)
limit_rows = self._derive_limits(
trade_date,
daily,
price_limits=list(reference["price_limits"]),
basic_rows=basic_rows,
previous_limit_rows=list(reference["previous_limit_rows"]),
capital_rows=list(reference["capital_rows"]),
)
previous_limit_rows = list(reference["previous_limit_rows"])
up_rows = [row for row in limit_rows if row.get("limit_type") == "U"]
down_rows = [row for row in limit_rows if row.get("limit_type") == "D"]
broken_rows = [row for row in limit_rows if row.get("limit_type") == "Z"]
limits = [self._normalize_limit(row, "涨停") for row in up_rows]
broken = [self._normalize_limit(row, "炸板") for row in broken_rows]
down_limits = [self._normalize_limit(row, "跌停") for row in down_rows]
previous_limits = [self._normalize_limit(row, "涨停") for row in previous_limit_rows]
yesterday_limits = _build_yesterday_performance(
previous_limits,
daily,
limits,
broken,
down_limits,
)
sectors = _build_sectors(limits)
previous_sectors = _build_sectors(previous_limits)
now = datetime.now().astimezone()
market_status = _realtime_market_status(now.time().replace(tzinfo=None))
dashboard = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date),
"source": "tushare",
"mode": "realtime",
"realtime": True,
"market_status": market_status,
"refresh_mode": "manual",
"auto_refresh": False,
"quote_count": len(daily),
"updated_at": now.isoformat(timespec="seconds"),
"notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
},
"overview": _build_overview(daily, up_rows, down_rows, broken_rows),
"limits": limits,
"broken": broken,
"down_limits": down_limits,
"yesterday_limits": yesterday_limits,
"limit_performance": _build_limit_performance(yesterday_limits),
"ladders": _build_ladders(limits),
"sectors": sectors,
"sector_rotation": _build_sector_rotation(sectors, previous_sectors),
}
return apply_sentiment_to_dashboard(dashboard)
def _load_realtime_reference(
self,
trade_date: str,
previous_trade_date: str,
) -> dict[str, Any]:
cache_key = f"{trade_date}:{previous_trade_date}"
with self._realtime_reference_lock:
cached = self._realtime_reference_cache.get(cache_key)
if cached:
return cached
basic_rows = self.query(
"stock_basic",
{"exchange": "", "list_status": "L"},
"ts_code,name,industry,market,list_date",
)
price_limits = self.query(
"stk_limit",
{"trade_date": trade_date},
"ts_code,trade_date,up_limit,down_limit",
)
previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
capital_rows = self.query(
"daily_basic",
{"trade_date": previous_trade_date},
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
)
if not basic_rows or not price_limits:
raise TushareError(f"Realtime reference data is incomplete for {trade_date}")
result = {
"basic_rows": basic_rows,
"price_limits": price_limits,
"previous_limit_rows": previous_limit_rows,
"capital_rows": capital_rows,
}
with self._realtime_reference_lock:
self._realtime_reference_cache[cache_key] = result
if len(self._realtime_reference_cache) > 3:
oldest = next(iter(self._realtime_reference_cache))
self._realtime_reference_cache.pop(oldest, None)
return result
def realtime_stock_quote(
self,
ts_code: str,
reference_date: str = "",
) -> dict[str, Any]:
rows = self.query("rt_k", {"ts_code": ts_code})
if not rows:
raise TushareError(f"No realtime quote returned for {ts_code}")
row = rows[0]
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
raise TushareError(f"Realtime quote is unavailable for {ts_code}")
basic: dict[str, Any] = {}
with self._realtime_reference_lock:
references = list(self._realtime_reference_cache.values())
for reference in reversed(references):
basic = next(
(
item for item in reference.get("basic_rows") or []
if str(item.get("ts_code") or "") == ts_code
),
{},
)
if basic:
break
if not basic:
basics = self.query(
"stock_basic",
{"ts_code": ts_code},
"ts_code,name,industry,market,list_date",
)
basic = basics[0] if basics else {}
capital = self._latest_capital(ts_code, reference_date)
float_share = _number(capital.get("float_share"))
# rt_k volume is shares; daily_basic float_share is reported in 10k shares.
turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0
market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d")
self._ensure_realtime_market_cache(market_date)
with self._realtime_reference_lock:
market_rows = list((self._latest_realtime_market.get(market_date) or {}).get("rows") or [])
references = list(self._realtime_reference_cache.values())
capital_map: dict[str, dict[str, Any]] = {}
for reference in reversed(references):
capital_map = {
str(item.get("ts_code") or ""): item
for item in reference.get("capital_rows") or []
}
if capital_map:
break
market_amounts = [_number(item.get("amount")) for item in market_rows if _number(item.get("amount")) > 0]
amount_percentile = _value_percentile(_number(row.get("amount")), market_amounts)
market_turnovers = []
for item in market_rows:
item_capital = capital_map.get(str(item.get("ts_code") or ""), {})
item_float_share = _number(item_capital.get("float_share"))
if item_float_share:
market_turnovers.append(_number(item.get("vol")) / item_float_share / 100)
market_turnover = (
sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
)
turnover_relative = turnover_rate / market_turnover if market_turnover else 0
activity = self._stock_activity_metrics(
ts_code,
market_date,
_number(row.get("vol")) / 100,
)
return {
"code": ts_code.split(".")[0],
"ts_code": ts_code,
"name": str(row.get("name") or basic.get("name") or "--").strip(),
"sector": basic.get("industry") or "其他",
"price": round(close, 3),
"change": round((close / previous_close - 1) * 100, 4),
"open": round(_number(row.get("open")), 3),
"high": round(_number(row.get("high")), 3),
"low": round(_number(row.get("low")), 3),
"previous_close": round(previous_close, 3),
"amount_billion": round(_number(row.get("amount")) / 100000000, 3),
"volume": _number(row.get("vol")),
"trade_count": int(_number(row.get("num"))),
"turnover_rate": round(turnover_rate, 4),
"market_turnover_rate": round(market_turnover, 4),
"turnover_relative": round(turnover_relative, 4),
"amount_percentile": round(amount_percentile * 100, 2),
"volume_activity_ratio": activity.get("volume_activity_ratio", 0),
"activity_history_date": activity.get("history_trade_date", ""),
"activity_source": activity.get("source", "unavailable"),
"float_share_10k": float_share,
"capital_trade_date": str(capital.get("trade_date") or ""),
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
"data_source": "tushare",
"realtime": True,
}
def _stock_activity_metrics(
self,
ts_code: str,
reference_date: str,
current_volume_lots: float,
) -> dict[str, Any]:
cache_key = f"{ts_code}:{reference_date}"
with self._realtime_reference_lock:
history = self._stock_activity_cache.get(cache_key)
if history is None:
try:
end = datetime.strptime(reference_date, "%Y%m%d")
except ValueError:
end = datetime.now().astimezone().replace(tzinfo=None)
rows = self.query(
"daily",
{
"ts_code": ts_code,
"start_date": (end - timedelta(days=30)).strftime("%Y%m%d"),
"end_date": reference_date,
},
"ts_code,trade_date,vol,amount",
)
completed = [
item for item in rows
if str(item.get("trade_date") or "") < reference_date and _number(item.get("vol")) > 0
]
completed.sort(key=lambda item: str(item.get("trade_date") or ""))
recent = completed[-5:]
history = {
"average_volume_lots": (
sum(_number(item.get("vol")) for item in recent) / len(recent)
if recent else 0
),
"history_trade_date": str(recent[-1].get("trade_date") or "") if recent else "",
}
with self._realtime_reference_lock:
self._stock_activity_cache[cache_key] = history
if len(self._stock_activity_cache) > 256:
oldest = next(iter(self._stock_activity_cache))
self._stock_activity_cache.pop(oldest, None)
average_volume = _number(history.get("average_volume_lots"))
progress = _trading_session_progress(datetime.now().astimezone().time().replace(tzinfo=None))
expected_volume = average_volume * progress
ratio = current_volume_lots / expected_volume if expected_volume else 0
return {
**history,
"volume_activity_ratio": round(ratio, 4),
"session_progress": round(progress, 4),
"source": "rt_volume/5d_average_at_same_progress" if expected_volume else "unavailable",
}
def realtime_factor_snapshot(self, requested_date: str) -> dict[str, Any]:
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
reference = self._load_realtime_reference(trade_date, previous_trade_date)
codes = [
str(row.get("ts_code") or "")
for row in reference.get("basic_rows") or []
if row.get("ts_code")
]
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
capital_map = {
str(row.get("ts_code") or ""): row
for row in reference.get("capital_rows") or []
}
rows = []
for quote in quotes:
ts_code = str(quote.get("ts_code") or "")
close = _number(quote.get("close"))
previous_close = _number(quote.get("pre_close"))
if not ts_code or close <= 0 or previous_close <= 0:
continue
capital = capital_map.get(ts_code, {})
float_share = _number(capital.get("float_share"))
rows.append(
{
"ts_code": ts_code,
"trade_date": trade_date,
"open": _number(quote.get("open")),
"high": _number(quote.get("high")),
"low": _number(quote.get("low")),
"close": close,
"pct_chg": (close / previous_close - 1) * 100,
"vol": _number(quote.get("vol")) / 100,
"amount": _number(quote.get("amount")),
"turnover_rate": (
_number(quote.get("vol")) / float_share / 100 if float_share else 0
),
"capital_trade_date": str(capital.get("trade_date") or ""),
}
)
if not rows:
raise TushareError(f"No realtime factor snapshot returned for {trade_date}")
return {
"trade_date": trade_date,
"previous_trade_date": previous_trade_date,
"source": "tushare_rt_k",
"realtime": True,
"rows": rows,
}
def _ensure_realtime_market_cache(self, requested_date: str) -> list[dict[str, Any]]:
with self._realtime_reference_lock:
cached = list(
(self._latest_realtime_market.get(requested_date) or {}).get("rows") or []
)
if cached:
return cached
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
if trade_date != requested_date:
return []
reference = self._load_realtime_reference(trade_date, previous_trade_date)
codes = [
str(row.get("ts_code") or "")
for row in reference.get("basic_rows") or []
if row.get("ts_code")
]
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
rows = [
row for row in quotes
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
]
with self._realtime_reference_lock:
self._latest_realtime_market[trade_date] = {
"rows": rows,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
return rows
def _latest_capital(self, ts_code: str, reference_date: str = "") -> dict[str, Any]:
end_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d")
cache_key = f"{ts_code}:{end_date}"
with self._realtime_reference_lock:
cached = self._capital_cache.get(cache_key)
if cached:
return cached
try:
end = datetime.strptime(end_date, "%Y%m%d")
except ValueError:
end = datetime.now().astimezone().replace(tzinfo=None)
end_date = end.strftime("%Y%m%d")
start_date = (end - timedelta(days=20)).strftime("%Y%m%d")
rows = self.query(
"daily_basic",
{"ts_code": ts_code, "start_date": start_date, "end_date": end_date},
"ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share,"
"free_share,total_mv,circ_mv",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
result = rows[-1] if rows else {}
with self._realtime_reference_lock:
self._capital_cache[cache_key] = result
if len(self._capital_cache) > 256:
oldest = next(iter(self._capital_cache))
self._capital_cache.pop(oldest, None)
return result
def _build_overview(
daily: list[dict[str, Any]],
up_rows: list[dict[str, Any]],
down_rows: list[dict[str, Any]],
broken_rows: list[dict[str, Any]],
) -> dict[str, Any]:
up_count = sum(1 for row in daily if _number(row.get("pct_chg")) > 0)
down_count = sum(1 for row in daily if _number(row.get("pct_chg")) < 0)
flat_count = len(daily) - up_count - down_count
amount_billion = sum(
_number(row.get("amount"))
/ (100000000 if row.get("amount_unit") == "yuan" else 100000)
for row in daily
)
limit_count = len(up_rows)
broken_count = len(broken_rows)
seal_rate = round(limit_count / max(limit_count + broken_count, 1) * 100, 1)
return {
"up_count": up_count,
"down_count": down_count,
"flat_count": flat_count,
"limit_up_count": limit_count,
"limit_down_count": len(down_rows),
"broken_count": broken_count,
"amount_billion": round(amount_billion, 1),
"seal_rate": seal_rate,
}
def _build_ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[int, list[dict[str, Any]]] = {}
for row in rows:
groups.setdefault(int(row.get("streak") or 1), []).append(row)
return [
{
"level": level,
"label": "首板" if level == 1 else f"{level}",
"count": len(stocks),
"stocks": sorted(stocks, key=lambda item: item.get("first_time") or "99:99:99"),
}
for level, stocks in sorted(groups.items(), reverse=True)
]
def _build_sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
counts = Counter(row.get("sector") or "其他" for row in rows)
result: list[dict[str, Any]] = []
for name, count in counts.most_common(20):
stocks = [row for row in rows if (row.get("sector") or "其他") == name]
max_streak = max(item.get("streak", 1) for item in stocks)
leader = max(stocks, key=lambda item: (item.get("streak", 1), item.get("amount_billion", 0)))
result.append(
{
"name": name,
"count": count,
"strength": min(100, 44 + count * 8 + max_streak * 5),
"amount_billion": round(sum(item.get("amount_billion", 0) for item in stocks), 1),
"leader": leader.get("name", "--"),
"change": round(sum(item.get("change", 0) for item in stocks) / count, 2),
"max_streak": max_streak,
}
)
return result
def _build_yesterday_performance(
previous_limits: list[dict[str, Any]],
daily: list[dict[str, Any]],
current_limits: list[dict[str, Any]],
current_broken: list[dict[str, Any]],
current_down: list[dict[str, Any]],
) -> list[dict[str, Any]]:
daily_map = {str(row.get("ts_code", "")).split(".")[0]: row for row in daily}
limit_map = {row["code"]: row for row in current_limits}
broken_codes = {row["code"] for row in current_broken}
down_codes = {row["code"] for row in current_down}
result = []
for previous in previous_limits:
code = previous["code"]
daily_row = daily_map.get(code, {})
current = limit_map.get(code)
if current:
outcome = "晋级"
elif code in broken_codes:
outcome = "炸板"
elif code in down_codes:
outcome = "跌停"
else:
outcome = "断板"
result.append(
{
"code": code,
"name": previous["name"],
"prior_streak": previous.get("streak", 1),
"current_streak": current.get("streak", 0) if current else 0,
"current_change": _number(daily_row.get("pct_chg")),
"current_price": _number(daily_row.get("close")),
"sector": previous.get("sector", "其他"),
"reason": previous.get("reason", "待补充"),
"outcome": outcome,
}
)
return result
def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True):
group = [row for row in rows if int(row.get("prior_streak") or 1) == level]
advanced = sum(row.get("outcome") == "晋级" for row in group)
positive = sum(_number(row.get("current_change")) > 0 for row in group)
result.append(
{
"level": level,
"label": "昨日首板" if level == 1 else f"昨日{level}",
"count": len(group),
"advanced": advanced,
"advance_rate": round(advanced / len(group) * 100, 1),
"positive_rate": round(positive / len(group) * 100, 1),
"average_change": round(sum(_number(row.get("current_change")) for row in group) / len(group), 2),
}
)
return result
def _build_sector_rotation(
current: list[dict[str, Any]], previous: list[dict[str, Any]]
) -> list[dict[str, Any]]:
previous_map = {row["name"]: row for row in previous}
result = []
for index, sector in enumerate(current, start=1):
previous_count = int(previous_map.get(sector["name"], {}).get("count", 0))
delta = int(sector["count"]) - previous_count
result.append(
{
**sector,
"rank": index,
"previous_count": previous_count,
"delta": delta,
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
}
)
return result
@@ -0,0 +1,214 @@
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_helpers import _text
from backend.data.providers.tushare_transport import TushareError
class DragonTigerMixin:
def hot_money_profiles(self) -> dict[str, Any]:
rows = self.query("hm_list", {}, "name,desc,orgs")
profiles: list[dict[str, Any]] = []
seen_names: set[str] = set()
for row in rows:
name = str(row.get("name") or "").strip()
if not name or name in seen_names:
continue
seen_names.add(name)
description = _text(row.get("desc"))
organization_text = _text(row.get("orgs"))
parsed_organizations: Any = None
if organization_text.startswith("["):
try:
parsed_organizations = json.loads(organization_text)
except json.JSONDecodeError:
parsed_organizations = None
organization_parts = (
parsed_organizations
if isinstance(parsed_organizations, list)
else re.split(r"[,;\n]+", organization_text)
)
organizations = list(dict.fromkeys(
_text(part)
for part in organization_parts
if _text(part)
))
profiles.append(
{
"id": f"hot-money-profile-{len(profiles) + 1}",
"name": name,
"description": description,
"organizations": organizations,
"organization_count": len(organizations),
}
)
return {
"meta": {
"source": "tushare",
"status": "success" if profiles else "empty",
"schema_version": 1,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "",
},
"summary": {
"profile_count": len(profiles),
"described_count": sum(bool(item["description"]) for item in profiles),
"organization_count": sum(item["organization_count"] for item in profiles),
},
"profiles": profiles,
}
def dragon_tiger(self, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
detail_rows = self.query(
"hm_detail",
{"trade_date": trade_date},
"trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount,"
"hm_name,hm_orgs,tag",
)
notices: list[str] = []
try:
directory_rows = self.query("hm_list", {}, "name,desc,orgs")
except TushareError as exc:
directory_rows = []
notices.append(f"游资名录暂不可用:{exc}")
directory = {
str(row.get("name") or "").strip(): {
"description": _text(row.get("desc")),
"orgs": _text(row.get("orgs")),
}
for row in directory_rows
if str(row.get("name") or "").strip()
}
# 个股龙虎榜仅用于补充涨幅和上榜原因,不参与游资身份识别。
try:
top_rows = self.query(
"top_list",
{"trade_date": trade_date},
"trade_date,ts_code,name,pct_change,reason",
)
except TushareError as exc:
top_rows = []
notices.append(f"个股龙虎榜辅助信息暂不可用:{exc}")
stock_context: dict[str, dict[str, Any]] = {}
for row in top_rows:
ts_code = str(row.get("ts_code") or "")
if ts_code and ts_code not in stock_context:
stock_context[ts_code] = row
groups: dict[str, dict[str, Any]] = {}
for row in detail_rows:
trader_name = str(row.get("hm_name") or "未命名游资").strip()
ts_code = str(row.get("ts_code") or "").strip()
stock = stock_context.get(ts_code, {})
directory_item = directory.get(trader_name, {})
seat_name = _text(row.get("hm_orgs")) or directory_item.get("orgs") or "--"
buy = round(_number(row.get("buy_amount")) / 1000000, 2)
sell = round(_number(row.get("sell_amount")) / 1000000, 2)
net_buy = round(_number(row.get("net_amount")) / 1000000, 2)
group = groups.setdefault(
trader_name,
{
"name": trader_name,
"description": directory_item.get("description") or "",
"directory_orgs": directory_item.get("orgs") or "",
"identity_type": "trader",
"identity_source": "tushare_hm",
"recognized": True,
"buy_million": 0.0,
"sell_million": 0.0,
"net_buy_million": 0.0,
"seat_names": set(),
"stock_codes": set(),
"operations": [],
},
)
group["buy_million"] += buy
group["sell_million"] += sell
group["net_buy_million"] += net_buy
if seat_name != "--":
group["seat_names"].add(seat_name)
code = ts_code.split(".")[0]
if code:
group["stock_codes"].add(code)
group["operations"].append(
{
"code": code,
"ts_code": ts_code,
"name": row.get("ts_name") or stock.get("name") or "--",
"change": (
_number(stock.get("pct_change"))
if stock.get("pct_change") is not None
else None
),
"direction": "买入" if net_buy > 0 else "卖出" if net_buy < 0 else "持平",
"buy_million": buy,
"sell_million": sell,
"net_buy_million": net_buy,
"seat_name": seat_name,
"seat_alias": trader_name,
"tag": _text(row.get("tag")) or "--",
"reason": _text(stock.get("reason")) or "--",
}
)
traders = list(groups.values())
traders.sort(key=lambda item: abs(item["net_buy_million"]), reverse=True)
for index, group in enumerate(traders, start=1):
group["id"] = f"hot-money-{index}"
group["buy_million"] = round(group["buy_million"], 2)
group["sell_million"] = round(group["sell_million"], 2)
group["net_buy_million"] = round(group["net_buy_million"], 2)
group["seat_count"] = len(group.pop("seat_names"))
group["stock_count"] = len(group.pop("stock_codes"))
group["operation_count"] = len(group["operations"])
group["operations"].sort(
key=lambda item: abs(float(item.get("net_buy_million") or 0)), reverse=True
)
operation_count = sum(item["operation_count"] for item in traders)
active_stocks = {
operation["code"] for item in traders for operation in item["operations"]
if operation["code"]
}
net_buy_total = round(sum(item["net_buy_million"] for item in traders), 2)
status = "success" if detail_rows else "partial" if top_rows else "empty"
if not detail_rows:
notices.insert(
0,
f"当日有 {len(stock_context)} 只股票上榜,但未返回可识别的游资每日明细。"
if top_rows
else "该交易日未返回龙虎榜或游资每日明细。",
)
return {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"source": "tushare",
"status": status,
"schema_version": 3,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(notices),
},
"summary": {
"trader_count": len(traders),
"identity_count": len(traders),
"operation_count": operation_count,
"active_stock_count": len(active_stocks),
"seat_net_buy_million": net_buy_total,
"unclassified_count": 0,
"directory_count": len(directory),
"official_stock_count": len(stock_context),
},
"traders": traders,
"unclassified_seats": [],
"rows": [],
}
@@ -0,0 +1,64 @@
from __future__ import annotations
from datetime import time as dt_time
from typing import Any
from backend.data.numbers import finite_number as _number
def _text(value: Any) -> str:
if isinstance(value, (list, tuple, set)):
return "".join(str(item).strip() for item in value if str(item).strip())
return str(value or "").strip()
def _prices_equal(left: Any, right: Any) -> bool:
if left is None or right is None:
return False
return abs(_number(left) - _number(right)) < 0.005
def _value_percentile(value: float, population: list[float]) -> float:
valid = sorted(item for item in population if item >= 0)
if not valid:
return 0.0
below = sum(item < value for item in valid)
equal = sum(item == value for item in valid)
return (below + equal * 0.5) / len(valid)
def _trading_session_progress(current_time: dt_time) -> float:
morning_start = dt_time(9, 30)
morning_end = dt_time(11, 30)
afternoon_start = dt_time(13, 0)
afternoon_end = dt_time(15, 0)
if current_time <= morning_start:
return 0.05
if current_time <= morning_end:
minutes = (current_time.hour * 60 + current_time.minute) - (9 * 60 + 30)
return max(0.05, min(0.5, minutes / 240))
if current_time < afternoon_start:
return 0.5
if current_time <= afternoon_end:
minutes = (current_time.hour * 60 + current_time.minute) - 13 * 60
return max(0.5, min(1.0, 0.5 + minutes / 240))
return 1.0
def _display_time(value: Any) -> str:
raw = str(value or "").replace(":", "").zfill(6)
if not raw.strip("0"):
return "--"
return f"{raw[:2]}:{raw[2:4]}:{raw[4:6]}"
def _realtime_market_status(current_time: dt_time) -> str:
if current_time < dt_time(9, 25):
return "pre_open"
if current_time < dt_time(9, 30):
return "auction"
if current_time <= dt_time(11, 30) or dt_time(13, 0) <= current_time <= dt_time(15, 0):
return "trading"
if current_time < dt_time(13, 0):
return "lunch_break"
return "closed"
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_transport import TushareError
class IndexMixin:
def market_indices(self, requested_date: str, lookback_days: int = 45) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start_date = (end - timedelta(days=max(30, lookback_days * 2))).strftime("%Y%m%d")
index_names = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
}
indices = []
for ts_code, name in index_names.items():
rows = self.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg,vol,amount",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
if not rows:
continue
latest = rows[-1]
close = _number(latest.get("close"))
close_5d = _number(rows[-6].get("close")) if len(rows) >= 6 else _number(rows[0].get("close"))
close_20d = _number(rows[-21].get("close")) if len(rows) >= 21 else _number(rows[0].get("close"))
indices.append(
{
"ts_code": ts_code,
"name": name,
"trade_date": str(latest.get("trade_date") or trade_date),
"close": close,
"pct_chg": round(_number(latest.get("pct_chg")), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": round((close / close_20d - 1) * 100, 3) if close_20d else 0,
"amount_billion": round(_number(latest.get("amount")) / 100000, 2),
}
)
if not indices:
raise TushareError(f"No index data returned for {trade_date}")
return {
"trade_date": trade_date,
"source": "tushare",
"realtime": False,
"precise": all(item["trade_date"] == trade_date for item in indices),
"indices": indices,
"aggregate": {
"average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3),
"average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3),
"average_return_20d": round(sum(item["return_20d"] for item in indices) / len(indices), 3),
},
}
def realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
index_names = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
}
rows = self.query("rt_idx_k", {"ts_code": ",".join(index_names)}, "")
row_map = {str(row.get("ts_code") or ""): row for row in rows}
indices = []
for ts_code, name in index_names.items():
row = row_map.get(ts_code)
if not row:
continue
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
history = self.query(
"index_daily",
{
"ts_code": ts_code,
"start_date": (datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)).strftime("%Y%m%d"),
"end_date": trade_date,
},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
previous_closes = [
_number(item.get("close")) for item in history
if str(item.get("trade_date") or "") < trade_date and _number(item.get("close")) > 0
]
close_5d = previous_closes[-5] if len(previous_closes) >= 5 else previous_closes[0] if previous_closes else previous_close
indices.append(
{
"ts_code": ts_code,
"name": str(row.get("name") or name).strip(),
"trade_date": trade_date,
"close": close,
"pct_chg": round((close / previous_close - 1) * 100, 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"amount_billion": round(_number(row.get("amount")) / 100000000, 2),
}
)
if len(indices) != len(index_names):
raise TushareError("Realtime index quotes are incomplete")
return {
"trade_date": trade_date,
"source": "tushare_rt_idx_k",
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3),
"average_return_5d": round(sum(item["return_5d"] for item in indices) / len(indices), 3),
"average_return_20d": 0,
},
}
@@ -0,0 +1,616 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_transport import TushareError
class ShenwanIndustryMixin:
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
"""Return the Shenwan industry active for a stock on trade_date."""
rows = []
for is_new in ("Y", "N"):
rows.extend(
self.query(
"index_member_all",
{"ts_code": ts_code, "is_new": is_new},
"l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,"
"ts_code,name,in_date,out_date,is_new",
)
)
rows = _reconcile_membership_rows(rows)
matched = [row for row in rows if _membership_active_on(row, trade_date)]
if not matched:
matched = [
row for row in rows
if row.get("is_new") == "Y"
and str(row.get("in_date") or "") <= trade_date
]
if not matched:
raise TushareError(f"No Shenwan industry returned for {ts_code}")
row = max(
matched,
key=lambda item: (
str(item.get("in_date") or ""),
1 if item.get("is_new") == "Y" else 0,
str(item.get("l3_code") or item.get("l2_code") or ""),
),
)
return {
"l1_code": str(row.get("l1_code") or ""),
"l1_name": str(row.get("l1_name") or ""),
"l2_code": str(row.get("l2_code") or ""),
"l2_name": str(row.get("l2_name") or ""),
"l3_code": str(row.get("l3_code") or ""),
"l3_name": str(row.get("l3_name") or ""),
"in_date": str(row.get("in_date") or ""),
"out_date": str(row.get("out_date") or ""),
"is_new": str(row.get("is_new") or ""),
}
def sw_sector_snapshot(
self,
ts_code: str,
requested_date: str,
realtime_expected: bool = False,
allow_realtime_close: bool = False,
) -> dict[str, Any]:
"""Build the single Shenwan L2 sector context used by heaven trend."""
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
industry = self.sw_stock_industry(ts_code, trade_date)
sector_code = str(industry.get("l2_code") or "")
if not sector_code:
raise TushareError(f"Shenwan L2 code is unavailable for {ts_code}")
members = self._sw_sector_members(sector_code, trade_date)
if not members:
raise TushareError(f"No Shenwan members returned for {sector_code}")
raw_member_count = len(members)
members, excluded_members = _filter_members_by_listing(
members,
self._stock_listing_reference(),
trade_date,
)
if not members:
raise TushareError(f"No listed Shenwan members returned for {sector_code}")
if realtime_expected:
snapshot = self._sw_realtime_sector_snapshot(
industry,
members,
trade_date,
previous_trade_date,
finalized=False,
)
snapshot.update({
"raw_member_count": raw_member_count,
"excluded_member_count": len(excluded_members),
"excluded_members": excluded_members,
})
return snapshot
member_set = {str(item.get("ts_code") or "") for item in members}
member_names = {
str(item.get("ts_code") or ""): str(item.get("name") or "")
for item in members
}
member_rows = [
row for row in self._load_daily(trade_date)
if str(row.get("ts_code") or "") in member_set
]
quoted_codes = {str(row.get("ts_code") or "") for row in member_rows}
suspended_members = self._confirmed_suspended_members(
members, quoted_codes, trade_date
)
up_count = sum(_number(row.get("pct_chg")) > 0 for row in member_rows)
down_count = sum(_number(row.get("pct_chg")) < 0 for row in member_rows)
leader = max(member_rows, key=lambda row: _number(row.get("pct_chg")), default={})
leader_code = str(leader.get("ts_code") or "")
equal_change = (
sum(_number(row.get("pct_chg")) for row in member_rows) / len(member_rows)
if member_rows else 0
)
coverage = len(member_rows) / max(len(members), 1) * 100
explained_count = len(member_rows) + len(suspended_members)
explained_coverage = explained_count / max(len(members), 1) * 100
coverage_issue = _sector_coverage_issue(
len(members),
len(member_rows),
explained_coverage,
explained_count,
)
inner_precise = not coverage_issue
inner_error = coverage_issue
amount_billion = sum(_number(row.get("amount")) for row in member_rows) / 100000
rows = self.query(
"sw_daily",
{"ts_code": sector_code, "trade_date": trade_date},
"ts_code,trade_date,name,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
)
daily = rows[0] if rows else {}
actual_trade_date = str(daily.get("trade_date") or "")
outer_precise = actual_trade_date == trade_date
outer_error = "" if outer_precise else (
f"No Shenwan daily returned for {sector_code} on {trade_date}"
)
if not outer_precise and allow_realtime_close:
try:
return self._sw_realtime_sector_snapshot(
industry,
members,
trade_date,
previous_trade_date,
finalized=True,
)
except TushareError as exc:
outer_error = f"{outer_error}; realtime close fallback failed: {exc}"
official_change = _number(daily.get("pct_change")) if outer_precise else None
return {
"code": sector_code,
"name": industry.get("l2_name") or daily.get("name") or sector_code,
"leader": str(leader.get("name") or member_names.get(leader_code) or "--"),
"leader_code": leader_code,
"leading_pct": round(_number(leader.get("pct_chg")), 3),
"change": round(official_change, 3) if official_change is not None else None,
"member_equal_change": round(equal_change, 3),
"turnover_rate": 0,
"up_count": up_count,
"down_count": down_count,
"flat_count": len(member_rows) - up_count - down_count,
"member_count": len(members),
"raw_member_count": raw_member_count,
"excluded_member_count": len(excluded_members),
"excluded_members": excluded_members,
"quote_count": len(member_rows),
"coverage": round(coverage, 1),
"explained_count": explained_count,
"explained_coverage": round(explained_coverage, 1),
"suspended_count": len(suspended_members),
"suspended_members": suspended_members,
"strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1),
"amount_billion": round(amount_billion, 2),
"count": 0,
"max_streak": 0,
"source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily",
"inner_source": "tushare_member_daily",
"outer_source": "tushare_sw_daily" if outer_precise else "unavailable",
"taxonomy": "sw_l2",
"industry": industry,
"trade_date": trade_date,
"inner_trade_date": trade_date if member_rows else "",
"outer_trade_date": actual_trade_date,
"realtime": False,
"finalized": True,
"inner_precise": inner_precise,
"outer_precise": outer_precise,
"precise": inner_precise and outer_precise,
"inner_error": inner_error,
"outer_error": outer_error,
"schema_version": 6,
"methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合",
}
def _sw_sector_members(
self,
sector_code: str,
trade_date: str,
) -> list[dict[str, Any]]:
rows = []
for is_new in ("Y", "N"):
rows.extend(
self.query(
"index_member_all",
{"l2_code": sector_code, "is_new": is_new},
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
)
)
deduped: dict[str, dict[str, Any]] = {}
for row in _reconcile_membership_rows(rows):
code = str(row.get("ts_code") or "")
if code and _membership_active_on(row, trade_date):
current = deduped.get(code)
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
deduped[code] = row
return list(deduped.values())
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
"""Return constituents active in a Shenwan L2 industry on the target date."""
return self._sw_sector_members(sector_code, trade_date)
def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
now = datetime.now().astimezone()
with self._stock_listing_lock:
loaded_at = self._stock_listing_cache.get("loaded_at")
cached = self._stock_listing_cache.get("rows")
if (
isinstance(loaded_at, datetime)
and isinstance(cached, dict)
and now - loaded_at < timedelta(hours=6)
):
return cached
rows: list[dict[str, Any]] = []
try:
for status in ("L", "D", "P"):
rows.extend(self.query(
"stock_basic",
{"list_status": status},
"ts_code,name,list_status,list_date,delist_date",
))
except TushareError:
# Unknown status must remain in the denominator so a reference-data
# failure cannot silently improve coverage.
return {}
reference = {
str(row.get("ts_code") or ""): dict(row)
for row in rows
if row.get("ts_code")
}
with self._stock_listing_lock:
type(self)._stock_listing_cache = {"loaded_at": now, "rows": reference}
return reference
def _confirmed_suspended_members(
self,
members: list[dict[str, Any]],
quoted_codes: set[str],
trade_date: str,
) -> list[dict[str, str]]:
suspended: list[dict[str, str]] = []
for member in members:
code = str(member.get("ts_code") or "")
if not code or code in quoted_codes:
continue
cache_key = f"{trade_date}:{code}"
with self._suspension_lock:
cached = self._suspension_cache.get(cache_key, "missing")
if cached == "missing":
try:
rows = self.query(
"suspend_d",
{"ts_code": code},
"ts_code,suspend_date,resume_date,ann_date,suspend_reason,reason_type",
)
except TushareError:
rows = []
active = [
row for row in rows
if str(row.get("suspend_date") or "")
and str(row.get("suspend_date") or "") <= trade_date
and (
not str(row.get("resume_date") or "")
or trade_date < str(row.get("resume_date") or "")
)
]
row = max(
active,
key=lambda item: str(item.get("suspend_date") or ""),
default=None,
)
cached = ({
"ts_code": code,
"name": str(member.get("name") or code),
"suspend_date": str(row.get("suspend_date") or ""),
"resume_date": str(row.get("resume_date") or ""),
"reason": str(row.get("suspend_reason") or row.get("reason_type") or "已确认停牌"),
} if row else None)
with self._suspension_lock:
type(self)._suspension_cache[cache_key] = cached
if isinstance(cached, dict):
suspended.append(cached)
return suspended
def _sw_realtime_sector_snapshot(
self,
industry: dict[str, Any],
members: list[dict[str, Any]],
trade_date: str,
previous_trade_date: str,
finalized: bool = False,
) -> dict[str, Any]:
sector_code = str(industry.get("l2_code") or "")
sw_rows = self.query(
"rt_sw_k",
{"ts_code": sector_code},
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
)
sw_row = sw_rows[0] if sw_rows else {}
trade_time = str(sw_row.get("trade_time") or "")
quote_date = trade_time[:10].replace("-", "")
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else ""
outer_precise = bool(sw_row and quote_date == trade_date)
if finalized and (not quote_clock or quote_clock < "15:00:00"):
outer_precise = False
official_change = _number(sw_row.get("pct_change"))
if not official_change:
close = _number(sw_row.get("close"))
pre_close = _number(sw_row.get("pre_close"))
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
if not outer_precise:
official_change = None
outer_error = ""
if not sw_row:
outer_error = f"No Shenwan realtime index returned for {sector_code}"
elif quote_date != trade_date:
outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}"
elif finalized and (not quote_clock or quote_clock < "15:00:00"):
outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})"
valid: list[dict[str, Any]] = []
codes: list[str] = []
reference: dict[str, Any] = {}
inner_error = ""
try:
reference = self._load_realtime_reference(trade_date, previous_trade_date)
active_codes = {
str(row.get("ts_code") or "")
for row in reference.get("basic_rows") or []
if row.get("ts_code")
}
codes = [
str(row.get("ts_code") or "")
for row in members
if str(row.get("ts_code") or "") in active_codes
]
if codes:
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
for row in quotes:
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
valid.append({**row, "change": (close / previous_close - 1) * 100})
else:
inner_error = f"No active Shenwan members returned for {sector_code}"
except TushareError as exc:
inner_error = str(exc)
coverage = len(valid) / max(len(codes), 1) * 100
valid_codes = {str(item.get("ts_code") or "") for item in valid}
suspended_members = self._confirmed_suspended_members(
members, valid_codes, trade_date
)
explained_count = len(valid) + len(suspended_members)
explained_coverage = explained_count / max(len(codes), 1) * 100
coverage_issue = _sector_coverage_issue(
len(codes), len(valid), explained_coverage, explained_count
)
inner_precise = bool(codes) and not coverage_issue
if not inner_precise and not inner_error:
inner_error = coverage_issue or "申万实时有效成分为空"
up_count = sum(item["change"] > 0 for item in valid)
down_count = sum(item["change"] < 0 for item in valid)
leader = max(valid, key=lambda item: item["change"], default={})
leader_code = str(leader.get("ts_code") or "")
member_names = {
str(item.get("ts_code") or ""): str(item.get("name") or "")
for item in members
}
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
try:
self._ensure_realtime_market_cache(trade_date)
with self._realtime_reference_lock:
market_rows = list(
(self._latest_realtime_market.get(trade_date) or {}).get("rows") or []
)
except TushareError as exc:
market_rows = []
inner_precise = False
inner_error = inner_error or str(exc)
capital_map = {
str(item.get("ts_code") or ""): item
for item in reference.get("capital_rows") or []
}
sector_turnovers = []
for item in valid:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
market_turnovers = []
for item in market_rows:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
relative_turnover = average_turnover / market_turnover if market_turnover else 0
if not relative_turnover:
inner_precise = False
inner_error = inner_error or "Shenwan member relative turnover is unavailable"
return {
"code": sector_code,
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
"leader": str(leader.get("name") or member_names.get(leader_code) or "--").strip(),
"leader_code": leader_code,
"leading_pct": round(_number(leader.get("change")), 3),
"change": round(official_change, 3) if official_change is not None else None,
"member_equal_change": round(equal_change, 3),
"turnover_rate": round(average_turnover, 4),
"market_turnover_rate": round(market_turnover, 4),
"relative_turnover": round(relative_turnover, 4),
"up_count": up_count,
"down_count": down_count,
"flat_count": len(valid) - up_count - down_count,
"member_count": len(codes),
"quote_count": len(valid),
"coverage": round(coverage, 1),
"explained_count": explained_count,
"explained_coverage": round(explained_coverage, 1),
"suspended_count": len(suspended_members),
"suspended_members": suspended_members,
"strength": round(max(0, min(100, 50 + (official_change if official_change is not None else equal_change) * 5)), 1),
"amount_billion": round(amount_billion, 2),
"count": sum(item["change"] >= 9.5 for item in valid),
"max_streak": 0,
"source": "tushare_rt_sw_k+sw_members_rt_k",
"inner_source": "tushare_sw_members+rt_k",
"outer_source": "tushare_rt_sw_k",
"taxonomy": "sw_l2",
"industry": industry,
"trade_date": trade_date,
"inner_trade_date": trade_date if valid else "",
"outer_trade_date": quote_date,
"trade_time": trade_time,
"realtime": True,
"finalized": finalized,
"inner_precise": inner_precise,
"outer_precise": outer_precise,
"precise": inner_precise and outer_precise,
"inner_error": inner_error,
"outer_error": outer_error,
"schema_version": 6,
"methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合",
}
def _filter_members_by_listing(
members: list[dict[str, Any]],
listing_reference: dict[str, dict[str, Any]],
trade_date: str,
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
eligible: list[dict[str, Any]] = []
excluded: list[dict[str, str]] = []
for member in members:
code = str(member.get("ts_code") or "")
listing = listing_reference.get(code)
if not listing:
eligible.append(member)
continue
list_date = str(listing.get("list_date") or "")
delist_date = str(listing.get("delist_date") or "")
reason = ""
effective_date = ""
if delist_date and delist_date <= trade_date:
reason = "目标日期前已退市"
effective_date = delist_date
elif list_date and list_date > trade_date:
reason = "目标日期尚未上市"
effective_date = list_date
if not reason:
eligible.append(member)
continue
excluded.append({
"ts_code": code,
"name": str(member.get("name") or listing.get("name") or code),
"reason": reason,
"effective_date": effective_date,
})
return eligible, excluded
def _sector_coverage_issue(
member_count: int,
quote_count: int,
coverage: float | None = None,
explained_count: int | None = None,
) -> str:
members = max(0, int(member_count or 0))
quotes = max(0, min(int(quote_count or 0), members))
if members <= 0:
if coverage is not None and float(coverage) >= 90:
return ""
if coverage is not None:
return "行业成分行情覆盖率低于90%"
return "申万有效成分为空"
explained = quotes if explained_count is None else max(
quotes, min(int(explained_count or 0), members)
)
actual_coverage = (
float(coverage)
if coverage is not None
else explained / members * 100
)
missing = members - explained
if members <= 7 and missing:
return f"小型行业有效成分状态仅确认 {explained}/{members},要求全部可解释"
if members <= 20 and (actual_coverage < 90 or missing > 1):
return f"中型行业有效成分状态仅确认 {explained}/{members},要求覆盖率至少90%且最多缺1只"
if members > 20 and actual_coverage < 90:
return f"行业有效成分状态仅确认 {explained}/{members},覆盖率低于90%"
return ""
def _membership_active_on(row: dict[str, Any], trade_date: str) -> bool:
start = str(row.get("in_date") or "")
end = str(row.get("out_date") or "")
return (not start or start <= trade_date) and (not end or end > trade_date)
def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Merge duplicate Y/N membership rows before evaluating their date interval."""
reconciled: dict[tuple[str, str, str, str, str], dict[str, Any]] = {}
for raw in rows:
row = dict(raw)
key = (
str(row.get("ts_code") or ""),
str(row.get("l1_code") or ""),
str(row.get("l2_code") or ""),
str(row.get("l3_code") or ""),
str(row.get("in_date") or ""),
)
current = reconciled.get(key)
if current is None:
reconciled[key] = row
continue
current_end = str(current.get("out_date") or "")
candidate_end = str(row.get("out_date") or "")
if candidate_end and not current_end:
current["out_date"] = candidate_end
current["is_new"] = row.get("is_new") or current.get("is_new")
for field, value in row.items():
if not current.get(field) and value not in (None, ""):
current[field] = value
return list(reconciled.values())
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
if not rows:
return None
target = identifier.strip().upper()
code_match = next(
(row for row in rows if str(row.get("ts_code") or "").strip().upper() == target),
None,
)
if code_match:
return code_match
def normalized(value: Any) -> str:
text = str(value or "").strip().replace(" ", "")
for suffix in ("板块", "概念", "行业"):
text = text.removesuffix(suffix)
aliases = {
"元器件": "元件",
"电子元器件": "元件",
}
return aliases.get(text, text)
target_name = normalized(identifier)
exact = [row for row in rows if normalized(row.get("name")) == target_name]
if exact:
return min(exact, key=_sector_match_priority)
fuzzy = [
row for row in rows
if target_name and (
target_name in normalized(row.get("name"))
or normalized(row.get("name")) in target_name
)
]
return min(
fuzzy,
key=lambda row: (len(normalized(row.get("name"))), *_sector_match_priority(row)),
) if fuzzy else None
def _sector_match_priority(row: dict[str, Any]) -> tuple[int, int, int]:
code = str(row.get("ts_code") or "")
exchange = str(row.get("exchange") or "").upper()
return (
0 if exchange == "A" else 1,
0 if code.startswith("881") else 1,
0 if _number(row.get("count")) > 0 else 1,
)
@@ -0,0 +1,224 @@
from __future__ import annotations
import math
import re
from datetime import datetime, time as dt_time
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_industries import _match_sector_row
from backend.data.providers.tushare_transport import TushareError
class SectorMixin:
def sector_snapshot(
self,
identifier: str,
requested_date: str,
realtime_expected: bool | None = None,
) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
raw_identifier = identifier.strip()
if not raw_identifier:
raise TushareError("Sector identifier is empty")
errors = []
now = datetime.now().astimezone()
if realtime_expected is None:
realtime_expected = (
trade_date == now.strftime("%Y%m%d")
and dt_time(9, 15) <= now.time().replace(tzinfo=None) <= dt_time(15, 5)
)
try:
dc_params = {"trade_date": trade_date}
if re.fullmatch(r"[A-Z0-9.]+", raw_identifier.upper()) and "." in raw_identifier:
dc_params["ts_code"] = raw_identifier.upper()
else:
dc_params["name"] = raw_identifier
dc_rows = self.query(
"dc_index",
dc_params,
"ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct,"
"total_mv,turnover_rate,up_num,down_num",
)
if not dc_rows and "name" in dc_params:
dc_rows = self.query(
"dc_index",
{"trade_date": trade_date},
"ts_code,trade_date,name,leading,leading_code,pct_change,leading_pct,"
"total_mv,turnover_rate,up_num,down_num",
)
dc_row = _match_sector_row(dc_rows, raw_identifier)
if dc_row and not realtime_expected:
change = _number(dc_row.get("pct_change"))
actual_trade_date = str(dc_row.get("trade_date") or "")
return {
"code": dc_row.get("ts_code") or "",
"name": dc_row.get("name") or raw_identifier,
"leader": dc_row.get("leading") or "--",
"leader_code": dc_row.get("leading_code") or "",
"leading_pct": _number(dc_row.get("leading_pct")),
"change": change,
"turnover_rate": _number(dc_row.get("turnover_rate")),
"up_count": int(_number(dc_row.get("up_num"))),
"down_count": int(_number(dc_row.get("down_num"))),
"total_mv": _number(dc_row.get("total_mv")),
"strength": round(max(0, min(100, 50 + change * 5)), 1),
"amount_billion": 0,
"count": 0,
"max_streak": 0,
"source": "tushare_dc",
"trade_date": actual_trade_date,
"realtime": False,
"precise": actual_trade_date == trade_date,
}
except TushareError as exc:
errors.append(f"DC: {exc}")
ts_code = raw_identifier.upper()
if re.fullmatch(r"\d{6}", ts_code):
ts_code = f"{ts_code}.TI"
try:
if re.fullmatch(r"\d{6}\.TI", ts_code):
index_rows = self.query(
"ths_index",
{"ts_code": ts_code},
"ts_code,name,count,exchange,list_date,type",
)
else:
index_rows = self.query(
"ths_index",
{},
"ts_code,name,count,exchange,list_date,type",
)
basic = _match_sector_row(index_rows, raw_identifier)
if not basic:
raise TushareError(f"No THS sector returned for {raw_identifier}")
except TushareError as exc:
errors.append(f"THS: {exc}")
raise TushareError("; ".join(errors)) from exc
actual_code = str(basic.get("ts_code") or ts_code)
if realtime_expected:
try:
realtime_sector = self._realtime_sector_snapshot(actual_code, basic, trade_date)
if realtime_sector:
return realtime_sector
except TushareError as exc:
errors.append(f"THS realtime members: {exc}")
daily_rows = self.query(
"ths_daily",
{"ts_code": actual_code, "trade_date": trade_date},
"ts_code,trade_date,close,pct_change,vol,turnover_rate,total_mv,float_mv",
)
daily = daily_rows[0] if daily_rows else {}
actual_trade_date = str(daily.get("trade_date") or "")
change = _number(daily.get("pct_change"))
return {
"code": actual_code,
"name": basic.get("name") or raw_identifier,
"leader": "--",
"change": change,
"leading_pct": change,
"turnover_rate": _number(daily.get("turnover_rate")),
"up_count": 0,
"down_count": 0,
"strength": round(max(0, min(100, 50 + change * 5)), 1),
"amount_billion": 0,
"count": 0,
"max_streak": 0,
"source": "tushare_ths",
"trade_date": actual_trade_date,
"realtime": False,
"precise": actual_trade_date == trade_date,
}
def _realtime_sector_snapshot(
self,
sector_code: str,
basic: dict[str, Any],
trade_date: str,
) -> dict[str, Any] | None:
members = self.query(
"ths_member",
{"ts_code": sector_code, "is_new": "Y"},
"ts_code,con_code,con_name,is_new",
)
codes = [str(row.get("con_code") or "") for row in members if row.get("con_code")]
if not codes:
return None
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
valid = []
for row in quotes:
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
valid.append(
{
**row,
"change": (close / previous_close - 1) * 100,
}
)
minimum = max(1, math.ceil(len(codes) * 0.9))
if len(valid) < minimum:
raise TushareError(
f"Realtime sector coverage is insufficient ({len(valid)}/{len(codes)})"
)
up_count = sum(item["change"] > 0 for item in valid)
down_count = sum(item["change"] < 0 for item in valid)
flat_count = len(valid) - up_count - down_count
leader = max(valid, key=lambda item: item["change"])
change = sum(item["change"] for item in valid) / len(valid)
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
self._ensure_realtime_market_cache(trade_date)
with self._realtime_reference_lock:
references = list(self._realtime_reference_cache.values())
market_rows = list((self._latest_realtime_market.get(trade_date) or {}).get("rows") or [])
capital_map: dict[str, dict[str, Any]] = {}
for reference in reversed(references):
capital_map = {
str(item.get("ts_code") or ""): item
for item in reference.get("capital_rows") or []
}
if capital_map:
break
sector_turnovers = []
for item in valid:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
market_turnovers = []
for item in market_rows:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
relative_turnover = average_turnover / market_turnover if market_turnover else 0
return {
"code": sector_code,
"name": basic.get("name") or sector_code,
"leader": str(leader.get("name") or "--").strip(),
"leader_code": leader.get("ts_code") or "",
"leading_pct": round(leader["change"], 3),
"change": round(change, 3),
"turnover_rate": round(average_turnover, 4),
"market_turnover_rate": round(market_turnover, 4),
"relative_turnover": round(relative_turnover, 4),
"up_count": up_count,
"down_count": down_count,
"flat_count": flat_count,
"member_count": len(codes),
"quote_count": len(valid),
"coverage": round(len(valid) / len(codes) * 100, 1),
"strength": round(max(0, min(100, 50 + change * 5)), 1),
"amount_billion": round(amount_billion, 2),
"count": sum(item["change"] >= 9.5 for item in valid),
"max_streak": 0,
"source": "tushare_rt_ths_members",
"trade_date": trade_date,
"realtime": True,
"precise": True,
"methodology": "同花顺行业最新成分股的 rt_k 等权涨跌、宽度与成交额聚合",
}
@@ -0,0 +1,137 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
class StockMixin:
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
daily = self.query(
"daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
factors = self.query(
"adj_factor",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,adj_factor",
)
basics = self.query(
"stock_basic",
{"ts_code": ts_code},
"ts_code,symbol,name,area,industry,market,list_date",
)
daily_basics = self.query(
"daily_basic",
{"ts_code": ts_code, "trade_date": trade_date},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
)
moneyflow = self.query(
"moneyflow",
{"ts_code": ts_code, "trade_date": trade_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors}
latest_factor = max(factor_map.values(), default=1) or 1
prices = []
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]:
factor = factor_map.get(row.get("trade_date"), latest_factor)
ratio = factor / latest_factor
prices.append(
{
"trade_date": _display_date(str(row.get("trade_date", ""))),
"open": round(_number(row.get("open")) * ratio, 3),
"high": round(_number(row.get("high")) * ratio, 3),
"low": round(_number(row.get("low")) * ratio, 3),
"close": round(_number(row.get("close")) * ratio, 3),
"change": _number(row.get("pct_chg")),
"volume": _number(row.get("vol")),
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
}
)
flow = moneyflow[0] if moneyflow else {}
basic = basics[0] if basics else {}
daily_basic = daily_basics[0] if daily_basics else {}
latest = prices[-1] if prices else {}
actual_trade_date = max(
(str(row.get("trade_date") or "") for row in daily),
default=trade_date,
) or trade_date
return {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_trade_date),
"source": "tushare",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "",
},
"stock": {
"code": ts_code.split(".")[0],
"ts_code": ts_code,
"name": basic.get("name") or "--",
"industry": basic.get("industry") or "其他",
"area": basic.get("area") or "--",
"market": basic.get("market") or "--",
"list_date": _display_date(str(basic.get("list_date") or "")),
"price": latest.get("close", 0),
"change": latest.get("change", 0),
"turnover_rate": _number(daily_basic.get("turnover_rate")),
"volume_ratio": _number(daily_basic.get("volume_ratio")),
"amount_billion": latest.get("amount_billion", 0),
},
"prices": prices,
"moneyflow": {
"net_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_million": round(
(_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount"))
- _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100,
2,
),
"medium_million": round(
(_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100,
2,
),
"small_million": round(
(_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100,
2,
),
},
}
def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
display_date = _display_date(trade_date)
rows = self.query(
"stk_mins",
{
"ts_code": ts_code,
"freq": "1min",
"start_date": f"{display_date} 09:00:00",
"end_date": f"{display_date} 15:30:00",
},
"ts_code,trade_time,open,close,high,low,vol,amount",
)
points = []
for row in sorted(rows, key=lambda item: str(item.get("trade_time") or "")):
trade_time = str(row.get("trade_time") or "")
if not trade_time:
continue
points.append(
{
"time": trade_time[-8:-3] if len(trade_time) >= 8 else trade_time,
"open": round(_number(row.get("open")), 3),
"high": round(_number(row.get("high")), 3),
"low": round(_number(row.get("low")), 3),
"close": round(_number(row.get("close")), 3),
"volume": _number(row.get("vol")),
"amount": _number(row.get("amount")),
}
)
return {"trade_date": display_date, "points": points}
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import Any
TUSHARE_URL = "http://api.tushare.pro"
class TushareError(RuntimeError):
pass
class TushareTransportMixin:
def query(
self,
api_name: str,
params: dict[str, Any] | None = None,
fields: str = "",
) -> list[dict[str, Any]]:
payload = json.dumps(
{
"api_name": api_name,
"token": self.token,
"params": params or {},
"fields": fields,
}
).encode("utf-8")
request = urllib.request.Request(
TUSHARE_URL,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "XiaobaiReviewWeb/0.2"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise TushareError(f"Tushare request failed: {exc}") from exc
if result.get("code") != 0:
raise TushareError(result.get("msg") or "Tushare returned an unknown error")
data = result.get("data") or {}
columns = data.get("fields") or []
return [dict(zip(columns, item)) for item in data.get("items") or []]
+7 -1
View File
@@ -1,8 +1,14 @@
from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY
from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
from .runner import Migration, MigrationError, MigrationRunner
MIGRATIONS = (M0001_ADOPT_LEGACY, M0002_JOB_RUNS, M0003_LLM_AUDIT)
MIGRATIONS = (
M0001_ADOPT_LEGACY,
M0002_JOB_RUNS,
M0003_LLM_AUDIT,
M0004_MENTOR_NOTES,
)
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -0,0 +1,24 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def add_mentor_note(connection: sqlite3.Connection) -> None:
columns = {
str(row["name"])
for row in connection.execute("PRAGMA table_info(mentor_preferences)")
}
if "note" not in columns:
connection.execute(
"ALTER TABLE mentor_preferences ADD COLUMN note TEXT NOT NULL DEFAULT ''"
)
MIGRATION = Migration(
version="0004",
name="add_mentor_note",
action=add_mentor_note,
signature="mentor-preferences-note:v1:note",
)
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from backend.features.accounts.service import AccountService
class AccountApplicationMixin:
def bind_user(self, user_id: int) -> None:
self._request_context.user_id = int(user_id)
encrypted = self.database.get_user_credentials(int(user_id))
self._request_context.credentials = self.vault.decrypt_json(encrypted) if encrypted else {}
self._request_context.access = self.database.user_access(int(user_id)) or {}
@property
def current_user_id(self) -> int:
user_id = getattr(self._request_context, "user_id", 0)
if not user_id:
raise ValueError("当前请求尚未绑定账号。")
return int(user_id)
def membership(self) -> dict[str, Any]:
return self.accounts.membership()
def admin_users(self) -> list[dict[str, Any]]:
return self.accounts.admin_users(self._platform_usage_today_for_user)
def update_membership(self, payload: dict[str, Any]) -> None:
self.accounts.update_membership(payload)
def register_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.register(username, password)
def login_account(self, username: str, password: str) -> dict[str, Any]:
return self.accounts.login(username, password)
def change_password(self, current_password: str, new_password: str) -> None:
self.accounts.change_password(current_password, new_password)
def create_account_session(self, user: dict[str, Any]) -> dict[str, Any]:
return self.accounts.create_session(user)
@staticmethod
def _validate_account_input(username: str, password: str) -> None:
AccountService.validate_input(username, password)
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.accounts.save_birth_profile(payload)
def stored_birth_profile(self) -> dict[str, str] | None:
return self.accounts.stored_birth_profile()
def account_personal_field(
self,
current_date: str,
current_field: dict[str, Any],
public: bool = False,
) -> dict[str, Any] | None:
return self.accounts.personal_field(current_date, current_field, public)
@staticmethod
def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
return AccountService.public_personal_profile(personal)
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
class AccountRoutesMixin:
def _handle_accounts_public_get(self, parsed) -> bool:
if parsed.path == "/api/auth/me":
self.auth_me()
return True
return False
def _handle_accounts_get(self, parsed) -> bool:
if parsed.path == "/api/account/status":
self.send_json({"ok": True, **self.application_service.status()})
return True
return False
def _handle_accounts_delete(self, parsed) -> bool:
if parsed.path == "/api/account/birth-profile":
deleted = self.application_service.database.delete_user_birth_profile(self.application_service.current_user_id)
self.send_json({"ok": True, "deleted": deleted})
return True
return False
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class AlertRoutesMixin:
def _handle_alerts_get(self, parsed) -> bool:
if parsed.path == "/api/alerts":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.alert_center(
query.get("status", ["all"])[0],
query.get("as_of", [date.today().isoformat()])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_alerts_post(self, parsed) -> bool:
alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path)
if alert_read_match:
self.send_json(
{"ok": True, **self.application_service.mark_alert_read(int(alert_read_match.group(1)))}
)
return True
if parsed.path == "/api/alerts/read-all":
body = self.read_json_body(True)
self.send_json(
{"ok": True, **self.application_service.mark_all_alerts_read(str(body.get("as_of") or ""))}
)
return True
return False
def _handle_alerts_delete(self, parsed) -> bool:
alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path)
if alert_match:
self.send_json(
{"ok": True, **self.application_service.delete_alert(int(alert_match.group(1)))}
)
return True
return False
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class AuctionRoutesMixin:
def _handle_auction_get(self, parsed) -> bool:
if parsed.path == "/api/auction":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.auction_center(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
@@ -0,0 +1,45 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.bootstrap.config import validate_text
class DragonTigerRoutesMixin:
def _handle_dragon_tiger_get(self, parsed) -> bool:
if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(self.application_service.get_dragon_tiger(trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/dragon-tiger/profiles":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.get_hot_money_profiles(
query.get("force", ["0"])[0] == "1"
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/seat-aliases":
self.send_json({"items": self.application_service.database.list_seat_aliases()})
return True
return False
def save_seat_alias(self) -> None:
try:
body = self.read_json_body()
seat_name = validate_text(body.get("seat_name"), "席位名称", 200, required=True)
alias = validate_text(body.get("alias"), "席位别名", 50, required=True)
self.application_service.database.save_seat_alias(seat_name, alias)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+196 -19
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
from typing import Any
from backend.llm import transport as llm_transport
@@ -10,6 +11,13 @@ class HeavenAgentError(RuntimeError):
pass
HEAVEN_PROMPT_VERSIONS = {
"trend": "heaven-trend-v4",
"fortune": "heaven-fortune-v9",
"heart": "heaven-heart-v5",
}
def interpret_heaven(
mode: str,
context: dict[str, Any],
@@ -42,6 +50,34 @@ def interpret_heaven(
answer = str(result.content).strip()
if not answer:
raise KeyError("empty response")
try:
_validate_answer(mode, answer, context)
except HeavenAgentError as validation_error:
repair_messages = [
*messages,
{"role": "assistant", "content": answer},
{
"role": "user",
"content": (
f"上一版未通过本地一致性校验:{validation_error}"
"请依据最初输入完整重写最终答案,只修正违规推断并补齐必答项。"
"不得讨论校验、提示词或重写过程,只输出新的正式解读。"
),
},
]
repaired = llm_transport.chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
messages=repair_messages,
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.7",
)
answer = str(repaired.content).strip()
if not answer:
raise KeyError("empty repaired response")
_validate_answer(mode, answer, context)
result = repaired
except llm_transport.OpenAIHTTPError as exc:
raise HeavenAgentError(exc.describe("问天模型调用失败")) from exc
except (llm_transport.OpenAITransportError, KeyError) as exc:
@@ -55,34 +91,175 @@ def interpret_heaven(
def _system_prompt(mode: str) -> str:
common = """
你是“小白复盘”的问天解读器。所有历法、卦象、爻位和市场指标已经由确定性程序计算,你只能解释提供的数据,不得改卦、改爻、改干支或编造行情
问天属于传统文化与娱乐化观察,不是预测模型,不承诺应验,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文,先给核心判断,再解释结构。引用市场数字时标明数据日期。输出纯文本,可使用简短标题
你是“小白复盘”的问天解读器。输入由calculation、knowledge和interpretation_contract组成:calculation是确定性程序已经算出的事实;knowledge是本次按条件精确检索到的原典、传统规则和产品边界;interpretation_contract规定本次必须回答与禁止推断的内容
只能综合输入中已经提供的事实和知识。不得改卦、改爻、改纳甲、改世应、改干支、重新计算五运六气,也不得凭模型记忆补造缺失字段。知识记录之间若存在张力,应说明条件与分歧,不要强行合成唯一结论
问天属于传统文化与自我观察,不是可验证的行情预测模型。必须给出有内容的倾向和依据,但不得把象义宣布为必然发生的股价结果,不输出无条件买卖指令,不用神秘话术制造确定性
使用中文和普通用户能够理解的表达。专业术语首次出现时紧接一句白话解释。先给核心判断,再说明证据和变化关系。每个主题必须使用独立一行的简短标题,格式为“## 标题”,标题后另起一段正文;不得把全部内容挤在一个长段落中。可以使用Markdown加粗,不使用Markdown表格。
""".strip()
if mode == "trend":
return common + """
当前任务是“观势·解势”。六爻从初爻到上爻依次是个股内核、个股外显、板块内核、板块外显、指数内核、指数外显;初二为地、三四为人、五上为天
行情数据只负责生成六爻,本次解势必须以卦象本身为主,不得根据指数涨跌、板块强弱、涨停家数、成交量或个股表现直接推演方向。context中不会提供这些数字,也不会提供爻位对应的市场角色
先解释本卦卦名的核心义、上下卦组合及大象;再只解释实际动爻所代表的转折,并说明本卦如何走向之卦;最后可把这一组卦势翻译成克制的市场语言
重点是“本卦为当下之势,动爻为变化关节,之卦为所趋之势”。不要说明某一动爻对应指数、板块或个股,也不要输出“一看指数、二看涨停家数”一类行情观察条件
全文控制在300至450个中文字符,最多四小段。卦理约占九成,市场翻译最多一句,只能落到节制、等待、守信、辨伪等行为态度,不得据此预测市场下一阶段、涨跌方向或动能变化。不直接荐股,不使用Markdown表格
不要使用“必然、确定、必涨、必跌、后续将、进入某阶段”等断语;天机只点出势的性质与变化关系,不替用户宣布结果。
当前任务是“观势·解势”。行情只负责在进入模型之前生成卦象,本次回答不得引用或反推指数涨跌、成交额、涨跌停、板块强弱或个股表现,也不得说明某一爻原先对应哪类市场指标
calculation中有意不提供股票、行业和板块身份。不得猜测或讨论观察对象所属行业、政策、消费环境、基本面、资金面或任何现实市场变量;只解释已经生成的卦象
必须明确给出卦义上的当下倾向、主要矛盾、实际动爻所的转折,以及本卦走向之卦后的变化方向。允许使用偏进、偏守、先难后易、由盛转收、转机有限、内外相违或结论有条件等相对判断;不得只罗列卦辞,也不得用“谨慎、等待、守信、辨伪”一类泛化劝诫代替解势
以knowledge中本卦、上下卦、卦辞、彖义、大象、实际动爻和之卦记录为依据。无动爻、一动爻和多动爻分别服从本次检索到的方法规则;多动爻有冲突时必须指出冲突,不得压成单一套话
按“## 核心判断、## 卦势依据、## 动爻转折、## 之卦趋向、## 决策映射”组织答案;无动爻时仍保留“动爻转折”,明确说明本次无动爻并解释结构的延续条件。结尾可以把卦势翻译成克制的交易决策语言,但只能表达条件、节奏和需要验证的矛盾,不得预测具体涨跌、价格、日期或给出直接荐股结论。篇幅随动爻数量自然展开,不设置固定字数
""".strip()
if mode == "fortune":
return common + """
当前任务是“观气·解运”。严格区分五运、六气、节气、月令和日干,不把丙午简单解释为火年
严格服从five_phase_field.framework提供的确定性结构,不自行重新计算五行:年纲由中运司天在泉构成;岁半以前司天为主、在泉为辅,岁半以后在泉为主、司天为辅;当前六气层以客气加临主气为核心;日辰只负责触发。节气只用于定位当前六气阶段,不得再次叠加为独立力量
重点解释framework.relations中的客主同气、客生主、主生客、客克主或主克客,以及客胜为从、主胜为逆、司天在泉同位、天符岁会等已经判定的关系。不得把司天、在泉、主气、客气视为彼此独立的证据重复计权,也不得自行增删传统格局
首要解释当日气场容易放大参与者的哪些情绪、判断偏差和操作冲动,例如急躁、恐惧、迟疑、追涨、过早止损或路径依赖;再给出一至两个调节动作
如有personal_profile,结合其日主、十神、五行平衡倾向说明当日对该用户主观状态的影响,但不得把简化平衡倾向说成唯一喜用神,也不得复述或猜测出生日期
不得引用市场上涨下跌家数、涨跌停数量、成交额、板块强度或个股表现来证明气场。industry_affinity只是五行行业取象示例,不是行情旁证;行业契合度最多在末尾用一句话说明,不得写“当日共振”或暗示相关行业必然涨跌
全文控制在420至600个中文字符,按“三层气机、人的状态、操作偏向、个人影响(如有)、制衡动作”组织,标题必须写“三层气机”。明确这些是传统历法框架下的观察语言,不宣称气候或五行直接导致股价
当前任务是“观气·解运”。页面用于直观展示的五行权重、主导元素和预制复合断语已明确排除,不得自行恢复这些结果,也不得按百分比重新生成单一五行结论
严格区分中运司天在泉、当前主气客气、节气定位和日辰触发。先解释中运与司天在泉构成的年纲,再解释客气加临主气的当前关系,最后说明日辰如何触发;不得把同一项拆成多份证据重复计权。相生不直接等于吉,相克不直接等于凶
必须使用knowledge中与本日中运、六气和客主关系精确匹配的记录。可形成“湿热交蒸、燥中夹滞”一类复合表达,但要从输入关系逐层说明,不能从页面权重结论倒推
如calculation.personal存在,只结合日主、十神和当日派生关系说明用户容易出现的主观感受与判断偏差;不得使用简化强弱、喜用神、出生日期或权重平衡结论
personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master.pillars是当日历法,不是用户出生四柱。stem_relations只是当日年、月、日三柱天干相对本命日主的程序结果,只能使用knowledge中本次命中的关系释义,不得自行重算十神或扩展五行生克过程。不得使用藏干、支中藏某干、某支为某库或燥湿属性等输入未提供的信息,也不得把当日日柱写成用户命局,或推断用户命局中某个十神“较重”、身强身弱或喜用神
日辰只按calculation.day_trigger.summary与knowledge中的日辰边界解释,不得从干支另行推导藏气、库气或五行生克链。个人合参不得宣称本命日主被当日某气生扶、泄耗或克制,只能说明已给关系标签可能对应的主观注意点
day_trigger.summary中的日干运势、地支五行和六气对应是三个并列的确定性事实。不得把日柱整体改写成某一种五行,也不得把地支与六气的“对应”改写成地支自身具有某种六气属性
calculation.industry_symbols只提供五行与行业的传统取象归类及其本次出现依据。必须说明这些气机对相关行业可能形成的象征性关注、节奏或约束,但不得引入行业实时行情,不得预测行业涨跌或把取象写成投资推荐;未列入industry_symbols的行业不得自行补造。
按“## 年纲、## 客主加临、## 日辰触发、## 行业影响、## 个人合参、## 制衡动作”组织答案;没有个人资料时可以省略“个人合参”。不得用行情上涨下跌、行业表现或个股结果证明运气关系。篇幅按实际关系自然展开,不设置固定字数。
""".strip()
return common + """
当前任务是“观心·解卦”。用户的问题始终只在心中,没有输入给你,因此你不能猜测问题内容,也不能替用户作具体决定
全文控制在180至350个中文字符。只写一句卦意;一小段动爻与之卦;最后三句极短的问心句
不要重述六条爻辞,不猜用户未说出口的问题,不以吉凶二字替代思考,不给出股票涨跌预测。语气安静、克制,越短越有余味
当前任务是“观心·解卦”。用户在起卦前确定的问题位于calculation.questionquestion_preset只说明问题来源。必须针对实际问题作答;无题观心时不得猜测用户没有说出的事项
question_scope是本次问题的产品边界。trade预设专指股票交易中的参与条件、机会、阻碍和风险,不是商业合作、融资、借贷或寻找资金方;除非用户问题明确写出这些背景,否则不得擅自补入
纳甲、卦宫、世应、六亲、六神、月建日辰、旬空、伏神、动变和冲合关系已经由确定性程序给出。只能解释这些结果,不得自行改排盘、补用神或用模型记忆重算。六神只作辅助,任何单项都不能独立决定结论
六亲是关系类别,不是现实人物或资金来源的一一映射。必须使用knowledge中的六亲、旬空、动变和六神边界;不得把妻财直接写成现金或融资,把子孙写成资金提供方,把兄弟写成合作方,也不得由某一六神直接推出紧迫、欺骗或吉凶。
除非calculation.question明确说明用户已经持仓、买入、卖出或正在管理仓位,否则不得假定用户已经入场,不得使用“持仓、仓位、建仓、入场、持有、买入、卖出、止损、止盈”等措辞描述用户现状。可以只写尚待核对的参与条件、风险边界和决策倾向。
除非问题明确涉及融资、借贷、合作或资源安排,否则不得制造外围资金、外围资源、资金进入、资源进入,也不得虚构资金或资源的来源、提供、注入、安排和路径。
按“## 所问之答、## 卦象依据、## 动变与之卦、## 可验证之处”组织答案。先直接回应所问,再用白话解释本卦所示处境、世应与相关六亲、关键动爻和变爻,最后说明之卦趋向及一项可以由用户验证的动作。若证据相互冲突,应明确说明结论成立的条件,不以“吉、凶”二字替代推理。
交易问题可以判断参与条件、内外阻碍、风险和决策倾向,但不得宣告具体股价、涨跌日期或替用户作无条件买卖决定。不得用旬空、填实、出空或干支日推算“未来几日”或某日应验;可验证动作必须是用户当下能核对的交易条件或自身判断,不能制造现实中不存在的合作方、承诺、资金或资源安排。心境问题聚焦念头、压力和盲点;无题观心只作一般卦象观照。篇幅随问题和动爻复杂度自然展开,不使用固定三句模板,也不得输出使用竖线分栏的Markdown表格。
""".strip()
def _validate_answer(
mode: str, answer: str, context: dict[str, Any] | None = None
) -> None:
compact = "".join(answer.split())
if len(compact) < 60:
raise HeavenAgentError("问天模型返回内容过短,未形成有效解读。")
forbidden = ("必涨", "必跌", "保证上涨", "保证下跌", "无条件买入", "无条件卖出")
if any(term in answer for term in forbidden):
raise HeavenAgentError("问天模型返回了禁止的确定性行情断语。")
if mode == "fortune" and "%" in answer:
raise HeavenAgentError("解运结果错误引用了已排除的权重百分比。")
if mode == "trend":
market_narratives = (
"行业", "板块", "个股", "指数", "成交额", "涨停", "跌停",
"政策", "消费环境", "基本面", "资金面",
)
if any(term in answer for term in market_narratives):
raise HeavenAgentError("解势结果错误引入了卦象之外的现实市场叙事。")
if mode == "fortune":
if re.search(
r"(?:命局|个人本身).{0,16}(?:偏重|较重|过旺|过弱|身强|身弱|喜用神)",
answer,
):
raise HeavenAgentError("解运结果错误推断了输入中不存在的命局强弱。")
if re.search(
r"藏干|[子丑寅卯辰巳午未申酉戌亥](?:中|内)|[子丑寅卯辰巳午未申酉戌亥].{0,4}(?:含|藏)|(?:中|内)藏|余气|[辰戌丑未].{0,4}(?:火库|水库|金库|木库|土库|燥土|湿土)",
answer,
):
raise HeavenAgentError("解运结果使用了输入中未提供的藏干推断。")
if re.search(
r"木生火|火生土|土生金|金生水|水生木|木克土|土克水|水克火|火克金|金克木",
answer,
):
raise HeavenAgentError("解运结果自行扩展了输入中未提供的五行生克链。")
if re.search(
r"(?:本命)?日主.{0,32}(?:生扶|泄耗|受克|被克|得生|被生|偏强|偏弱)",
answer,
):
raise HeavenAgentError("解运结果把当日关系错误扩展成了本命强弱推断。")
if re.search(
r"(?:日柱)?[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]"
r".{0,8}(?:本身|自身)(?:就)?是[木火土金水]",
answer,
):
raise HeavenAgentError("解运结果错误地把整个日柱归成了单一五行。")
if re.search(
r"[子丑寅卯辰巳午未申酉戌亥](?:的|具有|带有).{0,8}"
r"(?:风木|君火|湿土|相火|燥金|寒水)(?:之)?(?:属性|性质)",
answer,
):
raise HeavenAgentError("解运结果把六气对应误写成了地支自身属性。")
calculation = (context or {}).get("calculation") or {}
if calculation.get("industry_symbols") and "行业影响" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的行业影响。")
if re.search(
r"行业.{0,16}(?:必涨|必跌|必然上涨|必然下跌|确定领涨|确定领跌|投资推荐)",
answer,
):
raise HeavenAgentError("解运结果把行业取象错误写成了行情预测或投资推荐。")
personal = calculation.get("personal") or {}
personal_today = personal.get("today_relative_to_natal_day_master") or {}
pillar_values = {
str(value)
for group in (calculation.get("pillars") or {}, personal_today.get("pillars") or {})
for value in group.values()
if value
}
mentioned_pillars = set(
re.findall(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]", answer)
)
if mentioned_pillars - pillar_values:
raise HeavenAgentError("解运结果补入了确定性输入中不存在的干支。")
month_pillar = str((calculation.get("pillars") or {}).get("month") or "")
month_branch = month_pillar[1:2]
mentioned_month_branches = set(
re.findall(r"([子丑寅卯辰巳午未申酉戌亥])月", answer)
)
if mentioned_month_branches - ({month_branch} if month_branch else set()):
raise HeavenAgentError("解运结果补入了当前月份之外的地支月。")
if personal:
relations = {
str(value)
for value in (personal_today.get("stem_relations") or {}).values()
if value
}
if "个人合参" not in answer and "本命日主" not in answer:
raise HeavenAgentError("解运结果遗漏了本次必答的个人合参。")
if relations and not any(relation in answer for relation in relations):
raise HeavenAgentError("解运结果未使用程序提供的当日关系标签。")
if mode != "heart":
return
calculation = (context or {}).get("calculation") or {}
question = str(calculation.get("question") or "")
if _contains_markdown_table(answer):
raise HeavenAgentError("解卦结果错误输出了Markdown表格。")
position_terms = (
"持仓", "仓位", "建仓", "入场", "持有", "买入", "卖出", "止损", "止盈",
)
if not any(term in question for term in position_terms) and any(
term in answer for term in position_terms
):
raise HeavenAgentError("解卦结果擅自假定了用户的持仓或买卖状态。")
financing_terms = (
"融资", "借贷", "合作", "出资", "资金来源", "资金方", "投资人", "投资方",
"外部资金", "外围资金", "外部资源", "外围资源",
)
invented_scenarios = (
"融资", "借贷", "合作方", "资金提供方", "资金意向", "资金注入", "自有资金",
"外围资金", "外围资源", "资金进入", "资源进入",
)
invented_resource_path = re.search(
r"(?:资金|资源).{0,8}(?:来源|提供|注入|安排|路径)", answer
)
if not any(term in question for term in financing_terms) and (
any(term in answer for term in invented_scenarios) or invented_resource_path
):
raise HeavenAgentError("解卦结果擅自补入了用户没有提出的融资或合作场景。")
timing_patterns = (
r"未来\s*[一二三四五六七八九十\d]+\s*(?:个)?(?:交易)?日",
r"[子丑寅卯辰巳午未申酉戌亥]{1,2}日(?:到来|来临|之前|之后|前后)",
r"(?:等待|等到|待).{0,16}(?:旬空|空亡).{0,16}(?:填实|出空)",
)
if any(re.search(pattern, answer) for pattern in timing_patterns):
raise HeavenAgentError("解卦结果错误使用旬空或干支推算了具体应期。")
def _contains_markdown_table(answer: str) -> bool:
return bool(
re.search(r"(?m)^\s*\|", answer)
or re.search(r"(?m)^\s*:?-{3,}:?\s*\|", answer)
or re.search(r"(?m)\|\s*:?-{3,}:?\s*(?:\||$)", answer)
)
+2
View File
@@ -733,6 +733,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
return {
"name": primary["name"],
"text": primary["text"],
"tuan": primary.get("tuan") or "",
"image": primary.get("image") or "",
"inner_trigram": inner,
"outer_trigram": outer,
@@ -741,6 +742,7 @@ def hexagram_from_lines(values: list[int]) -> dict[str, Any]:
"transformed": {
"name": transformed["name"],
"text": transformed["text"],
"tuan": transformed.get("tuan") or "",
"image": transformed.get("image") or "",
"inner_trigram": transformed_inner,
"outer_trigram": transformed_outer,
+387
View File
@@ -0,0 +1,387 @@
from __future__ import annotations
import json
from functools import lru_cache
from typing import Any
from backend.bootstrap.config import APP_DIR
KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json"
def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]:
"""Build the only context shape that may cross the LLM boundary."""
if mode == "trend":
prepared = _prepare_trend(calculation)
elif mode == "fortune":
prepared = _prepare_fortune(calculation)
elif mode == "heart":
prepared = _prepare_heart(calculation)
else:
raise ValueError("不支持的问天知识模式。")
prepared["knowledge"] = retrieve_heaven_knowledge(mode, prepared)
return prepared
def retrieve_heaven_knowledge(mode: str, context: dict[str, Any]) -> dict[str, Any]:
catalog = _knowledge_catalog()
source_ids: list[str]
records: list[dict[str, Any]]
if mode == "trend":
source_ids = ["zhouyi"]
records = _trend_records(catalog, context)
elif mode == "fortune":
source_ids = ["neijing"]
records = _fortune_records(catalog, context)
elif mode == "heart":
source_ids = ["zhouyi", "jingfang", "huozhulin", "zengshan"]
records = _heart_records(catalog, context)
else:
raise ValueError("不支持的问天知识模式。")
return {
"version": str(catalog.get("version") or ""),
"retrieval": "deterministic-keyed",
"sources": [
{"id": source_id, **dict(catalog["sources"][source_id])}
for source_id in source_ids
],
"records": records,
}
def _prepare_trend(context: dict[str, Any]) -> dict[str, Any]:
return {
"mode": "trend",
"calculation": {
"data_trade_date": context.get("data_trade_date") or "",
"hexagram": context.get("hexagram") or {},
"movement": context.get("movement") or {},
},
"interpretation_contract": {
"required": ["明确卦势倾向", "主要矛盾", "实际动爻转折", "本卦到之卦的变化关系"],
"allowed": ["偏进或偏守", "先难后易或由盛转收", "结论有条件或存在分歧"],
"forbidden": ["原始行情旁证", "具体涨跌预测", "时间点预测", "无条件买卖指令", "泛化劝诫代替解卦"],
},
}
def _prepare_fortune(context: dict[str, Any]) -> dict[str, Any]:
field = context.get("five_phase_field") or {}
framework = field.get("framework") or {}
relations = framework.get("relations") or {}
layers = {
str(item.get("id") or ""): item
for item in framework.get("layers") or []
if isinstance(item, dict)
}
six_qi = field.get("six_qi") or {}
movement = field.get("movement") or {}
pillars = field.get("pillars") or {}
personal = context.get("personal_profile") or {}
sector_catalog = {
str(group.get("element") or ""): [
str(item.get("name") or "").strip()
for item in group.get("industries") or []
if str(item.get("name") or "").strip()
]
for group in field.get("sector_catalog") or []
if isinstance(group, dict)
}
industry_bases: dict[str, list[str]] = {}
def add_industry_basis(element: str, basis: str) -> None:
if element not in sector_catalog or not sector_catalog[element]:
return
industry_bases.setdefault(element, [])
if basis not in industry_bases[element]:
industry_bases[element].append(basis)
add_industry_basis(str(movement.get("phase") or ""), "中运")
for label, qi in (
("司天", six_qi.get("sitian")),
("在泉", six_qi.get("zaiquan")),
("主气", six_qi.get("host_qi")),
("客气", six_qi.get("guest_qi")),
):
qi_text = str(qi or "")
add_industry_basis(qi_text[-1:] if qi_text else "", label)
day_master = personal.get("day_master") or {}
current = personal.get("current") or {}
personal_context = {}
if day_master:
current_ten_gods = current.get("ten_gods") or {}
personal_context = {
"natal_day_master": {
"stem": day_master.get("stem") or "",
"element": day_master.get("element") or "",
},
"today_relative_to_natal_day_master": {
"pillars": current.get("pillars") or {},
"stem_relations": {
key: str((current_ten_gods.get(key) or {}).get("stem") or "")
for key in ("year", "month", "day")
},
},
}
return {
"mode": "fortune",
"calculation": {
"calendar_date": context.get("calendar_date") or field.get("date") or "",
"lunar_date": field.get("lunar_date") or "",
"pillars": {
"year": pillars.get("year") or "",
"month": pillars.get("month") or "",
"day": pillars.get("day") or "",
},
"solar_terms": field.get("solar_terms") or {},
"year_movement": {
"phase": movement.get("phase") or "",
"tendency": movement.get("tendency") or "",
"label": movement.get("label") or "",
},
"annual_qi": {
"sitian": six_qi.get("sitian") or "",
"zaiquan": six_qi.get("zaiquan") or "",
"ruling": six_qi.get("ruling") or "",
"ruling_qi": six_qi.get("ruling_qi") or "",
"annual_pattern": relations.get("annual_pattern") or {},
},
"current_qi": {
"step": six_qi.get("step"),
"step_name": six_qi.get("step_name") or "",
"host_qi": six_qi.get("host_qi") or "",
"guest_qi": six_qi.get("guest_qi") or "",
"guest_host_relation": relations.get("guest_host") or {},
"alignment": relations.get("alignment") or six_qi.get("alignment") or "",
},
"day_trigger": {
"day_pillar": pillars.get("day") or "",
"summary": (layers.get("day") or {}).get("summary") or "",
},
"industry_symbols": [
{
"element": element,
"basis": bases,
"industries": sector_catalog[element],
}
for element, bases in industry_bases.items()
],
"personal": personal_context,
},
"excluded_from_interpretation": [
"五行权重与百分比",
"主导元素排序",
"权重生成的复合断语",
"预制情绪与交易行为结论",
"行业实时行情旁证",
"简化喜用神与强弱结论",
],
"interpretation_contract": {
"required": ["年纲", "当前客主加临", "日辰触发", "行业影响", "个人合参(如有)", "制衡动作"],
"forbidden": [
"重新计算五行权重",
"把相生直接判吉",
"把相克直接判凶",
"用市场涨跌证明气场",
"把行业取象写成行业涨跌预测或投资推荐",
"把当日日柱误称为用户命局",
"推断未提供的命局强弱或喜用神",
],
},
}
def _prepare_heart(context: dict[str, Any]) -> dict[str, Any]:
preset = str(context.get("question_preset") or "custom")
if preset not in {"trade", "mind", "unthemed", "custom"}:
preset = "custom"
return {
"mode": "heart",
"calculation": {
"question": str(context.get("question") or "").strip(),
"question_preset": preset,
"question_scope": {
"trade": "股票交易中的参与条件、机会、阻碍与风险,不是融资或商业合作问题。",
"mind": "影响股票交易判断的情绪、执念或盲点。",
"unthemed": "不指定事项的一般观照。",
"custom": "只按用户实际写出的事项理解,不补写背景。",
}[preset],
"ritual": context.get("ritual") or {},
"hexagram": context.get("hexagram") or {},
"six_yao": context.get("six_yao") or {},
},
"interpretation_contract": {
"required": ["回应所问", "本卦处境", "世应与相关六亲", "关键动变", "之卦趋向", "可验证动作"],
"plain_language": "专业术语首次出现时立即用白话解释。",
"forbidden": [
"修改纳甲排盘",
"猜测未输入的问题",
"把股票交易改写成融资或合作问题",
"把六亲直接等同于现实人物或资金来源",
"单凭六神或空亡断吉凶",
"根据旬空填实或干支日期预测应期",
"具体股价和时间点预测",
"无条件买卖指令",
],
},
}
def _trend_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
hexagram = (context.get("calculation") or {}).get("hexagram") or {}
moving = [line for line in hexagram.get("lines") or [] if line.get("moving")]
method_key = "stable" if not moving else "single" if len(moving) == 1 else "multiple"
rules = catalog["trend"]["rules"]
records = [
{"id": "trend-method", "source": "product_method", "text": catalog["trend"]["method"]},
{"id": f"trend-moving-{method_key}", "source": "product_method", "text": rules[method_key]},
_hexagram_record("primary", hexagram),
]
records.extend(_line_record(line) for line in moving)
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
return records
def _fortune_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
movement = calculation.get("year_movement") or {}
annual_qi = calculation.get("annual_qi") or {}
current_qi = calculation.get("current_qi") or {}
knowledge = catalog["fortune"]
records = [
{"id": "fortune-principle", "source": "neijing", "text": knowledge["principle"]},
]
if calculation.get("industry_symbols"):
records.append(
{
"id": "fortune-industry-boundary",
"source": "product_method",
"text": knowledge["industry_boundary"],
}
)
personal = calculation.get("personal") or {}
if personal:
records.append(
{
"id": "fortune-personal-boundary",
"source": "product_method",
"text": knowledge["personal_boundary"],
}
)
today = personal.get("today_relative_to_natal_day_master") or {}
relation_semantics = knowledge.get("personal_relations") or {}
for relation in dict.fromkeys((today.get("stem_relations") or {}).values()):
if relation in relation_semantics:
records.append(
{
"id": f"fortune-personal-{relation}",
"source": "product_method",
"subject": relation,
"text": relation_semantics[relation],
}
)
tendency = str(movement.get("tendency") or "")
if tendency in knowledge["movement"]:
records.append({"id": f"movement-{tendency}", "source": "neijing", "text": knowledge["movement"][tendency]})
for key in ("sitian", "zaiquan"):
qi = str(annual_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"annual-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
for key in ("host_qi", "guest_qi"):
qi = str(current_qi.get(key) or "")
if qi in knowledge["qi"]:
records.append({"id": f"current-{key}", "source": "neijing", "subject": qi, "text": knowledge["qi"][qi]})
relation = current_qi.get("guest_host_relation") or {}
relation_type = str(relation.get("type") or "")
if relation_type in knowledge["relations"]:
records.append({"id": f"relation-{relation_type}", "source": "neijing", "subject": relation.get("label") or "", "text": knowledge["relations"][relation_type]})
records.append({"id": "day-trigger", "source": "neijing", "text": knowledge["day_trigger"]})
return records
def _heart_records(catalog: dict[str, Any], context: dict[str, Any]) -> list[dict[str, Any]]:
calculation = context.get("calculation") or {}
hexagram = calculation.get("hexagram") or {}
six_yao = calculation.get("six_yao") or {}
preset = str(calculation.get("question_preset") or "custom")
heart = catalog["heart"]
records = [
{"id": "heart-focus", "source": "product_method", "text": heart["focus"].get(preset, heart["focus"]["custom"])},
{"id": "heart-evidence-order", "source": "product_method", "items": heart["evidence_order"]},
{"id": "heart-limits", "source": "product_method", "text": heart["limits"]},
{"id": "heart-self-response", "source": "jingfang", "text": heart["semantics"]["self_response"]},
{"id": "heart-calendar", "source": "zengshan", "text": heart["semantics"]["calendar"]},
{"id": "heart-movement", "source": "huozhulin", "text": heart["semantics"]["movement"]},
{"id": "heart-six-spirits", "source": "zengshan", "text": heart["semantics"]["six_spirits"]},
{"id": "heart-timing-boundary", "source": "product_method", "text": heart["semantics"]["timing_boundary"]},
_hexagram_record("primary", hexagram),
]
relatives = {
str(line.get("relative") or "")
for line in six_yao.get("lines") or []
if line.get("relative")
}
for relative in sorted(relatives):
text = (heart["semantics"].get("relatives") or {}).get(relative)
if text:
records.append(
{
"id": f"heart-relative-{relative}",
"source": "huozhulin",
"subject": relative,
"text": text,
}
)
records.extend(_line_record(line) for line in hexagram.get("lines") or [] if line.get("moving"))
transformed = hexagram.get("transformed") or {}
if transformed:
records.append(_hexagram_record("transformed", transformed))
palace = six_yao.get("palace") or {}
records.append(
{
"id": "heart-palace",
"source": "jingfang",
"text": (
f"本卦归{palace.get('name') or '--'}{palace.get('stage') or '--'}"
f"世在{palace.get('self_position') or '--'}爻,应在{palace.get('response_position') or '--'}爻。"
),
}
)
return records
def _hexagram_record(kind: str, hexagram: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-{kind}",
"source": "zhouyi",
"kind": kind,
"name": hexagram.get("name") or "",
"inner_trigram": hexagram.get("inner_trigram") or "",
"outer_trigram": hexagram.get("outer_trigram") or "",
"text": hexagram.get("text") or "",
"tuan": hexagram.get("tuan") or "",
"image": hexagram.get("image") or "",
}
def _line_record(line: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"zhouyi-line-{line.get('position') or ''}",
"source": "zhouyi",
"position": line.get("position"),
"position_name": line.get("position_name") or "",
"line_name": line.get("line_name") or "",
"text": line.get("text") or "",
"image": line.get("image") or "",
}
@lru_cache(maxsize=1)
def _knowledge_catalog() -> dict[str, Any]:
payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8"))
if not payload.get("version") or not isinstance(payload.get("sources"), dict):
raise ValueError("问天知识库格式不完整。")
return payload
+412
View File
@@ -0,0 +1,412 @@
from __future__ import annotations
import copy
from typing import Any
from backend.bootstrap.config import validate_text
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.engine import _market_line_scores, _score_to_line
class HeavenManualMixin:
@staticmethod
def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
intraday = market_mode == "intraday"
fields = {
"stock_amount_percentile": {"line": 1, "label": "成交额全市场分位", "unit": "%", "min": 0, "max": 100},
"stock_turnover_rate": {"line": 1, "label": "个股换手率", "unit": "%", "min": 0, "max": 100},
"stock_turnover_relative": {"line": 1, "label": "相对市场换手", "unit": "", "min": 0, "max": 20},
"stock_volume_activity_ratio": {"line": 1, "label": "同进度量能", "unit": "", "min": 0, "max": 20},
"stock_seal_amount_million": {"line": 1, "label": "封单金额", "unit": "万元", "min": 0, "max": 100000000},
"stock_open_times": {"line": 1, "label": "开板次数", "unit": "", "min": 0, "max": 100, "integer": True},
"stock_change": {"line": 2, "label": "个股涨跌幅", "unit": "%", "min": -100, "max": 100},
"stock_streak": {"line": 2, "label": "连板高度", "unit": "", "min": 0, "max": 100, "integer": True},
"stock_status": {"line": 2, "label": "个股状态", "type": "select", "options": ["普通", "涨停", "炸板", "跌停"]},
"sector_name": {"line": [3, 4], "label": "申万二级行业", "type": "text", "max_length": 50},
"sector_up_count": {"line": 3, "label": "行业上涨家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"sector_down_count": {"line": 3, "label": "行业下跌家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "", "min": 0, "max": 20},
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "", "min": 0, "max": 100},
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
"market_amount_billion": {"line": 5, "label": "两市成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_recent_average_amount_billion": {"line": 5, "label": "近期平均成交额", "unit": "亿元", "min": 0, "max": 10000000},
"market_up_count": {"line": 5, "label": "上涨家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_down_count": {"line": 5, "label": "下跌家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_limit_up_count": {"line": 5, "label": "涨停家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"market_limit_down_count": {"line": 5, "label": "跌停家数", "unit": "", "min": 0, "max": 10000, "integer": True},
"index_sh_change": {"line": 6, "label": "上证指数涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_sz_change": {"line": 6, "label": "深证成指涨跌幅", "unit": "%", "min": -20, "max": 20},
"index_cy_change": {"line": 6, "label": "创业板指涨跌幅", "unit": "%", "min": -20, "max": 20},
"note": {"line": [], "label": "补录说明", "type": "text", "max_length": 200},
}
if intraday:
for key in ("stock_seal_amount_million", "stock_open_times"):
fields.pop(key)
else:
for key in ("stock_turnover_relative", "stock_volume_activity_ratio", "sector_relative_turnover"):
fields.pop(key)
return fields
@classmethod
def _validate_heaven_manual_data(
cls, raw: Any, market_mode: str
) -> dict[str, Any]:
if raw in (None, ""):
return {}
if not isinstance(raw, dict):
raise ValueError("六爻补录数据格式不正确。")
schema = cls._heaven_manual_schema(market_mode)
unknown = set(raw) - set(schema)
if unknown:
raise ValueError(f"六爻补录包含未知字段:{next(iter(sorted(unknown)))}")
values: dict[str, Any] = {}
for key, value in raw.items():
if value is None or (isinstance(value, str) and not value.strip()):
continue
spec = schema[key]
if spec.get("type") == "text":
values[key] = validate_text(value, spec["label"], int(spec["max_length"]))
continue
if spec.get("type") == "select":
text = str(value).strip()
if text not in spec["options"]:
raise ValueError(f"{spec['label']}不在允许范围内。")
values[key] = text
continue
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{spec['label']}必须是数字。") from exc
if number < float(spec["min"]) or number > float(spec["max"]):
raise ValueError(
f"{spec['label']}应在 {spec['min']}{spec['max']} 之间。"
)
values[key] = int(number) if spec.get("integer") else number
return values
@staticmethod
def _apply_heaven_manual_data(
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
manual_data: dict[str, Any],
market_mode: str,
trade_date: str,
stock_code: str,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
dashboard = copy.deepcopy(dashboard)
index_context = copy.deepcopy(index_context or {})
sector = copy.deepcopy(sector or {})
stock = copy.deepcopy(stock or {})
overview = dashboard.setdefault("overview", {})
stock_map = {
"stock_amount_percentile": "amount_percentile",
"stock_turnover_rate": "turnover_rate",
"stock_turnover_relative": "turnover_relative",
"stock_volume_activity_ratio": "volume_activity_ratio",
"stock_seal_amount_million": "seal_amount_million",
"stock_open_times": "open_times",
"stock_change": "change",
"stock_streak": "streak",
"stock_status": "status",
}
sector_map = {
"sector_name": "name",
"sector_up_count": "up_count",
"sector_down_count": "down_count",
"sector_coverage": "coverage",
"sector_relative_turnover": "relative_turnover",
"sector_member_equal_change": "member_equal_change",
"sector_change": "change",
"sector_leading_pct": "leading_pct",
}
overview_map = {
"market_sentiment_score": "sentiment_score",
"market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion",
"market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count",
"market_down_count": "down_count",
"market_limit_up_count": "limit_up_count",
"market_limit_down_count": "limit_down_count",
}
for manual_key, target in stock_map.items():
if manual_key in manual_data:
stock[target] = manual_data[manual_key]
for manual_key, target in sector_map.items():
if manual_key in manual_data:
sector[target] = manual_data[manual_key]
for manual_key, target in overview_map.items():
if manual_key in manual_data:
overview[target] = manual_data[manual_key]
if any(key.startswith("stock_") for key in manual_data):
stock.setdefault("code", stock_code)
stock.setdefault("name", stock_code or "--")
stock["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
if market_mode == "intraday" and "stock_volume_activity_ratio" in manual_data:
stock["activity_source"] = "user_supplied"
if any(key.startswith("sector_") for key in manual_data):
sector["_quantitative_mode"] = "intraday" if market_mode == "intraday" else "historical"
sector.setdefault("taxonomy", "sw_l2")
index_keys = (
("index_sh_change", "000001.SH", "上证指数"),
("index_sz_change", "399001.SZ", "深证成指"),
("index_cy_change", "399006.SZ", "创业板指"),
)
rows = {str(row.get("ts_code") or row.get("code") or ""): dict(row) for row in index_context.get("indices") or []}
for manual_key, code, name in index_keys:
if manual_key not in manual_data:
continue
row = rows.get(code, {"ts_code": code, "name": name})
row.update({"pct_chg": manual_data[manual_key], "trade_date": trade_date})
rows[code] = row
ordered_rows = [rows.get(code) for _, code, _ in index_keys]
if all(ordered_rows):
index_context["indices"] = ordered_rows
changes = [float(row.get("pct_chg") or 0) for row in ordered_rows]
aggregate = dict(index_context.get("aggregate") or {})
aggregate["average_pct_chg"] = sum(changes) / 3
index_context["aggregate"] = aggregate
return dashboard, index_context, sector, stock
@classmethod
def _heaven_line_checks(
cls,
trade_date: str,
dashboard: dict[str, Any],
recent_history: list[dict[str, Any]],
index_context: dict[str, Any],
sector: dict[str, Any],
stock: dict[str, Any],
market_mode: str,
manual_data: dict[str, Any],
) -> list[dict[str, Any]]:
intraday = market_mode == "intraday"
closed = market_mode == "closed"
schema = cls._heaven_manual_schema(market_mode)
required = {
1: (["stock_amount_percentile", "stock_turnover_relative", "stock_volume_activity_ratio"] if intraday else ["stock_amount_percentile", "stock_turnover_rate", "stock_seal_amount_million", "stock_open_times"]),
2: ["stock_change", "stock_streak", "stock_status"],
3: (["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_relative_turnover"] if intraday else ["sector_name", "sector_up_count", "sector_down_count", "sector_coverage", "sector_member_equal_change", "sector_leading_pct"]),
4: ["sector_name", "sector_change", "sector_leading_pct"],
5: ["market_sentiment_score", "market_seal_rate", "market_amount_billion", "market_recent_average_amount_billion", "market_up_count", "market_down_count", "market_limit_up_count", "market_limit_down_count"],
6: ["index_sh_change", "index_sz_change", "index_cy_change"],
}
names = {
1: ("初爻", "个股内核", "成交活跃、换手与量能"),
2: ("二爻", "个股外显", "涨跌、连板与状态"),
3: ("三爻", "行业内核", "行业宽度与成交活跃"),
4: ("四爻", "行业外显", "行业涨跌与领涨表现"),
5: ("五爻", "市场内核", "情绪、封板、成交与市场宽度"),
6: ("上爻", "指数外显", "三大指数当日涨跌"),
}
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_dates = {str(row.get("trade_date") or "").replace("-", "") for row in index_rows}
index_issues = []
if len(index_rows) < 3:
index_issues.append(f"三大指数仅取得 {len(index_rows)}/3 条行情")
elif index_date != trade_date or index_dates != {trade_date}:
actual_dates = "".join(sorted(value for value in index_dates if value)) or "未知"
index_issues.append(f"指数实际日期为 {actual_dates},目标交易日为 {trade_date}")
elif not index_context.get("precise"):
index_issues.append("三大指数行情未通过完整性校验")
elif intraday and not index_context.get("realtime"):
index_issues.append("盘中缺少可核验的实时指数行情")
elif not intraday and (index_context.get("realtime") or str(index_context.get("source") or "") != "tushare"):
index_issues.append("收盘或历史行情不是官方指数日线")
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
sector_common = []
if not sector:
sector_common.append("未取得申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
sector_common.append("行业分类不是申万二级")
elif sector_date != trade_date:
sector_common.append("行业行情日期与目标交易日不一致")
elif intraday and not sector.get("realtime"):
sector_common.append("盘中行业行情不是申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
sector_common.append("历史行业行情不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
sector_common.append("收盘行业实时行情尚未形成15:00最终快照")
sector_inner = list(sector_common)
sector_outer = list(sector_common)
if not sector.get("inner_precise", sector.get("precise")):
sector_inner.append(str(sector.get("inner_error") or sector.get("error") or "行业内核数据未通过校验"))
if not sector.get("outer_precise", sector.get("precise")):
sector_outer.append(str(sector.get("outer_error") or sector.get("error") or "行业外显数据未通过校验"))
if sector and sector_coverage_issue and sector_coverage_issue not in sector_inner:
sector_inner.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
sector_inner.append("缺少行业相对全市场换手活跃度")
stock_date = str(stock.get("trade_date") or "").replace("-", "")
stock_common = []
if not stock.get("code"):
stock_common.append("尚未载入有效个股")
elif stock_date != trade_date:
stock_common.append(f"个股实际日期为 {stock_date or '未知'},目标交易日为 {trade_date}")
elif not stock.get("precise"):
stock_common.append("个股行情未通过完整性校验")
elif intraday and not stock.get("realtime"):
stock_common.append("盘中个股行情不是实时行情")
elif not intraday and (stock.get("realtime") or str(stock.get("data_source") or "") != "tushare"):
stock_common.append("收盘或历史个股行情不是官方日线")
stock_inner = list(stock_common)
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
stock_inner.append("缺少可核验的实时换手率")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
stock_inner.append("缺少同时间进度量能基准")
overview = dashboard.get("overview") or {}
market_key_map = {
"market_sentiment_score": "sentiment_score", "market_seal_rate": "seal_rate",
"market_amount_billion": "amount_billion", "market_recent_average_amount_billion": "recent_average_amount_billion",
"market_up_count": "up_count", "market_down_count": "down_count",
"market_limit_up_count": "limit_up_count", "market_limit_down_count": "limit_down_count",
}
market_issues = []
for manual_key, source_key in market_key_map.items():
if source_key == "recent_average_amount_billion":
history_values = [item.get("amount_billion") for item in recent_history[:-1] if item.get("amount_billion") is not None]
if source_key not in overview and not history_values:
market_issues.append(f"缺少{schema[manual_key]['label']}")
elif source_key not in overview or overview.get(source_key) is None:
market_issues.append(f"缺少{schema[manual_key]['label']}")
automatic_issues = {
1: stock_inner, 2: stock_common, 3: sector_inner,
4: sector_outer, 5: market_issues, 6: index_issues,
}
limits = list(dashboard.get("limits") or [])
scores = _market_line_scores(dashboard, recent_history, index_context, sector, stock, limits)
value_map: dict[str, Any] = {
"stock_amount_percentile": stock.get("amount_percentile"),
"stock_turnover_rate": stock.get("turnover_rate"),
"stock_turnover_relative": stock.get("turnover_relative"),
"stock_volume_activity_ratio": stock.get("volume_activity_ratio"),
"stock_seal_amount_million": stock.get("seal_amount_million"),
"stock_open_times": stock.get("open_times"),
"stock_change": stock.get("change"), "stock_streak": stock.get("streak"),
"stock_status": stock.get("status"), "sector_name": sector.get("name"),
"sector_up_count": sector.get("up_count"), "sector_down_count": sector.get("down_count"),
"sector_coverage": sector.get("coverage"), "sector_relative_turnover": sector.get("relative_turnover"),
"sector_member_equal_change": sector.get("member_equal_change"),
"sector_change": sector.get("change"), "sector_leading_pct": sector.get("leading_pct"),
"market_sentiment_score": overview.get("sentiment_score"), "market_seal_rate": overview.get("seal_rate"),
"market_amount_billion": overview.get("amount_billion"),
"market_recent_average_amount_billion": overview.get("recent_average_amount_billion"),
"market_up_count": overview.get("up_count"), "market_down_count": overview.get("down_count"),
"market_limit_up_count": overview.get("limit_up_count"), "market_limit_down_count": overview.get("limit_down_count"),
}
history_values = [float(item.get("amount_billion")) for item in recent_history[:-1] if item.get("amount_billion") is not None]
if value_map["market_recent_average_amount_billion"] is None and history_values:
value_map["market_recent_average_amount_billion"] = sum(history_values) / len(history_values)
if value_map["stock_amount_percentile"] is None and not intraday:
amount = float(stock.get("amount_billion") or 0)
amounts = [float(item.get("amount_billion") or 0) for item in limits if item.get("amount_billion") is not None]
value_map["stock_amount_percentile"] = (
sum(item <= amount for item in amounts) / len(amounts) * 100 if amounts else None
)
row_by_code = {str(row.get("ts_code") or row.get("code") or ""): row for row in index_context.get("indices") or []}
value_map.update({
"index_sh_change": (row_by_code.get("000001.SH") or {}).get("pct_chg"),
"index_sz_change": (row_by_code.get("399001.SZ") or {}).get("pct_chg"),
"index_cy_change": (row_by_code.get("399006.SZ") or {}).get("pct_chg"),
})
def missing_value(key: str) -> bool:
value = value_map.get(key)
return value is None or (isinstance(value, str) and not value.strip())
invalid_fields = {
line_number: {key for key in keys if missing_value(key)}
for line_number, keys in required.items()
}
if stock_common:
invalid_fields[1].update(required[1])
invalid_fields[2].update(required[2])
else:
if intraday and stock.get("turnover_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_turnover_relative")
if intraday and stock.get("activity_source") in {None, "", "unavailable"}:
invalid_fields[1].add("stock_volume_activity_ratio")
if sector_common:
invalid_fields[3].update(required[3])
invalid_fields[4].update(required[4])
else:
if not sector.get("inner_precise", sector.get("precise")) or sector_coverage_issue:
invalid_fields[3].update(key for key in required[3] if key != "sector_name")
if sector.get("realtime") and not sector.get("relative_turnover"):
invalid_fields[3].add("sector_relative_turnover")
# The official SW index supplies only the sector's external change. A valid
# membership name and member-stock leader remain usable when that quote fails.
if not sector.get("outer_precise", sector.get("precise")):
invalid_fields[4].add("sector_change")
if index_issues:
invalid_fields[6].update(required[6])
checks = []
for line_number in range(1, 7):
manual_keys = [key for key in required[line_number] if key in manual_data]
unresolved_fields = [
key for key in required[line_number]
if key in invalid_fields[line_number] and key not in manual_data
]
hard_missing_identity = line_number in {1, 2} and not stock.get("code")
passed = not hard_missing_identity and not unresolved_fields
status = "manual" if passed and manual_keys else "passed" if passed else "failed"
reasons = [] if passed else [
*( ["请先输入并载入股票代码或名称"] if hard_missing_identity else automatic_issues[line_number] ),
*( ["需补充:" + "".join(schema[key]["label"] for key in unresolved_fields)] if unresolved_fields else [] ),
]
score = float(scores[line_number - 1]["score"])
position, layer, formula = names[line_number]
checks.append({
"line": line_number, "position": position, "layer": layer, "formula": formula,
"status": status, "passed": passed, "reasons": reasons,
"score": round(score, 3) if passed else None,
"line_value": _score_to_line(score) if passed else None,
"evidence": scores[line_number - 1]["evidence"] if passed else [],
"fields": [
{
"key": key, "label": schema[key]["label"], "unit": schema[key].get("unit", ""),
"type": schema[key].get("type", "number"), "options": schema[key].get("options", []),
"value": value_map.get(key), "manual": key in manual_data,
"required": True, "min": schema[key].get("min"), "max": schema[key].get("max"),
"integer": bool(schema[key].get("integer")),
}
for key in required[line_number]
],
})
return checks
@@ -0,0 +1,338 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
from backend.bootstrap.config import (
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from backend.data.providers.tushare_client import TushareError
class HeavenMarketContextMixin:
def _resolve_heaven_stock_code(self, query: str) -> str:
raw = validate_text(query, "股票代码或名称", 30, required=True)
code_match = re.fullmatch(r"(\d{6})(?:\.(?:SH|SZ|BJ))?", raw.upper())
if code_match:
return validate_stock_code(code_match.group(1))
candidates = self.database.search_stock_master(raw)
exact = [item for item in candidates if str(item.get("name") or "").casefold() == raw.casefold()]
if not exact and self.configured:
try:
rows = self._tushare_client().query(
"stock_basic",
{"name": raw, "list_status": "L"},
"ts_code,symbol,name,industry,market,list_date",
)
except TushareError:
rows = []
if rows:
self.database.upsert_stock_master(rows)
candidates = self.database.search_stock_master(raw)
exact = [
item
for item in candidates
if str(item.get("name") or "").casefold() == raw.casefold()
]
matches = exact or candidates
if len(matches) == 1:
return validate_stock_code(str(matches[0].get("code") or ""))
if len(matches) > 1:
choices = "".join(
f"{item.get('name') or '--'}{item.get('code') or '--'}"
for item in matches[:5]
)
raise ValueError(f"匹配到多只股票:{choices}。请输入六位股票代码。")
raise ValueError(f"未找到股票“{raw}”,请检查名称或输入六位股票代码。")
def _heaven_stock_context(
self,
stock_code: str,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str,
) -> dict[str, Any]:
"""Return the only stock contract accepted by heaven trend."""
pool_row = next(
(
dict(row) for key in ("limits", "broken", "down_limits")
for row in dashboard.get(key) or []
if str(row.get("code") or "") == stock_code
),
{},
)
if market_mode == "intraday":
if self.configured:
try:
quote = self._tushare_client().realtime_stock_quote(
tushare_code(stock_code),
trade_date,
)
return {
**quote,
"status": pool_row.get("status") or "普通",
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"precise": True,
}
except TushareError:
pass
if pool_row:
return {
**pool_row,
"data_source": "dashboard_rt" if dashboard.get("meta", {}).get("realtime") else "dashboard",
"trade_date": trade_date,
"realtime": bool(dashboard.get("meta", {}).get("realtime")),
"precise": False,
}
return {
"code": stock_code,
"name": "--",
"sector": "其他",
"trade_date": trade_date,
"realtime": False,
"precise": False,
}
detail = self.get_stock_detail(stock_code, trade_date, force=True)
detail_meta = detail.get("meta") or {}
stock = detail.get("stock") or {}
resolved_date = normalize_date(str(detail_meta.get("trade_date") or trade_date))
source = str(detail_meta.get("source") or "")
return {
"code": stock_code,
"name": stock.get("name") or pool_row.get("name") or "--",
"sector": stock.get("industry") or pool_row.get("sector") or "其他",
"status": pool_row.get("status") or "普通",
"change": stock.get("change") or 0,
"turnover_rate": stock.get("turnover_rate") or 0,
"amount_billion": stock.get("amount_billion") or 0,
"seal_amount_million": pool_row.get("seal_amount_million") or 0,
"open_times": pool_row.get("open_times") or 0,
"streak": pool_row.get("streak") or 0,
"data_source": source,
"trade_date": resolved_date,
"realtime": False,
"precise": source == "tushare" and resolved_date == trade_date,
}
def _heaven_index_context(
self,
trade_date: str,
dashboard: dict[str, Any],
market_mode: str = "historical",
) -> dict[str, Any]:
cached = self.database.get_data_snapshot("heaven_indices", trade_date)
cached_valid = False
if cached:
cached_rows = list(cached.get("indices") or [])
cached_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in cached_rows
}
cached_valid = (
len(cached_rows) == 3
and cached_dates == {trade_date}
and bool(cached.get("precise"))
and not cached.get("realtime")
and str(cached.get("source") or "") == "tushare"
and int(cached.get("schema_version") or 0) >= 3
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
error = "Tushare Token 未配置"
else:
try:
client = self._tushare_client()
if market_mode == "intraday":
payload = self._aggregate_index_context(trade_date)
payload["schema_version"] = 3
return payload
payload = client.market_indices(trade_date)
payload["schema_version"] = 3
if market_mode == "closed":
payload["finalized"] = True
self.database.save_data_snapshot(
"heaven_indices",
trade_date,
str(payload.get("source") or "tushare"),
payload,
)
return payload
except Exception as exc:
error = str(exc)
overview = dashboard.get("overview") or {}
up_count = float(overview.get("up_count") or 0)
down_count = float(overview.get("down_count") or 0)
breadth = (up_count - down_count) / max(up_count + down_count, 1)
return {
"source": "market_breadth_proxy",
"trade_date": trade_date,
"realtime": False,
"precise": False,
"schema_version": 3,
"notice": f"指数数据不可用,当前以市场宽度代理:{error}",
"indices": [],
"aggregate": {
"average_pct_chg": round(breadth * 2.5, 3),
"average_return_5d": 0,
"average_return_20d": 0,
},
}
def _aggregate_index_context(
self,
trade_date: str,
tushare_error: str = "",
) -> dict[str, Any]:
quotes = self.realtime_aggregator.tencent_indices()
epochs = [int(item.get("quote_time_epoch") or 0) for item in quotes]
quote_dates = {
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
for epoch in epochs if epoch
}
if len(quotes) != 3 or quote_dates != {trade_date}:
raise ValueError("腾讯三大指数日期与目标交易日不一致")
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
if max(epochs) - min(epochs) > max_skew:
raise ValueError(f"腾讯三大指数时间差超过{max_skew}")
code_map = {
"000001": "000001.SH",
"399001": "399001.SZ",
"399006": "399006.SZ",
}
client = self._tushare_client()
indices = []
start_date = (
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
).strftime("%Y%m%d")
for quote in quotes:
ts_code = code_map[str(quote.get("code") or "")]
history = client.query(
"index_daily",
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
history.sort(key=lambda item: str(item.get("trade_date") or ""))
completed_closes = [
float(item.get("close") or 0)
for item in history
if str(item.get("trade_date") or "") < trade_date
and float(item.get("close") or 0) > 0
]
close_5d = (
completed_closes[-5]
if len(completed_closes) >= 5
else completed_closes[0] if completed_closes else 0
)
close = float(quote.get("price") or 0)
indices.append(
{
"ts_code": ts_code,
"name": quote.get("name") or ts_code,
"trade_date": trade_date,
"close": close,
"pct_chg": round(float(quote.get("change") or 0), 3),
"return_5d": round((close / close_5d - 1) * 100, 3) if close_5d else 0,
"return_20d": 0,
"amount_billion": float(quote.get("amount_billion") or 0),
"quote_time": quote.get("quote_time") or "",
}
)
return {
"trade_date": trade_date,
"source": "+".join(
sorted({str(item.get("source") or "web_quote") for item in quotes})
+ ["tushare_index_daily"]
),
"realtime": True,
"precise": True,
"indices": indices,
"aggregate": {
"average_pct_chg": round(
sum(item["pct_chg"] for item in indices) / len(indices), 3
),
"average_return_5d": round(
sum(item["return_5d"] for item in indices) / len(indices), 3
),
"average_return_20d": 0,
},
"quote_time_skew_seconds": max(epochs) - min(epochs),
"notice": (
"指数实时行情来自腾讯行情,5日趋势来自Tushare历史指数。"
+ (f" Tushare实时指数未使用:{tushare_error}" if tushare_error else "")
),
}
def _heaven_sector_context(
self,
identifier: str,
trade_date: str,
market_mode: str = "historical",
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
cached_date = str((cached or {}).get("trade_date") or "").replace("-", "")
cached_valid = bool(
cached
and cached_date == trade_date
and cached.get("taxonomy") == "sw_l2"
and cached.get("inner_precise", cached.get("precise"))
and cached.get("outer_precise", cached.get("precise"))
and not cached.get("realtime")
and int(cached.get("schema_version") or 0) >= 6
)
if market_mode != "intraday" and cached_valid:
return cached
if not self.configured:
return None
try:
payload = self._tushare_client().sw_sector_snapshot(
tushare_code(identifier),
trade_date,
realtime_expected=market_mode == "intraday",
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_valid:
return cached
return {
"name": "",
"code": "",
"taxonomy": "sw_l2",
"source": "tushare",
"trade_date": trade_date,
"realtime": market_mode == "intraday",
"precise": False,
"inner_precise": False,
"outer_precise": False,
"coverage": 0,
"member_count": 0,
"quote_count": 0,
"error": f"申万二级行业数据获取失败:{exc}",
}
if not payload.get("realtime") and payload.get("precise"):
self.database.save_data_snapshot(
"heaven_sector",
cache_key,
str(payload.get("source") or "tushare"),
payload,
)
return payload
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import json
import secrets
from datetime import date
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.features.heaven.agent import (
HEAVEN_PROMPT_VERSIONS,
HeavenAgentError,
interpret_heaven,
)
from backend.features.heaven.engine import (
build_five_phase_field,
hexagram_from_lines,
)
from backend.features.heaven.knowledge import prepare_heaven_context
from backend.features.heaven.six_yao import build_six_yao_chart
from backend.features.market import MarketServiceMixin
class HeavenReadingMixin:
def heaven_personal(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
field = build_five_phase_field(
trade_date,
self.database.list_sector_phase_overrides(),
)
personal = self.account_personal_field(trade_date, field, public=True)
if not personal:
raise ValueError("请先在账号设置中保存个人命理资料。")
return personal
def heaven_hexagram(self, raw_lines: Any) -> dict[str, Any]:
if not isinstance(raw_lines, list):
raise ValueError("六爻起卦结果格式不正确。")
try:
lines = [int(value) for value in raw_lines]
except (TypeError, ValueError) as exc:
raise ValueError("六爻必须由六、七、八、九组成。") from exc
return hexagram_from_lines(lines)
def heaven_readings(
self, mode: str, context_date: str = "", limit: int = 100
) -> dict[str, Any]:
mode = str(mode or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("解读记录类型不正确。")
normalized_date = normalize_date(context_date) if context_date else ""
return {
"mode": mode,
"items": self.database.list_heaven_readings(
self.current_user_id, mode, normalized_date, limit
),
}
@staticmethod
def _heaven_reading_identity(
mode: str, context_date: str, context: dict[str, Any]
) -> tuple[str, str]:
display_date = MarketServiceMixin._display_compact_date(context_date)
if mode == "trend":
stock = (context.get("selected_focus") or {}).get("stock") or {}
code = str(stock.get("code") or "").strip()
name = str(stock.get("name") or "").strip()
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
subject = " ".join(item for item in (code, name) if item) or "观势"
detail = f"{display_date} · {hexagram.get('name') or '--'}{transformed.get('name') or '--'}"
return subject, detail
if mode == "fortune":
field = context.get("five_phase_field") or {}
pillars = field.get("pillars") or {}
dominant = (field.get("balance") or [{}])[0]
subject = f"{display_date} 观气"
detail = (
f"{pillars.get('year') or '--'}年 · {pillars.get('month') or '--'}月 · "
f"{pillars.get('day') or '--'}日 · {dominant.get('element') or '--'}气偏显"
)
return subject, detail
hexagram = context.get("hexagram") or {}
transformed = hexagram.get("transformed") or {}
question = str(context.get("question") or "").strip()
question_detail = f" · {question[:48]}" if question else ""
return (
f"{display_date} 观心",
f"{hexagram.get('name') or '--'}{transformed.get('name') or '--'}{question_detail}",
)
def heaven_interpret(self, payload: dict[str, Any]) -> dict[str, Any]:
mode = str(payload.get("mode") or "").strip()
if mode not in {"trend", "fortune", "heart"}:
raise ValueError("问天解读模式不正确。")
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
stale_fortune: dict[str, Any] | None = None
if mode == "fortune":
existing = self.database.latest_heaven_reading(
self.current_user_id, "fortune", trade_date
)
if self._legacy_truncated_heaven_reading(existing):
self.database.delete_heaven_reading(
self.current_user_id, int(existing["id"])
)
existing = None
if existing and self.database.heaven_reading_interpretation_version(
self.current_user_id, int(existing["id"])
) == prompt_version:
return {
"answer": existing["answer"],
"mode": mode,
"compiler": "stored",
"notice": "",
"reading": existing,
"reused": True,
}
stale_fortune = existing
if mode in {"trend", "fortune"}:
setup = self.heaven_setup(
trade_date,
str(payload.get("sector") or ""),
str(payload.get("stock_code") or ""),
payload.get("manual_data"),
)
if mode == "trend":
chart = setup["chart"]
if not chart.get("available"):
issues = "".join((chart.get("quality") or {}).get("issues") or [])
raise ValueError(f"观势数据未通过六爻校验,暂不解势:{issues}")
hexagram_context = json.loads(json.dumps(chart["hexagram"], ensure_ascii=False))
for line in hexagram_context.get("lines", []):
line.pop("evidence", None)
line.pop("score", None)
line.pop("talent", None)
line.pop("layer", None)
line.pop("role", None)
if not line.get("moving"):
line.pop("text", None)
line.pop("image", None)
line.pop("line_name", None)
context = {
"data_trade_date": setup["trade_date"],
"selected_focus": {
"sector": chart.get("sector") or "",
"stock": chart.get("stock") or {},
},
"hexagram": hexagram_context,
"movement": chart.get("movement") or {},
}
else:
personal_profile = self.account_personal_field(
setup["calendar_date"],
setup["field"],
public=False,
)
context = {
"calendar_date": setup["calendar_date"],
"five_phase_field": setup["field"],
"personal_profile": personal_profile,
}
context_date = setup["calendar_date"]
if mode == "trend":
context_date = setup["trade_date"]
else:
question = str(payload.get("question") or "").strip()
if len(question) > 300:
raise ValueError("观心问题不能超过300个字符。")
question_preset = str(payload.get("question_preset") or "unthemed").strip()
if question_preset not in {"trade", "mind", "unthemed", "custom"}:
question_preset = "custom"
if not question:
question = "不设具体问题,只观此刻一念。"
question_preset = "unthemed"
raw_lines = payload.get("lines")
hexagram = self.heaven_hexagram(raw_lines)
context = {
"question": question,
"question_preset": question_preset,
"hexagram": hexagram,
"six_yao": build_six_yao_chart(
[int(value) for value in raw_lines],
str(payload.get("cast_at") or ""),
),
"ritual": {
"breathing": "用户已完成1秒准备与五轮吸3秒、顿2秒、呼4秒的静心呼吸。",
"casting": "用户以三枚铜钱自初爻至上爻投掷六次。",
"reflection": "用户已在看见卦象后察看第一念。",
},
}
context_date = trade_date
agent_context = prepare_heaven_context(mode, context)
agent_context["interpretation_version"] = prompt_version
result, compiler = self._call_heaven_agent(mode, agent_context)
subject, subject_detail = self._heaven_reading_identity(
mode, context_date, context
)
dedupe_key = (
f"fortune:{context_date}"
if mode == "fortune"
else f"{mode}:{context_date}:{secrets.token_urlsafe(12)}"
)
if stale_fortune:
self.database.delete_heaven_reading(
self.current_user_id, int(stale_fortune["id"])
)
reading = self.database.save_heaven_reading(
self.current_user_id,
mode,
context_date,
subject,
subject_detail,
str(result.get("answer") or ""),
agent_context,
dedupe_key,
)
return {
**result,
"mode": mode,
"compiler": compiler,
"notice": "当前智能服务繁忙,已自动切换备用服务。" if compiler == "fallback" else "",
"reading": reading,
"reused": False,
}
@staticmethod
def _legacy_truncated_heaven_reading(reading: dict[str, Any] | None) -> bool:
return bool(reading and str(reading.get("answer") or "").rstrip().endswith("……"))
def _call_heaven_agent(self, mode: str, context: dict[str, Any]) -> tuple[dict[str, Any], str]:
prompt_version = HEAVEN_PROMPT_VERSIONS[mode]
result = self.llm_gateway.call(
f"heaven_{mode}",
prompt_version,
lambda profile: interpret_heaven(
mode,
context,
profile.api_key,
profile.base_url,
profile.model,
),
(HeavenAgentError,),
)
return result.value, result.role
+48
View File
@@ -7,6 +7,35 @@ from typing import Any
class HeavenRepositoryMixin:
def list_sector_phase_overrides(self) -> dict[str, str]:
with self.connect() as connection:
rows = connection.execute(
"SELECT name, element FROM sector_phase_overrides ORDER BY updated_at DESC, name"
).fetchall()
return {row["name"]: row["element"] for row in rows}
def save_sector_phase_override(self, name: str, element: str) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO sector_phase_overrides (name, element, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
element = excluded.element,
updated_at = excluded.updated_at
""",
(name, element, now),
)
def delete_sector_phase_override(self, name: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM sector_phase_overrides WHERE name = ?",
(name,),
)
return cursor.rowcount > 0
@staticmethod
def _heaven_reading_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if not row:
@@ -102,6 +131,25 @@ class HeavenRepositoryMixin:
items = self.list_heaven_readings(user_id, mode, context_date, 1)
return items[0] if items else None
def heaven_reading_interpretation_version(
self, user_id: int, reading_id: int
) -> str:
with self.connect() as connection:
row = connection.execute(
"""
SELECT context_snapshot FROM heaven_readings
WHERE id = ? AND user_id = ?
""",
(int(reading_id), int(user_id)),
).fetchone()
if not row:
return ""
try:
snapshot = json.loads(str(row["context_snapshot"] or "{}"))
except (TypeError, json.JSONDecodeError):
return ""
return str(snapshot.get("interpretation_version") or "")
def delete_heaven_reading(self, user_id: int, reading_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs, unquote
from backend.bootstrap.config import validate_text
class HeavenRoutesMixin:
def _handle_heaven_get(self, parsed) -> bool:
if parsed.path == "/api/heaven/readings":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.heaven_readings(
query.get("mode", [""])[0],
query.get("context_date", [""])[0],
int(query.get("limit", ["100"])[0]),
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/heaven/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
sector_name = query.get("sector", [""])[0]
stock_code = query.get("stock_code", [""])[0]
manual_data = None
manual_text = query.get("manual_data", [""])[0]
if manual_text:
try:
manual_data = json.loads(manual_text)
except json.JSONDecodeError:
self.send_json({"error": "六爻补录数据格式不正确。"}, HTTPStatus.BAD_REQUEST)
return True
try:
self.send_json(
self.application_service.heaven_setup(
trade_date,
sector_name,
stock_code,
manual_data,
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_heaven_delete(self, parsed) -> bool:
heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path)
if heaven_reading_match:
deleted = self.application_service.database.delete_heaven_reading(
self.application_service.current_user_id, int(heaven_reading_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return True
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match:
name = unquote(sector_phase_match.group(1)).strip()
deleted = self.application_service.database.delete_sector_phase_override(name)
self.send_json({"ok": True, "deleted": deleted})
return True
return False
def save_sector_phase_override(self) -> None:
try:
body = self.read_json_body()
name = validate_text(body.get("name"), "行业或题材名称", 50, required=True)
element = str(body.get("element") or "").strip()
if element not in {"", "", "", "", ""}:
raise ValueError("五行归类必须是木、火、土、金或水。")
self.application_service.database.save_sector_phase_override(name, element)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
from backend.features.heaven.engine import (
BRANCH_ELEMENT,
ELEMENT_CONTROLS,
ELEMENT_GENERATES,
LINE_POSITIONS,
Solar,
)
SHANGHAI = timezone(timedelta(hours=8), "Asia/Shanghai")
TRIGRAM_BITS = {
"": (1, 1, 1),
"": (1, 1, 0),
"": (1, 0, 1),
"": (1, 0, 0),
"": (0, 1, 1),
"": (0, 1, 0),
"": (0, 0, 1),
"": (0, 0, 0),
}
BITS_TRIGRAM = {bits: name for name, bits in TRIGRAM_BITS.items()}
PALACE_ELEMENT = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
# 京房纳甲通行表。每组均按初爻至三爻、四爻至上爻排列。
NAJIA = {
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
"": {
"inner": (("", ""), ("", ""), ("", "")),
"outer": (("", ""), ("", ""), ("", "")),
},
}
PALACE_STAGES = (
("本宫", (), 6),
("一世", (0,), 1),
("二世", (0, 1), 2),
("三世", (0, 1, 2), 3),
("四世", (0, 1, 2, 3), 4),
("五世", (0, 1, 2, 3, 4), 5),
("游魂", (0, 1, 2, 4), 4),
("归魂", (4,), 3),
)
SIX_SPIRITS = ("青龙", "朱雀", "勾陈", "螣蛇", "白虎", "玄武")
SPIRIT_START = {
"": 0,
"": 0,
"": 1,
"": 1,
"": 2,
"": 3,
"": 4,
"": 4,
"": 5,
"": 5,
}
BRANCH_CLASH = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_COMBINE = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
BRANCH_HARM = {
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "",
}
THREE_PUNISHMENTS = (frozenset("寅巳申"), frozenset("丑未戌"), frozenset("子卯"))
SELF_PUNISHMENT = set("辰午酉亥")
ADVANCE_PAIRS = {
("", ""), ("", ""), ("", ""), ("", ""),
("", ""), ("", ""), ("", ""), ("", ""),
}
RETREAT_PAIRS = {(target, source) for source, target in ADVANCE_PAIRS}
def build_six_yao_chart(values: list[int], cast_at: str = "") -> dict[str, Any]:
"""Return a deterministic Jing Fang Na Jia chart for a six-coin result."""
if len(values) != 6 or any(value not in {6, 7, 8, 9} for value in values):
raise ValueError("六爻必须由六、七、八、九组成,且从初爻到上爻排列。")
observed_at = _parse_cast_at(cast_at)
solar = Solar.fromYmdHms(
observed_at.year,
observed_at.month,
observed_at.day,
observed_at.hour,
observed_at.minute,
observed_at.second,
)
lunar = solar.getLunar()
month_gz = lunar.getMonthInGanZhiExact()
day_gz = lunar.getDayInGanZhiExact2()
time_gz = lunar.getTimeInGanZhi()
void_branches = tuple(lunar.getDayXunKongExact2())
month_branch = month_gz[1]
day_stem, day_branch = day_gz[0], day_gz[1]
bits = tuple(1 if value % 2 else 0 for value in values)
transformed_values = tuple(7 if value == 6 else 8 if value == 9 else value for value in values)
transformed_bits = tuple(1 if value % 2 else 0 for value in transformed_values)
palace = _palace_map()[bits]
palace_element = PALACE_ELEMENT[palace["trigram"]]
self_position = int(palace["self_position"])
response_position = self_position + 3 if self_position <= 3 else self_position - 3
najia = _najia_for_bits(bits)
transformed_najia = _najia_for_bits(transformed_bits)
spirits = tuple(
SIX_SPIRITS[(SPIRIT_START[day_stem] + index) % 6] for index in range(6)
)
lines: list[dict[str, Any]] = []
for index, ((stem, branch), value) in enumerate(zip(najia, values)):
position = index + 1
element = BRANCH_ELEMENT[branch]
transformed_stem, transformed_branch = transformed_najia[index]
transformed_element = BRANCH_ELEMENT[transformed_branch]
line = {
"position": position,
"position_name": LINE_POSITIONS[index],
"value": value,
"yin_yang": "" if value % 2 else "",
"moving": value in {6, 9},
"stem": stem,
"branch": branch,
"element": element,
"relative": _six_relative(palace_element, element),
"spirit": spirits[index],
"role": "" if position == self_position else "" if position == response_position else "",
"void": branch in void_branches,
"month": _calendar_relation("", month_branch, branch),
"day": _calendar_relation("", day_branch, branch),
}
if line["moving"]:
line["transformation"] = {
"value": transformed_values[index],
"yin_yang": "" if transformed_values[index] % 2 else "",
"stem": transformed_stem,
"branch": transformed_branch,
"element": transformed_element,
"relative": _six_relative(palace_element, transformed_element),
"relation_to_origin": _transformation_relation(
branch,
element,
transformed_branch,
transformed_element,
),
}
lines.append(line)
hidden = _hidden_spirits(palace["trigram"], palace_element, lines)
for item in hidden:
lines[item["position"] - 1].setdefault("hidden_spirits", []).append(item)
return {
"method": "京房纳甲·八宫世应",
"method_version": "xiaobai-six-yao-v1",
"sources": ["jingfang", "huozhulin", "zengshan"],
"cast_at": observed_at.isoformat(timespec="seconds"),
"timezone": "Asia/Shanghai",
"day_boundary": "晚子时仍按民用当日排日柱",
"calendar": {
"month": month_gz,
"month_branch": month_branch,
"day": day_gz,
"day_branch": day_branch,
"time": time_gz,
"day_void": "".join(void_branches),
},
"palace": {
"name": f"{palace['trigram']}",
"trigram": palace["trigram"],
"element": palace_element,
"stage": palace["stage"],
"self_position": self_position,
"response_position": response_position,
},
"lines": lines,
"hidden_spirits": hidden,
"branch_pattern": _hexagram_branch_pattern(lines),
"relationships": _significant_line_relationships(lines),
}
def _parse_cast_at(raw: str) -> datetime:
value = str(raw or "").strip()
if not value:
return datetime.now(SHANGHAI)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("起卦时间格式不正确。") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=SHANGHAI)
return parsed.astimezone(SHANGHAI)
@lru_cache(maxsize=1)
def _palace_map() -> dict[tuple[int, ...], dict[str, Any]]:
result: dict[tuple[int, ...], dict[str, Any]] = {}
for trigram, trigram_bits in TRIGRAM_BITS.items():
pure = trigram_bits + trigram_bits
for stage, flipped, self_position in PALACE_STAGES:
bits = list(pure)
for index in flipped:
bits[index] = 1 - bits[index]
key = tuple(bits)
if key in result:
raise RuntimeError("八宫映射出现重复卦象。")
result[key] = {
"trigram": trigram,
"stage": stage,
"self_position": self_position,
}
if len(result) != 64:
raise RuntimeError("八宫映射未覆盖六十四卦。")
return result
def _najia_for_bits(bits: tuple[int, ...]) -> tuple[tuple[str, str], ...]:
inner = BITS_TRIGRAM[bits[:3]]
outer = BITS_TRIGRAM[bits[3:]]
return tuple(NAJIA[inner]["inner"] + NAJIA[outer]["outer"])
def _six_relative(palace_element: str, line_element: str) -> str:
if line_element == palace_element:
return "兄弟"
if ELEMENT_GENERATES[line_element] == palace_element:
return "父母"
if ELEMENT_GENERATES[palace_element] == line_element:
return "子孙"
if ELEMENT_CONTROLS[palace_element] == line_element:
return "妻财"
return "官鬼"
def _calendar_relation(prefix: str, actor_branch: str, line_branch: str) -> dict[str, Any]:
actor_element = BRANCH_ELEMENT[actor_branch]
line_element = BRANCH_ELEMENT[line_branch]
labels = []
if actor_branch == line_branch:
labels.append(f"{prefix}{'' if prefix == '' else ''}")
if BRANCH_CLASH[actor_branch] == line_branch:
labels.append("月破" if prefix == "" else "日冲")
if BRANCH_COMBINE[actor_branch] == line_branch:
labels.append(f"{prefix}")
if BRANCH_HARM[actor_branch] == line_branch:
labels.append(f"{prefix}")
element_relation = _actor_element_relation(actor_element, line_element, prefix)
return {
"branch": actor_branch,
"element": actor_element,
"branch_relations": labels,
"element_relation": element_relation,
}
def _actor_element_relation(actor: str, target: str, prefix: str) -> str:
if actor == target:
return f"{prefix}与爻同气"
if ELEMENT_GENERATES[actor] == target:
return f"{prefix}生爻"
if ELEMENT_CONTROLS[actor] == target:
return f"{prefix}克爻"
if ELEMENT_GENERATES[target] == actor:
return f"爻生{prefix}"
return f"爻克{prefix}"
def _transformation_relation(
origin_branch: str,
origin_element: str,
target_branch: str,
target_element: str,
) -> list[str]:
labels = []
if (origin_branch, target_branch) in ADVANCE_PAIRS:
labels.append("化进神")
elif (origin_branch, target_branch) in RETREAT_PAIRS:
labels.append("化退神")
if BRANCH_COMBINE[origin_branch] == target_branch:
labels.append("化合")
if BRANCH_CLASH[origin_branch] == target_branch:
labels.append("化冲")
if target_element == origin_element:
labels.append("变爻同气")
elif ELEMENT_GENERATES[target_element] == origin_element:
labels.append("回头生")
elif ELEMENT_CONTROLS[target_element] == origin_element:
labels.append("回头克")
elif ELEMENT_GENERATES[origin_element] == target_element:
labels.append("原爻生变")
else:
labels.append("原爻克变")
return labels
def _hidden_spirits(
palace_trigram: str,
palace_element: str,
lines: list[dict[str, Any]],
) -> list[dict[str, Any]]:
present = {str(line["relative"]) for line in lines}
missing = {"父母", "兄弟", "子孙", "妻财", "官鬼"} - present
if not missing:
return []
pure_bits = TRIGRAM_BITS[palace_trigram] + TRIGRAM_BITS[palace_trigram]
result = []
for index, (stem, branch) in enumerate(_najia_for_bits(pure_bits)):
element = BRANCH_ELEMENT[branch]
relative = _six_relative(palace_element, element)
if relative not in missing:
continue
result.append(
{
"position": index + 1,
"position_name": LINE_POSITIONS[index],
"stem": stem,
"branch": branch,
"element": element,
"relative": relative,
"flying_relative": lines[index]["relative"],
}
)
return result
def _hexagram_branch_pattern(lines: list[dict[str, Any]]) -> str:
pairs = ((0, 3), (1, 4), (2, 5))
if all(BRANCH_CLASH[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六冲"
if all(BRANCH_COMBINE[lines[left]["branch"]] == lines[right]["branch"] for left, right in pairs):
return "六合"
return ""
def _significant_line_relationships(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for left_index in range(6):
for right_index in range(left_index + 1, 6):
left = lines[left_index]
right = lines[right_index]
if not (left["moving"] or right["moving"] or left["role"] or right["role"]):
continue
labels = _branch_pair_relations(left["branch"], right["branch"])
element_relation = _pair_element_relation(left["element"], right["element"])
if not labels and element_relation == "同气":
continue
result.append(
{
"positions": [left["position"], right["position"]],
"lines": [left["position_name"], right["position_name"]],
"branch_relations": labels,
"element_relation": element_relation,
}
)
return result
def _branch_pair_relations(left: str, right: str) -> list[str]:
labels = []
if BRANCH_COMBINE[left] == right:
labels.append("六合")
if BRANCH_CLASH[left] == right:
labels.append("六冲")
if BRANCH_HARM[left] == right:
labels.append("六害")
pair = frozenset((left, right))
if pair in THREE_PUNISHMENTS or (left == right and left in SELF_PUNISHMENT):
labels.append("相刑")
return labels
def _pair_element_relation(left: str, right: str) -> str:
if left == right:
return "同气"
if ELEMENT_GENERATES[left] == right:
return "前者生后者"
if ELEMENT_GENERATES[right] == left:
return "后者生前者"
if ELEMENT_CONTROLS[left] == right:
return "前者克后者"
return "后者克前者"
+370
View File
@@ -0,0 +1,370 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.bootstrap.config import normalize_date
from backend.data.providers.tushare_client import _sector_coverage_issue
from backend.features.heaven.agent import HEAVEN_PROMPT_VERSIONS
from backend.features.heaven.engine import build_five_phase_field, build_market_hexagram
class HeavenTrendMixin:
def heaven_setup(
self,
trade_date: str,
sector_name: str = "",
stock_code: str = "",
manual_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
dashboard = self.get_dashboard(normalized_date)
data_date = normalize_date(str(dashboard.get("meta", {}).get("trade_date") or normalized_date))
recent_history = self.database.snapshot_summaries(data_date, 10)
market_mode = self._heaven_market_mode(data_date, dashboard)
manual_data = self._validate_heaven_manual_data(manual_data, market_mode)
index_context = self._heaven_index_context(data_date, dashboard, market_mode)
external_stock = None
normalized_stock_code = ""
if stock_code.strip():
normalized_stock_code = self._resolve_heaven_stock_code(stock_code)
external_stock = self._heaven_stock_context(
normalized_stock_code,
data_date,
dashboard,
market_mode,
)
external_sector = None
if normalized_stock_code and self.configured:
external_sector = self._heaven_sector_context(
normalized_stock_code,
data_date,
market_mode,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
dashboard, index_context, external_sector, external_stock = self._apply_heaven_manual_data(
dashboard,
index_context,
external_sector,
external_stock,
manual_data,
market_mode,
data_date,
normalized_stock_code,
)
if external_sector and external_stock:
external_stock["sector"] = external_sector.get("name") or external_stock.get("sector")
sector_input = str((external_sector or {}).get("name") or sector_name.strip())
if not normalized_stock_code:
data_checks = []
chart = {
"available": False,
"selection_required": True,
"data_trade_date": data_date,
"sector": "",
"sector_code": "",
"sector_taxonomy": "",
"stock": {"code": "", "name": "", "status": ""},
"quality": {
"status": "awaiting_selection",
"issues": [],
"principle": "",
"sources": [],
},
"index_context": index_context,
}
else:
data_checks = self._heaven_line_checks(
data_date,
dashboard,
recent_history,
index_context,
external_sector or {},
external_stock or {},
market_mode,
manual_data,
)
quality_issues = [
f"{check['position']}·{check['layer']}{''.join(check['reasons'])}"
for check in data_checks
if not check["passed"]
]
if quality_issues:
chart = {
"available": False,
"selection_required": False,
"data_trade_date": data_date,
"sector": str((external_sector or {}).get("name") or sector_input or "--"),
"sector_code": str((external_sector or {}).get("code") or ""),
"sector_taxonomy": str((external_sector or {}).get("taxonomy") or ""),
"stock": {
"code": normalized_stock_code,
"name": str((external_stock or {}).get("name") or "--"),
"status": str((external_stock or {}).get("status") or ""),
},
"quality": {
"status": "blocked",
"issues": quality_issues,
"principle": "六爻任一层缺少同日、同口径的有效数据,本系统不成卦。",
"sources": self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
},
"index_context": index_context,
}
else:
chart = build_market_hexagram(
dashboard,
recent_history,
index_context,
sector_input,
normalized_stock_code,
external_stock,
external_sector,
)
chart["available"] = True
chart["selection_required"] = False
manual_active = any(check["status"] == "manual" for check in data_checks)
chart["quality"] = {
"status": "manual" if manual_active else "verified",
"issues": [],
"principle": (
"自动行情与用户补充数据均已通过同一套量化公式校验。"
if manual_active
else "指数、板块、个股均已通过同日同口径校验。"
),
"sources": [
*self._heaven_trend_sources(
data_date, index_context, external_sector, external_stock
),
*([{
"lines": "补录爻位",
"layer": "用户补充",
"realtime": market_mode == "intraday",
"detail": str(manual_data.get("note") or "量化数据经原公式重新计算"),
}] if manual_active else []),
],
}
chart["data_checks"] = data_checks
chart["manual_data"] = manual_data
sector_phase_overrides = self.database.list_sector_phase_overrides()
field = build_five_phase_field(
normalized_date,
sector_phase_overrides,
)
personal_profile = self.account_personal_field(
normalized_date,
field,
public=True,
)
daily_fortune_reading = self._reusable_daily_fortune_reading(normalized_date)
return {
"trade_date": data_date,
"calendar_date": normalized_date,
"market_mode": market_mode,
"chart": chart,
"field": field,
"personal_profile": personal_profile,
"daily_fortune_reading": daily_fortune_reading,
"sector_phase_overrides": [
{"name": name, "element": element}
for name, element in sector_phase_overrides.items()
],
"llm": {
"configured": self.llm_configured,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
}
def _reusable_daily_fortune_reading(
self, context_date: str
) -> dict[str, Any] | None:
reading = self.database.latest_heaven_reading(
self.current_user_id, "fortune", context_date
)
if not reading or self._legacy_truncated_heaven_reading(reading):
return None
version = self.database.heaven_reading_interpretation_version(
self.current_user_id, int(reading["id"])
)
if version != HEAVEN_PROMPT_VERSIONS["fortune"]:
return None
return reading
@staticmethod
def _heaven_market_mode(
trade_date: str,
dashboard: dict[str, Any],
now: datetime | None = None,
) -> str:
"""区分盘中、今日收盘和历史,避免把 rt_k 数据来源误当成交易状态。"""
now = now or datetime.now().astimezone()
if trade_date != now.strftime("%Y%m%d"):
return "historical"
meta = dashboard.get("meta") or {}
status = str(meta.get("market_status") or "").lower()
local_time = now.time().replace(tzinfo=None)
if status == "closed" or local_time > datetime.strptime("15:05", "%H:%M").time():
return "closed"
if status in {"trading", "auction", "pre_open"} or (
bool(meta.get("realtime"))
and local_time >= datetime.strptime("09:15", "%H:%M").time()
):
return "intraday"
return "historical"
@staticmethod
def _heaven_trend_sources(
trade_date: str,
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
) -> list[dict[str, Any]]:
sector = sector or {}
stock = stock or {}
return [
{
"lines": "五爻、上爻",
"layer": "指数",
"source": index_context.get("source") or "unavailable",
"trade_date": index_context.get("trade_date") or "",
"realtime": bool(index_context.get("realtime")),
"detail": f"三大指数 {len(index_context.get('indices') or [])}/3",
},
{
"lines": "三爻、四爻",
"layer": "行业",
"source": sector.get("source") or "unavailable",
"trade_date": sector.get("trade_date") or "",
"realtime": bool(sector.get("realtime")),
"detail": (
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
),
},
{
"lines": "初爻、二爻",
"layer": "个股",
"source": stock.get("data_source") or "unavailable",
"trade_date": stock.get("trade_date") or trade_date,
"realtime": bool(stock.get("realtime")),
"detail": (
f"{stock.get('name') or '--'};换手基准 "
f"{stock.get('capital_trade_date') or '--'}"
),
},
]
@staticmethod
def _heaven_trend_quality_issues(
trade_date: str,
dashboard: dict[str, Any],
index_context: dict[str, Any],
sector: dict[str, Any] | None,
stock: dict[str, Any] | None,
market_mode: str = "historical",
) -> list[str]:
issues: list[str] = []
intraday = market_mode == "intraday"
closed = market_mode == "closed"
if intraday:
meta = dashboard.get("meta") or {}
market_status = str(meta.get("market_status") or "")
now = datetime.now().astimezone()
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
snapshot_age = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
except ValueError:
snapshot_age = float("inf")
if market_status in {"trading", "auction", "pre_open"} and snapshot_age > 120:
issues.append("主行情快照超过2分钟,请点击顶部刷新")
# 收盘后不再用 dashboard.market_status 作为阻断条件。盘后同步可能将
# rt_k 快照替换成同日盘后日线而不带该字段;六爻数据本身的日期、
# 完整性和来源校验已足以判断是否可以成卦。
index_date = str(index_context.get("trade_date") or "").replace("-", "")
index_rows = list(index_context.get("indices") or [])
index_row_dates = {
str(row.get("trade_date") or "").replace("-", "") for row in index_rows
}
if not index_context.get("precise") or len(index_rows) < 3:
issues.append("指数层缺少三大指数的有效行情")
elif index_date != trade_date or index_row_dates != {trade_date}:
issues.append("指数行情与目标交易日不一致")
elif intraday and not index_context.get("realtime"):
issues.append("盘中指数层缺少可核验的实时行情")
elif not intraday and (
index_context.get("realtime")
or str(index_context.get("source") or "") != "tushare"
):
issues.append("历史/收盘指数层必须使用 Tushare 官方指数日线")
sector = sector or {}
sector_date = str(sector.get("trade_date") or "").replace("-", "")
sector_coverage = float(sector.get("coverage") or 0)
sector_explained_count = int(
sector.get("explained_count")
if sector.get("explained_count") is not None
else sector.get("quote_count") or 0
)
sector_explained_coverage = float(
sector.get("explained_coverage")
if sector.get("explained_coverage") is not None
else sector_coverage
)
sector_coverage_issue = _sector_coverage_issue(
int(sector.get("member_count") or 0),
int(sector.get("quote_count") or 0),
sector_explained_coverage,
sector_explained_count,
)
if not sector:
issues.append("行业层缺少申万二级行业归属")
elif sector.get("taxonomy") != "sw_l2":
issues.append("行业层必须使用申万二级行业分类")
elif sector_date != trade_date:
issues.append("行业行情与目标交易日不一致")
elif intraday and not sector.get("realtime"):
issues.append("盘中行业层缺少申万实时行情")
elif market_mode == "historical" and sector.get("realtime"):
issues.append("历史行业层不能使用实时快照")
elif closed and sector.get("realtime") and not sector.get("finalized"):
issues.append("收盘行业层缺少15:00最终快照")
if not sector.get("inner_precise", sector.get("precise")):
issues.append("行业内核缺少可核验的成分行情")
if not sector.get("outer_precise", sector.get("precise")):
issues.append("行业外显缺少申万官方行情")
if sector and sector_coverage_issue:
issues.append(sector_coverage_issue)
if sector.get("realtime") and not sector.get("relative_turnover"):
issues.append("行业内核缺少相对全市场换手活跃度")
stock = stock or {}
stock_date = str(stock.get("trade_date") or "").replace("-", "")
if not stock or not stock.get("code"):
issues.append("个股层尚未载入有效标的")
elif not stock.get("precise"):
issues.append("个股层缺少可核验的行情数据")
elif stock_date != trade_date:
issues.append("个股行情与目标交易日不一致")
elif intraday and not stock.get("realtime"):
issues.append("盘中个股层不是 rt_k 实时行情")
elif not intraday and (
stock.get("realtime")
or str(stock.get("data_source") or "") != "tushare"
):
issues.append("历史/收盘个股层必须使用 Tushare 官方日线")
if intraday and stock and not stock.get("turnover_source"):
issues.append("个股内核缺少可核验的实时换手率")
elif intraday and stock.get("turnover_source") == "unavailable":
issues.append("个股内核缺少流通股本,无法计算实时换手率")
if intraday and stock.get("activity_source") == "unavailable":
issues.append("个股内核缺少近5日量能基准")
elif intraday and not stock.get("activity_source"):
issues.append("个股内核缺少同时间进度量能")
return issues
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,221 @@
from __future__ import annotations
import copy
from datetime import datetime
from statistics import median
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketAuctionInsightsMixin:
def auction_center(
self,
requested_date: str,
force: bool = False,
user_id: int = 0,
) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
session = self._auction_session(requested_date, trade_date)
phase = str(session["phase"])
ifind_ready = bool(self.ifind and self.ifind.configured)
live_dynamic = phase == "observing" and ifind_ready
use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready
data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date
carried_forward = data_date != trade_date
cache_key = data_date
if not force and not live_dynamic:
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {
**result.get("meta", {}),
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool((result.get("summary") or {}).get("stock_count")),
"cached": True,
}
return self._with_auction_watchlist(result, data_date, user_id)
if use_ifind_snapshot:
rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
else:
rows = []
if not rows and not live_dynamic:
try:
rows = self.client.query("stk_auction", {"trade_date": data_date})
except TushareError:
rows = self.database.auction_factors_for_date(data_date)
if not rows:
return {
"meta": {
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": False,
"cached": False,
"notice": "该交易日暂无可用竞价快照",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": 0, "up_count": 0, "down_count": 0,
"limit_open_count": 0, "strong_open_count": 0,
"median_change": 0, "amount_billion": 0,
"candidate_count": 0, "focus_count": 0, "one_price_count": 0,
},
"expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
"candidate_meta": {"baseline_date": _display_date(previous_date)},
"themes": {"carry": [], "new_themes": []},
"amount_history": self._auction_amount_history(data_date),
"news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"},
"focus_rows": [], "one_price_rows": [], "rows": [],
"watchlist_rows": [], "watchlist_missing_count": 0,
}
master = self._stock_master()
try:
limit_rows = self.client.query(
"stk_limit",
{"trade_date": data_date},
"trade_date,ts_code,up_limit,down_limit",
)
except TushareError:
limit_rows = []
limit_map = {str(item.get("ts_code") or ""): item for item in limit_rows}
normalized = []
for row in rows:
ts_code = str(row.get("ts_code") or "")
stock = master.get(ts_code)
price = _number(row.get("price"))
pre_close = _number(row.get("pre_close"))
list_date = str((stock or {}).get("list_date") or "")
if (
not stock
or price <= 0
or pre_close <= 0
or (list_date and list_date >= data_date)
):
continue
change = (price / pre_close - 1) * 100
amount_million = _number(row.get("amount")) / 1_000_000
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
up_limit = _number((limit_map.get(ts_code) or {}).get("up_limit"))
is_one_price = bool(
up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005)
)
normalized.append(
{
"code": str(stock.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(stock.get("name") or "--"),
"sector": str(stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"volume_ten_thousand": round(_number(row.get("vol")) / 10_000, 2),
"amount_million": round(amount_million, 2),
"turnover_rate": round(turnover_rate, 4),
"volume_ratio": round(volume_ratio, 2),
"up_limit": round(up_limit, 2) if up_limit else None,
"is_one_price": is_one_price,
"signal": (
"竞价涨停" if change >= 9.5 else
"强势高开" if change >= 3 else
"高开" if change > 0.2 else
"深度低开" if change <= -3 else
"低开" if change < -0.2 else "平开"
),
}
)
normalized.sort(key=lambda item: (item["amount_million"], item["volume_ratio"]), reverse=True)
self.database.upsert_auction_factors(rows)
changes = [item["change"] for item in normalized]
total = len(normalized)
_, baseline_date = self._trade_context(data_date)
candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date)
candidate_map = {str(item.get("code") or ""): item for item in candidates}
one_price_rows = []
for row in normalized:
if not row.get("is_one_price"):
continue
enriched = candidate_map.get(str(row.get("code") or ""), {})
one_price_rows.append(
{
**row,
**enriched,
"attention_score": None,
"expectation": "",
"expected_change": None,
"expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离",
}
)
one_price_codes = {str(item.get("code") or "") for item in one_price_rows}
candidates = [item for item in candidates if str(item.get("code") or "") not in one_price_codes]
focus_rows = [item for item in focus_rows if str(item.get("code") or "") not in one_price_codes]
one_price_rows.sort(
key=lambda item: (
bool(item.get("is_market_core")),
_number(item.get("prior_streak")),
_number(item.get("amount_million")),
),
reverse=True,
)
expectations = {
label: sum(item.get("expectation") == label for item in candidates)
for label in ("超预期", "符合预期", "低于预期")
}
prior_snapshot = self.database.get_snapshot(baseline_date) or {}
themes = self._auction_theme_evidence(prior_snapshot, candidates + one_price_rows)
self._ensure_auction_amount_history(data_date)
amount_history = self._auction_amount_history(data_date)
prior_amounts = [item["amount_billion"] for item in amount_history[:-1]]
current_amount = round(sum(item["amount_million"] for item in normalized) / 100, 2)
previous_amount = prior_amounts[-1] if prior_amounts else 0
five_day_amounts = prior_amounts[-5:]
five_day_average = sum(five_day_amounts) / len(five_day_amounts) if five_day_amounts else 0
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool(normalized),
**session,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": total,
"up_count": sum(value > 0.2 for value in changes),
"down_count": sum(value < -0.2 for value in changes),
"limit_open_count": len(one_price_rows),
"strong_open_count": sum(value >= 3 for value in changes),
"median_change": round(median(changes), 2) if changes else 0,
"amount_billion": current_amount,
"amount_change_previous": round((current_amount / previous_amount - 1) * 100, 1) if previous_amount else None,
"amount_change_5d": round((current_amount / five_day_average - 1) * 100, 1) if five_day_average else None,
"candidate_count": len(candidates),
"focus_count": len(focus_rows),
"one_price_count": len(one_price_rows),
},
"expectations": expectations,
"candidate_meta": candidate_meta,
"themes": themes,
"amount_history": amount_history,
"news_feedback": {
"available": False,
"message": "隔夜消息反馈暂不可用",
"detail": "待稳定的新闻与公告数据接入后开放",
},
"focus_rows": focus_rows,
"one_price_rows": one_price_rows,
"rows": candidates,
}
if not live_dynamic:
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id)
@@ -0,0 +1,318 @@
from __future__ import annotations
import copy
from datetime import datetime, time as dt_time, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import CHINA_TIMEZONE, _display_date
class MarketAuctionDataMixin:
def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]:
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
requested = str(requested_date or "").replace("-", "")
today = now.strftime("%Y%m%d")
if requested != today or trade_date != today:
return {
"phase": "archive",
"actionable": False,
"next_transition_at": "",
}
local_time = now.time().replace(tzinfo=None)
transitions = (
(dt_time(9, 15), "pending", dt_time(9, 15)),
(dt_time(9, 25), "observing", dt_time(9, 25)),
(dt_time(9, 30), "selection", dt_time(9, 30)),
)
for boundary, phase, next_boundary in transitions:
if local_time < boundary:
transition = now.replace(
hour=next_boundary.hour,
minute=next_boundary.minute,
second=0,
microsecond=0,
)
return {
"phase": phase,
"actionable": phase == "selection",
"next_transition_at": transition.isoformat(timespec="seconds"),
}
return {
"phase": "finalized",
"actionable": False,
"next_transition_at": "",
}
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
dates = self.database.auction_factor_dates(trade_date, 10)
stock_list_dates = {
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
for item in self.database.list_stock_master()
if item.get("ts_code")
}
history = []
for current_date in dates:
rows = [
row for row in self.database.auction_factors_for_date(current_date)
if (
str(row.get("ts_code") or "") in stock_list_dates
and (
not stock_list_dates[str(row.get("ts_code") or "")]
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
)
)
]
history.append(
{
"trade_date": _display_date(current_date),
"amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2),
"stock_count": len(rows),
}
)
return history
def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None:
existing = set(self.database.auction_factor_dates(trade_date, target_days + 5))
if len(existing) >= target_days:
return
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=35)).strftime("%Y%m%d")
try:
calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": start,
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
except TushareError:
return
dates = sorted(
str(item.get("cal_date") or "")
for item in calendar
if int(_number(item.get("is_open"))) == 1 and item.get("cal_date")
)[-target_days:]
for current_date in dates:
if current_date in existing:
continue
try:
rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
except TushareError:
break
if rows:
self.database.upsert_auction_factors(rows)
existing.add(current_date)
def _with_auction_watchlist(
self,
result: dict[str, Any],
trade_date: str,
user_id: int,
) -> dict[str, Any]:
personalized = copy.deepcopy(result)
if not user_id:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
watched = self.database.list_watchlist(user_id)
if not watched:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
public_rows = {
str(item.get("code") or ""): item
for item in (
list(personalized.get("rows") or [])
+ list(personalized.get("one_price_rows") or [])
)
}
factors = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.auction_factors_for_date(trade_date)
}
master = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.list_stock_master()
}
rows = []
missing = 0
for item in watched:
code = str(item.get("code") or "")
if code in public_rows:
rows.append({**public_rows[code], "is_watchlist": True})
continue
factor = factors.get(code)
if not factor:
missing += 1
rows.append(
{
"code": code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("sector") or "其他"),
"available": False,
"is_watchlist": True,
}
)
continue
stock = master.get(code, {})
price = _number(factor.get("price"))
pre_close = _number(factor.get("pre_close"))
change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0
row = {
"code": code,
"ts_code": str(factor.get("ts_code") or ""),
"name": str(item.get("name") or stock.get("name") or "--"),
"sector": str(item.get("sector") or stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"amount_million": round(_number(factor.get("amount")) / 1_000_000, 2),
"turnover_rate": round(_number(factor.get("turnover_rate")), 4),
"volume_ratio": round(_number(factor.get("volume_ratio")), 2),
"candidate_sources": ["我的自选"],
"source_label": "我的自选",
"prior_streak": 0,
"concepts": [],
"expected_change": 0.0,
"core_tags": [],
"is_market_core": False,
"is_watchlist": True,
"available": True,
}
actual_strength = change + self._auction_confirmation(row)
row["actual_strength"] = round(actual_strength, 2)
row["expectation"] = self._expectation_label(actual_strength, 0.0)
row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False)
direction = "高于" if change > 0 else "低于" if change < 0 else "贴合"
row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}"
rows.append(row)
rows.sort(
key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))),
reverse=True,
)
personalized["watchlist_rows"] = rows
personalized["watchlist_missing_count"] = missing
return personalized
def _dynamic_auction_rows(
self,
trade_date: str,
baseline_date: str,
user_id: int,
) -> list[dict[str, Any]]:
if not self.ifind or not self.ifind.configured:
return []
master = self._stock_master()
placeholders = [
{
"code": str(item.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("industry") or "其他"),
}
for ts_code, item in master.items()
]
candidates, _, _ = self._auction_candidates(placeholders, baseline_date)
selected_codes = {
str(item.get("ts_code") or "")
for item in candidates
if item.get("ts_code")
}
if user_id:
watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)}
selected_codes.update(
ts_code for ts_code in master if ts_code.split(".")[0] in watched
)
selected_codes.discard("")
if not selected_codes:
return []
display_date = _display_date(trade_date)
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25))
end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}"
start_stamp = f"{display_date} 09:15:00"
snapshot_rows: list[dict[str, Any]] = []
ordered_codes = sorted(selected_codes)
for index in range(0, len(ordered_codes), 80):
try:
snapshot_rows.extend(
self.ifind.snapshots(
ordered_codes[index:index + 80],
[
"latest", "volume", "amount", "preClose",
"bid1", "bidSize1", "ask1", "askSize1",
],
start_stamp,
end_stamp,
cache_ttl=8,
)
)
except IfindError:
continue
latest: dict[str, dict[str, Any]] = {}
for row in snapshot_rows:
ts_code = str(row.get("thscode") or "")
previous = latest.get(ts_code) or {}
if (
ts_code
and _number(row.get("latest")) > 0
and str(row.get("time") or "") >= str(previous.get("time") or "")
):
latest[ts_code] = row
prior_factors = {
str(item.get("ts_code") or ""): item
for item in self.database.auction_factors_for_date(baseline_date)
}
normalized = []
for ts_code, row in latest.items():
price = _number(row.get("latest"))
pre_close = _number(row.get("preClose"))
volume = _number(row.get("volume"))
bid_size = _number(row.get("bidSize1"))
ask_size = _number(row.get("askSize1"))
if volume <= 0 and bid_size > 0 and ask_size > 0:
volume = min(bid_size, ask_size)
amount = _number(row.get("amount"))
if amount <= 0 and price > 0 and volume > 0:
amount = price * volume
prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol"))
normalized.append(
{
"ts_code": ts_code,
"trade_date": trade_date,
"vol": volume,
"price": price,
"amount": amount,
"pre_close": pre_close,
"turnover_rate": 0,
"volume_ratio": volume / prior_volume if prior_volume > 0 else 0,
"float_share": 0,
"bid_size1": bid_size,
"ask_size1": ask_size,
"snapshot_time": str(row.get("time") or ""),
"dynamic": True,
}
)
return normalized
@@ -0,0 +1,355 @@
from __future__ import annotations
from statistics import median
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.features.market.insights_context import _display_date
class MarketAuctionScoringMixin:
@staticmethod
def _expectation_label(actual_strength: float, expected_change: float) -> str:
difference = actual_strength - expected_change
if difference >= 1.5:
return "超预期"
if difference <= -1.5:
return "低于预期"
return "符合预期"
@staticmethod
def _auction_confirmation(row: dict[str, Any]) -> float:
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
return (
(0.6 if volume_ratio >= 2 else 0.3 if volume_ratio >= 1.2 else -0.5 if volume_ratio < 0.6 else 0)
+ (0.25 if turnover_rate >= 0.15 else -0.25 if turnover_rate < 0.03 else 0)
+ (0.3 if amount_million >= 20 else 0.15 if amount_million >= 5 else -0.3 if amount_million < 1 else 0)
)
@staticmethod
def _attention_score(
row: dict[str, Any],
expected_change: float,
core_tags: list[str],
sources: list[str],
prior_streak: int,
strong_sector: bool,
) -> float:
if core_tags:
identity_score = 35.0
elif prior_streak >= 2:
identity_score = 27.0
elif any(source in {"昨日涨停", "昨日炸板"} for source in sources):
identity_score = 21.0
else:
identity_score = 14.0
deviation_score = min(30.0, abs(_number(row.get("change")) - expected_change) * 5)
volume_score = min(10.0, max(0.0, _number(row.get("volume_ratio"))) / 2 * 10)
amount_score = min(6.0, max(0.0, _number(row.get("amount_million"))) / 10 * 6)
turnover_score = min(4.0, max(0.0, _number(row.get("turnover_rate"))) / 0.2 * 4)
theme_score = 15.0 if strong_sector else 7.0 if row.get("concepts") else 0.0
return round(min(100.0, identity_score + deviation_score + volume_score + amount_score + turnover_score + theme_score), 1)
def _auction_candidates(
self,
rows: list[dict[str, Any]],
baseline_date: str,
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
"""Build a narrow, explainable universe from prior limits, breaks and top-20 hot lists."""
snapshot = self.database.get_snapshot(baseline_date) or {}
prior_limits = list(snapshot.get("limits") or [])
prior_broken = list(snapshot.get("broken") or [])
prior_sectors = list(snapshot.get("sectors") or [])
strong_sector_names = {
str(item.get("name") or "") for item in prior_sectors[:5] if item.get("name")
}
ths_rows, dc_rows, errors = self._hot_rows(baseline_date)
candidates: dict[str, dict[str, Any]] = {}
core_tags: dict[str, set[str]] = {}
def ensure_candidate(item: dict[str, Any]) -> dict[str, Any] | None:
code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0])
if not code:
return None
return candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": str(item.get("sector") or "其他"),
"name": str(item.get("name") or item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
for item in prior_limits:
candidate = ensure_candidate(item)
if candidate is None:
continue
candidate["sources"].append("昨日涨停")
candidate["streak"] = max(1, int(_number(item.get("streak"), 1)))
for item in prior_broken:
candidate = ensure_candidate(item)
if candidate is not None and "昨日炸板" not in candidate["sources"]:
candidate["sources"].append("昨日炸板")
limit_streaks = [max(1, int(_number(item.get("streak"), 1))) for item in prior_limits]
highest_streak = max(limit_streaks, default=0)
for item in prior_limits:
code = str(item.get("code") or "")
streak = max(1, int(_number(item.get("streak"), 1)))
if streak >= 3:
core_tags.setdefault(code, set()).add("三板以上")
if highest_streak and streak == highest_streak:
core_tags.setdefault(code, set()).add("市场最高板")
for sector in prior_sectors[:5]:
name = str(sector.get("name") or "")
members = [item for item in prior_limits if str(item.get("sector") or "其他") == name]
if not members:
continue
leader = max(
members,
key=lambda item: (
int(_number(item.get("streak"), 1)),
_number(item.get("amount_billion")),
-_number(item.get("open_times")),
),
)
core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心")
leadership = sorted(
prior_limits,
key=lambda item: (
int(_number(item.get("streak"), 1)),
str(item.get("sector") or "") in strong_sector_names,
_number(item.get("amount_billion")),
),
reverse=True,
)
if leadership:
core_tags.setdefault(str(leadership[0].get("code") or ""), set()).add("市场领涨")
hot_records: dict[str, dict[str, Any]] = {}
for source, hot_rows, data_type in (
("同花顺热榜", ths_rows, "热股"),
("东方财富热榜", dc_rows, "A股市场"),
):
for item in hot_rows:
if str(item.get("data_type") or "") != data_type:
continue
ts_code = str(item.get("ts_code") or "")
code = ts_code.split(".")[0]
rank = max(1, int(_number(item.get("rank"), 9999)))
if not code or rank > 20:
continue
hot = hot_records.setdefault(
code,
{
"name": str(item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
hot["ths_rank" if source == "同花顺热榜" else "dc_rank"] = rank
if source == "同花顺热榜":
hot["concepts"] = self._parse_concepts(item.get("concept"))
ranked_hot = sorted(
hot_records.items(),
key=lambda pair: (
((21 - (pair[1].get("ths_rank") or 21)) / 20)
+ ((21 - (pair[1].get("dc_rank") or 21)) / 20)
+ (0.35 if pair[1].get("ths_rank") and pair[1].get("dc_rank") else 0)
),
reverse=True,
)
for code, _ in ranked_hot[:5]:
core_tags.setdefault(code, set()).add("人气前5")
for code, hot in hot_records.items():
ranks = [rank for rank in (hot.get("ths_rank"), hot.get("dc_rank")) if isinstance(rank, int)]
dual = len(ranks) == 2
if not ranks or (min(ranks) > 10 and not dual and code not in candidates and code not in core_tags):
continue
candidate = candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": "其他",
"name": hot["name"],
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
candidate["ths_rank"] = hot.get("ths_rank")
candidate["dc_rank"] = hot.get("dc_rank")
candidate["concepts"] = hot.get("concepts") or []
if hot.get("ths_rank") and "同花顺热榜" not in candidate["sources"]:
candidate["sources"].append("同花顺热榜")
if hot.get("dc_rank") and "东方财富热榜" not in candidate["sources"]:
candidate["sources"].append("东方财富热榜")
normalized = []
for row in rows:
candidate = candidates.get(str(row.get("code") or ""))
if not candidate:
continue
streak = int(candidate["streak"])
expected_change = {1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0 if streak else 0.5)
ranks = [
rank for rank in (candidate.get("ths_rank"), candidate.get("dc_rank"))
if isinstance(rank, int)
]
if len(ranks) == 2:
expected_change += 0.8
elif ranks:
best_rank = min(ranks)
expected_change += 0.7 if best_rank <= 10 else 0.4 if best_rank <= 30 else 0.2
expected_change = min(expected_change, 6.5)
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
confirmation = self._auction_confirmation(row)
actual_strength = _number(row.get("change")) + confirmation
label = self._expectation_label(actual_strength, expected_change)
is_broken = "昨日炸板" in candidate["sources"] and "昨日涨停" not in candidate["sources"]
identity = f"昨日{streak}" if streak > 1 else "昨日首板" if streak == 1 else "昨日炸板" if is_broken else "人气榜标的"
popularity = ",双榜共识" if len(ranks) == 2 else ",热榜靠前" if ranks and min(ranks) <= 10 else ""
difference = _number(row.get("change")) - expected_change
direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合"
reason = (
f"{identity}{popularity};竞价涨幅{direction}预期中枢"
f"{abs(difference):.1f}个百分点,量比{volume_ratio:.2f}"
)
tags = sorted(core_tags.get(str(row.get("code") or ""), set()))
scored_row = {
**row,
"concepts": candidate["concepts"],
}
attention_score = self._attention_score(
scored_row,
expected_change,
tags,
candidate["sources"],
streak,
str(candidate.get("sector") or row.get("sector") or "") in strong_sector_names,
)
normalized.append(
{
**scored_row,
"sector": candidate["sector"] if candidate["sector"] != "其他" else row.get("sector", "其他"),
"candidate_sources": candidate["sources"],
"source_label": " · ".join(candidate["sources"]),
"prior_streak": streak,
"concepts": candidate["concepts"],
"expected_change": round(expected_change, 2),
"actual_strength": round(actual_strength, 2),
"expectation": label,
"attention_score": attention_score,
"core_tags": tags,
"is_market_core": bool(tags),
"expectation_reason": reason,
}
)
normalized.sort(key=lambda item: (_number(item.get("attention_score")), _number(item.get("amount_million"))), reverse=True)
matched_top = {
str(item.get("code") or "")
for item in sorted(
(item for item in normalized if item.get("expectation") == "符合预期"),
key=lambda item: _number(item.get("attention_score")),
reverse=True,
)[:20]
}
focus_candidates = [
item for item in normalized
if item.get("is_market_core")
or (_number(item.get("attention_score")) >= 55 and item.get("expectation") != "符合预期")
or str(item.get("code") or "") in matched_top
]
mandatory = [item for item in focus_candidates if item.get("is_market_core")]
mandatory_codes = {str(item.get("code") or "") for item in mandatory}
optional = [item for item in focus_candidates if str(item.get("code") or "") not in mandatory_codes]
focus_rows = sorted(mandatory, key=lambda item: _number(item.get("attention_score")), reverse=True)
focus_rows.extend(optional[:max(0, 30 - len(focus_rows))])
focus_rows.sort(key=lambda item: _number(item.get("attention_score")), reverse=True)
return normalized, {
"baseline_date": _display_date(baseline_date),
"prior_limit_count": len(prior_limits),
"prior_broken_count": len(prior_broken),
"hot_candidate_count": sum(
any(source in {"同花顺热榜", "东方财富热榜"} for source in item["sources"])
for item in candidates.values()
),
"core_count": sum(bool(item.get("is_market_core")) for item in normalized),
"notice": "".join(errors),
}, focus_rows
@staticmethod
def _auction_theme_evidence(
prior_snapshot: dict[str, Any],
candidate_rows: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
prior_sectors = list(prior_snapshot.get("sectors") or [])
carry = []
for sector in prior_sectors[:10]:
name = str(sector.get("name") or "其他")
matched = [row for row in candidate_rows if str(row.get("sector") or "其他") == name]
changes = [_number(row.get("change")) for row in matched]
middle = median(changes) if changes else -10.0
positive_rate = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0.0
if middle >= 2 and positive_rate >= 60:
status = "强承接"
elif middle >= 0 and positive_rate >= 50:
status = "有承接"
elif middle > -2:
status = "分歧"
else:
status = "承接弱"
carry.append(
{
"name": name,
"status": status,
"prior_limit_count": int(_number(sector.get("count"))),
"leader": str(sector.get("leader") or "--"),
"matched_count": len(matched),
"median_change": round(middle, 2) if matched else None,
"positive_rate": round(positive_rate, 1),
"amount_million": round(sum(_number(row.get("amount_million")) for row in matched), 2),
}
)
concept_groups: dict[str, list[dict[str, Any]]] = {}
prior_names = {str(item.get("name") or "") for item in prior_sectors}
for row in candidate_rows:
for concept in row.get("concepts") or []:
if concept and concept not in prior_names:
concept_groups.setdefault(str(concept), []).append(row)
new_themes = []
for name, members in concept_groups.items():
unique = {str(item.get("code") or ""): item for item in members}
values = list(unique.values())
changes = [_number(item.get("change")) for item in values]
if len(values) < 2 or median(changes) < 2 or sum(value > 0.2 for value in changes) / len(values) < 0.67:
continue
new_themes.append(
{
"name": name,
"stock_count": len(values),
"median_change": round(median(changes), 2),
"amount_million": round(sum(_number(item.get("amount_million")) for item in values), 2),
"leaders": [str(item.get("name") or "--") for item in sorted(values, key=lambda value: _number(value.get("change")), reverse=True)[:3]],
}
)
new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"], item["amount_million"]), reverse=True)
return {"carry": carry, "new_themes": new_themes[:8]}
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Callable
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.providers.tushare_client import TushareClient, TushareError
if TYPE_CHECKING:
from database import ReviewDatabase
CHINA_TIMEZONE = timezone(timedelta(hours=8))
def _display_date(value: str) -> str:
text = str(value or "").replace("-", "")
if len(text) != 8:
return str(value or "")
return f"{text[:4]}-{text[4:6]}-{text[6:]}"
class MarketInsightsContextMixin:
def __init__(
self,
database: ReviewDatabase,
client: TushareClient,
now_provider: Callable[[], datetime] | None = None,
ifind: IfindHttpClient | None = None,
) -> None:
self.database = database
self.client = client
self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE))
self.ifind = ifind
def _trade_context(self, requested_date: str) -> tuple[str, str]:
"""Resolve trading dates without making cached feature pages depend on Tushare uptime."""
requested = str(requested_date or "").replace("-", "")
try:
return self.client.resolve_trade_context(requested)
except TushareError:
latest = self.database.get_latest_real_snapshot(requested) or {}
trade_date = str(
(latest.get("meta") or {}).get("trade_date")
or latest.get("_snapshot_date")
or requested
).replace("-", "")
previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {}
previous_date = str(
(previous.get("meta") or {}).get("trade_date")
or previous.get("_snapshot_date")
or ""
).replace("-", "")
return trade_date, previous_date
def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None:
return self.database.get_latest_data_snapshot(kind, "", trade_date)
def _stock_master(self) -> dict[str, dict[str, Any]]:
rows = self.database.list_stock_master()
if not rows:
rows = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
self.database.upsert_stock_master(rows)
rows = self.database.list_stock_master()
return {str(row.get("ts_code") or ""): row for row in rows}
@staticmethod
def _parse_concepts(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
text = str(value or "").strip()
if not text:
return []
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [str(item) for item in parsed if str(item).strip()]
except json.JSONDecodeError:
pass
return [part.strip() for part in text.split(",") if part.strip()]
@@ -0,0 +1,156 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketPopularityInsightsMixin:
def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("popularity_v1", trade_date)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
ths_rows, dc_rows, errors = self._hot_rows(trade_date)
actual_date = trade_date
carried_forward = False
if not ths_rows and not dc_rows and previous_date:
ths_rows, dc_rows, errors = self._hot_rows(previous_date)
actual_date = previous_date
carried_forward = bool(ths_rows or dc_rows)
if not ths_rows and not dc_rows:
fallback = self._latest_feature_snapshot("popularity_v1", trade_date)
if fallback:
result = copy.deepcopy(fallback)
result["meta"] = {
**result.get("meta", {}),
"requested_date": _display_date(requested_date),
"carried_forward": True,
"cached": True,
"notice": "当前榜单暂不可用,展示最近有效快照",
}
return result
return {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_date),
"carried_forward": False,
"cached": False,
"notice": "该交易日暂无可用人气榜",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0},
"combined": [], "ths": [], "dc": [],
}
prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
prior_date, _ = self._trade_context(prior_request)
previous_ths, previous_dc, _ = self._hot_rows(prior_date)
ths = self._normalize_hot(ths_rows, "热股", previous_ths)
dc = self._normalize_hot(dc_rows, "A股市场", previous_dc)
ths_map = {item["ts_code"]: item for item in ths}
dc_map = {item["ts_code"]: item for item in dc}
combined = []
for ts_code in set(ths_map) | set(dc_map):
ths_item = ths_map.get(ts_code)
dc_item = dc_map.get(ts_code)
base = ths_item or dc_item or {}
ths_rank = int(ths_item["rank"]) if ths_item else None
dc_rank = int(dc_item["rank"]) if dc_item else None
score = (
(101 - (ths_rank or 101)) * 0.5
+ (201 - (dc_rank or 201)) * 0.25
)
combined.append(
{
**base,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"score": round(score, 2),
"dual_source": bool(ths_item and dc_item),
"concepts": (ths_item or {}).get("concepts") or [],
}
)
combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True)
for index, item in enumerate(combined, 1):
item["rank"] = index
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_date),
"previous_trade_date": _display_date(prior_date),
"carried_forward": carried_forward,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(errors),
},
"summary": {
"ths_count": len(ths),
"dc_count": len(dc),
"dual_count": sum(item["dual_source"] for item in combined),
},
"combined": combined[:200],
"ths": ths,
"dc": dc,
}
self.database.save_data_snapshot("popularity_v1", trade_date, "market", result)
return result
def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
errors = []
try:
ths = self.client.query("ths_hot", {"trade_date": trade_date})
except TushareError:
ths = []
errors.append("同花顺榜单暂不可用")
try:
dc = self.client.query("dc_hot", {"trade_date": trade_date})
except TushareError:
dc = []
errors.append("东方财富榜单暂不可用")
return ths, dc, errors
def _normalize_hot(
self,
rows: list[dict[str, Any]],
data_type: str,
previous_rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
previous = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in previous_rows
if str(row.get("data_type") or "") == data_type
}
items = []
for row in rows:
if str(row.get("data_type") or "") != data_type:
continue
rank = int(_number(row.get("rank")))
ts_code = str(row.get("ts_code") or "")
prior_rank = previous.get(ts_code)
items.append(
{
"rank": rank,
"ts_code": ts_code,
"code": ts_code.split(".")[0],
"name": str(row.get("ts_name") or "--"),
"change": round(_number(row.get("pct_change")), 2),
"price": round(_number(row.get("current_price")), 2),
"hot": round(_number(row.get("hot")), 1),
"rank_change": (prior_rank - rank) if prior_rank else None,
"concepts": self._parse_concepts(row.get("concept")),
"reason": str(row.get("rank_reason") or ""),
"rank_time": str(row.get("rank_time") or ""),
}
)
items.sort(key=lambda item: item["rank"])
return items
@@ -0,0 +1,222 @@
from __future__ import annotations
import copy
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import non_nan_number as _number
from backend.data.providers.tushare_client import TushareError
from backend.features.market.insights_context import _display_date
class MarketThemeInsightsMixin:
def _theme_directory(self) -> list[dict[str, Any]]:
cached = self.database.get_data_snapshot("theme_directory_v1", "ths") or {}
if cached.get("items"):
return list(cached["items"])
rows = self.client.query(
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
)
items = [
{
"code": str(row.get("ts_code") or ""),
"name": str(row.get("name") or ""),
"member_count": int(_number(row.get("count"))),
"list_date": str(row.get("list_date") or ""),
}
for row in rows
if str(row.get("type") or "").upper() == "N"
and str(row.get("exchange") or "").upper() == "A"
and row.get("ts_code")
and row.get("name")
]
self.database.save_data_snapshot(
"theme_directory_v1", "ths", "market", {"items": items}
)
return items
def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("theme_library_v1", trade_date)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
try:
daily = self.client.query(
"ths_daily",
{"trade_date": trade_date},
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
)
except TushareError:
fallback = self._latest_feature_snapshot("theme_library_v1", trade_date)
if fallback:
result = copy.deepcopy(fallback)
result["meta"] = {
**result.get("meta", {}),
"requested_date": _display_date(requested_date),
"carried_forward": True,
"cached": True,
"notice": "当前题材行情暂不可用,展示最近有效快照",
}
return result
daily = []
actual_date = trade_date
carried_forward = False
if not daily and previous_date:
try:
daily = self.client.query(
"ths_daily",
{"trade_date": previous_date},
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
)
except TushareError:
daily = []
actual_date = previous_date
carried_forward = bool(daily)
daily_map = {str(row.get("ts_code") or ""): row for row in daily}
try:
hot_rows = self.client.query("ths_hot", {"trade_date": actual_date})
except TushareError:
hot_rows = []
hot_map = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in hot_rows
if str(row.get("data_type") or "") == "概念板块"
}
items = []
for item in self._theme_directory():
quote = daily_map.get(item["code"], {})
items.append(
{
**item,
"change": round(_number(quote.get("pct_change")), 2),
"close": round(_number(quote.get("close")), 3),
"turnover_rate": round(_number(quote.get("turnover_rate")), 2),
"volume": round(_number(quote.get("vol")), 2),
"hot_rank": hot_map.get(item["code"]),
"has_quote": bool(quote),
}
)
items.sort(
key=lambda item: (
item["has_quote"],
item["hot_rank"] is not None,
-(item["hot_rank"] or 9999),
item["change"],
),
reverse=True,
)
quoted = [item for item in items if item["has_quote"]]
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_date),
"carried_forward": carried_forward,
"cached": False,
"notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"theme_count": len(items),
"quoted_count": len(quoted),
"up_count": sum(item["change"] > 0 for item in quoted),
"down_count": sum(item["change"] < 0 for item in quoted),
"hot_count": len(hot_map),
},
"items": items,
}
self.database.save_data_snapshot("theme_library_v1", trade_date, "market", result)
return result
def theme_detail(self, code: str, requested_date: str) -> dict[str, Any]:
code = str(code or "").strip().upper()
library = self.theme_library(requested_date)
theme = next((item for item in library["items"] if item["code"] == code), None)
if not theme:
raise ValueError("未找到对应题材。")
actual_date = str(library["meta"]["trade_date"]).replace("-", "")
detail_key = f"{actual_date}:{code}"
cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key)
if cached_detail:
return cached_detail
try:
members = self.client.query(
"ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name"
)
except TushareError:
members = []
bars = self.database.daily_bars_for_date(actual_date)
if not bars:
bars = self.client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
self.database.upsert_daily_bars(bars)
bar_map = {str(row.get("ts_code") or ""): row for row in bars}
normalized_members = []
for member in members:
ts_code = str(member.get("con_code") or "")
quote = bar_map.get(ts_code, {})
normalized_members.append(
{
"code": ts_code.split(".")[0],
"ts_code": ts_code,
"name": str(member.get("con_name") or "--"),
"price": round(_number(quote.get("close")), 2),
"change": round(_number(quote.get("pct_chg")), 2),
"amount_billion": round(_number(quote.get("amount")) / 100_000, 2),
"has_quote": bool(quote),
}
)
normalized_members.sort(
key=lambda item: (item["has_quote"], item["change"], item["amount_billion"]),
reverse=True,
)
end = datetime.strptime(actual_date, "%Y%m%d")
try:
history = self.client.query(
"ths_daily",
{
"ts_code": code,
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
"end_date": actual_date,
},
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate",
)
except TushareError:
history = []
history.sort(key=lambda row: str(row.get("trade_date") or ""))
series = [
{
"trade_date": _display_date(str(row.get("trade_date") or "")),
"open": _number(row.get("open")),
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"close": _number(row.get("close")),
"change": _number(row.get("pct_change")),
"volume": _number(row.get("vol")),
}
for row in history[-90:]
]
result = {
"meta": {
"trade_date": _display_date(actual_date),
"notice": "" if members or history else "题材成分与走势暂不可用",
},
"theme": theme,
"series": series,
"members": normalized_members,
"summary": {
"member_count": len(normalized_members),
"up_count": sum(item["change"] > 0 for item in normalized_members if item["has_quote"]),
"down_count": sum(item["change"] < 0 for item in normalized_members if item["has_quote"]),
"quoted_count": sum(item["has_quote"] for item in normalized_members),
},
}
if members or history:
self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result)
return result
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
from backend.features.market import ChartDataError
class MarketRoutesMixin:
def _handle_market_get(self, parsed) -> bool:
if parsed.path == "/api/dashboard":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.get_dashboard(trade_date, False))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"数据加载失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
return True
if parsed.path == "/api/realtime-aggregate/health":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"ok": True,
"aggregate": self.application_service.realtime_aggregate_health(
query.get("sector", [""])[0]
),
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/search":
query = parse_qs(parsed.query)
search_query = query.get("q", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.search_entities(search_query, trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/search/detail":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(
self.application_service.get_search_detail(entity_type, identifier, trade_date)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except TushareError as exc:
self.send_json({"error": f"行情加载失败:{exc}"}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/chart/intraday":
query = parse_qs(parsed.query)
entity_type = query.get("type", [""])[0]
identifier = query.get("id", [""])[0]
try:
self.send_json(self.application_service.get_intraday_chart(entity_type, identifier))
except (ValueError, ChartDataError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
stock_preview_match = re.fullmatch(r"/api/stock/(\d{6})/preview", parsed.path)
if stock_preview_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(
self.application_service.get_stock_preview(stock_preview_match.group(1), trade_date, force)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
stock_match = re.fullmatch(r"/api/stock/(\d{6})", parsed.path)
if stock_match:
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
force = query.get("force", ["0"])[0] == "1"
try:
self.send_json(self.application_service.get_stock_detail(stock_match.group(1), trade_date, force))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+76 -1
View File
@@ -15,6 +15,11 @@ class MentorAgentError(RuntimeError):
pass
FOLLOW_UP_START = "<XIAOBAI_FOLLOW_UPS>"
FOLLOW_UP_END = "</XIAOBAI_FOLLOW_UPS>"
MAX_FOLLOW_UP_LENGTH = 80
@dataclass(frozen=True)
class MentorSkill:
skill_id: str
@@ -185,6 +190,8 @@ def stream_with_mentor(
base_url: str,
model: str,
timeout: int = 90,
*,
follow_ups: list[str] | None = None,
) -> Iterator[str]:
if not api_key or not model:
raise MentorAgentError("LLM API Key 或模型尚未配置。")
@@ -193,8 +200,10 @@ def stream_with_mentor(
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history[-10:])
messages.append({"role": "user", "content": question})
if follow_ups is not None:
follow_ups.clear()
try:
yield from llm_transport.stream_chat_completion(
upstream = llm_transport.stream_chat_completion(
api_key=api_key,
base_url=base_url,
model=model,
@@ -202,6 +211,7 @@ def stream_with_mentor(
timeout=timeout,
user_agent="XiaobaiReviewWeb/0.6",
)
yield from _stream_answer_and_collect_follow_ups(upstream, follow_ups)
except llm_transport.OpenAIEmptyResponseError as exc:
raise MentorAgentError("问师模型未返回有效内容。") from exc
except llm_transport.OpenAIHTTPError as exc:
@@ -223,6 +233,10 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
5. 优先回答用户真正的问题。市场分析通常按“判断、数据依据、思维模型下的应对、失效条件”组织;纯交易心理或方法问题可以自然回答,不强制套模板。
6. 保留该 Skill 的核心心智模型和表达节奏,但不要复述身份履历,不要宣称自己就是真人,不攻击或贬低用户。
7. 使用中文,信息密度高,避免空泛口号。引用数字时标明数据日期。
8. 正文结束后必须输出2至3条与本轮问题和正文直接相关的追问。追问用于帮助用户继续核实条件、风险或失效边界,不得引入正文没有依据的新事实,不得给出无条件买卖指令。严格使用以下机器结构,不要放进Markdown代码块,结束标签后不要再输出文字:
<XIAOBAI_FOLLOW_UPS>
["追问一?","追问二?","追问三?"]
</XIAOBAI_FOLLOW_UPS>
网页市场数据:
{context_json}
@@ -233,6 +247,67 @@ def _build_system_prompt(skill: MentorSkill, market_context: dict[str, Any]) ->
""".strip()
def _stream_answer_and_collect_follow_ups(
chunks: Iterator[str], follow_ups: list[str] | None
) -> Iterator[str]:
buffer = ""
collecting = False
for raw_chunk in chunks:
chunk = str(raw_chunk or "")
if not chunk:
continue
buffer += chunk
if collecting:
continue
marker_index = buffer.find(FOLLOW_UP_START)
if marker_index >= 0:
if marker_index:
yield buffer[:marker_index]
buffer = buffer[marker_index + len(FOLLOW_UP_START):]
collecting = True
continue
overlap = _marker_prefix_overlap(buffer, FOLLOW_UP_START)
emit_length = len(buffer) - overlap
if emit_length:
yield buffer[:emit_length]
buffer = buffer[emit_length:]
if not collecting:
if buffer:
yield buffer
return
raw_follow_ups = buffer.split(FOLLOW_UP_END, 1)[0].strip()
parsed = _parse_follow_ups(raw_follow_ups)
if follow_ups is not None and len(parsed) >= 2:
follow_ups.extend(parsed)
def _marker_prefix_overlap(value: str, marker: str) -> int:
max_length = min(len(value), len(marker) - 1)
for length in range(max_length, 0, -1):
if value.endswith(marker[:length]):
return length
return 0
def _parse_follow_ups(payload: str) -> list[str]:
try:
values = json.loads(payload)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(values, list):
return []
result: list[str] = []
for value in values:
question = re.sub(r"\s+", " ", str(value or "")).strip()
if not question or len(question) > MAX_FOLLOW_UP_LENGTH or question in result:
continue
result.append(question)
if len(result) == 3:
break
return result
def _parse_frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
+1 -16
View File
@@ -14,19 +14,4 @@ class MentorHttpMixin:
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for event in stream:
self._write_stream_event(event)
self._write_stream_event({"type": "done"})
except (ValueError, MentorAgentError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
self.send_ndjson_stream(stream, (ValueError, MentorAgentError))
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class MentorRoutesMixin:
def _handle_mentor_get(self, parsed) -> bool:
if parsed.path == "/api/mentors/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.mentor_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"items": self.application_service.mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_mentor_post(self, parsed) -> bool:
if parsed.path == "/api/mentors/preferences":
try:
result = self.application_service.save_mentor_preferences(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_mentor_delete(self, parsed) -> bool:
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
deleted = self.application_service.clear_mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
self.send_json({"ok": True, "deleted": deleted})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+25 -1
View File
@@ -129,9 +129,10 @@ class MentorServiceMixin:
def generate():
answer_parts: list[str] = []
follow_ups: list[str] = []
events = self.llm_gateway.stream(
"mentor",
f"mentor-skill-v1:{skill.skill_id}",
f"mentor-skill-v2:{skill.skill_id}",
lambda profile: stream_with_mentor(
skill,
context,
@@ -140,6 +141,7 @@ class MentorServiceMixin:
profile.api_key,
profile.base_url,
profile.model,
follow_ups=follow_ups,
),
(MentorAgentError,),
)
@@ -160,6 +162,7 @@ class MentorServiceMixin:
yield {
"type": "meta",
"data_trade_date": context["data_trade_date"],
"follow_ups": follow_ups or self._mentor_follow_up_fallback(question),
"notice": "智能解读已自动切换可用服务。"
if event.role == "fallback"
else "",
@@ -167,6 +170,27 @@ class MentorServiceMixin:
return generate()
@staticmethod
def _mentor_follow_up_fallback(question: str) -> list[str]:
normalized = question.strip()
if any(keyword in normalized for keyword in ("风险", "亏损", "回撤", "止损")):
return [
"这些风险最早会从哪些信号中暴露?",
"哪些变化会让当前风险判断失效?",
"如果风险继续扩大,仓位预案应如何调整?",
]
if any(keyword in normalized for keyword in ("股票", "个股", "代码", "怎么看")):
return [
"这个判断最关键的确认信号是什么?",
"哪些变化会让当前结论失效?",
"明日盘中应该优先观察哪些数据?",
]
return [
"这个判断最关键的确认依据是什么?",
"哪些变化会让当前结论失效?",
"下一步应该优先观察什么?",
]
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
import json
from http import HTTPStatus
class PoolRoutesMixin:
def save_reason(self) -> None:
try:
body = self.read_json_body()
self.application_service.save_reason(
str(body.get("trade_date") or ""),
str(body.get("code") or ""),
str(body.get("reason") or ""),
)
self.send_json({"ok": True})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+24
View File
@@ -117,6 +117,30 @@ class PoolServiceMixin:
finally:
self._ifind_event_lock.release()
@staticmethod
def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
for key, value in row.items():
label = str(key or "")
if any(token.casefold() == label.casefold() for token in tokens):
return value
for key, value in row.items():
label = str(key or "")
if any(token in label for token in tokens):
return value
return None
@classmethod
def _ifind_row_code(cls, row: dict[str, Any]) -> str:
value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode"))
match = re.search(r"(?<!\d)(\d{6})(?!\d)", str(value or ""))
if match:
return match.group(1)
for value in row.values():
match = re.search(r"(?<!\d)(\d{6})\.(?:SH|SZ|BJ)(?![A-Z])", str(value or ""), re.I)
if match:
return match.group(1)
return ""
@staticmethod
def _normalize_ifind_event_time(value: Any) -> str:
text = str(value or "").strip()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class PopularityRoutesMixin:
def _handle_popularity_get(self, parsed) -> bool:
if parsed.path == "/api/popularity":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.popularity(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+2 -16
View File
@@ -26,22 +26,8 @@ class ReviewHttpMixin:
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for chunk in stream:
self._write_stream_event({"type": "delta", "content": chunk})
self._write_stream_event({"type": "done"})
except (ValueError, ReviewAssistantError) as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
events = ({"type": "delta", "content": chunk} for chunk in stream)
self.send_ndjson_stream(events, (ValueError, ReviewAssistantError))
def save_watchlist(self) -> None:
try:
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class ReviewRoutesMixin:
def _handle_review_get(self, parsed) -> bool:
if parsed.path == "/api/trades":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.trade_entries(
query.get("start_date", [""])[0],
query.get("end_date", [""])[0],
query.get("code", [""])[0],
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/assistant/messages":
self.send_json({"items": self.application_service.assistant_messages()})
return True
if parsed.path == "/api/watchlist":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.review_watchlist(
query.get("trade_date", [date.today().isoformat()])[0]
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/notes":
query = parse_qs(parsed.query)
code = query.get("code", [""])[0]
trade_date = query.get("trade_date", [""])[0].replace("-", "")
scope = query.get("scope", ["all"])[0]
if scope not in {"all", "daily", "stock"}:
self.send_json({"error": "复盘记录范围不支持。"}, HTTPStatus.BAD_REQUEST)
return True
self.send_json(
{
"items": self.application_service.database.list_notes(
self.application_service.current_user_id, code, trade_date, scope
)
}
)
return True
return False
def _handle_review_delete(self, parsed) -> bool:
if parsed.path == "/api/assistant/messages":
deleted = self.application_service.clear_assistant_messages()
self.send_json({"ok": True, "deleted": deleted})
return True
watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
if watchlist_match:
deleted = self.application_service.database.delete_watchlist(
self.application_service.current_user_id, watchlist_match.group(1)
)
self.send_json({"ok": True, "deleted": deleted})
return True
note_match = re.fullmatch(r"/api/notes/(\d+)", parsed.path)
if note_match:
deleted = self.application_service.database.delete_note(
self.application_service.current_user_id, int(note_match.group(1))
)
self.send_json({"ok": True, "deleted": deleted})
return True
trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path)
if trade_match:
self.send_json(
{"ok": True, **self.application_service.delete_trade_entry(int(trade_match.group(1)))}
)
return True
return False
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class RotationRoutesMixin:
def _handle_rotation_get(self, parsed) -> bool:
if parsed.path == "/api/rotation/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.rotation_history(trade_date, 9))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/rotation/members":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.rotation_sector_members(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("sector", [""])[0],
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import statistics
from collections import defaultdict
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.factors import FactorBuilder
from backend.features.screener.formula import FormulaEvaluator
from database import ReviewDatabase
class BacktestRunner:
def __init__(
self,
database: ReviewDatabase,
factor_builder: FactorBuilder,
formula_evaluator: FormulaEvaluator,
) -> None:
self.database = database
self.factor_builder = factor_builder
self.formula_evaluator = formula_evaluator
self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {}
def build_factors(
self, trade_date: str, history_days: int
) -> tuple[list[dict[str, Any]], str]:
return self.factor_builder.build_factors(
trade_date, history_days=history_days
)
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
return self.formula_evaluator.apply_formula(rows, formula, regime)
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
meta = formula.get("meta") or {}
history_days = max(21, min(260, int(meta.get("history_days") or 80)))
holding_days = max(1, min(30, int(meta.get("backtest_days") or 3)))
take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3)))
stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3)))
dates = self.database.factor_dates(trade_date, history_days + holding_days + 20)
eligible_dates = dates[:-holding_days] if len(dates) > holding_days else []
frequency = str(meta.get("frequency") or "每日")
if "" in frequency:
grouped = {}
for value in eligible_dates:
grouped[value[:6]] = value
evaluation_dates = list(grouped.values())[-8:]
elif "双周" in frequency:
weekly_dates = []
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
weekly_dates = list(grouped.values())
evaluation_dates = weekly_dates[-16::2][-8:]
elif "" in frequency:
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
evaluation_dates = list(grouped.values())[-8:]
else:
evaluation_dates = eligible_dates[-8:]
wins = 0
losses = 0
samples = 0
returns = []
drawdowns = []
all_data = self.database.load_factor_data(
trade_date, history_days + holding_days + 20
)
bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in all_data["bars"]:
bars_by_code[row["ts_code"]].append(row)
for bars in bars_by_code.values():
bars.sort(key=lambda item: item["trade_date"])
for current_date in evaluation_dates:
try:
cache_key = (current_date, history_days)
factors = self._backtest_factor_cache.get(cache_key)
if factors is None:
factors, _ = self.build_factors(
current_date, history_days=history_days
)
if len(self._backtest_factor_cache) >= 64:
self._backtest_factor_cache.pop(
next(iter(self._backtest_factor_cache))
)
self._backtest_factor_cache[cache_key] = factors
except ValueError:
continue
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
for candidate in selected:
bars = bars_by_code.get(candidate["ts_code"], [])
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1)
future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
if len(future) < holding_days:
continue
entry = candidate["price"]
won = False
lost = False
for day in future:
low_return = (_number(day["low"]) / entry - 1) * 100
high_return = (_number(day["high"]) / entry - 1) * 100
if low_return <= stop_loss:
lost = True
break
if high_return >= take_profit:
won = True
break
if won:
wins += 1
elif lost:
losses += 1
samples += 1
returns.append((_number(future[-1]["close"]) / entry - 1) * 100)
drawdowns.append(min((_number(day["low"]) / entry - 1) * 100 for day in future))
return {
"samples": samples,
"wins": wins,
"losses": losses,
"win_rate": round(wins / samples * 100, 1) if samples else 0,
"average_3d_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_holding_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
"evaluation_days": len(evaluation_dates),
"frequency": frequency,
"holding_days": holding_days,
"take_profit": take_profit,
"stop_loss": stop_loss,
"definition": (
f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及"
f"{stop_loss:g}%计为成功;同日双触发按失败处理。"
),
"approximate": True,
}
+711
View File
@@ -0,0 +1,711 @@
from __future__ import annotations
from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES
from backend.features.screener.signals import attach_strategy_validity
REGIMES = {
"ice": "冰点",
"repair": "修复",
"fermentation": "发酵",
"climax": "高潮",
"divergence": "分化",
"retreat": "退潮",
}
FACTOR_FIELDS = {
"close": "收盘价",
"pct_chg": "当日涨幅",
"return_5d": "5日涨幅",
"return_10d": "10日涨幅",
"return_20d": "20日涨幅",
"return_60d": "60日涨幅",
"return_5d_rank": "5日涨幅排名",
"momentum_60_5": "中期动量",
"momentum_60_5_rank": "中期动量排名",
"above_ma20": "站上20日线",
"rsi_6": "RSI(6)",
"ma60_slope": "60日线斜率",
"ma20_slope_5d": "20日线5日斜率",
"ma_bull_alignment": "均线多头排列",
"drawdown_from_high_250": "距250日高点回撤",
"donchian_breakout_pct": "唐奇安突破幅度",
"range_20d": "20日振幅",
"rs_high_120": "RS线120日新高",
"excess_return_60d": "60日超额收益",
"weekly_trend_signal": "周线趋势信号",
"daily_buy_trigger": "日线买点",
"weekly_amount_trend": "周成交趋势",
"volume_ratio_5d": "5日量比",
"turnover_5d": "5日累计换手",
"volatility_10d": "10日波动率",
"amount_billion": "成交额",
"turnover_rate": "换手率",
"circ_mv_billion": "流通市值",
"net_flow_million": "主力净流入",
"large_flow_million": "大单净流入",
"net_flow_5d_million": "5日主力净流入",
"flow_to_circ_mv_5d": "5日净流入占流通市值",
"sector_strength": "板块强度",
"sector_return_5d": "行业5日涨幅",
"sector_return_20d": "行业20日涨幅",
"sector_momentum_rank": "行业20日动量排名",
"sector_stock_momentum_rank": "行业内个股动量排名",
"sector_net_flow_5d_million": "行业5日主力净流入",
"sector_flow_rank": "行业资金流排名",
"sector_prosperity_rank": "行业景气度排名",
"sector_trend_rank": "行业趋势排名",
"sector_crowding_rank": "行业拥挤度排名",
"sector_composite_score": "行业三维综合分",
"sector_limit_count": "板块涨停数",
"sector_up_count": "板块强势股数",
"relative_strength": "相对强度",
"limit_streak": "连板高度",
"auction_change": "竞价涨幅",
"auction_amount_million": "竞价成交额",
"auction_turnover_rate": "竞价换手率",
"auction_volume_ratio": "竞价量比",
"total_mv_billion": "总市值",
"pe_ttm": "市盈率TTM",
"pb": "市净率",
"ps_ttm": "市销率TTM",
"dividend_yield_ttm": "股息率TTM",
"dividend_years": "近年持续分红",
"roe": "净资产收益率",
"roa": "总资产收益率",
"roic": "投入资本回报率",
"gross_margin": "销售毛利率",
"netprofit_yoy": "净利润同比",
"revenue_yoy": "营业收入同比",
"ocf_to_opincome": "经营现金流质量",
"earnings_surprise_pct": "业绩超预期幅度",
"earnings_days_since_announce": "业绩公告后天数",
"earnings_event_quality": "业绩事件质量",
"popularity_score": "人气榜热度",
"popularity_rank_change": "人气排名跃升",
"popularity_dual_source": "双榜共识",
"institution_net_buy_million": "机构席位净买入",
"institution_seat_count": "机构席位数",
"style_size_fit": "大小盘风格匹配",
"style_growth_fit": "成长价值风格匹配",
"style_fit_score": "当前风格匹配度",
"factor_value_score": "价值因子分",
"factor_growth_score": "成长因子分",
"factor_quality_score": "质量因子分",
"factor_momentum_score": "动量因子分",
"factor_sentiment_score": "交易情绪因子分",
"multi_factor_composite": "动态多因子综合分",
"relative_position_60": "60日相对位置",
"max_abs_change_15d": "15日最大波动",
"close_to_high_15d": "距15日高点",
"close_to_high_60d": "距60日高点",
"no_limit_30d": "近30日无涨停",
"had_limit_80d": "近80日曾涨停",
"previous_first_limit": "昨日首板",
"previous_limit_signal": "昨日涨停或触板",
"previous_limit_streak": "昨日连板高度",
"previous_amount_billion": "昨日成交额",
"is_limit_up_today": "当日涨停",
"is_limit_down_today": "当日跌停",
"sector_breadth_ma20": "行业20日线宽度",
"no_limit_down_20d": "近20日无跌停",
"financial_risk": "财务风险标记",
"is_market_height": "当前市场最高板",
"new_space_board": "新晋空间板",
"max_continuous_board_10d": "近10日最高连板",
"dragon_first_yin": "龙头首阴",
"yin_day_pct": "首阴跌幅",
"vol_vs_previous": "较前日量能",
"broken_reversal": "断板反包",
"days_since_broken": "断板后天数",
"close_above_broken_high": "收复断板高点",
"vol_vs_broken_day": "较断板日量能",
"recent_limit_up_5d": "近5日涨停次数",
"intraday_min_pct": "盘中最大跌幅",
"lower_shadow_ratio": "下影线实体比",
}
FACTOR_GROUPS = {
"行情动量": [
"close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d",
"return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20",
"rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment",
"drawdown_from_high_250", "donchian_breakout_pct", "range_20d",
"rs_high_120", "excess_return_60d", "weekly_trend_signal",
"daily_buy_trigger", "weekly_amount_trend", "relative_strength",
"relative_position_60", "close_to_high_15d", "close_to_high_60d",
],
"量价交易": [
"volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate",
"net_flow_million", "large_flow_million", "net_flow_5d_million",
"flow_to_circ_mv_5d", "previous_amount_billion",
"intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day",
],
"板块结构": [
"sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank",
"sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank",
"sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank",
"sector_composite_score",
"sector_limit_count", "sector_up_count", "sector_breadth_ma20",
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
"is_limit_up_today", "is_limit_down_today",
"no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d",
"is_market_height", "new_space_board", "max_continuous_board_10d",
"dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken",
"close_above_broken_high", "recent_limit_up_5d",
],
"竞价因子": [
"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio",
],
"估值规模": [
"circ_mv_billion", "total_mv_billion", "pe_ttm", "pb", "ps_ttm",
"dividend_yield_ttm", "dividend_years",
],
"财务质量": [
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
"ocf_to_opincome", "financial_risk",
"earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality",
],
"特色数据": [
"popularity_score", "popularity_rank_change", "popularity_dual_source",
"institution_net_buy_million", "institution_seat_count",
"style_size_fit", "style_growth_fit", "style_fit_score",
"factor_value_score", "factor_growth_score", "factor_quality_score",
"factor_momentum_score", "factor_sentiment_score", "multi_factor_composite",
],
}
ALLOWED_OPERATORS = {">", ">=", "<", "<=", "==", "!=", "between", "in"}
BUILTIN_STRATEGIES = [
{
"name": "冰点抗跌先手",
"description": "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。",
"regimes": ["ice"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-3, 7]},
{"field": "return_5d", "op": ">=", "value": -5},
{"field": "amount_billion", "op": ">=", "value": 1},
{"field": "volatility_10d", "op": "<=", "value": 7},
],
"score": [
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.15, "direction": "asc"},
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
],
"limit": 12,
"min_score": 0.58,
},
},
{
"name": "修复先锋",
"description": "筛选率先站回趋势、温和放量并获得板块共振的修复前排。",
"regimes": ["repair"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [1, 9.7]},
{"field": "return_5d", "op": ">", "value": 0},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volume_ratio_5d", "op": ">=", "value": 1.05},
],
"score": [
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
{"field": "relative_strength", "weight": 0.24, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.18, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.16, "direction": "desc"},
{"field": "amount_billion", "weight": 0.14, "direction": "desc"},
],
"limit": 15,
"min_score": 0.54,
},
},
{
"name": "主线发酵跟随",
"description": "在主线扩散期寻找趋势、成交承载和板块涨停梯队共同增强的个股。",
"regimes": ["fermentation"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [0, 9.8]},
{"field": "return_5d", "op": ">=", "value": 3},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "sector_limit_count", "weight": 0.25, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "return_10d", "weight": 0.20, "direction": "desc"},
{"field": "amount_billion", "weight": 0.16, "direction": "desc"},
{"field": "large_flow_million", "weight": 0.15, "direction": "desc"},
],
"limit": 15,
"min_score": 0.55,
},
},
{
"name": "高潮核心去后排",
"description": "高潮阶段只保留容量、趋势和辨识度较高的核心,降低后排跟风权重。",
"regimes": ["climax"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-2, 7]},
{"field": "return_10d", "op": ">=", "value": 5},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 5},
],
"score": [
{"field": "amount_billion", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.22, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.15, "direction": "asc"},
{"field": "limit_streak", "weight": 0.15, "direction": "desc"},
],
"limit": 10,
"min_score": 0.62,
},
},
{
"name": "分化承接回流",
"description": "寻找分化中仍有趋势承接、板块强度和资金回流的核心候选。",
"regimes": ["divergence"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-3, 7]},
{"field": "return_5d", "op": ">", "value": 0},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volume_ratio_5d", "op": "between", "value": [0.7, 3.5]},
],
"score": [
{"field": "relative_strength", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.20, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.16, "direction": "asc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
],
"limit": 12,
"min_score": 0.57,
},
},
{
"name": "退潮防守观察",
"description": "退潮期采用高门槛防守筛选,结果为空代表当前不宜主动出击。",
"regimes": ["retreat"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "pct_chg", "op": "between", "value": [-2, 4]},
{"field": "return_5d", "op": ">=", "value": -2},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "volatility_10d", "op": "<=", "value": 4.5},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "volatility_10d", "weight": 0.30, "direction": "asc"},
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
{"field": "sector_strength", "weight": 0.15, "direction": "desc"},
{"field": "net_flow_million", "weight": 0.10, "direction": "desc"},
],
"limit": 8,
"min_score": 0.68,
},
},
{
"name": "竞价强势确认",
"description": "用竞价涨幅、成交承载和量比确认修复或发酵阶段的主动进攻标的。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "auction_change", "op": "between", "value": [1, 7]},
{"field": "auction_amount_million", "op": ">=", "value": 3},
{"field": "auction_volume_ratio", "op": ">=", "value": 0.8},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "auction_amount_million", "weight": 0.26, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.22, "direction": "desc"},
{"field": "auction_change", "weight": 0.18, "direction": "desc"},
{"field": "sector_strength", "weight": 0.18, "direction": "desc"},
{"field": "relative_strength", "weight": 0.16, "direction": "desc"},
],
"limit": 15,
"min_score": 0.56,
},
},
]
for _strategy in BUILTIN_STRATEGIES:
_strategy["formula"].setdefault("meta", {
"library": "smart", "category": "周期策略", "quality": "系统",
"frequency": "每日", "risk": "随市场阶段", "data_group": "行情因子",
})
CURATED_STRATEGIES = [
{
"name": "连续分红质量",
"description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。",
"regimes": list(REGIMES),
"formula": {
"meta": {"library": "curated", "category": "红利价值", "quality": "A", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 1095},
"filters": [
{"field": "dividend_years", "op": ">=", "value": 4},
{"field": "dividend_yield_ttm", "op": ">=", "value": 2},
{"field": "roe", "op": ">=", "value": 6},
{"field": "pb", "op": "between", "value": [0.1, 4]},
],
"score": [
{"field": "dividend_yield_ttm", "weight": 0.30, "direction": "desc"},
{"field": "roe", "weight": 0.24, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.18, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.16, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.12, "direction": "desc"},
], "limit": 20, "min_score": 0.52,
},
},
{
"name": "ROIC质量低波",
"description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。",
"regimes": ["ice", "repair", "divergence", "retreat"],
"formula": {
"meta": {"library": "curated", "category": "质量价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 730},
"filters": [
{"field": "roic", "op": ">=", "value": 6},
{"field": "gross_margin", "op": ">=", "value": 15},
{"field": "pe_ttm", "op": "between", "value": [1, 45]},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "roic", "weight": 0.28, "direction": "desc"},
{"field": "gross_margin", "weight": 0.22, "direction": "desc"},
{"field": "ps_ttm", "weight": 0.18, "direction": "asc"},
{"field": "volatility_10d", "weight": 0.18, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.14, "direction": "desc"},
], "limit": 20, "min_score": 0.54,
},
},
{
"name": "低估值现金流白马",
"description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。",
"regimes": ["ice", "repair", "divergence", "retreat"],
"formula": {
"meta": {"library": "curated", "category": "现金流价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 730},
"filters": [
{"field": "pb", "op": "between", "value": [0.1, 1.8]},
{"field": "roa", "op": ">=", "value": 3},
{"field": "ocf_to_opincome", "op": ">", "value": 0},
{"field": "netprofit_yoy", "op": ">=", "value": -15},
{"field": "total_mv_billion", "op": ">=", "value": 100},
],
"score": [
{"field": "roa", "weight": 0.26, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.24, "direction": "desc"},
{"field": "pb", "weight": 0.20, "direction": "asc"},
{"field": "total_mv_billion", "weight": 0.16, "direction": "desc"},
{"field": "volatility_10d", "weight": 0.14, "direction": "asc"},
], "limit": 20, "min_score": 0.53,
},
},
{
"name": "高增长合理估值",
"description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "成长质量", "quality": "B+", "frequency": "月度", "risk": "", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "pe_ttm", "op": "between", "value": [1, 35]},
{"field": "revenue_yoy", "op": ">=", "value": 10},
{"field": "netprofit_yoy", "op": ">=", "value": 15},
{"field": "roe", "op": ">=", "value": 5},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "netprofit_yoy", "weight": 0.27, "direction": "desc"},
{"field": "revenue_yoy", "weight": 0.23, "direction": "desc"},
{"field": "roe", "weight": 0.20, "direction": "desc"},
{"field": "pe_ttm", "weight": 0.16, "direction": "asc"},
{"field": "relative_strength", "weight": 0.14, "direction": "desc"},
], "limit": 20, "min_score": 0.55,
},
},
{
"name": "行业宽度主线",
"description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。",
"regimes": ["repair", "fermentation", "climax", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "行业轮动", "quality": "B+", "frequency": "每周", "risk": "", "data_group": "行情与行业"},
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "sector_breadth_ma20", "op": ">=", "value": 55},
{"field": "sector_strength", "op": ">=", "value": 55},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "amount_billion", "op": ">=", "value": 2},
],
"score": [
{"field": "sector_breadth_ma20", "weight": 0.28, "direction": "desc"},
{"field": "sector_strength", "weight": 0.24, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "sector_limit_count", "weight": 0.16, "direction": "desc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
], "limit": 20, "min_score": 0.56,
},
},
{
"name": "首板低开",
"description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。",
"regimes": ["ice", "repair", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "短线竞价", "quality": "B+", "frequency": "每日9:25", "risk": "", "data_group": "行情与竞价"},
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "previous_first_limit", "op": "==", "value": 1},
{"field": "auction_change", "op": "between", "value": [-4.5, -2.5]},
{"field": "relative_position_60", "op": "<=", "value": 0.55},
{"field": "previous_amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
{"field": "previous_amount_billion", "weight": 0.24, "direction": "desc"},
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
{"field": "sector_strength", "weight": 0.16, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.12, "direction": "desc"},
], "limit": 12, "min_score": 0.50,
},
},
{
"name": "小碎步临界突破",
"description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "形态突破", "quality": "B+", "frequency": "每日", "risk": "中高", "data_group": "历史行情"},
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "no_limit_30d", "op": "==", "value": 1},
{"field": "had_limit_80d", "op": "==", "value": 1},
{"field": "max_abs_change_15d", "op": "<=", "value": 3},
{"field": "close_to_high_15d", "op": ">=", "value": 0.98},
{"field": "close_to_high_60d", "op": ">=", "value": 0.90},
],
"score": [
{"field": "close_to_high_15d", "weight": 0.26, "direction": "desc"},
{"field": "volume_ratio_5d", "weight": 0.22, "direction": "desc"},
{"field": "relative_strength", "weight": 0.20, "direction": "desc"},
{"field": "max_abs_change_15d", "weight": 0.18, "direction": "asc"},
{"field": "circ_mv_billion", "weight": 0.14, "direction": "asc"},
], "limit": 15, "min_score": 0.54,
},
},
{
"name": "连板龙头",
"description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。",
"regimes": ["fermentation", "climax", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "连板接力", "quality": "B", "frequency": "每日", "risk": "很高", "data_group": "涨停结构"},
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "previous_limit_streak", "op": ">=", "value": 2},
{"field": "previous_amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "previous_limit_streak", "weight": 0.34, "direction": "desc"},
{"field": "sector_limit_count", "weight": 0.24, "direction": "desc"},
{"field": "previous_amount_billion", "weight": 0.18, "direction": "desc"},
{"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
{"field": "sector_strength", "weight": 0.10, "direction": "desc"},
], "limit": 10, "min_score": 0.50,
},
},
{
"name": "微盘三正",
"description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。",
"regimes": ["repair", "fermentation"],
"formula": {
"meta": {"library": "curated", "category": "小盘质量", "quality": "B", "frequency": "每周", "risk": "", "data_group": "估值与财务"},
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "pb", "op": ">", "value": 0},
{"field": "roe", "op": ">", "value": 0},
{"field": "ocf_to_opincome", "op": ">", "value": 0},
{"field": "circ_mv_billion", "op": "between", "value": [5, 100]},
{"field": "amount_billion", "op": ">=", "value": 0.5},
],
"score": [
{"field": "circ_mv_billion", "weight": 0.32, "direction": "asc"},
{"field": "roe", "weight": 0.24, "direction": "desc"},
{"field": "ocf_to_opincome", "weight": 0.20, "direction": "desc"},
{"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
{"field": "relative_strength", "weight": 0.10, "direction": "desc"},
], "limit": 20, "min_score": 0.52,
},
},
{
"name": "首板高开弱转强",
"description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": {"library": "curated", "category": "短线竞价", "quality": "B-", "frequency": "每日9:25", "risk": "", "data_group": "行情与竞价"},
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "previous_limit_signal", "op": "==", "value": 1},
{"field": "auction_change", "op": "between", "value": [1, 6]},
{"field": "auction_volume_ratio", "op": ">=", "value": 0.8},
{"field": "previous_amount_billion", "op": "between", "value": [3, 25]},
],
"score": [
{"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
{"field": "auction_volume_ratio", "weight": 0.24, "direction": "desc"},
{"field": "auction_change", "weight": 0.18, "direction": "desc"},
{"field": "sector_strength", "weight": 0.17, "direction": "desc"},
{"field": "relative_strength", "weight": 0.13, "direction": "desc"},
], "limit": 15, "min_score": 0.52,
},
},
]
CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES)
STRATEGY_ENVIRONMENT_NOTES = {
"连续分红质量": (
"防守市、低利率环境与中长期配置窗口",
"风险偏好快速上升时,稳健资产的价格弹性通常落后",
),
"ROIC质量低波": (
"震荡偏弱、重视盈利质量与回撤控制的市场",
"主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向",
),
"低估值现金流白马": (
"估值修复、价值回归及防守配置阶段",
"低估值可能来自基本面持续走弱,需警惕价值陷阱",
),
"高增长合理估值": (
"业绩驱动、成长风格占优且趋势获得确认的阶段",
"增长预期下修或估值快速收缩时,回撤可能明显放大",
),
"行业宽度主线": (
"主线清晰、行业内部多数个股同步走强的行情",
"板块快速轮动时,宽度信号容易在确认后迅速衰减",
),
"首板低开": (
"情绪修复期的分歧转一致与首板次日承接",
"退潮加速或低开缺少量能承接时,弱势可能继续扩大",
),
"小碎步临界突破": (
"趋势蓄势、波动收敛后临近突破的结构市",
"无量突破或指数剧烈震荡时,容易形成冲高回落",
),
"连板龙头": (
"高度拓展、题材梯队完整且接力情绪活跃的阶段",
"亏钱效应扩散或高位股集中退潮时,接力风险很高",
),
"微盘三正": (
"小盘风格活跃、流动性宽松且风险偏好较高的行情",
"风格切向大盘或微盘流动性收缩时,组合波动会显著上升",
),
"首板高开弱转强": (
"竞价承接明确、短线情绪修复或主线发酵阶段",
"高开缺乏板块共振时,竞价强势可能转为盘中兑现",
),
"中期动量·强者恒强": (
"趋势延续、主升段及强弱分化清晰的行情",
"无趋势震荡或快速轮动中,动量信号容易反复失效",
),
"强者回调": (
"主升趋势未破、强势股完成良性回踩的窗口",
"趋势已反转时,回调信号可能演变为下跌中继",
),
"超跌反转": (
"急跌后恐慌释放充分、市场进入修复预期的阶段",
"单边下跌初段容易过早介入,超跌不等于止跌",
),
"相对强度新高": (
"指数偏弱但结构性主线明确,或机构抱团强化的行情",
"基准快速补涨或强势方向瓦解时,相对优势可能迅速消失",
),
"均线多头排列": (
"中期趋势向上、回撤有序的趋势市与主升段",
"高位趋势末端或宽幅震荡中,均线信号通常反应滞后",
),
"唐奇安通道突破": (
"整理末端、放量突破并启动新趋势的行情",
"无量突破和宽幅震荡环境中,假突破出现概率较高",
),
"周线趋势·日线买点": (
"中期趋势稳定、日线回踩或再启动的多周期共振阶段",
"周线拐点尚未确认时,日线信号可能只是短暂反抽",
),
"空间板": (
"市场高度持续拓展、板块梯队完整的强接力环境",
"高度压缩或亏钱效应扩散时,最高板的补跌风险极高",
),
"龙头首阴": (
"主线龙头仍有辨识度、首次分歧后存在回流预期的阶段",
"题材退潮或龙头地位被替代后,首阴可能只是下跌起点",
),
"断板反包": (
"强势题材分歧后快速修复、核心股重新获得资金承接时",
"板块强度不足或反包缩量时,形态持续性通常较弱",
),
"核按钮反核": (
"恐慌释放后出现明确承接、短线情绪转暖的窗口",
"系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高",
),
"行业动量轮动": (
"主线相对清晰、行业趋势能够延续两周以上的结构市",
"行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减",
),
"主力资金行业流入": (
"板块轮动初期、资金先于价格形成连续净流入的阶段",
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
),
"景气-趋势-拥挤三维行业打分": (
"行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市",
"财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢",
),
"大小盘/成长价值风格切换(元策略)": (
"大小盘或成长价值风格形成持续相对强弱的阶段",
"风格快速往返切换时,近20日相对表现容易产生滞后信号",
),
"业绩超预期漂移(SUE/PEAD)": (
"业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时",
"预告与快报口径可能不同,公告后高开兑现会削弱漂移效应",
),
"多因子综合打分(IC动态加权)": (
"因子表现具备一定延续性、市场并非由单一极端主题主导时",
"近期有效因子可能快速失效,动态权重不能消除风格突变风险",
),
"热度突增潜伏(另类数据)": (
"人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期",
"榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高",
),
"机构榜溢价": (
"机构专用席位在相对低位形成明确净买入、且成交承载正常时",
"高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓",
),
}
for strategy in CURATED_STRATEGIES:
suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]]
strategy["formula"]["meta"].update(
{
"suitable_environment": suitable_environment,
"failure_risk": failure_risk,
}
)
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
for strategy in BUILTIN_STRATEGIES:
attach_strategy_validity(strategy)
+1 -1
View File
@@ -4,7 +4,7 @@ import json
from typing import Any
from backend.llm import transport as llm_transport
from backend.features.screener.engine import FACTOR_FIELDS, REGIMES
from backend.features.screener.catalog import FACTOR_FIELDS, REGIMES
class LLMCompilerError(RuntimeError):
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.screener.indicators import _optional_number
from database import ReviewDatabase
def _quarter_periods(trade_date: str, count: int) -> list[str]:
current = datetime.strptime(trade_date, "%Y%m%d")
quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31))
periods = []
year = current.year
while len(periods) < count:
for month, day in reversed(quarter_ends):
value = datetime(year, month, day)
if value <= current:
periods.append(value.strftime("%Y%m%d"))
if len(periods) == count:
break
year -= 1
return sorted(periods)
def _earnings_event_rows(
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str,
) -> list[dict[str, Any]]:
forecast_map: dict[tuple[str, str], dict[str, Any]] = {}
for row in forecasts:
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
ann_date = str(row.get("ann_date") or "")
if not all(key) or not ann_date or ann_date > trade_date:
continue
previous = forecast_map.get(key)
if previous is None or ann_date > str(previous.get("ann_date") or ""):
forecast_map[key] = row
result = []
for row in expresses:
ts_code = str(row.get("ts_code") or "")
end_date = str(row.get("end_date") or "")
ann_date = str(row.get("ann_date") or "")
forecast = forecast_map.get((ts_code, end_date))
if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date:
continue
lower = _optional_number(forecast.get("net_profit_min"))
upper = _optional_number(forecast.get("net_profit_max"))
forecast_profit = statistics.fmean(
value for value in (lower, upper) if value is not None
) if lower is not None or upper is not None else None
actual_profit = _optional_number(row.get("n_income"))
if forecast_profit in (None, 0) or actual_profit is None:
continue
# forecast is reported in ten-thousand yuan while express uses yuan.
if abs(actual_profit) > max(abs(forecast_profit), 1) * 100:
actual_profit /= 10000
surprise_pct = (actual_profit / forecast_profit - 1) * 100
result.append(
{
"end_date": end_date,
"ann_date": ann_date,
"ts_code": ts_code,
"forecast_profit": forecast_profit,
"actual_profit": actual_profit,
"surprise_pct": surprise_pct,
"revenue_yoy": _optional_number(row.get("yoy_sales")),
"netprofit_yoy": _optional_number(row.get("yoy_net_profit")),
"source": "forecast+express",
}
)
return result
def _popularity_factor_rows(
trade_date: str,
ths_rows: list[dict[str, Any]],
dc_rows: list[dict[str, Any]],
previous_ths: list[dict[str, Any]],
previous_dc: list[dict[str, Any]],
) -> list[dict[str, Any]]:
def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]:
result = {}
for row in rows:
if data_type and str(row.get("data_type") or "") != data_type:
continue
ts_code = str(row.get("ts_code") or "")
rank = int(_number(row.get("rank")))
if ts_code and rank > 0:
result[ts_code] = rank
return result
ths = ranks(ths_rows, "热股")
dc = ranks(dc_rows, "A股市场")
previous_ths_map = ranks(previous_ths, "热股")
previous_dc_map = ranks(previous_dc, "A股市场")
result = []
for ts_code in set(ths) | set(dc):
ths_rank = ths.get(ts_code)
dc_rank = dc.get(ts_code)
current_best = min(value for value in (ths_rank, dc_rank) if value is not None)
previous_candidates = [
value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code))
if value is not None
]
previous_best = min(previous_candidates) if previous_candidates else None
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
result.append(
{
"trade_date": trade_date,
"ts_code": ts_code,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"combined_score": round(score, 2),
"rank_change": (
previous_best - current_best
if previous_best is not None
else min(30, max(0, 31 - current_best))
if previous_ths_map or previous_dc_map else 0
),
"dual_source": bool(ths_rank and dc_rank),
}
)
return result
class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
self.database = database
self.client = client
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
lookback = max(25, min(260, int(lookback)))
trade_date, _ = self.client.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
calendar = self.client.query(
"trade_cal",
{"exchange": "SSE", "start_date": start, "end_date": trade_date, "is_open": 1},
"cal_date,is_open",
)
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
existing = set(self.database.factor_dates(trade_date, lookback + 10))
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
auction_source_dates = dates[-min(80, len(dates)):]
existing_auction = set(self.database.auction_factor_dates(trade_date, 90))
auction_dates_to_fetch = [
value for value in auction_source_dates
if value not in existing_auction or value == trade_date
]
long_calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"),
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
last_open_by_year: dict[str, str] = {}
last_open_by_month: dict[str, str] = {}
for row in long_calendar:
if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value)
valuation_dates = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values())
valuation_dates.update(last_open_by_month.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted(
value for value in valuation_dates if value not in existing_indicators or value == trade_date
)
master = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
master_count = self.database.upsert_stock_master(master)
bar_count = 0
for current_date in dates_to_fetch:
rows = self.client.query(
"daily",
{"trade_date": current_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
bar_count += self.database.upsert_daily_bars(rows)
indicator_count = 0
for current_date in indicator_dates_to_fetch:
indicators = self.client.query(
"daily_basic",
{"trade_date": current_date},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,"
"pe_ttm,pb,ps_ttm,dv_ttm",
)
indicator_count += self.database.upsert_daily_indicators(indicators)
notices = []
benchmark_count = 0
try:
benchmark_rows = self.client.query(
"index_daily",
{"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows)
except TushareError as exc:
notices.append(f"沪深300基准暂不可用:{exc}")
fundamental_count = 0
existing_periods = set(self.database.fundamental_periods())
for period in _quarter_periods(trade_date, 9):
if period in existing_periods and period < trade_date[:4] + "0101":
continue
try:
rows = self.client.query(
"fina_indicator_vip",
{"period": period},
"ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin,"
"netprofit_yoy,or_yoy,ocf_to_opincome",
)
except TushareError as exc:
notices.append(f"财务质量接口不可用:{exc}")
break
published = [
row for row in rows
if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date
]
published.sort(key=lambda row: str(row.get("ann_date") or ""))
fundamental_count += self.database.upsert_fundamental_indicators(published)
auction_count = 0
auction_dates = 0
for current_date in auction_dates_to_fetch:
try:
auction_rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
if auction_rows:
auction_count += self.database.upsert_auction_factors(auction_rows)
auction_dates += 1
except TushareError as exc:
notices.append(f"竞价因子接口不可用:{exc}")
break
moneyflow_count = 0
moneyflow_dates = 0
for current_date in dates[-min(5, len(dates)):]:
try:
moneyflow = self.client.query(
"moneyflow",
{"trade_date": current_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
moneyflow_count += self.database.upsert_moneyflow(moneyflow)
if moneyflow:
moneyflow_dates += 1
except TushareError as exc:
notices.append(f"资金流接口不可用:{exc}")
break
earnings_count = 0
forecasts: list[dict[str, Any]] = []
expresses: list[dict[str, Any]] = []
for period in _quarter_periods(trade_date, 5):
try:
forecast_rows = self.client.query(
"forecast_vip",
{"period": period},
"ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max",
)
express_rows = self.client.query(
"express_vip",
{"period": period},
"ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales",
)
except TushareError as exc:
notices.append(f"业绩事件接口不可用:{exc}")
break
forecasts.extend(forecast_rows)
expresses.extend(express_rows)
if forecasts and expresses:
earnings_count = self.database.upsert_earnings_events(
_earnings_event_rows(forecasts, expresses, trade_date)
)
popularity_count = 0
previous_trade_date = dates[-2] if len(dates) >= 2 else ""
try:
ths_rows = self.client.query("ths_hot", {"trade_date": trade_date})
dc_rows = self.client.query("dc_hot", {"trade_date": trade_date})
previous_ths = (
self.client.query("ths_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
previous_dc = (
self.client.query("dc_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
popularity_count = self.database.upsert_popularity_factors(
_popularity_factor_rows(
trade_date, ths_rows, dc_rows, previous_ths, previous_dc
)
)
except TushareError as exc:
notices.append(f"人气榜因子不可用:{exc}")
institution_count = 0
try:
institution_rows = self.client.query(
"top_inst",
{"trade_date": trade_date},
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
)
institution_count = self.database.upsert_lhb_institutions(institution_rows)
except TushareError as exc:
notices.append(f"机构席位明细不可用:{exc}")
return {
"trade_date": trade_date,
"calendar_dates": len(dates),
"fetched_dates": len(dates_to_fetch),
"stocks": master_count,
"bars": bar_count,
"benchmark_bars": benchmark_count,
"indicators": indicator_count,
"indicator_dates": len(indicator_dates_to_fetch),
"fundamentals": fundamental_count,
"moneyflow": moneyflow_count,
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count,
"auction_dates": auction_dates,
"earnings_events": earnings_count,
"popularity_rows": popularity_count,
"institution_rows": institution_count,
"notice": "".join(notices),
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
from __future__ import annotations
import statistics
from collections import defaultdict
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.indicators import (
_available_percentile_map,
_broken_reversal_metrics,
_ending_streak,
_is_limit_bar,
_limit_threshold,
_macd_last,
_macd_series,
_max_streak,
_optional_number,
_pearson,
_percentile_map,
_rounded_optional,
_rsi,
_touched_limit_bar,
_weekly_series,
)
from database import ReviewDatabase
class FactorBuilder:
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
def build_factors(
self,
trade_date: str,
realtime_snapshot: dict[str, Any] | None = None,
history_days: int = 80,
) -> tuple[list[dict[str, Any]], str]:
history_days = max(21, min(260, int(history_days)))
data = self.database.load_factor_data(trade_date, history_days)
dates = [value for value in data["dates"] if value <= trade_date]
if len(dates) < 21:
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
history_date = dates[-1]
realtime_map = {
str(row.get("ts_code") or ""): row
for row in (realtime_snapshot or {}).get("rows") or []
}
realtime_date = str((realtime_snapshot or {}).get("trade_date") or "")
use_realtime = bool(realtime_map and realtime_date == trade_date and history_date < trade_date)
actual_date = trade_date if use_realtime else history_date
master = {row["ts_code"]: row for row in data["master"]}
indicators = {row["ts_code"]: row for row in data["indicators"]}
fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])}
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_history", []):
indicator_history[str(row.get("ts_code") or "")].append(row)
indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_series", []):
indicator_series[str(row.get("ts_code") or "")].append(row)
benchmark_by_date = {
str(row.get("trade_date") or ""): _number(row.get("close"))
for row in data.get("benchmarks", [])
if _number(row.get("close")) > 0
}
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("moneyflow_history", []):
moneyflow_history[str(row.get("ts_code") or "")].append(row)
auction = {
row["ts_code"]: row
for row in data.get("auction", [])
if str(row.get("trade_date") or "") == actual_date
}
earnings_events: dict[str, dict[str, Any]] = {}
for row in data.get("earnings_events", []):
ts_code = str(row.get("ts_code") or "")
ann_date = str(row.get("ann_date") or "")
if ann_date <= actual_date and (
ts_code not in earnings_events
or ann_date > str(earnings_events[ts_code].get("ann_date") or "")
):
earnings_events[ts_code] = row
popularity = {
str(row.get("ts_code") or ""): row
for row in data.get("popularity", [])
}
institutions = {
str(row.get("ts_code") or ""): row
for row in data.get("institutions", [])
}
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data["bars"]:
if row["trade_date"] <= history_date:
grouped[row["ts_code"]].append(row)
snapshot = self.database.get_snapshot(actual_date) or {}
limit_map: dict[str, tuple[str, int]] = {}
for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")):
for row in snapshot.get(key) or []:
limit_map[str(row.get("code"))] = (status, int(row.get("streak") or 0))
factors = []
current_day = datetime.strptime(actual_date, "%Y%m%d")
for ts_code, bars in grouped.items():
bars.sort(key=lambda item: item["trade_date"])
if len(bars) < 21 or bars[-1]["trade_date"] != history_date:
continue
info = master.get(ts_code)
if not info:
continue
historical_closes = [_number(item["close"]) for item in bars]
historical_volumes = [_number(item["vol"]) for item in bars]
realtime = realtime_map.get(ts_code) if use_realtime else None
current = realtime or bars[-1]
closes = historical_closes + ([_number(realtime["close"])] if realtime else [])
volumes = historical_volumes + ([_number(realtime["vol"])] if realtime else [])
if closes[-1] <= 0:
continue
returns_10 = [_number(item["pct_chg"]) for item in bars[-10:]]
if realtime:
returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))]
previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0
indicator = indicators.get(ts_code, {})
fundamental = fundamentals.get(ts_code, {})
flow = moneyflow.get(ts_code, {})
flow_history = moneyflow_history.get(ts_code, [])
auction_row = auction.get(ts_code, {})
list_date = str(info.get("list_date") or "")
try:
listed_days = (current_day - datetime.strptime(list_date, "%Y%m%d")).days
except ValueError:
listed_days = 9999
code = str(info.get("code") or ts_code.split(".")[0])
status, streak = limit_map.get(code, ("", 0))
name = str(info.get("name") or "--")
shape_rows = bars + ([realtime] if realtime else [])
shape_close = [_number(item.get("close")) for item in shape_rows]
shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows]
shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows]
shape_changes = [_number(item.get("pct_chg")) for item in shape_rows]
position_rows = shape_rows[-60:]
position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0)
position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0)
relative_position = (
(closes[-1] - position_low) / (position_high - position_low)
if position_high > position_low else 0.5
)
previous_index = len(bars) - 1 if realtime else len(bars) - 2
previous_bar = bars[previous_index] if previous_index >= 0 else {}
previous_limit = _is_limit_bar(bars, previous_index, code, name)
previous_touched = _touched_limit_bar(bars, previous_index, code, name)
recent_prior_signal = any(
_is_limit_bar(bars, index, code, name)
or _touched_limit_bar(bars, index, code, name)
for index in range(max(0, previous_index - 2), previous_index)
)
previous_streak = 0
streak_index = previous_index
while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name):
previous_streak += 1
streak_index -= 1
limit_flags = [
_is_limit_bar(shape_rows, index, code, name)
for index in range(len(shape_rows))
]
annual_dividend_rows = indicator_history.get(ts_code, [])
dividend_years = sum(
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
)
current_streak = _ending_streak(limit_flags)
prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2)
streak = max(streak, current_streak)
return_60d = (
(closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
momentum_60_5 = (
(closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
ma20 = statistics.fmean(closes[-20:])
ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20
prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20
prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60
ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0
ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0
ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)]
high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high)
drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100
prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0
breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0
prior_lows_20 = shape_low[-21:-1]
range_20d = (
(prior_high_20 / min(prior_lows_20) - 1) * 100
if prior_lows_20 and min(prior_lows_20) > 0 else 100
)
turnover_rows = sorted(
indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "")
)
turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]]
if realtime and _number(realtime.get("turnover_rate")):
turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))]
turnover_5d = sum(turnover_values)
rs_values = [
_number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))]
for item in shape_rows[-120:]
if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0
]
benchmark_60 = [
benchmark_by_date.get(str(item.get("trade_date")))
for item in shape_rows[-61:]
if benchmark_by_date.get(str(item.get("trade_date")))
]
benchmark_return_60 = (
(benchmark_60[-1] / benchmark_60[0] - 1) * 100
if len(benchmark_60) >= 61 and benchmark_60[0] else 0
)
weekly_closes, weekly_amounts = _weekly_series(shape_rows)
weekly_dif, weekly_dea = _macd_last(weekly_closes)
daily_dif, daily_dea = _macd_series(closes)
daily_cross = (
len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1]
and daily_dif[-2] <= daily_dea[-2]
)
current_open = _number(current.get("open"))
daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open
previous_close = closes[-2] if len(closes) >= 2 else closes[-1]
intraday_min = (
(_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0
)
body = abs(closes[-1] - current_open)
lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low")))
lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0)
previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
earnings_event = earnings_events.get(ts_code, {})
announcement_date = str(earnings_event.get("ann_date") or "")
earnings_days = (
sum(1 for value in dates if announcement_date < value <= actual_date)
if announcement_date and announcement_date <= actual_date
else None
)
announcement_bar = next(
(item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date),
None,
)
announcement_bad = False
if announcement_bar is not None:
bar_index = shape_rows.index(announcement_bar)
prior_volumes = [
_number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index]
if _number(item.get("vol")) > 0
]
volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0
announcement_bad = (
_number(announcement_bar.get("close")) < _number(announcement_bar.get("open"))
and _number(announcement_bar.get("pct_chg")) < 0
and volume_baseline > 0
and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8
)
popularity_row = popularity.get(ts_code)
institution_row = institutions.get(ts_code)
factors.append(
{
"code": code,
"ts_code": ts_code,
"name": name,
"sector": info.get("industry") or "其他",
"market": info.get("market") or "--",
"listed_days": listed_days,
"close": round(closes[-1], 2),
"price": round(closes[-1], 2),
"pct_chg": round(_number(current["pct_chg"]), 2),
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
"return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2),
"return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2),
"return_60d": round(return_60d, 2),
"momentum_60_5": round(momentum_60_5, 2),
"above_ma20": int(closes[-1] > ma20),
"rsi_6": round(_rsi(closes, 6), 2),
"ma60_slope": round(ma60_slope, 3),
"ma20_slope_5d": round(ma20_slope, 3),
"ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]),
"drawdown_from_high_250": round(drawdown_250, 2),
"donchian_breakout_pct": round(breakout_pct, 2),
"range_20d": round(range_20d, 2),
"rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)),
"excess_return_60d": round(return_60d - benchmark_return_60, 2),
"weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0),
"daily_buy_trigger": int(daily_cross or daily_pullback),
"weekly_amount_trend": int(
len(weekly_amounts) >= 5
and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1])
),
"volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0,
"turnover_5d": round(turnover_5d, 2),
"volatility_10d": round(statistics.pstdev(returns_10), 2),
"amount_billion": round(
_number(current["amount"]) / (100000000 if realtime else 100000), 2
),
"turnover_rate": round(
_number(realtime.get("turnover_rate"))
if realtime else _number(indicator.get("turnover_rate")),
2,
),
"circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2),
"total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2),
"pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2),
"pb": _rounded_optional(indicator.get("pb"), 2),
"ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2),
"dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2),
"dividend_years": dividend_years,
"roe": _rounded_optional(fundamental.get("roe"), 2),
"roa": _rounded_optional(fundamental.get("roa"), 2),
"roic": _rounded_optional(fundamental.get("roic"), 2),
"gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2),
"netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
"revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2),
"earnings_days_since_announce": earnings_days,
"earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None,
"popularity_score": _rounded_optional(
popularity_row.get("combined_score") if popularity_row else None, 2
),
"popularity_rank_change": (
int(popularity_row["rank_change"])
if popularity_row and popularity_row.get("rank_change") is not None else None
),
"popularity_dual_source": (
int(bool(popularity_row.get("dual_source"))) if popularity_row else None
),
"institution_net_buy_million": (
round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2)
if institution_row else None
),
"institution_seat_count": (
int(institution_row.get("seat_count") or 0) if institution_row else None
),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"net_flow_5d_million": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100,
2,
),
"flow_to_circ_mv_5d": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history)
/ _number(indicator.get("circ_mv")) * 100,
4,
) if _number(indicator.get("circ_mv")) else 0,
"limit_status": status,
"limit_streak": streak,
"is_limit_up_today": int(limit_flags[-1]),
"is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)),
"auction_change": round(_number(auction_row.get("change")), 2),
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4),
"auction_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2),
"relative_position_60": round(relative_position, 4),
"max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2),
"close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) else 0,
"close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0,
"no_limit_30d": int(not any(limit_flags[-30:])),
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
"no_limit_down_20d": int(not any(
_number(item.get("pct_chg")) <= -_limit_threshold(code, name)
for item in shape_rows[-20:]
)),
"financial_risk": int(
"ST" in name.upper() or "退" in name
or (netprofit_yoy is not None and netprofit_yoy <= -100)
),
"prior_limit_streak": prior_streak,
"max_continuous_board_10d": _max_streak(limit_flags[-10:]),
"dragon_first_yin": int(
prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open
),
"yin_day_pct": round(_number(current.get("pct_chg")), 2),
"vol_vs_previous": round(vol_vs_previous, 3),
"broken_reversal": broken["signal"],
"days_since_broken": broken["days"],
"close_above_broken_high": broken["recovered"],
"vol_vs_broken_day": broken["volume_ratio"],
"recent_limit_up_5d": sum(limit_flags[-5:]),
"intraday_min_pct": round(intraday_min, 2),
"lower_shadow_ratio": round(lower_shadow_ratio, 2),
"previous_first_limit": int(previous_limit and not recent_prior_signal),
"previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
"previous_limit_streak": previous_streak,
"previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2),
}
)
market_return = statistics.fmean(row["return_5d"] for row in factors) if factors else 0
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in factors:
sectors[row["sector"]].append(row)
sector_metrics = []
market_amount = sum(max(0.0, row["amount_billion"]) for row in factors)
for sector_name, sector_rows in sectors.items():
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows)
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
sector_growth = [
statistics.fmean(values)
for row in sector_rows
if (values := [
value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy"))
if value is not None
])
]
prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0
average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows)
amount_share = (
sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100
if market_amount else 0.0
)
crowding_raw = average_turnover + amount_share
trend_raw = average_return_20d + breadth_ma20 / 10
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
sector_metrics.append(
{
"ts_code": sector_name,
"sector_return_20d": average_return_20d,
"sector_net_flow_5d_million": sector_net_flow,
"sector_prosperity_raw": prosperity_raw,
"sector_trend_raw": trend_raw,
"sector_crowding_raw": crowding_raw,
}
)
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
for row in sector_rows:
row["sector_strength"] = round(strength, 1)
row["sector_return_5d"] = round(average_return, 2)
row["sector_return_20d"] = round(average_return_20d, 2)
row["sector_net_flow_5d_million"] = round(sector_net_flow, 2)
row["sector_stock_momentum_rank"] = round(
stock_momentum_ranks.get(row["ts_code"], 0.0), 4
)
row["sector_limit_count"] = limit_count
row["sector_up_count"] = up_count
row["sector_breadth_ma20"] = round(breadth_ma20, 1)
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
sector_momentum_ranks = _percentile_map(
sector_metrics, "sector_return_20d", "desc"
)
sector_flow_ranks = _percentile_map(
sector_metrics, "sector_net_flow_5d_million", "desc"
)
sector_prosperity_ranks = _percentile_map(
sector_metrics, "sector_prosperity_raw", "desc"
)
sector_trend_ranks = _percentile_map(
sector_metrics, "sector_trend_raw", "desc"
)
sector_crowding_ranks = _percentile_map(
sector_metrics, "sector_crowding_raw", "desc"
)
for sector_name, sector_rows in sectors.items():
prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0)
trend_rank = sector_trend_ranks.get(sector_name, 0.0)
crowding_rank = sector_crowding_ranks.get(sector_name, 0.0)
composite_score = (
prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30
)
for row in sector_rows:
row["sector_momentum_rank"] = round(
sector_momentum_ranks.get(sector_name, 0.0), 4
)
row["sector_flow_rank"] = round(
sector_flow_ranks.get(sector_name, 0.0), 4
)
row["sector_prosperity_rank"] = round(prosperity_rank, 4)
row["sector_trend_rank"] = round(trend_rank, 4)
row["sector_crowding_rank"] = round(crowding_rank, 4)
row["sector_composite_score"] = round(composite_score, 4)
factor_specs = {
"factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")),
"factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")),
"factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")),
"factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")),
"factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")),
}
for output_field, specs in factor_specs.items():
maps = [_available_percentile_map(factors, field, direction) for field, direction in specs]
for row in factors:
values = [mapping.get(row["ts_code"]) for mapping in maps]
available = [value for value in values if value is not None]
row[output_field] = round(statistics.fmean(available), 4) if available else None
return_rank_map = _available_percentile_map(factors, "return_20d", "desc")
factor_weights = {}
for output_field in factor_specs:
pairs = [
(row.get(output_field), return_rank_map.get(row["ts_code"]))
for row in factors
if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None
]
correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs])
factor_weights[output_field] = max(0.05, correlation)
factor_weight_total = sum(factor_weights.values()) or 1
for row in factors:
weighted = [
(row.get(field), weight)
for field, weight in factor_weights.items()
if row.get(field) is not None
]
row["multi_factor_composite"] = round(
sum(value * weight for value, weight in weighted)
/ (sum(weight for _, weight in weighted) or factor_weight_total),
4,
) if weighted else None
size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc")
large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70]
small_rows = [
row for row in factors
if size_ranks.get(row["ts_code"]) is not None
and size_ranks[row["ts_code"]] <= 0.30
]
large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0
small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0
prefer_large = large_return >= small_return
growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70]
value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70]
growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0
value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0
prefer_growth = growth_return >= value_return
for row in factors:
size_rank = size_ranks.get(row["ts_code"])
row["style_size_fit"] = round(
size_rank if prefer_large else 1 - size_rank, 4
) if size_rank is not None else None
style_factor = "factor_growth_score" if prefer_growth else "factor_value_score"
row["style_growth_fit"] = row.get(style_factor)
style_values = [
value for value in (row.get("style_size_fit"), row.get("style_growth_fit"))
if value is not None
]
row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
return_ranks = _percentile_map(factors, "return_5d", "desc")
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0)
for row in factors:
row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4)
row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4)
is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height
row["is_market_height"] = int(is_height)
row["new_space_board"] = int(
is_height
and not (
prior_market_height >= 2
and int(row.get("prior_limit_streak") or 0) == prior_market_height
)
)
return factors, actual_date
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import copy
from typing import Any
from backend.features.screener.catalog import (
ALLOWED_OPERATORS,
BUILTIN_STRATEGIES,
FACTOR_FIELDS,
REGIMES,
)
from backend.features.screener.indicators import _matches, _percentile_map, _risk_flags
class FormulaEvaluator:
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
if not isinstance(formula, dict):
raise ValueError("选股公式必须是 JSON 对象。")
result = copy.deepcopy(formula)
universe = result.setdefault("universe", {})
universe["exclude_st"] = bool(universe.get("exclude_st", True))
universe["listed_days_min"] = max(0, min(5000, int(universe.get("listed_days_min", 120))))
filters = result.setdefault("filters", [])
if not isinstance(filters, list) or len(filters) > 20:
raise ValueError("筛选条件必须是列表,且不能超过 20 条。")
for condition in filters:
field = condition.get("field")
operator = condition.get("op")
if field not in FACTOR_FIELDS:
raise ValueError(f"不支持的选股因子:{field}")
if operator not in ALLOWED_OPERATORS:
raise ValueError(f"不支持的运算符:{operator}")
if "value" not in condition:
raise ValueError(f"因子 {field} 缺少比较值。")
scores = result.setdefault("score", [])
if not isinstance(scores, list) or not scores or len(scores) > 12:
raise ValueError("评分因子应为 1 至 12 条。")
for item in scores:
if item.get("field") not in FACTOR_FIELDS:
raise ValueError(f"不支持的评分因子:{item.get('field')}")
item["weight"] = float(item.get("weight", 0))
if item["weight"] <= 0 or item["weight"] > 1:
raise ValueError("评分权重必须大于 0 且不超过 1。")
if item.get("direction", "desc") not in {"asc", "desc"}:
raise ValueError("评分方向只能是 asc 或 desc。")
item["direction"] = item.get("direction", "desc")
result["limit"] = max(1, min(50, int(result.get("limit", 15))))
result["min_score"] = max(0, min(1, float(result.get("min_score", 0))))
return result
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
universe = formula["universe"]
eligible = []
score_fields = [item["field"] for item in formula["score"]]
for row in rows:
name = str(row.get("name") or "")
if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name):
continue
if row.get("listed_days", 0) < universe.get("listed_days_min", 0):
continue
if any(row.get(field) is None for field in score_fields):
continue
if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]):
eligible.append(row)
if not eligible:
return []
percentiles = {
item["field"]: _percentile_map(eligible, item["field"], item["direction"])
for item in formula["score"]
}
weight_total = sum(item["weight"] for item in formula["score"])
results = []
for row in eligible:
contributions = []
score = 0.0
for item in formula["score"]:
percentile = percentiles[item["field"]].get(row["ts_code"], 0.5)
points = percentile * item["weight"] / weight_total
score += points
contributions.append(
{
"field": item["field"],
"label": FACTOR_FIELDS[item["field"]],
"value": row.get(item["field"], 0),
"points": round(points * 100, 1),
}
)
if score < formula["min_score"]:
continue
contributions.sort(key=lambda item: item["points"], reverse=True)
item = dict(row)
item["score"] = round(score, 4)
item["score_display"] = round(score * 100, 1)
item["contributions"] = contributions
item["reason"] = "".join(entry["label"] for entry in contributions[:3])
include_regime_risk = formula.get("meta", {}).get("library") != "curated"
item["risk_flags"] = _risk_flags(row, regime, include_regime_risk)
results.append(item)
results.sort(key=lambda item: item["score"], reverse=True)
return results[: formula["limit"]]
def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]:
base = next((item for item in BUILTIN_STRATEGIES if regime in item["regimes"]), BUILTIN_STRATEGIES[1])
formula = copy.deepcopy(base["formula"])
description = prompt.strip() or base["description"]
lowered = description.lower()
if "低吸" in description:
formula["filters"] = [item for item in formula["filters"] if item["field"] != "pct_chg"]
formula["filters"].append({"field": "pct_chg", "op": "between", "value": [-3, 3]})
if "放量" in description:
formula["filters"].append({"field": "volume_ratio_5d", "op": ">=", "value": 1.2})
if "强势" in description or "突破" in description:
formula["filters"].append({"field": "return_5d", "op": ">=", "value": 5})
if "低波" in description or "稳健" in description:
formula["score"].append({"field": "volatility_10d", "weight": 0.18, "direction": "asc"})
if "资金" in description or "主力" in description:
formula["score"].append({"field": "net_flow_million", "weight": 0.18, "direction": "desc"})
if "小市值" in description or "小盘" in description:
formula["score"].append({"field": "circ_mv_billion", "weight": 0.15, "direction": "asc"})
if "竞价" in description:
formula["filters"].extend(
[
{"field": "auction_change", "op": "between", "value": [0.5, 8]},
{"field": "auction_amount_million", "op": ">=", "value": 2},
]
)
formula["score"].extend(
[
{"field": "auction_volume_ratio", "weight": 0.20, "direction": "desc"},
{"field": "auction_amount_million", "weight": 0.18, "direction": "desc"},
]
)
if "少量" in description or "精选" in description:
formula["limit"] = min(formula["limit"], 8)
formula["score"] = formula["score"][:12]
return {
"name": f"{REGIMES.get(regime, regime)}自定义策略",
"description": description,
"regimes": [regime],
"formula": formula,
"compiler": "local_template",
}
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import math
import statistics
from datetime import datetime
from typing import Any
from backend.data.numbers import finite_number as _number
def _optional_number(value: Any) -> float | None:
if value in (None, ""):
return None
try:
result = float(value)
except (TypeError, ValueError):
return None
return result if math.isfinite(result) else None
def _rounded_optional(value: Any, digits: int = 2) -> float | None:
parsed = _optional_number(value)
return round(parsed, digits) if parsed is not None else None
def _limit_threshold(code: str, name: str) -> float:
if code.startswith(("4", "8")):
return 29.0
if code.startswith(("30", "68")):
return 19.0
return 9.5
def _ending_streak(flags: list[bool], end_index: int | None = None) -> int:
if not flags:
return 0
index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1)
streak = 0
while index >= 0 and flags[index]:
streak += 1
index -= 1
return streak
def _max_streak(flags: list[bool]) -> int:
best = current = 0
for value in flags:
current = current + 1 if value else 0
best = max(best, current)
return best
def _rsi(values: list[float], period: int = 6) -> float:
if len(values) <= period:
return 50.0
changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))]
gains = sum(max(change, 0.0) for change in changes) / period
losses = sum(max(-change, 0.0) for change in changes) / period
if losses == 0:
return 100.0 if gains > 0 else 50.0
return 100 - 100 / (1 + gains / losses)
def _ema(values: list[float], period: int) -> list[float]:
if not values:
return []
alpha = 2 / (period + 1)
result = [values[0]]
for value in values[1:]:
result.append(value * alpha + result[-1] * (1 - alpha))
return result
def _macd_series(values: list[float]) -> tuple[list[float], list[float]]:
fast = _ema(values, 12)
slow = _ema(values, 26)
dif = [left - right for left, right in zip(fast, slow)]
return dif, _ema(dif, 9)
def _macd_last(values: list[float]) -> tuple[float, float]:
dif, dea = _macd_series(values)
return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0)
def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
weeks: dict[str, tuple[float, float]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
try:
key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V")
except ValueError:
continue
close = _number(row.get("close"))
amount = _number(row.get("amount"))
previous = weeks.get(key, (close, 0.0))
weeks[key] = (close, previous[1] + amount)
ordered = list(weeks.values())
return [item[0] for item in ordered], [item[1] for item in ordered]
def _broken_reversal_metrics(
rows: list[dict[str, Any]], flags: list[bool], code: str, name: str,
) -> dict[str, Any]:
result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0}
if not rows or not flags[-1]:
return result
current_close = _number(rows[-1].get("close"))
current_volume = _number(rows[-1].get("vol"))
for days in range(1, 4):
index = len(rows) - 1 - days
if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2:
continue
broken_high = _number(rows[index].get("high"))
broken_volume = _number(rows[index].get("vol"))
recovered = int(current_close >= broken_high > 0)
volume_ratio = current_volume / broken_volume if broken_volume else 0.0
return {
"signal": int(recovered and volume_ratio >= 1),
"days": days,
"recovered": recovered,
"volume_ratio": round(volume_ratio, 3),
}
return result
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index < 0 or index >= len(rows):
return False
return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name)
def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index <= 0 or index >= len(rows):
return False
previous_close = _number(rows[index - 1].get("close"))
high = _number(rows[index].get("high"))
if previous_close <= 0 or high <= 0:
return False
touched_change = (high / previous_close - 1) * 100
return touched_change >= _limit_threshold(code, name)
def _matches(actual: Any, operator: str, expected: Any) -> bool:
if actual is None:
return False
try:
if operator == "between":
return float(expected[0]) <= float(actual) <= float(expected[1])
if operator == "in":
return actual in expected
if operator == ">":
return float(actual) > float(expected)
if operator == ">=":
return float(actual) >= float(expected)
if operator == "<":
return float(actual) < float(expected)
if operator == "<=":
return float(actual) <= float(expected)
if operator == "==":
return actual == expected or float(actual) == float(expected)
if operator == "!=":
return actual != expected
except (TypeError, ValueError, IndexError):
return False
return False
def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]:
ordered = sorted(rows, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
result = {}
for index, row in enumerate(ordered):
percentile = index / denominator
result[row["ts_code"]] = 1 - percentile if direction == "asc" else percentile
return result
def _available_percentile_map(
rows: list[dict[str, Any]], field: str, direction: str,
) -> dict[str, float | None]:
available = [row for row in rows if row.get(field) is not None]
result: dict[str, float | None] = {
str(row.get("ts_code") or ""): None for row in rows
}
if not available:
return result
ordered = sorted(available, key=lambda item: _number(item.get(field)))
denominator = max(1, len(ordered) - 1)
for index, row in enumerate(ordered):
percentile = 0.5 if len(ordered) == 1 else index / denominator
result[str(row.get("ts_code") or "")] = (
1 - percentile if direction == "asc" else percentile
)
return result
def _pearson(first: list[float], second: list[float]) -> float:
if len(first) != len(second) or len(first) < 20:
return 0.0
first_mean = statistics.fmean(first)
second_mean = statistics.fmean(second)
numerator = sum(
(left - first_mean) * (right - second_mean)
for left, right in zip(first, second)
)
left_sum = sum((value - first_mean) ** 2 for value in first)
right_sum = sum((value - second_mean) ** 2 for value in second)
denominator = math.sqrt(left_sum * right_sum)
return numerator / denominator if denominator else 0.0
def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]:
flags = []
if row.get("pct_chg", 0) >= 9.5:
flags.append("当日接近涨停,次日存在高开与无法成交风险")
if row.get("return_10d", 0) >= 25:
flags.append("短期累计涨幅较高")
if row.get("volatility_10d", 0) >= 7:
flags.append("波动率偏高")
if row.get("amount_billion", 0) < 1:
flags.append("成交承载力偏弱")
if include_regime_risk and regime == "retreat":
flags.append("市场处于退潮阶段,策略可能选择空仓")
return flags
def _regime_reason(regime: str) -> str:
return {
"ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。",
"repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。",
"fermentation": "赚钱效应扩散,主线和梯队持续增强。",
"climax": "情绪处于高位,后排跟风与兑现风险同时上升。",
"divergence": "指数或核心仍强,但广度、封板质量开始分化。",
"retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。",
}.get(regime, "市场阶段待确认。")
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
def resolve_published_batch(
markers: list[dict[str, Any]],
requested_date: str,
legacy_date: str = "",
) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any] | None]:
requested_marker = next(
(
item for item in markers
if str(item.get("trade_date") or "") == requested_date
),
None,
)
published = next(
(item for item in markers if item.get("status") == "complete"),
None,
)
if published is None and legacy_date:
published = {
"trade_date": legacy_date,
"status": "complete",
"legacy_inferred": True,
"completed": [],
"skipped": [],
"failed": [],
}
request_status = dict(requested_marker or {})
request_status.setdefault("trade_date", requested_date)
request_status.setdefault("status", "pending")
descriptor = _published_descriptor(published, requested_date, request_status)
if descriptor and descriptor["is_fallback"]:
request_status["retaining_trade_date"] = descriptor["trade_date"]
return published, request_status, descriptor
def _published_descriptor(
marker: dict[str, Any] | None,
requested_date: str,
request_status: dict[str, Any],
) -> dict[str, Any] | None:
if marker is None:
return None
trade_date = str(marker.get("trade_date") or "")
is_fallback = trade_date != requested_date
status = str(request_status.get("status") or "pending")
notice = ""
if is_fallback:
if status == "running":
notice = "所选日期候选正在生成,当前保留上一成功批次"
elif status in {"failed", "partial"}:
notice = "所选日期候选未完整发布,当前保留上一成功批次"
else:
notice = "所选日期候选尚未发布,当前展示最近成功批次"
return {
"trade_date": trade_date,
"status": "complete",
"started_at": marker.get("started_at") or "",
"finished_at": marker.get("finished_at") or marker.get("updated_at") or "",
"library_version": int(marker.get("library_version") or 0),
"completed_count": len(marker.get("completed") or []),
"skipped_count": len(marker.get("skipped") or []),
"legacy_inferred": bool(marker.get("legacy_inferred")),
"is_fallback": is_fallback,
"notice": notice,
}
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.features.screener.catalog import REGIMES
from backend.features.screener.indicators import _regime_reason
from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history
from database import ReviewDatabase
class RegimeDetector:
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history(
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260))
)
if not series:
return {
"id": "repair", "label": REGIMES["repair"], "confidence": 25,
"reason": "复盘快照不足,暂按中性修复处理。", "evidence": [], "history": [],
}
current = series[-1]
previous = series[-2] if len(series) > 1 else current
score = _number(current.get("score"))
previous_score = _number(previous.get("score"))
delta = score - previous_score
seal_rate = _number(current.get("seal_rate"))
limit_up = _number(current.get("limit_up_count"))
broken = _number(current.get("broken_count"))
regime = next(
(key for key, label in REGIMES.items() if label == current.get("phase")),
"divergence",
)
confidence = min(92, 45 + len(series[-8:]) * 5 + min(abs(delta), 12))
evidence = [
f"情绪温度 {score:.0f},较前一交易日 {delta:+.0f}{current.get('direction') or '持平'}",
f"封板率 {seal_rate:.1f}%",
f"涨停 {limit_up:.0f} 家,炸板 {broken:.0f}",
]
return {
"id": regime,
"label": REGIMES[regime],
"confidence": round(confidence),
"reason": _regime_reason(regime),
"evidence": evidence,
"history": [
{"trade_date": item["trade_date"], "score": _number(item.get("score"))}
for item in series[-8:]
],
}
+96 -5
View File
@@ -433,11 +433,6 @@ class ScreenerRepositoryMixin:
}
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
try:
from sentiment_engine import build_sentiment_history
except ModuleNotFoundError:
from .sentiment_engine import build_sentiment_history
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
return [
{
@@ -691,6 +686,102 @@ class ScreenerRepositoryMixin:
result.append(payload)
return result
def screener_runs_for_dates(
self, user_id: int, trade_dates: list[str], limit: int = 1200,
) -> list[dict[str, Any]]:
normalized_dates = list(dict.fromkeys(str(item) for item in trade_dates if item))
if not normalized_dates:
return []
safe_limit = max(1, min(2400, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: list[Any] = [] if int(user_id) == 0 else [int(user_id)]
placeholders = ",".join("?" for _ in normalized_dates)
parameters.extend(normalized_dates)
parameters.append(safe_limit)
with self.connect() as connection:
rows = connection.execute(
f"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE {owner_clause} AND trade_date IN ({placeholders})
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
parameters,
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def recent_screener_runs(
self, user_id: int, trade_date: str, mode: str, limit: int = 40,
) -> list[dict[str, Any]]:
if int(user_id) == 0 or mode not in {"smart", "curated", "quant"}:
return []
safe_limit = max(1, min(160, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE user_id = ? AND trade_date <= ? AND mode = ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
(int(user_id), trade_date, mode, safe_limit),
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def list_screener_batch_markers(
self, end_date: str, limit: int = 30,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
SELECT cache_key, payload, updated_at
FROM data_snapshots
WHERE kind = 'screener_auto_v1' AND cache_key <= ?
ORDER BY cache_key DESC
LIMIT ?
""",
(end_date, safe_limit),
).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload"])
except json.JSONDecodeError:
continue
payload.setdefault("trade_date", str(row["cache_key"] or ""))
payload.setdefault("updated_at", str(row["updated_at"] or ""))
result.append(payload)
return result
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = (int(run_id),)
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import json
import re
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class ScreenerRoutesMixin:
def _handle_screener_get(self, parsed) -> bool:
if parsed.path == "/api/screener/setup":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
self.send_json(self.application_service.screener_setup(trade_date))
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/screener/tracking":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.screener_tracking(int(query.get("limit", ["12"])[0]))
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_screener_post(self, parsed) -> bool:
if parsed.path == "/api/screener/tracking":
try:
result = self.application_service.add_screener_tracking(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
def _handle_screener_delete(self, parsed) -> bool:
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
if strategy_match:
try:
result = self.application_service.delete_screener_strategy(int(strategy_match.group(1)))
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
if tracking_match:
result = self.application_service.remove_screener_tracking(int(tracking_match.group(1)))
self.send_json({"ok": True, **result})
return True
return False
def sync_screener_data(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.sync_screener_data(
str(body.get("trade_date") or date.today().isoformat()),
int(body.get("lookback") or 45),
)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"因子数据同步失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def compile_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.compile_screener_strategy(
str(body.get("prompt") or ""), str(body.get("regime") or "")
)
self.send_json({"ok": True, "strategy": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_screener_strategy(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.save_screener_strategy(body)
self.send_json({"ok": True, **result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def run_screener(self) -> None:
try:
body = self.read_json_body()
result = self.application_service.run_screener(body)
self.send_json({"ok": True, "result": result})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"选股执行失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def refresh_screener_tracking(self) -> None:
try:
body = self.read_json_body(True)
trade_date = str(body.get("trade_date") or date.today().isoformat())
self.send_json({"ok": True, **self.application_service.refresh_screener_tracking(trade_date)})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"跟踪刷新失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.features.screener.backtest import BacktestRunner
from backend.features.screener.catalog import REGIMES
from backend.features.screener.factors import FactorBuilder
from backend.features.screener.formula import FormulaEvaluator
from database import ReviewDatabase
class SelectionRunner:
def __init__(
self,
database: ReviewDatabase,
factor_builder: FactorBuilder,
formula_evaluator: FormulaEvaluator,
backtest_runner: BacktestRunner,
) -> None:
self.database = database
self.factor_builder = factor_builder
self.formula_evaluator = formula_evaluator
self.backtest_runner = backtest_runner
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
return self.formula_evaluator.validate_formula(formula)
def build_factors(
self,
trade_date: str,
realtime_snapshot: dict[str, Any] | None,
history_days: int,
) -> tuple[list[dict[str, Any]], str]:
return self.factor_builder.build_factors(
trade_date, realtime_snapshot, history_days
)
def apply_formula(
self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str
) -> list[dict[str, Any]]:
return self.formula_evaluator.apply_formula(rows, formula, regime)
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
return self.backtest_runner.backtest(trade_date, formula)
def screen(
self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str,
strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None,
mode: str = "smart",
prepared_factors: list[dict[str, Any]] | None = None,
prepared_date: str = "",
) -> dict[str, Any]:
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
formula = self.validate_formula(formula)
if prepared_factors is None:
history_days = int((formula.get("meta") or {}).get("history_days") or 80)
factors, actual_date = self.build_factors(
trade_date, realtime_snapshot, history_days
)
else:
factors = prepared_factors
actual_date = prepared_date or trade_date
candidates = self.apply_formula(factors, formula, regime)
backtest = self.backtest(actual_date, formula) if run_backtest else None
required_fields = sorted({
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
if item.get("field")
})
complete_rows = sum(
1 for row in factors
if all(row.get(field) is not None for field in required_fields)
)
coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0
health_status = "normal" if candidates else "no_signal"
if backtest and backtest["samples"] >= 20:
for candidate in candidates:
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
candidate["historical_probability"] = round(min(95, max(5, estimate)), 1)
candidate["probability_samples"] = backtest["samples"]
else:
for candidate in candidates:
candidate["historical_probability"] = None
candidate["probability_samples"] = backtest["samples"] if backtest else 0
result = {
"meta": {
"trade_date": _display_date(actual_date),
"regime": regime,
"regime_label": REGIMES.get(regime, regime),
"strategy_name": strategy_name,
"mode": mode,
"library_version": int(
(formula.get("meta") or {}).get("library_version") or 0
),
"universe_count": len(factors),
"candidate_count": len(candidates),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"health": {
"status": health_status,
"required_field_count": len(required_fields),
"complete_rows": complete_rows,
"universe_rows": len(factors),
"coverage": coverage,
"signal_count": len(candidates),
},
"selection_source": (
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
),
"realtime": bool(realtime_snapshot),
"history_cutoff": (
str(realtime_snapshot.get("previous_trade_date") or "")
if realtime_snapshot else actual_date
),
"factor_freshness": {
"realtime": [
"价格", "涨跌幅", "成交量", "成交额", "换手率",
"均线位置", "5/10日动量", "板块强度", "开盘竞价",
] if realtime_snapshot else [],
"historical": ["历史波动率", "流通市值", "资金流", "竞价因子", "回测"],
},
},
"formula": formula,
"candidates": candidates,
"backtest": backtest,
"disclaimer": (
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
if mode == "curated"
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
),
}
run_id = self.database.save_screener_run(
user_id, actual_date, regime, strategy_name, formula, result, mode
)
result["meta"]["run_id"] = run_id
return result
+98 -13
View File
@@ -12,12 +12,13 @@ from backend.features.screener.compiler import (
LLMCompilerError,
compile_strategy_with_llm,
)
from backend.features.screener.engine import (
FACTOR_FIELDS,
FACTOR_GROUPS,
REGIMES,
FactorDataService,
compile_local_strategy,
from backend.features.screener.catalog import FACTOR_FIELDS, FACTOR_GROUPS, REGIMES
from backend.features.screener.data_sync import FactorDataService
from backend.features.screener.formula import compile_local_strategy
from backend.features.screener.publication import resolve_published_batch
from backend.features.screener.signals import (
attach_strategy_validity,
build_candidate_archive,
)
@@ -95,26 +96,71 @@ class ScreenerServiceMixin:
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
attach_strategy_validity(strategy)
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
personal_results = self.database.screener_runs_for_date(
self.current_user_id, normalized_date
batch_markers = self.database.list_screener_batch_markers(normalized_date, 120)
complete_markers = [
item for item in batch_markers if item.get("status") == "complete"
][:30]
legacy_results = []
legacy_date = ""
if not batch_markers:
legacy_results = self.database.screener_runs_for_date(0, normalized_date)
if legacy_results:
legacy_date = normalized_date
published_marker, automatic_status, published_batch = resolve_published_batch(
batch_markers, normalized_date, legacy_date
)
published_date = str((published_batch or {}).get("trade_date") or "")
automatic_results = (
legacy_results
if legacy_results and published_date == normalized_date
else self.database.screener_runs_for_date(0, published_date)
if published_date
else []
)
personal_results = self.database.recent_screener_runs(
self.current_user_id, normalized_date, "quant", 40
)
recent_results = [
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
*personal_results,
]
latest_results: dict[str, dict[str, Any]] = {}
for result in reversed(recent_results):
mode = str(result.get("meta", {}).get("mode") or "smart")
latest_results[mode] = result
automatic_status = self.database.get_data_snapshot(
"screener_auto_v1", normalized_date
) or {}
published_dates = [
str(item.get("trade_date") or "") for item in complete_markers
if item.get("trade_date")
]
if legacy_date and legacy_date not in published_dates:
published_dates.append(legacy_date)
archive_runs = self.database.screener_runs_for_dates(0, published_dates, 1800)
archive_runs.extend(personal_results)
archive_as_of_date = published_date or (factor_dates[-1] if factor_dates else "")
marker_regime = (published_marker or {}).get("regime") or {}
archive_regime = str(
(marker_regime.get("id") if isinstance(marker_regime, dict) else marker_regime)
or regime.get("id") or "repair"
)
active_signals, candidate_history = build_candidate_archive(
archive_runs,
strategies,
factor_dates,
archive_as_of_date,
archive_regime,
)
self._attach_published_strategy_status(
strategies, automatic_results, published_marker, published_date
)
return {
"trade_date": normalized_date,
"requested_trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": strategies,
@@ -145,10 +191,49 @@ class ScreenerServiceMixin:
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
"published_batch": published_batch,
"published_status": published_marker or {},
"active_signals": active_signals,
"candidate_history": candidate_history,
# Kept during the client transition for compatibility with older frontends.
"latest_result": latest_results.get("smart"),
}
@staticmethod
def _attach_published_strategy_status(
strategies: list[dict[str, Any]],
automatic_results: list[dict[str, Any]],
marker: dict[str, Any] | None,
published_date: str,
) -> None:
results_by_name = {
str((item.get("meta") or {}).get("strategy_name") or ""): item
for item in automatic_results
}
skipped_by_name = {
str(item.get("name") or ""): item
for item in (marker or {}).get("skipped") or []
}
for strategy in strategies:
name = str(strategy.get("name") or "")
result = results_by_name.get(name)
skipped = skipped_by_name.get(name)
if result is not None:
candidates = result.get("candidates") or []
status = "ready" if candidates else "no_signal"
detail = f"{len(candidates)} 只候选" if candidates else "数据完整,暂无符合条件个股"
elif skipped is not None:
status = "missing_data"
detail = str(skipped.get("reason") or "缺少策略必需数据")
else:
status = "not_run"
detail = "该成功批次未运行此策略"
strategy["published_run"] = {
"trade_date": published_date,
"status": status,
"detail": detail,
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
from typing import Any
FREQUENCY_VALIDITY_DAYS = {
"每日": 1,
"每日9:25": 1,
"每周": 5,
"双周": 10,
"月度": 20,
"事件驱动": 5,
}
def signal_validity(mode: str, formula: dict[str, Any] | None) -> dict[str, Any]:
if mode == "smart":
return {
"type": "until_regime_change",
"label": "当前阶段不变时有效",
}
meta = (formula or {}).get("meta") or {}
frequency = str(meta.get("frequency") or "每日")
days = FREQUENCY_VALIDITY_DAYS.get(frequency, 1)
return {
"type": "trading_days",
"days": days,
"label": f"{days}个交易日",
}
def attach_strategy_validity(strategy: dict[str, Any]) -> None:
formula = strategy.get("formula") or {}
meta = formula.setdefault("meta", {})
mode = "curated" if meta.get("library") == "curated" else "smart"
meta["signal_validity"] = signal_validity(mode, formula)
def build_candidate_archive(
runs: list[dict[str, Any]],
strategies: list[dict[str, Any]],
trading_dates: list[str],
as_of_date: str,
as_of_regime: str,
history_limit: int = 1200,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
strategy_formulas = {
str(item.get("name") or ""): item.get("formula") or {}
for item in strategies
}
date_positions = {trade_date: index for index, trade_date in enumerate(trading_dates)}
as_of_position = date_positions.get(as_of_date, len(trading_dates) - 1)
history: dict[tuple[str, str, str], dict[str, Any]] = {}
active: dict[tuple[str, str], dict[str, Any]] = {}
ordered_runs = sorted(
runs,
key=lambda item: (
str((item.get("meta") or {}).get("trade_date") or ""),
int((item.get("meta") or {}).get("run_id") or 0),
),
reverse=True,
)
for result in ordered_runs:
meta = result.get("meta") or {}
mode = str(meta.get("mode") or "smart")
if mode not in {"smart", "curated", "quant"}:
continue
selection_date = str(meta.get("trade_date") or "").replace("-", "")
strategy_name = str(meta.get("strategy_name") or "未命名策略")
regime = str(meta.get("regime") or "")
formula = result.get("formula") or strategy_formulas.get(strategy_name) or {}
validity = signal_validity(mode, formula)
valid, valid_until, remaining = _signal_state(
validity,
selection_date,
regime,
trading_dates,
date_positions,
as_of_position,
as_of_regime,
)
hit = {
"selection_date": selection_date,
"strategy_name": strategy_name,
"regime": regime,
"run_id": int(meta.get("run_id") or 0),
"validity": validity,
"valid_until": valid_until,
"remaining_trading_days": remaining,
"active": valid,
}
for candidate in result.get("candidates") or []:
code = str(candidate.get("code") or "")
if not code:
continue
history_key = (mode, selection_date, code)
history_row = history.setdefault(
history_key,
_archive_row(candidate, mode, selection_date),
)
candidate_hit = {**hit, "score_display": candidate.get("score_display")}
_append_hit(history_row, candidate_hit)
if valid:
active_key = (mode, code)
active_row = active.get(active_key)
if active_row is None:
active_row = _archive_row(candidate, mode, selection_date)
active[active_key] = active_row
_append_hit(active_row, candidate_hit)
history_rows = sorted(
history.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)[: max(1, int(history_limit))]
active_rows = sorted(
active.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)
for row in [*history_rows, *active_rows]:
_finalize_archive_row(row)
return active_rows, history_rows
def _signal_state(
validity: dict[str, Any],
selection_date: str,
regime: str,
trading_dates: list[str],
date_positions: dict[str, int],
as_of_position: int,
as_of_regime: str,
) -> tuple[bool, str, int | None]:
if validity.get("type") == "until_regime_change":
return regime == as_of_regime, "", None
days = max(1, int(validity.get("days") or 1))
selected_position = date_positions.get(selection_date)
if selected_position is None or as_of_position < selected_position:
return False, "", 0
elapsed = as_of_position - selected_position
valid = elapsed < days
valid_position = selected_position + days - 1
valid_until = (
trading_dates[valid_position]
if 0 <= valid_position < len(trading_dates)
else ""
)
return valid, valid_until, max(0, days - elapsed) if valid else 0
def _archive_row(
candidate: dict[str, Any], mode: str, selection_date: str
) -> dict[str, Any]:
return {
"mode": mode,
"selection_date": selection_date,
"code": str(candidate.get("code") or ""),
"name": str(candidate.get("name") or ""),
"sector": str(candidate.get("sector") or ""),
"score_display": candidate.get("score_display"),
"pct_chg": candidate.get("pct_chg"),
"return_5d": candidate.get("return_5d"),
"hits": [],
}
def _append_hit(row: dict[str, Any], hit: dict[str, Any]) -> None:
identity = (hit["strategy_name"], hit["regime"], hit["run_id"])
existing = {
(item["strategy_name"], item["regime"], item["run_id"])
for item in row["hits"]
}
if identity not in existing:
row["hits"].append(dict(hit))
def _finalize_archive_row(row: dict[str, Any]) -> None:
hits = row.get("hits") or []
active_hits = [item for item in hits if item.get("active")]
row["matched_strategies"] = list(
dict.fromkeys(item["strategy_name"] for item in hits)
)
row["regimes"] = list(dict.fromkeys(item["regime"] for item in hits if item["regime"]))
row["active"] = bool(active_hits)
row["status"] = "持续有效" if active_hits else "已到期"
labels = list(
dict.fromkeys(item["validity"]["label"] for item in (active_hits or hits))
)
row["validity_label"] = " / ".join(labels)
def _numeric_score(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return -1.0
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
class SentimentRoutesMixin:
def _handle_sentiment_get(self, parsed) -> bool:
if parsed.path == "/api/sentiment/history":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
try:
limit = int(query.get("limit", ["20"])[0])
self.send_json(self.application_service.sentiment_history(trade_date, limit))
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
class SystemRoutesMixin:
def _handle_system_public_get(self, parsed) -> bool:
if parsed.path == "/api/health":
self.send_json(
{
"ok": True,
"storage": "sqlite",
"account_required": True,
"time": datetime.now().astimezone().isoformat(timespec="seconds"),
}
)
return True
return False
def _handle_system_get(self, parsed) -> bool:
if parsed.path == "/api/admin/settings":
self.send_json(
{"ok": True, **self.application_service.system_status(), "users": self.application_service.admin_users()}
)
return True
return False
def backfill_data(self) -> None:
try:
body = self.read_json_body()
results = self.application_service.backfill(
str(body.get("start_date") or ""),
str(body.get("end_date") or ""),
)
self.send_json({"ok": True, "results": results})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc:
self.send_json({"error": f"历史回补失败:{exc}"}, HTTPStatus.INTERNAL_SERVER_ERROR)
+254
View File
@@ -0,0 +1,254 @@
from __future__ import annotations
import re
import secrets
from typing import Any
from backend.bootstrap.config import TOKEN_PATTERN, validate_text
class SystemServiceMixin:
def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]:
encrypted = self.database.get_system_setting("credentials")
current = self.vault.decrypt_json(encrypted) if encrypted else {}
changed = False
first_user_id = self.database.first_user_id()
first_personal: dict[str, Any] = {}
if first_user_id:
first_encrypted = self.database.get_user_credentials(first_user_id)
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
"platform_llm_fallback_api_key": environment.get("platform_llm_fallback_api_key") or first_personal.get("llm_fallback_api_key") or "",
"platform_llm_fallback_base_url": environment.get("platform_llm_fallback_base_url") or first_personal.get("llm_fallback_base_url") or "",
"platform_llm_fallback_model": environment.get("platform_llm_fallback_model") or first_personal.get("llm_fallback_model") or "",
"member_daily_limit": 50,
"background_refresh_enabled": True,
}
for key, value in defaults.items():
if key not in current:
current[key] = value
changed = True
if not isinstance(current.get("llm_models"), list):
migrated_models: list[dict[str, str]] = []
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
profile = {
"api_key": str(current.get(f"platform_llm_{role}_api_key") or ""),
"base_url": str(current.get(f"platform_llm_{role}_base_url") or ""),
"model": str(current.get(f"platform_llm_{role}_model") or ""),
}
if profile["api_key"] or profile["model"]:
model_id = f"migrated-{role}"
migrated_models.append(
{"id": model_id, "name": label, **profile}
)
current[f"{role}_model_id"] = model_id
current["llm_models"] = migrated_models
current.setdefault("primary_model_id", "")
current.setdefault("fallback_model_id", "")
changed = True
if changed or not encrypted:
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
for row in self.database.list_user_credentials():
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
if "tushare_token" in personal:
personal.pop("tushare_token", None)
self.database.save_user_credentials(
int(row["user_id"]), self.vault.encrypt_json(personal)
)
return current
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property
def configured(self) -> bool:
return bool(self.token)
def _credentials(self) -> dict[str, str]:
credentials = getattr(self._request_context, "credentials", {})
return {
"llm_primary_api_key": str(credentials.get("llm_primary_api_key") or ""),
"llm_primary_base_url": str(
credentials.get("llm_primary_base_url") or "https://api.openai.com/v1"
),
"llm_primary_model": str(credentials.get("llm_primary_model") or ""),
"llm_fallback_api_key": str(credentials.get("llm_fallback_api_key") or ""),
"llm_fallback_base_url": str(credentials.get("llm_fallback_base_url") or ""),
"llm_fallback_model": str(credentials.get("llm_fallback_model") or ""),
}
def _save_credentials(self, credentials: dict[str, str]) -> None:
self.database.save_user_credentials(
self.current_user_id,
self.vault.encrypt_json(credentials),
)
self._request_context.credentials = dict(credentials)
@property
def token(self) -> str:
return str(self._system_credentials.get("tushare_token") or "")
def system_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
model_pool = []
for item in self._system_credentials.get("llm_models") or []:
if not isinstance(item, dict):
continue
profile = {
"api_key": str(item.get("api_key") or ""),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
}
model_pool.append(
{
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"base_url": profile["base_url"],
"model": profile["model"],
"configured": self._profile_configured(profile),
}
)
return {
"data": {
"configured": self.configured,
"ifind": self.ifind.status(),
"background_refresh_enabled": bool(
self._system_credentials.get("background_refresh_enabled", True)
),
**self.database.status(),
"jobs": self.jobs.repository.recent(12),
},
"llm": {
"primary_configured": self._profile_configured(platform["primary"]),
"fallback_configured": self._profile_configured(platform["fallback"]),
"models": model_pool,
"primary_model_id": str(self._system_credentials.get("primary_model_id") or ""),
"fallback_model_id": str(self._system_credentials.get("fallback_model_id") or ""),
},
"membership": {
"member_daily_limit": max(
1, int(self._system_credentials.get("member_daily_limit") or 50)
)
},
}
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
current = dict(self._system_credentials)
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
if isinstance(item, dict) and item.get("id")
}
raw_models = payload.get("models")
models: list[dict[str, str]] = []
if raw_models is not None:
if not isinstance(raw_models, list) or len(raw_models) > 20:
raise ValueError("模型池格式不正确,最多可保存 20 个模型。")
seen_ids: set[str] = set()
seen_names: set[str] = set()
for index, raw in enumerate(raw_models, start=1):
if not isinstance(raw, dict):
raise ValueError("模型池条目格式不正确。")
model_id = str(raw.get("id") or f"model-{secrets.token_hex(6)}").strip()
if not re.fullmatch(r"[A-Za-z0-9_-]{3,80}", model_id) or model_id in seen_ids:
raise ValueError("模型 ID 不正确或重复。")
name = validate_text(raw.get("name"), f"模型 {index} 名称", 50, required=True)
normalized_name = name.casefold()
if normalized_name in seen_names:
raise ValueError("模型名称不能重复。")
profile = self._validate_llm_profile(
raw,
existing_models.get(model_id) or {},
required=True,
label=name,
)
models.append({"id": model_id, "name": name, **profile})
seen_ids.add(model_id)
seen_names.add(normalized_name)
else:
models = [dict(item) for item in existing_models.values()]
model_ids = {item["id"] for item in models}
primary_model_id = str(
payload.get("primary_model_id", current.get("primary_model_id") or "") or ""
).strip()
fallback_model_id = str(
payload.get("fallback_model_id", current.get("fallback_model_id") or "") or ""
).strip()
if models and primary_model_id not in model_ids:
raise ValueError("请从模型池选择主模型。")
if not models:
primary_model_id = ""
fallback_model_id = ""
if fallback_model_id and fallback_model_id not in model_ids:
raise ValueError("辅助模型不在模型池中。")
if fallback_model_id and fallback_model_id == primary_model_id:
raise ValueError("主模型与辅助模型不能相同。")
try:
daily_limit = max(
1,
min(
1000,
int(payload.get("member_daily_limit", current.get("member_daily_limit") or 50)),
),
)
except (TypeError, ValueError) as exc:
raise ValueError("会员每日额度应为 1 至 1000。") from exc
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
"member_daily_limit": daily_limit,
"background_refresh_enabled": bool(
payload.get(
"background_refresh_enabled",
current.get("background_refresh_enabled", True),
)
),
}
)
self._save_system_credentials(current)
return self.system_status()
def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status()
return {
"configured": self.configured,
"mode": "tushare" if self.configured else "unavailable",
"llm_configured": self.llm_configured,
"llm_model": self.llm_primary_model if self.llm_configured else "",
"llm_fallback_configured": self.llm_fallback_configured,
"llm_fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
"llm_access": llm_access,
"birth_profile_configured": bool(self.stored_birth_profile()),
"birth_profile": self.stored_birth_profile(),
**self.database.status(),
}
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from datetime import date
from http import HTTPStatus
from urllib.parse import parse_qs
from backend.data.providers.tushare_client import TushareError
class ThemeRoutesMixin:
def _handle_themes_get(self, parsed) -> bool:
if parsed.path == "/api/themes":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.theme_library(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("force", ["0"])[0] == "1",
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
if parsed.path == "/api/themes/detail":
query = parse_qs(parsed.query)
try:
self.send_json(
self.application_service.theme_detail(
query.get("code", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
)
except (ValueError, TushareError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return True
return False
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from http import HTTPStatus
from urllib.parse import urlparse
PUBLIC_POST_HANDLERS = {
"/api/auth/register": "auth_register",
"/api/auth/login": "auth_login",
}
AUTHENTICATED_POST_HANDLERS = {
"/api/auth/logout": "auth_logout",
"/api/account/birth-profile": "save_birth_profile",
"/api/account/password": "change_password",
"/api/alerts": "save_alert",
"/api/trades": "save_trade_entry",
"/api/assistant/chat": "stream_assistant_chat",
"/api/admin/settings": "save_system_settings",
"/api/admin/settings/test": "test_system_llm_settings",
"/api/admin/membership": "save_membership",
"/api/admin/refresh": "start_background_refresh",
"/api/watchlist": "save_watchlist",
"/api/notes": "save_note",
"/api/reasons": "save_reason",
"/api/seat-aliases": "save_seat_alias",
"/api/heaven/sector-phases": "save_sector_phase_override",
"/api/backfill": "backfill_data",
"/api/screener/sync": "sync_screener_data",
"/api/screener/compile": "compile_screener_strategy",
"/api/screener/strategies": "save_screener_strategy",
"/api/screener/run": "run_screener",
"/api/screener/tracking/refresh": "refresh_screener_tracking",
"/api/mentors/chat": "stream_mentor_chat",
"/api/heaven/hexagram": "heaven_hexagram",
"/api/heaven/personal": "heaven_personal",
"/api/heaven/interpret": "heaven_interpret",
}
class ApplicationHttpDispatchMixin:
def _dispatch_named_handler(self, path: str, handlers: dict[str, str]) -> bool:
handler_name = handlers.get(path)
if handler_name is None:
return False
getattr(self, handler_name)()
return True
def do_GET(self) -> None:
parsed = urlparse(self.path)
if self._handle_system_public_get(parsed):
return
if self._handle_accounts_public_get(parsed):
return
if parsed.path.startswith("/api/"):
if not self.require_auth():
return
if not self.require_access("GET", parsed.path):
return
for handler in (
self._handle_system_get,
self._handle_accounts_get,
self._handle_alerts_get,
self._handle_review_get,
self._handle_market_get,
self._handle_auction_get,
self._handle_themes_get,
self._handle_popularity_get,
self._handle_sentiment_get,
self._handle_rotation_get,
self._handle_dragon_tiger_get,
self._handle_screener_get,
self._handle_mentor_get,
self._handle_heaven_get,
):
if handler(parsed):
return
self.serve_static(parsed.path)
def do_POST(self) -> None:
parsed = urlparse(self.path)
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
return
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("POST", parsed.path):
return
if self._dispatch_named_handler(parsed.path, AUTHENTICATED_POST_HANDLERS):
return
for handler in (
self._handle_alerts_post,
self._handle_screener_post,
self._handle_mentor_post,
):
if handler(parsed):
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
def do_DELETE(self) -> None:
parsed = urlparse(self.path)
if not self.require_auth() or not self.require_csrf():
return
if not self.require_access("DELETE", parsed.path):
return
for handler in (
self._handle_accounts_delete,
self._handle_review_delete,
self._handle_mentor_delete,
self._handle_screener_delete,
self._handle_alerts_delete,
self._handle_heaven_delete,
):
if handler(parsed):
return
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
+36
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import mimetypes
import secrets
from collections.abc import Iterable
from http import HTTPStatus
from http.cookies import SimpleCookie
from typing import Any
@@ -103,6 +104,13 @@ class HttpTransportMixin:
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if candidate.is_dir():
candidate = (candidate / "index.html").resolve()
try:
candidate.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
try:
@@ -142,5 +150,33 @@ class HttpTransportMixin:
self.end_headers()
self.wfile.write(content)
def _write_stream_event(self, payload: dict[str, Any]) -> None:
self.wfile.write(
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
)
self.wfile.flush()
def send_ndjson_stream(
self,
events: Iterable[dict[str, Any]],
error_types: tuple[type[Exception], ...],
) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for event in events:
self._write_stream_event(event)
self._write_stream_event({"type": "done"})
except error_types as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def log_message(self, format_string: str, *args: Any) -> None:
print(f"[{self.log_date_time_string()}] {format_string % args}")
+37 -19
View File
@@ -18,6 +18,9 @@ class InProcessJobRunner:
self.repository = repository
self._locks: dict[str, threading.Lock] = {}
self._locks_guard = threading.Lock()
self._scheduler_guard = threading.Lock()
self._scheduler_stop = threading.Event()
self._scheduler_thread: threading.Thread | None = None
def submit(
self, job_id: str, idempotency_key: str, action: JobAction,
@@ -52,27 +55,42 @@ class InProcessJobRunner:
return True
def start_scheduler(
self, callback: Callable[[], None], stop_event: threading.Event,
interval_seconds: float, initial_delay_seconds: float = 0,
self, callback: Callable[[], None], interval_seconds: float,
initial_delay_seconds: float = 0,
) -> threading.Thread:
def schedule_loop() -> None:
if stop_event.wait(initial_delay_seconds):
return
while not stop_event.is_set():
try:
callback()
except Exception:
# Submitted jobs persist their own failures; the scheduler must stay alive.
pass
stop_event.wait(interval_seconds)
with self._scheduler_guard:
current = self._scheduler_thread
if current is not None and current.is_alive():
return current
self._scheduler_stop.clear()
thread = threading.Thread(
target=schedule_loop,
name="background-job-scheduler",
daemon=True,
)
thread.start()
return thread
def schedule_loop() -> None:
if self._scheduler_stop.wait(initial_delay_seconds):
return
while not self._scheduler_stop.is_set():
try:
callback()
except Exception:
# Submitted jobs persist failures; the scheduler must stay alive.
pass
self._scheduler_stop.wait(interval_seconds)
thread = threading.Thread(
target=schedule_loop,
name="background-job-scheduler",
daemon=True,
)
self._scheduler_thread = thread
thread.start()
return thread
def stop_scheduler(self, timeout_seconds: float = 5) -> bool:
with self._scheduler_guard:
thread = self._scheduler_thread
self._scheduler_stop.set()
if thread is not None and thread is not threading.current_thread():
thread.join(max(0, timeout_seconds))
return thread is None or not thread.is_alive()
def wait_for_idle(self, timeout_seconds: float = 5) -> bool:
deadline = time.monotonic() + max(0, timeout_seconds)
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import threading
import time
from datetime import date
from backend.bootstrap.config import normalize_date
class JobServiceMixin:
def start_background_jobs(self) -> threading.Thread:
return self.jobs.start_scheduler(
self._background_refresh_tick,
interval_seconds=5,
initial_delay_seconds=3,
)
def stop_background_jobs(self, timeout_seconds: float = 5) -> bool:
scheduler_stopped = self.jobs.stop_scheduler(timeout_seconds)
workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
return scheduler_stopped and workers_stopped
def request_background_sync(self, trade_date: str) -> bool:
normalized = normalize_date(trade_date)
key = f"manual:{normalized}:{time.time_ns()}"
return self.jobs.submit(
"market.refresh",
key,
lambda: self.sync_dashboard(normalized),
{"trade_date": normalized, "trigger": "administrator"},
)
def _background_refresh_tick(self) -> None:
if not (
self.configured
and self._system_credentials.get("background_refresh_enabled", True)
):
return
today = date.today().strftime("%Y%m%d")
snapshot = self.database.get_snapshot(today) or {}
if self._realtime_snapshot_due(today, snapshot):
bucket = int(time.time() // 5)
self.jobs.submit(
"market.refresh",
f"realtime:{today}:{bucket}",
lambda: self.sync_dashboard(today),
{"trade_date": today, "trigger": "realtime-poll"},
)
self._schedule_automatic_screeners(today, snapshot)
+1
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
from backend.bootstrap.config import validate_text
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
-7
View File
@@ -1,7 +0,0 @@
"""Compatibility alias for the canonical market chart clients."""
import sys
from backend.features.market import charts as _implementation
sys.modules[__name__] = _implementation
+2 -2
View File
@@ -1,6 +1,6 @@
# Governance Registries
These registries describe the approved product surface of the modular preservation candidate.
These registries describe the approved product surface of the standalone application.
- `pages.config.json`: primary page identity, navigation group, access expectation, scrolling,
and mobile composition policy.
@@ -8,7 +8,7 @@ These registries describe the approved product surface of the modular preservati
- `api.config.json`: current routes generated from the preserved `server.py` API surface, with
one feature owner and backend access class per route. Its dispatcher implementation lives in
`backend/application.py`.
- `architecture-inventory.json`: generated inventory of candidate pages, routes, tables,
- `architecture-inventory.json`: generated inventory of current pages, routes, tables,
providers, model entry points, CSS layers, and remaining code hotspots.
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
known blocked datasets.
+573 -71
View File
@@ -13,7 +13,8 @@
"api_exact_paths": 53,
"api_prefixes": 0,
"api_patterns": 11,
"database_tables": 36
"database_tables": 36,
"frontend_page_fragments": 12
},
"pages": [
{
@@ -197,7 +198,7 @@
{
"provider": "tushare",
"path": "backend/data/providers/tushare_client.py",
"runtime_role": "primary deterministic market data"
"runtime_role": "stable client facade for primary deterministic market data"
},
{
"provider": "ifind",
@@ -220,6 +221,53 @@
"runtime_role": "index observation fallback"
}
],
"provider_domains": [
{
"provider": "tushare",
"path": "backend/data/providers/tushare_transport.py",
"responsibility": "HTTP transport and provider errors"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_dashboard.py",
"responsibility": "market overview and realtime breadth"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_indices.py",
"responsibility": "market indices"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_industries.py",
"responsibility": "Shenwan membership and industry snapshots"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_sectors.py",
"responsibility": "generic sector snapshots"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_dragon_tiger.py",
"responsibility": "hot-money directory and dragon-tiger activity"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_stocks.py",
"responsibility": "stock detail and intraday bars"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_daily.py",
"responsibility": "trading calendar, daily bars, and limit lists"
},
{
"provider": "tushare",
"path": "backend/data/providers/tushare_helpers.py",
"responsibility": "shared deterministic normalization helpers"
}
],
"provider_construction": [
{
"client": "TushareClient",
@@ -239,6 +287,30 @@
"owner": "backend/data/gateway.py"
}
],
"heaven_service_owners": {
"facade": "backend/features/heaven/service.py",
"manual_validation_and_safety": "backend/features/heaven/manual.py",
"trend_orchestration_and_quality": "backend/features/heaven/trend.py",
"market_context": "backend/features/heaven/market_context.py",
"readings_and_interpretation": "backend/features/heaven/readings.py"
},
"market_insight_owners": {
"facade": "backend/features/market/insights.py",
"shared_context": "backend/features/market/insights_context.py",
"auction_scoring": "backend/features/market/insights_auction_scoring.py",
"auction_data": "backend/features/market/insights_auction_data.py",
"auction_orchestration": "backend/features/market/insights_auction.py",
"themes": "backend/features/market/insights_themes.py",
"popularity": "backend/features/market/insights_popularity.py"
},
"application_owners": {
"composition_root": "backend/application.py",
"http_dispatch": "backend/http/dispatch.py",
"system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py",
"feature_routes": "backend/features/*/routes.py"
},
"numeric_normalization": [
{
"function": "finite_number",
@@ -287,95 +359,525 @@
"path": "backend/llm/transport.py"
}
],
"css_layers": [
"/shared/tokens.css?v=20260729-1",
"/styles/styles.css",
"/styles/renovation.css?v=20260725-5",
"/styles/redesign-v2.css?v=20260728-1",
"/styles/design-system.css?v=20260728-4",
"/styles/theme.css?v=20260728-2",
"/pages/heaven/page.css?v=20260728-7"
"http_transport": [
{
"function": "send_json",
"path": "backend/http/handler.py"
},
{
"function": "send_ndjson_stream",
"path": "backend/http/handler.py"
}
],
"css_layers": [
"/shared/tokens.css?v=20260820-3",
"/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260820-8",
"/shared/auth.css?v=20260820-5",
"/shared/components/controls.css?v=20260820-2",
"/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1",
"/shared/components/tables.css?v=20260820-1",
"/shared/components/dialogs.css?v=20260820-3",
"/shared/components/feedback.css?v=20260806-1",
"/pages/market/foundation.css?v=20260820-4",
"/pages/sentiment/foundation.css?v=20260820-2",
"/pages/pools/foundation.css?v=20260820-1",
"/pages/ladder/foundation.css?v=20260820-1",
"/pages/rotation/foundation.css?v=20260820-1",
"/pages/auction/foundation.css?v=20260820-1",
"/pages/themes/foundation.css?v=20260820-1",
"/pages/popularity/foundation.css?v=20260820-1",
"/pages/dragon-tiger/foundation.css?v=20260820-1",
"/pages/screener/foundation.css?v=20260820-4",
"/pages/mentor/foundation.css?v=20260820-2",
"/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260820-4"
],
"frontend_composition": {
"shell": "frontend/index.html",
"bootstrap": "frontend/bootstrap.js",
"registry": "frontend/pages.config.js",
"startup": "frontend/app.js",
"runtime_owners": {
"context": "frontend/shared/context.js",
"application": "frontend/shared/application.js",
"feedback": "frontend/shared/feedback.js",
"dashboard": "frontend/shared/dashboard.js",
"session": "frontend/shared/session.js",
"admin": "frontend/shared/admin.js",
"theme": "frontend/shared/theme.js",
"table": "frontend/shared/table.js"
},
"market_runtime_owners": {
"breadth": "frontend/pages/market/breadth.js",
"charts": "frontend/pages/market/charts.js",
"entity_detail": "frontend/pages/market/entity-detail.js",
"stock_detail": "frontend/pages/market/stock-detail.js",
"preview": "frontend/pages/market/preview.js",
"search": "frontend/pages/market/search.js",
"bindings": "frontend/pages/market/bindings.js"
},
"fragments": [
"/pages/pools/page.html",
"/pages/sentiment/page.html",
"/pages/heaven/page.html",
"/pages/ladder/page.html",
"/pages/screener/page.html",
"/pages/mentor/page.html",
"/pages/rotation/page.html",
"/pages/auction/page.html",
"/pages/themes/page.html",
"/pages/popularity/page.html",
"/pages/dragon-tiger/page.html",
"/pages/review/page.html"
]
},
"code_hotspots": [
{
"path": "frontend/styles/styles.css",
"bytes": 361776,
"lines": 15465
"path": "frontend/pages/heaven/foundation.css",
"bytes": 185936,
"lines": 11734
},
{
"path": "frontend/styles/redesign-v2.css",
"bytes": 263539,
"lines": 8570
},
{
"path": "frontend/index.html",
"bytes": 135019,
"lines": 1892
},
{
"path": "backend/features/screener/engine.py",
"bytes": 108387,
"lines": 2203
},
{
"path": "backend/data/providers/tushare_client.py",
"bytes": 94124,
"lines": 2165
},
{
"path": "frontend/app.js",
"bytes": 89213,
"lines": 1939
"path": "frontend/pages/screener/foundation.css",
"bytes": 103547,
"lines": 6576
},
{
"path": "frontend/pages/heaven/page.js",
"bytes": 86493,
"lines": 1830
"bytes": 97189,
"lines": 2069
},
{
"path": "frontend/styles/renovation.css",
"bytes": 83949,
"lines": 1553
},
{
"path": "frontend/pages/heaven/page.css",
"bytes": 73222,
"lines": 1084
},
{
"path": "backend/features/heaven/service.py",
"bytes": 63123,
"lines": 1303
},
{
"path": "backend/features/market/insights.py",
"bytes": 57998,
"lines": 1307
},
{
"path": "frontend/pages/market/runtime.js",
"bytes": 55720,
"lines": 1333
"path": "frontend/shared/shell.css",
"bytes": 63550,
"lines": 3757
},
{
"path": "backend/features/heaven/engine.py",
"bytes": 51670,
"lines": 1181
"bytes": 51764,
"lines": 1183
},
{
"path": "backend/application.py",
"bytes": 48749,
"lines": 1129
"path": "frontend/index.html",
"bytes": 47131,
"lines": 646
},
{
"path": "frontend/styles/theme.css",
"bytes": 36427,
"lines": 1253
"path": "backend/features/screener/catalog.py",
"bytes": 35571,
"lines": 711
},
{
"path": "frontend/pages/auction/foundation.css",
"bytes": 35247,
"lines": 2416
},
{
"path": "database.py",
"bytes": 33284,
"lines": 746
"bytes": 32073,
"lines": 716
},
{
"path": "backend/features/screener/factors.py",
"bytes": 31756,
"lines": 562
},
{
"path": "backend/data/providers/tushare_dashboard.py",
"bytes": 28051,
"lines": 644
},
{
"path": "backend/data/providers/tushare_industries.py",
"bytes": 26540,
"lines": 616
},
{
"path": "backend/features/heaven/manual.py",
"bytes": 24521,
"lines": 412
},
{
"path": "frontend/pages/heaven/page.html",
"bytes": 19747,
"lines": 262
},
{
"path": "frontend/pages/screener/page.html",
"bytes": 19579,
"lines": 229
},
{
"path": "frontend/pages/market/preview.js",
"bytes": 18178,
"lines": 446
},
{
"path": "backend/features/heaven/trend.py",
"bytes": 16772,
"lines": 370
},
{
"path": "backend/features/market/insights_auction_scoring.py",
"bytes": 16689,
"lines": 355
},
{
"path": "frontend/pages/market/charts.js",
"bytes": 15311,
"lines": 387
},
{
"path": "frontend/pages/pools/page.html",
"bytes": 14942,
"lines": 235
},
{
"path": "backend/features/screener/data_sync.py",
"bytes": 14743,
"lines": 342
},
{
"path": "frontend/shared/admin.js",
"bytes": 14145,
"lines": 261
},
{
"path": "backend/features/heaven/market_context.py",
"bytes": 13681,
"lines": 338
},
{
"path": "frontend/shared/session.js",
"bytes": 13176,
"lines": 293
},
{
"path": "backend/features/market/insights_auction_data.py",
"bytes": 12829,
"lines": 318
},
{
"path": "backend/features/system/service.py",
"bytes": 12392,
"lines": 254
},
{
"path": "backend/features/market/insights_auction.py",
"bytes": 10717,
"lines": 221
},
{
"path": "backend/features/heaven/readings.py",
"bytes": 10539,
"lines": 244
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{
"path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876,
"lines": 224
},
{
"path": "backend/features/market/insights_themes.py",
"bytes": 9348,
"lines": 222
},
{
"path": "frontend/pages/market/entity-detail.js",
"bytes": 9119,
"lines": 199
},
{
"path": "backend/data/providers/tushare_dragon_tiger.py",
"bytes": 9059,
"lines": 214
},
{
"path": "backend/features/screener/indicators.py",
"bytes": 8562,
"lines": 238
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 8357,
"lines": 116
},
{
"path": "backend/features/screener/formula.py",
"bytes": 6983,
"lines": 146
},
{
"path": "backend/data/providers/tushare_daily.py",
"bytes": 6837,
"lines": 160
},
{
"path": "backend/application.py",
"bytes": 6751,
"lines": 178
},
{
"path": "backend/features/market/insights_popularity.py",
"bytes": 6739,
"lines": 156
},
{
"path": "frontend/shared/context.js",
"bytes": 6547,
"lines": 220
},
{
"path": "frontend/pages/sentiment/page.html",
"bytes": 6488,
"lines": 81
},
{
"path": "backend/data/providers/tushare_stocks.py",
"bytes": 6244,
"lines": 137
},
{
"path": "backend/features/screener/backtest.py",
"bytes": 6202,
"lines": 141
},
{
"path": "backend/features/screener/selection.py",
"bytes": 6092,
"lines": 138
},
{
"path": "frontend/pages/dragon-tiger/page.html",
"bytes": 5754,
"lines": 85
},
{
"path": "frontend/pages/market/stock-detail.js",
"bytes": 5690,
"lines": 124
},
{
"path": "backend/data/providers/tushare_indices.py",
"bytes": 5451,
"lines": 118
},
{
"path": "frontend/pages/market/search.js",
"bytes": 5384,
"lines": 131
},
{
"path": "frontend/pages.config.js",
"bytes": 5380,
"lines": 130
},
{
"path": "frontend/pages/auction/page.html",
"bytes": 5350,
"lines": 74
},
{
"path": "frontend/shared/feedback.js",
"bytes": 5157,
"lines": 153
},
{
"path": "frontend/pages/review/page.html",
"bytes": 4753,
"lines": 57
},
{
"path": "backend/features/screener/routes.py",
"bytes": 4712,
"lines": 106
},
{
"path": "backend/features/market/routes.py",
"bytes": 4276,
"lines": 91
},
{
"path": "frontend/shared/theme.js",
"bytes": 4258,
"lines": 117
},
{
"path": "backend/features/screener/engine.py",
"bytes": 4242,
"lines": 129
},
{
"path": "backend/http/dispatch.py",
"bytes": 4118,
"lines": 115
},
{
"path": "frontend/shared/table.js",
"bytes": 3790,
"lines": 81
},
{
"path": "backend/features/heaven/routes.py",
"bytes": 3475,
"lines": 79
},
{
"path": "backend/features/review/routes.py",
"bytes": 3369,
"lines": 81
},
{
"path": "frontend/pages/themes/page.html",
"bytes": 3316,
"lines": 55
},
{
"path": "frontend/app.js",
"bytes": 3201,
"lines": 93
},
{
"path": "backend/features/market/insights_context.py",
"bytes": 3175,
"lines": 84
},
{
"path": "frontend/pages/rotation/page.html",
"bytes": 3031,
"lines": 54
},
{
"path": "frontend/pages/market/bindings.js",
"bytes": 2663,
"lines": 40
},
{
"path": "backend/features/accounts/application.py",
"bytes": 2442,
"lines": 63
},
{
"path": "backend/features/mentor/routes.py",
"bytes": 2299,
"lines": 57
},
{
"path": "backend/features/screener/regime.py",
"bytes": 2202,
"lines": 53
},
{
"path": "backend/data/providers/tushare_client.py",
"bytes": 2166,
"lines": 68
},
{
"path": "frontend/pages/popularity/page.html",
"bytes": 2165,
"lines": 35
},
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 2083,
"lines": 64
},
{
"path": "frontend/pages/market/breadth.js",
"bytes": 2071,
"lines": 41
},
{
"path": "backend/features/dragon_tiger/routes.py",
"bytes": 1919,
"lines": 45
},
{
"path": "backend/jobs/service.py",
"bytes": 1746,
"lines": 49
},
{
"path": "backend/features/alerts/routes.py",
"bytes": 1687,
"lines": 47
},
{
"path": "frontend/shared/application.js",
"bytes": 1642,
"lines": 53
},
{
"path": "backend/features/market/insights.py",
"bytes": 1580,
"lines": 45
},
{
"path": "frontend/bootstrap.js",
"bytes": 1535,
"lines": 39
},
{
"path": "backend/data/providers/tushare_transport.py",
"bytes": 1455,
"lines": 48
},
{
"path": "backend/features/system/routes.py",
"bytes": 1423,
"lines": 40
},
{
"path": "backend/features/themes/routes.py",
"bytes": 1337,
"lines": 35
},
{
"path": "backend/features/rotation/routes.py",
"bytes": 1195,
"lines": 30
},
{
"path": "frontend/pages/ladder/page.html",
"bytes": 1143,
"lines": 19
},
{
"path": "backend/features/popularity/routes.py",
"bytes": 822,
"lines": 23
},
{
"path": "backend/features/auction/routes.py",
"bytes": 817,
"lines": 23
},
{
"path": "backend/features/accounts/routes.py",
"bytes": 803,
"lines": 22
},
{
"path": "backend/features/sentiment/routes.py",
"bytes": 724,
"lines": 19
},
{
"path": "backend/features/pools/routes.py",
"bytes": 568,
"lines": 18
},
{
"path": "backend/features/heaven/service.py",
"bytes": 435,
"lines": 15
}
]
}
+117
View File
@@ -0,0 +1,117 @@
{
"version": "2026.08.05-5",
"sources": {
"zhouyi": {
"title": "周易经文与十翼",
"scope": "卦辞、爻辞、彖传、象传",
"kind": "public_domain_primary",
"note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。"
},
"jingfang": {
"title": "京氏易传",
"scope": "八宫与纳甲体系来源",
"kind": "public_domain_traditional",
"note": "确定性程序采用京房纳甲、八宫世应的通行排法。"
},
"huozhulin": {
"title": "火珠林",
"scope": "纳甲筮法、六亲与日月关系",
"kind": "public_domain_traditional",
"note": "用于观心规则脉络,不直接复制后世简化断语。"
},
"zengshan": {
"title": "增删卜易",
"scope": "用神、世应、动变、日月旺衰",
"kind": "public_domain_traditional",
"note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。"
},
"neijing": {
"title": "黄帝内经·素问运气七篇",
"scope": "五运、司天在泉、主客气与运气关系",
"kind": "public_domain_primary",
"note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。"
}
},
"trend": {
"method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。",
"rules": {
"stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。",
"single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。",
"multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。"
}
},
"fortune": {
"principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。",
"movement": {
"太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。",
"不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。"
},
"qi": {
"厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。",
"少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。",
"太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。",
"少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。",
"阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。",
"太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。"
},
"relations": {
"same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。",
"guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。",
"host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。",
"guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。",
"host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。"
},
"day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。",
"industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。",
"personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。",
"personal_relations": {
"比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。",
"劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。",
"食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。",
"伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。",
"偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。",
"正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。",
"七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。",
"正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。",
"偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。",
"正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。"
}
},
"heart": {
"presets": {
"trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?",
"mind": "此刻影响我交易判断的情绪、执念或盲点是什么?",
"unthemed": "不设具体问题,只观此刻一念。"
},
"focus": {
"trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。",
"mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。",
"unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。",
"custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。"
},
"evidence_order": [
"用户问题与预设来源",
"本卦及卦宫",
"世应与所问相关六亲",
"月建日辰、旬空及冲合生克",
"实际动爻与变爻",
"之卦与整体卦义",
"六神辅助象义"
],
"limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。",
"semantics": {
"self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。",
"calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。",
"movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。",
"six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。",
"timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。",
"relatives": {
"兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。",
"子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。",
"妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。",
"官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。",
"父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。"
}
}
}
}
+1 -31
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -665,36 +665,6 @@ class ReviewDatabase(
)
MigrationRunner().apply(connection, MIGRATIONS)
def list_sector_phase_overrides(self) -> dict[str, str]:
with self.connect() as connection:
rows = connection.execute(
"SELECT name, element FROM sector_phase_overrides ORDER BY updated_at DESC, name"
).fetchall()
return {row["name"]: row["element"] for row in rows}
def save_sector_phase_override(self, name: str, element: str) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO sector_phase_overrides (name, element, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
element = excluded.element,
updated_at = excluded.updated_at
""",
(name, element, now),
)
def delete_sector_phase_override(self, name: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM sector_phase_overrides WHERE name = ?",
(name,),
)
return cursor.rowcount > 0
def list_wencai_saved_queries(
self, user_id: int, limit: int = 30
) -> list[dict[str, Any]]:
+325
View File
@@ -0,0 +1,325 @@
> ⚠️ **本文档已过时,仅留档备查,请勿删除。**
> 本交接说明核实于 2026-08-06,其中「当前提交」「当前状态」「正在处理的事项」「验证记录」等已与代码现状不符(当时的未提交改动现已合并,项目已推进到全站视觉统一收尾阶段)。
> 最新内容请看 `docs/项目需求.md`、`docs/最新进度.md`、`docs/任务清单.md` 和 `docs/README.md`。
> 架构与维护规矩仍以根目录 `AGENTS.md`、`ARCHITECTURE.md` 为准;本文第 2、6 节(架构与决策)仍可作参考。
# 小白复盘项目交接说明
> 核实日期:2026-08-06Asia/Shanghai
> 正式源码边界:`webapp/app/`
> 产品行为基准:`docs/product/小白复盘-完整产品规格说明书.md`
本文件不是聊天摘要。内容以当前仓库、配置注册表、测试、Git 状态和产品规格交叉核实为准。后续维护者应先阅读根目录 `AGENTS.md``ARCHITECTURE.md`、本文件和产品规格,再修改代码。
## 0. 状态口径与证据
本文使用四种状态,不能混用:
- **已实现**:当前正式源码中存在对应实现。
- **自动验证通过**:有测试或注册表检查证明,不等同于人工视觉验收。
- **人工已验收**:用户已经确认迁移后的正式 `app/` 在功能和视觉上与迁移前等价;该结论只覆盖当时基线。
- **待验收/待实现**:代码尚未完成,或虽已写入工作区但尚未取得本轮人工确认和 Git 回档点。
### 0.1 Git 与运行快照
- 分支:`main`
- 当前提交:`bd97ba1 feat: unify trading workspace visual system`
- `HEAD``origin/main` 一致;远端为内部 Gitea 仓库。
- 生成本文前工作区已有 37 个修改文件,约 `2490` 行新增、`2958` 行删除,主要是全站视觉调整和最新问师改造;这些改动不是本文创建的,禁止丢弃。
- 生成本文时 `8797` 端口没有监听进程,因此实时数据源和 LLM 的运行可用性没有通过在线健康检查确认。
- 当前正式数据库为 `data/review.db`,使用 SQLite WAL;数据库、`.env`、Token、私有 Skill、日志和运行产物不进入 Git。
- 本轮文档生成后的自动验证结果见本文末尾“验证记录”。
## 1. 项目目标和当前状态
### 1.1 项目目标
小白复盘是面向 A 股盘后复盘和盘前观察的本地/局域网 Web 工作台。目标不是自动交易,而是把真实行情、市场情绪、涨跌停结构、集合竞价、板块题材、选股、思维模型问答、传统文化观察和个人复盘放在一套可追溯、可复现、账号隔离的系统中。
产品必须坚持以下底线:
1. 不使用演示行情冒充真实数据,不静默混用日期、单位、复权或数据源。
2. 计算型数据缺失时失败关闭;公开网页源只允许作为已登记的展示兜底。
3. 阶段、策略筛选、情绪、观势取象和六爻排盘由确定性程序完成;LLM 只编译自然语言条件或解释确定性结果。
4. 用户自选、复盘、交易日志、问师/问天历史等私有数据必须按账号隔离。
5. PC 端优先达到稳定、精致、可长期维护;移动端必须独立设计,不能把 PC 页面简单压缩。
### 1.2 当前状态
正式版本已经从历史混乱目录保真迁入 `webapp/app/`,用户已人工确认迁移本身在功能和视觉上成功。项目已经完成模块化单体边界、页面碎片化、数据网关、LLM 网关、后台任务、数据库迁移、注册表和统一验收工具等结构治理。
当前不是“从零重写”状态,也不应再次从旧根目录或失败的 `next/` 复制实现。现阶段属于:
- 核心 PC 产品可用,16 个主工作区均有正式实现。
- 当前工作区正在进行全站 PC 视觉一致性调整,以及问师经典 QQ 式三栏界面和动态追问能力;自动化测试已覆盖,尚待本轮人工视觉验收和提交。
- 移动端明确暂停,当前存在样式但不能据此宣称可用。
- 完整 IC 动态加权、稳定宏观/政策/隔夜消息、分析师一致预期、Level-2 等依赖数据与算法的能力尚未完成。
- 局域网单实例是当前部署边界;公网多实例能力不属于当前完成范围。
## 2. 技术架构与主要目录
### 2.1 总体架构
项目采用**模块化单体**:一个 Python 进程、一个 SQLite WAL 数据库、无构建工具的 HTML/CSS/JavaScript 前端。
```text
Browser
-> frontend/shared/api.js
-> backend/http + backend/features/<feature>/routes.py
-> feature service
-> Repository / DataGateway / LLMGateway
-> SQLite / Tushare / iFinD / display-only providers / LLM provider
Scheduler
-> backend/jobs
-> 同一套 feature service / repository / gateway
```
该结构适合当前局域网单实例产品:部署简单、数据本地、回档直接,同时通过领域边界避免再次退化成单文件应用。除非进入公网多实例阶段,不要提前引入微服务、消息队列或前端构建框架。
### 2.2 主要目录
| 路径 | 唯一职责 |
|---|---|
| `server.py` | 稳定启动/导入门面 |
| `backend/bootstrap/` | 配置、依赖组装、启动与组合根 |
| `backend/http/` | 鉴权、请求 ID、JSON/NDJSON、静态文件、流式连接和统一异常 |
| `backend/features/` | 按账户、市场、选股、问师、问天、复盘等领域组织业务、路由和 Repository |
| `backend/data/` | `DataGateway`、数据源策略、来源/日期/单位/新鲜度/覆盖率质量门 |
| `backend/data/providers/` | Tushare、iFinD 等供应商适配;不得由业务模块直接调用 |
| `backend/database/` | SQLite 连接、顺序迁移和 Repository 组合 |
| `backend/jobs/` | 行情刷新、盘后选股、事件补充的锁、状态、幂等和重试 |
| `backend/llm/` | 模型选择、会员/额度、主辅回退、流式协议、取消和审计 |
| `frontend/index.html` | 登录层、全站 Shell、摘要条、状态栏、全局弹窗和唯一页面挂载点 |
| `frontend/shared/` | 唯一 API 出口、状态、Shell、会话、主题和公共组件 |
| `frontend/pages/` | 页面局部 `page.html``page.js``foundation.css` |
| `config/` | 页面、功能、API、数据字段、质量和任务注册表 |
| `data/` | 正式数据库与私有数据,不入 Git |
| `runtime/` | 日志、PID、缓存、测试结果,不入 Git |
| `tests/` | Python 单元/边界/契约测试与 Playwright 浏览器回归 |
| `tools/` | 启动、注册表生成、架构清单和统一验收工具 |
| `docs/` | 产品规格、维护、治理、历史迁移和当前交接/Issue |
### 2.3 注册表和运行事实
- `config/pages.config.json`16 个主页面,默认页为情绪周期。
- `config/features.config.json`20 个功能及 `public/authenticated/member/admin` 权限。
- `config/api.config.json`:当前 53 个精确 API 路径和 11 个正则路径,由工具生成并校验。
- `config/jobs.config.json`:行情刷新、15:10 后盘后选股、iFinD 事件补充三类任务。
- `config/data-fields.config.json`:数据源与字段用途;Tushare/iFinD 可进入已登记计算,东方财富/腾讯只允许展示,未解决数据集显式阻塞。
- `config/data-quality.config.json`:单位、覆盖率、新鲜度和失败关闭规则。
- `config/architecture-inventory.json`:生成的架构清单和代码热点,不应手工编造。
### 2.4 数据源边界
| 数据源 | 当前角色 | 约束 |
|---|---|---|
| Tushare | 交易日、股票主数据、日线、估值、财务、资金、申万行业、涨跌停、最终竞价、热榜、龙虎榜等主要计算数据 | 按接口权限和质量门使用 |
| iFinD | 动态竞价、展示型日 K/分时和盘后事件补充 | 凭据/授权到期时必须显式不可用,不得伪造 |
| 东方财富/腾讯 | 分时或实时指数的展示观察兜底 | 不得静默进入情绪、选股或问天计算 |
| Local | 情绪等确定性派生结果 | 保存算法/输入版本,保证可复现 |
| unresolved | 分析师一致预期、Level-2 | 当前阻塞,不能用名称或空字段冒充实现 |
## 3. 已完成功能
以下表示当前正式源码存在实现;人工视觉结论仅继承用户对迁移基线的确认,不覆盖本轮未提交视觉改动。
### 3.1 全局与账户
- 注册、登录、退出、首账号管理员、普通/会员/管理员权限。
- 个人资料、生辰资料、修改密码、会员状态、系统管理与公共凭据配置。
- 顶栏日期、默认最近真实交易日、情绪摘要条、日间/夜间、全局搜索、提醒中心。
- 股票、题材、板块、指数详情;日 K/分时与代码/题材悬浮预览。
- 统一 Toast、弹窗、空态、加载、错误转换和页面生命周期基础设施。
### 3.2 市场复盘页面
- 情绪周期:温度、阶段、方向、置信度、构成、趋势和交易日明细。
- 涨停池、炸板池、跌停池、昨日涨停、涨停表现。
- 市场天梯、板块轮动与成分股联动。
- 集合竞价:盘前状态、9:25 最终筛选、普通异动/一字板、成交额对比和自选。
- 题材库、人气热榜、龙虎榜和游资名录/详情基础能力。
### 3.3 智能选股
- 六阶段盘后候选、29 套精选策略、策略适用说明和确定性候选结果。
- 自定义公式 DSL、自然语言编译公式、因子与权重手动配置。
- 候选按策略/日期隔离,盘后自动发布最近完整交易日结果。
- 用户手动加入五交易日策略跟踪,T+1/T+3/T+5 反馈和幂等提醒。
- 数据缺失、无符合条件、任务失败等状态区分。
- 当前多因子为基础动态版;完整 IC 版不在“已完成”范围内。
### 3.4 问师与 LLM
- 公共/管理员私有思维模型 Skill 注册、证据等级、关注维度和排序偏好。
- 按账号、模型、交易日隔离对话;最多带入最近 10 条历史。
- 按模型类型提供不同市场上下文,识别个股时追加有限标的数据。
- 统一 LLM 会员/额度、主辅回退、流式去重、停止生成、审计和安全错误。
- 当前工作区已经实现经典 QQ 式联系人/会话/资料三栏和同次调用动态追问;状态为“自动验证通过、待人工验收和提交”,详见 Issue 001。
### 3.5 问天
- 观势:真实行情安全门、三才六爻、势值、本卦/之卦、客观数据补录与恢复自动数据。
- 观气:历法、节气、中运/司天在泉/主客气、个人合参、五行行业取象和每日解运持久化。
- 观心:交易/心境/无题预设、呼吸流程、六次铜钱起卦、第一念、京房纳甲/八宫世应/六亲/六神/旬空等确定性排盘。
- 本地知识检索、答案一致性校验和 LLM 解释;LLM 不起卦、不修改程序结果。
### 3.6 个人复盘
- 账号私有自选追踪、个股笔记、三个独立输入框的每日复盘及历史。
- 结构化交易日志、编辑删除、胜率/盈亏/仓位统计。
- 复盘助手流式对话,读取共享市场和当前用户记录,不执行交易。
- 手工提醒、已读状态、策略跟踪 T+1/T+5 自动提醒和幂等去重。
### 3.7 工程治理
- 正式源码独立于父目录旧程序和失败 `next/`
- 页面结构、行为和样式已按领域拆分;浏览器请求统一经过 `frontend/shared/api.js`
- Tushare 大客户端、智能选股、问天、市场洞察和 HTTP 层已拆成职责明确的模块门面。
- 有正式数据库 migration、数据/LLM/job 网关、API/功能/页面/数据注册表。
- 统一验收工具覆盖 Python、注册表、JS 语法、Git 空白、SQLite 完整性和可选 Playwright。
## 4. 尚未完成的功能
每项均有独立 Issue,Issue 状态优先于历史聊天中的阶段编号。
| Issue | 状态 | 优先级 | 未完成内容 |
|---|---|---:|---|
| [ISSUE-001](issues/ISSUE-001-finalize-mentor-redesign.md) | 待人工验收/提交 | P0 | 问师三栏界面、停止生成和动态追问收口 |
| [ISSUE-002](issues/ISSUE-002-checkpoint-current-pc-visual-work.md) | 待审查/提交 | P0 | 当前全站 PC 视觉改动的逐页验收、拆分和回档点 |
| [ISSUE-003](issues/ISSUE-003-mobile-redesign.md) | 明确延期 | P2 | 独立移动 Shell、逐页信息架构和触控交互 |
| [ISSUE-004](issues/ISSUE-004-full-ic-multifactor.md) | 未实现 | P1 | 12 个月 Rank IC、季度重算、中性化和前 5% 输出 |
| [ISSUE-005](issues/ISSUE-005-policy-macro-overnight-data.md) | 数据源未定 | P1 | 稳定政策/宏观/隔夜消息序列与竞价量化 |
| [ISSUE-006](issues/ISSUE-006-analyst-consensus-data.md) | 数据阻塞 | P2 | 一致预期、预测修正、评级/目标价等字段 |
| [ISSUE-007](issues/ISSUE-007-level2-auction.md) | 授权阻塞 | P2 | Level-2 委托队列、逐笔和动态竞价深度 |
| [ISSUE-008](issues/ISSUE-008-hot-money-profile-history.md) | 低优先级 | P3 | 游资档案的更完整历史画像和归类质量 |
| [ISSUE-009](issues/ISSUE-009-documentation-status-drift.md) | 待整理 | P1 | 活跃文档/注册表中移动端、端口和验收状态漂移 |
| [ISSUE-010](issues/ISSUE-010-live-provider-llm-readiness.md) | 待运行核验 | P0 | 启动正式服务并验证数据源、iFinD、LLM 与任务健康 |
| [ISSUE-011](issues/ISSUE-011-public-deployment-hardening.md) | 未来范围 | P3 | 公网多实例、TLS、PostgreSQL、队列、缓存和集中监控 |
明确不是待办:问师自主联网取数当前已因风险高于收益而延期;全能金融爬虫 Skill 已放弃;旧 `next/` 已冻结失败;不要把这些内容重新加入实现。
## 5. 已知问题与风险
### 5.1 用户可见问题
1. **移动端整体不可用或交互较差。** 当前存在大量媒体查询和 `mobile_layout: dedicated` 注册值,但这只证明代码存在,不证明通过人工可用性验收。
2. **当前问师与全站视觉改动未完成交付闭环。** 自动化已通过,但工作区未提交,且用户尚未对本轮 QQ 式问师界面进行视觉确认。
3. **实时数据和 LLM 当前在线状态未知。** 生成本文时 8797 未启动;外部服务还受本机网络、系统凭据、接口权限和 iFinD 授权有效期影响。
4. **缺失数据不能被误显示为无信号。** 分析师一致预期、Level-2 和部分宏观/新闻数据目前无正式来源;相关策略或页面必须显示数据缺失/阻塞。
### 5.2 维护风险
- `frontend/pages/heaven/foundation.css` 约 11,734 行、`frontend/pages/screener/foundation.css` 约 6,565 行、`frontend/shared/shell.css` 约 3,224 行;它们是当前最大 CSS 热点。没有具体回归证据时不得为了“减行数”盲拆。
- `frontend/pages/heaven/page.js` 约 2,069 行、`backend/features/heaven/engine.py` 约 1,183 行,问天仍是高复杂度领域。
-`database.py` 仍是历史 schema/Repository 组合锚点,不是新增业务查询的位置;继续向其中加功能会破坏治理结果。
- 自动化测试不能替代产品规格第 25 至 27 节的全矩阵人工验收,尤其是外部真实数据、LLM、日夜主题、1080P/4K 和移动端。
- 当前脏工作区横跨 37 个文件。提交前必须按功能拆分或至少留下清晰回档说明,不能把无关改动混成无法审计的大提交。
## 6. 已作出的重要技术决策及原因
| 决策 | 原因 |
|---|---|
| `webapp/app/` 是唯一正式源码 | 已完成保真迁移并人工确认;避免继续依赖父目录旧代码或失败 `next/` |
| 保持模块化单体 | 当前局域网单实例用一个进程和 SQLite 最简单;领域边界已经足以控制复杂度 |
| 不更换技术栈,前端保持无构建 HTML/CSS/JS | 迁移目标是整理和减法,不是重拍功能;减少部署与人工维护成本 |
| 页面、功能、API、数据和任务采用注册表 | 防止入口散落、权限漂移和“代码有但系统不知道” |
| 浏览器 API、外部数据和 LLM 各自只有一个网关出口 | 统一鉴权、错误、质量、额度、降级和审计 |
| 计算数据失败关闭,展示兜底隔离 | 防止公开网页源或旧快照静默污染情绪、选股、竞价和问天结果 |
| 智能选股由条件和数据确定执行,LLM 只编译公式 | 保证同日期同策略可复现,避免刷新结果漂移 |
| 问天确定性引擎负责历法/卦象,LLM 只解释 | 结果可复现、可测试,避免模型改卦或编造事实 |
| 问师外部工具自主取数暂缓 | 当前缺少成熟权限、来源和失败边界,风险高于收益 |
| 放弃通用金融爬虫 Skill | 网页规则不稳定、版权/安全/口径不可控,不适合进入正式计算链 |
| 移动端暂停并要求独立设计 | 密集 PC 表格不能靠压缩获得可用手机体验;先保证 PC 功能与视觉 |
| 不确定代码默认保留,删除需扫描、差异测试和人工验收 | 防止“减法”误删隐含功能;历史迁移日志用于回档证据 |
| 公网能力不提前实现 | 当前用户场景是本地/局域网;多实例、PostgreSQL 和队列应由真实部署需求驱动 |
## 7. 当前正在处理的事项
### 7.1 问师改造
当前未提交代码已经完成:
- 经典 QQ 式 PC 三栏结构:联系人、对话、当前模型资料/证据。
- 动态追问:模型在同一次输出末尾返回 `<XIAOBAI_FOLLOW_UPS>` 机器块;服务端剥离机器块,并在最终 NDJSON `meta.follow_ups` 返回 2 至 3 条建议。
- 动态追问不额外调用 LLM、不重复扣额度;点击只预填输入框。
- 停止生成控制、Enter 发送、流式占位与回答状态。
- 问师 CSS 从历史约 2,800 行收敛到约 988 行。
相关文件:
- `backend/features/mentor/agent.py`
- `backend/features/mentor/service.py`
- `frontend/pages/mentor/page.html`
- `frontend/pages/mentor/page.js`
- `frontend/pages/mentor/foundation.css`
- `tests/test_mentor_stream.py`
- `tests/e2e/app-shell.spec.js`
尚缺:启动正式服务、接入真实 LLM 做一次端到端验证、用户人工确认日间/夜间及 1080P/4K 视觉、建立提交并推送回档点。
### 7.2 当前全站视觉改动
工作区还包含 Shell、设计令牌、公共组件以及市场、情绪、股池、天梯、轮动、竞价、题材、热榜、龙虎榜、选股、问天、复盘等页面样式改动。它们已进入自动化回归,但尚未形成独立验收结论。移动端已被产品决策暂停,因此不能因为这些 CSS 中存在移动规则就标记移动端完成。
## 8. 推荐的后续执行顺序
1. **先恢复运行环境并核验外部能力。** 启动 8797,检查健康、登录、最近真实交易日、Tushare/iFinD、LLM 主辅模型和后台任务;不通过时先解决 Issue 010。
2. **人工验收问师。** 完成 Issue 001 的真实 LLM、流式、停止、动态追问、日夜和分辨率检查。
3. **审查当前全站视觉差异。** 按 16 页逐页检查 Issue 002,确认哪些是 PC 正式改动、哪些是已暂停移动尝试,保持功能等价。
4. **建立回档点。** 将问师和全站视觉按可审计边界提交并推送,不夹带密钥、数据库或运行产物。
5. **清理活跃文档状态漂移。** 完成 Issue 009,使 README、注册表和交接状态不再暗示移动端已验收。
6. **先补可获得的高价值数据,再升级算法。** 先确定 Issue 005/006 的合法稳定来源,再实施 Issue 004;没有完整历史覆盖时不能伪造 IC。
7. **有正式授权后再做 Level-2。** Issue 007 不能用普通快照模拟。
8. **低优先级完善游资档案。** Issue 008 不应阻塞市场、选股、问师和问天稳定性。
9. **PC 稳定后才重启移动端设计。** Issue 003 必须单独打样和逐页人工验收。
10. **确定公网商业化再做部署升级。** Issue 011 需要单独架构决策和迁移方案。
## 9. 每项任务的验收标准
本节是交接总表;独立 Issue 内给出更具体的范围和命令。产品规格第 25 至 27 节固定案例仍是最终依据。
| 任务 | 必须满足的验收标准 |
|---|---|
| Issue 001 问师收口 | 真实 LLM 只输出一份正文;同次调用出现 2 至 3 条有效追问;点击只预填;停止后不上演回退重放;无额外额度;日夜、1080P/4K 人工通过 |
| Issue 002 PC 视觉回档 | 16 页日间/夜间、1920×1080、4K 无白块、遮挡、双滚动和功能回归;当前差异可解释;提交可独立回退 |
| Issue 003 移动端 | 320/375/390/430/768 及横屏无页面横溢;底部五入口、市场子导航、弹窗/抽屉、宽表和键盘交互可用;用户逐页验收 |
| Issue 004 完整 IC | 行业内去极值、z-score、行业/市值中性化、过去 12 月下期收益 Rank IC、季度重算、前 5% 均有版本化确定性测试;无未来函数;UI 明确基础/IC 模式 |
| Issue 005 政策宏观隔夜 | 合法稳定来源、字段/单位/时间/版权/新鲜度登记完整;历史归档可复现;缺失显式失败;消息只按确认规则进入竞价量化 |
| Issue 006 一致预期 | 五类字段有 point-in-time 历史、公告时点和覆盖率;策略缺数据与无命中可区分;回测无未来函数 |
| Issue 007 Level-2 | 有正式授权;委托队列/逐笔/快照时间可追溯;盘中断线不伪造;与 9:25 最终归档区分;回放测试通过 |
| Issue 008 游资画像 | 名录、别名、席位归类和历史操作可追溯;未知席位保留;同名误合并有回归测试;左名录右详情无超长弹窗 |
| Issue 009 文档漂移 | 活跃文档、端口、移动端状态、完成状态与注册表一致;历史迁移文档明确只作审计,不被当运行说明;文档链接有效 |
| Issue 010 在线就绪 | `/api/health` 可达;登录和最近真实快照正常;数据源与 LLM 分别可诊断;失效凭据不泄露;重启后任务与结果不重复 |
| Issue 011 公网部署 | 完成 ADR;TLS、可信 Host、限流、集中密钥、审计、备份恢复、多实例数据库和任务互斥全部通过;不破坏局域网数据边界 |
### 9.1 通用自动验收
```powershell
cd C:\Users\MoBai\Documents\gupiaofupan\webapp\app
python tools/verify_baseline.py
python tools/verify_baseline.py --e2e
git diff --check
```
### 9.2 通用人工验收
- 普通、会员、管理员三种权限。
- 正常、有数据为空、数据缺失、上游失败、请求超时、最近快照九类状态。
- 日间、夜间、1920×1080、3840×2160;移动 Issue 开始后再加入完整移动视口矩阵。
- 真实行情日期与图表一致;开盘前不制造当天空 K 线。
- 用户甲乙的自选、复盘、日志、对话、问天历史互不可见。
- 所有保存/删除/添加只出现可关闭的规范反馈,不出现超长空弹窗。
- 密钥、数据库、日志和私有 Skill 不进入 Git diff。
## 10. 验证记录
2026-08-06 本轮结果;后续代码变化后不能沿用:
- Python326 个测试全部通过(约 11.8 秒)。
- Playwright49 个测试全部通过(约 2.2 分钟)。
- API 注册表与架构清单:均为 current。
- JavaScript:统一工具枚举的全部 `.js/.mjs` 均通过 `node --check`
- SQLite`data/review.db``PRAGMA integrity_check``ok`,验证时大小为 455,434,240 字节。
- Git`git diff --check` 通过。
- 说明:组合命令在本代理的 120 秒命令上限处被终止于 Playwright 阶段;Playwright 随后以同一配置单独运行并完整通过,因此上述各子项均有本轮实际结果。
+35
View File
@@ -0,0 +1,35 @@
# 小白复盘 · 交接手册首页(首页说明)
> 一句话:这是「小白复盘」项目的交接手册入口。新来的智能体(或人)先看这一页,再按下面顺序读四份文档,就能知道这个项目是干什么的、干到哪了、下一步做什么。
## 先读哪些文件(按顺序)
1. `项目需求.md` —— 这个项目是干什么的、要解决什么问题、有哪些功能。
2. `最新进度.md` —— 目前整体做到哪一步了。
3. `任务清单.md` —— 正在做 / 已做完 / 还没安排,三栏一目了然。
4. 本文件 `README.md` —— 就是你现在看的这一页。
读完上面四份,就算“接手”了。想深入了解实现细节,再往下读。
## 想深入了解时再读这些
- `product/小白复盘-完整产品规格说明书.md` —— 最完整、最权威的“产品需求”说明书,从零重建项目都用它。
- `maintenance/人工维护指南.md` —— 怎么启动、怎么改代码、怎么跑测试、怎么备份和回退。
- `governance/` —— 架构决策和历次结构治理记录。
- `migration/` —— 从旧代码保真迁进 `app/` 的历史账本和证据,只用于审计和追溯,不参与运行。
- 根目录的 `AGENTS.md`(维护硬规矩)、`ARCHITECTURE.md`(技术架构)。
## 更新规矩(每完成或新增一个任务都要做)
任何智能体完成或新增一个任务后,必须顺手把这份手册更新到位,不能只改代码:
1. 任务做完或新增 → 更新 `任务清单.md`:把任务从「正在做」挪到「已做完」,或把新任务加进对应栏目。
2. 整体进度变了 → 更新 `最新进度.md`
3. 需求或功能变了 → 更新 `项目需求.md`(重大变化还要同步 `product/` 里的完整说明书)。
4. 更新完提交并推送进仓库(保存并上传到放代码的网站),不能只留在自己电脑里。
## 注意事项
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
+401
View File
@@ -0,0 +1,401 @@
# `app/`代码减法账本
> 基线:`xiaobai-preservation-complete-20260801`
> 工作目录:只允许修改`webapp/app/`;原版根目录和冻结的`next/`只读
> 目标:删除重复实现和历史补丁,不改变功能、视觉、交互、动画、计算、权限、API或数据行为
## 固定规则
1. 每批只处理一个明确边界,先证明重复或无消费者,再修改。
2. 新共享实现必须在同一提交删除全部被替代实现;禁止只加一层包装。
3. 运行代码总量原则上不得增加;测试和证据代码单独统计。
4. 迁移期源码相等测试不得简单删除。发生已批准的结构重构时,必须替换成行为、错误语义和
唯一所有权契约。
5. 每批通过领域测试、全量候选测试、独立导出测试和受影响的浏览器流程后才建立Git检查点。
6. CSS最后处理;没有逐页日间、夜间和多视口截图证据,不删除视觉规则。
## 批次记录
| 批次 | 边界 | 基线问题 | 目标 | 状态 |
|---|---|---|---|---|
| CR-01 | LLM供应商传输 | 问师、问天、复盘助手和策略编译各自构造HTTP请求、解析响应和读取错误 | 只保留`backend/llm/transport.py`一个网络出口 | 已完成 |
| CR-02 | HTTP精确POST委托 | 27个端点重复使用“比较路径、调用无参数处理器、返回”三行分支 | 用公开/受保护两张显式映射统一委托,同时保留复杂路由的原控制流 | 已完成 |
| CR-03 | 股票市场后缀转换 | Tushare业务与iFinD图表各保留一份完全相同的沪深京代码转换函数 | 图表复用`bootstrap/config.py::tushare_code`,只保留一份函数体 | 已完成 |
| CR-04 | 数值归一化策略 | 四个业务模块分别保留两组完全相同的数值转换函数体 | 由`backend/data/numbers.py`集中拥有两种既有语义,消费者保留原局部别名 | 已完成 |
| CR-05 | 根级兼容入口 | 正式后端仍有五处通过迁移兼容模块反向导入规范实现 | 正式代码改用规范路径;兼容入口只服务原公开导入契约 | 已完成 |
| CR-06 | 紧凑日期显示 | Tushare与选股引擎各保留一份完全相同的`YYYYMMDD`显示转换 | 由`bootstrap/config.py`拥有唯一格式策略,消费者保留原局部别名 | 已完成 |
| CR-07 | 数据Provider组装 | 核查网关、容器和业务服务是否重复创建外部数据客户端 | 固化唯一创建位置及兼容例外,不改数据源语义 | 已完成 |
| CR-08 | NDJSON流式传输 | 问师与复盘助手重复维护响应头、事件写入、完成、断线及关闭流程 | HTTP共享层拥有唯一流式连接生命周期 | 已完成 |
| CR-09 | 应用服务门面 | 两个仅服务股池iFinD补全的方法仍错位在全局`DashboardService` | 原函数体机械归位到`PoolServiceMixin` | 已完成 |
| CR-10 | Repository所有权 | 五行行业阶段覆盖的三项持久化方法仍错位在根级数据库门面 | 原函数体机械归位到问天Repository,兼容数据继续保留 | 已完成 |
| CR-11 | 后台任务生命周期 | 导入应用即启动调度线程,启动早于端口绑定,停止只置位但不等待 | 运行时显式启停,每个Runner只拥有一个可等待的调度线程 | 已完成 |
| CR-12 | CSS跨层精确重复 | 七层样式保留多次视觉改造形成的重复顶层规则,前层声明被后层逐字覆盖 | 只删除能够由CSSOM证明完全重复的前层规则,并建立浏览器级重复门禁 | 已完成 |
| CR-13 | CSS跨文件嵌套精确重复 | 相同媒体上下文中的完整规则分散在不同样式文件,前层声明仍被后层完整重复 | 递归核对CSSOM上下文,只退休跨文件的精确副本 | 已完成 |
| CR-14 | CSS同文件精确重复 | 同一文件、同一媒体上下文仍保留多轮视觉调整形成的完整重复规则 | 删除较早副本并把同文件重复纳入浏览器门禁 | 已完成 |
## CR-01验收口径
- 四个功能模块不得包含`urllib.request``/chat/completions`
- 非流式与流式请求的URL、鉴权、User-Agent、SSE累积和空响应行为保持不变。
- 各功能原有错误类型和用户可见错误文案保持不变。
- `LLMGateway`的会员、额度、主辅回退、首字后不中途换模型和审计规则保持不变。
- 架构清单必须登记唯一传输入口;完整测试和真实主模型最小调用通过。
## CR-01结果
- 四个功能模块的运行代码由572行降至422行;新增唯一传输实现129行,生产代码净减少21行、
约1.4 KB。行数不是主要收益,关键是5处`/chat/completions`请求只剩1处。
- 三套重复HTTP错误正文解析合并为一套;各功能原有错误类型和用户可见文案由专项测试固定。
- 迁移期4个“Agent文件逐字相等”断言没有直接删除,而是替换为共享传输唯一所有权、提示词模块
归属、SSE行为和兼容模块对象契约。
- 候选311项、纯`app/`导出248项、45项Playwright通过;24个JavaScript文件、架构/API注册表和
SQLite完整性检查通过。
- 使用候选数据库中加密保存的主模型完成真实非流式与流式最小调用,分别成功返回完整响应和
4个流式分片。调用未输出密钥或模型正文。
- 本批不修改页面、CSS、提示词、业务计算、会员额度、模型回退、数据库或部署。
回档基线为`xiaobai-preservation-complete-20260801`;本批检查点为
`xiaobai-reduction-01-llm-transport-20260801`
## CR-02验收口径
- 仅纳入没有路径参数、请求体解析或专属异常分支的精确POST端点;其余路由保持原样。
- 公开注册/登录端点继续在鉴权前分发;受保护端点继续严格执行登录、CSRF、注册表权限、处理器。
- 27个映射路径必须都由权威API注册表解析,处理方法必须真实存在,公开与受保护集合不得重叠。
- API路径、功能归属、访问角色、状态码、错误正文和静态页面绕过鉴权行为保持不变。
- API清单生成器必须结构化读取显式映射;架构清查复用API清单,不再维护第二套路由发现规则。
## CR-02结果
- 2个公开端点和25个受保护端点改为显式委托映射,原来的79行重复分支被43行映射、分发与调用替代;
`backend/application.py`净减少36行,规范化源码约减少1.0 KB。
- 带正则路径参数、请求体读取、查询参数转换或特殊异常语义的GET、POST、DELETE端点未改动。
- 架构清查删除了自行扫描精确/正则API路径的第二套规则,改为消费权威`api.config.json`API注册表的
53个精确路径、11个正则路径、功能归属和权限均未变化。
- 原版231项、候选315项、纯`app/`导出252项、45项Playwright通过;24个JavaScript文件、
API/架构注册表、Git空白检查和SQLite完整性检查通过。
- 本批不修改前端、CSS、业务计算、数据源、数据库结构、LLM、会员规则或部署。
本批基线为`xiaobai-reduction-01-llm-transport-20260801`;检查点为
`xiaobai-reduction-02-http-dispatch-20260801`
## CR-03验收口径
- 图表模块不再定义第二份市场后缀转换函数,仍保留原局部名称和两个调用点。
- 深市、沪市、北交所的既有映射结果保持不变;不借本批修正或扩展代码规则。
- 原图表文件除该函数外的所有顶层定义继续与原版AST逐项相等。
- 原版`_stock_market_code`函数的参数和函数体必须与唯一共享实现AST相等,运行时别名必须指向
同一个函数对象。
## CR-03结果
- 删除`backend/features/market/charts.py`中第二份10行定义,以1行导入别名复用共享实现,生产代码
净减少9行;全仓后端只剩一份沪深京后缀转换函数体。
- 未合并实时聚合、东方财富图表、iFinD和Tushare的HTTP传输;它们的缓存、错误、重试和降级语义
不同,仅有外形相似,证据不足以安全抽象。
- 原有迁移期整文件相等断言被等价范围断言、共享函数AST断言和唯一对象断言替代,没有降低门禁。
- 原版231项、候选317项、纯`app/`导出254项、45项Playwright通过;API/架构注册表、
24个JavaScript文件、Git空白检查和SQLite完整性检查通过。
- 本批不修改图表请求、数据来源、缓存、时间范围、行情计算、前端、CSS、数据库或部署。
本批基线为`xiaobai-reduction-02-http-dispatch-20260801`;检查点为
`xiaobai-reduction-03-market-symbol-20260801`
## CR-04验收口径
- 只合并参数、函数体和运行结果完全一致的数值转换函数,不借本批改变任何业务计算或异常默认值。
- `finite_number`继续拒绝`NaN`与正负无穷;`non_nan_number`继续只拒绝`NaN`并保留正负无穷。
- Tushare与智能选股必须复用有限数策略;市场洞察与情绪引擎必须复用非NaN策略,并继续暴露原局部
`_number`名称以保持兼容。
- 实时行情和图表转换器的空值、默认值或参数签名语义不同,必须继续独立保留,不能因名称相同而合并。
- 原版函数参数和函数体分别与共享实现AST相等;四个消费者的局部别名必须指向对应的唯一函数对象。
## CR-04结果
- 删除Tushare、智能选股、市场洞察和情绪引擎中的四份重复函数体,新建两种明确命名的共享策略;生产
代码净减少约12行,全仓AST扫描不再发现完全相同的函数定义。
- 将可复用的函数及模块AST契约归入测试辅助层,原有迁移保持性测试改为“未改范围保持相等、被替换
函数与共享实现相等、运行时唯一对象”三重断言,没有降低门禁。
- 实时行情`backend/data/realtime.py::_number`与图表`backend/features/market/charts.py::_number`
被明确保留;它们不是本批重复实现,也未改变行为。
- 58项定向测试、41项保持性/治理测试、原版231项、候选320项、纯`app/`导出257项和45项
Playwright通过;24个JavaScript文件、API/架构注册表、Git空白检查和SQLite完整性检查通过。
- 本批不修改前端、CSS、接口、数据来源、行情口径、选股条件、数据库、LLM、权限或部署。
本批基线为`xiaobai-reduction-03-market-symbol-20260801`;检查点为
`xiaobai-reduction-04-numeric-normalization-20260801`
## CR-05验收口径
- 逐项扫描根级Python入口、生产代码、测试、工具和动态导入;没有消费者或兼容责任的入口才能删除。
- 规范后端不得经由`screener``advanced_strategies``tushare_client``server`兼容入口
间接访问已经归位的实现。
- 所有根级模块继续保持原导入名称、导出对象及模块对象身份,既有启动命令和第三方维护脚本不受影响。
- `api_access`、选股Repository的惰性`sentiment_engine`导入及根级`database.py`属于已登记边界,
分别留到HTTP、Repository阶段处理,不在本批跨边界修改。
## CR-05结果
- 审计确认21个根级兼容入口均有测试、工具、启动或原公开导入契约消费者,因此本批没有冒险删除文件。
- 容器、策略编译器、选股引擎及数据同步命令的五处导入改为规范模块路径,正式代码不再通过四个根级
兼容模块反向进入实现;运行代码行数未增加。
- 特性边界测试取消选股引擎旧例外,并新增全后端兼容导入门禁;只允许两项已登记过渡边界,后续代码
无法重新引入隐式根级依赖。
- 候选321项、纯`app/`导出258项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite
完整性检查通过;本批不涉及页面、CSS或浏览器行为。
- 本批不修改业务计算、策略公式、数据源、API、数据库、LLM、权限、前端或部署。
本批基线为`xiaobai-reduction-04-numeric-normalization-20260801`;检查点为
`xiaobai-reduction-05-compatibility-boundaries-20260801`
## CR-06验收口径
- 只合并参数、函数体和异常行为完全相同的日期/文本转换;名称相似但空值、未来日期、错误文案或
输入格式不同的函数不得合并。
- Tushare与选股引擎继续暴露局部`_display_date`名称,并分别指向唯一共享实现。
- 原版两个`_display_date`函数必须分别与共享实现AST相等,所有原调用结果保持不变。
- 市场洞察的日期显示函数会清理连字符并容忍空值,语义不同,必须继续独立保留。
## CR-06结果
- 删除Tushare与选股引擎内两个重复日期函数体,新增`display_compact_date`唯一策略;生产代码总
行数不增加,重复函数体由两份降为一份。
- 架构清单登记日期格式唯一所有权;保持性测试改为未改范围AST相等、共享函数AST相等和运行时
对象身份三重契约,没有放宽原迁移门禁。
- `normalize_date`、市场洞察日期显示、实时行情时间格式和会员日期边界因语义不同均原样保留。
- 候选321项、纯`app/`导出258项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite
完整性检查通过;本批不涉及页面、CSS或浏览器行为。
- 本批不修改日期输入规则、业务计算、选股结果、接口、数据库、数据源、LLM、权限或部署。
本批基线为`xiaobai-reduction-05-compatibility-boundaries-20260801`;检查点为
`xiaobai-reduction-06-date-formatting-20260801`
## CR-07验收口径
- iFinD、图表、实时观察器和Provider适配器必须只在`build_data_gateway`创建,并由容器共享。
- Tushare必须继续通过实时Token供应器按需创建,不能为了减少对象数量缓存过期Token。
- 市场服务中为原版隔离测试桩保留的一处`TushareClient(self.token)`是明确兼容例外,不得被误判为
第二条正式数据链路。
- 不得合并Tushare、iFinD、东方财富和腾讯的传输、缓存、重试或降级逻辑。
## CR-07结果
- 全后端构造点扫描确认iFinD、MarketChart、东方财富图表和实时观察器均只有网关一个创建位置;
`ApplicationContainer`暴露的是同一对象引用,没有第二份客户端。
- Tushare Provider使用动态Token供应器,市场服务只有一处已登记测试兼容回退;本批没有发现可安全
删除的生产实现,因此不为追求行数强行修改运行代码。
- 架构清单新增Provider创建所有权,自动测试会在未来出现第二个未登记构造点时失败。
- 候选322项、纯`app/`导出259项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite
完整性检查通过;本批不涉及页面、CSS或浏览器行为。
- 本批不修改请求频率、缓存、重试、Token更新、数据源选择、计算口径、API、数据库或前端。
本批基线为`xiaobai-reduction-06-date-formatting-20260801`;检查点为
`xiaobai-reduction-07-provider-ownership-20260801`
## CR-08验收口径
- 只合并NDJSON响应头、事件序列化、完成事件、业务错误事件、客户端断线和连接关闭这些传输行为。
- 问师继续直接输出原事件字典;复盘助手继续把文本分片包装为`delta/content`事件。
- 两个功能各自的业务异常类型、请求体错误状态码、错误正文和流创建时机保持不变。
- `_write_stream_event`与连接生命周期必须只由`backend/http/handler.py`拥有,应用大类和功能HTTP
模块不再保留第二份实现。
## CR-08结果
- 删除问师和复盘助手各16行重复流式控制流,并将应用大类中的10行事件写入方法归入HTTP共享层;
新共享实现29行、两个调用适配共3行,生产代码净减少10行。
- 新增专项测试固定四个响应头、中文NDJSON序列化、增量顺序、完成事件、业务错误事件和关闭状态。
- 候选324项、纯`app/`导出261项及45项Playwright通过;24个JavaScript文件、API/架构注册表、
Git空白检查和SQLite完整性检查通过。
- 本批不修改提示词、模型选择、会员计次、流式正文、前端解析、API路径、数据库或数据源。
本批基线为`xiaobai-reduction-07-provider-ownership-20260801`;检查点为
`xiaobai-reduction-08-ndjson-transport-20260801`
## CR-09验收口径
- 只有消费者全部属于单一领域、且能够按原函数体机械移动的方法才从应用门面移出。
- `_ifind_field``_ifind_row_code`继续保持静态/类方法签名、字段优先级、大小写规则和代码正则。
- 账号委托属于稳定公开门面;系统设置属于跨领域协调;后台刷新留到CR-11,本批均不得删除或重写。
- 移动后`DashboardService`必须继续通过Mixin解析同名方法,调用点和返回值不变。
## CR-09结果
- 将iFinD字段匹配和股票代码提取两个方法从应用大类机械移动到股池服务,原版与迁移方法AST逐项
相等;应用大类不再直接拥有股池专属实现。
- 连同迁移期遗留空行,`backend/application.py`减少32行,股池服务增加24行,生产代码净减少8行。
- 候选324项、纯`app/`导出261项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite
完整性检查通过;本批不涉及页面、CSS或浏览器行为。
- 本批不修改字段匹配、涨跌停原因补全、接口、数据源、缓存、数据库、权限或前端。
本批基线为`xiaobai-reduction-08-ndjson-transport-20260801`;检查点为
`xiaobai-reduction-09-service-facade-20260801`
## CR-10验收口径
- 只移动调用方、数据表和业务含义均明确属于单一领域的方法;数据库连接、事务和返回值必须保持不变。
- `list_sector_phase_overrides``save_sector_phase_override``delete_sector_phase_override`必须由
`backend/features/heaven/repository.py`拥有,并继续通过`ReviewDatabase`的Mixin解析。
- 不修改表结构、迁移顺序、时间格式、排序、冲突更新或删除结果语义。
- `wencai_saved_queries`及其三个方法属于已登记的账户隔离兼容数据;即使前端入口已取消,也必须保留。
## CR-10结果
- 将五行行业阶段覆盖的查询、保存和删除三个方法从根级`database.py`机械移动到问天Repository
调用名称、SQL、事务边界、时间值和返回结果均未改变。
- 根级数据库门面不再直接拥有问天领域的持久化实现,问财历史兼容表和方法完整保留,未扩大删除范围。
- 34项Repository、问天、账户隔离、清理契约及迁移定向测试通过;候选324项、纯`app/`导出261项、
24个JavaScript文件、API/架构注册表、Git空白检查和SQLite完整性检查通过。
- 本批不修改页面、CSS、API、数据库结构、行情、数据源、业务计算、LLM、权限或后台任务。
本批基线为`xiaobai-reduction-09-service-facade-20260801`;检查点为
`xiaobai-reduction-10-repository-ownership-20260801`
## CR-11验收口径
- 导入`backend.application`或构造`DashboardService`不得启动后台调度;必须先成功绑定HTTP端口,
再由运行时显式启动。
- 同一`InProcessJobRunner`重复启动调度器必须返回同一活动线程,停止必须置位并在限定时间内等待退出,
有序停止后允许重新启动。
- 运行时关闭顺序固定为:停止调度、等待已提交任务、关闭HTTP服务器;迁移对比工具继续兼容原版入口。
- 三个任务的注册定义、5秒刷新频率、3秒初始延迟、幂等键、锁、重试、业务函数和结果不得改变。
- 声明的超时继续是目标与审计字段;Python线程不能安全强杀,本批不伪造硬取消能力。
## CR-11结果
- 删除`DashboardService`构造阶段的调度副作用,端口占用、模块导入和单元测试不再提前创建后台写线程;
`backend/bootstrap/runtime.py`成为正式启动与停止所有者。
- `InProcessJobRunner`集中持有调度停止事件和线程引用;重复启动幂等,停止可等待,原任务锁、持久化运行
状态、成功幂等、失败记录和后续重试逻辑保持不变。
- 新增语法树与运行顺序门禁,固定“构造不启动”“绑定后启动”“停止后关服”,并补齐重复启动与重启测试。
- 候选328项、纯`app/`导出265项和45项Playwright通过;24个JavaScript文件、API/架构注册表、
Git空白检查和SQLite完整性检查通过。
- 本批没有可安全删除的重复任务实现;为补齐原先缺失的生命周期,生产代码净增加24行。增加内容仅为
调度状态、幂等启停和运行时委托,不新增业务层、任务或兼容包装。
- 本批不修改页面、CSS、API、数据源、刷新计算、自动选股条件、数据库结构、权限或LLM。
本批基线为`xiaobai-reduction-10-repository-ownership-20260801`;检查点为
`xiaobai-reduction-11-background-jobs-20260801`
## CR-11人工验收修正
- 2026-08-02人工验收发现本地页面可访问,但行情与LLM同时无法连接。第一原因是验收服务由受限
自动化会话启动,子进程继承了禁止外部网络访问的权限;重新在主机正常网络权限下启动后恢复。
- 随后的主模型连接测试暴露`LLMServiceMixin`机械迁移时遗漏`validate_text`导入,导致请求在真正
访问模型前抛出`NameError`并关闭HTTP连接;恢复原依赖并增加保存模型连接探测的运行契约测试。
- 网站自身实测`000001`返回Tushare日K 60根、分时242点;主模型
`MiniMax-M2.7-highspeed`在2236毫秒内回复`OK`,证明服务进程的数据与LLM出网链路均已恢复。
- 修正后候选329项、纯`app/`导出266项通过;本次只恢复缺失导入和测试,不修改模型配置、额度、
提示词、回退策略、行情来源或计算逻辑。
- 继续验收观势时发现模型已经成功返回,但问天服务在保存解势记录前关闭了HTTP连接。原因是原版
`server.py`已有的`secrets`导入在机械拆分到问天服务时遗漏,生成非观气记录去重键时触发
`NameError`。迁移版恢复该标准库依赖,并增加“模型成功返回后保存观势结果”的完整服务回归测试。
- 使用`000001 平安银行`完成真实页面复测:六爻安全门6/6通过,解势结果正常返回并写入历史,
`8797`错误日志为空。该修正不改变提示词、模型选择、额度、卦象计算、记录结构或前端行为。
本修正基线为`xiaobai-reduction-11-background-jobs-20260801`;检查点为
`xiaobai-reduction-11-runtime-connectivity-fix-20260802`;后续解势修正检查点为
`xiaobai-reduction-11-heaven-interpret-fix-20260802`
## CR-12验收口径
- 本批只处理不同样式文件中、同为顶层、选择器与完整声明逐字等价的规则;媒体查询、伪状态、
动画、问天隔离样式以及仅外形相似的规则不进入删除范围。
- 删除的必须是较早加载的副本,较晚层继续提供完全相同的最终声明;样式加载顺序、变量、HTML、
JavaScript和主题切换逻辑均不改变。
- 迁移保真测试必须逐段登记允许退休的原始CSS文本,除登记片段外,其余源码继续与原版逐字符相等。
- 浏览器必须验证跨层顶层精确重复为零,并通过日间、夜间、桌面、390px移动端及全站交互回归。
## CR-12结果
- 通过浏览器CSSOM扫描七层运行样式,共发现57组完整重复规则;本批只批准其中7组跨文件顶层重复,
分别涉及工作区显示、折叠侧栏、板块轮动末列、选股概率值、摘要条两项声明及龙虎榜原因列。
其余50组位于同文件或嵌套媒体条件等更复杂环境,证据不足,继续保留。
- 删除7条较早加载的规则,三个生产CSS文件净减少19行、480字节;后层最终规则、选择器优先级与
加载顺序均未改变,没有新增兼容覆盖或第二套样式实现。
- 新增浏览器CSSOM门禁,任何两个样式层再次出现相同顶层选择器与完整声明都会失败;迁移保真门禁
只允许已登记的7个精确源码片段退休,其他CSS差异仍会失败。
- 真实`8797`页面复核情绪周期日间/夜间、龙虎榜和390×844移动端;三个受影响节点的计算样式
与删除前一致,移动端无横向溢出。候选330项、CSS/前端契约33项、迁移对照63项及46项
Playwright全部通过,24个JavaScript文件、API/架构注册表和SQLite完整性检查通过。
- 本批不修改页面布局、颜色、字体、间距、响应式规则、主题、动画、业务功能、API、数据库或部署。
本批基线为`xiaobai-reduction-11-heaven-interpret-fix-20260802`;检查点为
`xiaobai-reduction-12-css-exact-duplicates-20260802`
## CR-13验收口径
- 本批只处理不同样式文件中、处于浏览器规范化后完全相同媒体条件下、选择器与完整CSSOM声明完全
相同的规则;不同媒体上下文、同文件重复、近似声明、动画和问天隔离样式继续保留。
- 删除的必须是较早加载的副本,较晚样式层继续提供相同声明;媒体条件、规则顺序、选择器优先级、
变量、HTML、JavaScript和主题逻辑不得改变。
- 保真门禁必须逐段登记允许退休的原始源码;浏览器门禁必须递归遍历嵌套规则,并只将同一上下文内
跨文件的完整重复判为失败。
- 390×844明暗主题、1000×800中等宽度和1000×600低高度断点必须保持原计算样式与视觉结果。
## CR-13结果
- 浏览器CSSOM确认22组跨文件嵌套重复:1组位于721-1279px媒体条件,12组位于720px移动端条件,
9组位于720px或1023px低高度复合条件;全部删除较早层副本,后层规则原样保留。
- `styles.css`减少65行,`redesign-v2.css`减少15行,生产CSS合计净减少80行、约1.9 KB;没有新增
兼容覆盖、声明值、选择器或样式文件。
- Playwright门禁由顶层扫描扩展为递归上下文扫描,修改后同一嵌套上下文的跨文件完整重复为0;同文件
重复和不同上下文规则不在本批范围,未被误删。
- 1000×800和1000×600修改前后截图逐字节一致;390×844明暗主题的关键显示、定位、间距、网格、
溢出和导航状态一致,页面目视无差异。
- 候选330项、CSS/前端契约33项、迁移对照63项及46项Playwright全部通过;24个JavaScript文件、
API/架构注册表和SQLite完整性检查通过。
- 本批不修改页面布局、颜色、字体、间距、主题、动画、业务功能、API、数据库、数据源、LLM或部署。
本批基线为`xiaobai-reduction-12-css-exact-duplicates-20260802`;检查点为
`xiaobai-reduction-13-css-nested-duplicates-20260802`
## CR-13后续产品修正:龙虎榜整页滚动
- 2026-08-02用户明确要求取消龙虎榜“当日操作明细单独纵向滚动”,改为龙虎榜主内容区整页纵向滚动,
解决低分辨率下操作明细可视高度过小的问题;这是经批准的产品行为变化,不作为CSS去重处理。
- 龙虎榜从桌面固定视口共享规则中独立出来;主内容区继续使用工作区高度并承担纵向滚动,游资卡片、
操作明细和待归类席位按内容自然展开,宽操作表继续保留横向滚动。
- 保真门禁以精确源码替换单独登记本次差异,其他CSS仍与原母版逐字符比较;未新增覆盖层或第二套规则。
- 1366×768真实页面中主内容区为692px、内容高度为2620px,整页滚动可达;操作明细自身高度与内容
高度一致,不再形成纵向小窗口,1180px宽表仍可横向滚动。
- 本次不修改龙虎榜数据、筛选、搜索、游资卡牌、表格字段、游资档案、API、数据库或其他页面的
滚动所有权。
本修正基线为`xiaobai-reduction-13-css-nested-duplicates-20260802`;检查点为
`xiaobai-fix-dragon-page-scroll-20260802`
## CR-14验收口径
- 只处理同一CSS文件、同一浏览器规范化媒体上下文中,选择器和完整CSSOM声明完全相同的规则;
不同媒体上下文、近似声明、动画和问天隔离样式继续保留。
- 每组只删除较早出现的副本并保留最后一份原规则;样式文件加载顺序、媒体条件、选择器优先级、变量、
HTML、JavaScript和主题逻辑均不得改变。
- 保真门禁必须精确登记原始片段及其出现/退休次数;浏览器门禁从“只拒绝跨文件重复”提升为
“同一上下文内任何完整重复均拒绝”。
- 1366×768桌面暗色关键页面和390×844移动端关键页面的尺寸、滚动范围及视觉结果必须保持不变,
并通过全站Playwright回归。
## CR-14结果
- 浏览器CSSOM确认33组同文件精确重复,其中两个规则各出现三次;共退休35个较早副本:
`styles.css`9个、`renovation.css`25个、`redesign-v2.css`1个,运行时同上下文完整重复降为0。
- 三个生产CSS文件合计净减少97行、3272字节;未新增选择器、声明、覆盖层或样式文件,最后一份原规则
及其媒体上下文全部保留。
- 保真门禁新增精确出现次数与退休次数审计,除登记片段外继续与原母版逐字节比较;Playwright CSSOM
门禁现会拒绝跨文件和同文件重复,后续不能重新堆回同类规则。
- 1366×768暗色模式复核情绪周期、集合竞价、题材库、智能选股、问师和我的复盘;390×844复核
情绪周期、集合竞价、智能选股和我的复盘。关键尺寸、滚动范围保持一致,移动端情绪周期截图逐像素一致,
其余页面目视无差异且无横向溢出。
- 候选330项、CSS/前端契约33项、迁移对照63项及46项Playwright全部通过;24个JavaScript文件、
API/架构注册表和SQLite完整性检查通过。
- 本批不修改页面布局、颜色、字体、间距、响应式行为、主题、动画、业务功能、API、数据库、数据源、
LLM或部署。
本批基线为`xiaobai-fix-dragon-page-scroll-20260802`;检查点为
`xiaobai-reduction-14-css-same-file-duplicates-20260802`
## 人工验收记录
- 2026-08-01:用户检查CR-02与CR-03运行结果,确认未发现明显异常。本记录仅表示本轮可见功能与
页面使用未发现明显回归,不替代后续批次各自的自动测试和人工抽查。

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