Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf5d8c2b6 | ||
|
|
6144a480c7 | ||
|
|
b213b4362a | ||
|
|
1b333b6b93 | ||
|
|
224e2a7f21 | ||
|
|
ed9858e330 | ||
|
|
3ae07b8aae | ||
|
|
5a507239a3 | ||
|
|
152c0000ce | ||
|
|
cf7183d0c0 | ||
|
|
22d4dcd339 | ||
|
|
ee37223722 | ||
|
|
ad08d309c6 | ||
|
|
6ab910eeea | ||
|
|
f67100929b | ||
|
|
df45638edd | ||
|
|
bb67085dd4 | ||
|
|
e778c883db | ||
|
|
b60a4cb682 | ||
|
|
74304795ec | ||
|
|
0fba71f62c | ||
|
|
e0cba74f8e | ||
|
|
39b6f71443 | ||
|
|
8ac3adbb5d | ||
|
|
33f9db43b1 | ||
|
|
bd97ba1829 | ||
|
|
b3a21d05b7 | ||
|
|
6a058c2929 | ||
|
|
e1e76cd51e | ||
|
|
cc5fb8d73e | ||
|
|
9f691a47a0 |
@@ -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/`替代且没有剩余消费者的内容,不顺带修改产品行为。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
runtime/
|
||||
data/cache/
|
||||
data/private-mentor-skills/
|
||||
data/*.db
|
||||
|
||||
+2
-7
@@ -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/
|
||||
|
||||
@@ -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`不得提交。
|
||||
+135
-32
@@ -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,57 +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, JSON/NDJSON responses, static
|
||||
delivery, streaming connection lifecycle, 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`.
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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 的板块成分内核与个股数据。
|
||||
|
||||
登录后可调用:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from backend.features.alerts.service import AlertService
|
||||
|
||||
__all__ = ["AlertService"]
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Compatibility imports for code that still uses the original configuration module."""
|
||||
|
||||
from backend.bootstrap.config import * # noqa: F401,F403
|
||||
@@ -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
-969
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 []]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.question,question_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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -131,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(
|
||||
|
||||
@@ -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
@@ -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 "后者克前者"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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:]
|
||||
],
|
||||
}
|
||||
@@ -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),)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -104,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:
|
||||
|
||||
@@ -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,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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
@@ -298,94 +370,514 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
"/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": 360238,
|
||||
"lines": 15385
|
||||
"path": "frontend/pages/heaven/foundation.css",
|
||||
"bytes": 185936,
|
||||
"lines": 11734
|
||||
},
|
||||
{
|
||||
"path": "frontend/styles/redesign-v2.css",
|
||||
"bytes": 262826,
|
||||
"lines": 8554
|
||||
},
|
||||
{
|
||||
"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": 83812,
|
||||
"lines": 1550
|
||||
},
|
||||
{
|
||||
"path": "frontend/pages/heaven/page.css",
|
||||
"bytes": 73222,
|
||||
"lines": 1084
|
||||
},
|
||||
{
|
||||
"path": "backend/features/heaven/service.py",
|
||||
"bytes": 63138,
|
||||
"lines": 1304
|
||||
},
|
||||
{
|
||||
"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": 47769,
|
||||
"lines": 1092
|
||||
"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": 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。",
|
||||
"子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。",
|
||||
"妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。",
|
||||
"官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。",
|
||||
"父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
> ⚠️ **本文档已过时,仅留档备查,请勿删除。**
|
||||
> 本交接说明核实于 2026-08-06,其中「当前提交」「当前状态」「正在处理的事项」「验证记录」等已与代码现状不符(当时的未提交改动现已合并,项目已推进到全站视觉统一收尾阶段)。
|
||||
> 最新内容请看 `docs/项目需求.md`、`docs/最新进度.md`、`docs/任务清单.md` 和 `docs/README.md`。
|
||||
> 架构与维护规矩仍以根目录 `AGENTS.md`、`ARCHITECTURE.md` 为准;本文第 2、6 节(架构与决策)仍可作参考。
|
||||
|
||||
# 小白复盘项目交接说明
|
||||
|
||||
> 核实日期:2026-08-06(Asia/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 本轮结果;后续代码变化后不能沿用:
|
||||
|
||||
- Python:326 个测试全部通过(约 11.8 秒)。
|
||||
- Playwright:49 个测试全部通过(约 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 随后以同一配置单独运行并完整通过,因此上述各子项均有本轮实际结果。
|
||||
@@ -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. 更新完提交并推送进仓库(保存并上传到放代码的网站),不能只留在自己电脑里。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
|
||||
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
|
||||
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
|
||||
@@ -30,6 +30,8 @@
|
||||
| CR-10 | Repository所有权 | 五行行业阶段覆盖的三项持久化方法仍错位在根级数据库门面 | 原函数体机械归位到问天Repository,兼容数据继续保留 | 已完成 |
|
||||
| CR-11 | 后台任务生命周期 | 导入应用即启动调度线程,启动早于端口绑定,停止只置位但不等待 | 运行时显式启停,每个Runner只拥有一个可等待的调度线程 | 已完成 |
|
||||
| CR-12 | CSS跨层精确重复 | 七层样式保留多次视觉改造形成的重复顶层规则,前层声明被后层逐字覆盖 | 只删除能够由CSSOM证明完全重复的前层规则,并建立浏览器级重复门禁 | 已完成 |
|
||||
| CR-13 | CSS跨文件嵌套精确重复 | 相同媒体上下文中的完整规则分散在不同样式文件,前层声明仍被后层完整重复 | 递归核对CSSOM上下文,只退休跨文件的精确副本 | 已完成 |
|
||||
| CR-14 | CSS同文件精确重复 | 同一文件、同一媒体上下文仍保留多轮视觉调整形成的完整重复规则 | 删除较早副本并把同文件重复纳入浏览器门禁 | 已完成 |
|
||||
|
||||
## CR-01验收口径
|
||||
|
||||
@@ -348,6 +350,51 @@
|
||||
本批基线为`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
Reference in New Issue
Block a user