diff --git a/AGENTS.md b/AGENTS.md index d179369..705b923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`替代且没有剩余消费者的内容,不顺带修改产品行为。 diff --git a/app/.dockerignore b/app/.dockerignore index 82eb2ca..58d5f1d 100644 --- a/app/.dockerignore +++ b/app/.dockerignore @@ -7,6 +7,7 @@ __pycache__/ *.py[cod] *.log +runtime/ data/cache/ data/private-mentor-skills/ data/*.db diff --git a/app/.gitignore b/app/.gitignore index 9226beb..b62287d 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -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/ diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..85a2b4d --- /dev/null +++ b/app/AGENTS.md @@ -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//`,数据适配进入`backend/data/`,后台任务进入 + `backend/jobs/`,HTTP公共能力进入`backend/http/`。 +- 页面结构、行为和样式分别由`frontend/pages//`及`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`不得提交。 diff --git a/app/ARCHITECTURE.md b/app/ARCHITECTURE.md index 542357d..438c4e9 100644 --- a/app/ARCHITECTURE.md +++ b/app/ARCHITECTURE.md @@ -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,157 @@ 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//routes.py`. - `backend/features//` 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 + model interpretation belong to `readings.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//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//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//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. diff --git a/app/DOCKER_DEPLOY.md b/app/DOCKER_DEPLOY.md index dc43d63..378050d 100644 --- a/app/DOCKER_DEPLOY.md +++ b/app/DOCKER_DEPLOY.md @@ -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/ ``` 迁移包包含数据库和密钥,传输完成后应及时删除两端的压缩包。 diff --git a/app/README.md b/app/README.md index f03fb7a..7a6ac8b 100644 --- a/app/README.md +++ b/app/README.md @@ -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 的板块成分内核与个股数据。 登录后可调用: diff --git a/app/advanced_strategies.py b/app/advanced_strategies.py deleted file mode 100644 index fcb16c9..0000000 --- a/app/advanced_strategies.py +++ /dev/null @@ -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 diff --git a/app/alert_service.py b/app/alert_service.py deleted file mode 100644 index f4264ee..0000000 --- a/app/alert_service.py +++ /dev/null @@ -1,3 +0,0 @@ -from backend.features.alerts.service import AlertService - -__all__ = ["AlertService"] diff --git a/app/app_config.py b/app/app_config.py deleted file mode 100644 index 0ec4284..0000000 --- a/app/app_config.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Compatibility imports for code that still uses the original configuration module.""" - -from backend.bootstrap.config import * # noqa: F401,F403 diff --git a/app/assistant_agent.py b/app/assistant_agent.py deleted file mode 100644 index ba1b10f..0000000 --- a/app/assistant_agent.py +++ /dev/null @@ -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 diff --git a/app/backend/application.py b/app/backend/application.py index 4ed9570..d9b6121 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -1,54 +1,63 @@ from __future__ import annotations -import json -import re -import secrets import threading -import time -from datetime import date, datetime, time as dt_time, timedelta, timezone -from http import HTTPStatus +from datetime import datetime from http.server import BaseHTTPRequestHandler -from typing import Any -from urllib.parse import parse_qs, unquote, urlparse from api_access import ROUTES +from backend.bootstrap.config import DATA_DIR, MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR from backend.bootstrap.container import build_application_container from backend.bootstrap.settings import load_runtime_settings -from backend.http import HttpTransportMixin -from backend.llm import LLMGateway, LLMGatewayError -from backend.llm.http import LLMHttpMixin -from backend.llm.service import LLMServiceMixin -from backend.features.market import ChartDataError, MarketServiceMixin -from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field -from backend.features.alerts import AlertHttpMixin, AlertServiceMixin -from backend.features.review import ReviewHttpMixin, ReviewServiceMixin -from backend.bootstrap.config import ( - DATA_DIR, - MENTOR_SKILLS_DIR, - PRIVATE_MENTOR_SKILLS_DIR, - TOKEN_PATTERN, - normalize_date, - validate_text, -) -from database import ReviewDatabase +from backend.data.providers.tushare_client import TushareError +from backend.features.accounts.application import AccountApplicationMixin from backend.features.accounts.http import AccountHttpMixin +from backend.features.accounts.routes import AccountRoutesMixin from backend.features.accounts.security import SecretVault from backend.features.accounts.service import AccountService +from backend.features.alerts import AlertHttpMixin, AlertServiceMixin +from backend.features.alerts.routes import AlertRoutesMixin from backend.features.auction import AuctionServiceMixin +from backend.features.auction.routes import AuctionRoutesMixin from backend.features.dragon_tiger import DragonTigerServiceMixin +from backend.features.dragon_tiger.routes import DragonTigerRoutesMixin +from backend.features.heaven import HeavenHttpMixin, HeavenServiceMixin, build_personal_field +from backend.features.heaven.routes import HeavenRoutesMixin +from backend.features.market import MarketServiceMixin +from backend.features.market.routes import MarketRoutesMixin from backend.features.mentor import MentorHttpMixin, MentorServiceMixin +from backend.features.mentor.routes import MentorRoutesMixin from backend.features.pools import PoolServiceMixin +from backend.features.pools.routes import PoolRoutesMixin from backend.features.popularity import PopularityServiceMixin +from backend.features.popularity.routes import PopularityRoutesMixin +from backend.features.review import ReviewHttpMixin, ReviewServiceMixin +from backend.features.review.routes import ReviewRoutesMixin from backend.features.rotation import RotationServiceMixin +from backend.features.rotation.routes import RotationRoutesMixin +from backend.features.screener.routes import ScreenerRoutesMixin from backend.features.screener.service import ( SCREENER_LIBRARY_VERSION, ScreenerServiceMixin, automatic_screener_jobs, ) from backend.features.sentiment import SentimentServiceMixin +from backend.features.sentiment.routes import SentimentRoutesMixin from backend.features.system import SystemHttpMixin +from backend.features.system.routes import SystemRoutesMixin +from backend.features.system.service import SystemServiceMixin from backend.features.themes import ThemeServiceMixin -from backend.data.providers.tushare_client import TushareError +from backend.features.themes.routes import ThemeRoutesMixin +from backend.http import HttpTransportMixin +from backend.http.dispatch import ( + AUTHENTICATED_POST_HANDLERS, + PUBLIC_POST_HANDLERS, + ApplicationHttpDispatchMixin, +) +from backend.jobs.service import JobServiceMixin +from backend.llm import LLMGateway +from backend.llm.http import LLMHttpMixin +from backend.llm.service import LLMServiceMixin +from database import ReviewDatabase LEGACY_SECRET_KEYS = { @@ -66,7 +75,11 @@ LEGACY_SECRET_KEYS = { "LLM_FALLBACK_MODEL", } + class DashboardService( + SystemServiceMixin, + AccountApplicationMixin, + JobServiceMixin, MarketServiceMixin, SentimentServiceMixin, PoolServiceMixin, @@ -129,392 +142,26 @@ class DashboardService( ) self.screener.ensure_builtin_strategies() - 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 _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 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 _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 membership(self) -> dict[str, Any]: - return self.accounts.membership() - - - 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 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 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) - - 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) - - - - - 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(), - } SERVICE = DashboardService() -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 RequestHandler( + SystemRoutesMixin, + AccountRoutesMixin, + AlertRoutesMixin, + ReviewRoutesMixin, + MarketRoutesMixin, + AuctionRoutesMixin, + ThemeRoutesMixin, + PopularityRoutesMixin, + SentimentRoutesMixin, + RotationRoutesMixin, + DragonTigerRoutesMixin, + ScreenerRoutesMixin, + MentorRoutesMixin, + HeavenRoutesMixin, + PoolRoutesMixin, AccountHttpMixin, SystemHttpMixin, MentorHttpMixin, @@ -522,571 +169,10 @@ class RequestHandler( AlertHttpMixin, ReviewHttpMixin, LLMHttpMixin, + ApplicationHttpDispatchMixin, HttpTransportMixin, BaseHTTPRequestHandler, ): server_version = "XiaobaiReviewWeb/0.8" application_service = SERVICE route_registry = ROUTES - - 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 parsed.path == "/api/health": - self.send_json( - { - "ok": True, - "storage": "sqlite", - "account_required": True, - "time": datetime.now().astimezone().isoformat(timespec="seconds"), - } - ) - return - if parsed.path == "/api/auth/me": - self.auth_me() - return - if parsed.path.startswith("/api/"): - if not self.require_auth(): - return - if not self.require_access("GET", parsed.path): - return - if parsed.path == "/api/admin/settings": - self.send_json( - {"ok": True, **SERVICE.system_status(), "users": SERVICE.admin_users()} - ) - return - if parsed.path == "/api/account/status": - self.send_json({"ok": True, **SERVICE.status()}) - return - if parsed.path == "/api/alerts": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/trades": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/assistant/messages": - self.send_json({"items": SERVICE.assistant_messages()}) - return - if parsed.path == "/api/dashboard": - query = parse_qs(parsed.query) - trade_date = query.get("trade_date", [date.today().isoformat()])[0] - try: - self.send_json(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 - if parsed.path == "/api/auction": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/themes": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/themes/detail": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/popularity": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - if parsed.path == "/api/realtime-aggregate/health": - query = parse_qs(parsed.query) - try: - self.send_json( - { - "ok": True, - "aggregate": SERVICE.realtime_aggregate_health( - query.get("sector", [""])[0] - ), - } - ) - except ValueError as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - 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(SERVICE.sentiment_history(trade_date, limit)) - except (TypeError, ValueError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - 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(SERVICE.rotation_history(trade_date, 9)) - except (TypeError, ValueError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - if parsed.path == "/api/rotation/members": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - 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(SERVICE.get_dragon_tiger(trade_date, force)) - except ValueError as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - if parsed.path == "/api/dragon-tiger/profiles": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - 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(SERVICE.search_entities(search_query, trade_date)) - except ValueError as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - 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( - 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 - 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(SERVICE.get_intraday_chart(entity_type, identifier)) - except (ValueError, ChartDataError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - 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( - 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 - 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(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 - if parsed.path == "/api/watchlist": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - 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 - self.send_json( - { - "items": SERVICE.database.list_notes( - SERVICE.current_user_id, code, trade_date, scope - ) - } - ) - return - if parsed.path == "/api/seat-aliases": - self.send_json({"items": SERVICE.database.list_seat_aliases()}) - return - 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(SERVICE.screener_setup(trade_date)) - except ValueError as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - if parsed.path == "/api/screener/tracking": - query = parse_qs(parsed.query) - try: - self.send_json( - SERVICE.screener_tracking(int(query.get("limit", ["12"])[0])) - ) - except (TypeError, ValueError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - 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(SERVICE.mentor_setup(trade_date)) - except ValueError as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) - return - if parsed.path == "/api/mentors/messages": - query = parse_qs(parsed.query) - try: - self.send_json( - { - "items": 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 - if parsed.path == "/api/heaven/readings": - query = parse_qs(parsed.query) - try: - self.send_json( - 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 - 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 - try: - self.send_json( - 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 - 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 - alert_read_match = re.fullmatch(r"/api/alerts/(\d+)/read", parsed.path) - if alert_read_match: - self.send_json( - {"ok": True, **SERVICE.mark_alert_read(int(alert_read_match.group(1)))} - ) - return - if parsed.path == "/api/alerts/read-all": - body = self.read_json_body(True) - self.send_json( - {"ok": True, **SERVICE.mark_all_alerts_read(str(body.get("as_of") or ""))} - ) - return - if parsed.path == "/api/screener/tracking": - try: - result = 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 - if parsed.path == "/api/mentors/preferences": - try: - result = 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 - 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 - if parsed.path == "/api/account/birth-profile": - deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id) - self.send_json({"ok": True, "deleted": deleted}) - return - if parsed.path == "/api/assistant/messages": - deleted = SERVICE.clear_assistant_messages() - self.send_json({"ok": True, "deleted": deleted}) - return - if parsed.path == "/api/mentors/messages": - query = parse_qs(parsed.query) - try: - deleted = 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 - strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path) - if strategy_match: - try: - result = 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 - tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path) - if tracking_match: - result = SERVICE.remove_screener_tracking(int(tracking_match.group(1))) - self.send_json({"ok": True, **result}) - return - watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path) - if watchlist_match: - deleted = SERVICE.database.delete_watchlist( - SERVICE.current_user_id, watchlist_match.group(1) - ) - self.send_json({"ok": True, "deleted": deleted}) - return - note_match = re.fullmatch(r"/api/notes/(\d+)", parsed.path) - if note_match: - deleted = SERVICE.database.delete_note( - SERVICE.current_user_id, int(note_match.group(1)) - ) - self.send_json({"ok": True, "deleted": deleted}) - return - alert_match = re.fullmatch(r"/api/alerts/(\d+)", parsed.path) - if alert_match: - self.send_json( - {"ok": True, **SERVICE.delete_alert(int(alert_match.group(1)))} - ) - return - trade_match = re.fullmatch(r"/api/trades/(\d+)", parsed.path) - if trade_match: - self.send_json( - {"ok": True, **SERVICE.delete_trade_entry(int(trade_match.group(1)))} - ) - return - heaven_reading_match = re.fullmatch(r"/api/heaven/readings/(\d+)", parsed.path) - if heaven_reading_match: - deleted = SERVICE.database.delete_heaven_reading( - SERVICE.current_user_id, int(heaven_reading_match.group(1)) - ) - self.send_json({"ok": True, "deleted": deleted}) - return - 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 = SERVICE.database.delete_sector_phase_override(name) - self.send_json({"ok": True, "deleted": deleted}) - return - self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND) - - def save_reason(self) -> None: - try: - body = self.read_json_body() - 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) - - 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) - 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) - - 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("五行归类必须是木、火、土、金或水。") - 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) - - def backfill_data(self) -> None: - try: - body = self.read_json_body() - results = 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) - - def sync_screener_data(self) -> None: - try: - body = self.read_json_body() - result = 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 = 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 = 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 = 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, **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) diff --git a/app/backend/data/providers/tushare_client.py b/app/backend/data/providers/tushare_client.py index 4c071b9..8b8d5b5 100644 --- a/app/backend/data/providers/tushare_client.py +++ b/app/backend/data/providers/tushare_client.py @@ -1,30 +1,60 @@ from __future__ import annotations -import json -import math -import re -import urllib.error -import urllib.request -from collections import Counter from dataclasses import dataclass -from datetime import datetime, time as dt_time, timedelta from threading import Lock from typing import Any, ClassVar 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 - - -TUSHARE_URL = "http://api.tushare.pro" - - -class TushareError(RuntimeError): - pass +from backend.data.providers.tushare_daily import DailyMarketMixin +from backend.data.providers.tushare_dashboard import ( + DashboardMixin, + _build_ladders, + _build_limit_performance, + _build_overview, + _build_sector_rotation, + _build_sectors, + _build_yesterday_performance, +) +from backend.data.providers.tushare_dragon_tiger import DragonTigerMixin +from backend.data.providers.tushare_helpers import ( + _display_time, + _prices_equal, + _realtime_market_status, + _text, + _trading_session_progress, + _value_percentile, +) +from backend.data.providers.tushare_indices import IndexMixin +from backend.data.providers.tushare_industries import ( + ShenwanIndustryMixin, + _filter_members_by_listing, + _match_sector_row, + _membership_active_on, + _reconcile_membership_rows, + _sector_coverage_issue, + _sector_match_priority, +) +from backend.data.providers.tushare_sectors import SectorMixin +from backend.data.providers.tushare_stocks import StockMixin +from backend.data.providers.tushare_transport import ( + TUSHARE_URL, + TushareError, + TushareTransportMixin, +) @dataclass -class TushareClient: +class TushareClient( + DashboardMixin, + IndexMixin, + ShenwanIndustryMixin, + SectorMixin, + DragonTigerMixin, + StockMixin, + DailyMarketMixin, + TushareTransportMixin, +): token: str timeout: int = 30 _realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {} @@ -36,2130 +66,3 @@ class TushareClient: _stock_listing_lock: ClassVar[Lock] = Lock() _suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {} _suspension_lock: ClassVar[Lock] = Lock() - - 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 []] - - 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 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, - }, - } - - 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 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 等权涨跌、宽度与成交额聚合", - } - - 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": [], - } - - 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} - - 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, - } - - -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 _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, - ) - - -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" - - -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 diff --git a/app/backend/data/providers/tushare_daily.py b/app/backend/data/providers/tushare_daily.py new file mode 100644 index 0000000..bb09d32 --- /dev/null +++ b/app/backend/data/providers/tushare_daily.py @@ -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, + } diff --git a/app/backend/data/providers/tushare_dashboard.py b/app/backend/data/providers/tushare_dashboard.py new file mode 100644 index 0000000..b7e98c1 --- /dev/null +++ b/app/backend/data/providers/tushare_dashboard.py @@ -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 diff --git a/app/backend/data/providers/tushare_dragon_tiger.py b/app/backend/data/providers/tushare_dragon_tiger.py new file mode 100644 index 0000000..f81b304 --- /dev/null +++ b/app/backend/data/providers/tushare_dragon_tiger.py @@ -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": [], + } diff --git a/app/backend/data/providers/tushare_helpers.py b/app/backend/data/providers/tushare_helpers.py new file mode 100644 index 0000000..6260e9e --- /dev/null +++ b/app/backend/data/providers/tushare_helpers.py @@ -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" diff --git a/app/backend/data/providers/tushare_indices.py b/app/backend/data/providers/tushare_indices.py new file mode 100644 index 0000000..7087628 --- /dev/null +++ b/app/backend/data/providers/tushare_indices.py @@ -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, + }, + } diff --git a/app/backend/data/providers/tushare_industries.py b/app/backend/data/providers/tushare_industries.py new file mode 100644 index 0000000..c75f970 --- /dev/null +++ b/app/backend/data/providers/tushare_industries.py @@ -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, + ) diff --git a/app/backend/data/providers/tushare_sectors.py b/app/backend/data/providers/tushare_sectors.py new file mode 100644 index 0000000..5e76d2a --- /dev/null +++ b/app/backend/data/providers/tushare_sectors.py @@ -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 等权涨跌、宽度与成交额聚合", + } diff --git a/app/backend/data/providers/tushare_stocks.py b/app/backend/data/providers/tushare_stocks.py new file mode 100644 index 0000000..0d0434e --- /dev/null +++ b/app/backend/data/providers/tushare_stocks.py @@ -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} diff --git a/app/backend/data/providers/tushare_transport.py b/app/backend/data/providers/tushare_transport.py new file mode 100644 index 0000000..30e602e --- /dev/null +++ b/app/backend/data/providers/tushare_transport.py @@ -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 []] diff --git a/app/backend/features/accounts/application.py b/app/backend/features/accounts/application.py new file mode 100644 index 0000000..6608e6b --- /dev/null +++ b/app/backend/features/accounts/application.py @@ -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) diff --git a/app/backend/features/accounts/routes.py b/app/backend/features/accounts/routes.py new file mode 100644 index 0000000..89af383 --- /dev/null +++ b/app/backend/features/accounts/routes.py @@ -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 diff --git a/app/backend/features/alerts/routes.py b/app/backend/features/alerts/routes.py new file mode 100644 index 0000000..21aa2e3 --- /dev/null +++ b/app/backend/features/alerts/routes.py @@ -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 diff --git a/app/backend/features/auction/routes.py b/app/backend/features/auction/routes.py new file mode 100644 index 0000000..26e6f5e --- /dev/null +++ b/app/backend/features/auction/routes.py @@ -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 diff --git a/app/backend/features/dragon_tiger/routes.py b/app/backend/features/dragon_tiger/routes.py new file mode 100644 index 0000000..ae13652 --- /dev/null +++ b/app/backend/features/dragon_tiger/routes.py @@ -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) diff --git a/app/backend/features/heaven/manual.py b/app/backend/features/heaven/manual.py new file mode 100644 index 0000000..bbf27ce --- /dev/null +++ b/app/backend/features/heaven/manual.py @@ -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 diff --git a/app/backend/features/heaven/market_context.py b/app/backend/features/heaven/market_context.py new file mode 100644 index 0000000..a18c47b --- /dev/null +++ b/app/backend/features/heaven/market_context.py @@ -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 diff --git a/app/backend/features/heaven/readings.py b/app/backend/features/heaven/readings.py new file mode 100644 index 0000000..dbf5955 --- /dev/null +++ b/app/backend/features/heaven/readings.py @@ -0,0 +1,220 @@ +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 HeavenAgentError, interpret_heaven +from backend.features.heaven.engine import ( + build_five_phase_field, + hexagram_from_lines, +) +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 {} + return ( + f"{display_date} 观心", + f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}", + ) + + 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())) + 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: + return { + "answer": existing["answer"], + "mode": mode, + "compiler": "stored", + "notice": "", + "reading": existing, + "reused": True, + } + 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, + ) + fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False)) + catalog = fortune_field.pop("sector_catalog", []) + dominant_elements = { + item.get("element") for item in fortune_field.get("balance", [])[:2] + } + fortune_field["industry_affinity"] = [ + { + "element": group.get("element"), + "examples": [ + item.get("name") + for item in group.get("industries", [])[:8] + if item.get("name") + ], + } + for group in catalog + if group.get("element") in dominant_elements + ] + context = { + "calendar_date": setup["calendar_date"], + "five_phase_field": fortune_field, + "personal_profile": personal_profile, + } + context_date = setup["calendar_date"] + if mode == "trend": + context_date = setup["trade_date"] + else: + context = { + "hexagram": self.heaven_hexagram(payload.get("lines")), + "ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。", + } + context_date = trade_date + result, compiler = self._call_heaven_agent(mode, 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)}" + ) + reading = self.database.save_heaven_reading( + self.current_user_id, + mode, + context_date, + subject, + subject_detail, + str(result.get("answer") or ""), + 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]: + result = self.llm_gateway.call( + f"heaven_{mode}", + f"heaven-{mode}-v1", + lambda profile: interpret_heaven( + mode, + context, + profile.api_key, + profile.base_url, + profile.model, + ), + (HeavenAgentError,), + ) + return result.value, result.role diff --git a/app/backend/features/heaven/routes.py b/app/backend/features/heaven/routes.py new file mode 100644 index 0000000..5ceb2c7 --- /dev/null +++ b/app/backend/features/heaven/routes.py @@ -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) diff --git a/app/backend/features/heaven/service.py b/app/backend/features/heaven/service.py index 9b00856..55803d9 100644 --- a/app/backend/features/heaven/service.py +++ b/app/backend/features/heaven/service.py @@ -1,1304 +1,15 @@ from __future__ import annotations -import copy -import json -import re -import secrets -from datetime import date, 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, _sector_coverage_issue -from backend.features.heaven.agent import HeavenAgentError, interpret_heaven -from backend.features.heaven.engine import ( - _market_line_scores, - _score_to_line, - build_five_phase_field, - build_market_hexagram, - hexagram_from_lines, -) -from backend.features.market import MarketServiceMixin +from backend.features.heaven.manual import HeavenManualMixin +from backend.features.heaven.market_context import HeavenMarketContextMixin +from backend.features.heaven.readings import HeavenReadingMixin +from backend.features.heaven.trend import HeavenTrendMixin -class HeavenServiceMixin: - @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 - - 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_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.database.latest_heaven_reading( - self.current_user_id, "fortune", normalized_date - ) - if self._legacy_truncated_heaven_reading(daily_fortune_reading): - daily_fortune_reading = None - 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 _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, - } - - @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 - - 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 {} - return ( - f"{display_date} 观心", - f"{hexagram.get('name') or '--'} → {transformed.get('name') or '--'}", - ) - - 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())) - 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: - return { - "answer": existing["answer"], - "mode": mode, - "compiler": "stored", - "notice": "", - "reading": existing, - "reused": True, - } - 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, - ) - fortune_field = json.loads(json.dumps(setup["field"], ensure_ascii=False)) - catalog = fortune_field.pop("sector_catalog", []) - dominant_elements = { - item.get("element") for item in fortune_field.get("balance", [])[:2] - } - fortune_field["industry_affinity"] = [ - { - "element": group.get("element"), - "examples": [ - item.get("name") - for item in group.get("industries", [])[:8] - if item.get("name") - ], - } - for group in catalog - if group.get("element") in dominant_elements - ] - context = { - "calendar_date": setup["calendar_date"], - "five_phase_field": fortune_field, - "personal_profile": personal_profile, - } - context_date = setup["calendar_date"] - if mode == "trend": - context_date = setup["trade_date"] - else: - context = { - "hexagram": self.heaven_hexagram(payload.get("lines")), - "ritual": "用户已完成30秒静心、六次三枚铜钱起卦,并在心中察看第一念。问题未输入。", - } - context_date = trade_date - result, compiler = self._call_heaven_agent(mode, 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)}" - ) - reading = self.database.save_heaven_reading( - self.current_user_id, - mode, - context_date, - subject, - subject_detail, - str(result.get("answer") or ""), - 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]: - result = self.llm_gateway.call( - f"heaven_{mode}", - f"heaven-{mode}-v1", - lambda profile: interpret_heaven( - mode, - context, - profile.api_key, - profile.base_url, - profile.model, - ), - (HeavenAgentError,), - ) - return result.value, result.role - - 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 +class HeavenServiceMixin( + HeavenManualMixin, + HeavenMarketContextMixin, + HeavenTrendMixin, + HeavenReadingMixin, +): + pass diff --git a/app/backend/features/heaven/trend.py b/app/backend/features/heaven/trend.py new file mode 100644 index 0000000..13aafa7 --- /dev/null +++ b/app/backend/features/heaven/trend.py @@ -0,0 +1,358 @@ +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.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.database.latest_heaven_reading( + self.current_user_id, "fortune", normalized_date + ) + if self._legacy_truncated_heaven_reading(daily_fortune_reading): + daily_fortune_reading = None + 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 "", + }, + } + + @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 diff --git a/app/backend/features/market/insights.py b/app/backend/features/market/insights.py index 4ec8274..b68becd 100644 --- a/app/backend/features/market/insights.py +++ b/app/backend/features/market/insights.py @@ -1,5 +1,6 @@ from __future__ import annotations +# These imports preserve the historical module-level compatibility surface. import copy import json from datetime import datetime, time as dt_time, timedelta, timezone @@ -9,1299 +10,36 @@ from typing import TYPE_CHECKING, Any, Callable from backend.data.numbers import non_nan_number as _number from backend.data.providers.ifind_client import IfindError, IfindHttpClient from backend.data.providers.tushare_client import TushareClient, TushareError - -if TYPE_CHECKING: - from database import ReviewDatabase +from backend.features.market.insights_auction import ( + MarketAuctionInsightsMixin as _MarketAuctionInsightsMixin, +) +from backend.features.market.insights_auction_data import ( + MarketAuctionDataMixin as _MarketAuctionDataMixin, +) +from backend.features.market.insights_auction_scoring import ( + MarketAuctionScoringMixin as _MarketAuctionScoringMixin, +) +from backend.features.market.insights_context import ( + CHINA_TIMEZONE, + MarketInsightsContextMixin as _MarketInsightsContextMixin, + _display_date, +) +from backend.features.market.insights_popularity import ( + MarketPopularityInsightsMixin as _MarketPopularityInsightsMixin, +) +from backend.features.market.insights_themes import ( + MarketThemeInsightsMixin as _MarketThemeInsightsMixin, +) -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 MarketInsightsService: +class MarketInsightsService( + _MarketInsightsContextMixin, + _MarketAuctionScoringMixin, + _MarketAuctionDataMixin, + _MarketAuctionInsightsMixin, + _MarketThemeInsightsMixin, + _MarketPopularityInsightsMixin, +): """Read-only market features backed by Tushare and shared SQLite caches.""" - 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 _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 _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 _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]} - - 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 - - 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) - - 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 - - @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()] - - 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 + pass diff --git a/app/backend/features/market/insights_auction.py b/app/backend/features/market/insights_auction.py new file mode 100644 index 0000000..c7bb675 --- /dev/null +++ b/app/backend/features/market/insights_auction.py @@ -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) diff --git a/app/backend/features/market/insights_auction_data.py b/app/backend/features/market/insights_auction_data.py new file mode 100644 index 0000000..d55a394 --- /dev/null +++ b/app/backend/features/market/insights_auction_data.py @@ -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 diff --git a/app/backend/features/market/insights_auction_scoring.py b/app/backend/features/market/insights_auction_scoring.py new file mode 100644 index 0000000..e1e4f09 --- /dev/null +++ b/app/backend/features/market/insights_auction_scoring.py @@ -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]} diff --git a/app/backend/features/market/insights_context.py b/app/backend/features/market/insights_context.py new file mode 100644 index 0000000..a223428 --- /dev/null +++ b/app/backend/features/market/insights_context.py @@ -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()] diff --git a/app/backend/features/market/insights_popularity.py b/app/backend/features/market/insights_popularity.py new file mode 100644 index 0000000..f057309 --- /dev/null +++ b/app/backend/features/market/insights_popularity.py @@ -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 diff --git a/app/backend/features/market/insights_themes.py b/app/backend/features/market/insights_themes.py new file mode 100644 index 0000000..2c5a4a1 --- /dev/null +++ b/app/backend/features/market/insights_themes.py @@ -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 diff --git a/app/backend/features/market/routes.py b/app/backend/features/market/routes.py new file mode 100644 index 0000000..1055216 --- /dev/null +++ b/app/backend/features/market/routes.py @@ -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 diff --git a/app/backend/features/mentor/routes.py b/app/backend/features/mentor/routes.py new file mode 100644 index 0000000..f9aba96 --- /dev/null +++ b/app/backend/features/mentor/routes.py @@ -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 diff --git a/app/backend/features/pools/routes.py b/app/backend/features/pools/routes.py new file mode 100644 index 0000000..2adafd0 --- /dev/null +++ b/app/backend/features/pools/routes.py @@ -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) diff --git a/app/backend/features/popularity/routes.py b/app/backend/features/popularity/routes.py new file mode 100644 index 0000000..830889b --- /dev/null +++ b/app/backend/features/popularity/routes.py @@ -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 diff --git a/app/backend/features/review/routes.py b/app/backend/features/review/routes.py new file mode 100644 index 0000000..9e568ba --- /dev/null +++ b/app/backend/features/review/routes.py @@ -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 diff --git a/app/backend/features/rotation/routes.py b/app/backend/features/rotation/routes.py new file mode 100644 index 0000000..a9b37dd --- /dev/null +++ b/app/backend/features/rotation/routes.py @@ -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 diff --git a/app/backend/features/screener/backtest.py b/app/backend/features/screener/backtest.py new file mode 100644 index 0000000..e9a7295 --- /dev/null +++ b/app/backend/features/screener/backtest.py @@ -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, + } diff --git a/app/backend/features/screener/catalog.py b/app/backend/features/screener/catalog.py new file mode 100644 index 0000000..f8e985e --- /dev/null +++ b/app/backend/features/screener/catalog.py @@ -0,0 +1,707 @@ +from __future__ import annotations + +from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES + + +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) diff --git a/app/backend/features/screener/compiler.py b/app/backend/features/screener/compiler.py index 167a473..445da26 100644 --- a/app/backend/features/screener/compiler.py +++ b/app/backend/features/screener/compiler.py @@ -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): diff --git a/app/backend/features/screener/data_sync.py b/app/backend/features/screener/data_sync.py new file mode 100644 index 0000000..6680fbe --- /dev/null +++ b/app/backend/features/screener/data_sync.py @@ -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), + } diff --git a/app/backend/features/screener/engine.py b/app/backend/features/screener/engine.py index 7d7baf9..0d56dac 100644 --- a/app/backend/features/screener/engine.py +++ b/app/backend/features/screener/engine.py @@ -1,1061 +1,71 @@ from __future__ import annotations -import copy -import json -import math -import statistics -from collections import defaultdict -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 -from backend.data.providers.tushare_client import TushareClient, TushareError -from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES +from backend.features.screener.backtest import BacktestRunner +from backend.features.screener.catalog import ( + ADVANCED_CURATED_STRATEGIES, + ALLOWED_OPERATORS, + BUILTIN_STRATEGIES, + CURATED_STRATEGIES, + FACTOR_FIELDS, + FACTOR_GROUPS, + REGIMES, + STRATEGY_ENVIRONMENT_NOTES, +) +from backend.features.screener.data_sync import ( + FactorDataService, + _earnings_event_rows, + _popularity_factor_rows, + _quarter_periods, +) +from backend.features.screener.factors import FactorBuilder +from backend.features.screener.formula import FormulaEvaluator, compile_local_strategy +from backend.features.screener.indicators import ( + _available_percentile_map, + _broken_reversal_metrics, + _ema, + _ending_streak, + _is_limit_bar, + _limit_threshold, + _macd_last, + _macd_series, + _matches, + _max_streak, + _optional_number, + _pearson, + _percentile_map, + _regime_reason, + _risk_flags, + _rounded_optional, + _rsi, + _touched_limit_bar, + _weekly_series, +) +from backend.features.screener.regime import RegimeDetector +from backend.features.screener.selection import SelectionRunner from database import ReviewDatabase -from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history - - -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) - - -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), - } class ScreenerEngine: + """Stable facade over the independently owned screener services.""" + def __init__(self, database: ReviewDatabase) -> None: self.database = database - self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {} + self.factor_builder = FactorBuilder(database) + self.formula_evaluator = FormulaEvaluator() + self.regime_detector = RegimeDetector(database) + self.backtest_runner = BacktestRunner( + database, self.factor_builder, self.formula_evaluator + ) + self.selection_runner = SelectionRunner( + database, + self.factor_builder, + self.formula_evaluator, + self.backtest_runner, + ) def ensure_builtin_strategies(self) -> None: existing = { @@ -1071,81 +81,13 @@ class ScreenerEngine: ) 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:] - ], - } + return self.regime_detector.detect_regime(trade_date) def factor_health(self, trade_date: str) -> dict[str, Any]: return self.database.factor_health_summary(trade_date) 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 + return self.formula_evaluator.validate_formula(formula) def screen( self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str, @@ -1155,89 +97,18 @@ class ScreenerEngine: 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) + return self.selection_runner.screen( + user_id, + trade_date, + formula, + regime, + strategy_name, + run_backtest, + realtime_snapshot, + mode, + prepared_factors, + prepared_date, ) - 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 def build_factors( self, @@ -1245,959 +116,14 @@ class ScreenerEngine: 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" + return self.factor_builder.build_factors( + trade_date, realtime_snapshot, history_days ) - 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 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"]] + 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, - } - - -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", - } - - -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, "市场阶段待确认。") + return self.backtest_runner.backtest(trade_date, formula) diff --git a/app/backend/features/screener/factors.py b/app/backend/features/screener/factors.py new file mode 100644 index 0000000..5fd064d --- /dev/null +++ b/app/backend/features/screener/factors.py @@ -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 diff --git a/app/backend/features/screener/formula.py b/app/backend/features/screener/formula.py new file mode 100644 index 0000000..0a9b302 --- /dev/null +++ b/app/backend/features/screener/formula.py @@ -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", + } diff --git a/app/backend/features/screener/indicators.py b/app/backend/features/screener/indicators.py new file mode 100644 index 0000000..f7c8efe --- /dev/null +++ b/app/backend/features/screener/indicators.py @@ -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, "市场阶段待确认。") diff --git a/app/backend/features/screener/regime.py b/app/backend/features/screener/regime.py new file mode 100644 index 0000000..df81525 --- /dev/null +++ b/app/backend/features/screener/regime.py @@ -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:] + ], + } diff --git a/app/backend/features/screener/repository.py b/app/backend/features/screener/repository.py index 16f962b..9d3f16a 100644 --- a/app/backend/features/screener/repository.py +++ b/app/backend/features/screener/repository.py @@ -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 [ { diff --git a/app/backend/features/screener/routes.py b/app/backend/features/screener/routes.py new file mode 100644 index 0000000..a3a93ca --- /dev/null +++ b/app/backend/features/screener/routes.py @@ -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) diff --git a/app/backend/features/screener/selection.py b/app/backend/features/screener/selection.py new file mode 100644 index 0000000..34e98f1 --- /dev/null +++ b/app/backend/features/screener/selection.py @@ -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 diff --git a/app/backend/features/screener/service.py b/app/backend/features/screener/service.py index d22ae06..e74a109 100644 --- a/app/backend/features/screener/service.py +++ b/app/backend/features/screener/service.py @@ -12,13 +12,9 @@ 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 SCREENER_LIBRARY_VERSION = 8 diff --git a/app/backend/features/sentiment/routes.py b/app/backend/features/sentiment/routes.py new file mode 100644 index 0000000..be34aa1 --- /dev/null +++ b/app/backend/features/sentiment/routes.py @@ -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 diff --git a/app/backend/features/system/routes.py b/app/backend/features/system/routes.py new file mode 100644 index 0000000..5956cc9 --- /dev/null +++ b/app/backend/features/system/routes.py @@ -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) diff --git a/app/backend/features/system/service.py b/app/backend/features/system/service.py new file mode 100644 index 0000000..4f14ebe --- /dev/null +++ b/app/backend/features/system/service.py @@ -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(), + } diff --git a/app/backend/features/themes/routes.py b/app/backend/features/themes/routes.py new file mode 100644 index 0000000..5f6cedd --- /dev/null +++ b/app/backend/features/themes/routes.py @@ -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 diff --git a/app/backend/http/dispatch.py b/app/backend/http/dispatch.py new file mode 100644 index 0000000..3b58526 --- /dev/null +++ b/app/backend/http/dispatch.py @@ -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) diff --git a/app/backend/jobs/service.py b/app/backend/jobs/service.py new file mode 100644 index 0000000..35bc31e --- /dev/null +++ b/app/backend/jobs/service.py @@ -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) diff --git a/app/chart_data_provider.py b/app/chart_data_provider.py deleted file mode 100644 index f07b035..0000000 --- a/app/chart_data_provider.py +++ /dev/null @@ -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 diff --git a/app/config/README.md b/app/config/README.md index f27497a..5b352f6 100644 --- a/app/config/README.md +++ b/app/config/README.md @@ -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. diff --git a/app/config/architecture-inventory.json b/app/config/architecture-inventory.json index 70444c7..dfebedc 100644 --- a/app/config/architecture-inventory.json +++ b/app/config/architecture-inventory.json @@ -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", @@ -299,73 +371,83 @@ ], "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/base.css?v=20260802-5", + "/shared/shell.css?v=20260802-5", + "/shared/auth.css?v=20260802-5", + "/shared/components/controls.css?v=20260802-5", + "/shared/components/navigation.css?v=20260802-5", + "/shared/components/cards.css?v=20260802-5", + "/shared/components/tables.css?v=20260802-5", + "/shared/components/dialogs.css?v=20260802-5", + "/shared/components/feedback.css?v=20260802-5", + "/pages/market/foundation.css?v=20260802-5", + "/pages/sentiment/foundation.css?v=20260802-5", + "/pages/pools/foundation.css?v=20260802-5", + "/pages/ladder/foundation.css?v=20260802-5", + "/pages/rotation/foundation.css?v=20260802-5", + "/pages/auction/foundation.css?v=20260802-5", + "/pages/themes/foundation.css?v=20260802-5", + "/pages/popularity/foundation.css?v=20260802-5", + "/pages/dragon-tiger/foundation.css?v=20260802-5", + "/pages/screener/foundation.css?v=20260802-5", + "/pages/mentor/foundation.css?v=20260802-5", + "/pages/heaven/foundation.css?v=20260802-5", + "/pages/review/foundation.css?v=20260802-5" ], + "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": 359673, - "lines": 15360 + "path": "frontend/pages/heaven/foundation.css", + "bytes": 183624, + "lines": 11655 }, { - "path": "frontend/styles/redesign-v2.css", - "bytes": 262013, - "lines": 8531 - }, - { - "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": 100200, + "lines": 6433 }, { "path": "frontend/pages/heaven/page.js", - "bytes": 86493, - "lines": 1830 - }, - { - "path": "frontend/styles/renovation.css", - "bytes": 81205, - "lines": 1480 - }, - { - "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 + "bytes": 92669, + "lines": 1965 }, { "path": "backend/features/heaven/engine.py", @@ -373,19 +455,429 @@ "lines": 1181 }, { - "path": "backend/application.py", - "bytes": 47769, - "lines": 1092 + "path": "frontend/index.html", + "bytes": 44688, + "lines": 632 }, { - "path": "frontend/styles/theme.css", - "bytes": 36427, - "lines": 1253 + "path": "frontend/shared/shell.css", + "bytes": 40260, + "lines": 2800 + }, + { + "path": "backend/features/screener/catalog.py", + "bytes": 35424, + "lines": 707 + }, + { + "path": "frontend/pages/auction/foundation.css", + "bytes": 32818, + "lines": 2305 }, { "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": 19103, + "lines": 255 + }, + { + "path": "frontend/pages/screener/page.html", + "bytes": 18513, + "lines": 219 + }, + { + "path": "frontend/pages/market/preview.js", + "bytes": 18178, + "lines": 446 + }, + { + "path": "backend/features/market/insights_auction_scoring.py", + "bytes": 16689, + "lines": 355 + }, + { + "path": "backend/features/heaven/trend.py", + "bytes": 16310, + "lines": 358 + }, + { + "path": "frontend/pages/market/charts.js", + "bytes": 15311, + "lines": 387 + }, + { + "path": "frontend/pages/pools/page.html", + "bytes": 14958, + "lines": 235 + }, + { + "path": "backend/features/screener/data_sync.py", + "bytes": 14743, + "lines": 342 + }, + { + "path": "frontend/shared/admin.js", + "bytes": 13975, + "lines": 256 + }, + { + "path": "backend/features/heaven/market_context.py", + "bytes": 13681, + "lines": 338 + }, + { + "path": "frontend/shared/session.js", + "bytes": 12842, + "lines": 285 + }, + { + "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/data/providers/tushare_sectors.py", + "bytes": 9876, + "lines": 224 + }, + { + "path": "backend/features/market/insights_themes.py", + "bytes": 9348, + "lines": 222 + }, + { + "path": "backend/features/heaven/readings.py", + "bytes": 9313, + "lines": 220 + }, + { + "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/shared/dashboard.js", + "bytes": 8424, + "lines": 194 + }, + { + "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/pages/sentiment/page.html", + "bytes": 6488, + "lines": 81 + }, + { + "path": "frontend/shared/context.js", + "bytes": 6433, + "lines": 216 + }, + { + "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": "frontend/pages/mentor/page.html", + "bytes": 5501, + "lines": 72 + }, + { + "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": "backend/features/screener/engine.py", + "bytes": 4242, + "lines": 129 + }, + { + "path": "backend/http/dispatch.py", + "bytes": 4118, + "lines": 115 + }, + { + "path": "frontend/shared/theme.js", + "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": 3309, + "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 } ] } diff --git a/app/docs/README.md b/app/docs/README.md new file mode 100644 index 0000000..f0bf340 --- /dev/null +++ b/app/docs/README.md @@ -0,0 +1,9 @@ +# 文档索引 + +- `product/小白复盘-完整产品规格说明书.md`:从零恢复产品时的完整功能与行为资产。 +- `maintenance/人工维护指南.md`:当前正式源码的启动、修改、验收、数据和回退流程。 +- `governance/`:架构决策、注册表治理和历次结构治理记录。 +- `migration/`:从旧根目录保真迁入`app/`的历史账本、证据和失败版本记录。 + +日常维护优先阅读根目录`AGENTS.md`、`ARCHITECTURE.md`和维护指南。`migration/`只用于审计与 +追溯,不参与应用启动、测试选择或运行时路径解析。 diff --git a/docs/governance/adr/0001-modular-monolith.md b/app/docs/governance/adr/0001-modular-monolith.md similarity index 100% rename from docs/governance/adr/0001-modular-monolith.md rename to app/docs/governance/adr/0001-modular-monolith.md diff --git a/docs/governance/architecture-inventory.json b/app/docs/governance/architecture-inventory.json similarity index 100% rename from docs/governance/architecture-inventory.json rename to app/docs/governance/architecture-inventory.json diff --git a/docs/governance/architecture-standard.md b/app/docs/governance/architecture-standard.md similarity index 100% rename from docs/governance/architecture-standard.md rename to app/docs/governance/architecture-standard.md diff --git a/docs/governance/code-reduction.md b/app/docs/governance/code-reduction.md similarity index 100% rename from docs/governance/code-reduction.md rename to app/docs/governance/code-reduction.md diff --git a/docs/governance/stage-01-baseline.md b/app/docs/governance/stage-01-baseline.md similarity index 100% rename from docs/governance/stage-01-baseline.md rename to app/docs/governance/stage-01-baseline.md diff --git a/docs/governance/stage-02-inventory.md b/app/docs/governance/stage-02-inventory.md similarity index 100% rename from docs/governance/stage-02-inventory.md rename to app/docs/governance/stage-02-inventory.md diff --git a/docs/governance/stage-04-registries.md b/app/docs/governance/stage-04-registries.md similarity index 100% rename from docs/governance/stage-04-registries.md rename to app/docs/governance/stage-04-registries.md diff --git a/docs/governance/stage-05-bootstrap.md b/app/docs/governance/stage-05-bootstrap.md similarity index 100% rename from docs/governance/stage-05-bootstrap.md rename to app/docs/governance/stage-05-bootstrap.md diff --git a/docs/governance/stage-06-data-gateway.md b/app/docs/governance/stage-06-data-gateway.md similarity index 100% rename from docs/governance/stage-06-data-gateway.md rename to app/docs/governance/stage-06-data-gateway.md diff --git a/docs/governance/stage-07-data-quality.md b/app/docs/governance/stage-07-data-quality.md similarity index 100% rename from docs/governance/stage-07-data-quality.md rename to app/docs/governance/stage-07-data-quality.md diff --git a/docs/governance/stage-08-database-migrations.md b/app/docs/governance/stage-08-database-migrations.md similarity index 100% rename from docs/governance/stage-08-database-migrations.md rename to app/docs/governance/stage-08-database-migrations.md diff --git a/docs/governance/stage-09-repositories.md b/app/docs/governance/stage-09-repositories.md similarity index 100% rename from docs/governance/stage-09-repositories.md rename to app/docs/governance/stage-09-repositories.md diff --git a/docs/governance/stage-10-feature-services.md b/app/docs/governance/stage-10-feature-services.md similarity index 100% rename from docs/governance/stage-10-feature-services.md rename to app/docs/governance/stage-10-feature-services.md diff --git a/docs/governance/stage-11-http-governance.md b/app/docs/governance/stage-11-http-governance.md similarity index 100% rename from docs/governance/stage-11-http-governance.md rename to app/docs/governance/stage-11-http-governance.md diff --git a/docs/governance/stage-12-background-jobs.md b/app/docs/governance/stage-12-background-jobs.md similarity index 100% rename from docs/governance/stage-12-background-jobs.md rename to app/docs/governance/stage-12-background-jobs.md diff --git a/docs/governance/stage-13-llm-gateway.md b/app/docs/governance/stage-13-llm-gateway.md similarity index 100% rename from docs/governance/stage-13-llm-gateway.md rename to app/docs/governance/stage-13-llm-gateway.md diff --git a/docs/governance/stage-14-frontend-boundaries.md b/app/docs/governance/stage-14-frontend-boundaries.md similarity index 100% rename from docs/governance/stage-14-frontend-boundaries.md rename to app/docs/governance/stage-14-frontend-boundaries.md diff --git a/docs/governance/stage-15-frontend-shell.md b/app/docs/governance/stage-15-frontend-shell.md similarity index 100% rename from docs/governance/stage-15-frontend-shell.md rename to app/docs/governance/stage-15-frontend-shell.md diff --git a/docs/governance/stage-16-css-tokens.md b/app/docs/governance/stage-16-css-tokens.md similarity index 100% rename from docs/governance/stage-16-css-tokens.md rename to app/docs/governance/stage-16-css-tokens.md diff --git a/docs/governance/stage-17-page-modules.md b/app/docs/governance/stage-17-page-modules.md similarity index 100% rename from docs/governance/stage-17-page-modules.md rename to app/docs/governance/stage-17-page-modules.md diff --git a/app/docs/maintenance/人工维护指南.md b/app/docs/maintenance/人工维护指南.md new file mode 100644 index 0000000..f58f78d --- /dev/null +++ b/app/docs/maintenance/人工维护指南.md @@ -0,0 +1,76 @@ +# 小白复盘人工维护指南 + +## 1. 正式边界 + +`app/`是唯一正式源码和运行目录。父目录旧程序与失败的`next/`不属于应用依赖,也不得作为后续 +实现来源。产品规格位于`docs/product/`,历史迁移证据位于`docs/migration/`。 + +## 2. 目录定位 + +```text +server.py 进程入口 +backend/bootstrap/ 配置、依赖组装和启动 +backend/http/ 鉴权、响应和公共HTTP能力 +backend/features/ 按产品领域组织的服务、路由和Repository +backend/data/ 数据网关、质量规则和供应商适配 +backend/database/ SQLite连接、迁移和Repository组合 +backend/jobs/ 后台任务、状态、锁与重试 +backend/llm/ 模型选择、鉴权、额度、流式和审计 +frontend/shared/ API、状态、Shell和公共组件 +frontend/pages/ 页面结构、行为和页面样式 +config/ 页面、功能、API、任务和数据字段注册表 +data/ 正式数据库和私有数据,不进入Git +runtime/ 日志、PID、缓存和测试产物,不进入Git +tests/ 单元、契约、边界和浏览器回归 +tools/ 启动、注册表生成和统一验收工具 +``` + +## 3. 本地启动 + +```powershell +cd app +python -m pip install -r requirements.txt +powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 -Port 8797 +``` + +日志、PID和Python缓存写入`runtime/`。前台启动可使用: + +```powershell +python server.py --host 127.0.0.1 --port 8797 +``` + +## 4. 修改流程 + +1. 阅读`AGENTS.md`、`ARCHITECTURE.md`、相关注册表和测试。 +2. 找到职责唯一所有者,不建立转发层或临时补丁文件。 +3. 保持API、数据库、权限、数据口径和用户可见行为兼容。 +4. 行情字段必须登记来源、时间、单位、复权、新鲜度和降级规则。 +5. 用户私有数据必须包含并按`user_id`隔离。 +6. 先运行领域测试,再运行统一验收,最后做真实浏览器检查。 + +## 5. 自动验收 + +```powershell +python tools/verify_baseline.py +python tools/verify_baseline.py --e2e +``` + +统一验收覆盖全部Python测试、API与架构注册表、JavaScript语法、Git空白检查和SQLite只读完整性; +`--e2e`额外运行Playwright。前端改动还需人工检查日间/夜间、1080P/4K、移动端、滚动、弹窗、 +图表、问师流式结果和问天动画。 + +## 6. 数据与密钥 + +- 正式数据库固定为`data/review.db`。 +- `.env`中的`APP_ENCRYPTION_KEY`必须与数据库成对备份。 +- 不要复制正在写入的SQLite文件;停服或使用SQLite backup API。 +- `.env`、Token、密码、数据库、私有Skill和运行日志不得提交Git或写入Docker镜像。 +- 同一时刻只允许一个正式实例写主库。 + +## 7. 部署与回退 + +Docker以当前目录为构建上下文,持久化挂载`./data:/app/data`。升级前保存当前Git提交、数据库一致性 +备份和`.env`;升级后验证健康、登录、最近交易日、私有数据、数据源、LLM和关键写入流程。 + +出现问题时先停止新进程,保存故障日志和数据库副本,再恢复上一Git提交及其成对数据库和`.env`。 +不要使用破坏性Git命令覆盖未提交数据。 diff --git a/docs/migration/README.md b/app/docs/migration/README.md similarity index 100% rename from docs/migration/README.md rename to app/docs/migration/README.md diff --git a/docs/migration/evidence/slice-00/README.md b/app/docs/migration/evidence/slice-00/README.md similarity index 100% rename from docs/migration/evidence/slice-00/README.md rename to app/docs/migration/evidence/slice-00/README.md diff --git a/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png b/app/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png rename to app/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png diff --git a/docs/migration/evidence/slice-00/app-exact-dark-390x844.png b/app/docs/migration/evidence/slice-00/app-exact-dark-390x844.png similarity index 100% rename from docs/migration/evidence/slice-00/app-exact-dark-390x844.png rename to app/docs/migration/evidence/slice-00/app-exact-dark-390x844.png diff --git a/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png b/app/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-00/app-exact-light-1920x1080.png rename to app/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png diff --git a/docs/migration/evidence/slice-00/app-exact-light-390x844.png b/app/docs/migration/evidence/slice-00/app-exact-light-390x844.png similarity index 100% rename from docs/migration/evidence/slice-00/app-exact-light-390x844.png rename to app/docs/migration/evidence/slice-00/app-exact-light-390x844.png diff --git a/docs/migration/evidence/slice-01/README.md b/app/docs/migration/evidence/slice-01/README.md similarity index 100% rename from docs/migration/evidence/slice-01/README.md rename to app/docs/migration/evidence/slice-01/README.md diff --git a/docs/migration/evidence/slice-01/app-light-1920x1080.png b/app/docs/migration/evidence/slice-01/app-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-01/app-light-1920x1080.png rename to app/docs/migration/evidence/slice-01/app-light-1920x1080.png diff --git a/docs/migration/evidence/slice-02/README.md b/app/docs/migration/evidence/slice-02/README.md similarity index 100% rename from docs/migration/evidence/slice-02/README.md rename to app/docs/migration/evidence/slice-02/README.md diff --git a/docs/migration/evidence/slice-02/app-light-1280x720.png b/app/docs/migration/evidence/slice-02/app-light-1280x720.png similarity index 100% rename from docs/migration/evidence/slice-02/app-light-1280x720.png rename to app/docs/migration/evidence/slice-02/app-light-1280x720.png diff --git a/docs/migration/evidence/slice-03/README.md b/app/docs/migration/evidence/slice-03/README.md similarity index 100% rename from docs/migration/evidence/slice-03/README.md rename to app/docs/migration/evidence/slice-03/README.md diff --git a/docs/migration/evidence/slice-03/app-light-1920x1080.png b/app/docs/migration/evidence/slice-03/app-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-03/app-light-1920x1080.png rename to app/docs/migration/evidence/slice-03/app-light-1920x1080.png diff --git a/docs/migration/evidence/slice-04/README.md b/app/docs/migration/evidence/slice-04/README.md similarity index 100% rename from docs/migration/evidence/slice-04/README.md rename to app/docs/migration/evidence/slice-04/README.md diff --git a/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png b/app/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png rename to app/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png diff --git a/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png b/app/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png rename to app/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png diff --git a/docs/migration/evidence/slice-05/README.md b/app/docs/migration/evidence/slice-05/README.md similarity index 100% rename from docs/migration/evidence/slice-05/README.md rename to app/docs/migration/evidence/slice-05/README.md diff --git a/docs/migration/evidence/slice-05/api-diff.json b/app/docs/migration/evidence/slice-05/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-05/api-diff.json rename to app/docs/migration/evidence/slice-05/api-diff.json diff --git a/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png b/app/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-05/app-light-auction-1920x1080.png rename to app/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png diff --git a/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png b/app/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png rename to app/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png diff --git a/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png b/app/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png rename to app/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png diff --git a/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png b/app/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-05/app-light-themes-1920x1080.png rename to app/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png diff --git a/docs/migration/evidence/slice-05/database-diff.json b/app/docs/migration/evidence/slice-05/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-05/database-diff.json rename to app/docs/migration/evidence/slice-05/database-diff.json diff --git a/docs/migration/evidence/slice-06/README.md b/app/docs/migration/evidence/slice-06/README.md similarity index 100% rename from docs/migration/evidence/slice-06/README.md rename to app/docs/migration/evidence/slice-06/README.md diff --git a/docs/migration/evidence/slice-06/api-diff.json b/app/docs/migration/evidence/slice-06/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-06/api-diff.json rename to app/docs/migration/evidence/slice-06/api-diff.json diff --git a/docs/migration/evidence/slice-06/api-requests.json b/app/docs/migration/evidence/slice-06/api-requests.json similarity index 100% rename from docs/migration/evidence/slice-06/api-requests.json rename to app/docs/migration/evidence/slice-06/api-requests.json diff --git a/docs/migration/evidence/slice-06/curated-screener.jpg b/app/docs/migration/evidence/slice-06/curated-screener.jpg similarity index 100% rename from docs/migration/evidence/slice-06/curated-screener.jpg rename to app/docs/migration/evidence/slice-06/curated-screener.jpg diff --git a/docs/migration/evidence/slice-06/custom-screener.jpg b/app/docs/migration/evidence/slice-06/custom-screener.jpg similarity index 100% rename from docs/migration/evidence/slice-06/custom-screener.jpg rename to app/docs/migration/evidence/slice-06/custom-screener.jpg diff --git a/docs/migration/evidence/slice-06/database-diff.json b/app/docs/migration/evidence/slice-06/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-06/database-diff.json rename to app/docs/migration/evidence/slice-06/database-diff.json diff --git a/docs/migration/evidence/slice-06/tracking.jpg b/app/docs/migration/evidence/slice-06/tracking.jpg similarity index 100% rename from docs/migration/evidence/slice-06/tracking.jpg rename to app/docs/migration/evidence/slice-06/tracking.jpg diff --git a/docs/migration/evidence/slice-07/README.md b/app/docs/migration/evidence/slice-07/README.md similarity index 100% rename from docs/migration/evidence/slice-07/README.md rename to app/docs/migration/evidence/slice-07/README.md diff --git a/docs/migration/evidence/slice-07/api-diff.json b/app/docs/migration/evidence/slice-07/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-07/api-diff.json rename to app/docs/migration/evidence/slice-07/api-diff.json diff --git a/docs/migration/evidence/slice-07/api-requests.json b/app/docs/migration/evidence/slice-07/api-requests.json similarity index 100% rename from docs/migration/evidence/slice-07/api-requests.json rename to app/docs/migration/evidence/slice-07/api-requests.json diff --git a/docs/migration/evidence/slice-07/database-diff.json b/app/docs/migration/evidence/slice-07/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-07/database-diff.json rename to app/docs/migration/evidence/slice-07/database-diff.json diff --git a/docs/migration/evidence/slice-07/mentor-a-filter.jpg b/app/docs/migration/evidence/slice-07/mentor-a-filter.jpg similarity index 100% rename from docs/migration/evidence/slice-07/mentor-a-filter.jpg rename to app/docs/migration/evidence/slice-07/mentor-a-filter.jpg diff --git a/docs/migration/evidence/slice-07/mentor-all.jpg b/app/docs/migration/evidence/slice-07/mentor-all.jpg similarity index 100% rename from docs/migration/evidence/slice-07/mentor-all.jpg rename to app/docs/migration/evidence/slice-07/mentor-all.jpg diff --git a/docs/migration/evidence/slice-08/README.md b/app/docs/migration/evidence/slice-08/README.md similarity index 100% rename from docs/migration/evidence/slice-08/README.md rename to app/docs/migration/evidence/slice-08/README.md diff --git a/docs/migration/evidence/slice-08/api-diff.json b/app/docs/migration/evidence/slice-08/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-08/api-diff.json rename to app/docs/migration/evidence/slice-08/api-diff.json diff --git a/docs/migration/evidence/slice-08/api-requests.json b/app/docs/migration/evidence/slice-08/api-requests.json similarity index 100% rename from docs/migration/evidence/slice-08/api-requests.json rename to app/docs/migration/evidence/slice-08/api-requests.json diff --git a/docs/migration/evidence/slice-08/database-diff.json b/app/docs/migration/evidence/slice-08/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-08/database-diff.json rename to app/docs/migration/evidence/slice-08/database-diff.json diff --git a/docs/migration/evidence/slice-08/heaven-fortune-1080.png b/app/docs/migration/evidence/slice-08/heaven-fortune-1080.png similarity index 100% rename from docs/migration/evidence/slice-08/heaven-fortune-1080.png rename to app/docs/migration/evidence/slice-08/heaven-fortune-1080.png diff --git a/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png b/app/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png similarity index 100% rename from docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png rename to app/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png diff --git a/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png b/app/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png similarity index 100% rename from docs/migration/evidence/slice-08/heaven-heart-dark-1080.png rename to app/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png diff --git a/docs/migration/evidence/slice-08/heaven-trend-1080.png b/app/docs/migration/evidence/slice-08/heaven-trend-1080.png similarity index 100% rename from docs/migration/evidence/slice-08/heaven-trend-1080.png rename to app/docs/migration/evidence/slice-08/heaven-trend-1080.png diff --git a/docs/migration/evidence/slice-09/README.md b/app/docs/migration/evidence/slice-09/README.md similarity index 100% rename from docs/migration/evidence/slice-09/README.md rename to app/docs/migration/evidence/slice-09/README.md diff --git a/docs/migration/evidence/slice-09/api-diff.json b/app/docs/migration/evidence/slice-09/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-09/api-diff.json rename to app/docs/migration/evidence/slice-09/api-diff.json diff --git a/docs/migration/evidence/slice-09/api-requests.json b/app/docs/migration/evidence/slice-09/api-requests.json similarity index 100% rename from docs/migration/evidence/slice-09/api-requests.json rename to app/docs/migration/evidence/slice-09/api-requests.json diff --git a/docs/migration/evidence/slice-09/database-diff.json b/app/docs/migration/evidence/slice-09/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-09/database-diff.json rename to app/docs/migration/evidence/slice-09/database-diff.json diff --git a/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png b/app/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png rename to app/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png diff --git a/docs/migration/evidence/slice-10/README.md b/app/docs/migration/evidence/slice-10/README.md similarity index 96% rename from docs/migration/evidence/slice-10/README.md rename to app/docs/migration/evidence/slice-10/README.md index 5f92c1b..38d60cc 100644 --- a/docs/migration/evidence/slice-10/README.md +++ b/app/docs/migration/evidence/slice-10/README.md @@ -27,7 +27,7 @@ - 七层原版样式、问天样式、加载动画、共享脚本、页面注册脚本及Lucide供应商文件在移动后保持字节一致。 - `index.html`的差异仅限资源新路径和两个机械拆分脚本入口;反向替换后与原版逐字符一致。 - 浏览器请求仍只有`shared/api.js`一个`fetch`出口。 -- 完整行号映射见`frontend-source-map.json`;拆分工具为`app/tools/split_frontend_runtime.py`,可重复验证但不用于运行时构建。 +- 完整行号映射见`frontend-source-map.json`。该映射继续作为切片10的历史证据;一次性拆分工具在人工验收和当前所有者契约建立后已退役,不再进入维护工具链。 ## 3. API 与数据库差分 diff --git a/docs/migration/evidence/slice-10/api-diff.json b/app/docs/migration/evidence/slice-10/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-10/api-diff.json rename to app/docs/migration/evidence/slice-10/api-diff.json diff --git a/docs/migration/evidence/slice-10/api-requests.json b/app/docs/migration/evidence/slice-10/api-requests.json similarity index 100% rename from docs/migration/evidence/slice-10/api-requests.json rename to app/docs/migration/evidence/slice-10/api-requests.json diff --git a/docs/migration/evidence/slice-10/browser-acceptance.json b/app/docs/migration/evidence/slice-10/browser-acceptance.json similarity index 100% rename from docs/migration/evidence/slice-10/browser-acceptance.json rename to app/docs/migration/evidence/slice-10/browser-acceptance.json diff --git a/docs/migration/evidence/slice-10/database-diff.json b/app/docs/migration/evidence/slice-10/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-10/database-diff.json rename to app/docs/migration/evidence/slice-10/database-diff.json diff --git a/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png b/app/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png b/app/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png b/app/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png b/app/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png rename to app/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png b/app/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png rename to app/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png b/app/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png similarity index 100% rename from docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png rename to app/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png diff --git a/docs/migration/evidence/slice-10/frontend-source-map.json b/app/docs/migration/evidence/slice-10/frontend-source-map.json similarity index 100% rename from docs/migration/evidence/slice-10/frontend-source-map.json rename to app/docs/migration/evidence/slice-10/frontend-source-map.json diff --git a/docs/migration/evidence/slice-11/README.md b/app/docs/migration/evidence/slice-11/README.md similarity index 95% rename from docs/migration/evidence/slice-11/README.md rename to app/docs/migration/evidence/slice-11/README.md index a7159bf..f4bd0e4 100644 --- a/docs/migration/evidence/slice-11/README.md +++ b/app/docs/migration/evidence/slice-11/README.md @@ -48,9 +48,9 @@ 修复后统一验收命令再次通过302项测试、24个JavaScript文件、SQLite完整性和45项Playwright (2.0分钟)。 -早期切片的五个前端源码测试原本要求与未试删的`static/app.js`逐字符一致。本切片把它们统一到 -`preservation_helpers.assert_frontend_runtime_matches_audited_baseline`:只允许审计登记的五段原始 -行号被删除,任何其他新增、删除、重排或内容变化仍会使全部历史测试失败。 +早期切片的五个前端源码测试原本要求与未试删的`static/app.js`逐字符一致。本切片验收时曾通过 +行号回放机制限制未登记改动。完成全站人工验收及前端所有权治理后,该过渡机制已退役;当前由 +运行时注册表、唯一所有者、DOM/API契约、JavaScript语法检查和Playwright行为回归持续守门。 完整API和数据库结果分别见`api-diff.json`与`database-diff.json`,二者`all_equal`均为`true`。 diff --git a/docs/migration/evidence/slice-11/api-diff.json b/app/docs/migration/evidence/slice-11/api-diff.json similarity index 100% rename from docs/migration/evidence/slice-11/api-diff.json rename to app/docs/migration/evidence/slice-11/api-diff.json diff --git a/docs/migration/evidence/slice-11/browser-acceptance.json b/app/docs/migration/evidence/slice-11/browser-acceptance.json similarity index 100% rename from docs/migration/evidence/slice-11/browser-acceptance.json rename to app/docs/migration/evidence/slice-11/browser-acceptance.json diff --git a/docs/migration/evidence/slice-11/completion-audit.md b/app/docs/migration/evidence/slice-11/completion-audit.md similarity index 92% rename from docs/migration/evidence/slice-11/completion-audit.md rename to app/docs/migration/evidence/slice-11/completion-audit.md index 04ecb91..4af53cb 100644 --- a/docs/migration/evidence/slice-11/completion-audit.md +++ b/app/docs/migration/evidence/slice-11/completion-audit.md @@ -21,7 +21,7 @@ | 数据库无损、用户隔离不变 | 36张表由初始schema和有序migration管理;62个schema对象、21张关键表差分相同 | 数据库差分;migration/repository/account-boundary tests | 自动闭环 | | 后台任务可追踪和重试 | 3个任务登记调度、锁、超时、重试和输出版本;运行状态持久化 | `jobs.config.json`;job runner tests | 自动闭环 | | LLM唯一治理边界 | 问师、问天、复盘助手和策略编译经统一网关执行会员、额度、模型回退、流式和审计规则 | 切片07/09;LLM gateway/stream tests | 自动闭环 | -| 前端唯一请求出口、Shell和页面职责 | 只有`frontend/shared/api.js`调用`fetch`;Shell、状态、弹窗、页面生命周期及页面模块已归位 | frontend boundary tests;切片10源码重组哈希 | 自动闭环 | +| 前端唯一请求出口、Shell和页面职责 | 只有`frontend/shared/api.js`调用业务数据`fetch`;Shell、状态、弹窗、页面生命周期及页面模块已归位 | frontend boundary tests;运行时注册表;Playwright | 自动闭环 | | CSS、主题、动画和移动行为不改写 | 原七层CSS和问天动画按字节/源码移动;令牌层与加载顺序受测试保护;用户逐页确认无迁移视觉回归 | CSS governance;切片10/11浏览器证据;`manual-acceptance.md` | 人工闭环 | | 不确定代码先记录再试删 | 2个文件和5个无消费者函数经自动与人工验收确认废弃;历史表兼容责任继续保留 | `uncertain-code-audit.md`;cleanup contract;`manual-acceptance.md` | 人工闭环 | | 可由人工维护者复验 | 候选README/架构说明、工具分类、API/架构生成器、统一验证命令均可从`app/`独立运行;系统临时目录导出实测242项候选测试通过 | `app/tools/README.md`;maintenance tool tests;独立导出复验 | 自动闭环 | @@ -34,8 +34,8 @@ - 根`database.py`从原版2,839行降为746行,只保留初始schema、组合入口及有明确历史兼容责任的 方法;各领域查询已移入对应Repository。 - 20个其他根Python兼容模块均为1-15行导出/模块别名,不含第二套实现。 -- 原9,283行前端运行时按原连续源码范围拆入共享层和13个页面目录;试删后仍由源码重组测试 - 保护,不能悄悄增加、丢失或重排原行为。 +- 原9,283行前端运行时按原连续源码范围拆入共享层和13个页面目录;该行号映射作为迁移历史 + 证据保留,日常维护改由当前模块所有权和行为回归约束。 - 已确认没有消费者的`demo_data.py`、旧问天加载文件和5个函数进入可单项恢复的试删候选; `wencai_saved_queries`因旧数据库兼容和账号隔离继续保留。 @@ -62,8 +62,8 @@ 候选测试夹具同步固定到明确工作日,消除跨午夜/周末不确定性。产品实现与交易日规则未改, 随后统一验收再次通过302项Python测试和45项Playwright。 6. 独立导出`app/`后成功启动健康接口,236项非迁移业务/前端测试通过;同时发现六项日常前端 - 契约经辅助函数隐式读取旧`static/app.js`。候选运行时重组现使用已审计的9,283行覆盖边界, - 只有明确的保真差分断言继续读取原版,避免未来清理旧目录后日常契约失效。 + 契约经辅助函数隐式读取旧`static/app.js`。完成人工验收后,日常契约已改为装配当前注册脚本; + 只有确有价值的静态资产、页面前缀及后端AST差分继续读取原版,避免历史源码回放成为第二套维护对象。 7. 对切片10十组截图重新做像素审计,九组平均色差均低于0.3/255;集合竞价候选图实际为登录 失效页,已明确标为无效并撤销其截图证明力。集合竞价最终视觉继续列为人工验收项。 8. 统一验证器现在按环境选择完整保真套件或候选自有套件;系统临时目录中的纯`app/`导出通过 diff --git a/docs/migration/evidence/slice-11/database-diff.json b/app/docs/migration/evidence/slice-11/database-diff.json similarity index 100% rename from docs/migration/evidence/slice-11/database-diff.json rename to app/docs/migration/evidence/slice-11/database-diff.json diff --git a/docs/migration/evidence/slice-11/manual-acceptance.md b/app/docs/migration/evidence/slice-11/manual-acceptance.md similarity index 100% rename from docs/migration/evidence/slice-11/manual-acceptance.md rename to app/docs/migration/evidence/slice-11/manual-acceptance.md diff --git a/docs/migration/evidence/slice-11/screenshot-pixel-audit.json b/app/docs/migration/evidence/slice-11/screenshot-pixel-audit.json similarity index 100% rename from docs/migration/evidence/slice-11/screenshot-pixel-audit.json rename to app/docs/migration/evidence/slice-11/screenshot-pixel-audit.json diff --git a/docs/migration/evidence/slice-11/uncertain-code-audit.md b/app/docs/migration/evidence/slice-11/uncertain-code-audit.md similarity index 100% rename from docs/migration/evidence/slice-11/uncertain-code-audit.md rename to app/docs/migration/evidence/slice-11/uncertain-code-audit.md diff --git a/docs/migration/next失败冻结记录.md b/app/docs/migration/next失败冻结记录.md similarity index 100% rename from docs/migration/next失败冻结记录.md rename to app/docs/migration/next失败冻结记录.md diff --git a/docs/migration/人工维护与本地切换指南.md b/app/docs/migration/人工维护与本地切换指南.md similarity index 87% rename from docs/migration/人工维护与本地切换指南.md rename to app/docs/migration/人工维护与本地切换指南.md index d5e6260..fc2501c 100644 --- a/docs/migration/人工维护与本地切换指南.md +++ b/app/docs/migration/人工维护与本地切换指南.md @@ -1,5 +1,8 @@ # 小白复盘人工维护与本地切换指南 +> 历史文档:记录迁移验收期间的双版本操作,已由`docs/maintenance/人工维护指南.md`取代。 +> 其中涉及父目录原版和迁移比较工具的命令不再作为当前维护流程执行。 +> > 适用目录:`webapp/app/` > 当前状态:本地迁移已完成自动与用户人工验收;正式数据库、Docker和NAS尚未切换 @@ -17,7 +20,10 @@ ```text app/ - server.py 进程兼容入口;正式实现装配在backend/ + server.py 稳定进程入口;正式实现装配在backend/ + database.py SQLite初始schema与Repository组合入口 + api_access.py API访问级别注册入口 + sync_data.py 手工行情同步命令 backend/bootstrap/ 路径、环境、配置、依赖组装和HTTP服务器 backend/http/ 鉴权、路由元数据、响应与静态资源传输 backend/features/ 按产品领域组织的服务和Repository @@ -25,18 +31,18 @@ app/ backend/database/ SQLite连接、schema和组合Repository backend/jobs/ 后台任务定义、状态、调度与重试 backend/llm/ 所有模型调用、流式传输、额度与审计边界 - frontend/shared/ API、状态、Shell、组件和跨页能力 - frontend/pages/ 每个产品页面的原版行为;问天样式也在对应目录 - frontend/styles/ 原版七层样式和共享令牌 + frontend/shared/ API、状态、令牌、Shell、组件与共享样式 + frontend/pages/ 页面结构、行为与页面专属样式 config/ 页面、功能、API、任务、字段和数据质量注册表 tests/ 单元、边界、保真差分和浏览器回归 tools/ 清单、API/数据库差分和保真运行工具 data/ 运行数据库、私有Skill和备份;不提交Git + runtime/ 本地日志、缓存、PID与浏览器测试产物;不提交Git 游资skills/ 可公开的问师Skill ``` -根级`app/*.py`多数是兼容导入壳。维护业务时先到`backend/features/<领域>/`找正式实现, -不要在兼容壳中新增第二套逻辑。所有浏览器网络请求必须继续经过 +根目录不再保留业务兼容导入壳。维护业务时直接到`backend/features/<领域>/`或 +`backend/data/`寻找唯一实现,不得重新建立根级转发文件。所有浏览器网络请求必须继续经过 `frontend/shared/api.js`,所有LLM调用必须继续经过`backend/llm/`。 ## 3. 本地隔离启动 @@ -58,7 +64,7 @@ python -u app\tools\run_preservation_runtime.py ` 1. 阅读`AGENTS.md`、保真迁移状态、迁移账本和对应领域测试。 2. 从`config/pages.config.json`与`features.config.json`确认页面、功能和权限边界。 -3. 只修改一个完整领域路径;不要同时在兼容壳和正式模块写实现。 +3. 只修改一个完整领域路径;不要建立根级兼容壳或第二套实现。 4. 新增API时同步检查`config/api.config.json`及`backend/http/`的权限元数据。 5. 用户私有表必须包含并按`user_id`查询,补充跨账号隔离测试。 6. 行情字段必须登记来源、时间、单位、复权、新鲜度和降级规则,不允许静默换源。 diff --git a/app/docs/migration/保真迁移状态.json b/app/docs/migration/保真迁移状态.json new file mode 100644 index 0000000..cb29ac7 --- /dev/null +++ b/app/docs/migration/保真迁移状态.json @@ -0,0 +1,76 @@ +{ + "schema_version": 1, + "updated_at": "2026-08-03T21:31:06+08:00", + "status": "standalone_closure_verified_checkpoint_pending", + "migration_mode": "behavior_preserving_source_migration", + "source_of_truth": "standalone_app_runtime_and_source", + "source_root": ".", + "target_root": ".", + "failed_roots": [], + "retired_external_roots": [ + "parent original runtime", + "parent next directory" + ], + "current_slice": "standalone-closure", + "last_completed_slice": "standalone-closure", + "last_automated_slice": "standalone-closure", + "last_checkpoint": "xiaobai-standalone-closure-20260803", + "next_action": "create and push the standalone closure checkpoint, then clean retired parent content in a separate reversible commit", + "authoritative_documents": [ + "AGENTS.md", + "ARCHITECTURE.md", + "docs/product/小白复盘-完整产品规格说明书.md", + "docs/maintenance/人工维护指南.md" + ], + "hard_invariants": [ + "do_not_use_next_as_migration_source", + "do_not_rewrite_existing_product_behavior", + "do_not_change_technology_stack_without_explicit_user_approval", + "preserve_visual_interaction_animation_calculation_and_data_semantics", + "do_not_depend_on_parent_source_tests_static_config_or_data", + "keep_one_canonical_owner_for_each_runtime_responsibility", + "keep_secrets_databases_private_skills_and_runtime_artifacts_out_of_git", + "do_not_switch_docker_or_nas_before_user_approval" + ], + "approved_decisions": { + "target_directory_name": "app", + "target_directory_structure": "approved_2026-07-30", + "migration_slice_order": "approved_2026-07-30", + "execution_mode": "autonomous_until_complete", + "manual_visual_and_functional_acceptance": "approved_2026-08-01", + "slice_11_trial_retirements": "confirmed_retired_2026-08-01", + "styles_css_governance": "automated_complete_manual_visual_pending_2026-08-02", + "frontend_html_governance": "automated_complete_manual_visual_pending_2026-08-03", + "frontend_app_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "frontend_market_runtime_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "screener_engine_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "tushare_provider_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "heaven_service_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "market_insights_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "application_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "preservation_audit_governance": "automated_complete_manual_acceptance_pending_2026-08-03", + "root_compatibility_modules": "19_retired_after_reference_scan_2026-08-03", + "runtime_artifact_boundary": "centralized_under_runtime_2026-08-03", + "standalone_source_boundary": "verified_2026-08-03", + "permanent_documents_inside_app": "verified_2026-08-03", + "migration_comparison_scaffolding": "retired_after_standalone_acceptance_2026-08-03" + }, + "manual_acceptance": { + "accepted_at": "2026-08-01T09:42:04+08:00", + "runtime": "http://127.0.0.1:8797/", + "scope": "all pages and all user-testable functions", + "result": "visual and functional preservation accepted; observed issues were predominantly original-version legacy issues, with no migration regression found that blocks acceptance" + }, + "verification": { + "python_tests": 289, + "javascript_files": 39, + "api_routes": 74, + "playwright_tests": 46, + "sqlite_integrity": "ok", + "parent_runtime_references": 0 + }, + "open_decisions": [ + "retired_parent_cleanup_after_checkpoint", + "docker_and_nas_switch_timing" + ] +} diff --git a/docs/migration/保真迁移账本.md b/app/docs/migration/保真迁移账本.md similarity index 51% rename from docs/migration/保真迁移账本.md rename to app/docs/migration/保真迁移账本.md index b22b43d..d962667 100644 --- a/docs/migration/保真迁移账本.md +++ b/app/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:切片11自动与人工验收完成,本地保真迁移交付完成;正式部署切换尚未执行 +> 当前状态:正式源码独立化收口自动验收通过,等待建立并推送最终回档提交 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -34,6 +34,8 @@ | 2026-07-31 | `xiaobai-preservation-slice-11-audit-candidate-20260731` | 逐项目标审计并修复候选维护工具旧路径 | 302项测试、24个脚本、45项Playwright、21项API与62项schema差分通过;仍等待人工确认 | | 2026-08-01 | `xiaobai-preservation-slice-11-audit-candidate-standalone-20260801` | 独立维护与截图证据复核 | 正式仓库305项、独立导出242项测试通过;撤销无效竞价截图;仍等待人工确认 | | 2026-08-01 | `xiaobai-preservation-complete-20260801` | 用户逐页逐功能验收并完成迁移收尾 | 视觉与功能保真通过;试删确认废弃;本地交付完成,部署未切换 | +| 2026-08-03 | 工作区未提交 | 完成11项核心热点治理及历史保真审计脚手架退役 | 356项Python、全部当前JavaScript、SQLite完整性及46项Playwright通过;等待人工验收 | +| 2026-08-03 | `xiaobai-standalone-closure-20260803` | 正式源码独立化收口与最终回档 | 永久文档归位、父目录依赖清零、289项Python及46项Playwright通过;提交与推送执行中 | ## 资产处置登记 @@ -70,7 +72,7 @@ | `assistant_agent.py`与复盘服务 | 业务服务 | 我的复盘、自选、交易日志与复盘助手 | 机械移动并保留兼容别名 | `app/backend/features/review/` | Agent文件哈希、8个服务方法AST、5个真实API及会员/非会员页面等价 | 已移动 | | 复盘、自选、交易日志与助手消息方法 | 持久化 | 用户私有复盘数据 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/review/repository.py` | 13个方法AST一致;相关表逐行一致;浏览器账户隔离通过 | 已移动 | | 提醒编排与提醒持久化方法 | 业务服务/持久化 | 提醒中心与策略跟踪提醒 | 按职责机械移动 | `app/backend/features/alerts/` | 5个服务方法、6个Repository方法AST及真实API一致 | 已移动 | -| `static/app.js` | 前端总运行时 | 全站页面、状态、交互与动画 | 按原连续行机械拆分 | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/runtime.js`、`shared/export.js` | 9,283行重组SHA-256与原版一致;85项前端专项测试通过 | 已移动 | +| `static/app.js` | 前端总运行时 | 全站页面、状态、交互与动画 | 按原连续行机械拆分 | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/{breadth,charts,entity-detail,stock-detail,preview,search,bindings}.js`、`shared/export.js` | 9,283行重组SHA-256与原版一致;完整基线及浏览器测试通过 | 已移动 | | `static/index.html` | 前端DOM骨架 | 全站 | 只调整资源路径和拆分脚本加载 | `app/frontend/index.html` | 反向替换路径与脚本后逐字符一致;浏览器差分通过 | 已移动 | | `static/styles.css`等七层样式 | 视觉运行资产 | 全部页面、主题和移动端 | 字节级移动 | `app/frontend/styles/`、`app/frontend/pages/heaven/page.css`、`app/frontend/shared/tokens.css` | 逐文件SHA-256一致;桌面、夜间和移动端计算样式一致 | 已移动 | | `static/ui-core.js`、页面/共享脚本和供应商资产 | 前端共享/第三方资产 | Shell、页面注册、通用组件与图标 | 字节级移动 | `app/frontend/shared/`、`pages/`、`vendor/` | 逐文件SHA-256一致;45项Playwright和真实浏览器差分通过 | 已移动 | @@ -232,6 +234,215 @@ 低于动态渲染噪声阈值。2026-08-01用户随后在真实登录状态下逐页浏览并测试全部功能,确认 视觉与功能迁移成功;发现的问题几乎都属于原版遗留问题,未发现阻止验收的迁移回归。 +## CSS核心治理:`styles.css` + +- 治理基线:提交`cc5fb8d`,标签`xiaobai-reduction-14-css-same-file-duplicates-20260802`。 +- 目标:一次性消除基础样式、历史页面样式和多轮覆盖混杂,不把旧债转移到新的补丁文件。 + 共享规则归入`frontend/shared/`,页面规则归入对应`frontend/pages//foundation.css`。 +- 已永久删除:`styles.css`、`renovation.css`、`redesign-v2.css`、`design-system.css`、 + `theme.css`及`pages/heaven/page.css`;没有建立`legacy.css`、`override.css`或`fix.css`。 +- 最终加载契约:`tokens.css`之后只加载9个共享所有者和13个页面所有者,共22个正式CSS; + 不再存在历史后置层。同一`selector + @media/@supports/@scope上下文`只有一个定义和一个 + 文件所有者,页面ID不能跨页面泄漏,未加页面作用域的Shell/控件根选择器只能位于共享层。 +- 规模结果:治理输入为861,341字节、8,569条`selector/context`规则;最终为712,217字节、 + 5,754条规则,净删除149,124字节和2,815条规则。含`tokens.css`的最终源码为47,557个 + 物理行;该数字高于 + 混合压缩格式的旧文件,是因为统一改为一条声明一行,不能作为重复度指标。 +- 临时资产:合并器、旧版对照HTML、旧CSS副本及浏览器临时基线均已删除;历史恢复只依赖 + Git基线和标签,不在生产目录保留第二套CSS。 +- 结构门禁:覆盖唯一加载顺序、历史层不存在、所有者标头、全局选择器/上下文唯一性、 + 页面ID归属、共享根归属、禁用补丁文件名、规则数和体积上限。 +- 浏览器等价:真实`8797`运行页已完成拆分前后4K夜间情绪周期直接视觉对照,结构、图表、 + 表格、颜色和滚动位置一致;现行页面的日间/夜间、1920×1080、3840×2160与390×844 + 由端到端测试覆盖。全站最终视觉仍须用户在真实数据状态下人工验收,不以自动测试代替。 +- 自动验收:统一命令`python tools/verify_baseline.py --e2e`已通过,包括341项单元/契约测试、 + 46项真实浏览器测试、全部JavaScript语法检查、API/架构注册表、数据库完整性及补丁格式检查。 +- 未触碰:业务功能、DOM、JavaScript、数据口径、数据库、LLM、Docker/NAS、`next/`及其 + 冻结状态均未改变。 + +## HTML核心治理:`frontend/index.html` + +- 治理目标:一次性消除16个主页面和1个内部页面集中在入口HTML的问题,不重写页面、 + 不用JavaScript字符串保存DOM,也不保留第二套隐藏页面结构。 +- 机械拆分:原页面连续DOM区间按既有功能所有权归入12个真实`pages//page.html`; + 五类股池共属`pools`,智能选股与策略持续跟踪共属`screener`。Shell、摘要条、状态栏和 + 全局弹窗继续由`index.html`唯一持有。 +- 唯一装配:`index.html`只加载`bootstrap.js`;页面片段路径和既有脚本执行顺序只登记在 + `pages.config.js`。Bootstrap先并行取得静态片段,按原DOM顺序直接插入`.app-main`,再按 + 原顺序加载运行脚本,不增加影响Flex/Grid直接子关系的包装节点。 +- 启动契约:动态脚本并行下载但以`async=false`保持原执行顺序;全部运行脚本执行后发布 + 唯一`runtime-ready`状态。原入口只做一项精确登记的必要调整:若DOMContentLoaded尚未 + 发生则监听一次,若装配完成时文档已就绪则直接初始化,避免异步装配遗漏启动事件。 +- 规模结果:`index.html`由1,908行降为655行;页面结构分布到12个功能片段。HTML总量没有 + 伪装成删除,因为本次治理解决的是所有权集中,而不是删除产品界面。 +- 保真证据:片段重组后的完整页面区域与原版对应82,575个字符逐字符一致;原`app.js` + 除上述精确启动替换外仍通过既有9,283行源码重组保真测试,页面ID、DOM层级、动画与业务 + 实现未被改写。345项Python单元/契约测试和46项Playwright浏览器测试全部通过。 +- 结构门禁:入口不得出现`workspace-view`根节点;每个注册视图必须只存在于一个片段; + 片段禁止脚本;页面ID全局唯一;运行脚本顺序只允许一个注册所有者;业务数据请求继续 + 仅通过`shared/api.js`,Bootstrap只允许读取同源静态片段。 +- 未触碰:CSS视觉规则、业务功能、数据口径、数据库、LLM、Docker/NAS和冻结的`next/`。 + +## 前端启动核心治理:`frontend/app.js` + +- 治理目标:一次性消除入口脚本同时持有应用状态、主题、账户、管理后台、仪表盘、表格、 + 全站反馈以及16页控件事件的问题,不把原单体整体改名转移到另一个大文件。 +- 入口结果:`frontend/app.js`由1,943行降为101行,只保留初始化、认证后启动、初始路由、 + 页面挂载协调和唯一绑定调度;入口内不再定义页面业务事件、共享状态、主题、后台管理、 + 仪表盘或通用表格实现。 +- 共享所有权:状态与DOM句柄归入`shared/context.js`,API/Shell/页面生命周期组装归入 + `shared/application.js`,反馈与动效归入`shared/feedback.js`,行情仪表盘与日期归入 + `shared/dashboard.js`,认证和账户归入`shared/session.js`,系统管理归入`shared/admin.js`, + 主题归入`shared/theme.js`,通用表格归入`shared/table.js`。每项职责只有一个正式所有者, + 没有建立`legacy`、`compat`、`override`或`fix`转存层。 +- 页面所有权:原单一`bindEvents`已删除;12个功能页面在自身`page.js`注册一次性控件绑定, + 公共行情悬浮窗和详情绑定当时归入`pages/market/runtime.js`,后续已按下节治理为七个窄职责文件。`pages/runtime.js`负责防重绑定, + `app.js`不再逐页枚举选择器或事件。 +- 保真边界:除跨页事件绑定函数的结构化拆分外,原业务函数主体按源码范围机械迁移;旧 + 事件注册逐项归入对应所有者。保真测试会精确剥离登记过的页面绑定扩展后再与母版比较, + 不以文件存在或测试自洽代替母版差分。 +- 结构门禁:限制入口不超过120行;禁止状态创建、API/Shell组装、仪表盘、主题、后台和 + 表格实现回流入口;验证每个功能页面具有唯一绑定所有者;验证共享职责只有一个定义文件。 +- 自动验收:348项Python单元/契约测试、全部JavaScript语法检查、API注册表、架构清单、 + SQLite完整性和Git空白检查通过;46项Playwright浏览器测试全部通过(约2.5分钟)。 +- 未触碰:HTML与CSS视觉结果、业务计算、API数据口径、数据库、LLM、Docker/NAS及冻结的 + `next/`均未改变。本轮仍需用户在真实数据与登录状态下做最终人工功能确认。 + +## 行情前端运行时核心治理:`frontend/pages/market/runtime.js` + +- 治理目标:一次性消除行情图表、个股详情、板块/题材/指数详情、悬浮行情、全局搜索、 + 市场宽度和事件绑定集中在一个运行时文件的问题,不建立兼容入口或换名后的新单体。 +- 所有权结果:原`runtime.js`永久删除;现由`breadth.js`、`charts.js`、`entity-detail.js`、 + `stock-detail.js`、`preview.js`、`search.js`和`bindings.js`分别唯一持有七类职责,加载顺序只在 + `pages.config.js`登记。最大文件为`preview.js`的455个物理行,其余均不超过392行。 +- 有效代码审计:55个具名函数全部具有静态消费者,没有把无法证明无用的行情逻辑当作垃圾删除。 + 拆分前工作树为1,392个物理行,拆分后七文件合计1,404行;增加的12行只来自更细的原版源码 + 范围标记和文件边界,不是第二实现、包装层或业务代码膨胀。 +- 保真证据:原9,283行`static/app.js`仍可由当前全部源码范围逐字符重组,现有行情函数主体、 + DOM、Canvas绘制、API路径、事件顺序和错误处理未改写。注册表缓存版本同步提升,避免浏览器 + 刷新后继续加载已删除的旧运行时。 +- 结构门禁:禁止恢复`pages/market/runtime.js`;七个文件必须按固定顺序唯一注册;每类核心 + 函数只能存在于指定所有者;任一市场运行时文件不得超过500个物理行。 +- 自动验收:统一命令`python tools/verify_baseline.py --e2e`通过,包括349项Python单元/契约 + 测试、39个JavaScript语法检查、API/架构注册表、SQLite完整性、Git空白检查和46项真实 + 浏览器测试(约2.3分钟)。 +- 未触碰:视觉样式、页面DOM、后端业务、数据口径、数据库、LLM、Docker/NAS和冻结的`next/`。 + 本轮仍需用户在真实登录与数据状态下做最终人工功能确认。 + +## 智能选股引擎核心治理:`backend/features/screener/engine.py` + +- 治理目标:消除因子目录、外部同步、技术指标、因子构建、公式、阶段识别、选股执行和回测 + 集中在一个引擎文件的问题,同时保留原策略公式、定时任务、API、数据库副作用和前端行为。 +- 所有权结果:`engine.py`缩减为129行兼容门面;真实实现分别由`catalog.py`、`data_sync.py`、 + `indicators.py`、`factors.py`、`formula.py`、`regime.py`、`selection.py`和`backtest.py`唯一持有。 + 领域模块不再反向依赖兼容门面。 +- 规模说明:原单体为2,203行,拆分后的八个所有者与兼容门面合计2,456行;增加的253行来自 + 独立模块导入、类边界和窄委托,不包含第二套策略、因子或算法实现。本轮解决职责集中,未以 + 删除产品公式伪装代码减量。 +- 保真证据:所有迁移方法和辅助函数与原版逐项AST相同;公共`ScreenerEngine`、 + `FactorDataService`、目录常量、公式编译函数及根级兼容导入保持同一对象和原调用契约。 +- 结构门禁:兼容门面不得超过180行;领域模块不得依赖门面;每项计算只允许一个源码所有者; + 禁止重新合并为单体引擎。 +- 自动验收:353项Python测试和46项Playwright浏览器测试通过;架构/API注册表、JavaScript、 + SQLite完整性和Git空白检查通过。用户仍需在真实数据状态下做最终人工功能确认。 + +## Tushare客户端核心治理:`backend/data/providers/tushare_client.py` + +- 治理目标:消除HTTP请求、市场总览、实时宽度、指数、申万行业、通用板块、龙虎榜、个股详情、 + 交易日和涨跌停数据集中在一个Provider文件的问题,不改变方法签名、字段、单位、日期、缓存、 + 异常、降级或数据源语义。 +- 所有权结果:公共`TushareClient`仍是唯一可构造类型,`tushare_client.py`缩减为68行,只保留 + dataclass字段、共享缓存状态和领域Mixin组合。传输、总览、指数、申万行业、板块、龙虎榜、 + 个股、交易日及通用转换分别由九个`tushare_*.py`模块唯一持有。 +- 规模说明:原单体为2,165行,拆分后的领域模块与门面合计2,293行;增加的128行来自独立文件 + 的导入和类声明。最大所有者为644行,没有第二套`TushareClient`或重复方法体;一次性机械 + 拆分脚本已删除。 +- 保真证据:原类全部31个方法、18个顶层辅助函数和11个dataclass/共享缓存字段均与原版逐项 + AST一致;根级兼容模块、`DataGateway`、测试子类及`patch.object(TushareClient, "query")` + 继续指向同一个公共类和异常类型。 +- 结构门禁:门面不得超过100行且不得定义业务方法;任一领域所有者不得超过700行;领域模块 + 不得反向依赖门面;原方法必须完整且不重复地分布在登记所有者中。 +- 自动验收:354项Python测试、46项Playwright浏览器测试、全部JavaScript语法、API/架构注册表、 + SQLite完整性和Git空白检查通过。未修改前端、数据库、正式数据、Docker/NAS或冻结的`next/`; + 用户仍需在真实行情和页面状态下做最终人工功能确认。 + +## 问天服务核心治理:`backend/features/heaven/service.py` + +- 治理目标:消除手工六爻补录、趋势推演、市场上下文采集、观气/观心记录与LLM解读集中在一个问天服务文件的问题,不改变API、参数、返回字段、计算口径、数据库副作用、模型调用或前端行为。 +- 所有权结果:`service.py`由1,304行缩减为15行无方法组合门面;手工数据校验与六爻安全门唯一归属`manual.py`,趋势组装、市场模式、来源及质量校验唯一归属`trend.py`,个股/指数/行业上下文唯一归属`market_context.py`,个人气场、卦象、历史记录及智能解读唯一归属`readings.py`。 +- 规模说明:四个实现所有者分别为412、358、338、220行,连同15行门面合计1,343行;比原文件增加39行,全部来自四组独立导入和类边界,没有委托包装、第二实现或兼容转存层。一次性机械拆分脚本已删除。 +- 保真证据:20个问天服务方法逐项与原版AST比较;现有有意适配的`_heaven_reading_identity`保持当前生效实现。测试同时强制每个方法只归属一个文件、门面不得定义服务方法、领域模块不得反向依赖门面、每个所有者不得超过500行。 +- 自动验收:66项问天专项测试、354项全量Python测试及统一`python tools/verify_baseline.py --e2e`验收通过;后者覆盖JavaScript语法、API/架构注册表、SQLite完整性和46项Playwright浏览器流程。`git diff --check`无空白错误。 +- 未触碰:前端DOM/CSS/动画、HTTP路由、数据表与正式数据、LLM协议与提示词、Docker/NAS及冻结的`next/`均未改变;仍需用户在真实观势、观气、观心流程中做最终人工确认。 + +## 市场洞察核心治理:`backend/features/market/insights.py` + +- 治理目标:消除共享交易上下文、集合竞价评分与数据准备、竞价结果编排、题材库和人气热榜集中在一个市场洞察文件的问题,不改变API、参数、返回字段、评分、候选范围、缓存、数据库副作用或数据源降级语义。 +- 所有权结果:`insights.py`由1,307行缩减为45行无方法公共门面;构造与共享上下文唯一归属`insights_context.py`,竞价评分与候选构造唯一归属`insights_auction_scoring.py`,竞价时段/成交额/自选/动态快照唯一归属`insights_auction_data.py`,竞价中心编排唯一归属`insights_auction.py`,题材库与详情唯一归属`insights_themes.py`,人气榜唯一归属`insights_popularity.py`。 +- 规模说明:六个实现所有者分别为84、355、318、221、222、156行,连同45行门面合计1,401行;比原文件增加94行,全部来自独立导入、类边界和公共门面的历史模块级兼容导出,没有委托包装、第二实现或业务代码复制。一次性机械拆分脚本已删除。 +- 保真证据:原类全部22个方法逐项与原版AST比较,`_display_date`辅助函数保持原AST,公共`_number`仍指向统一数值标准化函数。测试强制每个方法只归属一个文件、门面不得定义业务方法、领域模块不得反向依赖门面、任一所有者不得超过400行。 +- 自动验收:48项市场洞察专项及前端契约测试、354项全量Python测试和统一`python tools/verify_baseline.py --e2e`验收通过;后者覆盖JavaScript语法、API/架构注册表、SQLite完整性和46项Playwright浏览器流程。`git diff --check`无空白错误。 +- 未触碰:集合竞价、题材库、人气榜的前端DOM/CSS及交互,HTTP路由、数据库结构与正式数据、数据源策略、Docker/NAS及冻结的`next/`均未改变;仍需用户在真实行情状态下做最终人工确认。 + +## 应用组合与HTTP协调核心治理:`backend/application.py` + +- 治理目标:消除系统配置、账户委托、后台任务生命周期、74条API的跨领域解析与响应以及应用依赖装配集中在一个文件的问题,同时保持公共`DashboardService`、`RequestHandler`、`SERVICE`、API注册表、鉴权顺序、异常状态码和启动方式不变。 +- 所有权结果:`application.py`由1,092行缩减为178行,只保留依赖导入、服务/请求处理器组合、当前构造函数和进程级服务实例。系统配置归属`features/system/service.py`,账户桥接归属`features/accounts/application.py`,任务生命周期归属`jobs/service.py`;公共鉴权、命名POST分派、feature遍历、静态回退和404归属`http/dispatch.py`;15类端点解析与响应分别归属对应`features//routes.py`。 +- 规模说明:组合根178行、统一分派器115行、三个应用服务所有者合计366行、15个feature路由所有者合计716行,总计1,375行,比原文件增加283行。增加部分来自19个独立模块的必要导入与类边界、各feature处理完成标志和统一分派循环;没有复制业务计算、保留第二套路由或增加兼容包装实现。物理行数增加,单文件职责和跨领域修改范围实质下降,此项不能表述为总代码减少。 +- 路由保真:重新生成的API仍为74条,方法、路径、精确/正则匹配、功能归属和访问级别与拆分前逐项一致,`config/api.config.json`保持原SHA-256哈希。公共POST在认证前分派;受保护请求仍按认证、CSRF、权限的原顺序执行;静态页面仍绕过API权限检查。 +- 结构门禁:`application.py`不得超过220行且仅允许`DashboardService.__init__`;`RequestHandler`不得直接定义HTTP方法;`http/dispatch.py`不得超过150行;每个feature路由文件不得超过120行、不得反向依赖`backend.application`或全局`SERVICE`;27个应用服务方法必须唯一归属于系统、账户或任务所有者。 +- 自动验收:356项全量Python测试和统一`python tools/verify_baseline.py --e2e`验收通过;后者覆盖JavaScript语法、API/架构注册表、SQLite完整性和46项Playwright浏览器流程。`git diff --check`无空白错误,一次性机械拆分脚本已删除。 +- 未触碰:前端DOM/CSS/动画、API数据字段、业务计算、数据库结构与正式数据、数据源和LLM策略、Docker/NAS及冻结的`next/`均未改变;仍需用户在真实登录和数据状态下做最终人工确认。 + +## 历史保真审计脚手架治理 + +- 治理目标:删除依靠复制数百行旧CSS片段、旧`app.js`行号和重组哈希证明保真的高维护机制,避免已退役的单体源码继续成为隐藏的第二套实现。 +- 删除结果:`tests/preservation_helpers.py`由600余行降至115行,只保留当前页面/脚本装配、必要哈希和AST契约;生产JavaScript中的源码范围标记全部删除;一次性`tools/split_frontend_runtime.py`退役。 +- 替代约束:当前脚本注册表和顺序、唯一符号所有者、页面DOM、API、JavaScript语法、CSS所有权、数据库完整性及Playwright交互成为持续维护边界。新增结构测试禁止历史标记、CSS回放表和旧拆分工具重新出现。 +- 历史证据:`docs/migration/evidence/slice-10/frontend-source-map.json`继续保留,只用于说明当时如何完成机械拆分,不参与应用启动、日常测试或未来功能开发。 +- 自动验收:356项Python测试通过;统一`py tools/verify_baseline.py --e2e`通过全部当前JavaScript语法、74条API注册表、架构清单、SQLite完整性和46项Playwright浏览器流程;`git diff --check`无空白错误。 +- 未触碰:业务计算、API字段、DOM结构、CSS规则、动画、数据源、LLM、数据库内容、Docker/NAS及冻结的`next/`均未改变;生产JavaScript仅删除无运行效果的审计注释。 + +## 根目录兼容转发与运行产物治理 + +- 治理目标:解决`app/`根目录散落19个旧导入转发文件,以及日志、Python缓存和浏览器测试产物 + 分散在源码目录的问题;不改变业务实现、导入对象身份、API、数据库、前端或启动行为。 +- 转发层删除:根目录Python文件由23个降为4个,只保留`api_access.py`、`database.py`、 + `server.py`和`sync_data.py`四个真实入口。19个兼容模块全部删除,生产代码和普通测试改为直接 + 导入`backend/`内的唯一实现;结构测试禁止这些旧模块或旧导入路径重新出现。 +- 唯一实现修正:`backend/features/screener/repository.py`中仅剩的一处运行时旧路径回退已删除, + 直接使用该模块原本已经导入的情绪领域实现,没有增加新的门面、包装或第二套业务逻辑。 +- 运行产物边界:新增`runtime/`作为唯一运行产物目录,日志统一进入`runtime/logs/`,Python字节码 + 缓存进入`runtime/cache/python/`,Playwright结果进入`runtime/test-results/`;项目根目录不再生成 + `*.log`。`tools/start_local.ps1`统一负责本地隐藏启动、PID记录、缓存和日志落点。 +- 现有产物整理:历史8785/8797日志和浏览器测试结果已移入`runtime/`,源码树中32个旧 + `__pycache__`目录在确认路径均位于`app/`后清除。`runtime/.gitignore`只保留目录边界,不提交 + 运行产物。 +- 自动验收:352项Python测试和46项Playwright浏览器测试全部通过;74条API注册表、架构清单、 + 全部JavaScript语法、SQLite完整性及`git diff --check`均通过。测试数量减少来自删除只证明旧别名 + 存在的兼容测试,新增结构门禁继续验证19个旧文件不得回归。 +- 未触碰:页面DOM/CSS/视觉/动画、API协议、业务计算、正式数据、数据库结构、数据源、LLM、 + Docker/NAS及冻结的`next/`均未改变。提交与推送仍等待用户明确要求。 + +## 独立化收口 + +- 正式边界:`app/`成为唯一产品源码和运行目录;生产代码、测试选择、部署和维护不再读取父目录。 +- 文档归位:完整产品规格、治理记录、迁移账本和证据整体进入`app/docs/`;新增当前维护指南与 + 文档索引,历史双版本切换指南明确标记为历史资料。 +- 测试收口:10个只比较父目录旧源码的`test_preservation_*`及旧比较辅助模块退役;当前页面与 + 脚本装配辅助函数缩减为独立的`tests/frontend_test_helpers.py`。统一验收始终运行全部当前测试, + 不再根据父目录文件是否存在静默改变测试集合。 +- 工具收口:5个只服务迁移复制和新旧差分的脚本退出活动`tools/`;迁移方法和结果继续由Git历史 + 与`docs/migration/evidence/`保存。 +- 数据边界:`app/data/review.db`是唯一正式数据库;外层旧库最后写入停留在2026-08-01。 + `.env`、数据库、私有Skill和`runtime/`均保持Git忽略,未进入提交。 +- 独立扫描:活动源码、测试、工具和部署文档对`ROOT.parent`、`ORIGINAL_ROOT`、父级`static`及 + 迁移比较工具的运行引用为0;历史文档中的旧路径仅作审计记录。 +- 自动验收:289项Python测试、39个JavaScript文件语法、74条API、架构注册表、435MB SQLite + 完整性及46项Playwright浏览器测试全部通过;`git diff --check`无空白错误。 +- 后续边界:外层旧程序与失败的`next/`只待本回档点推送后清理;清理必须使用独立提交,不与 + 产品代码修改混合。Docker/NAS切换仍需用户单独批准。 + ## 决策记录 | 日期 | 决策 | 原因 | @@ -242,6 +453,8 @@ | 2026-07-30 | 目标目录确定为`app/` | 使用长期正式名称,不再建立新的版本式重写目录 | | 2026-07-30 | 先建立原样可运行副本,再迁移目录职责 | 保证整理的是原件,而不是依据规格书重新实现 | | 2026-07-30 | 不确定代码进入待删账本并延迟试删 | 删除必须能够单独回档并经自动与人工验收 | +| 2026-08-03 | `app/`成为唯一正式源码边界 | 用户确认结构重铸与迁移目标基本完成并要求独立化收口 | +| 2026-08-03 | 迁移比较脚手架退出日常测试 | 父目录删除后测试集合必须保持完整且确定,不能静默跳过 | ## 恢复工作检查 diff --git a/docs/migration/原版保真迁移总纲.md b/app/docs/migration/原版保真迁移总纲.md similarity index 100% rename from docs/migration/原版保真迁移总纲.md rename to app/docs/migration/原版保真迁移总纲.md diff --git a/docs/migration/原版资产清单.json b/app/docs/migration/原版资产清单.json similarity index 100% rename from docs/migration/原版资产清单.json rename to app/docs/migration/原版资产清单.json diff --git a/docs/migration/目标目录与切片顺序.md b/app/docs/migration/目标目录与切片顺序.md similarity index 100% rename from docs/migration/目标目录与切片顺序.md rename to app/docs/migration/目标目录与切片顺序.md diff --git a/docs/migration/重建迁移章程.md b/app/docs/migration/重建迁移章程.md similarity index 100% rename from docs/migration/重建迁移章程.md rename to app/docs/migration/重建迁移章程.md diff --git a/docs/product/小白复盘-完整产品规格说明书.md b/app/docs/product/小白复盘-完整产品规格说明书.md similarity index 100% rename from docs/product/小白复盘-完整产品规格说明书.md rename to app/docs/product/小白复盘-完整产品规格说明书.md diff --git a/app/frontend/app.js b/app/frontend/app.js index b3552c7..0fbfd37 100644 --- a/app/frontend/app.js +++ b/app/frontend/app.js @@ -1,480 +1,3 @@ -/* PRESERVATION-SOURCE-BEGIN app.js:1-1206 */ -const { - clamp, - displayCompactDate, - escapeHtml, - formatNumber, - formatTimestamp, - localDateString, - number, - parseLocalDate, - todayString, -} = window.XiaobaiUI; - -const { - emptyStateHtml, - renderEmptyState, -} = window.XiaobaiComponents; - -const HEART_BREATH_INHALE_MS = 3_000; -const HEART_BREATH_HOLD_MS = 2_000; -const HEART_BREATH_EXHALE_MS = 4_000; -const HEART_BREATH_PREPARE_MS = 1_000; -const HEART_BREATH_CYCLE_MS = HEART_BREATH_INHALE_MS + HEART_BREATH_HOLD_MS + HEART_BREATH_EXHALE_MS; -const HEART_BREATH_ACTIVE_MS = HEART_BREATH_CYCLE_MS * 5; -const HEART_BREATH_TOTAL_MS = HEART_BREATH_PREPARE_MS + HEART_BREATH_ACTIVE_MS; -const THEME_STORAGE_KEY = "xiaobaiTheme"; -let activeThemeTransition = null; -let themeSwitchSequence = 0; - -const state = window.XiaobaiState.create({ - session: { - user: null, - csrfToken: "", - authMode: "login", - started: false, - activeView: "sentimentCycleView", - dashboardLoading: false, - dashboardRequestSequence: 0, - dashboardRequestDate: "", - adminModels: [], - globalSearchResults: [], - globalSearchActiveIndex: -1, - globalSearchRequestSequence: 0, - }, - market: { - dashboard: null, - filter: "all", - query: "", - sortKey: "streak", - sortDirection: "desc", - brokenQuery: "", - brokenSortKey: "", - brokenSortDirection: "desc", - downQuery: "", - downSortKey: "", - downSortDirection: "asc", - yesterdayFilter: "all", - yesterdayQuery: "", - yesterdaySortKey: "", - yesterdaySortDirection: "desc", - dragonTiger: null, - dragonViewMode: "daily", - dragonFilter: "all", - dragonQuery: "", - selectedDragonTraderId: "", - hotMoneyProfiles: null, - hotMoneyProfileQuery: "", - selectedHotMoneyProfileId: "", - rotationHistory: null, - rotationHistoryKey: "", - rotationSelectedSector: "", - rotationSelectedDate: "", - rotationMembers: null, - rotationMembersKey: "", - rotationMembersLoading: false, - rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", - rotationLoading: false, - auctionData: null, - auctionDataset: "focus", - auctionFilter: "all", - auctionQuery: "", - auctionSortKey: "attention_score", - auctionSortDirection: "desc", - auctionLoading: false, - auctionTimer: null, - themeLibrary: null, - themeQuery: "", - selectedThemeCode: "", - themeDetail: null, - themeLoading: false, - popularityData: null, - popularitySource: "combined", - popularityQuery: "", - popularityLoading: false, - expandedLadderLevels: new Set(), - ladderSortMode: "time", - sentimentHistory: null, - sentimentRange: 20, - sentimentHistoryKey: "", - sentimentLoading: false, - }, - details: { - stockDetail: null, - activeStock: null, - stockDetailChartMode: "daily", - stockDetailIntraday: null, - stockDetailRequestSequence: 0, - entityDetailItem: null, - entityDetailPayload: null, - entityDetailChartMode: "daily", - entityDetailIntraday: null, - entityDetailRequestSequence: 0, - stockPreviewCode: "", - stockPreviewType: "stock", - stockPreviewItem: null, - stockPreviewPayload: null, - stockPreviewChart: "daily", - stockPreviewFallback: null, - initialStockOpened: false, - }, - review: { - watchlist: [], - watchlistSelection: null, - watchlistSearchResults: [], - watchlistSearchRequestSequence: 0, - editingDailyNoteId: 0, - notes: [], - tradeEntries: [], - tradeSummary: {}, - editingTradeId: 0, - alerts: [], - alertFilter: "all", - alertUnreadCount: 0, - assistantMessages: [], - assistantLoading: false, - assistantController: null, - }, - screener: { - screenerSetup: null, - screenerSetupKey: "", - screenerSetupRequestKey: "", - screenerSetupPromise: null, - selectedRegime: "", - selectedStrategy: null, - customStrategyDraft: null, - screenerRunning: false, - screenerRunningMode: "", - screenerResults: { smart: null, curated: null, quant: null }, - screenerResultContexts: { smart: null, curated: null, quant: null }, - screenerResultStore: {}, - screenerTracking: null, - screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode")) - ? localStorage.getItem("xiaobaiScreenerMode") - : "smart", - curatedCategory: "全部", - curatedSchool: "全部", - curatedQuery: "", - curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", - selectedCuratedStrategyId: 0, - quantFilters: [], - quantScores: [], - screenerMobileView: "strategy", - }, - mentor: { - mentorSetup: null, - selectedMentorId: "", - mentorMessages: [], - mentorLoading: false, - mentorQuery: "", - mentorGrade: "all", - mentorDirectoryOpen: false, - mentorSortMode: false, - mentorSavingPreferences: false, - mentorController: null, - }, - heaven: { - heavenSetup: null, - heavenManualData: null, - personalField: null, - heavenPanel: "trend", - heavenInterpretations: { trend: "", fortune: "", heart: "" }, - heavenReadingMode: "trend", - heavenReadingTab: "current", - heavenReadingHistory: { trend: [], fortune: [], heart: [] }, - heavenReadingSelectedId: 0, - heavenReadingLoading: false, - heavenReadingError: "", - heartStage: "intro", - heartTimer: null, - heartSeconds: HEART_BREATH_TOTAL_MS / 1000, - heartBreathingEndsAt: 0, - heartLines: [], - heartThrows: [], - heartHexagram: null, - heartCurtainTimer: null, - heartStageToken: 0, - heartRevealToken: 0, - heavenPerformanceKey: "", - heavenPerformancePanels: new Set(), - heavenPerformanceActive: "", - heavenRequestSequence: 0, - }, -}); - -window.XiaobaiAPI.configure({ - csrfToken: () => state.csrfToken, - onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"), -}); - -const applicationShell = window.XiaobaiShell.create({ - state, - pages: window.XiaobaiPages, - motionEnabled, - animateRows, - refreshIcons, - tradeDate: () => displayCompactDate( - state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "", - ), - onNavigate: (viewId) => openView(viewId), - onNavigationSync: () => toggleAccountDropdown(false), -}); - -const pageModules = window.XiaobaiPageModules.create({ - pages: window.XiaobaiPages, - actions: { - closeTransientUi: () => closeStockPreview(), - applyAccess: () => applyMembershipAccess(), - clearAuction: () => clearAuctionTimer(), - stopHeaven: () => { - stopQiFieldCanvas(); - stopHeartDust(); - cancelHeavenPerformance(); - }, - loadSentiment: () => loadSentimentHistory(), - loadRotation: () => loadRotationHistory(), - loadAuction: () => loadAuctionCenter(), - loadThemes: () => loadThemeLibrary(), - loadPopularity: () => loadPopularity(), - loadDragonTiger: () => loadDragonTiger(), - loadReview: () => loadReviewWorkspace(), - loadScreener: () => { - if (hasMemberAccess() && state.dashboard) loadScreenerSetup(); - }, - loadMentor: () => { - if (hasMemberAccess()) loadMentorSetup(); - }, - loadHeaven: () => { - if (!hasMemberAccess()) return; - loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim()); - }, - }, -}); - -const elements = { - tradeDate: document.querySelector("#tradeDate"), - loading: document.querySelector("#loadingOverlay"), - toast: document.querySelector("#toast"), - stockDialog: document.querySelector("#stockDialog"), - tradeLogDialog: document.querySelector("#tradeLogDialog"), - watchlistDialog: document.querySelector("#watchlistDialog"), - alertsDialog: document.querySelector("#alertsDialog"), - assistantDialog: document.querySelector("#assistantDialog"), - heavenReadingDialog: document.querySelector("#heavenReadingDialog"), - globalSearchDialog: document.querySelector("#globalSearchDialog"), - globalSearchInput: document.querySelector("#globalSearchInput"), - globalSearchResults: document.querySelector("#globalSearchResults"), - entityDetailDialog: document.querySelector("#entityDetailDialog"), - entityDetailChart: document.querySelector("#entityDetailChart"), - settingsDialog: document.querySelector("#settingsDialog"), - adminDialog: document.querySelector("#adminDialog"), - priceChart: document.querySelector("#priceChart"), - stockPreview: document.querySelector("#stockPreview"), - stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"), - stockPreviewChart: document.querySelector("#stockPreviewChart"), -}; - -function openModalDialog(dialog) { - applicationShell.openModalDialog(dialog); -} - -const metricAnimationFrames = new WeakMap(); -const stockPreviewCache = new Map(); -const STOCK_PREVIEW_DELAY = 380; -const STOCK_PREVIEW_CACHE_MS = 5 * 60 * 1000; -const LIVE_REFRESH_DEFAULT_MS = 10 * 1000; -let qiFieldAnimationFrame = 0; -let qiFieldSoloElement = ""; -let heavenPerformanceToken = 0; -let heavenReadingAnimation = null; -let heartHoldTimer = null; -let heartHoldTriggered = false; -let heartHoldStartedAt = 0; -let heartHoldAnimationFrame = 0; -let heartCastingBusy = false; -let heartDustAnimationFrame = 0; -let heartDustParticles = []; -let heartIncenseAnimation = null; -const heartCoinRotations = [0, 0, 0]; -let rowAnimationObserver = null; -let stockPreviewOpenTimer = null; -let stockPreviewCloseTimer = null; -let stockPreviewAbortController = null; -let stockPreviewAnchor = null; -let sentimentChartAnimationFrame = null; -let heavenResizeTimer = null; -let globalSearchTimer = null; -let watchlistSearchTimer = null; -let assistantRenderFrame = 0; - -const heartSound = { - enabled: false, - context: null, - ensure() { - if (!this.context) { - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - if (!AudioContextClass) return null; - this.context = new AudioContextClass(); - } - if (this.context.state === "suspended") this.context.resume(); - return this.context; - }, - tone(frequency, duration, gain, type = "sine", delay = 0) { - if (!this.enabled) return; - const context = this.ensure(); - if (!context) return; - const start = context.currentTime + delay; - const oscillator = context.createOscillator(); - const volume = context.createGain(); - oscillator.type = type; - oscillator.frequency.value = frequency; - volume.gain.setValueAtTime(0.0001, start); - volume.gain.linearRampToValueAtTime(gain, start + 0.015); - volume.gain.exponentialRampToValueAtTime(0.0001, start + duration); - oscillator.connect(volume).connect(context.destination); - oscillator.start(start); - oscillator.stop(start + duration + 0.05); - }, - chime(frequency = 640) { - this.tone(frequency, 4.8, 0.12); - this.tone(frequency * 2.02, 3.6, 0.045); - this.tone(frequency * 3.96, 2.2, 0.018); - }, - coin(delay = 0) { - this.tone(2350 + Math.random() * 260, 0.28, 0.055, "triangle", delay); - this.tone(3250 + Math.random() * 260, 0.18, 0.025, "triangle", delay + 0.01); - }, -}; - -const HEART_WHISPERS = [ - ["应无所住,而生其心", 10, 12, 0], - ["不是风动,不是幡动,仁者心动", 89, 8, 1], - ["菩提本无树,明镜亦非台", 16, 52, 2], - ["本来无一物,何处惹尘埃", 84, 54, 3], - ["心外无物,心外无理", 22, 18, 4], - ["知行合一", 78, 30, 5], - ["此心光明,亦复何言", 90, 60, 6], -]; - -window.addEventListener("resize", () => { - clearTimeout(heavenResizeTimer); - heavenResizeTimer = setTimeout(() => { - if (state.activeView !== "heavenView") return; - if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { - renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); - drawQiUseConnections(false); - } - if (state.heavenPanel === "heart") startHeartDust(); - }, 120); -}); - -document.addEventListener("DOMContentLoaded", initialize); - -function syncThemeControl() { - const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; - const button = document.querySelector("#themeToggle"); - if (!button) return; - const dark = theme === "dark"; - const label = dark ? "切换到日间模式" : "切换到夜间模式"; - button.title = label; - button.setAttribute("aria-label", label); - button.setAttribute("aria-pressed", String(dark)); - button.querySelector("i")?.setAttribute("data-lucide", dark ? "sun" : "moon"); -} - -function clearThemeTransitionEffects() { - document.querySelectorAll(".row-enter, .row-pending, .view-entering").forEach((element) => { - element.classList.remove("row-enter", "row-pending", "view-entering"); - element.style.removeProperty("--row-delay"); - }); -} - -function redrawThemeSensitiveVisuals() { - if (!elements.stockPreview.hidden && state.stockPreviewPayload) { - selectStockPreviewChart(state.stockPreviewChart); - } - if (elements.stockDialog.open) { - if (state.stockDetailChartMode === "intraday" && state.stockDetailIntraday?.points?.length) { - drawIntradayCanvas( - elements.priceChart, - state.stockDetailIntraday.points, - [], - state.stockDetailIntraday.meta?.previous_close, - ); - } else if (state.stockDetail?.prices) drawPriceChart(state.stockDetail.prices); - } - if (elements.entityDetailDialog.open) { - if (state.entityDetailChartMode === "intraday" && state.entityDetailIntraday?.points?.length) { - drawIntradayCanvas( - elements.entityDetailChart, - state.entityDetailIntraday.points, - [], - state.entityDetailIntraday.meta?.previous_close, - ); - } else if (state.entityDetailPayload?.series) { - drawEntityDetailChart(state.entityDetailPayload.series); - } - } - if (state.activeView === "sentimentCycleView" && state.sentimentHistory) { - drawSentimentTrendChart(state.sentimentHistory.rows || []); - } - if (state.activeView === "heavenView") { - if (state.heavenPanel === "fortune" && state.heavenSetup?.field) { - renderQiFieldCanvas(state.heavenSetup.field.balance || [], { intro: false }); - drawQiUseConnections(false); - } - if (state.heavenPanel === "heart") startHeartDust(); - } -} - -function commitTheme(normalized, persist) { - document.documentElement.dataset.theme = normalized; - document.documentElement.style.colorScheme = normalized; - if (persist) { - try { - localStorage.setItem(THEME_STORAGE_KEY, normalized); - } catch (_error) { - // The selected theme still applies for the current page when storage is unavailable. - } - } - syncThemeControl(); - refreshIcons(); - redrawThemeSensitiveVisuals(); -} - -function applyTheme(theme, persist = true) { - const normalized = theme === "dark" ? "dark" : "light"; - const root = document.documentElement; - if (root.dataset.theme === normalized) { - commitTheme(normalized, persist); - return; - } - const sequence = ++themeSwitchSequence; - activeThemeTransition?.skipTransition?.(); - clearThemeTransitionEffects(); - root.classList.add("theme-switching"); - - const update = () => commitTheme(normalized, persist); - const finish = () => { - if (sequence !== themeSwitchSequence) return; - clearThemeTransitionEffects(); - root.classList.remove("theme-switching"); - activeThemeTransition = null; - }; - const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; - if (!reducedMotion && typeof document.startViewTransition === "function") { - activeThemeTransition = document.startViewTransition(update); - activeThemeTransition.finished.then(finish, finish); - return; - } - update(); - requestAnimationFrame(() => requestAnimationFrame(finish)); -} - -function toggleTheme() { - applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"); -} - async function initialize() { syncThemeControl(); refreshIcons(); @@ -540,725 +63,6 @@ async function startAuthenticatedApp() { } } -function selectAuthMode(mode) { - state.authMode = mode === "register" ? "register" : "login"; - document.querySelectorAll("[data-auth-mode]").forEach((button) => { - button.classList.toggle("active", button.dataset.authMode === state.authMode); - }); - const registering = state.authMode === "register"; - document.querySelector("#authConfirmField").hidden = !registering; - document.querySelector("#authPasswordConfirm").required = registering; - document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password"; - document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录"; - document.querySelector("#authError").hidden = true; -} - -async function submitAuthForm(event) { - event.preventDefault(); - const username = document.querySelector("#authUsername").value.trim(); - const password = document.querySelector("#authPassword").value; - const errorElement = document.querySelector("#authError"); - if (state.authMode === "register" && password !== document.querySelector("#authPasswordConfirm").value) { - errorElement.textContent = "两次输入的密码不一致。"; - errorElement.hidden = false; - return; - } - const button = document.querySelector("#authSubmitButton"); - button.disabled = true; - try { - const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password }); - document.querySelector("#authForm").reset(); - await applyAuthenticatedSession(session); - } catch (error) { - errorElement.textContent = error.message || "账号操作失败"; - errorElement.hidden = false; - } finally { - button.disabled = false; - } -} - -async function applyAuthenticatedSession(session) { - state.user = session.user; - state.csrfToken = session.csrf_token || ""; - setText("accountName", session.user?.username || "账号"); - const isAdmin = session.user?.role === "admin"; - updateAccountIdentityBadges(session.user?.membership || {}); - document.querySelector("#settingsButton").hidden = !isAdmin; - document.querySelector("#syncButton").hidden = !isAdmin; - document.querySelector("#reasonForm").hidden = !isAdmin; - document.querySelector("#sectorPhaseManager").hidden = !isAdmin; - document.querySelector("#authGate").hidden = true; - applyMembershipAccess(); - await startAuthenticatedApp(); -} - -function showAuthGate(message = "") { - state.user = null; - state.csrfToken = ""; - const gate = document.querySelector("#authGate"); - gate.hidden = false; - const errorElement = document.querySelector("#authError"); - errorElement.textContent = message; - errorElement.hidden = !message; - document.querySelector("#authUsername").focus(); -} - -async function logoutAccount() { - toggleAccountDropdown(false); - try { - await apiRequest("/api/auth/logout", "POST", {}); - } catch (error) { - showToast(error.message || "退出失败"); - return; - } - window.location.reload(); -} - -function bindEvents() { - document.querySelectorAll("[data-auth-mode]").forEach((button) => { - button.addEventListener("click", () => selectAuthMode(button.dataset.authMode)); - }); - document.querySelector("#authForm").addEventListener("submit", submitAuthForm); - document.querySelector("#refreshButton").addEventListener("click", async (event) => { - const button = event.currentTarget; - button.disabled = true; - try { - await loadDashboard(false, false, false); - } finally { - button.disabled = false; - } - }); - document.querySelector("#syncButton").addEventListener("click", startAdminRefresh); - elements.tradeDate.addEventListener("change", () => { - state.dashboardRequestSequence += 1; - state.heavenRequestSequence += 1; - state.heavenManualData = null; - document.querySelector("#qiObservationDate").value = elements.tradeDate.value; - loadDashboard(); - }); - document.querySelector("#prevDate").addEventListener("click", () => shiftDate(-1)); - document.querySelector("#nextDate").addEventListener("click", () => shiftDate(1)); - document.querySelector("#stockSearch").addEventListener("input", (event) => { - state.query = event.target.value.trim().toLowerCase(); - renderLimitTable(); - }); - document.querySelectorAll("[data-table-search]").forEach((input) => { - input.addEventListener("input", () => { - const query = input.value.trim().toLowerCase(); - const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`); - body?.querySelectorAll("tr").forEach((row) => { - row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query); - }); - }); - }); - - document.querySelectorAll("[data-filter]").forEach((button) => { - button.addEventListener("click", () => { - document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active")); - button.classList.add("active"); - state.filter = button.dataset.filter; - renderLimitTable(); - }); - }); - - document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); - document.querySelector("#themeToggle").addEventListener("click", toggleTheme); - document.querySelector("#alertButton").addEventListener("click", openAlerts); - document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant); - document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close()); - document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion); - document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse); - document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation); - document.querySelectorAll("[data-assistant-prompt]").forEach((button) => { - button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt)); - }); - document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close()); - document.querySelector("#alertForm").addEventListener("submit", saveAlert); - document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead); - document.querySelector("#alertList").addEventListener("click", handleAlertAction); - document.querySelectorAll("[data-alert-filter]").forEach((button) => { - button.addEventListener("click", () => selectAlertFilter(button.dataset.alertFilter)); - }); - document.querySelector("#closeGlobalSearch").addEventListener("click", closeGlobalSearch); - document.querySelector("#closeEntityDetail").addEventListener("click", () => elements.entityDetailDialog.close()); - document.querySelectorAll("[data-entity-detail-chart]").forEach((button) => { - button.addEventListener("click", () => selectEntityDetailChart(button.dataset.entityDetailChart)); - }); - elements.globalSearchDialog.addEventListener("click", (event) => { - if (event.target === elements.globalSearchDialog) closeGlobalSearch(); - }); - elements.globalSearchInput.addEventListener("input", scheduleGlobalSearch); - elements.globalSearchInput.addEventListener("keydown", handleGlobalSearchInputKeydown); - elements.globalSearchResults.addEventListener("click", (event) => { - const result = event.target.closest("[data-search-result-index]"); - if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex)); - }); - document.addEventListener("click", (event) => { - if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false); - }); - window.addEventListener("keydown", handleGlobalSearchShortcut); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - toggleAccountDropdown(false, true); - toggleMentorDirectory(false); - } - handleAccountMenuKeydown(event); - }); - window.addEventListener("resize", () => { - if (window.innerWidth > 720) toggleMentorDirectory(false); - if (!elements.stockPreview.hidden) closeStockPreview(); - if (state.activeView === "dragonView") layoutDragonCards(); - }); - document.querySelectorAll("[data-open-account]").forEach((button) => { - button.addEventListener("click", () => openSettings("membership")); - }); - document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => { - header.addEventListener("click", () => changeSort(header.dataset.sort)); - }); - document.querySelector("#brokenSearch").addEventListener("input", (event) => { - state.brokenQuery = event.target.value.trim().toLowerCase(); - renderBrokenTable(state.dashboard?.broken || []); - }); - document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => { - header.addEventListener("click", () => changeBrokenSort(header.dataset.brokenSort)); - }); - document.querySelector("#downSearch").addEventListener("input", (event) => { - state.downQuery = event.target.value.trim().toLowerCase(); - renderDownTable(state.dashboard?.down_limits || []); - }); - document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => { - header.addEventListener("click", () => changeDownSort(header.dataset.downSort)); - }); - document.querySelector("#yesterdaySearch").addEventListener("input", (event) => { - state.yesterdayQuery = event.target.value.trim().toLowerCase(); - renderYesterdayTable(state.dashboard?.yesterday_limits || []); - }); - document.querySelectorAll("[data-yesterday-filter]").forEach((button) => { - button.addEventListener("click", () => { - state.yesterdayFilter = button.dataset.yesterdayFilter; - renderYesterdayTable(state.dashboard?.yesterday_limits || []); - }); - }); - document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => { - header.addEventListener("click", () => changeYesterdaySort(header.dataset.yesterdaySort)); - }); - document.querySelectorAll("[data-ladder-sort]").forEach((button) => { - button.addEventListener("click", () => { - state.ladderSortMode = button.dataset.ladderSort === "open" ? "open" : "time"; - document.querySelectorAll("[data-ladder-sort]").forEach((item) => { - const active = item === button; - item.classList.toggle("active", active); - item.setAttribute("aria-pressed", String(active)); - }); - renderLadderBoard(state.dashboard?.ladders || []); - }); - }); - - document.querySelector("#exportButton").addEventListener("click", exportStocks); - document.querySelector("#brokenExportButton").addEventListener("click", exportBroken); - document.querySelector("#downExportButton").addEventListener("click", exportDown); - document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday); - document.querySelector("#ladderExportButton").addEventListener("click", exportLadder); - document.querySelector("#rotationExportButton").addEventListener("click", exportRotation); - document.querySelectorAll("[data-rotation-order]").forEach((button) => { - button.addEventListener("click", () => { - state.rotationOrder = button.dataset.rotationOrder === "latest" ? "latest" : "oldest"; - localStorage.setItem("xiaobaiRotationOrder", state.rotationOrder); - renderRotationHistory(); - }); - }); - document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory); - document.querySelectorAll("[data-sentiment-range]").forEach((button) => { - button.addEventListener("click", () => { - state.sentimentRange = number(button.dataset.sentimentRange) || 20; - document.querySelectorAll("[data-sentiment-range]").forEach((item) => { - item.classList.toggle("active", item === button); - }); - loadSentimentHistory(true); - }); - }); - document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings()); - document.querySelector("#accountButton").addEventListener("click", (event) => { - event.stopPropagation(); - toggleAccountDropdown(); - }); - document.querySelector("#accountVipBadge").addEventListener("click", () => openSettings("membership")); - document.querySelectorAll("[data-account-panel]").forEach((button) => { - button.addEventListener("click", () => openSettings(button.dataset.accountPanel)); - }); - document.querySelector("#switchAccountMenuButton").addEventListener("click", switchAccount); - document.querySelector("#logoutMenuButton").addEventListener("click", logoutAccount); - document.querySelector("#closeSettingsDialog").addEventListener("click", () => elements.settingsDialog.close()); - document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close()); - document.querySelector("#closeStockDialog").addEventListener("click", () => elements.stockDialog.close()); - document.querySelectorAll("[data-stock-detail-chart]").forEach((button) => { - button.addEventListener("click", () => selectStockDetailChart(button.dataset.stockDetailChart)); - }); - document.querySelector("#closeStockPreview").addEventListener("click", closeStockPreview); - elements.stockPreviewBackdrop.addEventListener("click", closeStockPreview); - document.querySelector("#openStockDetailFromPreview").addEventListener("click", openStockDetailFromPreview); - document.querySelectorAll("[data-preview-chart]").forEach((button) => { - button.addEventListener("click", () => selectStockPreviewChart(button.dataset.previewChart)); - }); - elements.stockPreview.addEventListener("pointerenter", cancelStockPreviewClose); - elements.stockPreview.addEventListener("pointerleave", scheduleStockPreviewClose); - document.addEventListener("pointerover", handleStockPreviewPointerOver); - document.addEventListener("pointerout", handleStockPreviewPointerOut); - document.addEventListener("focusin", handleStockPreviewFocus); - document.addEventListener("focusout", handleStockPreviewFocusOut); - document.addEventListener("click", handleMobileStockPreviewClick, true); - document.addEventListener("keydown", handleStockPreviewKeydown); - document.addEventListener("scroll", repositionStockPreview, true); - document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true)); - document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows); - document.querySelector("#auctionSearch").addEventListener("input", (event) => { - state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderAuctionTable(); - }); - document.querySelectorAll("[data-auction-dataset]").forEach((button) => { - button.addEventListener("click", () => { - state.auctionDataset = button.dataset.auctionDataset || "focus"; - state.auctionFilter = "all"; - state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score"; - state.auctionSortDirection = "desc"; - document.querySelectorAll("[data-auction-dataset]").forEach((item) => { - const active = item === button; - item.classList.toggle("active", active); - item.setAttribute("aria-selected", String(active)); - }); - document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item.dataset.auctionFilter === "all")); - renderAuctionTable(); - }); - }); - document.querySelectorAll("[data-auction-filter]").forEach((button) => { - button.addEventListener("click", () => { - state.auctionFilter = button.dataset.auctionFilter || "all"; - document.querySelectorAll("[data-auction-filter]").forEach((item) => item.classList.toggle("active", item === button)); - renderAuctionTable(); - }); - }); - document.querySelector("#auctionTable").addEventListener("click", (event) => { - const header = event.target.closest("th[data-auction-sort]"); - if (!header) return; - const key = header.dataset.auctionSort; - if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc"; - else { - state.auctionSortKey = key; - state.auctionSortDirection = "desc"; - } - renderAuctionTable(); - }); - document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer); - document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close()); - document.querySelector("#strategyDrawer").addEventListener("click", (event) => { - if (event.target === event.currentTarget) event.currentTarget.close(); - }); - document.querySelector("#themeRefreshButton").addEventListener("click", () => loadThemeLibrary(true)); - document.querySelector("#themeSearch").addEventListener("input", (event) => { - state.themeQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderThemeDirectory(); - }); - document.querySelector("#themeDirectory").addEventListener("click", (event) => { - const button = event.target.closest("[data-theme-code]"); - if (button) selectTheme(button.dataset.themeCode); - }); - document.querySelector("#popularityRefreshButton").addEventListener("click", () => loadPopularity(true)); - document.querySelector("#popularitySearch").addEventListener("input", (event) => { - state.popularityQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderPopularityTable(); - }); - document.querySelectorAll("[data-popularity-source]").forEach((button) => { - button.addEventListener("click", () => { - state.popularitySource = button.dataset.popularitySource || "combined"; - document.querySelectorAll("[data-popularity-source]").forEach((item) => { - const active = item === button; - item.classList.toggle("active", active); - item.setAttribute("aria-selected", String(active)); - }); - renderPopularityTable(); - }); - }); - document.querySelector("#dragonRefreshButton").addEventListener("click", () => { - if (state.dragonViewMode === "profiles") loadHotMoneyProfiles(true); - else loadDragonTiger(true); - }); - document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true)); - document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1)); - document.querySelector("#dragonExportButton").addEventListener("click", () => { - if (state.dragonViewMode === "profiles") exportHotMoneyProfiles(); - else exportDragonTiger(); - }); - document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => { - button.addEventListener("click", () => selectDragonViewMode(button.dataset.dragonViewMode)); - }); - document.querySelector("#dragonSearch").addEventListener("input", (event) => { - state.dragonQuery = event.target.value.trim().toLowerCase(); - renderDragonTraderList(); - }); - document.querySelectorAll("[data-dragon-filter]").forEach((button) => { - button.addEventListener("click", () => { - state.dragonFilter = button.dataset.dragonFilter; - document.querySelectorAll("[data-dragon-filter]").forEach((item) => { - item.classList.toggle("active", item === button); - }); - renderDragonTraderList(); - }); - }); - document.querySelector("#hotMoneyProfileSearch").addEventListener("input", (event) => { - state.hotMoneyProfileQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderHotMoneyProfiles(); - }); - document.querySelector("#hotMoneyProfileList").addEventListener("click", (event) => { - const button = event.target.closest("[data-hot-money-profile]"); - if (!button) return; - state.selectedHotMoneyProfileId = button.dataset.hotMoneyProfile; - renderHotMoneyProfiles(); - }); - document.querySelector("#journalForm").addEventListener("submit", saveJournal); - document.querySelector("#journalDate").addEventListener("change", populateJournalForm); - document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog()); - document.querySelector("#closeWatchlistDialog").addEventListener("click", closeWatchlistDialog); - document.querySelector("#cancelWatchlistEdit").addEventListener("click", closeWatchlistDialog); - document.querySelector("#changeWatchlistSelection").addEventListener("click", clearWatchlistSelection); - document.querySelector("#watchlistSearchInput").addEventListener("input", scheduleWatchlistSearch); - document.querySelector("#watchlistForm").addEventListener("submit", saveWatchlistFromDialog); - document.querySelector("#watchlistSearchResults").addEventListener("click", handleWatchlistSearchResult); - document.querySelector("#reviewHistoryToggle").addEventListener("click", (event) => { - const panel = document.querySelector("#reviewHistoryPanel"); - const expanded = event.currentTarget.getAttribute("aria-expanded") === "true"; - event.currentTarget.setAttribute("aria-expanded", String(!expanded)); - event.currentTarget.querySelector("span").textContent = expanded ? "历史复盘" : "收起历史"; - panel.hidden = expanded; - if (!expanded) panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); - }); - document.querySelector("#openTradeLogDialog").addEventListener("click", openTradeLogDialog); - document.querySelector("#closeTradeLogDialog").addEventListener("click", closeTradeLogDialog); - document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog); - document.querySelector("#cancelTradeEdit").addEventListener("click", closeTradeLogDialog); - elements.tradeLogDialog.addEventListener("close", resetTradeLogForm); - document.querySelector("#tradeLogTableBody").addEventListener("click", handleTradeLogAction); - document.querySelector("#stockNoteForm").addEventListener("submit", saveStockNote); - document.querySelector("#watchStockButton").addEventListener("click", toggleActiveWatchlist); - document.querySelector("#stockHeavenButton").addEventListener("click", openActiveStockInHeaven); - document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder); - document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride); - document.querySelector("#backfillButton").addEventListener("click", backfillData); - document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => { - await loadScreenerTracking(true); - openView("screenerTrackingView"); - }); - document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView")); - document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking); - document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction); - document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => { - button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView)); - }); - document.querySelector("#compileStrategyButton").addEventListener("click", compileStrategy); - document.querySelector("#saveStrategyButton").addEventListener("click", saveCurrentStrategy); - document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy); - document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults); - document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus); - document.querySelectorAll("[data-screener-mode]").forEach((button) => { - button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode)); - }); - document.querySelector("#curatedStrategyList").addEventListener("click", (event) => { - if (event.target.closest("button")) return; - const card = event.target.closest("[data-curated-strategy]"); - if (!card) return; - state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy); - renderCuratedStrategyLibrary(); - renderScreenerResult(); - }); - document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => { - state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderCuratedStrategyLibrary(); - }); - document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => { - state.curatedCategory = event.target.value; - renderCuratedStrategyLibrary(); - }); - document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => { - const button = event.target.closest("[data-curated-school]"); - if (!button) return; - state.curatedSchool = button.dataset.curatedSchool; - renderCuratedStrategyLibrary(); - }); - document.querySelectorAll("[data-curated-view]").forEach((button) => { - button.addEventListener("click", () => { - state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list"; - localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode); - renderCuratedStrategyLibrary(); - }); - }); - document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder); - document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter()); - document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore()); - document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput); - document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput); - document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick); - document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput); - document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput); - document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick); - ["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => { - document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary); - document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary); - }); - document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy); - document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy); - document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus); - document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion); - document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation); - document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => { - toggleMentorDirectory(!state.mentorDirectoryOpen); - }); - document.querySelector("#closeMentorDirectory").addEventListener("click", () => toggleMentorDirectory(false)); - document.querySelector("#mentorDirectoryBackdrop").addEventListener("click", () => toggleMentorDirectory(false)); - document.querySelector("#mentorSortToggle").addEventListener("click", toggleMentorSortMode); - document.querySelector("#mentorSearchInput").addEventListener("input", (event) => { - state.mentorQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); - renderMentorDirectory(); - }); - document.querySelectorAll("[data-mentor-grade]").forEach((button) => { - button.addEventListener("click", () => { - state.mentorGrade = button.dataset.mentorGrade || "all"; - document.querySelectorAll("[data-mentor-grade]").forEach((item) => { - item.classList.toggle("active", item === button); - }); - renderMentorDirectory(); - }); - }); - document.querySelectorAll("[data-mentor-prompt]").forEach((button) => { - button.addEventListener("click", () => useMentorQuickPrompt(button.dataset.mentorPrompt)); - }); - document.querySelectorAll("[data-heaven-panel]").forEach((button) => { - button.addEventListener("click", () => selectHeavenPanel(button.dataset.heavenPanel, true)); - }); - document.querySelector("#loadHeavenSelectionButton").addEventListener("click", loadHeavenSelection); - document.querySelector("#heavenCalibrationForm").addEventListener("submit", applyHeavenCalibration); - document.querySelector("#resetHeavenCalibrationButton").addEventListener("click", resetHeavenCalibration); - document.querySelector("#heavenStockInput").addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - loadHeavenSelection(); - } - }); - document.querySelector("#interpretTrendButton").addEventListener("click", () => interpretHeaven("trend")); - document.querySelector("#interpretFortuneButton").addEventListener("click", () => interpretHeaven("fortune")); - document.querySelector("#historyTrendButton").addEventListener("click", () => openHeavenHistory("trend")); - document.querySelector("#historyFortuneButton").addEventListener("click", () => openHeavenHistory("fortune")); - document.querySelector("#qiObservationDate").addEventListener("change", () => { - state.personalField = null; - state.heavenManualData = null; - state.heavenInterpretations.fortune = ""; - loadHeavenSetup( - true, - "", - document.querySelector("#heavenStockInput").value.trim(), - ); - }); - document.querySelector("#openPersonalSettingsButton").addEventListener("click", () => openSettings("profile")); - document.querySelector("#accountBirthForm").addEventListener("submit", saveAccountBirthProfile); - document.querySelector("#deleteBirthProfileButton").addEventListener("click", deleteAccountBirthProfile); - document.querySelector("#passwordForm").addEventListener("submit", changeAccountPassword); - document.querySelector("#sectorPhaseForm").addEventListener("submit", saveSectorPhaseOverride); - document.querySelector("#startBreathingButton").addEventListener("click", startHeartBreathing); - document.querySelector("#beginCastingButton").addEventListener("click", beginHeartCasting); - document.querySelector("#heartSoundToggle").addEventListener("click", toggleHeartSound); - document.querySelector("#historyHeartButton").addEventListener("click", () => openHeavenHistory("heart")); - initializeHeartCoinHold(); - initializeHeartLineInspection(); - document.querySelector("#interpretHeartButton").addEventListener("click", () => interpretHeaven("heart")); - document.querySelector("#viewHeartReadingButton").addEventListener("click", () => openHeavenReading("heart")); - document.querySelector("#restartHeartButton").addEventListener("click", resetHeartRitual); - document.querySelector("#closeHeavenReadingDialog").addEventListener("click", () => elements.heavenReadingDialog.close()); - elements.heavenReadingDialog.addEventListener("close", stopHeavenReadingAnimation); - document.querySelectorAll("[data-heaven-reading-tab]").forEach((button) => { - button.addEventListener("click", () => selectHeavenReadingTab(button.dataset.heavenReadingTab)); - }); - document.querySelector("#heavenReadingHistoryList").addEventListener("click", handleHeavenHistorySelection); - document.querySelector("#heavenReadingHistoryDetail").addEventListener("click", handleHeavenHistoryAction); - document.querySelectorAll("[data-heart-return]").forEach((button) => { - button.addEventListener("click", resetHeartRitual); - }); - document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value)); - document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings); - document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool); - document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings); - document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel); - document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh); - window.addEventListener("resize", redrawThemeSensitiveVisuals); - initializeAutoTableSorting(); -} - -async function loadDashboard(force = false, background = false, showOverlay = true) { - const requestedDate = elements.tradeDate.value; - if (state.dashboardLoading && state.dashboardRequestDate === requestedDate) return; - state.dashboardLoading = true; - state.dashboardRequestDate = requestedDate; - const requestSequence = ++state.dashboardRequestSequence; - if (force) stockPreviewCache.clear(); - if (!background && showOverlay) { - setLoading(true, "正在加载市场数据"); - setStatus("正在加载市场数据"); - } else if (!background) { - setStatus("正在刷新行情"); - } - try { - const query = new URLSearchParams({ trade_date: elements.tradeDate.value }); - if (force) query.set("force", "1"); - const payload = await apiRequest(`/api/dashboard?${query}`); - if ( - requestSequence !== state.dashboardRequestSequence - || requestedDate !== elements.tradeDate.value - ) return; - applyDashboard(payload, background); - } catch (error) { - if (background) { - setStatus("实时刷新暂时中断,正在等待重试"); - } else { - showToast(error.message || "无法连接本地服务"); - setStatus("加载失败"); - } - } finally { - if (requestSequence === state.dashboardRequestSequence) { - state.dashboardLoading = false; - state.dashboardRequestDate = ""; - if (!background && showOverlay) setLoading(false); - updateDateButtons(); - } - } -} - -async function startAdminRefresh() { - const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean); - buttons.forEach((button) => { button.disabled = true; }); - try { - const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value }); - showToast(payload.message || "后台刷新已提交"); - setStatus("后台刷新运行中,当前页面保持不变"); - } catch (error) { - showToast(error.message || "后台刷新启动失败"); - } finally { - buttons.forEach((button) => { button.disabled = false; }); - } -} - -function applyDashboard(payload, background = false) { - state.dashboard = payload; - const selectedDate = payload.meta.requested_date || payload.meta.trade_date; - elements.tradeDate.value = selectedDate; - document.querySelector("#qiObservationDate").value = selectedDate; - document.querySelector("#journalDate").value = selectedDate; - renderDashboard(); - setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`); - if (!background) { - if (state.activeView === "dragonView") loadDragonTiger(); - if (state.activeView === "screenerView") loadScreenerSetup(); - if (state.activeView === "screenerTrackingView") loadScreenerTracking(true); - if (state.activeView === "mentorView") loadMentorSetup(true); - if (state.activeView === "heavenView") loadHeavenSetup(true); - if (state.activeView === "sentimentCycleView") loadSentimentHistory(true); - if (state.activeView === "rotationView") loadRotationHistory(true); - if (state.activeView === "auctionView") loadAuctionCenter(true); - if (state.activeView === "themeLibraryView") loadThemeLibrary(true); - if (state.activeView === "popularityView") loadPopularity(true); - } - const requestedStock = new URLSearchParams(window.location.search).get("stock"); - if (!state.initialStockOpened && /^\d{6}$/.test(requestedStock || "")) { - state.initialStockOpened = true; - openStock(requestedStock); - } -} - -function dashboardSourceLabel(meta = {}) { - if (meta.realtime && !["closed", "after_hours"].includes(String(meta.market_status || ""))) return "盘中行情"; - if (meta.carried_forward) return "最近收盘行情"; - if (meta.market_status === "historical") return "历史行情"; - return "收盘行情"; -} - -function renderDashboard() { - const { meta, overview, ladders, sectors } = state.dashboard; - animateMetric("tapeUp", overview.up_count, (value) => Math.round(value)); - animateMetric("tapeDown", overview.down_count, (value) => Math.round(value)); - setText("tapeLimit", `${overview.limit_up_count} / 跌停 ${overview.limit_down_count}`); - animateMetric("tapeAmount", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`); - animateMetric("limitUpMetric", overview.limit_up_count, (value) => `${Math.round(value)} 家`); - animateMetric("limitDownMetric", overview.limit_down_count, (value) => `${Math.round(value)} 家`); - animateMetric("brokenMetric", overview.broken_count, (value) => `${Math.round(value)} 家`); - animateMetric("sealRateMetric", overview.seal_rate, (value) => `${formatNumber(value, 1)}%`); - animateMetric("amountMetric", overview.amount_billion, (value) => `${formatNumber(value, 1)} 亿`); - setText("dataDateMetric", dashboardDataTimestamp(meta)); - animateMetric("sentimentScore", overview.sentiment_score, (value) => Math.round(value)); - setText("sentimentText", sentimentLabel(overview.sentiment_score)); - updateSentimentGauge(overview.sentiment_score); - setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`); - - renderLimitTable(); - renderLadderMini(ladders || []); - renderSectorMini(sectors || []); - renderBrokenTable(state.dashboard.broken || []); - renderDownTable(state.dashboard.down_limits || []); - renderYesterdayTable(state.dashboard.yesterday_limits || []); - renderPerformance(state.dashboard.limit_performance || []); - renderLadderBoard(ladders || []); - renderRotationMembers(); -} - -/* PRESERVATION-SOURCE-END app.js:1-1206 */ -/* PRESERVATION-SOURCE-BEGIN app.js:3471-3489 */ -async function backfillData() { - const button = document.querySelector("#backfillButton"); - button.disabled = true; - setLoading(true, "正在回补历史交易日"); - try { - const payload = await apiRequest("/api/backfill", "POST", { - start_date: document.querySelector("#backfillStart").value, - end_date: document.querySelector("#backfillEnd").value, - }); - showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`); - await openAdminSettings(true); - } catch (error) { - showToast(error.message); - } finally { - setLoading(false); - button.disabled = false; - } -} - -/* PRESERVATION-SOURCE-END app.js:3471-3489 */ -/* PRESERVATION-SOURCE-BEGIN app.js:8427-8904 */ -function hasMemberAccess() { - return state.user?.role === "admin" || Boolean(state.user?.membership?.active); -} - -function updateAccountIdentityBadges(membership = {}) { - const isAdmin = state.user?.role === "admin" || Boolean(membership.is_admin); - const subscribed = Boolean(membership.subscribed); - document.querySelector("#accountAdminBadge").hidden = !isAdmin; - const vipBadge = document.querySelector("#accountVipBadge"); - vipBadge.hidden = false; - vipBadge.classList.toggle("is-nonmember", !subscribed); - setText("accountVipLabel", subscribed ? "会员" : "非会员"); - vipBadge.title = subscribed ? "查看会员状态" : "查看会员权益"; -} - -function applyMembershipAccess() { - const unlocked = hasMemberAccess(); - document.querySelectorAll(".member-feature-view").forEach((view) => { - view.classList.toggle("member-locked", !unlocked); - const gate = view.querySelector(".member-gate"); - if (gate) gate.hidden = unlocked; - view.querySelectorAll("button, input, textarea, select").forEach((control) => { - if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return; - control.disabled = !unlocked; - }); - }); - const assistantButton = document.querySelector("#assistantButton"); - assistantButton.classList.toggle("member-locked-control", !unlocked); - assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)"; - updateAssistantControls(); -} function openView(viewId, updateHash = true) { if (!applicationShell.page(viewId) || !pageModules.has(viewId)) return; @@ -1268,672 +72,22 @@ function openView(viewId, updateHash = true) { pageModules.afterMount(viewId, previousView); } -function initializeAutoTableSorting() { - markAutoSortableHeaders(document); - document.addEventListener("click", (event) => { - const header = event.target.closest?.("th[data-auto-sort]"); - if (!header || header.closest("#limitTable")) return; - const table = header.closest("table"); - const body = table?.tBodies?.[0]; - if (!body || body.rows.length < 2) return; - const direction = header.classList.contains("sort-asc") ? "desc" : "asc"; - table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => { - item.classList.remove("sort-asc", "sort-desc", "sorted"); - item.removeAttribute("aria-sort"); - const arrow = item.querySelector(".arr"); - if (arrow) arrow.textContent = "↕"; - }); - header.classList.add(`sort-${direction}`, "sorted"); - header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending"); - const activeArrow = header.querySelector(".arr"); - if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼"; - const columnIndex = header.cellIndex; - const rows = [...body.rows].map((row, index) => ({ row, index })); - rows.sort((left, right) => { - const leftValue = autoSortValue(left.row.cells[columnIndex]); - const rightValue = autoSortValue(right.row.cells[columnIndex]); - let result; - if (leftValue.kind === "number" && rightValue.kind === "number") result = leftValue.value - rightValue.value; - else result = String(leftValue.value).localeCompare(String(rightValue.value), "zh-CN", { numeric: true, sensitivity: "base" }); - if (result === 0) result = left.index - right.index; - return direction === "asc" ? result : -result; - }); - rows.forEach(({ row }) => body.appendChild(row)); - const firstHeader = [...header.parentElement.cells][0]?.textContent.trim(); - if (["#", "排名"].includes(firstHeader)) { - [...body.rows].forEach((row, index) => { - if (row.cells[0]) row.cells[0].textContent = String(index + 1); - }); - } - }); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize, { once: true }); +} else { + initialize(); } -function markAutoSortableHeaders(root) { - root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => { - if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return; - if (number(header.colSpan) > 1) return; - const label = header.textContent.trim(); - if (!label || ["#", "操作"].includes(label)) return; - header.dataset.autoSort = "true"; - header.classList.add("sortable"); - if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", ''); - header.title = `${label}:点击排序`; - }); -} -function autoSortValue(cell) { - const text = String(cell?.dataset?.sortValue || cell?.textContent || "").trim(); - if (!text || text === "--" || text.includes("样本不足")) return { kind: "text", value: "\uffff" }; - const boardMatch = text.match(/(\d+)\s*板/); - if (boardMatch) return { kind: "number", value: Number(boardMatch[1]) }; - const normalized = text.replaceAll(",", "").replace(/[+%]/g, ""); - const numericMatch = normalized.match(/^-?\d+(?:\.\d+)?/); - if (numericMatch) { - let value = Number(numericMatch[0]); - if (text.includes("亿")) value *= 10000; - return { kind: "number", value }; - } - return { kind: "text", value: text }; -} -function changeSort(key) { - if (state.sortKey === key) state.sortDirection = state.sortDirection === "asc" ? "desc" : "asc"; - else { - state.sortKey = key; - state.sortDirection = ["name", "code", "sector", "first_time", "last_time"].includes(key) ? "asc" : "desc"; - } - renderLimitTable(); -} -function compareRows(left, right) { - const leftValue = left[state.sortKey] ?? ""; - const rightValue = right[state.sortKey] ?? ""; - let result = typeof leftValue === "number" || typeof rightValue === "number" - ? number(leftValue) - number(rightValue) - : String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true }); - if (result === 0 && state.sortKey !== "first_time") result = String(left.first_time || "").localeCompare(String(right.first_time || "")); - return state.sortDirection === "asc" ? result : -result; +function bindEvents() { + bindDashboardEvents(); + bindSessionEvents(); + bindMarketEvents(); + bindThemeEvents(); + pageModules.bind(); + bindAdminEvents(); + bindSharedTableEvents(); } - -function updateSortHeaders() { - document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => { - header.classList.remove("sort-asc", "sort-desc", "sorted"); - const active = header.dataset.sort === state.sortKey; - if (active) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted"); - const arrow = header.querySelector(".arr"); - if (arrow) arrow.textContent = active ? (state.sortDirection === "asc" ? "▲" : "▼") : "↕"; - }); -} - -function shiftDate(delta) { - const current = parseLocalDate(elements.tradeDate.value); - current.setDate(current.getDate() + delta); - const next = localDateString(current); - if (next > todayString()) return; - elements.tradeDate.value = next; - state.heavenManualData = null; - document.querySelector("#qiObservationDate").value = next; - loadDashboard(); -} - -function updateDateButtons() { - document.querySelector("#nextDate").disabled = elements.tradeDate.value >= todayString(); -} - -function selectAccountPanel(panel) { - const selected = ["profile", "membership", "password"].includes(panel) ? panel : "profile"; - const titles = { profile: "个人资料", membership: "会员状态", password: "修改密码" }; - setText("accountDialogTitle", titles[selected]); - document.querySelectorAll("[data-account-panel-content]").forEach((section) => { - section.hidden = section.dataset.accountPanelContent !== selected; - }); - document.querySelector("#connectionStatus").hidden = selected !== "membership"; - return selected; -} - -async function openSettings(panel = "profile") { - selectAccountPanel(panel); - toggleAccountDropdown(false); - toggleHeaderCommandMenu(false); - const status = document.querySelector("#connectionStatus"); - status.className = "connection-status"; - status.textContent = "正在读取账号状态"; - openModalDialog(elements.settingsDialog); - try { - const payload = await apiRequest("/api/account/status"); - const access = payload.llm_access || {}; - const membership = access.membership || {}; - if (state.user) { - state.user.membership = membership; - updateAccountIdentityBadges(membership); - applyMembershipAccess(); - } - status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步"; - status.classList.toggle("connected", true); - setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户"); - setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通"); - setText("membershipRemainingValue", membership.subscribed && membership.expires_at - ? `${number(membership.remaining_days)} 天` - : membership.is_admin || membership.subscribed ? "长期有效" : "--"); - setText("membershipDetail", membership.subscribed - ? `${membership.plan || "会员"}${membership.expires_at ? ` · 有效至 ${membershipDateDisplay(membership.expires_at, true)}` : " · 长期有效"}` - : membership.is_admin - ? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。" - : "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。"); - setText("membershipQuotaHint", `会员默认每日智能分析额度 ${number(access.daily_limit)} 次,由管理员统一设置。`); - setText("membershipUsage", membership.active - ? `今日已用 ${number(access.used_today)} 次` - : "今日智能分析:--"); - setText("membershipUsageSummary", membership.active ? `${number(access.used_today)} / ${number(access.daily_limit)}` : "--"); - setText("membershipRemainingUsage", membership.is_admin ? "不限" : membership.active ? `${number(access.remaining_calls)} 次` : "--"); - const birth = payload.birth_profile || {}; - if (birth.birth_datetime) { - const [birthDate, birthTime] = String(birth.birth_datetime).split("T"); - document.querySelector("#accountBirthDate").value = birthDate || ""; - document.querySelector("#accountBirthTime").value = (birthTime || "").slice(0, 5); - document.querySelector("#accountBirthGender").value = birth.gender || "unspecified"; - } - setText("birthProfileStatus", payload.birth_profile_configured ? "已加密保存" : "尚未设置"); - document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured; - } catch (error) { - status.hidden = false; - status.textContent = "账户状态暂时无法同步"; - showToast(error.message || "账号信息加载失败"); - } -} - -async function changeAccountPassword(event) { - event.preventDefault(); - const form = event.currentTarget; - const button = form.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/account/password", "POST", { - current_password: document.querySelector("#currentPassword").value, - new_password: document.querySelector("#newPassword").value, - confirm_password: document.querySelector("#confirmPassword").value, - }); - form.reset(); - showToast("密码已更新"); - } catch (error) { - showToast(error.message || "密码更新失败"); - } finally { - button.disabled = false; - } -} - -async function switchAccount() { - const button = document.querySelector("#switchAccountMenuButton"); - button.disabled = true; - toggleAccountDropdown(false); - try { - await apiRequest("/api/auth/logout", "POST", {}); - window.location.reload(); - } catch (error) { - showToast(error.message || "切换账号失败"); - button.disabled = false; - } -} - -async function openAdminSettings(refreshOnly = false) { - if (state.user?.role !== "admin") return; - if (!refreshOnly) openModalDialog(elements.adminDialog); - const status = document.querySelector("#adminConnectionStatus"); - status.textContent = "正在读取系统状态"; - try { - const payload = await apiRequest("/api/admin/settings"); - const data = payload.data || {}; - const ifind = data.ifind || {}; - const llm = payload.llm || {}; - const membership = payload.membership || {}; - status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`; - status.classList.toggle("connected", Boolean(data.configured)); - setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停"); - document.querySelector("#systemTokenInput").value = ""; - document.querySelector("#systemIfindTokenInput").value = ""; - document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled); - document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50; - renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || ""); - renderAdminUsers(payload.users || []); - } catch (error) { - status.textContent = error.message || "系统配置读取失败"; - } -} - -function selectAdminPanel(panel) { - const selected = ["market", "models", "members"].includes(panel) ? panel : "market"; - document.querySelector("#adminSectionSelect").value = selected; - document.querySelectorAll("[data-admin-panel]").forEach((item) => { - item.hidden = item.dataset.adminPanel !== selected; - }); -} - -function renderModelPool(models, primaryId = "", fallbackId = "") { - state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" })); - const container = document.querySelector("#modelPoolList"); - container.innerHTML = state.adminModels.map((item, index) => ` -
-
${escapeHtml(item.name || `模型 ${index + 1}`)}${item.configured ? "已保存密钥" : "待配置"}
-
- - - - -
-
未测试
-
- `).join("") || emptyStateHtml("模型池为空,请先添加模型"); - updateModelRoleOptions(primaryId, fallbackId); - container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]")))); - container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]")))); - container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels)); - refreshIcons(); -} - -function collectModelPool() { - const saved = new Map(state.adminModels.map((item) => [item.id, item])); - return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({ - id: row.dataset.modelId, - name: row.querySelector("[data-model-field='name']").value.trim(), - base_url: row.querySelector("[data-model-field='base_url']").value.trim(), - model: row.querySelector("[data-model-field='model']").value.trim(), - api_key: row.querySelector("[data-model-field='api_key']").value.trim(), - configured: Boolean(saved.get(row.dataset.modelId)?.configured), - })); -} - -function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) { - const models = collectModelPool(); - const options = models.map((item) => ``).join(""); - const primary = document.querySelector("#platformPrimaryModelSelect"); - const fallback = document.querySelector("#platformFallbackModelSelect"); - primary.innerHTML = models.length ? options : ''; - fallback.innerHTML = `${options}`; - primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || ""; - fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : ""; -} - -function updateModelRoleLabels() { - updateModelRoleOptions(); -} - -function addPlatformModel() { - const models = collectModelPool(); - const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false }); - renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value); - document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus(); -} - -function deletePlatformModel(row) { - if (!row) return; - const id = row.dataset.modelId; - const primary = document.querySelector("#platformPrimaryModelSelect").value; - const fallback = document.querySelector("#platformFallbackModelSelect").value; - if (id === primary || id === fallback) { - showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型"); - return; - } - const models = collectModelPool().filter((item) => item.id !== id); - renderModelPool(models, primary, fallback); -} - -function renderAdminUsers(users) { - const container = document.querySelector("#adminUsersList"); - container.innerHTML = users.map((user) => { - const admin = user.role === "admin"; - const member = Boolean(user.membership_subscribed); - const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · "); - const expiry = member - ? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效") - : user.membership_status === "suspended" - ? "会员已停用" - : user.membership_status === "active" && user.membership_expires_at - ? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期` - : "尚未开通"; - return `
-
${escapeHtml(user.username)}${escapeHtml(identityLabels)}${escapeHtml(expiry)}
-
今日调用 ${number(user.used_today)}
-
- - - -
当前到期${escapeHtml(expiry)}
- -
-
`; - }).join("") || emptyStateHtml("暂无注册用户"); - container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership)); -} - -async function saveMembership(event) { - event.preventDefault(); - const form = event.currentTarget; - const data = Object.fromEntries(new FormData(form).entries()); - const button = form.querySelector("button[type='submit']"); - button.disabled = true; - try { - const payload = await apiRequest("/api/admin/membership", "POST", data); - renderAdminUsers(payload.users || []); - showToast("会员状态已更新"); - } catch (error) { - showToast(error.message || "会员状态保存失败"); - } finally { - button.disabled = false; - } -} - -async function saveMarketSettings(event) { - event.preventDefault(); - const button = event.currentTarget.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/admin/settings", "POST", { - tushare_token: document.querySelector("#systemTokenInput").value.trim(), - ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(), - background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked, - }); - document.querySelector("#systemTokenInput").value = ""; - document.querySelector("#systemIfindTokenInput").value = ""; - showToast("行情配置已保存"); - await openAdminSettings(true); - } catch (error) { - showToast(error.message || "系统配置保存失败"); - } finally { - button.disabled = false; - } -} - -async function saveModelPool(event) { - event.preventDefault(); - const button = event.currentTarget.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/admin/settings", "POST", { - models: collectModelPool(), - primary_model_id: document.querySelector("#platformPrimaryModelSelect").value, - fallback_model_id: document.querySelector("#platformFallbackModelSelect").value, - }); - showToast("模型池已保存"); - await openAdminSettings(true); - } catch (error) { - showToast(error.message || "模型池保存失败"); - } finally { - button.disabled = false; - } -} - -async function saveMembershipSettings(event) { - event.preventDefault(); - const button = event.currentTarget.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/admin/settings", "POST", { - member_daily_limit: number(document.querySelector("#memberDailyLimit").value), - }); - showToast("会员调用额度已保存"); - await openAdminSettings(true); - } catch (error) { - showToast(error.message || "会员调用额度保存失败"); - } finally { - button.disabled = false; - } -} - -async function testPlatformModel(row) { - if (!row) return; - const button = row.querySelector("[data-test-model]"); - const status = row.querySelector(".model-test-status"); - const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {}; - button.disabled = true; - status.textContent = "连接中"; - try { - const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile }); - status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`; - status.className = "model-test-status success"; - } catch (error) { - status.textContent = error.message; - status.className = "model-test-status failure"; - } finally { - button.disabled = false; - } -} - -function membershipDateDisplay(value) { - if (!value) return ""; - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) return String(value).slice(0, 10); - return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed); -} - -/* PRESERVATION-SOURCE-END app.js:8427-8904 */ -/* PRESERVATION-SOURCE-BEGIN app.js:9052-9283 */ -function trendClass(trend) { - return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat"; -} - -function changeClass(value) { - return number(value) > 0 ? "up" : number(value) < 0 ? "down" : ""; -} - -function sentimentLabel(score) { - const value = number(score); - if (value >= 80) return "情绪高涨"; - if (value >= 60) return "情绪偏强"; - if (value >= 40) return "情绪中性"; - if (value >= 20) return "情绪偏弱"; - return "情绪冰点"; -} - -function streakLabel(streak) { - const value = Math.max(1, number(streak)); - return value === 1 ? "首板" : `${value}板`; -} - -function signed(value) { - const parsed = number(value); - return `${parsed > 0 ? "+" : ""}${formatNumber(parsed, 2)}`; -} - -function dashboardDataTimestamp(meta = {}) { - const tradeDate = displayCompactDate(meta.trade_date); - if (tradeDate === "--") return "--"; - const intraday = tradeDate === todayString() && Boolean(meta.realtime) && !["closed", "after_hours"].includes(String(meta.market_status || "")); - if (intraday) { - const updated = new Date(meta.updated_at); - if (!Number.isNaN(updated.getTime())) { - const dateText = `${updated.getFullYear()}-${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")}`; - const timeText = updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); - return `${dateText} ${timeText}`; - } - } - return `${tradeDate} 15:00`; -} - -function formatMoneyMillion(value) { - const parsed = number(value); - const sign = parsed > 0 ? "+" : ""; - if (Math.abs(parsed) >= 100) return `${sign}${formatNumber(parsed / 100, 2)} 亿`; - return `${sign}${formatNumber(parsed * 100, 0)} 万`; -} - -async function apiRequest(url, method = "GET", body = null, requestOptions = {}) { - return window.XiaobaiAPI.request(url, method, body, requestOptions); -} - -function setLoading(loading, text = "正在加载复盘数据", context = "default") { - elements.loading.hidden = !loading; - elements.loading.dataset.context = loading ? context : "default"; - setText("loadingTitle", text); - setText( - "loadingHint", - context === "screener" - ? "正在完成因子筛选、候选排序与历史样本回测,这通常需要一点时间" - : "请稍候", - ); -} - -function setStatus(text) { - applicationShell.setStatus(text); -} - -let toastTimer; -function showToast(message) { - clearTimeout(toastTimer); - elements.toast.textContent = message; - elements.toast.hidden = false; - toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 3600); -} - -function setText(id, value) { - const element = document.getElementById(id); - if (element) element.textContent = value; -} - -function motionEnabled() { - return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; -} - -function refreshIcons() { - if (!window.lucide?.createIcons) return; - window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); -} - -function toggleHeaderCommandMenu(force) { - applicationShell.toggleHeaderCommandMenu(force); -} - -function toggleAccountDropdown(force, returnFocus = false) { - const menu = document.querySelector("#accountDropdown"); - const button = document.querySelector("#accountButton"); - if (!menu || !button) return; - const open = typeof force === "boolean" ? force : menu.hidden; - menu.hidden = !open; - button.setAttribute("aria-expanded", String(open)); - document.querySelector(".account-menu-shell")?.classList.toggle("is-open", open); - if (open) { - setText("accountMenuName", state.user?.username || "当前账号"); - const membership = state.user?.membership || {}; - setText("accountMenuRole", state.user?.role === "admin" ? (membership.subscribed ? "管理员 · 会员" : "管理员") : membership.subscribed ? "会员用户" : "普通用户"); - } else if (returnFocus) { - button.focus(); - } -} - -function handleAccountMenuKeydown(event) { - const menu = document.querySelector("#accountDropdown"); - if (!menu) return; - if (menu.hidden) { - if (document.activeElement?.id === "accountButton" && event.key === "ArrowDown") { - event.preventDefault(); - toggleAccountDropdown(true); - menu.querySelector('[role="menuitem"]')?.focus(); - } - return; - } - const items = [...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')]; - if (!items.length) return; - const current = items.indexOf(document.activeElement); - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - const offset = event.key === "ArrowDown" ? 1 : -1; - items[(current + offset + items.length) % items.length].focus(); - } else if (event.key === "Home" || event.key === "End") { - event.preventDefault(); - items[event.key === "Home" ? 0 : items.length - 1].focus(); - } -} - -function updateSentimentGauge(rawScore) { - const gauge = document.querySelector("#sentimentGauge"); - if (!gauge) return; - const score = clamp(rawScore, 0, 100); - const previous = Number(gauge.dataset.score); - gauge.dataset.score = String(score); - gauge.style.setProperty("--score", score); - if (!motionEnabled() || !Number.isFinite(previous) || Math.abs(previous - score) < 15) return; - gauge.classList.remove("sentiment-pulse"); - void gauge.offsetWidth; - gauge.classList.add("sentiment-pulse"); - gauge.addEventListener("animationend", () => gauge.classList.remove("sentiment-pulse"), { once: true }); -} - -function animateMetric(id, rawValue, formatter = (value) => value) { - const element = document.getElementById(id); - const target = Number(rawValue); - if (!element || !Number.isFinite(target)) { - setText(id, formatter(rawValue)); - return; - } - const storedValue = Number(element.dataset.metricValue); - const previous = Number.isFinite(storedValue) ? storedValue : 0; - element.dataset.metricValue = String(target); - const existingFrame = metricAnimationFrames.get(element); - if (existingFrame) cancelAnimationFrame(existingFrame); - if (!motionEnabled() || previous === target) { - element.textContent = formatter(target); - return; - } - element.classList.remove("metric-changed"); - void element.offsetWidth; - element.classList.add("metric-changed"); - const startedAt = performance.now(); - const duration = 560; - const update = (now) => { - const progress = Math.min(1, (now - startedAt) / duration); - const eased = 1 - (1 - progress) ** 3; - element.textContent = formatter(previous + (target - previous) * eased); - if (progress < 1) { - metricAnimationFrames.set(element, requestAnimationFrame(update)); - } else { - element.textContent = formatter(target); - metricAnimationFrames.delete(element); - setTimeout(() => element.classList.remove("metric-changed"), 80); - } - }; - metricAnimationFrames.set(element, requestAnimationFrame(update)); -} - -function animateRows(container) { - if (!container) return; - const rows = [...container.children].filter((item) => item.matches("tr, [data-code]")); - if (!motionEnabled()) { - rows.forEach((row) => row.classList.remove("row-pending", "row-enter")); - return; - } - const unseenRows = rows.filter((row) => row.dataset.motionSeen !== "1"); - unseenRows.slice(0, 12).forEach((row, index) => { - row.dataset.motionSeen = "1"; - row.classList.remove("row-pending", "row-enter"); - row.style.setProperty("--row-delay", `${index * 24}ms`); - requestAnimationFrame(() => row.classList.add("row-enter")); - row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true }); - }); - if (!("IntersectionObserver" in window)) { - unseenRows.slice(12).forEach((row) => { row.dataset.motionSeen = "1"; }); - return; - } - if (!rowAnimationObserver) { - rowAnimationObserver = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - const row = entry.target; - rowAnimationObserver.unobserve(row); - row.dataset.motionSeen = "1"; - row.classList.remove("row-pending"); - row.style.setProperty("--row-delay", "0ms"); - requestAnimationFrame(() => row.classList.add("row-enter")); - row.addEventListener("animationend", () => row.classList.remove("row-enter"), { once: true }); - }); - }, { threshold: 0.08, rootMargin: "0px 0px 40px 0px" }); - } - unseenRows.slice(12).forEach((row) => { - row.classList.add("row-pending"); - rowAnimationObserver.observe(row); - }); -} - -function waitForMotion(duration) { - return new Promise((resolve) => setTimeout(resolve, motionEnabled() ? duration : 0)); -} -/* PRESERVATION-SOURCE-END app.js:9052-9283 */ diff --git a/app/frontend/bootstrap.js b/app/frontend/bootstrap.js new file mode 100644 index 0000000..ab70307 --- /dev/null +++ b/app/frontend/bootstrap.js @@ -0,0 +1,39 @@ +const PAGE_REGISTRY_URL = "/pages.config.js?v=20260803-2"; + +function loadRuntimeScripts(sources) { + return Promise.all(sources.map((src) => new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = src; + script.async = false; + script.addEventListener("load", resolve, { once: true }); + script.addEventListener("error", () => reject(new Error(`无法加载运行脚本:${src}`)), { once: true }); + document.body.append(script); + }))); +} + +async function loadPageFragment(fragment) { + const response = await fetch(fragment.url, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`无法加载页面片段:${fragment.url}(HTTP ${response.status})`); + } + return response.text(); +} + +async function bootstrap() { + await import(PAGE_REGISTRY_URL); + const registry = window.XiaobaiPages; + const mount = document.querySelector(".app-main"); + if (!registry || !mount) throw new Error("页面注册表或挂载点不存在"); + + const fragments = await Promise.all(registry.fragments.map(loadPageFragment)); + fragments.forEach((markup) => mount.insertAdjacentHTML("beforeend", markup)); + await loadRuntimeScripts(registry.runtimeScripts); + document.body.dataset.runtimeReady = "true"; +} + +bootstrap().catch((error) => { + document.body.dataset.bootstrapError = "true"; + const status = document.querySelector("#statusText"); + if (status) status.textContent = error?.message || "页面初始化失败"; + setTimeout(() => { throw error; }); +}); diff --git a/app/frontend/index.html b/app/frontend/index.html index 2996b1f..3449e83 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -18,12 +18,28 @@ })(); - - - - - - + + + + + + + + + + + + + + + + + + + + + +
@@ -47,8 +63,8 @@
-
-
+
+
上涨 -- 下跌 -- 涨停 -- @@ -90,9 +106,9 @@
-