diff --git a/.dockerignore b/.dockerignore index 82eb2ca..58d5f1d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ __pycache__/ *.py[cod] *.log +runtime/ data/cache/ data/private-mentor-skills/ data/*.db diff --git a/.gitignore b/.gitignore index 9226beb..b62287d 100644 --- a/.gitignore +++ b/.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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..85a2b4d --- /dev/null +++ b/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/ARCHITECTURE.md b/ARCHITECTURE.md index 542357d..438c4e9 100644 --- a/ARCHITECTURE.md +++ b/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/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index dc43d63..378050d 100644 --- a/DOCKER_DEPLOY.md +++ b/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/README.md b/README.md index f03fb7a..7a6ac8b 100644 --- a/README.md +++ b/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/advanced_strategies.py b/advanced_strategies.py deleted file mode 100644 index fcb16c9..0000000 --- a/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/alert_service.py b/alert_service.py deleted file mode 100644 index f4264ee..0000000 --- a/alert_service.py +++ /dev/null @@ -1,3 +0,0 @@ -from backend.features.alerts.service import AlertService - -__all__ = ["AlertService"] diff --git a/app_config.py b/app_config.py deleted file mode 100644 index 0ec4284..0000000 --- a/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/assistant_agent.py b/assistant_agent.py deleted file mode 100644 index ba1b10f..0000000 --- a/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/backend/application.py b/backend/application.py index 4ed9570..d9b6121 100644 --- a/backend/application.py +++ b/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/backend/data/providers/tushare_client.py b/backend/data/providers/tushare_client.py index 4c071b9..8b8d5b5 100644 --- a/backend/data/providers/tushare_client.py +++ b/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/backend/data/providers/tushare_daily.py b/backend/data/providers/tushare_daily.py new file mode 100644 index 0000000..bb09d32 --- /dev/null +++ b/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/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py new file mode 100644 index 0000000..b7e98c1 --- /dev/null +++ b/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/backend/data/providers/tushare_dragon_tiger.py b/backend/data/providers/tushare_dragon_tiger.py new file mode 100644 index 0000000..f81b304 --- /dev/null +++ b/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/backend/data/providers/tushare_helpers.py b/backend/data/providers/tushare_helpers.py new file mode 100644 index 0000000..6260e9e --- /dev/null +++ b/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/backend/data/providers/tushare_indices.py b/backend/data/providers/tushare_indices.py new file mode 100644 index 0000000..7087628 --- /dev/null +++ b/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/backend/data/providers/tushare_industries.py b/backend/data/providers/tushare_industries.py new file mode 100644 index 0000000..c75f970 --- /dev/null +++ b/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/backend/data/providers/tushare_sectors.py b/backend/data/providers/tushare_sectors.py new file mode 100644 index 0000000..5e76d2a --- /dev/null +++ b/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/backend/data/providers/tushare_stocks.py b/backend/data/providers/tushare_stocks.py new file mode 100644 index 0000000..0d0434e --- /dev/null +++ b/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/backend/data/providers/tushare_transport.py b/backend/data/providers/tushare_transport.py new file mode 100644 index 0000000..30e602e --- /dev/null +++ b/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/backend/features/accounts/application.py b/backend/features/accounts/application.py new file mode 100644 index 0000000..6608e6b --- /dev/null +++ b/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/backend/features/accounts/routes.py b/backend/features/accounts/routes.py new file mode 100644 index 0000000..89af383 --- /dev/null +++ b/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/backend/features/alerts/routes.py b/backend/features/alerts/routes.py new file mode 100644 index 0000000..21aa2e3 --- /dev/null +++ b/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/backend/features/auction/routes.py b/backend/features/auction/routes.py new file mode 100644 index 0000000..26e6f5e --- /dev/null +++ b/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/backend/features/dragon_tiger/routes.py b/backend/features/dragon_tiger/routes.py new file mode 100644 index 0000000..ae13652 --- /dev/null +++ b/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/backend/features/heaven/manual.py b/backend/features/heaven/manual.py new file mode 100644 index 0000000..bbf27ce --- /dev/null +++ b/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/backend/features/heaven/market_context.py b/backend/features/heaven/market_context.py new file mode 100644 index 0000000..a18c47b --- /dev/null +++ b/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/backend/features/heaven/readings.py b/backend/features/heaven/readings.py new file mode 100644 index 0000000..dbf5955 --- /dev/null +++ b/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/backend/features/heaven/routes.py b/backend/features/heaven/routes.py new file mode 100644 index 0000000..5ceb2c7 --- /dev/null +++ b/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/backend/features/heaven/service.py b/backend/features/heaven/service.py index 9b00856..55803d9 100644 --- a/backend/features/heaven/service.py +++ b/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/backend/features/heaven/trend.py b/backend/features/heaven/trend.py new file mode 100644 index 0000000..13aafa7 --- /dev/null +++ b/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/backend/features/market/insights.py b/backend/features/market/insights.py index 4ec8274..b68becd 100644 --- a/backend/features/market/insights.py +++ b/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/backend/features/market/insights_auction.py b/backend/features/market/insights_auction.py new file mode 100644 index 0000000..c7bb675 --- /dev/null +++ b/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/backend/features/market/insights_auction_data.py b/backend/features/market/insights_auction_data.py new file mode 100644 index 0000000..d55a394 --- /dev/null +++ b/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/backend/features/market/insights_auction_scoring.py b/backend/features/market/insights_auction_scoring.py new file mode 100644 index 0000000..e1e4f09 --- /dev/null +++ b/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/backend/features/market/insights_context.py b/backend/features/market/insights_context.py new file mode 100644 index 0000000..a223428 --- /dev/null +++ b/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/backend/features/market/insights_popularity.py b/backend/features/market/insights_popularity.py new file mode 100644 index 0000000..f057309 --- /dev/null +++ b/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/backend/features/market/insights_themes.py b/backend/features/market/insights_themes.py new file mode 100644 index 0000000..2c5a4a1 --- /dev/null +++ b/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/backend/features/market/routes.py b/backend/features/market/routes.py new file mode 100644 index 0000000..1055216 --- /dev/null +++ b/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/backend/features/mentor/routes.py b/backend/features/mentor/routes.py new file mode 100644 index 0000000..f9aba96 --- /dev/null +++ b/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/backend/features/pools/routes.py b/backend/features/pools/routes.py new file mode 100644 index 0000000..2adafd0 --- /dev/null +++ b/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/backend/features/popularity/routes.py b/backend/features/popularity/routes.py new file mode 100644 index 0000000..830889b --- /dev/null +++ b/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/backend/features/review/routes.py b/backend/features/review/routes.py new file mode 100644 index 0000000..9e568ba --- /dev/null +++ b/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/backend/features/rotation/routes.py b/backend/features/rotation/routes.py new file mode 100644 index 0000000..a9b37dd --- /dev/null +++ b/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/backend/features/screener/backtest.py b/backend/features/screener/backtest.py new file mode 100644 index 0000000..e9a7295 --- /dev/null +++ b/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/backend/features/screener/catalog.py b/backend/features/screener/catalog.py new file mode 100644 index 0000000..f8e985e --- /dev/null +++ b/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/backend/features/screener/compiler.py b/backend/features/screener/compiler.py index 167a473..445da26 100644 --- a/backend/features/screener/compiler.py +++ b/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/backend/features/screener/data_sync.py b/backend/features/screener/data_sync.py new file mode 100644 index 0000000..6680fbe --- /dev/null +++ b/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/backend/features/screener/engine.py b/backend/features/screener/engine.py index 7d7baf9..0d56dac 100644 --- a/backend/features/screener/engine.py +++ b/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/backend/features/screener/factors.py b/backend/features/screener/factors.py new file mode 100644 index 0000000..5fd064d --- /dev/null +++ b/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/backend/features/screener/formula.py b/backend/features/screener/formula.py new file mode 100644 index 0000000..0a9b302 --- /dev/null +++ b/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/backend/features/screener/indicators.py b/backend/features/screener/indicators.py new file mode 100644 index 0000000..f7c8efe --- /dev/null +++ b/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/backend/features/screener/regime.py b/backend/features/screener/regime.py new file mode 100644 index 0000000..df81525 --- /dev/null +++ b/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/backend/features/screener/repository.py b/backend/features/screener/repository.py index 16f962b..9d3f16a 100644 --- a/backend/features/screener/repository.py +++ b/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/backend/features/screener/routes.py b/backend/features/screener/routes.py new file mode 100644 index 0000000..a3a93ca --- /dev/null +++ b/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/backend/features/screener/selection.py b/backend/features/screener/selection.py new file mode 100644 index 0000000..34e98f1 --- /dev/null +++ b/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/backend/features/screener/service.py b/backend/features/screener/service.py index d22ae06..e74a109 100644 --- a/backend/features/screener/service.py +++ b/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/backend/features/sentiment/routes.py b/backend/features/sentiment/routes.py new file mode 100644 index 0000000..be34aa1 --- /dev/null +++ b/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/backend/features/system/routes.py b/backend/features/system/routes.py new file mode 100644 index 0000000..5956cc9 --- /dev/null +++ b/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/backend/features/system/service.py b/backend/features/system/service.py new file mode 100644 index 0000000..4f14ebe --- /dev/null +++ b/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/backend/features/themes/routes.py b/backend/features/themes/routes.py new file mode 100644 index 0000000..5f6cedd --- /dev/null +++ b/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/backend/http/dispatch.py b/backend/http/dispatch.py new file mode 100644 index 0000000..3b58526 --- /dev/null +++ b/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/backend/jobs/service.py b/backend/jobs/service.py new file mode 100644 index 0000000..35bc31e --- /dev/null +++ b/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/chart_data_provider.py b/chart_data_provider.py deleted file mode 100644 index f07b035..0000000 --- a/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/config/README.md b/config/README.md index f27497a..5b352f6 100644 --- a/config/README.md +++ b/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/config/architecture-inventory.json b/config/architecture-inventory.json index 70444c7..dfebedc 100644 --- a/config/architecture-inventory.json +++ b/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/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f0bf340 --- /dev/null +++ b/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/docs/governance/adr/0001-modular-monolith.md new file mode 100644 index 0000000..1f89693 --- /dev/null +++ b/docs/governance/adr/0001-modular-monolith.md @@ -0,0 +1,75 @@ +# ADR 0001: Govern as a Modular Monolith + +Status: Accepted + +Date: 2026-07-29 + +## Context + +The application is deployed on a LAN NAS as one Docker container with a Python HTTP process, +SQLite, a build-free browser client, scheduled refresh work, external market providers, and +LLM features. Product breadth has grown, while routing, persistence, frontend state, and CSS +remain concentrated in a few large files. + +The system may later become internet-facing, gain more features, replace SQLite, or move jobs +to workers. It does not currently have load or team boundaries that justify distributed +services. + +## Decision + +Retain one deployable application and introduce strict internal modules, ports, adapters, +feature registries, data contracts, repository contracts, and regression gates. + +The deployment remains: + +```text +one image + one application process + one persistent data volume + port 8765 +``` + +Internal code moves toward: + +```text +delivery -> application services -> ports -> infrastructure +``` + +Compatibility facades permit incremental migration. No feature is rewritten solely to match +the target directory structure. + +## Consequences + +### Positive + +- Current NAS deployment stays simple. +- Refactoring can proceed in reversible stages. +- Feature ownership and account boundaries become visible. +- Data, database, LLM, and job adapters can be replaced later. +- A measured hotspot can be extracted without first untangling business logic. + +### Costs + +- The transition temporarily contains old and new entrypoints. +- Boundary tests and registries require ongoing maintenance. +- A single process remains a capacity and fault-isolation limit until infrastructure is + deliberately extracted. + +## Rejected Alternatives + +### Immediate microservices + +Rejected because they add network contracts, service discovery, deployment coordination, +distributed tracing, and failure modes before load requires them. + +### Full framework rewrite + +Rejected because replacing the HTTP and frontend frameworks while moving boundaries would +combine structural and behavioral risk. + +### Continue patching flat modules + +Rejected because current file size, direct provider creation, global page state, and CSS +override layers already make regressions difficult to isolate. + +## Revisit Conditions + +Reconsider service extraction when one module has independently measured scaling needs, +requires a separate availability boundary, or needs an independent release lifecycle. diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json new file mode 100644 index 0000000..fd68534 --- /dev/null +++ b/docs/governance/architecture-inventory.json @@ -0,0 +1,317 @@ +{ + "schema_version": 1, + "captured_from": "governed source tree", + "runtime": { + "http_server": "http.server.ThreadingHTTPServer", + "application_processes": 1, + "database": "SQLite WAL", + "frontend": "build-free HTML/CSS/JavaScript", + "container_port": 8765 + }, + "counts": { + "primary_pages": 16, + "api_exact_paths": 53, + "api_prefixes": 0, + "api_patterns": 11, + "database_tables": 34 + }, + "pages": [ + { + "id": "sentimentCycleView", + "title": "情绪周期" + }, + { + "id": "limitPool", + "title": "涨停池" + }, + { + "id": "brokenView", + "title": "炸板池" + }, + { + "id": "downView", + "title": "跌停板" + }, + { + "id": "yesterdayView", + "title": "昨日涨停" + }, + { + "id": "performanceView", + "title": "涨停表现" + }, + { + "id": "ladderView", + "title": "市场天梯" + }, + { + "id": "rotationView", + "title": "板块轮动" + }, + { + "id": "auctionView", + "title": "集合竞价" + }, + { + "id": "themeLibraryView", + "title": "题材库" + }, + { + "id": "popularityView", + "title": "人气热榜" + }, + { + "id": "dragonView", + "title": "龙虎榜" + }, + { + "id": "screenerView", + "title": "智能选股" + }, + { + "id": "mentorView", + "title": "问师" + }, + { + "id": "heavenView", + "title": "问天" + }, + { + "id": "reviewWorkspaceView", + "title": "我的复盘" + } + ], + "api": { + "exact": [ + "/api/account/birth-profile", + "/api/account/password", + "/api/account/status", + "/api/admin/membership", + "/api/admin/refresh", + "/api/admin/settings", + "/api/admin/settings/test", + "/api/alerts", + "/api/alerts/read-all", + "/api/assistant/chat", + "/api/assistant/messages", + "/api/auction", + "/api/auth/login", + "/api/auth/logout", + "/api/auth/me", + "/api/auth/register", + "/api/backfill", + "/api/chart/intraday", + "/api/dashboard", + "/api/dragon-tiger", + "/api/dragon-tiger/profiles", + "/api/health", + "/api/heaven/hexagram", + "/api/heaven/interpret", + "/api/heaven/personal", + "/api/heaven/readings", + "/api/heaven/sector-phases", + "/api/heaven/setup", + "/api/mentors/chat", + "/api/mentors/messages", + "/api/mentors/preferences", + "/api/mentors/setup", + "/api/notes", + "/api/popularity", + "/api/realtime-aggregate/health", + "/api/reasons", + "/api/rotation/history", + "/api/rotation/members", + "/api/screener/compile", + "/api/screener/run", + "/api/screener/setup", + "/api/screener/strategies", + "/api/screener/sync", + "/api/screener/tracking", + "/api/screener/tracking/refresh", + "/api/search", + "/api/search/detail", + "/api/seat-aliases", + "/api/sentiment/history", + "/api/themes", + "/api/themes/detail", + "/api/trades", + "/api/watchlist" + ], + "prefixes": [], + "patterns": [ + "/api/alerts/(\\d+)", + "/api/alerts/(\\d+)/read", + "/api/heaven/readings/(\\d+)", + "/api/heaven/sector-phases/(.+)", + "/api/notes/(\\d+)", + "/api/screener/strategies/(\\d+)", + "/api/screener/tracking/(\\d+)", + "/api/stock/(\\d{6})", + "/api/stock/(\\d{6})/preview", + "/api/trades/(\\d+)", + "/api/watchlist/(\\d{6})" + ] + }, + "database_tables": [ + "users", + "user_sessions", + "user_credentials", + "user_birth_profiles", + "system_settings", + "llm_usage", + "dashboard_snapshots", + "sync_runs", + "data_snapshots", + "watchlist", + "review_notes", + "reason_overrides", + "seat_aliases", + "sector_phase_overrides", + "stock_master", + "daily_bars", + "benchmark_bars", + "daily_indicators", + "fundamental_indicators", + "moneyflow_daily", + "auction_factors", + "earnings_events", + "popularity_factors", + "lhb_institution_daily", + "screener_strategies", + "screener_runs", + "mentor_messages", + "mentor_preferences", + "wencai_saved_queries", + "strategy_tracks", + "alerts", + "trade_entries", + "assistant_messages", + "heaven_readings" + ], + "background_job_methods": [ + "_background_refresh_tick", + "_schedule_automatic_screeners", + "_schedule_ifind_event_enrichment", + "run_automatic_screeners" + ], + "external_data_adapters": [ + { + "provider": "tushare", + "path": "tushare_client.py", + "runtime_role": "primary deterministic market data" + }, + { + "provider": "ifind", + "path": "ifind_client.py", + "runtime_role": "realtime, charts, snapshots, enrichment" + }, + { + "provider": "eastmoney", + "path": "chart_data_provider.py", + "runtime_role": "display chart fallback" + }, + { + "provider": "eastmoney", + "path": "realtime_aggregator.py", + "runtime_role": "isolated realtime observation" + }, + { + "provider": "tencent", + "path": "realtime_aggregator.py", + "runtime_role": "index observation fallback" + } + ], + "llm_entrypoints": [ + { + "function": "stream_with_mentor", + "path": "mentor_agent.py" + }, + { + "function": "interpret_heaven", + "path": "heaven_agent.py" + }, + { + "function": "stream_review_assistant", + "path": "assistant_agent.py" + }, + { + "function": "compile_strategy_with_llm", + "path": "llm_strategy.py" + }, + { + "function": "test_llm_connection", + "path": "llm_strategy.py" + } + ], + "css_layers": [ + "/shared/tokens.css?v=20260729-1", + "/styles.css", + "/renovation.css?v=20260725-5", + "/redesign-v2.css?v=20260728-1", + "/design-system.css?v=20260728-4", + "/theme.css?v=20260728-2", + "/wentian-v2.css?v=20260728-7" + ], + "code_hotspots": [ + { + "path": "static/app.js", + "bytes": 441313, + "lines": 9283 + }, + { + "path": "static/styles.css", + "bytes": 361780, + "lines": 15465 + }, + { + "path": "server.py", + "bytes": 263744, + "lines": 5856 + }, + { + "path": "static/redesign-v2.css", + "bytes": 263539, + "lines": 8570 + }, + { + "path": "static/index.html", + "bytes": 134831, + "lines": 1890 + }, + { + "path": "database.py", + "bytes": 121546, + "lines": 2839 + }, + { + "path": "screener.py", + "bytes": 108535, + "lines": 2213 + }, + { + "path": "tushare_client.py", + "bytes": 94312, + "lines": 2175 + }, + { + "path": "static/renovation.css", + "bytes": 83949, + "lines": 1553 + }, + { + "path": "static/wentian-v2.css", + "bytes": 73222, + "lines": 1084 + }, + { + "path": "market_insights.py", + "bytes": 58066, + "lines": 1312 + }, + { + "path": "static/theme.css", + "bytes": 36427, + "lines": 1253 + } + ] +} diff --git a/docs/governance/architecture-standard.md b/docs/governance/architecture-standard.md new file mode 100644 index 0000000..64ae1d2 --- /dev/null +++ b/docs/governance/architecture-standard.md @@ -0,0 +1,277 @@ +# Application Architecture Standard + +Status: Accepted + +Date: 2026-07-29 + +## 1. Objective + +The application remains a deployable modular monolith while its internal boundaries are made +explicit. Governance must preserve current functionality, account isolation, visual behavior, +and Docker simplicity. A structural refactor is not permission to change product behavior. + +## 2. Target Topology + +```text +browser + -> frontend shared API client + -> backend HTTP controllers + -> feature application services + -> repositories / DataGateway / LLMGateway + -> SQLite / market providers / model providers + +background jobs + -> the same feature application services + -> the same repositories and gateways +``` + +The browser and jobs are two delivery mechanisms. Neither owns business rules. + +## 3. Target Source Layout + +```text +frontend/ + shared/ + tokens.css + shell.js + api.js + state.js + components/ + pages// + page.js + desktop.css + mobile.css + pages.config.js + +backend/ + bootstrap/ + http/ + router.py + auth.py + errors.py + schemas/ + features// + routes.py + service.py + repository.py + schemas.py + data/ + gateway.py + policy.py + contracts.py + providers/ + database/ + connection.py + migrations/ + repositories/ + jobs/ + llm/ + gateway.py + usage.py + models.py + prompts/ + features_config.py + +tests/ +data/ +server.py +``` + +The migration may use compatibility facades. Old modules are removed only after all callers +move and regression gates pass. + +## 4. Dependency Direction + +Allowed direction: + +```text +HTTP / jobs -> feature services -> ports -> infrastructure adapters +frontend pages -> shared components/state/API -> backend API +``` + +Forbidden dependencies: + +1. Frontend code must not call external market or LLM providers directly. +2. HTTP controllers must not contain scoring, screening, divination, or persistence logic. +3. Feature services must not instantiate Tushare, iFinD, Eastmoney, Tencent, SQLite, or LLM + clients directly. +4. Provider adapters must not import feature services. +5. Repositories must not call external providers. +6. Features must not read another feature's tables directly; cross-feature work goes through + an application service or declared read model. +7. Background jobs must call the same services used by HTTP flows rather than duplicate + calculations. + +## 5. Feature Ownership + +Each feature owns its routes, application service, schemas, repository interface, page module, +tests, and documentation. Initial feature IDs are defined in `features.config.json` during +Stage 04. + +Shared code is allowed only when at least two features use the same stable behavior. A shared +module must not branch on page names or feature IDs to emulate unrelated components. + +## 6. HTTP Contract + +- Public application APIs use `/api/` and UTF-8 JSON. +- Controllers validate transport input, authorize the request, call one application service, + and serialize the result. +- Dates exposed to users use `YYYY-MM-DD`; provider-specific compact dates stay inside + adapters. +- Datetimes include an explicit timezone. Market time is interpreted as Asia/Shanghai. +- Successful collections use `items`; pagination uses `page`, `page_size`, and `total` when + required. +- Errors use a stable `code`, a user-safe `message`, and a request correlation ID. Provider + credentials and raw response bodies never appear in browser errors. +- Existing response shapes remain compatible until a versioned migration is approved. +- Backend authorization is authoritative. Hidden frontend controls are not a security rule. + +## 7. Data Contract + +Every calculation field declares: + +- canonical field ID and Chinese display label; +- entity and frequency; +- type, unit, precision, timezone, and adjustment mode; +- unique primary source and permitted fallback sources; +- display-only or calculation-eligible status; +- freshness and completeness thresholds; +- missing-value behavior; +- point-in-time availability rules; +- owning dataset and persistence location. + +Missing calculation data fails closed. A strategy may not silently replace a required field +with a proxy and continue under the original strategy name. Display fallbacks cannot enter +screening, scoring, backtesting, sentiment, or Wentian calculations unless explicitly approved +for that canonical field. + +One OHLC bar or factor history must not mix providers. Source changes are recorded with the +stored observation and invalidate incompatible cached calculations. + +## 8. Market Provider Policy + +- Tushare is the default deterministic source for master data, calendar, daily bars, + fundamentals, valuations, industry data, lists, and post-close datasets. +- iFinD is the default source for licensed realtime snapshots, intraday data, dynamic auction + observations, charts, and approved event enrichment. +- Eastmoney and Tencent public endpoints are display or observation fallbacks only unless a + field contract explicitly promotes them. +- All provider traffic passes through `DataGateway`; provider classes only translate their own + protocol into canonical contracts. +- Retries, timeouts, quotas, cache TTL, circuit state, and provenance are centralized. + +## 9. Persistence Standard + +- SQLite WAL remains the current deployment database. +- Schema changes use ordered migration files with an immutable version ID. +- A migration is transactional where SQLite permits it and must be safe to run once. +- Destructive migrations require a verified backup and explicit acceptance. +- User-owned tables include `user_id`, a foreign key, an ownership index, and cross-account + tests. +- Repositories return domain-shaped records; controllers never execute SQL. +- Financial and research data preserve announcement timestamps to prevent look-ahead use. +- A future PostgreSQL adapter must satisfy the same repository contracts. + +## 10. Background Jobs + +- Jobs declare an ID, schedule, input date policy, dependencies, lock key, timeout, retry + policy, and idempotency key. +- Job runs persist start, completion, failure code, retry count, source coverage, and output + version. +- A process restart must not duplicate a completed post-close calculation. +- Exceptions are logged and surfaced in system management; they are never silently discarded. +- The current in-process runner may remain, but jobs cannot depend on thread-local request + context. This permits later extraction to a worker without changing business services. + +## 11. LLM Standard + +All model calls pass through `LLMGateway`, which owns: + +- membership authorization and feature availability; +- quota reservation, settlement, and daily limits; +- model-pool selection and fallback; +- timeout, cancellation, retry, and streaming protocol; +- prompt version, model, latency, token usage, and failure audit; +- removal of secrets and engineering details from user-visible errors. + +Skill evidence, source material, and data profiles remain separate from reusable prompt +templates. Deterministic calculations happen before the LLM call and are not delegated to the +model. + +## 12. Frontend Standard + +- `shared/api.js` is the only browser request exit. +- `shared/shell.js` owns sidebar, topbar, market summary, status bar, global dialogs, and page + mounting. +- `pages.config.js` owns navigation metadata and page loading, not authorization truth. +- A page module fetches data, owns page-local state, and composes shared components. +- Global mutable page state and cross-page DOM queries are prohibited after migration. +- Tables use shared shells with page-owned column schemas and formatters. +- Opening, closing, focus management, and feedback for dialogs use one dialog service. + +## 13. CSS and Design Tokens + +The cascade order is fixed: + +```text +tokens -> reset/base -> shell/layout -> shared components -> page styles -> theme overrides +``` + +- Literal colors, font sizes, and spacing are introduced through tokens first. +- Page styles are scoped to the page root and cannot redefine the shell or another page. +- A later file cannot be used indefinitely as a patch layer for an earlier file. +- Shared components have one authoritative definition. +- CSS is removed only after selector/reference scanning, light/dark screenshots, and full + browser regression. + +## 14. Mobile Standard + +Mobile shares tokens, data, permissions, components, and state with desktop, but it may use a +different composition and interaction model. + +- Shared mobile shell rules live in `shared/mobile-shell.css`. +- Each complex page may own `mobile.css` and a mobile view renderer. +- One global all-page `mobile.css` override pile is prohibited. +- Desktop tables may become summary lists and detail views on mobile. +- Mobile acceptance is performed independently at 390x844 and 430x932. +- Mobile changes must not alter approved desktop geometry. + +## 15. Configuration and Secrets + +- Environment variables seed deployment configuration; encrypted system settings hold managed + runtime credentials. +- Tokens, API keys, refresh tokens, model secrets, and encryption keys never enter Git, logs, + API responses, or browser storage. +- Feature flags, page metadata, field contracts, and permissions are versioned configuration, + not ad hoc conditionals. + +## 16. Observability + +External calls record provider, operation, elapsed time, cache result, freshness, normalized +error code, and correlation ID. Logs must not contain secrets or personal birth data. + +Health checks distinguish process health, database health, provider degradation, background +job health, and model availability. Provider degradation does not make the process health +endpoint fail unless the application itself cannot serve stored data. + +## 17. Change and Release Contract + +Every governance phase must: + +1. begin from a clean Git worktree; +2. preserve the Stage 01 baseline contract; +3. include migration or compatibility tests for changed boundaries; +4. pass `python tools/verify_baseline.py`; +5. run Playwright at phase boundaries affecting runtime or frontend behavior; +6. document residual risk; +7. create and push a dedicated rollback commit. + +No phase combines framework replacement, visual redesign, and business-rule changes. + +## 18. Public-Internet Evolution + +The modular monolith remains the default. Reverse proxy/TLS, PostgreSQL, Redis, durable jobs, +central secrets, monitoring, and rate limiting can replace infrastructure adapters later. +Feature services and frontend contracts must not depend on whether those adapters are local or +remote. A feature is extracted into a separate service only when measured load, independent +release needs, or fault isolation justifies the operational cost. diff --git a/docs/governance/code-reduction.md b/docs/governance/code-reduction.md new file mode 100644 index 0000000..ad939d5 --- /dev/null +++ b/docs/governance/code-reduction.md @@ -0,0 +1,401 @@ +# `app/`代码减法账本 + +> 基线:`xiaobai-preservation-complete-20260801` +> 工作目录:只允许修改`webapp/app/`;原版根目录和冻结的`next/`只读 +> 目标:删除重复实现和历史补丁,不改变功能、视觉、交互、动画、计算、权限、API或数据行为 + +## 固定规则 + +1. 每批只处理一个明确边界,先证明重复或无消费者,再修改。 +2. 新共享实现必须在同一提交删除全部被替代实现;禁止只加一层包装。 +3. 运行代码总量原则上不得增加;测试和证据代码单独统计。 +4. 迁移期源码相等测试不得简单删除。发生已批准的结构重构时,必须替换成行为、错误语义和 + 唯一所有权契约。 +5. 每批通过领域测试、全量候选测试、独立导出测试和受影响的浏览器流程后才建立Git检查点。 +6. CSS最后处理;没有逐页日间、夜间和多视口截图证据,不删除视觉规则。 + +## 批次记录 + +| 批次 | 边界 | 基线问题 | 目标 | 状态 | +|---|---|---|---|---| +| CR-01 | LLM供应商传输 | 问师、问天、复盘助手和策略编译各自构造HTTP请求、解析响应和读取错误 | 只保留`backend/llm/transport.py`一个网络出口 | 已完成 | +| CR-02 | HTTP精确POST委托 | 27个端点重复使用“比较路径、调用无参数处理器、返回”三行分支 | 用公开/受保护两张显式映射统一委托,同时保留复杂路由的原控制流 | 已完成 | +| CR-03 | 股票市场后缀转换 | Tushare业务与iFinD图表各保留一份完全相同的沪深京代码转换函数 | 图表复用`bootstrap/config.py::tushare_code`,只保留一份函数体 | 已完成 | +| CR-04 | 数值归一化策略 | 四个业务模块分别保留两组完全相同的数值转换函数体 | 由`backend/data/numbers.py`集中拥有两种既有语义,消费者保留原局部别名 | 已完成 | +| CR-05 | 根级兼容入口 | 正式后端仍有五处通过迁移兼容模块反向导入规范实现 | 正式代码改用规范路径;兼容入口只服务原公开导入契约 | 已完成 | +| CR-06 | 紧凑日期显示 | Tushare与选股引擎各保留一份完全相同的`YYYYMMDD`显示转换 | 由`bootstrap/config.py`拥有唯一格式策略,消费者保留原局部别名 | 已完成 | +| CR-07 | 数据Provider组装 | 核查网关、容器和业务服务是否重复创建外部数据客户端 | 固化唯一创建位置及兼容例外,不改数据源语义 | 已完成 | +| CR-08 | NDJSON流式传输 | 问师与复盘助手重复维护响应头、事件写入、完成、断线及关闭流程 | HTTP共享层拥有唯一流式连接生命周期 | 已完成 | +| CR-09 | 应用服务门面 | 两个仅服务股池iFinD补全的方法仍错位在全局`DashboardService` | 原函数体机械归位到`PoolServiceMixin` | 已完成 | +| CR-10 | Repository所有权 | 五行行业阶段覆盖的三项持久化方法仍错位在根级数据库门面 | 原函数体机械归位到问天Repository,兼容数据继续保留 | 已完成 | +| CR-11 | 后台任务生命周期 | 导入应用即启动调度线程,启动早于端口绑定,停止只置位但不等待 | 运行时显式启停,每个Runner只拥有一个可等待的调度线程 | 已完成 | +| CR-12 | CSS跨层精确重复 | 七层样式保留多次视觉改造形成的重复顶层规则,前层声明被后层逐字覆盖 | 只删除能够由CSSOM证明完全重复的前层规则,并建立浏览器级重复门禁 | 已完成 | +| CR-13 | CSS跨文件嵌套精确重复 | 相同媒体上下文中的完整规则分散在不同样式文件,前层声明仍被后层完整重复 | 递归核对CSSOM上下文,只退休跨文件的精确副本 | 已完成 | +| CR-14 | CSS同文件精确重复 | 同一文件、同一媒体上下文仍保留多轮视觉调整形成的完整重复规则 | 删除较早副本并把同文件重复纳入浏览器门禁 | 已完成 | + +## CR-01验收口径 + +- 四个功能模块不得包含`urllib.request`或`/chat/completions`。 +- 非流式与流式请求的URL、鉴权、User-Agent、SSE累积和空响应行为保持不变。 +- 各功能原有错误类型和用户可见错误文案保持不变。 +- `LLMGateway`的会员、额度、主辅回退、首字后不中途换模型和审计规则保持不变。 +- 架构清单必须登记唯一传输入口;完整测试和真实主模型最小调用通过。 + +## CR-01结果 + +- 四个功能模块的运行代码由572行降至422行;新增唯一传输实现129行,生产代码净减少21行、 + 约1.4 KB。行数不是主要收益,关键是5处`/chat/completions`请求只剩1处。 +- 三套重复HTTP错误正文解析合并为一套;各功能原有错误类型和用户可见文案由专项测试固定。 +- 迁移期4个“Agent文件逐字相等”断言没有直接删除,而是替换为共享传输唯一所有权、提示词模块 + 归属、SSE行为和兼容模块对象契约。 +- 候选311项、纯`app/`导出248项、45项Playwright通过;24个JavaScript文件、架构/API注册表和 + SQLite完整性检查通过。 +- 使用候选数据库中加密保存的主模型完成真实非流式与流式最小调用,分别成功返回完整响应和 + 4个流式分片。调用未输出密钥或模型正文。 +- 本批不修改页面、CSS、提示词、业务计算、会员额度、模型回退、数据库或部署。 + +回档基线为`xiaobai-preservation-complete-20260801`;本批检查点为 +`xiaobai-reduction-01-llm-transport-20260801`。 + +## CR-02验收口径 + +- 仅纳入没有路径参数、请求体解析或专属异常分支的精确POST端点;其余路由保持原样。 +- 公开注册/登录端点继续在鉴权前分发;受保护端点继续严格执行登录、CSRF、注册表权限、处理器。 +- 27个映射路径必须都由权威API注册表解析,处理方法必须真实存在,公开与受保护集合不得重叠。 +- API路径、功能归属、访问角色、状态码、错误正文和静态页面绕过鉴权行为保持不变。 +- API清单生成器必须结构化读取显式映射;架构清查复用API清单,不再维护第二套路由发现规则。 + +## CR-02结果 + +- 2个公开端点和25个受保护端点改为显式委托映射,原来的79行重复分支被43行映射、分发与调用替代; + `backend/application.py`净减少36行,规范化源码约减少1.0 KB。 +- 带正则路径参数、请求体读取、查询参数转换或特殊异常语义的GET、POST、DELETE端点未改动。 +- 架构清查删除了自行扫描精确/正则API路径的第二套规则,改为消费权威`api.config.json`;API注册表的 + 53个精确路径、11个正则路径、功能归属和权限均未变化。 +- 原版231项、候选315项、纯`app/`导出252项、45项Playwright通过;24个JavaScript文件、 + API/架构注册表、Git空白检查和SQLite完整性检查通过。 +- 本批不修改前端、CSS、业务计算、数据源、数据库结构、LLM、会员规则或部署。 + +本批基线为`xiaobai-reduction-01-llm-transport-20260801`;检查点为 +`xiaobai-reduction-02-http-dispatch-20260801`。 + +## CR-03验收口径 + +- 图表模块不再定义第二份市场后缀转换函数,仍保留原局部名称和两个调用点。 +- 深市、沪市、北交所的既有映射结果保持不变;不借本批修正或扩展代码规则。 +- 原图表文件除该函数外的所有顶层定义继续与原版AST逐项相等。 +- 原版`_stock_market_code`函数的参数和函数体必须与唯一共享实现AST相等,运行时别名必须指向 + 同一个函数对象。 + +## CR-03结果 + +- 删除`backend/features/market/charts.py`中第二份10行定义,以1行导入别名复用共享实现,生产代码 + 净减少9行;全仓后端只剩一份沪深京后缀转换函数体。 +- 未合并实时聚合、东方财富图表、iFinD和Tushare的HTTP传输;它们的缓存、错误、重试和降级语义 + 不同,仅有外形相似,证据不足以安全抽象。 +- 原有迁移期整文件相等断言被等价范围断言、共享函数AST断言和唯一对象断言替代,没有降低门禁。 +- 原版231项、候选317项、纯`app/`导出254项、45项Playwright通过;API/架构注册表、 + 24个JavaScript文件、Git空白检查和SQLite完整性检查通过。 +- 本批不修改图表请求、数据来源、缓存、时间范围、行情计算、前端、CSS、数据库或部署。 + +本批基线为`xiaobai-reduction-02-http-dispatch-20260801`;检查点为 +`xiaobai-reduction-03-market-symbol-20260801`。 + +## CR-04验收口径 + +- 只合并参数、函数体和运行结果完全一致的数值转换函数,不借本批改变任何业务计算或异常默认值。 +- `finite_number`继续拒绝`NaN`与正负无穷;`non_nan_number`继续只拒绝`NaN`并保留正负无穷。 +- Tushare与智能选股必须复用有限数策略;市场洞察与情绪引擎必须复用非NaN策略,并继续暴露原局部 + `_number`名称以保持兼容。 +- 实时行情和图表转换器的空值、默认值或参数签名语义不同,必须继续独立保留,不能因名称相同而合并。 +- 原版函数参数和函数体分别与共享实现AST相等;四个消费者的局部别名必须指向对应的唯一函数对象。 + +## CR-04结果 + +- 删除Tushare、智能选股、市场洞察和情绪引擎中的四份重复函数体,新建两种明确命名的共享策略;生产 + 代码净减少约12行,全仓AST扫描不再发现完全相同的函数定义。 +- 将可复用的函数及模块AST契约归入测试辅助层,原有迁移保持性测试改为“未改范围保持相等、被替换 + 函数与共享实现相等、运行时唯一对象”三重断言,没有降低门禁。 +- 实时行情`backend/data/realtime.py::_number`与图表`backend/features/market/charts.py::_number` + 被明确保留;它们不是本批重复实现,也未改变行为。 +- 58项定向测试、41项保持性/治理测试、原版231项、候选320项、纯`app/`导出257项和45项 + Playwright通过;24个JavaScript文件、API/架构注册表、Git空白检查和SQLite完整性检查通过。 +- 本批不修改前端、CSS、接口、数据来源、行情口径、选股条件、数据库、LLM、权限或部署。 + +本批基线为`xiaobai-reduction-03-market-symbol-20260801`;检查点为 +`xiaobai-reduction-04-numeric-normalization-20260801`。 + +## CR-05验收口径 + +- 逐项扫描根级Python入口、生产代码、测试、工具和动态导入;没有消费者或兼容责任的入口才能删除。 +- 规范后端不得经由`screener`、`advanced_strategies`、`tushare_client`或`server`兼容入口 + 间接访问已经归位的实现。 +- 所有根级模块继续保持原导入名称、导出对象及模块对象身份,既有启动命令和第三方维护脚本不受影响。 +- `api_access`、选股Repository的惰性`sentiment_engine`导入及根级`database.py`属于已登记边界, + 分别留到HTTP、Repository阶段处理,不在本批跨边界修改。 + +## CR-05结果 + +- 审计确认21个根级兼容入口均有测试、工具、启动或原公开导入契约消费者,因此本批没有冒险删除文件。 +- 容器、策略编译器、选股引擎及数据同步命令的五处导入改为规范模块路径,正式代码不再通过四个根级 + 兼容模块反向进入实现;运行代码行数未增加。 +- 特性边界测试取消选股引擎旧例外,并新增全后端兼容导入门禁;只允许两项已登记过渡边界,后续代码 + 无法重新引入隐式根级依赖。 +- 候选321项、纯`app/`导出258项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite + 完整性检查通过;本批不涉及页面、CSS或浏览器行为。 +- 本批不修改业务计算、策略公式、数据源、API、数据库、LLM、权限、前端或部署。 + +本批基线为`xiaobai-reduction-04-numeric-normalization-20260801`;检查点为 +`xiaobai-reduction-05-compatibility-boundaries-20260801`。 + +## CR-06验收口径 + +- 只合并参数、函数体和异常行为完全相同的日期/文本转换;名称相似但空值、未来日期、错误文案或 + 输入格式不同的函数不得合并。 +- Tushare与选股引擎继续暴露局部`_display_date`名称,并分别指向唯一共享实现。 +- 原版两个`_display_date`函数必须分别与共享实现AST相等,所有原调用结果保持不变。 +- 市场洞察的日期显示函数会清理连字符并容忍空值,语义不同,必须继续独立保留。 + +## CR-06结果 + +- 删除Tushare与选股引擎内两个重复日期函数体,新增`display_compact_date`唯一策略;生产代码总 + 行数不增加,重复函数体由两份降为一份。 +- 架构清单登记日期格式唯一所有权;保持性测试改为未改范围AST相等、共享函数AST相等和运行时 + 对象身份三重契约,没有放宽原迁移门禁。 +- `normalize_date`、市场洞察日期显示、实时行情时间格式和会员日期边界因语义不同均原样保留。 +- 候选321项、纯`app/`导出258项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite + 完整性检查通过;本批不涉及页面、CSS或浏览器行为。 +- 本批不修改日期输入规则、业务计算、选股结果、接口、数据库、数据源、LLM、权限或部署。 + +本批基线为`xiaobai-reduction-05-compatibility-boundaries-20260801`;检查点为 +`xiaobai-reduction-06-date-formatting-20260801`。 + +## CR-07验收口径 + +- iFinD、图表、实时观察器和Provider适配器必须只在`build_data_gateway`创建,并由容器共享。 +- Tushare必须继续通过实时Token供应器按需创建,不能为了减少对象数量缓存过期Token。 +- 市场服务中为原版隔离测试桩保留的一处`TushareClient(self.token)`是明确兼容例外,不得被误判为 + 第二条正式数据链路。 +- 不得合并Tushare、iFinD、东方财富和腾讯的传输、缓存、重试或降级逻辑。 + +## CR-07结果 + +- 全后端构造点扫描确认iFinD、MarketChart、东方财富图表和实时观察器均只有网关一个创建位置; + `ApplicationContainer`暴露的是同一对象引用,没有第二份客户端。 +- Tushare Provider使用动态Token供应器,市场服务只有一处已登记测试兼容回退;本批没有发现可安全 + 删除的生产实现,因此不为追求行数强行修改运行代码。 +- 架构清单新增Provider创建所有权,自动测试会在未来出现第二个未登记构造点时失败。 +- 候选322项、纯`app/`导出259项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite + 完整性检查通过;本批不涉及页面、CSS或浏览器行为。 +- 本批不修改请求频率、缓存、重试、Token更新、数据源选择、计算口径、API、数据库或前端。 + +本批基线为`xiaobai-reduction-06-date-formatting-20260801`;检查点为 +`xiaobai-reduction-07-provider-ownership-20260801`。 + +## CR-08验收口径 + +- 只合并NDJSON响应头、事件序列化、完成事件、业务错误事件、客户端断线和连接关闭这些传输行为。 +- 问师继续直接输出原事件字典;复盘助手继续把文本分片包装为`delta/content`事件。 +- 两个功能各自的业务异常类型、请求体错误状态码、错误正文和流创建时机保持不变。 +- `_write_stream_event`与连接生命周期必须只由`backend/http/handler.py`拥有,应用大类和功能HTTP + 模块不再保留第二份实现。 + +## CR-08结果 + +- 删除问师和复盘助手各16行重复流式控制流,并将应用大类中的10行事件写入方法归入HTTP共享层; + 新共享实现29行、两个调用适配共3行,生产代码净减少10行。 +- 新增专项测试固定四个响应头、中文NDJSON序列化、增量顺序、完成事件、业务错误事件和关闭状态。 +- 候选324项、纯`app/`导出261项及45项Playwright通过;24个JavaScript文件、API/架构注册表、 + Git空白检查和SQLite完整性检查通过。 +- 本批不修改提示词、模型选择、会员计次、流式正文、前端解析、API路径、数据库或数据源。 + +本批基线为`xiaobai-reduction-07-provider-ownership-20260801`;检查点为 +`xiaobai-reduction-08-ndjson-transport-20260801`。 + +## CR-09验收口径 + +- 只有消费者全部属于单一领域、且能够按原函数体机械移动的方法才从应用门面移出。 +- `_ifind_field`与`_ifind_row_code`继续保持静态/类方法签名、字段优先级、大小写规则和代码正则。 +- 账号委托属于稳定公开门面;系统设置属于跨领域协调;后台刷新留到CR-11,本批均不得删除或重写。 +- 移动后`DashboardService`必须继续通过Mixin解析同名方法,调用点和返回值不变。 + +## CR-09结果 + +- 将iFinD字段匹配和股票代码提取两个方法从应用大类机械移动到股池服务,原版与迁移方法AST逐项 + 相等;应用大类不再直接拥有股池专属实现。 +- 连同迁移期遗留空行,`backend/application.py`减少32行,股池服务增加24行,生产代码净减少8行。 +- 候选324项、纯`app/`导出261项、24个JavaScript文件、API/架构注册表、Git空白检查和SQLite + 完整性检查通过;本批不涉及页面、CSS或浏览器行为。 +- 本批不修改字段匹配、涨跌停原因补全、接口、数据源、缓存、数据库、权限或前端。 + +本批基线为`xiaobai-reduction-08-ndjson-transport-20260801`;检查点为 +`xiaobai-reduction-09-service-facade-20260801`。 + +## CR-10验收口径 + +- 只移动调用方、数据表和业务含义均明确属于单一领域的方法;数据库连接、事务和返回值必须保持不变。 +- `list_sector_phase_overrides`、`save_sector_phase_override`与`delete_sector_phase_override`必须由 + `backend/features/heaven/repository.py`拥有,并继续通过`ReviewDatabase`的Mixin解析。 +- 不修改表结构、迁移顺序、时间格式、排序、冲突更新或删除结果语义。 +- `wencai_saved_queries`及其三个方法属于已登记的账户隔离兼容数据;即使前端入口已取消,也必须保留。 + +## CR-10结果 + +- 将五行行业阶段覆盖的查询、保存和删除三个方法从根级`database.py`机械移动到问天Repository; + 调用名称、SQL、事务边界、时间值和返回结果均未改变。 +- 根级数据库门面不再直接拥有问天领域的持久化实现,问财历史兼容表和方法完整保留,未扩大删除范围。 +- 34项Repository、问天、账户隔离、清理契约及迁移定向测试通过;候选324项、纯`app/`导出261项、 + 24个JavaScript文件、API/架构注册表、Git空白检查和SQLite完整性检查通过。 +- 本批不修改页面、CSS、API、数据库结构、行情、数据源、业务计算、LLM、权限或后台任务。 + +本批基线为`xiaobai-reduction-09-service-facade-20260801`;检查点为 +`xiaobai-reduction-10-repository-ownership-20260801`。 + +## CR-11验收口径 + +- 导入`backend.application`或构造`DashboardService`不得启动后台调度;必须先成功绑定HTTP端口, + 再由运行时显式启动。 +- 同一`InProcessJobRunner`重复启动调度器必须返回同一活动线程,停止必须置位并在限定时间内等待退出, + 有序停止后允许重新启动。 +- 运行时关闭顺序固定为:停止调度、等待已提交任务、关闭HTTP服务器;迁移对比工具继续兼容原版入口。 +- 三个任务的注册定义、5秒刷新频率、3秒初始延迟、幂等键、锁、重试、业务函数和结果不得改变。 +- 声明的超时继续是目标与审计字段;Python线程不能安全强杀,本批不伪造硬取消能力。 + +## CR-11结果 + +- 删除`DashboardService`构造阶段的调度副作用,端口占用、模块导入和单元测试不再提前创建后台写线程; + `backend/bootstrap/runtime.py`成为正式启动与停止所有者。 +- `InProcessJobRunner`集中持有调度停止事件和线程引用;重复启动幂等,停止可等待,原任务锁、持久化运行 + 状态、成功幂等、失败记录和后续重试逻辑保持不变。 +- 新增语法树与运行顺序门禁,固定“构造不启动”“绑定后启动”“停止后关服”,并补齐重复启动与重启测试。 +- 候选328项、纯`app/`导出265项和45项Playwright通过;24个JavaScript文件、API/架构注册表、 + Git空白检查和SQLite完整性检查通过。 +- 本批没有可安全删除的重复任务实现;为补齐原先缺失的生命周期,生产代码净增加24行。增加内容仅为 + 调度状态、幂等启停和运行时委托,不新增业务层、任务或兼容包装。 +- 本批不修改页面、CSS、API、数据源、刷新计算、自动选股条件、数据库结构、权限或LLM。 + +本批基线为`xiaobai-reduction-10-repository-ownership-20260801`;检查点为 +`xiaobai-reduction-11-background-jobs-20260801`。 + +## CR-11人工验收修正 + +- 2026-08-02人工验收发现本地页面可访问,但行情与LLM同时无法连接。第一原因是验收服务由受限 + 自动化会话启动,子进程继承了禁止外部网络访问的权限;重新在主机正常网络权限下启动后恢复。 +- 随后的主模型连接测试暴露`LLMServiceMixin`机械迁移时遗漏`validate_text`导入,导致请求在真正 + 访问模型前抛出`NameError`并关闭HTTP连接;恢复原依赖并增加保存模型连接探测的运行契约测试。 +- 网站自身实测`000001`返回Tushare日K 60根、分时242点;主模型 + `MiniMax-M2.7-highspeed`在2236毫秒内回复`OK`,证明服务进程的数据与LLM出网链路均已恢复。 +- 修正后候选329项、纯`app/`导出266项通过;本次只恢复缺失导入和测试,不修改模型配置、额度、 + 提示词、回退策略、行情来源或计算逻辑。 +- 继续验收观势时发现模型已经成功返回,但问天服务在保存解势记录前关闭了HTTP连接。原因是原版 + `server.py`已有的`secrets`导入在机械拆分到问天服务时遗漏,生成非观气记录去重键时触发 + `NameError`。迁移版恢复该标准库依赖,并增加“模型成功返回后保存观势结果”的完整服务回归测试。 +- 使用`000001 平安银行`完成真实页面复测:六爻安全门6/6通过,解势结果正常返回并写入历史, + `8797`错误日志为空。该修正不改变提示词、模型选择、额度、卦象计算、记录结构或前端行为。 + +本修正基线为`xiaobai-reduction-11-background-jobs-20260801`;检查点为 +`xiaobai-reduction-11-runtime-connectivity-fix-20260802`;后续解势修正检查点为 +`xiaobai-reduction-11-heaven-interpret-fix-20260802`。 + +## CR-12验收口径 + +- 本批只处理不同样式文件中、同为顶层、选择器与完整声明逐字等价的规则;媒体查询、伪状态、 + 动画、问天隔离样式以及仅外形相似的规则不进入删除范围。 +- 删除的必须是较早加载的副本,较晚层继续提供完全相同的最终声明;样式加载顺序、变量、HTML、 + JavaScript和主题切换逻辑均不改变。 +- 迁移保真测试必须逐段登记允许退休的原始CSS文本,除登记片段外,其余源码继续与原版逐字符相等。 +- 浏览器必须验证跨层顶层精确重复为零,并通过日间、夜间、桌面、390px移动端及全站交互回归。 + +## CR-12结果 + +- 通过浏览器CSSOM扫描七层运行样式,共发现57组完整重复规则;本批只批准其中7组跨文件顶层重复, + 分别涉及工作区显示、折叠侧栏、板块轮动末列、选股概率值、摘要条两项声明及龙虎榜原因列。 + 其余50组位于同文件或嵌套媒体条件等更复杂环境,证据不足,继续保留。 +- 删除7条较早加载的规则,三个生产CSS文件净减少19行、480字节;后层最终规则、选择器优先级与 + 加载顺序均未改变,没有新增兼容覆盖或第二套样式实现。 +- 新增浏览器CSSOM门禁,任何两个样式层再次出现相同顶层选择器与完整声明都会失败;迁移保真门禁 + 只允许已登记的7个精确源码片段退休,其他CSS差异仍会失败。 +- 真实`8797`页面复核情绪周期日间/夜间、龙虎榜和390×844移动端;三个受影响节点的计算样式 + 与删除前一致,移动端无横向溢出。候选330项、CSS/前端契约33项、迁移对照63项及46项 + Playwright全部通过,24个JavaScript文件、API/架构注册表和SQLite完整性检查通过。 +- 本批不修改页面布局、颜色、字体、间距、响应式规则、主题、动画、业务功能、API、数据库或部署。 + +本批基线为`xiaobai-reduction-11-heaven-interpret-fix-20260802`;检查点为 +`xiaobai-reduction-12-css-exact-duplicates-20260802`。 + +## CR-13验收口径 + +- 本批只处理不同样式文件中、处于浏览器规范化后完全相同媒体条件下、选择器与完整CSSOM声明完全 + 相同的规则;不同媒体上下文、同文件重复、近似声明、动画和问天隔离样式继续保留。 +- 删除的必须是较早加载的副本,较晚样式层继续提供相同声明;媒体条件、规则顺序、选择器优先级、 + 变量、HTML、JavaScript和主题逻辑不得改变。 +- 保真门禁必须逐段登记允许退休的原始源码;浏览器门禁必须递归遍历嵌套规则,并只将同一上下文内 + 跨文件的完整重复判为失败。 +- 390×844明暗主题、1000×800中等宽度和1000×600低高度断点必须保持原计算样式与视觉结果。 + +## CR-13结果 + +- 浏览器CSSOM确认22组跨文件嵌套重复:1组位于721-1279px媒体条件,12组位于720px移动端条件, + 9组位于720px或1023px低高度复合条件;全部删除较早层副本,后层规则原样保留。 +- `styles.css`减少65行,`redesign-v2.css`减少15行,生产CSS合计净减少80行、约1.9 KB;没有新增 + 兼容覆盖、声明值、选择器或样式文件。 +- Playwright门禁由顶层扫描扩展为递归上下文扫描,修改后同一嵌套上下文的跨文件完整重复为0;同文件 + 重复和不同上下文规则不在本批范围,未被误删。 +- 1000×800和1000×600修改前后截图逐字节一致;390×844明暗主题的关键显示、定位、间距、网格、 + 溢出和导航状态一致,页面目视无差异。 +- 候选330项、CSS/前端契约33项、迁移对照63项及46项Playwright全部通过;24个JavaScript文件、 + API/架构注册表和SQLite完整性检查通过。 +- 本批不修改页面布局、颜色、字体、间距、主题、动画、业务功能、API、数据库、数据源、LLM或部署。 + +本批基线为`xiaobai-reduction-12-css-exact-duplicates-20260802`;检查点为 +`xiaobai-reduction-13-css-nested-duplicates-20260802`。 + +## CR-13后续产品修正:龙虎榜整页滚动 + +- 2026-08-02用户明确要求取消龙虎榜“当日操作明细单独纵向滚动”,改为龙虎榜主内容区整页纵向滚动, + 解决低分辨率下操作明细可视高度过小的问题;这是经批准的产品行为变化,不作为CSS去重处理。 +- 龙虎榜从桌面固定视口共享规则中独立出来;主内容区继续使用工作区高度并承担纵向滚动,游资卡片、 + 操作明细和待归类席位按内容自然展开,宽操作表继续保留横向滚动。 +- 保真门禁以精确源码替换单独登记本次差异,其他CSS仍与原母版逐字符比较;未新增覆盖层或第二套规则。 +- 1366×768真实页面中主内容区为692px、内容高度为2620px,整页滚动可达;操作明细自身高度与内容 + 高度一致,不再形成纵向小窗口,1180px宽表仍可横向滚动。 +- 本次不修改龙虎榜数据、筛选、搜索、游资卡牌、表格字段、游资档案、API、数据库或其他页面的 + 滚动所有权。 + +本修正基线为`xiaobai-reduction-13-css-nested-duplicates-20260802`;检查点为 +`xiaobai-fix-dragon-page-scroll-20260802`。 + +## CR-14验收口径 + +- 只处理同一CSS文件、同一浏览器规范化媒体上下文中,选择器和完整CSSOM声明完全相同的规则; + 不同媒体上下文、近似声明、动画和问天隔离样式继续保留。 +- 每组只删除较早出现的副本并保留最后一份原规则;样式文件加载顺序、媒体条件、选择器优先级、变量、 + HTML、JavaScript和主题逻辑均不得改变。 +- 保真门禁必须精确登记原始片段及其出现/退休次数;浏览器门禁从“只拒绝跨文件重复”提升为 + “同一上下文内任何完整重复均拒绝”。 +- 1366×768桌面暗色关键页面和390×844移动端关键页面的尺寸、滚动范围及视觉结果必须保持不变, + 并通过全站Playwright回归。 + +## CR-14结果 + +- 浏览器CSSOM确认33组同文件精确重复,其中两个规则各出现三次;共退休35个较早副本: + `styles.css`9个、`renovation.css`25个、`redesign-v2.css`1个,运行时同上下文完整重复降为0。 +- 三个生产CSS文件合计净减少97行、3272字节;未新增选择器、声明、覆盖层或样式文件,最后一份原规则 + 及其媒体上下文全部保留。 +- 保真门禁新增精确出现次数与退休次数审计,除登记片段外继续与原母版逐字节比较;Playwright CSSOM + 门禁现会拒绝跨文件和同文件重复,后续不能重新堆回同类规则。 +- 1366×768暗色模式复核情绪周期、集合竞价、题材库、智能选股、问师和我的复盘;390×844复核 + 情绪周期、集合竞价、智能选股和我的复盘。关键尺寸、滚动范围保持一致,移动端情绪周期截图逐像素一致, + 其余页面目视无差异且无横向溢出。 +- 候选330项、CSS/前端契约33项、迁移对照63项及46项Playwright全部通过;24个JavaScript文件、 + API/架构注册表和SQLite完整性检查通过。 +- 本批不修改页面布局、颜色、字体、间距、响应式行为、主题、动画、业务功能、API、数据库、数据源、 + LLM或部署。 + +本批基线为`xiaobai-fix-dragon-page-scroll-20260802`;检查点为 +`xiaobai-reduction-14-css-same-file-duplicates-20260802`。 + +## 人工验收记录 + +- 2026-08-01:用户检查CR-02与CR-03运行结果,确认未发现明显异常。本记录仅表示本轮可见功能与 + 页面使用未发现明显回归,不替代后续批次各自的自动测试和人工抽查。 diff --git a/docs/governance/stage-01-baseline.md b/docs/governance/stage-01-baseline.md new file mode 100644 index 0000000..efada7b --- /dev/null +++ b/docs/governance/stage-01-baseline.md @@ -0,0 +1,60 @@ +# Stage 01: Governance Baseline + +Date: 2026-07-29 + +Baseline commit: `0030bb8cc18c5aa7107dd8fd866355a6507bd6da` + +## Purpose + +This baseline freezes observable behavior before architecture governance begins. Later stages +may move code and introduce internal contracts, but must not change API behavior, account +boundaries, persisted user data, desktop presentation, or existing workflows unless a change +is approved separately. + +## Deployment Contract + +- One Python process serves the API and static browser client. +- One SQLite database is persisted at `data/review.db`. +- Docker exposes `0.0.0.0:8765` and mounts only `./data` at `/app/data`. +- Public market data is shared; private records are scoped by `user_id`. +- Market-data and LLM credentials remain server-side. + +## Verified Baseline + +- Python: 169 tests passed. +- Playwright: 45 tests passed at desktop and mobile viewports. +- JavaScript entry points pass `node --check`. +- `git diff --check` reports no patch errors. +- SQLite `PRAGMA integrity_check` returns `ok`. +- The database was backed up with the SQLite backup API to + `data/backups/governance-stage-01-0030bb8.db` and the backup independently passed + `PRAGMA integrity_check`. The backup is intentionally excluded from Git. + +## Repeatable Verification + +Run the fast baseline on every structural change: + +```shell +python tools/verify_baseline.py +``` + +Run the browser suite at phase boundaries: + +```shell +python tools/verify_baseline.py --e2e +``` + +## Regression Gates + +1. API routes and response fields remain compatible until a versioned contract says otherwise. +2. Existing SQLite files must upgrade without deleting or reassigning user-owned rows. +3. A failed external data request must not synthesize market prices. +4. Ordinary, member, and administrator permissions must remain distinct. +5. Desktop visual refactors require 1080P and 4K comparison in both themes. +6. Mobile work is isolated from the approved desktop shell and workflows. +7. Old code is deleted only after its replacement is active and reference scans are clean. + +## Rollback + +Code can return to this point with Git commit `0030bb8`. Database rollback must use the +matching backup above; a code rollback alone is not sufficient after a schema migration. diff --git a/docs/governance/stage-02-inventory.md b/docs/governance/stage-02-inventory.md new file mode 100644 index 0000000..4754aa8 --- /dev/null +++ b/docs/governance/stage-02-inventory.md @@ -0,0 +1,72 @@ +# Stage 02: Architecture Inventory + +Date: 2026-07-29 + +The machine-readable inventory is `architecture-inventory.json`. Regenerate it with: + +```shell +python tools/build_architecture_inventory.py +python tools/build_architecture_inventory.py --check +``` + +## Current Shape + +- The product is a single-container modular monolith with one Python process and SQLite WAL. +- The browser is a build-free single-page application served by the same process. +- The primary navigation exposes 16 workspaces. +- The current database bootstrap defines 34 tables. +- HTTP routing and service orchestration are concentrated in `server.py`. +- Schema creation, inline migrations, and all persistence methods are concentrated in + `database.py`. +- The frontend loads six ordered CSS layers and one global application script. +- Background refresh and automatic screener work execute as daemon threads in the HTTP + process. + +## Ownership Inventory + +### Shared market and system data + +Dashboard snapshots, synchronization runs, stock master data, daily bars, benchmark bars, +daily indicators, fundamental indicators, money flow, auction factors, earnings events, +popularity factors, institutional list data, public screener strategies, reason overrides, +seat aliases, sector phase overrides, and encrypted system settings are shared. + +### User-owned data + +Sessions, personal credentials, birth profiles, watchlists, review notes, custom strategies, +private screener runs, mentor messages and preferences, saved queries, tracking entries, +alerts, trades, assistant messages, and heaven readings are user-scoped. Every repository +extracted in later stages must preserve this boundary explicitly. + +## Runtime Entrypoints + +- HTTP and application bootstrap: `server.py` +- Persistence and migrations: `database.py` +- Access policy: `api_access.py` +- Market providers: `tushare_client.py`, `ifind_client.py`, `chart_data_provider.py`, + `realtime_aggregator.py` +- Deterministic computation: `sentiment_engine.py`, `screener.py`, `advanced_strategies.py`, + `market_insights.py`, `heaven_engine.py` +- LLM calls: `mentor_agent.py`, `heaven_agent.py`, `assistant_agent.py`, `llm_strategy.py` +- Browser shell and pages: `static/index.html`, `static/app.js`, and the ordered CSS files + +## Primary Structural Risks + +1. Request routing, use cases, scheduling, provider selection, serialization, and account + context coexist in one service module. +2. Database migrations cannot be reviewed independently from repository behavior. +3. Several business paths instantiate provider clients directly, so source policy is not + centrally enforceable. +4. LLM authorization, quota accounting, model selection, streaming, and error handling are + distributed across multiple adapters. +5. Global DOM state and page behavior share one JavaScript file, increasing cross-page + regression risk. +6. CSS correctness depends on load order and late overrides rather than explicit layer + ownership. +7. In-process daemon jobs have no durable queue and some failure paths are not observable. + +## Inventory Limits + +The JSON inventory records source-level declarations and references. It does not claim that +every declared endpoint is exercised or every table is populated. Runtime coverage and data +quality are separate contracts introduced in later governance stages. diff --git a/docs/governance/stage-04-registries.md b/docs/governance/stage-04-registries.md new file mode 100644 index 0000000..db03c09 --- /dev/null +++ b/docs/governance/stage-04-registries.md @@ -0,0 +1,48 @@ +# Stage 04: Governance Registries + +Date: 2026-07-29 + +## Result + +Four versioned registries now describe the existing product before runtime decomposition: + +1. `config/pages.config.json` registers all 16 primary workspaces. +2. `config/features.config.json` registers 20 feature owners and their access/data scope. +3. `config/api.config.json` registers 74 method/path combinations, including dynamic paths. +4. `config/data-fields.config.json` registers 18 initial canonical data products and provider + eligibility. + +The API registry is generated from the current request handler so route drift fails tests. The +other registries are curated contracts and may change only through an explicit product or data +governance decision. + +## Access Decisions Preserved + +- Health, login, and registration are public. +- Market views require an authenticated account. +- Intelligent screening, Mentor, Wentian, and their history endpoints require membership. +- My Review remains visible to authenticated users; LLM assistant endpoints remain member + gated inside that workspace. +- System management and shared knowledge modification remain administrator-only. +- Frontend visibility does not grant API access. + +## Data Decisions Preserved + +- Tushare and licensed iFinD data may be calculation inputs when a dataset contract permits it. +- Eastmoney and Tencent public endpoints are display-only in the initial registry. +- Analyst consensus history and Level-2 microstructure remain blocked rather than silently + approximated. +- Chart fallbacks remain separate from calculation datasets. +- The current unadjusted deterministic daily-bar behavior is recorded honestly; adjustment + normalization is deferred to the data-quality phase rather than changed here. + +## Transitional Rule + +These files do not yet replace `server.py`, `api_access.py`, or the current frontend navigation. +They are checked against those surfaces to prevent untracked drift. Later phases make each +registry authoritative in one atomic migration with compatibility tests. + +## Verification + +`tests/test_governance_registries.py` verifies uniqueness, page coverage, current API coverage, +explicit public routes, feature ownership, role validity, and provider eligibility. diff --git a/docs/governance/stage-05-bootstrap.md b/docs/governance/stage-05-bootstrap.md new file mode 100644 index 0000000..313f57c --- /dev/null +++ b/docs/governance/stage-05-bootstrap.md @@ -0,0 +1,24 @@ +# Stage 05: Bootstrap and Dependency Assembly + +Date: 2026-07-29 + +## Result + +- Environment and legacy LLM credential resolution moved to `backend/bootstrap/settings.py`. +- Encryption-key initialization remains behavior-compatible and server-side. +- Stable service construction moved to `backend/bootstrap/container.py`. +- `DashboardService` keeps its compatibility attributes but receives them from one application + container. +- The iFinD client is instantiated once and shared by chart services. +- HTTP routes, API payloads, background thread timing, database paths, and frontend assets are + unchanged. + +## Transitional Boundary + +Some methods still construct Tushare clients directly. Stage 06 introduces `DataGateway` and +migrates those provider creation paths without combining that work with bootstrap changes. + +## Rollback + +Reverting this stage restores inline service construction. No database or configuration +migration is required. diff --git a/docs/governance/stage-06-data-gateway.md b/docs/governance/stage-06-data-gateway.md new file mode 100644 index 0000000..83bd32c --- /dev/null +++ b/docs/governance/stage-06-data-gateway.md @@ -0,0 +1,29 @@ +# Stage 06: Unified Data Gateway + +Date: 2026-07-29 + +## Result + +- Added canonical provider and dataset contracts under `backend/data`. +- Added `DataSourcePolicy`, loaded from the Stage 04 data registry. +- Added provider adapters for Tushare and iFinD. +- Added one `DataGateway` that owns Tushare creation, the shared iFinD client, chart routing, + and isolated Eastmoney/Tencent realtime observation. +- Replaced all real `DashboardService` Tushare construction paths with the gateway. +- Kept one compatibility constructor for unit tests that instantiate an incomplete service with + `__new__`; production instances never use it. +- The Tushare token is supplied lazily, so administrator credential changes do not leave a + stale client in memory. + +## Enforcement Introduced + +- Unregistered datasets fail. +- Blocked datasets fail. +- Public-web providers cannot be promoted to calculation inputs through a fallback call. +- Display chart fallbacks remain distinct from deterministic calculation datasets. + +## Deferred to Stage 07 + +Stage 06 centralizes provider access but does not yet attach freshness, coverage, unit, and +point-in-time quality evidence to every returned observation. Stage 07 introduces those gates +without changing provider routing again. diff --git a/docs/governance/stage-07-data-quality.md b/docs/governance/stage-07-data-quality.md new file mode 100644 index 0000000..8d2f671 --- /dev/null +++ b/docs/governance/stage-07-data-quality.md @@ -0,0 +1,32 @@ +# Stage 07: Data Quality and Provenance Gates + +Date: 2026-07-29 + +## Result + +- Added a quality rule for every registered data product. +- Added canonical Asia/Shanghai timestamp handling. +- Added source and usage verification before quality acceptance. +- Added maximum-age checks for realtime auction, intraday charts, and realtime indices. +- Added minimum coverage thresholds for deterministic datasets. +- Added unit profiles for prices, shares, currency, percentages, ratios, ranks, and timestamps. +- Added adjustment checks that distinguish current unadjusted deterministic bars from iFinD + forward-adjusted display charts. +- Added announcement-date point-in-time checks for financial data. +- Added explicit provider chains; a display fallback cannot silently become a calculation + source. +- Analyst consensus and Level-2 remain blocked until a qualified provider is registered. + +## Fail-Closed Contract + +A calculation evidence envelope is rejected when its source is unauthorized, its dataset is +blocked, its timestamp is from the future, its realtime data is stale, its coverage is below +the registered threshold, its units or adjustment mode differ, or its financial record was not +available at the evaluation time. + +## Compatibility + +Existing legacy provider response shapes remain unchanged in this stage. New governed feature +paths must submit quality evidence through `DataGateway.require_quality`. Existing feature +paths receive envelopes as they migrate behind domain services, avoiding a simultaneous +rewrite of calculations and provider routing. diff --git a/docs/governance/stage-08-database-migrations.md b/docs/governance/stage-08-database-migrations.md new file mode 100644 index 0000000..0ec3262 --- /dev/null +++ b/docs/governance/stage-08-database-migrations.md @@ -0,0 +1,28 @@ +# Stage 08: Database Connection and Migration Foundation + +Date: 2026-07-29 + +## Result + +- Centralized SQLite connection policy in `SQLiteConnectionFactory`. +- Preserved WAL, foreign-key enforcement, row mapping, handle cleanup, and the existing + 20-second contention tolerance. +- Added an ordered migration runner with immutable checksums and an applied-migration ledger. +- Added savepoint rollback so a failed migration cannot be recorded or leave partial schema. +- Adopted existing databases as version `0001` only after verifying the required legacy + tables. +- Kept the legacy idempotent bootstrap in place for compatibility with databases created by + every previous application version. + +## Forward Rule + +All schema changes after this stage must be a new immutable module under +`backend/database/migrations`. Editing an applied migration is rejected by checksum. A +database containing a migration unknown to the running code is rejected rather than opened +with an older schema interpretation. + +## Residual Risk + +The historical inline bootstrap remains a compatibility facade during repository migration. +It may be removed only after legacy upgrade fixtures cover every supported historical shape. +No user table or row is rewritten in this stage. diff --git a/docs/governance/stage-09-repositories.md b/docs/governance/stage-09-repositories.md new file mode 100644 index 0000000..a4a105c --- /dev/null +++ b/docs/governance/stage-09-repositories.md @@ -0,0 +1,27 @@ +# Stage 09: Account-Scoped Repository Boundaries + +Date: 2026-07-29 + +## Result + +- Added narrow repository ports for alerts, the trade journal, and strategy tracking. +- Added SQLite adapters that expose only the persistence operations each service requires. +- Required a positive account owner on every private read, write, update, and delete path. +- Kept the shared automatic screener-run lookup explicit as the sole zero-owner read in the + strategy tracking adapter. +- Routed the application container through a repository bundle. +- Removed direct alert and trade deletion/update calls from the HTTP-facing dashboard service. +- Preserved structural compatibility for isolated tests and gradual extraction from the legacy + database facade. + +## Boundary + +Application services depend on repository protocols. The SQLite implementation may later be +replaced without changing those services. Repository adapters do not call market providers, +and provider adapters do not access user tables. + +## Residual Migration + +The legacy `ReviewDatabase` still contains the SQL behind these adapters and remains the +compatibility facade for features not yet extracted. Subsequent feature stages can move SQL +behind the same ports one feature at a time after account-isolation tests pass. diff --git a/docs/governance/stage-10-feature-services.md b/docs/governance/stage-10-feature-services.md new file mode 100644 index 0000000..88067dd --- /dev/null +++ b/docs/governance/stage-10-feature-services.md @@ -0,0 +1,28 @@ +# Stage 10: Feature Application Service Layout + +Date: 2026-07-29 + +## Result + +- Created the governed `backend/features/` source boundary. +- Moved alert behavior into the alerts feature. +- Moved trade-journal behavior into the review feature. +- Moved strategy-tracking behavior into the screener feature. +- Changed the application container to import feature-owned services directly. +- Reduced the former top-level service modules to compatibility exports, preserving existing + imports without maintaining duplicate implementations. +- Added dependency tests that prevent feature services from importing HTTP delivery code or + concrete market-provider adapters. + +## Template + +Each migrated feature owns its application behavior and depends on repository or gateway +ports. HTTP delivery and background jobs may invoke the service, but neither may become the +owner of its rules. The same layout is now available for the remaining market, account, +Mentor, Wentian, and screener services. + +## Compatibility + +No route, response shape, persisted data, service method, or top-level import path changes in +this stage. Compatibility exports are removed only after all internal and external callers +use the feature packages. diff --git a/docs/governance/stage-11-http-governance.md b/docs/governance/stage-11-http-governance.md new file mode 100644 index 0000000..509a517 --- /dev/null +++ b/docs/governance/stage-11-http-governance.md @@ -0,0 +1,28 @@ +# Stage 11: HTTP Route, Access, and Error Governance + +Date: 2026-07-29 + +## Result + +- Promoted `config/api.config.json` from a transitional inventory to the runtime route and + access registry. +- Added deterministic exact and regex route resolution with duplicate and regex validation. +- Replaced hand-maintained member/admin path sets with the registered access contract. +- Rejected unregistered API routes before business dispatch. +- Added safe request correlation IDs to JSON responses and `X-Request-ID` headers. +- Extended every legacy JSON error with stable `code`, `message`, and `request_id` fields while + preserving the existing `error` field used by the browser. +- Kept the current request handler and all route response bodies compatible while feature + route modules are migrated incrementally. + +## Runtime Authority + +Changing or adding an API now requires one coherent change to the handler and API registry. +The generated source inventory test prevents either side from drifting. Backend access remains +authoritative; frontend visibility cannot grant a route. + +## Residual Migration + +Individual dispatch branches still live in the compatibility request handler. Feature-owned +controllers will move behind the same registry in later stages without changing route identity, +authorization, or error serialization. diff --git a/docs/governance/stage-12-background-jobs.md b/docs/governance/stage-12-background-jobs.md new file mode 100644 index 0000000..91f7801 --- /dev/null +++ b/docs/governance/stage-12-background-jobs.md @@ -0,0 +1,30 @@ +# Stage 12: Governed Background Jobs + +Date: 2026-07-29 + +## Result + +- Registered market refresh, automatic screening, and iFinD event enrichment as versioned + jobs with schedules, date policy, dependencies, locks, timeouts, retry limits, and output + versions. +- Added the `job_runs` migration and persistent run ledger. +- Centralized worker and scheduler thread creation in `InProcessJobRunner`. +- Added process-level lock keys and persistent idempotency keys. +- Persisted successful, failed, and retried attempts with elapsed time and normalized error + type. +- Exposed recent job runs in administrator system status. +- Limited retained completed history while preserving running jobs. +- Kept existing feature services and the detailed market `sync_runs` audit unchanged. + +## Compatibility + +The runner remains in-process, matching the current single-container deployment. Jobs call the +same application methods as HTTP requests and do not depend on request-local account state. +The persistent contract permits a later worker process without changing job identities or +business calculations. + +## Timeout Boundary + +Timeouts are declared and elapsed time is recorded. Python threads cannot be terminated safely, +so hard cancellation remains cooperative until jobs move to a durable worker process. Locks and +idempotency prevent concurrent duplicate execution in the current single-process deployment. diff --git a/docs/governance/stage-13-llm-gateway.md b/docs/governance/stage-13-llm-gateway.md new file mode 100644 index 0000000..2c454cc --- /dev/null +++ b/docs/governance/stage-13-llm-gateway.md @@ -0,0 +1,32 @@ +# Stage 13: Unified LLM Gateway + +## Boundary + +All runtime model calls now enter through `backend/llm/gateway.py`. Feature agents retain +their deterministic context assembly, prompt content, and provider response parsing. + +The gateway owns: + +- membership and daily quota enforcement; +- primary and fallback model selection; +- fallback only before the first streamed delta; +- stable user-visible availability and interruption errors; +- one logical-call audit record with feature, model role, prompt version, latency, status, + normalized error code, and token fields reserved for providers that report usage. + +Administrator connection probes also cross the gateway boundary, but do not consume member +quota or create usage records. + +## Compatibility + +- Mentor and review-assistant stream payloads are unchanged. +- Heaven readings retain their primary/fallback notice and persistence behavior. +- Strategy compilation still falls back to the deterministic local compiler when both model + profiles are unavailable, while access and quota failures remain blocking. +- Provider credentials and raw provider failures remain outside browser responses. + +## Residual Risk + +The current OpenAI-compatible streaming providers do not consistently return token usage, so +the audit schema records zero until transport adapters expose trustworthy token counts. Request +cancellation remains bounded by the existing provider socket timeout. diff --git a/docs/governance/stage-14-frontend-boundaries.md b/docs/governance/stage-14-frontend-boundaries.md new file mode 100644 index 0000000..f584a25 --- /dev/null +++ b/docs/governance/stage-14-frontend-boundaries.md @@ -0,0 +1,38 @@ +# Stage 14: Frontend Request and State Boundaries + +## Request Boundary + +`static/shared/api.js` is now the only application file allowed to call `fetch`. It owns: + +- JSON serialization and response parsing; +- CSRF attachment for mutating requests; +- expired-session notification; +- abort signals; +- NDJSON stream decoding and normalized stream errors. + +The mentor and review-assistant streams use the same client as ordinary API requests. Existing +function signatures and page interactions remain unchanged. + +## State Boundary + +`static/shared/state.js` stores mutable state in explicit domains: session, market, entity +details, review, screener, mentor, and heaven. A compatibility proxy retains the existing flat +access syntax while rejecting unregistered fields. New page modules can request their owned +domain without depending on another page's data. + +This stage establishes a migration boundary rather than splitting the build-free monolith in a +single high-risk edit. Page extraction can now proceed domain by domain with no contract change. + +## Enforcement + +Automated checks require that: + +- only `shared/api.js` contains browser `fetch` calls; +- shared state and API scripts load before `app.js`; +- application state is created through the shared state boundary. + +## Residual Risk + +`static/app.js` still contains page renderers and event handlers in one file. The state domains +make ownership explicit, but those functions should move into page modules only in later, +independently verified stages. diff --git a/docs/governance/stage-15-frontend-shell.md b/docs/governance/stage-15-frontend-shell.md new file mode 100644 index 0000000..1f77f2f --- /dev/null +++ b/docs/governance/stage-15-frontend-shell.md @@ -0,0 +1,42 @@ +# Stage 15: Shared Frontend Shell and Page Registry + +## Runtime Page Registry + +`static/pages.config.js` is the build-free runtime representation of +`config/pages.config.json`. It registers every primary workspace plus the internal strategy +tracking workspace. Automated parity checks prevent titles, feature ownership, access labels, +groups, default-page selection, and layout metadata from drifting between the two registries. + +Legacy route aliases are resolved by the registry instead of page business code. The registry +describes navigation and presentation metadata only; backend authorization remains +authoritative. + +## Shared Shell + +`static/shared/shell.js` now owns: + +- sidebar initialization, persistence, collapse state, and responsive control labels; +- primary and mobile navigation binding and active-state synchronization; +- workspace mounting, entry animation, URL state, and scroll reset; +- header command-menu lifecycle; +- market-summary expansion; +- status-bar page titles and data dates; +- the single-open-dialog lifecycle used by global application dialogs. + +`static/app.js` retains feature-specific enter and leave behavior, such as stopping Wentian +animations or loading auction data. It asks the shell to mount a registered page and no longer +mutates global page geometry directly. + +## Compatibility + +- Existing DOM IDs, CSS classes, query parameters, page animations, and mobile navigation are + unchanged. +- `screenerTrackingView` continues to highlight the Intelligent Screener navigation item. +- Existing function entry points remain as thin compatibility facades where feature code still + calls status, dialog, or command-menu services. + +## Residual Risk + +Feature renderers and feature event binding still share `static/app.js`. Their state ownership +is now explicit and their shell dependencies are removed, so later page-module extraction can +be performed one feature at a time instead of as a single rewrite. diff --git a/docs/governance/stage-16-css-tokens.md b/docs/governance/stage-16-css-tokens.md new file mode 100644 index 0000000..099ef9b --- /dev/null +++ b/docs/governance/stage-16-css-tokens.md @@ -0,0 +1,49 @@ +# Stage 16: CSS Tokens and Cascade Governance + +## Scope + +This stage changes CSS ownership, not visual design. Existing selectors, page geometry, +responsive behavior, theme behavior, and the isolated Wentian visual layer remain intact. + +## Canonical Token Layer + +`static/shared/tokens.css` is the only owner of global application tokens. It is loaded before +all application styles and follows a three-layer contract: + +1. Primitive tokens contain raw palette, dimension, elevation, and motion values. +2. Semantic tokens describe interface meaning such as canvas, surface, border, text, action, + market direction, and warning states. +3. Component tokens define shared card, control, shell, table, chart, and profile contracts. + +Dark mode overrides semantic and compatibility values in the same file. It does not redefine +component geometry. The `--wt-*` namespace remains owned by `wentian-v2.css` because Wentian +has a deliberately isolated visual language. + +## Compatibility + +The historical `--xb-*`, `--r2-*`, and flat variables such as `--blue`, `--line`, and `--up` +remain compatibility aliases. Their effective light and dark values are unchanged. New CSS +must use semantic or component tokens; compatibility names exist only so feature selectors can +be migrated incrementally without a broad visual rewrite. + +The stylesheet order is fixed as: + +1. shared tokens; +2. legacy/base styles; +3. renovation and page redesign rules; +4. canonical non-Wentian design-system rules; +5. runtime theme selectors; +6. isolated Wentian rules. + +## Enforcement + +`tests/test_css_governance.py` verifies the load order, the three token layers, single global +ownership, compatibility coverage, and Wentian isolation. A new global `:root` token block in +any legacy stylesheet fails the regression suite. + +## Residual Migration + +Page styles still contain literal values and historical variable references. Removing those is +an incremental page-by-page task because blindly replacing them would risk changing already +accepted visuals. Stage 16 establishes the ownership boundary that prevents new drift while +leaving safe migration points for later stages. diff --git a/docs/governance/stage-17-page-modules.md b/docs/governance/stage-17-page-modules.md new file mode 100644 index 0000000..95f974e --- /dev/null +++ b/docs/governance/stage-17-page-modules.md @@ -0,0 +1,53 @@ +# Stage 17: Frontend Page Modules and Shared Components + +## Scope + +This stage establishes frontend feature ownership without changing page markup, API contracts, +permissions, visual design, or user interaction. The build-free deployment model is retained. + +## Page Module Runtime + +`static/pages/runtime.js` owns the page lifecycle registry. Each feature registers its views in +`static/pages//page.js` and declares named enter and leave actions. The application +injects the existing feature functions into that runtime, so page modules do not reach into +another feature's state or DOM. + +The lifecycle boundary now owns: + +- page-specific data loading after a successful shell mount; +- member-aware entry for Screener, Mentor, and Wentian; +- auction timer cleanup when leaving Auction; +- canvas, dust, and performance cleanup when leaving Wentian; +- the internal Screener Tracking view's ownership relationship. + +`openView` is now a generic coordinator. It validates the route, asks the page runtime to leave +the previous page, mounts through the shared shell, and enters the next page. It contains no +page-name branch chain. + +## Shared Components + +`static/shared/components.js` is the common rendering boundary for small, stable DOM patterns. +The first migrated component is the empty state used across market rotation, themes, +Dragon-Tiger, review, Screener, Wentian history, alerts, entity details, and administration. +It centralizes escaping and class composition while preserving the exact existing markup. + +Collection rendering and text assignment are exposed for later incremental migrations. They +remain dependency-free and use `XiaobaiUI` for safe escaping. + +## Enforcement + +Frontend boundary tests verify that: + +- shared components load after UI primitives and before page code; +- the page runtime loads before every feature registration and before `app.js`; +- every public and internal workspace belongs to exactly one feature page module; +- `openView` contains no feature-specific view comparisons; +- shared empty-state rendering is used by multiple feature families; +- provider requests still exit only through `shared/api.js`. + +## Compatibility and Residual Risk + +Feature renderers and event handlers still reside in `app.js`; moving them all at once would +create a high-risk rewrite across already accepted workflows. The new lifecycle and component +boundaries let those functions move feature by feature later without changing navigation or +loading behavior. Dedicated mobile composition remains the next governance phase. diff --git a/docs/maintenance/人工维护指南.md b/docs/maintenance/人工维护指南.md new file mode 100644 index 0000000..f58f78d --- /dev/null +++ b/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/docs/migration/README.md new file mode 100644 index 0000000..addee67 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,13 @@ +# 迁移文档入口 + +后续恢复迁移工作时按以下顺序读取,禁止从历史阶段文档直接继续: + +1. [`../../AGENTS.md`](../../AGENTS.md):仓库级不可违反约束。 +2. [`原版保真迁移总纲.md`](原版保真迁移总纲.md):当前唯一有效的迁移方法。 +3. [`保真迁移状态.json`](保真迁移状态.json):机器可读当前状态和下一步。 +4. [`保真迁移账本.md`](保真迁移账本.md):连续检查点、资产处置和决策记录。 +5. [`next失败冻结记录.md`](next失败冻结记录.md):失败实现的隔离边界。 +6. [`人工维护与本地切换指南.md`](人工维护与本地切换指南.md):迁移版目录、验证、数据边界、人工验收、切换与回退。 +7. [`evidence/slice-11/README.md`](evidence/slice-11/README.md):当前候选的试删审计和全量自动验收结果。 + +`重建迁移章程.md`及`next/`内阶段文档均是失败过程历史记录,不再指导后续实施。 diff --git a/docs/migration/evidence/slice-00/README.md b/docs/migration/evidence/slice-00/README.md new file mode 100644 index 0000000..afa71f3 --- /dev/null +++ b/docs/migration/evidence/slice-00/README.md @@ -0,0 +1,34 @@ +# 切片00:原样可运行副本 + +## 范围 + +- 原版基线:`41329943c4878fc09ed82ec376eb93ab151e4092` +- 目标目录:`app/` +- 数据库:使用SQLite在线备份生成`app/data/review.db`,不写原版数据库。 +- 排除:冻结的`next/`、密钥、正式数据库、日志、缓存、构建产物。 + +## 自动证据 + +| 检查 | 结果 | +|---|---| +| 原版与目标源文件SHA-256 | 628项一致,0项差异 | +| Python测试 | 231项通过 | +| Playwright测试 | 45项通过 | +| 数据库结构 | 36张表一致 | +| 数据库行数 | 2,830,708行一致 | +| SQLite完整性 | `ok` | +| 真实服务 | `http://127.0.0.1:8785/api/health`返回`ok` | +| 真实登录 | 管理员账号登录成功,默认进入情绪周期 | + +## 视觉证据 + +- `app-exact-light-1920x1080.png` +- `app-exact-dark-1920x1080.png` +- `app-exact-light-390x844.png` +- `app-exact-dark-390x844.png` + +四个视口均使用真实服务、数据库副本和原版前端资产。1920与390宽度下没有页面横向溢出。 + +## 结论 + +`app/`当前是原版的可独立运行副本,不包含来自`next/`的产品实现,也尚未进行业务拆分。 diff --git a/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png b/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png new file mode 100644 index 0000000..4e8b67b Binary files /dev/null and b/docs/migration/evidence/slice-00/app-exact-dark-1920x1080.png differ diff --git a/docs/migration/evidence/slice-00/app-exact-dark-390x844.png b/docs/migration/evidence/slice-00/app-exact-dark-390x844.png new file mode 100644 index 0000000..e9dc0cc Binary files /dev/null and b/docs/migration/evidence/slice-00/app-exact-dark-390x844.png differ diff --git a/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png b/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png new file mode 100644 index 0000000..5c2ec13 Binary files /dev/null and b/docs/migration/evidence/slice-00/app-exact-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-00/app-exact-light-390x844.png b/docs/migration/evidence/slice-00/app-exact-light-390x844.png new file mode 100644 index 0000000..bf2df28 Binary files /dev/null and b/docs/migration/evidence/slice-00/app-exact-light-390x844.png differ diff --git a/docs/migration/evidence/slice-01/README.md b/docs/migration/evidence/slice-01/README.md new file mode 100644 index 0000000..5e7b27a --- /dev/null +++ b/docs/migration/evidence/slice-01/README.md @@ -0,0 +1,57 @@ +# 切片 01:启动、HTTP、账号、会员与系统管理 + +> 基线:`4083dce`(切片 00 原样可运行副本) +> 回档标签:`xiaobai-preservation-slice-01-20260731` +> 结论:自动差分通过;前端运行资产未改动,仍等待最终全站人工验收 + +## 1. 本次范围 + +本切片只整理原版 `app/` 副本中的启动、通用 HTTP、账号、会员和系统管理实现。所有实现均从 +原文件机械移动,原导入面由兼容外壳保持;没有从冻结的 `next/` 复制产品代码,也没有重新设计 +页面、接口或数据结构。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/server.py` 的进程组装与完整应用服务 | `app/backend/application.py`、`app/backend/bootstrap/runtime.py` | `app/server.py` 继续导出原符号并保持原启动命令 | +| `app/server.py` 的 Cookie、鉴权、CSRF、静态文件和 JSON 传输 | `app/backend/http/handler.py` | `RequestHandler` 组合 `HttpTransportMixin` | +| `app/server.py` 的账号与会员 HTTP 方法 | `app/backend/features/accounts/http.py` | `RequestHandler` 组合 `AccountHttpMixin` | +| `app/server.py` 的系统管理 HTTP 方法 | `app/backend/features/system/http.py` | `RequestHandler` 组合 `SystemHttpMixin` | +| `app/server.py` 的账号、会员和出生信息业务方法 | `app/backend/features/accounts/service.py` | `DashboardService` 委托 `AccountService` | +| `app/database.py` 的账号、会话、会员与用户凭据方法 | `app/backend/features/accounts/repository.py` | `ReviewDatabase` 组合 `AccountRepositoryMixin` | +| `app/database.py` 的系统设置存取方法 | `app/backend/features/system/repository.py` | `ReviewDatabase` 组合 `SystemSettingsRepositoryMixin` | +| `app/security.py` | `app/backend/features/accounts/security.py` | 根文件保留原导出 | +| `app/app_config.py` | `app/backend/bootstrap/config.py` | 根文件保留原导出 | + +## 2. 不变量与差分证据 + +- API:`config/api.config.json` 与 `app/config/api.config.json` 的 SHA-256 相同;路由路径、方法、 + 访问角色、状态码和错误载荷由原注册表与全量测试继续约束。 +- 数据库:原版和迁移副本均为 36 张业务表、62 个 schema 对象,规范化 schema SHA-256 均为 + `0615a0423856d0eb02bccd81a071840bd50d6563da96d556cec49967ad8a5f4c`。 +- 账号边界:注册、登录、会话、密码、会员和出生信息使用临时数据库执行同一原版契约,5 项专项 + 测试全部通过。 +- 前端:本切片没有修改 `app/static/`;`index.html`、`app.js`、`styles.css` 与根目录原版对应文件 + 哈希相同。`app-light-1920x1080.png` 保存本切片运行截图。 +- 兼容:原 `python server.py` 命令以及测试和外部模块使用的 `server.DashboardService`、 + `server.RequestHandler`、`server.SERVICE`、`server.automatic_screener_jobs` 均保持可用。 + +## 3. 验证结果 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 236 项通过 | +| `python -m unittest tests.test_preservation_slice_accounts -v` | 5 项通过 | +| `npx playwright test --reporter=dot` | 45 项通过 | +| `python -m compileall -q ...` | 通过 | +| `git diff --check` | 通过(仅 Git 的 CRLF 提示) | + +Playwright 在自行创建 Python 静态服务器时存在 Windows 子进程退出等待问题;验证时预先启动 +`127.0.0.1:8876` 静态服务器并由 Playwright 复用,45 项用例在 143 秒内正常返回退出码 0。 + +## 4. 保留与待处理 + +- `app/backend/application.py` 仍包含其余尚未迁移切片的原版实现,这是刻意保留,不是本切片遗漏。 +- `app/server.py`、`app/app_config.py`、`app/security.py` 和 `app/database.py` 的兼容面须等所有消费者 + 完成归位后再评估;本阶段禁止删除。 +- 没有删除任何不确定代码,没有修改正式根目录数据库,也没有切换 Docker/NAS。 + diff --git a/docs/migration/evidence/slice-01/app-light-1920x1080.png b/docs/migration/evidence/slice-01/app-light-1920x1080.png new file mode 100644 index 0000000..3d7c6f1 Binary files /dev/null and b/docs/migration/evidence/slice-01/app-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-02/README.md b/docs/migration/evidence/slice-02/README.md new file mode 100644 index 0000000..6134016 --- /dev/null +++ b/docs/migration/evidence/slice-02/README.md @@ -0,0 +1,61 @@ +# 切片 02:公共行情、搜索、详情、图表与数据网关 + +> 基线:`4002f09`(切片 01) +> 回档标签:`xiaobai-preservation-slice-02-20260731` +> 结论:源码、API、数据库、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片从原版副本机械移动公共行情纵向链路,没有从 `next/` 取用代码,也没有修改计算逻辑。 + +| 原位置 | 新的唯一实现位置 | 原位置兼容 | +|---|---|---| +| `app/backend/application.py` 的 30 个总览、搜索、详情、图表方法 | `app/backend/features/market/service.py` | `DashboardService` 继承 `MarketServiceMixin` | +| `app/database.py` 的 11 个行情快照、搜索目录、同步记录方法 | `app/backend/features/market/repository.py` | `ReviewDatabase` 继承 `MarketRepositoryMixin` | +| `app/tushare_client.py` | `app/backend/data/providers/tushare_client.py` | 根模块为同一模块对象的兼容别名 | +| `app/ifind_client.py` | `app/backend/data/providers/ifind_client.py` | 根模块为同一模块对象的兼容别名 | +| `app/realtime_aggregator.py` | `app/backend/data/realtime.py` | 根模块为同一模块对象的兼容别名 | +| `app/chart_data_provider.py` | `app/backend/features/market/charts.py` | 根模块为同一模块对象的兼容别名 | + +`app/tools/move_class_methods.py` 使用 Python AST 确定方法及装饰器的源码边界,只移动原文本片段。 +该工具会在缺失方法、目标标记不唯一或源码无法解析时停止,供后续切片继续复用。 + +## 2. 等价证据 + +- `test_preservation_slice_market.py` 对 30 个业务方法和 11 个 Repository 方法逐项执行无位置信息 + AST 比较,全部与根目录原版 `server.py`、`database.py` 完全相同。 +- Tushare、iFinD 和实时观察器文件与原版 SHA-256 完全相同;图表模块全部类和函数 AST 与原版相同, + 仅内部导入改为新规范位置。 +- 四个根级兼容模块与新模块共享同一类对象,旧导入和旧 monkeypatch 路径继续有效。 +- `config/api.config.json`、API路径、鉴权角色、错误结构和数据库 schema 未修改。 +- `app/static/` 未修改;七个核心 HTML/JS/CSS 文件哈希继续与原版相同。 +- `app-light-1280x720.png` 为真实 `8785` 服务完成载入后的日间模式截图,SHA-256 为 + `a7ee1b682f68c418d792727dbc4534d7494dae4f4811cdbba208bd86dcf25d10`。 + +## 3. 真实运行检查 + +- 迁移副本:`http://127.0.0.1:8785/`,管理员登录成功。 +- 总览:返回 2026-07-30 Tushare 已缓存行情,涨停 56 只。 +- 搜索:搜索“中国平安”返回 `601318`,点击后打开完整个股详情、日 K、资金流、事件逻辑和复盘笔记。 +- 页面:1280px 视口无横向溢出,数据加载状态正常,浏览器控制台 0 个错误。 +- 分时:迁移版与原版在当前本机网络环境均返回同一个 `Intraday chart request failed`,因此记录为 + 既存外部接口状态,不是本切片差异;没有擅自增加降级或改变来源策略。 + +## 4. 自动验证 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 241 项通过 | +| `python -m unittest tests.test_preservation_slice_market -q` | 6 项通过 | +| 行情、图表、实时与数据库专项集合 | 61 项通过 | +| `npx playwright test --reporter=dot` | 45 项通过 | +| `python -m compileall -q ...` | 通过 | +| `git diff --check` | 通过 | + +## 5. 保留边界 + +- 情绪计算仍在 `application.py`,切片 03 再归位;行情服务只通过继承调用,没有复制。 +- 竞价、题材、人气、龙虎榜和问天对公共行情客户端的调用仍可通过兼容别名工作,待各自切片迁移。 +- 根级四个数据模块、`DashboardService` 和 `ReviewDatabase` 的兼容面在所有消费者完成迁移前保留。 +- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。 + diff --git a/docs/migration/evidence/slice-02/app-light-1280x720.png b/docs/migration/evidence/slice-02/app-light-1280x720.png new file mode 100644 index 0000000..9fdd8ed Binary files /dev/null and b/docs/migration/evidence/slice-02/app-light-1280x720.png differ diff --git a/docs/migration/evidence/slice-03/README.md b/docs/migration/evidence/slice-03/README.md new file mode 100644 index 0000000..91b469c --- /dev/null +++ b/docs/migration/evidence/slice-03/README.md @@ -0,0 +1,66 @@ +# 切片 03:情绪周期、五类股池与涨停表现 + +> 基线:`a426432`(切片 02) +> 回档标签:`xiaobai-preservation-slice-03-20260731` +> 结论:源码、API、数据库、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动原版副本中的真实实现,没有从 `next/` 取用代码,也没有改写情绪公式、股池数据、 +原因补全、表格、样式或交互。 + +| 原位置 | 新的唯一实现位置 | 原位置兼容 | +|---|---|---| +| `app/backend/application.py` 的 2 个情绪服务方法 | `app/backend/features/sentiment/service.py` | `DashboardService` 继承 `SentimentServiceMixin` | +| `app/backend/application.py` 的 6 个股池原因及事件补全方法 | `app/backend/features/pools/service.py` | `DashboardService` 继承 `PoolServiceMixin` | +| `app/database.py` 的 2 个原因覆盖方法 | `app/backend/features/pools/repository.py` | `ReviewDatabase` 继承 `PoolRepositoryMixin` | +| `app/sentiment_engine.py` | `app/backend/features/sentiment/engine.py` | 根模块为同一模块对象的兼容别名 | + +五类股池、涨停梯队和涨停表现仍由切片 02 已归位的原 Tushare 总览实现生成,本切片没有建立第二套 +计算或数据来源。 + +## 2. 等价证据 + +- `test_preservation_slice_sentiment_pools.py` 对 8 个业务方法和 2 个 Repository 方法逐项执行无位置 + 信息 AST 比较,全部与根目录原版 `server.py`、`database.py` 完全相同。 +- 新的情绪引擎文件与原版 `sentiment_engine.py` SHA-256 完全相同;根级兼容模块与新模块是同一模块对象。 +- 已归位的应用、行情服务、Tushare Provider、演示数据和选股模块直接导入新的唯一实现;Tushare + Provider 仅调整该导入,其全部类和函数 AST 继续与原版一致。 +- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上请求 `/api/dashboard` 与 + `/api/sentiment/history`,JSON 状态、字段、值和顺序完全相同。 +- 2026-07-30 的同请求结果均为:涨停 56、炸板 23、跌停 83、昨日涨停 81、情绪历史 20 日。 +- 原版和迁移版数据库均为 62 个 schema 对象,schema 哈希均为 + `17918327f8b919496e6630458293f9f777c7c24662625bb3fc0b64ff0a8fbeef`。 +- `config/api.config.json`、API 路径、鉴权和 `app/static/` 未修改。 +- `app-light-1920x1080.png` 是真实迁移服务载入完成后的情绪周期页面,SHA-256 为 + `e387417abbe0667e00875a8d4061b5546748ecf2452a692d06d078d516330dab`。 + +## 3. 真实运行检查 + +- 迁移副本:`http://127.0.0.1:8785/`,管理员会话与缓存行情载入正常。 +- 情绪周期:20 个连续交易日、当前阶段、评分构成和交易日明细均完整显示。 +- 股池:涨停池 56 行、炸板池 23 行、跌停池 83 行、昨日涨停 81 行。 +- 涨停表现:四档晋级率、市场宽度和今日结论均显示原版结果。 +- 1920×1080 下六个页面横向溢出均为 0;日间、夜间背景与面板状态正常;浏览器控制台无迁移错误。 + +## 4. 自动验证 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 248 项通过 | +| `python -m unittest tests.test_preservation_slice_sentiment_pools -q` | 6 项通过 | +| 情绪、总览、缓存、iFinD 与前端契约专项集合 | 48 项通过 | +| `npx.cmd playwright test --reporter=dot` | 45 项通过 | +| `python -m compileall -q ...` | 通过 | +| `git diff --check` | 通过 | + +Windows 下由 Playwright 自行创建临时静态服务器时,45 项完成后子进程无法回收;改为预先启动同一个 +`8876` 静态服务器并让 Playwright 复用后,测试以零退出码正常结束,结果为 `45 passed (2.1m)`。 + +## 5. 保留边界 + +- 板块轮动仍调用情绪历史公共函数,待切片 04 与市场天梯一并归位。 +- 竞价、题材、人气和龙虎榜对股池数据的消费保持原调用路径,待切片 05 迁移。 +- 根级情绪引擎兼容模块、`DashboardService` 与 `ReviewDatabase` 兼容面继续保留;数据库内尚未迁移的 + 选股统计方法仍走兼容别名,待切片 06 随完整方法一并归位。 +- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。 diff --git a/docs/migration/evidence/slice-03/app-light-1920x1080.png b/docs/migration/evidence/slice-03/app-light-1920x1080.png new file mode 100644 index 0000000..987c2cf Binary files /dev/null and b/docs/migration/evidence/slice-03/app-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-04/README.md b/docs/migration/evidence/slice-04/README.md new file mode 100644 index 0000000..80a429c --- /dev/null +++ b/docs/migration/evidence/slice-04/README.md @@ -0,0 +1,58 @@ +# 切片 04:市场天梯与板块轮动 + +> 基线:`b3555d2`(切片 03) +> 回档标签:`xiaobai-preservation-slice-04-20260731` +> 结论:源码、API、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片从原版副本机械移动板块轮动服务,没有从 `next/` 取用代码,也没有修改天梯、轮动的计算、 +排序、展开、配色、页面结构或交互。 + +| 原位置 | 新的唯一实现位置 | 原位置兼容 | +|---|---|---| +| `app/backend/application.py` 的 2 个轮动方法 | `app/backend/features/rotation/service.py` | `DashboardService` 继承 `RotationServiceMixin` | +| Tushare Provider 的天梯与轮动构造函数 | 保持 `app/backend/data/providers/tushare_client.py` | 切片 02 已归位的公共数据实现 | + +市场天梯没有独立后端 API 或第二套计算,直接展示 `/api/dashboard` 中原 Tushare 实现生成的 +`ladders`;因此没有为目录形式建立空的天梯服务。 + +## 2. 等价证据 + +- `test_preservation_slice_ladder_rotation.py` 对 2 个轮动服务方法逐项执行无位置信息 AST 比较, + 全部与根目录原版 `server.py` 完全相同。 +- `_build_ladders` 与 `_build_sector_rotation` 两个原数据构造函数的 AST 与根目录原版完全相同。 +- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上返回的天梯数据及 9 日轮动历史 + JSON 逐字段完全相同。 +- 成分股接口在当前外部网络状态下两版均返回 HTTP 400、`bad_request` 和相同的 + `该板块成分股暂不可用:Tushare request failed:`,没有改变错误或增加静默降级。 +- `config/api.config.json`、API 路径、鉴权、数据库 schema 和 `app/static/` 未修改。 + +## 3. 真实运行检查 + +- 市场天梯:8 个层级(含断层)、18 个首屏股票单元格、3 个结构分析模块正常;1920×1080 下 + 页面宽度无溢出,首板展开入口保留。 +- 板块轮动:9 个交易日、每日 Top 12 共 108 个板块单元格、由远到近/由近到远两个排序入口正常; + 1920×1080 下页面宽度无溢出并保持全页滚动。 +- 日间模式页面控制台没有错误或警告。 +- `app-light-ladder-1920x1080.png` SHA-256: + `9e57d18d92e745fd92131f7bf08f21faaaa745476dd942cdaa2a703b9a7a303a`。 +- `app-light-rotation-1920x1080.png` SHA-256: + `03c092bb40bd0eb672136dff853abffc87e9790840107c2f22e6cf31d5f83c09`。 + +## 4. 自动验证 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 252 项通过 | +| `python -m unittest tests.test_preservation_slice_ladder_rotation -q` | 4 项通过 | +| 切片 02 至 04 与总览缓存专项集合 | 24 项通过 | +| `npx.cmd playwright test --reporter=dot` | 45 项通过 | +| `git diff --check` | 通过 | + +## 5. 保留边界 + +- 成分股接口依赖的日行情与因子持久化方法仍由原 `ReviewDatabase` 提供,因其同时服务智能选股, + 待切片 06 随完整共享职责归位。 +- 天梯和轮动前端资产保持原位,切片 10 再按页面职责归档;当前没有复制或改写。 +- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。 diff --git a/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png b/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png new file mode 100644 index 0000000..a3ddc25 Binary files /dev/null and b/docs/migration/evidence/slice-04/app-light-ladder-1920x1080.png differ diff --git a/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png b/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png new file mode 100644 index 0000000..344b821 Binary files /dev/null and b/docs/migration/evidence/slice-04/app-light-rotation-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/README.md b/docs/migration/evidence/slice-05/README.md new file mode 100644 index 0000000..40b135b --- /dev/null +++ b/docs/migration/evidence/slice-05/README.md @@ -0,0 +1,69 @@ +# 切片 05:集合竞价、题材库、人气热榜与龙虎榜 + +> 基线:`814e757`(切片 04) +> 回档标签:`xiaobai-preservation-slice-05-20260731` +> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片没有从`next/`取用代码,也没有重写计算、页面或接口。集合竞价、题材库和人气热榜原本 +共享`MarketInsightsService`,其中竞价候选会直接调用人气榜热度数据,因此整体移动到公共行情 +领域,避免拆出互相复制的实现;各页面入口仍按功能目录归位。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/market_insights.py` | `app/backend/features/market/insights.py` | 根级模块导出同一类对象 | +| `DashboardService`竞价入口 | `app/backend/features/auction/service.py` | `AuctionServiceMixin` | +| `DashboardService`题材入口 | `app/backend/features/themes/service.py` | `ThemeServiceMixin` | +| `DashboardService`人气入口 | `app/backend/features/popularity/service.py` | `PopularityServiceMixin` | +| `DashboardService`龙虎榜及游资档案 | `app/backend/features/dragon_tiger/service.py` | `DragonTigerServiceMixin` | +| 竞价、人气、龙虎榜持久化方法 | 对应功能目录的`repository.py` | `ReviewDatabase`继承原接口 | + +Tushare Provider 中`hot_money_profiles`与`dragon_tiger`继续保持切片02归位的唯一实现,没有为目录 +形式再制造一套数据构造逻辑。 + +## 2. 源码与接口等价 + +- `test_preservation_slice_market_insights.py`逐项比较22个市场洞察方法、8个页面服务方法、7个 + Repository方法和2个Tushare方法,全部与根目录原版无位置信息AST一致。 +- `DashboardService`与`ReviewDatabase`不再重复保留已移动方法;根级`market_insights`与新模块 + 暴露同一个`MarketInsightsService`类对象。 +- 原版`8784`和迁移版`8785`使用同一数据库的独立副本,集合竞价、题材库、题材详情、人气热榜、 + 龙虎榜、游资档案和席位别名共7个真实API状态码及JSON一致。 +- 题材详情在当前外部网络条件下两版均返回HTTP 400;差分只排除每次请求随机生成的 + `request_id`,错误码与错误内容仍完全一致。 +- 完整接口摘要见`api-diff.json`。 + +## 3. 数据库差分 + +- 两个副本均为62个schema对象,哈希均为 + `60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1`。 +- `auction_factors` 511914行、`popularity_factors` 232行、`lhb_institution_daily` 47行、 + `seat_aliases` 0行、`stock_master` 5535行均逐行一致。 +- 完整表计数与哈希见`database-diff.json`;运行数据库副本已在验收后删除,未提交凭据或正式数据。 + +## 4. 真实浏览器检查 + +- 1920×1080日间模式检查集合竞价、题材库、人气热榜和龙虎榜四页;均无横向溢出,控制台无 + 错误或警告。 +- 集合竞价载入30行重点候选;题材库载入394个题材及选中题材成分股;人气热榜载入3个摘要 + 模块和200行综合榜;龙虎榜按当前缓存显示既有不可用空态。 +- 四页HTML、主JS、CSS及各自页面JS与根目录原版字节哈希一致。 +- 截图SHA-256: + - `app-light-auction-1920x1080.png`:`8065086c8f2b360aeb1004bd60429f3e2d7f1b8c872ec4a965e9a5d0c016b915` + - `app-light-themes-1920x1080.png`:`80e1e41101497ee7213e1dadfaa4c9572a1c47b3495edd09e36745ccdb639402` + - `app-light-popularity-1920x1080.png`:`614d6b7770f4e9ec72c059ab8a4129486df9eff5c4dd7e8279cc68ebcb80a36b` + - `app-light-dragon-tiger-1920x1080.png`:`2f1e2ad3a9bc3874c73bae884fc8744177cef354c7176559da1453fab1f85993` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| `python -m unittest discover -s tests -q` | 258项通过 | +| `python -m unittest tests.test_preservation_slice_market_insights -q` | 6项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过 | +| `git diff --check` | 通过 | + +- 竞价、人气和龙虎榜因子同时服务切片06智能选股,迁移后仍由`ReviewDatabase`原方法名暴露。 +- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。 +- 没有删除待定代码、没有修改根目录正式数据库、没有切换Docker/NAS。 diff --git a/docs/migration/evidence/slice-05/api-diff.json b/docs/migration/evidence/slice-05/api-diff.json new file mode 100644 index 0000000..2f8827d --- /dev/null +++ b/docs/migration/evidence/slice-05/api-diff.json @@ -0,0 +1,61 @@ +{ + "all_equal": true, + "endpoints": [ + { + "endpoint": "/api/auction?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce", + "migrated_sha256": "523144cc14d876577b7d518cdf38fd2722b13a8f01ed5d2e20dcc38f6a2624ce", + "equal": true + }, + { + "endpoint": "/api/themes?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1", + "migrated_sha256": "95c2ad418f18d877d94ec2a71fe6fafd5e329b069d87a9300f7fcac92d4ba5d1", + "equal": true + }, + { + "endpoint": "/api/themes/detail?code=885001.TI&trade_date=2026-07-29", + "original_status": 400, + "migrated_status": 400, + "original_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a", + "migrated_sha256": "b11a3314b172d3ad6ba28d969fcd6a9a2a4b49e7d29ed804496a4dfecd2a364a", + "equal": true + }, + { + "endpoint": "/api/popularity?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94", + "migrated_sha256": "e62a93c41f7c3f95c3d47f8ccaedaa809563c8dd65c04dd54b164f73e6014e94", + "equal": true + }, + { + "endpoint": "/api/dragon-tiger?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843", + "migrated_sha256": "a3998b935377d5fd0673ec5b9214d1b0a64680d155c61e6cff49d0d5fcf0e843", + "equal": true + }, + { + "endpoint": "/api/dragon-tiger/profiles", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "equal": true + }, + { + "endpoint": "/api/seat-aliases", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78", + "migrated_sha256": "2b0fb0a6b3e353c69158d61221c2200e4199d0d60dd0b9d99702a22eaa917a78", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png b/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png new file mode 100644 index 0000000..c98baa7 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-auction-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png b/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png new file mode 100644 index 0000000..96ce5ff Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-dragon-tiger-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png b/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png new file mode 100644 index 0000000..c76e7a2 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-popularity-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png b/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png new file mode 100644 index 0000000..29c3b55 Binary files /dev/null and b/docs/migration/evidence/slice-05/app-light-themes-1920x1080.png differ diff --git a/docs/migration/evidence/slice-05/database-diff.json b/docs/migration/evidence/slice-05/database-diff.json new file mode 100644 index 0000000..f1aefd5 --- /dev/null +++ b/docs/migration/evidence/slice-05/database-diff.json @@ -0,0 +1,51 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true + }, + { + "table": "lhb_institution_daily", + "original_count": 47, + "migrated_count": 47, + "original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "equal": true + }, + { + "table": "seat_aliases", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "stock_master", + "original_count": 5535, + "migrated_count": 5535, + "original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-06/README.md b/docs/migration/evidence/slice-06/README.md new file mode 100644 index 0000000..fde53b3 --- /dev/null +++ b/docs/migration/evidence/slice-06/README.md @@ -0,0 +1,69 @@ +# 切片 06:智能选股、自定义选股与策略持续跟踪 + +> 基线:`cf2aad2`(切片 05) +> 回档标签:`xiaobai-preservation-slice-06-20260731` +> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只机械移动原版选股实现,没有从`next/`取用代码,也没有改写策略、因子、权重、排序、 +盘后候选或跟踪逻辑。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/screener.py` | `app/backend/features/screener/engine.py` | 根级模块指向同一模块对象 | +| `app/advanced_strategies.py` | `app/backend/features/screener/strategies.py` | 根级模块指向同一模块对象 | +| `app/llm_strategy.py` | `app/backend/features/screener/compiler.py` | 根级模块指向同一模块对象 | +| `DashboardService`选股入口 | `app/backend/features/screener/service.py` | `ScreenerServiceMixin` | +| `ReviewDatabase`选股持久化方法 | `app/backend/features/screener/repository.py` | `ScreenerRepositoryMixin` | +| 原策略持续跟踪服务 | `app/backend/features/screener/tracking.py` | 容器直接导入唯一实现 | + +四个同时服务公共行情与选股的方法归入`MarketRepositoryMixin`,没有复制第二套实现: +`upsert_stock_master`、`list_stock_master`、`upsert_daily_bars`、`daily_bars_for_date`。 + +## 2. 源码与接口等价 + +- `test_preservation_slice_screener.py`逐项验证选股引擎、29套策略定义、自然语言策略编译器、 + 13个服务方法、25个选股持久化方法和跟踪服务与原版等价。 +- 三个根级兼容模块与正式模块暴露同一模块及类对象;原类不再重复保留已移动方法。 +- 原版与迁移版的`/api/screener/setup`、`/api/screener/tracking`和`/api/screener/run` + 状态码及业务JSON一致。 +- 差分仅规范化策略启动刷新和顺序执行必然变化的数组顺序与`updated_at`;其他字段仍逐项比较。 +- 请求与差分结果见`api-requests.json`和`api-diff.json`。 + +## 3. 数据库差分 + +- 两个临时副本均为62个schema对象,结构完全一致。 +- 13张关键表逐行一致,覆盖约143万条日线、77万条技术指标和51万条竞价因子。 +- `screener_runs`两边均为251行;会话和运行时间戳只在临时副本内规范化。 +- 正式`data/review.db`和`app/data/review.db`未写入测试策略、测试会话或差分时间戳。 +- 完整表计数与哈希见`database-diff.json`。 + +## 4. 真实浏览器检查 + +- 已登录迁移版真实服务,分别检查阶段选股、策略选股、自定义选股和策略持续跟踪。 +- 阶段选股显示退潮、置信度92%、匹配策略和262日因子就绪状态;策略选股载入29套策略。 +- 自定义选股保留因子权重、过滤条件、公式入口、手动执行和独立候选结果。 +- 跟踪页只展示手动加入的候选,批次、标的、T+1/T+3进度、胜率和移除操作均正常。 +- 各检查状态无横向溢出、无加载遮罩残留,浏览器控制台无错误。 +- 截图SHA-256: + - `curated-screener.jpg`:`529ecd0947b34b42eb63d166c6804b0041e26fa362a171e4d0d7b584e073c05e` + - `custom-screener.jpg`:`7f31f50d44bc63a488277be0a74232e2ac256779d0accda42b96cc1eef88cc6e` + - `tracking.jpg`:`b19b1bc0cd4a98c29500917676bdcc4cc9a430a0c72dea8aff1110ad911db6e3` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 265项通过 | +| `python -m unittest tests.test_preservation_slice_screener -q` | 7项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过 | +| `git diff --check` | 通过 | + +- Windows下由Playwright自行创建静态服务器时存在子进程不退出的测试基线问题;复用独立的8876 + 测试服务器后45项用例在2.1分钟内通过并正常返回退出码0。 +- 选股引擎保留原版数据客户端依赖,边界测试将其与普通页面Service区分;后续只可在不改变行为且有 + 独立差分证据时治理该依赖。 +- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。 +- 没有删除待定代码、没有修改正式数据库、没有切换Docker/NAS。 diff --git a/docs/migration/evidence/slice-06/api-diff.json b/docs/migration/evidence/slice-06/api-diff.json new file mode 100644 index 0000000..2bfd963 --- /dev/null +++ b/docs/migration/evidence/slice-06/api-diff.json @@ -0,0 +1,38 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "盘后自动候选与策略库", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "equal": true, + "first_difference": null + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "equal": true, + "first_difference": null + }, + { + "name": "自定义选股执行", + "method": "POST", + "endpoint": "/api/screener/run", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b", + "migrated_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-06/api-requests.json b/docs/migration/evidence/slice-06/api-requests.json new file mode 100644 index 0000000..bc38631 --- /dev/null +++ b/docs/migration/evidence/slice-06/api-requests.json @@ -0,0 +1,59 @@ +[ + { + "name": "盘后自动候选与策略库", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "sort_lists": { + "$.strategies": "id" + }, + "exclude_paths": [ + "$.strategies[].updated_at" + ] + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12" + }, + { + "name": "自定义选股执行", + "method": "POST", + "endpoint": "/api/screener/run", + "exclude_paths": [ + "$.result.meta.updated_at" + ], + "payload": { + "trade_date": "2026-07-30", + "regime": "retreat", + "strategy_name": "保真迁移差分策略", + "mode": "quant", + "run_backtest": false, + "formula": { + "meta": { + "library": "quant", + "category": "量化公式" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [-2, 2] + } + ], + "score": [ + { + "field": "amount_billion", + "weight": 1, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0 + } + } + } +] diff --git a/docs/migration/evidence/slice-06/curated-screener.jpg b/docs/migration/evidence/slice-06/curated-screener.jpg new file mode 100644 index 0000000..bea9c71 Binary files /dev/null and b/docs/migration/evidence/slice-06/curated-screener.jpg differ diff --git a/docs/migration/evidence/slice-06/custom-screener.jpg b/docs/migration/evidence/slice-06/custom-screener.jpg new file mode 100644 index 0000000..c47d767 Binary files /dev/null and b/docs/migration/evidence/slice-06/custom-screener.jpg differ diff --git a/docs/migration/evidence/slice-06/database-diff.json b/docs/migration/evidence/slice-06/database-diff.json new file mode 100644 index 0000000..cf90a71 --- /dev/null +++ b/docs/migration/evidence/slice-06/database-diff.json @@ -0,0 +1,115 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "stock_master", + "original_count": 5535, + "migrated_count": 5535, + "original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "equal": true + }, + { + "table": "daily_bars", + "original_count": 1430501, + "migrated_count": 1430501, + "original_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6", + "migrated_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6", + "equal": true + }, + { + "table": "benchmark_bars", + "original_count": 261, + "migrated_count": 261, + "original_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9", + "migrated_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9", + "equal": true + }, + { + "table": "daily_indicators", + "original_count": 770238, + "migrated_count": 770238, + "original_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9", + "migrated_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9", + "equal": true + }, + { + "table": "fundamental_indicators", + "original_count": 49490, + "migrated_count": 49490, + "original_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d", + "migrated_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d", + "equal": true + }, + { + "table": "moneyflow_daily", + "original_count": 36368, + "migrated_count": 36368, + "original_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d", + "migrated_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d", + "equal": true + }, + { + "table": "earnings_events", + "original_count": 580, + "migrated_count": 580, + "original_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d", + "migrated_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d", + "equal": true + }, + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true + }, + { + "table": "lhb_institution_daily", + "original_count": 47, + "migrated_count": 47, + "original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "equal": true + }, + { + "table": "screener_strategies", + "original_count": 36, + "migrated_count": 36, + "original_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312", + "migrated_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312", + "equal": true + }, + { + "table": "screener_runs", + "original_count": 251, + "migrated_count": 251, + "original_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498", + "migrated_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498", + "equal": true + }, + { + "table": "strategy_tracks", + "original_count": 16, + "migrated_count": 16, + "original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-06/tracking.jpg b/docs/migration/evidence/slice-06/tracking.jpg new file mode 100644 index 0000000..060afda Binary files /dev/null and b/docs/migration/evidence/slice-06/tracking.jpg differ diff --git a/docs/migration/evidence/slice-07/README.md b/docs/migration/evidence/slice-07/README.md new file mode 100644 index 0000000..ec4694f --- /dev/null +++ b/docs/migration/evidence/slice-07/README.md @@ -0,0 +1,71 @@ +# 切片 07:问师、模型 Skill 与 LLM 流式链路 + +> 基线:`4bab921`(切片 06) +> 回档标签:`xiaobai-preservation-slice-07-20260731` +> 结论:源码、API、数据库、Skill 资产、真实页面和全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动原版问师、Skill 注册、模型访问与流式协议,没有从`next/`取用代码,也没有改写 +提示词、数据侧重、模型排序、权限、计次、回退或流式去重逻辑。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/mentor_agent.py` | `app/backend/features/mentor/agent.py` | 根级模块指向同一模块对象 | +| `DashboardService`问师方法 | `app/backend/features/mentor/service.py` | `MentorServiceMixin` | +| 问师消息和偏好持久化 | `app/backend/features/mentor/repository.py` | `MentorRepositoryMixin` | +| 问师流式HTTP方法 | `app/backend/features/mentor/http.py` | `MentorHttpMixin` | +| `app/llm_stream.py` | `app/backend/llm/stream.py` | 根级模块指向同一模块对象 | +| `DashboardService`模型访问方法 | `app/backend/llm/service.py` | `LLMServiceMixin` | +| LLM调用审计持久化 | `app/backend/llm/repository.py` | `LLMAuditRepositoryMixin` | +| 旧个人模型HTTP兼容入口 | `app/backend/llm/http.py` | `LLMHttpMixin` | + +模型池的增删、主辅模型选择及会员每日额度仍由系统管理负责;`backend/llm`只负责解析当前可用模型、 +鉴权、计次、首段前回退、流式传输和调用审计,避免复制第二套系统设置逻辑。 + +## 2. 源码与 Skill 等价 + +- `mentor_agent.py`和`llm_stream.py`与原版文件SHA-256一致,根级兼容模块与正式模块为同一模块对象。 +- 8个问师服务方法、5个问师Repository方法、2个LLM审计Repository方法与原版无位置信息AST一致。 +- 21个LLM服务方法中19个与原版AST一致;`_platform_usage_today`及按用户查询的辅助方法保留切片01 + 已验证的用户边界适配,使会员管理可在不切换请求上下文的情况下显示每位用户当日用量。 +- 问师流式和旧模型HTTP方法仅把原全局`SERVICE`改为Mixin的`self.application_service`,响应状态、 + Content-Type、NDJSON事件、异常和断连处理不变。 +- 公开`游资skills`共190个文件,原版与迁移版逐路径、逐SHA-256比较,差异为0。 +- 私有“小白”Skill仍位于Git忽略的`data/private-mentor-skills`,未复制到公开目录或证据文件。 + +## 3. API与数据库差分 + +- 原版`8786`和迁移版`8787`使用同一数据库的独立副本。 +- `/api/mentors/setup`和按用户、模型、日期读取消息的API状态码及业务JSON完全一致。 +- 两版均为62个schema对象;`mentor_messages` 28行、`mentor_preferences` 45行、`llm_usage` 72行、 + `system_settings` 1行、`users` 3行,均逐行一致。 +- 接口证据见`api-requests.json`、`api-diff.json`,数据库证据见`database-diff.json`。 +- 差分只在系统临时目录的数据库副本上运行,登录会话和后台任务运行数据未纳入业务表比较;临时副本 + 已在验收后删除,正式数据库未写入测试消息或LLM调用记录。 + +## 4. 真实浏览器检查 + +- 迁移版真实服务载入24个思维模型,管理员可见私有“小白”,公开模型的A/B/C标签、简介和置顶按钮正常。 +- A级筛选显示13个模型;全部、A级、B级、C级、搜索、整理、清空对话、建议问题、输入框和发送按钮均存在。 +- 页面宽度与1280像素视口一致,无横向溢出;浏览器控制台无错误。 +- 浏览器不调用真实外部模型,防止模型容量和网络波动污染迁移结论;流式首段回退、输出后禁止切模、 + 完整快照去重和空响应处理由`test_llm_gateway`、`test_mentor_stream`与`test_llm_stream`覆盖。 +- 截图SHA-256: + - `mentor-all.jpg`:`9cda4d6896da6932b8e9db014eed4882359287d6999e2181af7d945ea6272bd9` + - `mentor-a-filter.jpg`:`5dca958d5edf7ab11767474a3c4405d4cb96029f7a1efb35fa8155c61c863984` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 273项通过 | +| `python -m unittest tests.test_preservation_slice_mentor_llm -q` | 8项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过 | +| `git diff --check` | 通过 | + +- `assistant_agent.py`属于切片09复盘助手,本切片不提前移动。 +- 问天的模型调用属于切片08,本切片只复用统一LLM网关,不移动问天业务。 +- 前端DOM、页面JS、CSS和移动端行为未改动,统一归档延至切片10。 +- 没有删除待定代码、没有修改正式数据库、没有切换Docker/NAS。 diff --git a/docs/migration/evidence/slice-07/api-diff.json b/docs/migration/evidence/slice-07/api-diff.json new file mode 100644 index 0000000..c5a5c19 --- /dev/null +++ b/docs/migration/evidence/slice-07/api-diff.json @@ -0,0 +1,27 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "mentor setup and public skill catalog", + "method": "GET", + "endpoint": "/api/mentors/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "3a5bd83b58e849757664bbfe8cf54e704bc9a7682333deb8297212c488aa3ddc", + "migrated_sha256": "3a5bd83b58e849757664bbfe8cf54e704bc9a7682333deb8297212c488aa3ddc", + "equal": true, + "first_difference": null + }, + { + "name": "mentor messages scoped by user mentor and date", + "method": "GET", + "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-07/api-requests.json b/docs/migration/evidence/slice-07/api-requests.json new file mode 100644 index 0000000..3ad8f8f --- /dev/null +++ b/docs/migration/evidence/slice-07/api-requests.json @@ -0,0 +1,14 @@ +[ + { + "name": "mentor setup and public skill catalog", + "method": "GET", + "endpoint": "/api/mentors/setup?trade_date=2026-07-30", + "payload": null + }, + { + "name": "mentor messages scoped by user mentor and date", + "method": "GET", + "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730", + "payload": null + } +] diff --git a/docs/migration/evidence/slice-07/database-diff.json b/docs/migration/evidence/slice-07/database-diff.json new file mode 100644 index 0000000..daa2cb3 --- /dev/null +++ b/docs/migration/evidence/slice-07/database-diff.json @@ -0,0 +1,51 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "mentor_messages", + "original_count": 28, + "migrated_count": 28, + "original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "equal": true + }, + { + "table": "mentor_preferences", + "original_count": 45, + "migrated_count": 45, + "original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "equal": true + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true + }, + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-07/mentor-a-filter.jpg b/docs/migration/evidence/slice-07/mentor-a-filter.jpg new file mode 100644 index 0000000..fd1d83a Binary files /dev/null and b/docs/migration/evidence/slice-07/mentor-a-filter.jpg differ diff --git a/docs/migration/evidence/slice-07/mentor-all.jpg b/docs/migration/evidence/slice-07/mentor-all.jpg new file mode 100644 index 0000000..4f85d30 Binary files /dev/null and b/docs/migration/evidence/slice-07/mentor-all.jpg differ diff --git a/docs/migration/evidence/slice-08/README.md b/docs/migration/evidence/slice-08/README.md new file mode 100644 index 0000000..cbfe325 --- /dev/null +++ b/docs/migration/evidence/slice-08/README.md @@ -0,0 +1,69 @@ +# 切片 08:问天、观势、观气与观心 + +> 基线:`2919229`(切片 07) +> 回档标签:`xiaobai-preservation-slice-08-20260731` +> 结论:源码、API、数据库、真实页面、动画与全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动原版问天 Agent、历法/卦象引擎、服务、持久化与三个 HTTP 入口,没有从 +`next/`取用代码,也没有改写六爻、安全门、五运六气、个人合参、铜钱起卦、历史去重或 +LLM 解读逻辑。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/heaven_agent.py` | `app/backend/features/heaven/agent.py` | 根级模块指向同一模块对象 | +| `app/heaven_engine.py` | `app/backend/features/heaven/engine.py` | 根级模块指向同一模块对象 | +| `DashboardService`问天方法 | `app/backend/features/heaven/service.py` | `HeavenServiceMixin` | +| 问天历史持久化 | `app/backend/features/heaven/repository.py` | `HeavenRepositoryMixin` | +| 三个问天 POST 入口 | `app/backend/features/heaven/http.py` | `HeavenHttpMixin` | + +历法引擎搬迁后仍从`app/vendor`及`app/data/iching_zh.json`读取原资源;这是唯一资源路径适配, +顶层函数与类定义未变。旧测试依赖的根级模块继续有效。 + +## 2. 源码等价 + +- `heaven_agent.py`与原版文件 SHA-256 一致。 +- `heaven_engine.py`所有顶层函数和类与原版无位置信息 AST 一致。 +- 20个问天服务方法中19个与原版 AST 一致;`_heaven_reading_identity`仅将旧巨型类名 + `DashboardService`替换为已归位的`MarketServiceMixin`静态日期格式化方法。 +- 5个问天 Repository 方法与原版 AST 一致。 +- 三个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`,状态码、字段和异常语义不变。 +- `DashboardService`、`ReviewDatabase`与`RequestHandler`不再重复保留已迁移实现。 + +## 3. API 与数据库差分 + +- 原版`8788`和迁移版`8789`使用同一正式数据库的两个临时副本。 +- 固定日期的问天初始化、三类历史读取、六爻成卦和个人五行计算共6个接口,状态码与业务 JSON 完全一致。 +- 未调用真实`/api/heaven/interpret`,避免外部模型波动及测试记录写入;提示词和调用链由源码等价与单元测试覆盖。 +- 两版均为62个 schema 对象;`heaven_readings`、`users`、`system_settings`、`llm_usage`、 + `user_birth_profiles`和`sector_phase_overrides`逐行一致。 +- 差分只在系统临时目录副本运行,正式数据库未写入问天记录或 LLM 用量。 + +## 4. 真实浏览器与动画检查 + +- 1920×1080视口逐页检查观势、观气、观心,无横向溢出,页面滚动边界可用。 +- 观势保留星空、三才六爻与八卦待载入场景;观气生成100个星点,五运六气环动画名为`wt-spin`, + 五行行业默认折叠;观心呼吸波纹、香火燃烧和阶段文字均持续运行。 +- 夜间模式问天背景为`rgb(11, 17, 32)`,全页无控制台错误。 +- 没有触发真实解势、解运或解卦调用,加载动画的静态资产及脚本未改动。 +- 截图 SHA-256: + - `heaven-trend-1080.png`:`9e70d9cbfd741e74d3c4511c0e76ef8e625a6a31fd5d6bde014103f807bcc76e` + - `heaven-fortune-1080.png`:`e4cdf0e0fc1d9325724654a781febed1d509e49ddccecb3355803b5e9f1d2f5c` + - `heaven-heart-breathing-1080.png`:`3493a59b9032c1a8a1ba7da609b499c736170d2c7947b3aa0ac4c8b95d981ff5` + - `heaven-heart-dark-1080.png`:`3d346fea6b593fa4e43037f5fc68eda1a3f3446a223c2022d9084922f646a088` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 280项通过 | +| `python -m unittest tests.test_preservation_slice_heaven -q` | 7项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过(2.2分钟) | +| `git diff --check` | 通过 | + +- 复盘助手、交易日志、自选、笔记与提醒属于切片09,本切片不提前移动。 +- 前端 DOM、页面 JS、CSS和移动端行为未改动,统一归档延至切片10。 +- `static/heaven-loading.js`是否失效仍不确定,继续保留到切片11试删。 +- 没有删除待定代码、没有修改正式数据库、没有测试或切换 Docker/NAS。 diff --git a/docs/migration/evidence/slice-08/api-diff.json b/docs/migration/evidence/slice-08/api-diff.json new file mode 100644 index 0000000..bfd0941 --- /dev/null +++ b/docs/migration/evidence/slice-08/api-diff.json @@ -0,0 +1,71 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "heaven setup awaiting stock selection", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-29", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92", + "migrated_sha256": "b72f2e972c3993717d639d1ccc68a9f1fb716a5846823a485a05458103465b92", + "equal": true, + "first_difference": null + }, + { + "name": "trend interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "equal": true, + "first_difference": null + }, + { + "name": "fortune interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=fortune&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0", + "migrated_sha256": "e60debaad54c58ad34fc7f025bfeaba7d2349aee2531b3f507c6e1968dbdcbf0", + "equal": true, + "first_difference": null + }, + { + "name": "heart interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=heart&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072", + "migrated_sha256": "1fe13531909c83c645b4fbb6740f46f2590adb346ab6e43414023967bb51b072", + "equal": true, + "first_difference": null + }, + { + "name": "six-line hexagram calculation", + "method": "POST", + "endpoint": "/api/heaven/hexagram", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960", + "migrated_sha256": "9773a9f288ea5fbe063b778fc47c613972fd7747f8fc6f80333316f33923c960", + "equal": true, + "first_difference": null + }, + { + "name": "personal five-phase calculation", + "method": "POST", + "endpoint": "/api/heaven/personal", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e", + "migrated_sha256": "3b179ea4e3e0b43d12e929b4057a51264772fadcf41e5c87573dc5d18873bc9e", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-08/api-requests.json b/docs/migration/evidence/slice-08/api-requests.json new file mode 100644 index 0000000..9e94377 --- /dev/null +++ b/docs/migration/evidence/slice-08/api-requests.json @@ -0,0 +1,42 @@ +[ + { + "name": "heaven setup awaiting stock selection", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-29", + "payload": null + }, + { + "name": "trend interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "payload": null + }, + { + "name": "fortune interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=fortune&limit=20", + "payload": null + }, + { + "name": "heart interpretation history", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=heart&limit=20", + "payload": null + }, + { + "name": "six-line hexagram calculation", + "method": "POST", + "endpoint": "/api/heaven/hexagram", + "payload": { + "lines": [7, 8, 9, 6, 7, 8] + } + }, + { + "name": "personal five-phase calculation", + "method": "POST", + "endpoint": "/api/heaven/personal", + "payload": { + "trade_date": "2026-07-29" + } + } +] diff --git a/docs/migration/evidence/slice-08/database-diff.json b/docs/migration/evidence/slice-08/database-diff.json new file mode 100644 index 0000000..6258c71 --- /dev/null +++ b/docs/migration/evidence/slice-08/database-diff.json @@ -0,0 +1,59 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "heaven_readings", + "original_count": 31, + "migrated_count": 31, + "original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "equal": true + }, + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true + }, + { + "table": "user_birth_profiles", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "equal": true + }, + { + "table": "sector_phase_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-08/heaven-fortune-1080.png b/docs/migration/evidence/slice-08/heaven-fortune-1080.png new file mode 100644 index 0000000..62ad91c Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-fortune-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png b/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png new file mode 100644 index 0000000..66f4295 Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-heart-breathing-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png b/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png new file mode 100644 index 0000000..5057971 Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-heart-dark-1080.png differ diff --git a/docs/migration/evidence/slice-08/heaven-trend-1080.png b/docs/migration/evidence/slice-08/heaven-trend-1080.png new file mode 100644 index 0000000..8ba600e Binary files /dev/null and b/docs/migration/evidence/slice-08/heaven-trend-1080.png differ diff --git a/docs/migration/evidence/slice-09/README.md b/docs/migration/evidence/slice-09/README.md new file mode 100644 index 0000000..dfa3a95 --- /dev/null +++ b/docs/migration/evidence/slice-09/README.md @@ -0,0 +1,60 @@ +# 切片 09:我的复盘、自选、笔记、交易日志、提醒与复盘助手 + +> 基线:`b3df070`(切片 08) +> 回档标签:`xiaobai-preservation-slice-09-20260731` +> 结论:源码、API、数据库、账户隔离、真实页面及全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只机械移动原版复盘、提醒和复盘助手实现,没有从`next/`取用代码,也没有修改自选、笔记、交易日志、提醒、会员灰化、LLM 上下文或流式协议。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `assistant_agent.py` | `app/backend/features/review/agent.py` | 根级模块指向同一模块对象 | +| `DashboardService`提醒方法 | `app/backend/features/alerts/facade.py` | `AlertServiceMixin` | +| `DashboardService`复盘方法 | `app/backend/features/review/service.py` | `ReviewServiceMixin` | +| `ReviewDatabase`提醒方法 | `app/backend/features/alerts/repository.py` | `AlertRepositoryMixin` | +| `ReviewDatabase`复盘方法 | `app/backend/features/review/repository.py` | `ReviewRepositoryMixin` | +| 五个 POST/流式入口 | `app/backend/features/alerts/http.py`、`review/http.py` | HTTP Mixin | + +原版已经存在的`AlertService`、`TradeJournalService`、Repository Port 与 SQLite Adapter 继续保留为唯一底层实现。本切片没有制造第二套提醒或交易日志服务。 + +## 2. 源码等价与必要适配 + +- `assistant_agent.py`与正式 Agent 文件 SHA-256 一致,根级兼容模块与正式 Agent 是同一模块对象。 +- 5 个提醒编排方法、8 个复盘编排方法、6 个提醒 Repository 方法和 13 个复盘 Repository 方法均与原版无位置信息 AST 一致。 +- 五个 HTTP 方法只把全局`SERVICE`改为 Mixin 的`self.application_service`;状态码、字段、异常和流式事件写法不变。 +- `backend.features.alerts`和`backend.features.review`仅对旧底层服务采用延迟导出,避免`database -> feature package -> sqlite adapter -> database`初始化循环;外部`from ... import AlertService/TradeJournalService`接口不变。 +- 公共`RequestHandler._write_stream_event`继续保留,因为问师和复盘助手共同使用,且现有测试直接覆盖;统一传输层留到切片 10。 + +## 3. API 与数据库差分 + +- 原版`8790`和迁移版`8791`使用正式数据库的两个临时副本,未写正式数据库。 +- 固定日期下自选、全部复盘笔记、交易日志、提醒中心及复盘助手历史共 5 个鉴权接口,状态码与业务 JSON 完全一致。 +- 两版均为 62 个 schema 对象;`watchlist`、`review_notes`、`trade_entries`、`alerts`、`assistant_messages`、`users`、`system_settings`和`llm_usage`逐行一致。 +- 真实浏览器在迁移版临时副本中新增一条交易记录,弹窗正确关闭并显示短提示“交易记录已保存”;没有写正式数据库。 +- 未调用真实`/api/assistant/chat`,避免外部模型波动和 LLM 用量写入;Agent、流式累积与权限路径由源码等价和单元测试覆盖。 + +## 4. 真实浏览器检查 + +- 1920×1080 检查我的复盘完整布局,自选 6 只、交易日志、每日复盘三个独立输入框和最近复盘均可访问,无横向溢出。 +- 添加自选与交易日志弹窗居中可用;交易日志保存后没有出现超长空弹窗。 +- 提醒中心正常展示筛选、新建提醒和提醒记录。 +- 管理员会员账号显示完整可用的复盘助手;临时普通账号显示相同界面、顶部“复盘助手仅对会员开放”,下方快捷问题、输入框和发送按钮均灰化禁用。 +- 临时普通账号自选和交易日志均为空,证明用户私有数据没有串到管理员账号。 +- 夜间模式背景为`rgb(18, 20, 22)`,1920×1080 页面无横向溢出,控制台无错误。 +- 截图`review-workspace-night-1920x1080.png` SHA-256:`bc803305a391930a0aafd68062bab6de86eca14612799eebfda220cf2ad0798e`。 + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231 项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 287 项通过 | +| `python -m unittest tests.test_preservation_slice_review_alerts -q` | 7 项通过 | +| `npx.cmd playwright test --reporter=dot` | 45 项通过(2.2 分钟) | +| `git diff --check` | 通过 | + +- 前端 DOM、页面 JS、CSS 与移动端行为未改动,统一归位延至切片 10。 +- `demo_data.py`、`static/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片 11 试删。 +- 没有修改`next/`、正式数据库、Docker/NAS 或`8765`服务。 diff --git a/docs/migration/evidence/slice-09/api-diff.json b/docs/migration/evidence/slice-09/api-diff.json new file mode 100644 index 0000000..6b54980 --- /dev/null +++ b/docs/migration/evidence/slice-09/api-diff.json @@ -0,0 +1,60 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "review watchlist", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "equal": true, + "first_difference": null + }, + { + "name": "all review notes", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "equal": true, + "first_difference": null + }, + { + "name": "trade journal through fixed date", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "equal": true, + "first_difference": null + }, + { + "name": "alert center through fixed date", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4", + "migrated_sha256": "8807772f2b611ea8b95033aa19cae84f08b08e92d76fff8e3cd5b79966ea04f4", + "equal": true, + "first_difference": null + }, + { + "name": "review assistant history", + "method": "GET", + "endpoint": "/api/assistant/messages", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-09/api-requests.json b/docs/migration/evidence/slice-09/api-requests.json new file mode 100644 index 0000000..4bc736b --- /dev/null +++ b/docs/migration/evidence/slice-09/api-requests.json @@ -0,0 +1,32 @@ +[ + { + "name": "review watchlist", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "payload": null + }, + { + "name": "all review notes", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "payload": null + }, + { + "name": "trade journal through fixed date", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "payload": null + }, + { + "name": "alert center through fixed date", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "payload": null + }, + { + "name": "review assistant history", + "method": "GET", + "endpoint": "/api/assistant/messages", + "payload": null + } +] diff --git a/docs/migration/evidence/slice-09/database-diff.json b/docs/migration/evidence/slice-09/database-diff.json new file mode 100644 index 0000000..bc921c6 --- /dev/null +++ b/docs/migration/evidence/slice-09/database-diff.json @@ -0,0 +1,75 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "watchlist", + "original_count": 6, + "migrated_count": 6, + "original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "equal": true + }, + { + "table": "review_notes", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "equal": true + }, + { + "table": "trade_entries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "alerts", + "original_count": 2, + "migrated_count": 2, + "original_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88", + "migrated_sha256": "37b46fb4a754cf9c6179b1be88eed7f5a5398ddfc87160f41ded622ffc313c88", + "equal": true + }, + { + "table": "assistant_messages", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true + }, + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png b/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png new file mode 100644 index 0000000..f0f8acd Binary files /dev/null and b/docs/migration/evidence/slice-09/review-workspace-night-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/README.md b/docs/migration/evidence/slice-10/README.md new file mode 100644 index 0000000..38d60cc --- /dev/null +++ b/docs/migration/evidence/slice-10/README.md @@ -0,0 +1,70 @@ +# 切片 10:前端 Shell、页面、样式与移动端职责归位 + +> 基线:`38de3de`(切片 09) +> 回档标签:`xiaobai-preservation-slice-10-20260731` +> 结论:源码、静态资产、API、数据库、桌面/移动页面及动画差分通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只移动和拆分原版前端源码,没有从`next/`取用代码,也没有重写页面、样式、动画、交互或响应式规则。 + +| 原位置 | 新位置 | 处理方式 | +|---|---|---| +| `static/index.html` | `app/frontend/index.html` | 原 DOM 骨架,仅调整静态资源路径和拆分脚本加载顺序 | +| `static/app.js` | `app/frontend/app.js`、`pages/*/page.js`、`pages/market/runtime.js`、`shared/export.js` | 按连续源码行机械拆分 | +| `static/styles.css`等公共样式 | `app/frontend/styles/` | 字节级移动 | +| `static/wentian-v2.css` | `app/frontend/pages/heaven/page.css` | 字节级移动 | +| `static/ui-core.js` | `app/frontend/shared/ui-core.js` | 字节级移动 | +| `static/heaven-loading-v2.js` | `app/frontend/pages/heaven/loading-v2.js` | 字节级移动 | +| `static/pages/`、`shared/`、`vendor/` | `app/frontend/`同职责目录 | 字节级移动 | + +`app/backend/bootstrap/config.py`现在只把静态根目录从`app/static/`切换到`app/frontend/`。原`app/static/`已不再存在,避免形成两套可被误改的前端实现。 + +## 2. 源码与静态资产等价 + +- 原`static/app.js`共 9,283 行,SHA-256 为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`。 +- 所有拆分文件中的保真源码片段按原行号重组后,SHA-256仍为同一值,9,283行无遗漏、无重排、无内容变化。 +- 七层原版样式、问天样式、加载动画、共享脚本、页面注册脚本及Lucide供应商文件在移动后保持字节一致。 +- `index.html`的差异仅限资源新路径和两个机械拆分脚本入口;反向替换后与原版逐字符一致。 +- 浏览器请求仍只有`shared/api.js`一个`fetch`出口。 +- 完整行号映射见`frontend-source-map.json`。该映射继续作为切片10的历史证据;一次性拆分工具在人工验收和当前所有者契约建立后已退役,不再进入维护工具链。 + +## 3. API 与数据库差分 + +- 同一管理员账号和同一固定日期下,对原版与迁移版执行21个全站只读请求。 +- 请求覆盖账号、行情、情绪、轮动、竞价、题材、人气、龙虎榜、选股、问师、问天、复盘与提醒;状态码和规范化业务JSON全部一致。 +- 两个数据库副本均包含62个schema对象,schema哈希一致。 +- 21张关键表的记录数、顺序和规范化内容逐行一致;`screener_strategies`仅排除每次初始化可能更新的`updated_at`。 +- 完整结果见`api-requests.json`、`api-diff.json`和`database-diff.json`,两份差分文件的`all_equal`均为`true`。 + +## 4. 真实浏览器差分 + +- 1920×1080日间模式检查情绪周期、智能选股、观势、观气、观心介绍及观心呼吸。 +- 1920×1080夜间模式检查情绪周期的背景、字体、表格、几何和横向溢出。 +- 390×844检查情绪周期与观势:移动Shell、底部五入口、页面纵向滚动、无横向溢出及问天特效均一致。 +- 观势星空节点90个、观气100个、观心110个;三页八卦节点均为2个。 +- 观势与观气保留`wt-tw`和`wt-spin`动画;观心呼吸保留`heart-incense-burn`、`heart-incense-glow`及动态波纹。 +- 观气五行行业为5组且默认折叠;观心介绍到呼吸流程可正常进入。 +- 原版和迁移版检查流程均未产生新增控制台error或warn。 +- 动画帧、焦点框和动态状态文字属于采样瞬时状态,因此截图文件哈希不要求相同;可见布局几何、计算样式、节点、动画名称和交互结果必须一致,本次均通过。 +- 两个服务使用相同主机名、不同端口时会共享并覆盖登录Cookie;曾导致迁移版切页被误判为失效。逐服务重新登录后行为一致,该问题属于并行验收环境限制,不是产品回归。 +- 2026-08-01像素复核发现`frontend-migrated-auction-light-1920x1080`实际截取了登录状态失效页, + 不能证明集合竞价视觉等价,文件已重命名为`INVALID-login-session`。集合竞价仍有切片05真实页面、 + 本切片源码/样式保真、API及Playwright证据,但最终视觉明确留待人工验收,不以替代证据冒充 + 本切片截图差分。 +- 机器可读记录见`browser-acceptance.json`,截图均保存在本目录。 + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 前端专项测试 | 85项通过 | +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 294项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过(2.3分钟) | +| 全部拆分后JavaScript执行`node --check` | 通过 | +| `git diff --check` | 通过 | + +- `demo_data.py`、`frontend/heaven-loading.js`、五个疑似无引用前端函数和`wencai_saved_queries`继续保留到切片11逐项审计。 +- 没有修改`next/`、正式数据库、Docker/NAS或`8765`服务。 +- 自动差分只能证明已检查范围一致;依据迁移总纲,未经用户最终人工确认不得宣称全站视觉已经完全等价。 diff --git a/docs/migration/evidence/slice-10/api-diff.json b/docs/migration/evidence/slice-10/api-diff.json new file mode 100644 index 0000000..aee58c5 --- /dev/null +++ b/docs/migration/evidence/slice-10/api-diff.json @@ -0,0 +1,236 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "当前账号", + "method": "GET", + "endpoint": "/api/auth/me", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "migrated_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "equal": true, + "first_difference": null + }, + { + "name": "账号状态", + "method": "GET", + "endpoint": "/api/account/status", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05", + "migrated_sha256": "a1f81967a58dae0947191fe1a7465c87e70403339e36897329de94c541272e05", + "equal": true, + "first_difference": null + }, + { + "name": "全市场总览", + "method": "GET", + "endpoint": "/api/dashboard?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "migrated_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "equal": true, + "first_difference": null + }, + { + "name": "情绪周期历史", + "method": "GET", + "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "migrated_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "equal": true, + "first_difference": null + }, + { + "name": "板块轮动历史", + "method": "GET", + "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "migrated_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "equal": true, + "first_difference": null + }, + { + "name": "集合竞价", + "method": "GET", + "endpoint": "/api/auction?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "migrated_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "equal": true, + "first_difference": null + }, + { + "name": "题材库", + "method": "GET", + "endpoint": "/api/themes?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "migrated_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "equal": true, + "first_difference": null + }, + { + "name": "人气热榜", + "method": "GET", + "endpoint": "/api/popularity?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "migrated_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "equal": true, + "first_difference": null + }, + { + "name": "龙虎榜", + "method": "GET", + "endpoint": "/api/dragon-tiger?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6", + "migrated_sha256": "173baf4e1f0b56678aef3f74276f29af49271ea34c89c0c18283bfe27f5cf2a6", + "equal": true, + "first_difference": null + }, + { + "name": "游资档案", + "method": "GET", + "endpoint": "/api/dragon-tiger/profiles", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "equal": true, + "first_difference": null + }, + { + "name": "智能选股工作区", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "equal": true, + "first_difference": null + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "equal": true, + "first_difference": null + }, + { + "name": "问师模型库", + "method": "GET", + "endpoint": "/api/mentors/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "migrated_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "equal": true, + "first_difference": null + }, + { + "name": "问师历史", + "method": "GET", + "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + }, + { + "name": "问天初始化", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "migrated_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "equal": true, + "first_difference": null + }, + { + "name": "问天历史", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "equal": true, + "first_difference": null + }, + { + "name": "自选追踪", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "equal": true, + "first_difference": null + }, + { + "name": "全部复盘笔记", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "equal": true, + "first_difference": null + }, + { + "name": "交易日志", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "equal": true, + "first_difference": null + }, + { + "name": "提醒中心", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95", + "migrated_sha256": "495a1f83ed661913807899e0955ef1b89193c30bc9a5be0587f7f03d60411d95", + "equal": true, + "first_difference": null + }, + { + "name": "复盘助手历史", + "method": "GET", + "endpoint": "/api/assistant/messages", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-10/api-requests.json b/docs/migration/evidence/slice-10/api-requests.json new file mode 100644 index 0000000..dcf28c1 --- /dev/null +++ b/docs/migration/evidence/slice-10/api-requests.json @@ -0,0 +1,29 @@ +[ + {"name": "当前账号", "method": "GET", "endpoint": "/api/auth/me", "exclude_paths": ["$.csrf_token"]}, + {"name": "账号状态", "method": "GET", "endpoint": "/api/account/status"}, + {"name": "全市场总览", "method": "GET", "endpoint": "/api/dashboard?trade_date=2026-07-30"}, + {"name": "情绪周期历史", "method": "GET", "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9"}, + {"name": "板块轮动历史", "method": "GET", "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9"}, + {"name": "集合竞价", "method": "GET", "endpoint": "/api/auction?trade_date=2026-07-30", "exclude_paths": ["$.meta.updated_at"]}, + {"name": "题材库", "method": "GET", "endpoint": "/api/themes?trade_date=2026-07-30"}, + {"name": "人气热榜", "method": "GET", "endpoint": "/api/popularity?trade_date=2026-07-30"}, + {"name": "龙虎榜", "method": "GET", "endpoint": "/api/dragon-tiger?trade_date=2026-07-30"}, + {"name": "游资档案", "method": "GET", "endpoint": "/api/dragon-tiger/profiles"}, + { + "name": "智能选股工作区", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "sort_lists": {"$.strategies": "id"}, + "exclude_paths": ["$.strategies[].updated_at"] + }, + {"name": "策略持续跟踪", "method": "GET", "endpoint": "/api/screener/tracking?limit=12"}, + {"name": "问师模型库", "method": "GET", "endpoint": "/api/mentors/setup?trade_date=2026-07-30"}, + {"name": "问师历史", "method": "GET", "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730"}, + {"name": "问天初始化", "method": "GET", "endpoint": "/api/heaven/setup?trade_date=2026-07-30"}, + {"name": "问天历史", "method": "GET", "endpoint": "/api/heaven/readings?mode=trend&limit=20"}, + {"name": "自选追踪", "method": "GET", "endpoint": "/api/watchlist?trade_date=2026-07-30"}, + {"name": "全部复盘笔记", "method": "GET", "endpoint": "/api/notes?scope=all"}, + {"name": "交易日志", "method": "GET", "endpoint": "/api/trades?end_date=2026-07-30"}, + {"name": "提醒中心", "method": "GET", "endpoint": "/api/alerts?status=all&as_of=2026-07-30"}, + {"name": "复盘助手历史", "method": "GET", "endpoint": "/api/assistant/messages"} +] diff --git a/docs/migration/evidence/slice-10/browser-acceptance.json b/docs/migration/evidence/slice-10/browser-acceptance.json new file mode 100644 index 0000000..bfc6a6c --- /dev/null +++ b/docs/migration/evidence/slice-10/browser-acceptance.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "tested_at": "2026-07-31T12:00:00+08:00", + "result": "passed_with_documented_evidence_gap", + "environments": { + "original": "temporary_original_runtime", + "migrated": "temporary_app_runtime", + "account": "administrator", + "database": "isolated_copies" + }, + "viewports": [ + { + "width": 1920, + "height": 1080, + "theme": "light", + "views": [ + "sentiment", + "screener", + "heaven_trend", + "heaven_fortune", + "heaven_heart_intro", + "heaven_heart_breath" + ], + "equal": true + }, + { + "width": 1920, + "height": 1080, + "theme": "dark", + "views": [ + "sentiment" + ], + "equal": true + }, + { + "width": 390, + "height": 844, + "theme": "light", + "views": [ + "sentiment", + "heaven_trend" + ], + "equal": true + } + ], + "invalid_captures": [ + { + "view": "auction", + "file": "frontend-migrated-auction-light-1920x1080.INVALID-login-session.png", + "reason": "The migrated capture shows an expired login session and is not visual-equivalence evidence.", + "replacement_claim": "No replacement screenshot claim; final visual acceptance remains manual." + } + ], + "heaven_animation_contract": { + "trend_star_nodes": 90, + "fortune_star_nodes": 100, + "heart_star_nodes": 110, + "bagua_nodes_per_view": 2, + "trend_and_fortune_animations": [ + "wt-tw", + "wt-spin" + ], + "heart_breath_animations": [ + "heart-incense-burn", + "heart-incense-glow" + ], + "fortune_industry_groups": 5, + "fortune_industries_collapsed_by_default": true, + "equal": true + }, + "mobile_contract": { + "primary_navigation_items": 5, + "horizontal_overflow": false, + "vertical_page_scroll": true, + "heaven_trend_geometry": { + "active": { + "height": 723, + "width": 374, + "x": 8, + "y": 50 + }, + "header": { + "width": 390, + "height": 50 + }, + "bottom_navigation": { + "width": 390, + "height": 58 + } + }, + "equal": true + }, + "console": { + "new_errors": 0, + "new_warnings": 0 + }, + "screenshot_policy": "Dynamic animation frames, focus outlines, and live status text may change pixel hashes. Acceptance compares visible geometry, computed styles, DOM state, animation names, interaction results, and overflow behavior.", + "known_test_environment_constraint": "Original and migrated services on the same hostname share cookies across ports. Each service must be logged in separately immediately before comparison.", + "all_equal": true, + "manual_acceptance_required": true +} diff --git a/docs/migration/evidence/slice-10/database-diff.json b/docs/migration/evidence/slice-10/database-diff.json new file mode 100644 index 0000000..e32d16a --- /dev/null +++ b/docs/migration/evidence/slice-10/database-diff.json @@ -0,0 +1,202 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true, + "excluded_columns": [] + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true, + "excluded_columns": [] + }, + { + "table": "watchlist", + "original_count": 6, + "migrated_count": 6, + "original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "equal": true, + "excluded_columns": [] + }, + { + "table": "review_notes", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "equal": true, + "excluded_columns": [] + }, + { + "table": "trade_entries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "alerts", + "original_count": 2, + "migrated_count": 2, + "original_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553", + "migrated_sha256": "40b1ee5678b32f29ae356c1b30958e66138d34afd2aa6b8364fa02c23df66553", + "equal": true, + "excluded_columns": [] + }, + { + "table": "assistant_messages", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_messages", + "original_count": 28, + "migrated_count": 28, + "original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_preferences", + "original_count": 45, + "migrated_count": 45, + "original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "equal": true, + "excluded_columns": [] + }, + { + "table": "heaven_readings", + "original_count": 31, + "migrated_count": 31, + "original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "equal": true, + "excluded_columns": [] + }, + { + "table": "user_birth_profiles", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "equal": true, + "excluded_columns": [] + }, + { + "table": "sector_phase_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "screener_strategies", + "original_count": 36, + "migrated_count": 36, + "original_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "migrated_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "equal": true, + "excluded_columns": [ + "updated_at" + ] + }, + { + "table": "screener_runs", + "original_count": 250, + "migrated_count": 250, + "original_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "migrated_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "strategy_tracks", + "original_count": 16, + "migrated_count": 16, + "original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "equal": true, + "excluded_columns": [] + }, + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true, + "excluded_columns": [] + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true, + "excluded_columns": [] + }, + { + "table": "seat_aliases", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "reason_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true, + "excluded_columns": [] + }, + { + "table": "wencai_saved_queries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + } + ] +} diff --git a/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png b/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png new file mode 100644 index 0000000..e35ebf7 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-auction-light-1920x1080.INVALID-login-session.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png new file mode 100644 index 0000000..8f7e22f Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png new file mode 100644 index 0000000..f0c8a84 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-dark-sentiment-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png new file mode 100644 index 0000000..11fddec Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-fortune-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png new file mode 100644 index 0000000..ce8edb4 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-breath-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png new file mode 100644 index 0000000..1d57982 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-heart-intro-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png new file mode 100644 index 0000000..81581d5 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png new file mode 100644 index 0000000..45fdc51 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-heaven-trend-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png new file mode 100644 index 0000000..438b6ac Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png new file mode 100644 index 0000000..e4426f3 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-migrated-screener-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png new file mode 100644 index 0000000..c1b7ead Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-auction-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png new file mode 100644 index 0000000..49884f9 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png new file mode 100644 index 0000000..8d89b46 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-dark-sentiment-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png new file mode 100644 index 0000000..af8b182 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-fortune-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png new file mode 100644 index 0000000..0af84ce Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-breath-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png new file mode 100644 index 0000000..16a753d Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-heart-intro-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png new file mode 100644 index 0000000..c2df7bc Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-390x844.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png new file mode 100644 index 0000000..769014e Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-heaven-trend-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png new file mode 100644 index 0000000..d79a397 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png b/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png new file mode 100644 index 0000000..738fcd1 Binary files /dev/null and b/docs/migration/evidence/slice-10/frontend-original-screener-light-1920x1080.png differ diff --git a/docs/migration/evidence/slice-10/frontend-source-map.json b/docs/migration/evidence/slice-10/frontend-source-map.json new file mode 100644 index 0000000..262782e --- /dev/null +++ b/docs/migration/evidence/slice-10/frontend-source-map.json @@ -0,0 +1,190 @@ +{ + "source": "static/app.js", + "source_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6", + "source_line_count": 9283, + "reassembled_sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6", + "all_source_lines_preserved": true, + "core": { + "target": "app/frontend/app.js", + "ranges": [ + { + "start": 1, + "end": 1206 + }, + { + "start": 3471, + "end": 3489 + }, + { + "start": 8427, + "end": 8904 + }, + { + "start": 9052, + "end": 9283 + } + ] + }, + "modules": [ + { + "target": "app/frontend/pages/sentiment/page.js", + "ranges": [ + { + "start": 1207, + "end": 1516, + "sha256": "70a884362b3f97ceb28e2f3a83a70ce26e61c79e23bfeee62b301b9068f2b69c" + } + ] + }, + { + "target": "app/frontend/pages/pools/page.js", + "ranges": [ + { + "start": 1517, + "end": 1915, + "sha256": "ff8623609c1ef6eb793deaef2b627746727a4768962711d1712e35833b4f58c3" + } + ] + }, + { + "target": "app/frontend/pages/market/runtime.js", + "ranges": [ + { + "start": 1916, + "end": 1957, + "sha256": "703dafd0417926df8572a885ffa32a79ee16346019f0e14fcee9e03ad1f1baf7" + }, + { + "start": 6893, + "end": 7719, + "sha256": "a670986ac2099a181f5f348cd48bc4777da0d4709b3ac79239067ff5666261fb" + }, + { + "start": 7969, + "end": 8426, + "sha256": "467409c2cfc06ee96bb7f422be55f79920c3a9936229b2234dd8f78b537e7f26" + } + ] + }, + { + "target": "app/frontend/pages/rotation/page.js", + "ranges": [ + { + "start": 1958, + "end": 2124, + "sha256": "33f536213db33be0934527f13527712a9d521b5f89a5784b42887c5bf8631f2f" + } + ] + }, + { + "target": "app/frontend/pages/ladder/page.js", + "ranges": [ + { + "start": 2125, + "end": 2216, + "sha256": "2be84071921910acbec2328f1f10dd8565420ea03a1930ff255040bf99d679a8" + } + ] + }, + { + "target": "app/frontend/pages/auction/page.js", + "ranges": [ + { + "start": 2217, + "end": 2481, + "sha256": "7f16901ae8769b9570693211022538feb02f2cda4bffe6fa95ba6ce60c10b012" + } + ] + }, + { + "target": "app/frontend/pages/themes/page.js", + "ranges": [ + { + "start": 2482, + "end": 2583, + "sha256": "c134d762bda669483f0c094efae9281919b68b2ffb83c9457670bc9013ce1515" + } + ] + }, + { + "target": "app/frontend/pages/popularity/page.js", + "ranges": [ + { + "start": 2584, + "end": 2659, + "sha256": "db72e42385e4a4bd4314ab471b27c1bb804f13650ee463f0843d783b0ba50bff" + } + ] + }, + { + "target": "app/frontend/pages/dragon-tiger/page.js", + "ranges": [ + { + "start": 2660, + "end": 3028, + "sha256": "b33ccad80ce997bddd75e4025eec35aa246fa5e9f5ca9ccd5f17e587885274cd" + } + ] + }, + { + "target": "app/frontend/pages/review/page.js", + "ranges": [ + { + "start": 3029, + "end": 3470, + "sha256": "10dba1966b32941cb28ac46d1cb1b20ae74ea271fd7be22a23b46d290ece80ef" + }, + { + "start": 7720, + "end": 7968, + "sha256": "3af64079f997fabbf0ff4023088eb62f94c0091a823bd6d65c1f9e485d1c5ad8" + } + ] + }, + { + "target": "app/frontend/pages/screener/page.js", + "ranges": [ + { + "start": 3490, + "end": 4336, + "sha256": "76dfdd5610453f0abb5899baba9d751b5f597450ec656bf10580ce91447a68b9" + }, + { + "start": 6668, + "end": 6892, + "sha256": "a7d0fa98b07516169a8e46c2ae2f6efbaeab651887c479919210835f0877533e" + } + ] + }, + { + "target": "app/frontend/pages/mentor/page.js", + "ranges": [ + { + "start": 4337, + "end": 4827, + "sha256": "b2067c062fb6cb539dd6402074d02f1ae65903957106d7ebf8d63bc5487f244c" + } + ] + }, + { + "target": "app/frontend/pages/heaven/page.js", + "ranges": [ + { + "start": 4828, + "end": 6667, + "sha256": "57bd47c239c4ee9302c7cd7a1f86a5d0932df6065148a34bafe0c49bef644cb1" + } + ] + }, + { + "target": "app/frontend/shared/export.js", + "ranges": [ + { + "start": 8905, + "end": 9051, + "sha256": "17b867189087615a4414da4f9db3862306e05cb527c25c794beb9c4c74b583ab" + } + ] + } + ] +} diff --git a/docs/migration/evidence/slice-11/README.md b/docs/migration/evidence/slice-11/README.md new file mode 100644 index 0000000..f4bd0e4 --- /dev/null +++ b/docs/migration/evidence/slice-11/README.md @@ -0,0 +1,108 @@ +# 切片 11:不确定代码审计、全量验收与交接准备 + +> 基线:`dec3cd12365df5e7d4c391369f14c457cf56d7d9` +> 候选回档标签:`xiaobai-preservation-slice-11-candidate-20260731` +> 严格审计候选标签:`xiaobai-preservation-slice-11-audit-candidate-20260731` +> 跨日复验候选标签:`xiaobai-preservation-slice-11-audit-candidate-20260801` +> 独立维护候选标签:`xiaobai-preservation-slice-11-audit-candidate-standalone-20260801` +> 最终完成标签:`xiaobai-preservation-complete-20260801` +> 当前结论:自动与用户人工验收均已完成;本地迁移交付完成,尚未执行正式切换、Docker或NAS部署 + +## 1. 本切片做了什么 + +本切片不增加功能、不调整视觉,也不继续拆分业务代码。它逐项审计切片10保留的待定资产, +把有完整证据的无效实现放入可单独回档的试删候选,并为人工接管和后续切换准备文档。 + +经试删和人工验收后确认废弃: + +- `app/demo_data.py`:没有运行导入、动态注册、数据库或配置责任的旧演示数据构造器。 +- `app/frontend/heaven-loading.js`:页面未加载的旧问天动画;正式入口继续使用 + `frontend/pages/heaven/loading-v2.js`。 +- 五个只有定义、没有消费者的前端函数:`commonReviewColumns`、`outcomeClass`、 + `screenerResultMatchesSelection`、`selectRegime`、`showHeartRitualCurtain`。 + +明确保留: + +- `wencai_saved_queries`表及三个Repository方法。它们承担历史数据库兼容和用户隔离责任, + 当前库为空不能证明其他部署库也为空。 +- 与试删函数相邻但证据不足的DOM、状态字段和CSS。没有为追求行数继续连带删除。 + +逐项指纹、引用扫描、责任判断和恢复位置见`uncertain-code-audit.md`。 + +## 2. 自动验证结果 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 305项通过 | +| 历史切片与前端/清理专项复核 | 通过 | +| 迁移版JavaScript语法检查 | 24个文件通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过(1.6分钟) | +| 固定只读API差分 | 21个端点全部一致 | +| 数据库差分 | 62个schema对象、21张关键表全部一致 | +| `git diff --check` | 通过 | + +2026-08-01 01:19(Asia/Shanghai)跨日复验时,原版与迁移版共用的实时详情测试暴露出 +测试夹具依赖运行日的问题:夹具在周六动态生成“今日”,而业务正确拒绝在非交易日合并实时 +行情。两版测试夹具同步固定到明确的工作日盘中/盘前时点,产品代码与行情日期规则未改。 +修复后统一验收命令再次通过302项测试、24个JavaScript文件、SQLite完整性和45项Playwright +(2.0分钟)。 + +早期切片的五个前端源码测试原本要求与未试删的`static/app.js`逐字符一致。本切片验收时曾通过 +行号回放机制限制未登记改动。完成全站人工验收及前端所有权治理后,该过渡机制已退役;当前由 +运行时注册表、唯一所有者、DOM/API契约、JavaScript语法检查和Playwright行为回归持续守门。 + +完整API和数据库结果分别见`api-diff.json`与`database-diff.json`,二者`all_equal`均为`true`。 + +严格完成审计另外验证了候选维护工具本身:API注册表和候选架构清单均可生成并执行 +`--check`;24个前端脚本全部由统一命令发现;迁移期写入工具必须显式指定输出或`--apply`。 +Windows下Playwright托管Python静态服务器会在45项执行后不退出,统一验证器现改为显式启动、 +探活和停止8876;最终同一命令正常退出并明确报告`45 passed`。四项维护工具契约测试使迁移版 +总数从298增加到302。逐项目标矩阵见`completion-audit.md`。 + +后续独立性审计把仅含`app/`的Git导出放到系统临时目录:运行模块全部解析到导出内部,统一 +维护命令通过242项候选自有测试、两个注册表、24个JavaScript文件和SQLite完整性检查,并明确 +跳过不存在的Git工作区。正式仓库继续执行全部保真差分,最终为305项Python测试和45项 +Playwright通过。架构热点字节数已改为换行归一化后的UTF-8大小,Windows CRLF与Git/Linux LF +不会再制造假差异。 + +## 3. 真实浏览器验收 + +- 桌面端复核情绪周期、智能选股三个工作区、问天日间/夜间和加载资源;控制台无新增 + error或warn,页面无横向溢出。 +- 问天只加载`/pages/heaven/loading-v2.js`,未请求已试删的`/heaven-loading.js`。 +- 观势、观气、观心星空与八卦节点、原动画名称和可见行为保持不变。 +- `390x844`逐一点击涨停池、智能选股、问师、问天、我的复盘五个移动入口;底部导航固定, + 页面均可完整纵向滚动且没有文档级横向溢出。 +- 观势、观气、观心三页均复核;观气滚动到底后“个人合参”和“五行对应行业”可达。 +- 同账号、同数据库快照、同主题和同视口下,原版与迁移版问天布局和计算样式一致。 + +机器可读记录见`browser-acceptance.json`。截图和更广的桌面/移动基线继续沿用切片10证据目录。 +切片10截图的事后像素复核见`screenshot-pixel-audit.json`;九组有效截图平均色差低于0.3/255, +集合竞价候选图因登录状态失效被明确排除,未作为自动截图证据。2026-08-01用户随后在`8797` +真实登录状态下逐页检查全部页面并测试全部功能,确认视觉与功能迁移成功;所见问题几乎都 +属于原版遗留问题,未发现阻止验收的迁移回归。完整人工结论见`manual-acceptance.md`。 + +## 4. 数据与部署边界 + +- API/数据库比较使用两个隔离副本:`slice11-final-original`与`slice11-final-migrated`。 +- 没有写入根目录正式`data/review.db`,没有修改`.env`或私有Skill。 +- 没有占用`8765`,没有执行Docker构建、NAS测试或服务器切换。 +- `app/`已经完成本地迁移验收;原版根目录仍是当前部署基线和切换前回档来源。 +- `next/`保持冻结,没有作为代码、样式、测试或文档来源。 + +## 5. 回档与最终确认 + +2026-08-01用户完成全部页面和功能人工验收,确认迁移在视觉和功能上成功,并确认人工检查中 +发现的问题几乎都属于原版遗留问题。切片11的2个文件和5个无消费者函数据此从“候选试删”改为 +“确认废弃”;恢复标签继续保留作历史兼容应急,不代表删除结论仍待裁决。 + +本地迁移收尾已执行: + +1. 试删候选状态改为“确认废弃”。 +2. 建立最终完成标签并推送Gitea。 +3. `app/`作为后续结构治理的唯一开发目录。 + +正式数据库、原版部署、Docker和NAS仍未切换;切换时间与根目录清理必须由用户另行批准。 + +人工维护、验证、切换和回退步骤见`docs/migration/人工维护与本地切换指南.md`。 diff --git a/docs/migration/evidence/slice-11/api-diff.json b/docs/migration/evidence/slice-11/api-diff.json new file mode 100644 index 0000000..9115b6e --- /dev/null +++ b/docs/migration/evidence/slice-11/api-diff.json @@ -0,0 +1,236 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "当前账号", + "method": "GET", + "endpoint": "/api/auth/me", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "migrated_sha256": "33e0c08463d5f14a142840f894d92985135c6ce2bee22c93c4dde34dc433cf5b", + "equal": true, + "first_difference": null + }, + { + "name": "账号状态", + "method": "GET", + "endpoint": "/api/account/status", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "0ac1b88affa62671f14ba9ea0bd42155952fdc2e03dba3573c232bb452733bc6", + "migrated_sha256": "0ac1b88affa62671f14ba9ea0bd42155952fdc2e03dba3573c232bb452733bc6", + "equal": true, + "first_difference": null + }, + { + "name": "全市场总览", + "method": "GET", + "endpoint": "/api/dashboard?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "migrated_sha256": "565730fb75cec6f6758321074dfeba7bff19db6611476d511f9bc7257a5870f5", + "equal": true, + "first_difference": null + }, + { + "name": "情绪周期历史", + "method": "GET", + "endpoint": "/api/sentiment/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "migrated_sha256": "1a577fb859a2fd06749169e1d0f08f78b51c5217b55506c7903487a956b6192b", + "equal": true, + "first_difference": null + }, + { + "name": "板块轮动历史", + "method": "GET", + "endpoint": "/api/rotation/history?trade_date=2026-07-30&limit=9", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "migrated_sha256": "eaf3ac41f479c0f57f161546f197c1321721b4a8390486a916232fe6ea1d3d37", + "equal": true, + "first_difference": null + }, + { + "name": "集合竞价", + "method": "GET", + "endpoint": "/api/auction?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "migrated_sha256": "bd75b100485c0afa0cdddc73d1880160eea8c0b8c91f556245c665d591285ed6", + "equal": true, + "first_difference": null + }, + { + "name": "题材库", + "method": "GET", + "endpoint": "/api/themes?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "migrated_sha256": "f45e218d9bff5191445fe45b03fc62f3d9c440b8353404ae4b54901c20929552", + "equal": true, + "first_difference": null + }, + { + "name": "人气热榜", + "method": "GET", + "endpoint": "/api/popularity?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "migrated_sha256": "69987a80f02ac02bf9ff49b3a0a99782385f1933c643c9e3dbb5a75a1e20dd29", + "equal": true, + "first_difference": null + }, + { + "name": "龙虎榜", + "method": "GET", + "endpoint": "/api/dragon-tiger?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "1d6936e33aea2bb4258d1d4d007385626d0c638fc290b66271f00649dba59358", + "migrated_sha256": "1d6936e33aea2bb4258d1d4d007385626d0c638fc290b66271f00649dba59358", + "equal": true, + "first_difference": null + }, + { + "name": "游资档案", + "method": "GET", + "endpoint": "/api/dragon-tiger/profiles", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "migrated_sha256": "dfb1e534c018ec12fd8d8ee0e7fe0f234e73dc7715d7f0a948f74ef8d12d779a", + "equal": true, + "first_difference": null + }, + { + "name": "智能选股工作区", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "equal": true, + "first_difference": null + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "equal": true, + "first_difference": null + }, + { + "name": "问师模型库", + "method": "GET", + "endpoint": "/api/mentors/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "migrated_sha256": "0ff7794fe45f49ab980221fa855b64c70d5c9b83c78ef86edc041e713490b626", + "equal": true, + "first_difference": null + }, + { + "name": "问师历史", + "method": "GET", + "endpoint": "/api/mentors/messages?mentor_id=kobe92-perspective&trade_date=20260730", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + }, + { + "name": "问天初始化", + "method": "GET", + "endpoint": "/api/heaven/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "migrated_sha256": "c814c2004c7dd672c397887725af000c150693a50900bb0ce1aa94599af1a3c8", + "equal": true, + "first_difference": null + }, + { + "name": "问天历史", + "method": "GET", + "endpoint": "/api/heaven/readings?mode=trend&limit=20", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "migrated_sha256": "2aa83ea177bd5cb2351ac4f3a60fdcd17e02cfc14b5753e157e515f03fc2c6c1", + "equal": true, + "first_difference": null + }, + { + "name": "自选追踪", + "method": "GET", + "endpoint": "/api/watchlist?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "migrated_sha256": "29c001cc6859312fd90082d0fb83818d263827e28ec21157b94c415b71c45078", + "equal": true, + "first_difference": null + }, + { + "name": "全部复盘笔记", + "method": "GET", + "endpoint": "/api/notes?scope=all", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "migrated_sha256": "a2a1f08610d017de0e46080a1268f72b734eeb80f9cada311d7e03eb1416656a", + "equal": true, + "first_difference": null + }, + { + "name": "交易日志", + "method": "GET", + "endpoint": "/api/trades?end_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "migrated_sha256": "77287b34046e22748e94e61ead841e157f6b63f08de1b2fe61f5d715b2e989a4", + "equal": true, + "first_difference": null + }, + { + "name": "提醒中心", + "method": "GET", + "endpoint": "/api/alerts?status=all&as_of=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "b611e936ada5d547c728e33e811630282d1eecd7c1ab1517e22b34902eda032a", + "migrated_sha256": "b611e936ada5d547c728e33e811630282d1eecd7c1ab1517e22b34902eda032a", + "equal": true, + "first_difference": null + }, + { + "name": "复盘助手历史", + "method": "GET", + "endpoint": "/api/assistant/messages", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "migrated_sha256": "eef46741adfc3a9f76294d3b78f37a45f113092ac9d44ee77c7a038a88ff09a1", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-11/browser-acceptance.json b/docs/migration/evidence/slice-11/browser-acceptance.json new file mode 100644 index 0000000..dbb6858 --- /dev/null +++ b/docs/migration/evidence/slice-11/browser-acceptance.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "tested_at": "2026-07-31T16:10:00+08:00", + "runtime": { + "url": "http://127.0.0.1:8797/", + "root": "app", + "database": "app/data/backups/slice11-final-migrated/review.db", + "production_data_modified": false + }, + "overall": true, + "desktop": { + "checked": true, + "views": [ + "sentimentCycleView", + "screenerView", + "heavenView" + ], + "themes": [ + "light", + "dark" + ], + "console_errors": 0, + "console_warnings": 0, + "horizontal_overflow": false + }, + "mobile": { + "viewport": [ + 390, + 844 + ], + "fixed_navigation": { + "selector": ".module-nav", + "position": "fixed", + "height": 58, + "bottom_offset": 0, + "primary_tab_count": 5 + }, + "views": [ + { + "id": "limitPool", + "document_height": 1607, + "horizontal_overflow": false, + "vertical_content_reachable": true + }, + { + "id": "screenerView", + "document_height": 1555, + "horizontal_overflow": false, + "vertical_content_reachable": true + }, + { + "id": "mentorView", + "document_height": 961, + "horizontal_overflow": false, + "vertical_content_reachable": true + }, + { + "id": "heavenView", + "document_height": 844, + "horizontal_overflow": false, + "vertical_content_reachable": true + }, + { + "id": "reviewWorkspaceView", + "document_height": 1475, + "horizontal_overflow": false, + "vertical_content_reachable": true + } + ], + "heaven_panels": [ + { + "id": "trend", + "document_height": 844, + "vertical_content_reachable": true + }, + { + "id": "fortune", + "document_height": 1730, + "vertical_content_reachable": true, + "bottom_content_checked": "五行对应行业" + }, + { + "id": "heart", + "document_height": 1156, + "vertical_content_reachable": true + } + ] + }, + "preservation_comparison": { + "same_account": true, + "same_database_snapshot": true, + "same_theme": "dark", + "same_mobile_viewport": [ + 390, + 844 + ], + "original_and_migrated_layout_equal": true, + "theme_toggle_icon_note": "原版和迁移版都保留切换后图标需重载才同步的既有行为;相同加载序列下结果一致。" + }, + "manual_acceptance_required": true +} diff --git a/docs/migration/evidence/slice-11/completion-audit.md b/docs/migration/evidence/slice-11/completion-audit.md new file mode 100644 index 0000000..4af53cb --- /dev/null +++ b/docs/migration/evidence/slice-11/completion-audit.md @@ -0,0 +1,83 @@ +# 保真迁移完成度逐项审计 + +> 审计对象:`webapp/app/` +> 审计基线:`xiaobai-preservation-slice-11-candidate-20260731` +> 结论口径:自动证据通过不替代用户人工验收,也不授权部署切换 + +本审计回答的不是“测试是否为绿色”,而是迁移总纲、批准目录、产品表面和人工维护目标是否 +分别有可复验的证据。状态只使用:`自动闭环`、`人工闭环`、`明确保留`和`未执行`。 + +## 1. 目标与证据矩阵 + +| 要求 | 当前实现 | 主要证据 | 状态 | +|---|---|---|---| +| 原版是唯一产品/视觉基线 | 所有切片从根目录原版复制或机械移动;规格书不覆盖真实行为 | 总纲;切片00-11源码映射 | 自动闭环 | +| `next/`完全隔离 | `next/`未进入候选导入、资产、测试或部署路径;切片10后Git差分为0 | `next失败冻结记录.md`;Git路径审计 | 自动闭环 | +| 批准的模块化单体目录 | `backend/{bootstrap,http,features,data,database,jobs,llm}`、`frontend/{shared,pages,styles,vendor}`、`config/data/tests/tools`均已归位 | `app/config/architecture-inventory.json`;切片01-10 | 自动闭环 | +| 不制造第二套产品实现 | 根入口是兼容外壳;业务方法从原类机械移动,专项测试逐符号比较AST/哈希 | 切片01-10 preservation tests | 自动闭环 | +| 16个主工作区及内部能力完整 | 页面注册表有16个主工作区;策略跟踪、搜索、详情、提醒、账户和系统管理保留 | `pages.config.json`;前端边界测试;Playwright | 自动闭环 | +| API路径、权限、错误语义不变 | 53个精确路径和11个正则路径有唯一所有者;鉴权在后端执行 | `api.config.json`;HTTP/权限测试;21端点差分 | 自动闭环 | +| 数据源有统一边界和质量规则 | 18个数据集登记来源、用途、单位、新鲜度、覆盖率、复权和失败关闭规则 | data registries;DataGateway/quality tests | 自动闭环 | +| 数据库无损、用户隔离不变 | 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;运行时注册表;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;独立导出复验 | 自动闭环 | +| 正式数据库与部署不受影响 | 验收只使用隔离SQLite副本和非8765端口 | 各切片README;运行记录 | 自动闭环 | +| Docker/NAS和正式入口切换 | 用户已明确本轮不做NAS Docker测试;人工验收前禁止切换 | 迁移状态和切换指南 | 未执行 | + +## 2. 结构减法的可量化结果 + +- 根`server.py`从原版约5,851行降为25行稳定入口;实现归入启动、HTTP和产品领域模块。 +- 根`database.py`从原版2,839行降为746行,只保留初始schema、组合入口及有明确历史兼容责任的 + 方法;各领域查询已移入对应Repository。 +- 20个其他根Python兼容模块均为1-15行导出/模块别名,不含第二套实现。 +- 原9,283行前端运行时按原连续源码范围拆入共享层和13个页面目录;该行号映射作为迁移历史 + 证据保留,日常维护改由当前模块所有权和行为回归约束。 +- 已确认没有消费者的`demo_data.py`、旧问天加载文件和5个函数进入可单项恢复的试删候选; + `wencai_saved_queries`因旧数据库兼容和账号隔离继续保留。 + +大文件不等于自动可删。筛选引擎、Tushare适配器、问天确定性计算和原CSS仍是单一有效实现; +在没有更细的同输入差分与人工视觉确认前继续拆分,反而违反“整理原件而非重拍”的迁移约束。 +它们已登记在`config/architecture-inventory.json`的`code_hotspots`,供后续正常维护按领域处理。 + +## 3. 本次严格审计发现并关闭的缺口 + +候选版初次自动验收后,三个维护工具仍带有旧目录假设:基线验证器引用不存在的`static/`, +架构清单写向不存在的候选文档目录,原样清单生成器假设`app/app`且会因试删文件直接异常。 +这些问题不改变产品页面,却会让接管者无法按指南复验,因此不能视为可维护性完成。 + +关闭方式: + +1. 基线验证器改为检查候选全量测试、两个生成注册表、`frontend/`下全部JavaScript、Git差异和 + 测试数据库完整性。 +2. 候选架构清单固定生成到`config/architecture-inventory.json`,从正式候选路径统计16页、 + 64类路由匹配、36张表、供应商/LLM入口、CSS层和热点文件。 +3. 原样资产清单、方法搬运和前端拆分明确标为迁移期工具;可能写文件的操作必须显式传路径或 + `--apply`,默认帮助路径不改文件。 +4. 新增维护工具契约测试,保证生成文件新鲜、所有工具`--help`可用,旧`static/`路径不会回归。 +5. 跨到2026-08-01周六后,实时详情测试中用运行日构造的“固定时间”不再代表交易日;原版和 + 候选测试夹具同步固定到明确工作日,消除跨午夜/周末不确定性。产品实现与交易日规则未改, + 随后统一验收再次通过302项Python测试和45项Playwright。 +6. 独立导出`app/`后成功启动健康接口,236项非迁移业务/前端测试通过;同时发现六项日常前端 + 契约经辅助函数隐式读取旧`static/app.js`。完成人工验收后,日常契约已改为装配当前注册脚本; + 只有确有价值的静态资产、页面前缀及后端AST差分继续读取原版,避免历史源码回放成为第二套维护对象。 +7. 对切片10十组截图重新做像素审计,九组平均色差均低于0.3/255;集合竞价候选图实际为登录 + 失效页,已明确标为无效并撤销其截图证明力。集合竞价最终视觉继续列为人工验收项。 +8. 统一验证器现在按环境选择完整保真套件或候选自有套件;系统临时目录中的纯`app/`导出通过 + 242项测试、注册表、24个JavaScript文件和SQLite检查。架构热点大小按归一化UTF-8计算, + CRLF/LF不再导致清单失效;正式仓库最终通过305项测试和45项Playwright。 + +## 4. 最终人工裁决与部署边界 + +2026-08-01用户在隔离端口`8797`浏览全部页面并测试全部功能,确认视觉与功能迁移成功;发现的 +问题几乎都属于原版遗留问题,未发现阻止验收的迁移回归。该结论已经关闭以下迁移裁决: + +1. 切片11试删候选确认为废弃,恢复标签继续保留。 +2. 页面视觉、功能、交互和动画的人工验收完成。 +3. 允许建立本地迁移完成标签并推送Gitea。 + +该结论不授权切换正式数据库、Docker或NAS,也不授权删除根目录原版、数据库备份或冻结的 +`next/`记录。部署切换继续以`人工维护与本地切换指南.md`为准,并须单独获得用户批准。 diff --git a/docs/migration/evidence/slice-11/database-diff.json b/docs/migration/evidence/slice-11/database-diff.json new file mode 100644 index 0000000..a65ce9a --- /dev/null +++ b/docs/migration/evidence/slice-11/database-diff.json @@ -0,0 +1,202 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "users", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "migrated_sha256": "4a0f135bef8ebae454693d3f40e1157d84d814d18d849f33e2e760ed1d8a7f89", + "equal": true, + "excluded_columns": [] + }, + { + "table": "system_settings", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "migrated_sha256": "86d333a5c7feaf7111cd79b2db29513326a9b0d3781607f78bf633579e2ca8e9", + "equal": true, + "excluded_columns": [] + }, + { + "table": "watchlist", + "original_count": 6, + "migrated_count": 6, + "original_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "migrated_sha256": "070161f5cdcc33addf289f11cb0ae54bfc0d669a39426fc33398b64512dba2fe", + "equal": true, + "excluded_columns": [] + }, + { + "table": "review_notes", + "original_count": 3, + "migrated_count": 3, + "original_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "migrated_sha256": "86ed485c2ce922e3434f8ac742d38431ef430b5c22024cacb6f64f0e8e71ee98", + "equal": true, + "excluded_columns": [] + }, + { + "table": "trade_entries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "alerts", + "original_count": 2, + "migrated_count": 2, + "original_sha256": "3fd3b7ceb0950d543fba9cebdd2b4294fe187b37a6b8d4755a49428661b3ae1c", + "migrated_sha256": "3fd3b7ceb0950d543fba9cebdd2b4294fe187b37a6b8d4755a49428661b3ae1c", + "equal": true, + "excluded_columns": [] + }, + { + "table": "assistant_messages", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_messages", + "original_count": 28, + "migrated_count": 28, + "original_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "migrated_sha256": "b906b776d575ff6fe459b2aa0e8b396267feed36d3d85f7edd7947b284482aeb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "mentor_preferences", + "original_count": 45, + "migrated_count": 45, + "original_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "migrated_sha256": "c19c7d05e6745e4f1787d1eed5f39d7e555ec24f8e490b585c19158edc2505ec", + "equal": true, + "excluded_columns": [] + }, + { + "table": "heaven_readings", + "original_count": 31, + "migrated_count": 31, + "original_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "migrated_sha256": "e6cfc86f010fb182d7522bb99e5d268e826f93896035172120df51a011590951", + "equal": true, + "excluded_columns": [] + }, + { + "table": "user_birth_profiles", + "original_count": 1, + "migrated_count": 1, + "original_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "migrated_sha256": "2a74d4b2edd3a730a46f17fb6ab3926575c42662c1d968643b0d54586ab68b13", + "equal": true, + "excluded_columns": [] + }, + { + "table": "sector_phase_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "screener_strategies", + "original_count": 36, + "migrated_count": 36, + "original_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "migrated_sha256": "872846dcb746446bda37723e63db737a2a0cf0540eb6a1da264012248887f4c5", + "equal": true, + "excluded_columns": [ + "updated_at" + ] + }, + { + "table": "screener_runs", + "original_count": 250, + "migrated_count": 250, + "original_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "migrated_sha256": "9f58ff2f85e5b85878153f7694161620d77cb9b0f351754f2974a8a0cc8ad8fb", + "equal": true, + "excluded_columns": [] + }, + { + "table": "strategy_tracks", + "original_count": 16, + "migrated_count": 16, + "original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "equal": true, + "excluded_columns": [] + }, + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true, + "excluded_columns": [] + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true, + "excluded_columns": [] + }, + { + "table": "seat_aliases", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "reason_overrides", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + }, + { + "table": "llm_usage", + "original_count": 72, + "migrated_count": 72, + "original_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "migrated_sha256": "03a070316a125cef904bbfb2bb06b06e792242d5713142e70354402c741393c3", + "equal": true, + "excluded_columns": [] + }, + { + "table": "wencai_saved_queries", + "original_count": 0, + "migrated_count": 0, + "original_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "migrated_sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "equal": true, + "excluded_columns": [] + } + ] +} diff --git a/docs/migration/evidence/slice-11/manual-acceptance.md b/docs/migration/evidence/slice-11/manual-acceptance.md new file mode 100644 index 0000000..0268f45 --- /dev/null +++ b/docs/migration/evidence/slice-11/manual-acceptance.md @@ -0,0 +1,33 @@ +# 切片 11:用户人工验收记录 + +> 验收日期:2026-08-01 +> 验收地址:`http://127.0.0.1:8797/` +> 数据边界:迁移审计数据库副本,未写正式数据库 +> 结论:通过 + +## 验收范围 + +用户在迁移版中浏览了全部页面,并测试了全部可操作功能。验收覆盖页面视觉、布局、主题、 +交互、动画、行情读取、LLM功能及主要写入流程。验收期间使用的Tushare、iFinD和主LLM均完成 +真实最小调用验证,迁移版能够读取数据库中加密保存的原配置。 + +## 用户结论 + +迁移版在视觉和功能上迁移成功。人工检查中发现的问题几乎全部属于原版已经存在的遗留问题; +未发现阻止本次验收的`app/`目录迁移回归。 + +## 最终裁决 + +1. 接受`app/`与原版在功能、视觉、交互和动画上的保真结果。 +2. 确认切片11登记的`demo_data.py`、旧问天加载文件和5个无消费者前端函数可以永久废弃。 +3. 保留`wencai_saved_queries`及证据不足的相邻资产,继续承担旧数据库兼容责任。 +4. 允许建立本地迁移完成提交和标签并推送Gitea。 +5. 本次验收不授权切换Docker/NAS、删除原版或改写正式数据库;部署切换必须另行决定。 + +## 证据关系 + +- 自动完成度审计:`completion-audit.md` +- 不确定代码处置:`uncertain-code-audit.md` +- 浏览器自动记录:`browser-acceptance.json` +- API和数据库差分:`api-diff.json`、`database-diff.json` +- 回档基线:`xiaobai-preservation-slice-10-20260731` diff --git a/docs/migration/evidence/slice-11/screenshot-pixel-audit.json b/docs/migration/evidence/slice-11/screenshot-pixel-audit.json new file mode 100644 index 0000000..703a97d --- /dev/null +++ b/docs/migration/evidence/slice-11/screenshot-pixel-audit.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "audited_at": "2026-08-01T01:55:00+08:00", + "source_directory": "docs/migration/evidence/slice-10", + "difference_threshold_per_channel": 8, + "valid_pairs": [ + {"view": "dark-sentiment-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.065, "changed_pixel_percent": 0.123}, + {"view": "dark-sentiment-390x844", "size": "375x812", "mean_absolute_difference": 0.009, "changed_pixel_percent": 0.012}, + {"view": "heaven-fortune-light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.043, "changed_pixel_percent": 0.046}, + {"view": "heaven-heart-breath-light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.256, "changed_pixel_percent": 0.545}, + {"view": "heaven-heart-intro-light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.047, "changed_pixel_percent": 0.068}, + {"view": "heaven-trend-390x844", "size": "390x844", "mean_absolute_difference": 0.284, "changed_pixel_percent": 0.712}, + {"view": "heaven-trend-light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.040, "changed_pixel_percent": 0.063}, + {"view": "light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.000, "changed_pixel_percent": 0.000}, + {"view": "screener-light-1920x1080", "size": "1683x1080", "mean_absolute_difference": 0.064, "changed_pixel_percent": 0.157} + ], + "invalid_pairs": [ + { + "view": "auction-light-1920x1080", + "mean_absolute_difference": 15.820, + "changed_pixel_percent": 77.932, + "reason": "The migrated image is an expired-login page, not the auction workspace.", + "disposition": "Renamed INVALID-login-session and excluded from visual-equivalence evidence." + } + ], + "conclusion": "Nine valid pairs are geometrically and visually consistent within dynamic rendering noise. Auction remains a manual visual acceptance item." +} diff --git a/docs/migration/evidence/slice-11/uncertain-code-audit.md b/docs/migration/evidence/slice-11/uncertain-code-audit.md new file mode 100644 index 0000000..8e90f47 --- /dev/null +++ b/docs/migration/evidence/slice-11/uncertain-code-audit.md @@ -0,0 +1,91 @@ +# 切片 11:不确定代码审计日志 + +> 审计基线:`dec3cd12365df5e7d4c391369f14c457cf56d7d9` +> 恢复标签:`xiaobai-preservation-slice-10-20260731` +> 原则:只有静态引用、动态注册、运行路径和兼容责任同时排除后才试删;其余继续保留 +> 当前状态:自动与人工验收均已通过,试删项于2026-08-01确认废弃 + +本日志记录试删前的原位置、内容指纹、证据、决定和恢复方式。试删阶段不等于永久废弃; +2026-08-01用户完成迁移版全部页面和功能人工检查,确认视觉与功能迁移成功,所见问题几乎都 +属于原版遗留问题,未发现阻止验收的迁移回归。该人工结论关闭了本日志的最终确认门槛。 + +## 1. `app/demo_data.py` + +- 类型:旧演示行情构造器,376行、17,199字节。 +- SHA-256:`fb69682d499993534e7ee029989b35cf512c455eec49e07d7b09658cddd9e028`。 +- 静态引用:运行代码、配置、启动入口、Docker、页面及后台任务均没有导入或调用`DEMO_LIMITS`、`build_demo_dashboard`、`build_demo_dragon_tiger`或`build_demo_stock_detail`。 +- 动态/注册路径:没有模块名字符串、插件注册或反射加载。 +- 产品事实:`app/README.md`明确主行情不再回退演示数据;行情服务和Repository只负责排除历史`source=demo`缓存,未依赖本文件。 +- 兼容责任:不参与数据库schema、历史记录解释或配置读取。 +- 最终决定:确认废弃;切片10标签继续保留单项恢复能力。 +- 恢复:从切片10标签恢复`app/demo_data.py`。 + +## 2. `app/frontend/heaven-loading.js` + +- 类型:旧版问天加载动画,642行、30,563字节。 +- SHA-256:`ee914762b893a89f745e9e705cac6840c1dbd6c5b68dc7a47620b8c4db3615c7`。 +- 静态引用:`app/frontend/index.html`只加载`/pages/heaven/loading-v2.js`,没有加载本文件。 +- 动态/注册路径:没有脚本清单、页面注册表或运行时代码引用旧路径;新旧文件虽然都导出`window.HeavenLoadingCanvas`,但浏览器只能执行v2。 +- 运行证据:切片10已覆盖观势、观气和观心解读加载动画,v2节点、动画名及可见行为与原版基线一致。 +- 兼容责任:不是数据库、配置或历史数据资产。 +- 最终决定:确认废弃;切片10标签继续保留单项恢复能力。 +- 恢复:从切片10标签恢复该文件。 + +## 3. 五个疑似无引用前端函数 + +全前端非供应商JavaScript逐词扫描后,下列符号都只有定义本身一次;HTML、页面注册表、事件绑定、字符串查找、测试入口和其他脚本均无消费者: + +| 函数 | 位置 | 原用途线索 | 决定 | +|---|---|---|---| +| `commonReviewColumns` | `frontend/shared/export.js` | 旧通用导出列定义;当前每个导出入口均显式传列 | 确认废弃 | +| `outcomeClass` | `frontend/app.js` | 旧昨日涨停结果样式映射;当前渲染不调用 | 确认废弃 | +| `screenerResultMatchesSelection` | `frontend/pages/screener/page.js` | 旧选股结果存在性包装;已由`activeScreenerResultEntry`直接承担 | 确认废弃 | +| `selectRegime` | `frontend/pages/screener/page.js` | 旧阶段手动切换;当前阶段由盘后数据写入且无按钮绑定 | 确认废弃 | +| `showHeartRitualCurtain` | `frontend/pages/heaven/page.js` | 旧观心幕帘过场;当前流程直接调用`activateHeartRises` | 确认废弃 | + +本次只删除这5个函数,不连带删除`selectedRegime`、`heartRitualCurtain`隐藏节点、`heartCurtainTimer`或相关CSS。后者与仍在使用的选股状态、观心节点和复合选择器相邻,尚不足以证明可独立删除,默认保留。 + +恢复:从切片10标签按上述文件恢复对应函数;源码映射可通过`docs/migration/evidence/slice-10/frontend-source-map.json`定位。 + +## 4. `wencai_saved_queries`及其方法 + +- 类型:历史兼容数据表及用户隔离Repository方法。 +- 当前入口:问财前端和`/api/wencai*`已按用户决定取消;iFinD的`wencai()`能力仍由股池原因补全使用,但不依赖保存查询表。 +- 保留责任:数据库初始化仍创建表和用户索引;`list_wencai_saved_queries`、`save_wencai_query`、`delete_wencai_saved_query`保持已有数据可读取;`test_ifind_features`明确验证跨用户隔离。 +- 风险:当前数据库表为空不能证明所有用户历史库均为空;删除会改变62项schema契约并破坏旧库兼容。 +- 决定:保留,不试删。只有未来正式数据库迁移、导出/归档和用户批准同时具备时才允许移除。 + +## 5. 试删验收门槛 + +每次试删后必须同时满足: + +1. 全前端符号与资源引用扫描没有悬空消费者。 +2. 全部JavaScript通过`node --check`。 +3. 原版231项测试继续通过,迁移版全量测试通过。 +4. 21个固定只读API与数据库schema/关键表差分继续一致;明确批准删除的运行外文件不应改变API或数据库。 +5. 45项Playwright通过,问天加载动画与智能选股流程无回归。 +6. 用户使用迁移版完成最终人工页面验收。 + +任一项失败时,按本日志指向的切片10标签恢复对应候选,不把其他已通过候选一起回退。 + +## 6. 试删后结果 + +- 五个前端符号在全部非供应商JavaScript中的剩余引用均为0。 +- 运行代码中没有`demo_data`导入;页面只请求`/pages/heaven/loading-v2.js`。 +- 24个现存前端JavaScript文件通过`node --check`。 +- 原版231项、迁移版298项Python测试和45项Playwright全部通过。 +- 21个只读API、62个schema对象和21张关键表继续与原版一致。 +- 桌面日间/夜间及390x844移动端真实页面检查通过,问天三页和观气底部内容可达。 +- `wencai_saved_queries`及三个方法保持存在,并由清理契约测试持续保护。 + +## 7. 人工最终确认 + +- 验收日期:2026-08-01。 +- 验收运行时:隔离端口`8797`与迁移审计数据库副本。 +- 验收范围:用户逐页浏览全部页面并测试全部可操作功能。 +- 验收结论:视觉与功能迁移成功;发现的问题几乎都是原版遗留问题,未发现阻止验收的迁移回归。 +- 删除结论:`demo_data.py`、旧问天加载文件和上述5个无消费者函数正式确认为废弃; + `wencai_saved_queries`及证据不足的相邻DOM、状态和CSS继续保留。 + +确认废弃不取消恢复证据。若后续发现历史兼容责任,可从 +`xiaobai-preservation-slice-10-20260731`按本日志记录单项恢复,不回退其他迁移成果。 diff --git a/docs/migration/next失败冻结记录.md b/docs/migration/next失败冻结记录.md new file mode 100644 index 0000000..a711429 --- /dev/null +++ b/docs/migration/next失败冻结记录.md @@ -0,0 +1,29 @@ +# `next/`失败冻结记录 + +> 决策日期:2026-07-30 +> 状态:用户验收失败,永久冻结,禁止部署 + +## 结论 + +`next/`采用重新实现方式建立,与本次迁移的真实目标不一致。用户确认其页面视觉、布局和基础功能 +未达到原系统等价要求,因此该目录不再作为产品候选、迁移成果或后续重构基础。 + +原发布候选`xiaobai-next-rc-20260730-1`自本记录生效起作废。不得使用`next/Dockerfile`、 +`next/compose.yaml`或`next/compose.preflight.yaml`部署任何正式或预检环境。 + +## 冻结边界 + +1. `next/`只保留作失败复盘、测试思路和迁移工具参考,不再修补产品功能或视觉。 +2. 后续迁移不得从`next/`反向覆盖原系统,也不得把其页面作为视觉基准。 +3. 原系统仍是唯一产品事实和视觉基线;后续工作必须采用原代码保真式结构迁移。 +4. 未经用户新的明确决定,不删除`next/`,不移动失败标签,不恢复其发布候选身份。 +5. `next/data/`中的本地迁移副本继续保持Git忽略,不作为生产数据。 + +## 失败原因 + +- 错误地把“建立独立目录并迁移”执行成了重新设计和重新实现。 +- 自动测试主要验证新实现内部契约,没有证明与原系统逐页面、逐状态、逐像素等价。 +- 以代码行数下降和规格案例覆盖代替了原系统完整行为保真。 +- 用户人工验收确认视觉、布局及基础功能不可作为正式产品使用。 + +本记录优先于`next/`内所有阶段完成、发布候选、预检和切换说明。 diff --git a/docs/migration/人工维护与本地切换指南.md b/docs/migration/人工维护与本地切换指南.md new file mode 100644 index 0000000..fc2501c --- /dev/null +++ b/docs/migration/人工维护与本地切换指南.md @@ -0,0 +1,159 @@ +# 小白复盘人工维护与本地切换指南 + +> 历史文档:记录迁移验收期间的双版本操作,已由`docs/maintenance/人工维护指南.md`取代。 +> 其中涉及父目录原版和迁移比较工具的命令不再作为当前维护流程执行。 +> +> 适用目录:`webapp/app/` +> 当前状态:本地迁移已完成自动与用户人工验收;正式数据库、Docker和NAS尚未切换 + +## 1. 先确认哪个版本是正式版本 + +- 当前正式基线仍是`webapp/`根目录及根目录`data/review.db`。 +- `webapp/app/`是已完成人工验收的保真迁移版本,运行代码来自原版的移动、机械拆分和去重, + 不是重新开发。 +- `webapp/next/`是已否决的冻结版本,禁止部署、继续开发或复制实现。 +- 用户人工验收前,不要删除根级原版,不要让`app/`写入正式数据库,也不要修改NAS容器。 + +发生产品含义冲突时,依次以用户明确决定、原版真实运行、原版源码与数据、产品规格书为准。 + +## 2. 迁移版目录怎么找代码 + +```text +app/ + server.py 稳定进程入口;正式实现装配在backend/ + database.py SQLite初始schema与Repository组合入口 + api_access.py API访问级别注册入口 + sync_data.py 手工行情同步命令 + backend/bootstrap/ 路径、环境、配置、依赖组装和HTTP服务器 + backend/http/ 鉴权、路由元数据、响应与静态资源传输 + backend/features/ 按产品领域组织的服务和Repository + backend/data/ 数据网关、质量策略、实时聚合和供应商适配 + backend/database/ SQLite连接、schema和组合Repository + backend/jobs/ 后台任务定义、状态、调度与重试 + backend/llm/ 所有模型调用、流式传输、额度与审计边界 + frontend/shared/ API、状态、令牌、Shell、组件与共享样式 + frontend/pages/ 页面结构、行为与页面专属样式 + config/ 页面、功能、API、任务、字段和数据质量注册表 + tests/ 单元、边界、保真差分和浏览器回归 + tools/ 清单、API/数据库差分和保真运行工具 + data/ 运行数据库、私有Skill和备份;不提交Git + runtime/ 本地日志、缓存、PID与浏览器测试产物;不提交Git + 游资skills/ 可公开的问师Skill +``` + +根目录不再保留业务兼容导入壳。维护业务时直接到`backend/features/<领域>/`或 +`backend/data/`寻找唯一实现,不得重新建立根级转发文件。所有浏览器网络请求必须继续经过 +`frontend/shared/api.js`,所有LLM调用必须继续经过`backend/llm/`。 + +## 3. 本地隔离启动 + +不要直接拿正式数据库做迁移验收。先创建一个目录并用SQLite backup API生成一致副本,或使用 +`app/data/backups/`中专门的验收副本。然后在`webapp`根目录运行: + +```powershell +python -u app\tools\run_preservation_runtime.py ` + --runtime-root app ` + --data-dir app\data\backups\manual-acceptance ` + --port 8797 +``` + +浏览器打开`http://127.0.0.1:8797/`。该命令不占用正式`8765`,并把数据库、私有Skill和 +运行写入限制在指定测试目录。验收完成后先停止该进程,再处理测试副本。 + +## 4. 每次改动的最低流程 + +1. 阅读`AGENTS.md`、保真迁移状态、迁移账本和对应领域测试。 +2. 从`config/pages.config.json`与`features.config.json`确认页面、功能和权限边界。 +3. 只修改一个完整领域路径;不要建立根级兼容壳或第二套实现。 +4. 新增API时同步检查`config/api.config.json`及`backend/http/`的权限元数据。 +5. 用户私有表必须包含并按`user_id`查询,补充跨账号隔离测试。 +6. 行情字段必须登记来源、时间、单位、复权、新鲜度和降级规则,不允许静默换源。 +7. 先跑领域测试,再跑下面的全量门槛,最后用真实浏览器检查桌面、夜间和移动端。 +8. 更新迁移/维护文档后再提交;一个可回档节点只包含一个可以独立解释的改动。 + +## 5. 全量验证命令 + +原版基线: + +```powershell +cd webapp +python -m unittest discover -s tests -q +``` + +迁移版: + +```powershell +cd webapp\app +python tools\verify_baseline.py +python tools\verify_baseline.py --e2e +``` + +第一条命令已经包含迁移版全量单元测试、API/架构注册表新鲜度、全部前端JavaScript语法、 +Git空白错误和测试数据库只读完整性检查。第二条额外运行Playwright。工具的日常/验收/迁移期 +分类见`app/tools/README.md`;迁移期工具不是正常开发命令。 + +Playwright需要能够启动本机无头Edge。若测试停在浏览器启动前且没有`msedge`进程,先检查执行 +环境是否禁止GUI/无头浏览器进程;这不是页面失败,不要通过删除测试或延长产品超时绕过。 + +API和数据库差分工具: + +```text +app/tools/compare_preservation_apis.py +app/tools/compare_preservation_databases.py +``` + +运行前先查看脚本参数,并使用同一时点生成的原版/迁移版数据库副本。任何`all_equal=false`都应 +阻止提交和切换。 + +## 6. 数据、密钥和私有内容 + +- SQLite数据库与`.env`中的`APP_ENCRYPTION_KEY`必须成对备份;密钥丢失后不能恢复加密字段。 +- `data/private-mentor-skills/`只属于管理员本机/服务器,不进入Git和Docker镜像。 +- 不要用文件管理器复制正在写入的`review.db`;使用SQLite backup API或停服后复制。 +- 不要把Tushare、iFinD、LLM Token、账号密码、数据库副本或私有Skill提交到仓库。 +- 切换期间只有一个数据库可以成为写入主库,禁止让原版与迁移版长期各写一份后再人工合并。 + +## 7. 人工验收清单 + +2026-08-01用户已在隔离端口`8797`完成全部页面和功能验收,确认视觉与功能迁移成功;所见问题 +几乎都属于原版遗留问题,未发现阻止验收的迁移回归。以下清单继续作为后续结构修改和部署 +切换时的回归标准: + +- 用同一账号、日期和主题对照原版与迁移版全部页面。 +- 检查日间/夜间、1080P/4K、390像素移动端和浏览器缩放后的滚动与弹窗。 +- 检查图表悬浮、股票/板块/题材/指数详情、全局搜索和日期切换。 +- 检查选股三个工作区、策略跟踪、刷新后结果保持和候选来源隔离。 +- 检查问师流式回答只出现一次、置顶排序、历史和会员限制。 +- 检查问天三页全部过场、呼吸/铜钱、加载动画、历史和解读结果。 +- 使用两个账号检查自选、笔记、交易日志、提醒和对话互不可见。 +- 检查系统管理、会员期限、模型池、数据回补和后台任务状态。 + +人工验收发现差异时,记录页面、账号、日期、主题、视口、输入和截图;先对照原版复现,再判断 +是迁移回归还是原版既有问题。 + +## 8. 获得部署批准后的本地切换方案 + +以下只是准备步骤,本次迁移没有执行: + +1. 停止原版和迁移版进程,确认没有后台任务继续写库。 +2. 对根目录正式数据库执行SQLite一致性备份,同时备份`.env`和私有Skill。 +3. 把同一份最新正式数据恢复到`app/data/`,保持原`APP_ENCRYPTION_KEY`不变。 +4. 在非`8765`端口启动`app/server.py`并完成健康、登录、关键页面和写入冒烟测试。 +5. 记录切换提交、数据库备份位置和启动时间后,才把正式入口指向`app/`。 +6. 切换观察期内保留根级原版和切换前数据库,只允许迁移版写主库。 + +Docker/NAS切换应以`app/`作为构建上下文,另行执行构建、卷挂载、权限、健康检查和回退演练。 +本轮没有进行这些操作。 + +## 9. 回退方案 + +若切换后出现问题: + +1. 立即停止迁移版,避免继续写库。 +2. 保存故障日志和当前数据库副本用于调查。 +3. 恢复切换前成对备份的`review.db`与`.env`。 +4. 从切换记录指定的原版提交重新启动根级`server.py`。 +5. 验证登录、健康接口、最近交易日、私有数据和模型配置后恢复使用。 + +切片11已确认废弃项仍可从`xiaobai-preservation-slice-10-20260731`单项恢复;不要用破坏性的 +Git重置覆盖正式数据或用户未提交的代码。 diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json new file mode 100644 index 0000000..cb29ac7 --- /dev/null +++ b/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/docs/migration/保真迁移账本.md new file mode 100644 index 0000000..d962667 --- /dev/null +++ b/docs/migration/保真迁移账本.md @@ -0,0 +1,466 @@ +# 小白复盘保真迁移账本 + +> 当前状态:正式源码独立化收口自动验收通过,等待建立并推送最终回档提交 + +本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 +`保真迁移状态.json`。 + +## 固定事实 + +- 原版根目录是唯一产品和视觉基线。 +- `next/`已被用户否决并冻结,不进入后续迁移。 +- 新目标目录由用户批准为`app/`,最终作为正式程序目录。 +- 后续采用原代码保真式迁移,不重新实现,不更换技术栈。 +- 2026-07-30用户授权在既定约束内自主完成全部迁移,阶段性差异仍必须阻断后续切片。 + +## 当前检查点 + +| 日期 | 提交或标签 | 事件 | 结论 | +|---|---|---|---| +| 2026-07-30 | `xiaobai-next-rejected-20260730` | 冻结失败的`next/`实现 | 禁止部署或继续开发 | +| 2026-07-30 | `xiaobai-preservation-migration-charter-20260730` | 建立保真迁移总纲、状态和恢复协议 | 尚未开始新迁移 | +| 2026-07-30 | `41329943c4878fc09ed82ec376eb93ab151e4092` | 完成只读资产清查并由用户批准`app/`结构 | 开始切片00 | +| 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 | +| 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 | +| 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 | +| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 | +| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 | +| 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 | +| 2026-07-31 | `xiaobai-preservation-slice-07-20260731` | 问师、模型Skill与LLM流式链路原实现归位 | 自动、API、数据库、Skill与浏览器差分通过,进入切片08 | +| 2026-07-31 | `xiaobai-preservation-slice-08-20260731` | 问天、观势、观气与观心原实现归位 | 自动、API、数据库、动画与浏览器差分通过,进入切片09 | +| 2026-07-31 | `xiaobai-preservation-slice-09-20260731` | 复盘、自选、交易日志、提醒与复盘助手原实现归位 | 自动、API、数据库、账户隔离与浏览器差分通过,进入切片10 | +| 2026-07-31 | `xiaobai-preservation-slice-10-20260731` | 前端Shell、页面、CSS、动画与移动端职责归位 | 源码重组、自动、API、数据库、桌面、夜间、移动端与动画差分通过,进入切片11 | +| 2026-07-31 | `xiaobai-preservation-slice-11-candidate-20260731` | 不确定代码审计、全量验收与人工交接准备 | 自动、API、数据库和浏览器验收通过;等待人工确认,未切换部署 | +| 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通过;提交与推送执行中 | + +## 资产处置登记 + +开始清查后,每个资产必须登记,禁止只记录已迁移项而遗漏未处理项。 + +| 原位置/符号 | 类型 | 消费者 | 处置 | 新位置 | 等价证据 | 状态 | +|---|---|---|---|---|---|---| +| 原版运行源文件(排除`next/`、日志、缓存、构建产物和正式数据库) | 运行资产 | 全站 | 原样保留后逐项移动 | `app/` | 388项受控资产哈希一致;231项Python与45项Playwright测试通过 | 已复制 | +| `next/` | 失败实现 | 无正式消费者 | 原样保留但禁止迁移 | - | 用户冻结决定 | 已冻结 | +| `demo_data.py` | 旧演示代码 | 无运行、动态、配置或兼容消费者 | 确认废弃 | 切片10标签可单项恢复 | 全量自动、API、数据库、浏览器及用户人工验收通过 | 已删除 | +| `static/heaven-loading.js` | 旧动画 | 页面只加载`heaven-loading-v2.js` | 确认废弃 | 切片10标签可单项恢复 | 问天三页加载动画、45项Playwright及用户人工验收通过 | 已删除 | +| `commonReviewColumns`等5个前端函数 | 无引用符号 | 定义外引用为0 | 确认废弃 | 切片10标签可按原行号恢复 | 审计白名单比较、24个JS语法、浏览器流程及用户人工验收通过 | 已删除 | +| `wencai_saved_queries`及其方法 | 历史兼容数据 | 旧库兼容与用户隔离 | 原样保留 | `app/backend/database/`兼容区 | schema及关键表差分一致;清理契约持续保护 | 保留 | +| 现有7层CSS | 视觉运行资产 | 全部页面和主题 | 原样保留后逐页归档 | `app/frontend/` | 必须通过截图与计算样式差分 | 保留 | +| `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 | +| `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/`、`app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 | +| `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 | +| `DashboardService`板块轮动方法 | 业务服务 | 板块轮动页 | 按职责机械移动 | `app/backend/features/rotation/service.py` | 2个方法AST、真实API与原版一致 | 已移动 | +| Tushare天梯与轮动构造函数 | 公共数据计算 | 市场天梯、板块轮动 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个构造函数AST与原版一致 | 已归位 | +| `MarketInsightsService` | 共享市场洞察服务 | 集合竞价、题材库、人气热榜 | 整体机械移动,保留唯一共享实现 | `app/backend/features/market/insights.py` | 22个方法AST与原版一致;根级模块为同一类对象别名 | 已移动 | +| `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 | +| `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 | +| Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 | +| 选股引擎、策略库与公式编译器 | 业务计算 | 阶段、策略、自定义选股 | 整体机械移动并保留兼容别名 | `app/backend/features/screener/engine.py`、`strategies.py`、`compiler.py` | 原版AST/源码等价;根级模块为同一模块对象 | 已移动 | +| `DashboardService`选股方法 | 业务服务 | 选股三个工作区 | 按职责机械移动 | `app/backend/features/screener/service.py` | 13个方法AST及3个真实API一致 | 已移动 | +| `ReviewDatabase`选股方法 | 持久化 | 因子、策略运行、候选与跟踪 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/screener/repository.py` | 25个方法AST一致;62个schema对象及13张关键表逐行一致 | 已移动 | +| `StrategyTrackingService` | 业务服务 | 手动候选持续跟踪 | 保持唯一实现并调整容器导入 | `app/backend/features/screener/tracking.py` | 类定义AST、真实API与浏览器行为一致 | 已归位 | +| `mentor_agent.py`与问师服务 | 业务服务 | Skill发现、问师上下文与流式回答 | 整体机械移动并保留兼容别名 | `app/backend/features/mentor/` | Agent文件哈希、8个服务方法AST、2个真实API及浏览器行为一致 | 已移动 | +| 问师消息与偏好方法 | 持久化 | 用户对话、置顶和排序 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/mentor/repository.py` | 5个方法AST一致;相关表逐行一致 | 已移动 | +| `llm_stream.py`与模型访问方法 | 公共模型能力 | 问师、问天、复盘助手与策略编译 | 移入唯一模型边界并保留兼容别名 | `app/backend/llm/` | 流式文件哈希一致;21个服务方法与既有用户边界一致 | 已移动 | +| 公开`游资skills` | 运行资产 | 问师模型库 | 原样保留 | `app/游资skills/` | 190个文件逐路径和SHA-256一致 | 已复制 | +| `heaven_agent.py`、`heaven_engine.py`与问天服务 | 业务计算 | 观势、观气、观心 | 机械移动并保留兼容别名 | `app/backend/features/heaven/` | Agent文件哈希、引擎定义AST、20个服务方法、6个API及真实动画页面等价 | 已移动 | +| 问天历史方法 | 持久化 | 三类解读历史与当日解运复用 | 按职责机械移动 | `app/backend/features/heaven/repository.py` | 5个方法AST一致;62个schema对象及6张关键表逐行一致 | 已移动 | +| `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/{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和真实浏览器差分通过 | 已移动 | + +处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 + +## 切片记录 + +每个切片记录以下内容:原版基线、复制范围、必要路径调整、差异测试、人工验收、未关闭问题和回档提交。 +已完成切片:`slice-00-exact-runtime-copy`。 + +- 原版基线:提交`41329943c4878fc09ed82ec376eb93ab151e4092`。 +- 复制范围:确认参与运行、测试、部署和维护的原版文件;排除`next/`和运行产物。 +- 允许改动:新目录中的路径配置、测试根目录和端口;不得改业务实现。 +- 验收:新目录独立启动,231项Python测试、45项Playwright测试、数据库副本和静态资产哈希验证。 +- 回档:本切片建立独立Git提交并推送Gitea。 +- 完整证据:`docs/migration/evidence/slice-00/README.md`。 +- 资产清单:`docs/migration/原版资产清单.json`,388项,0项哈希差异。 + +已完成切片:`slice-01-startup-http-accounts-system`。 + +- 原版基线:提交`4083dce`,即切片00原样副本。 +- 迁移范围:进程入口、应用组装、HTTP传输、账号安全、账号/会员业务、账号/系统设置持久化。 +- 兼容边界:根级`server.py`、`app_config.py`、`security.py`继续保留原导入和命令入口。 +- API与数据库:API/功能注册表哈希一致;原版与副本数据库均为36表、62个schema对象且schema哈希一致。 +- 验收:236项Python测试、5项切片专项测试、45项Playwright测试全部通过;前端运行资产未改动。 +- 回档:标签`xiaobai-preservation-slice-01-20260731`。 +- 完整证据:`docs/migration/evidence/slice-01/README.md`。 + +已完成切片:`slice-02-market-search-charts-data`。 + +- 原版基线:提交`4002f09`,即切片01回档点。 +- 迁移范围:30个公共行情服务方法、11个行情持久化方法及Tushare/iFinD/图表/实时观察实现。 +- 兼容边界:四个根级数据模块保留模块别名;未迁移功能继续使用旧导入且指向同一实现。 +- 等价证明:41个方法AST逐项一致,三个数据文件哈希一致,图表定义AST一致,静态资产哈希一致。 +- 验收:241项Python测试、6项切片源码等价测试、45项Playwright测试及真实服务搜索/详情流程通过。 +- 既存状态:原版和迁移版的实时分时在当前环境均返回相同外部请求失败,不作为迁移回归处理。 +- 回档:标签`xiaobai-preservation-slice-02-20260731`。 +- 完整证据:`docs/migration/evidence/slice-02/README.md`。 + +已完成切片:`slice-03-sentiment-pools-performance`。 + +- 原版基线:提交`a426432`,即切片02回档点。 +- 迁移范围:情绪计算引擎、2个情绪服务方法、6个股池原因与iFinD事件补全方法、2个原因覆盖持久化方法。 +- 兼容边界:根级`sentiment_engine.py`保留同一模块对象别名;股池生成仍使用切片02的原Tushare总览实现。 +- API与数据库:原版`8784`和迁移版`8785`的总览、情绪历史JSON完全一致;两库schema均为62项且哈希一致。 +- 验收:248项Python测试、6项切片源码等价测试、45项Playwright测试及六个真实页面流程通过。 +- 回档:标签`xiaobai-preservation-slice-03-20260731`。 +- 完整证据:`docs/migration/evidence/slice-03/README.md`。 + +已完成切片:`slice-04-ladder-rotation`。 + +- 原版基线:提交`b3555d2`,即切片03回档点。 +- 迁移范围:2个板块轮动服务方法;市场天梯继续使用切片02已归位的原Tushare数据构造实现。 +- 兼容边界:`DashboardService`通过`RotationServiceMixin`保持所有原调用;天梯不制造空服务或第二套计算。 +- API与错误:天梯与9日轮动历史JSON完全一致;成分股两版均返回同一Tushare外部失败语义。 +- 验收:252项Python测试、4项切片源码等价测试、45项Playwright测试及两个真实页面流程通过。 +- 回档:标签`xiaobai-preservation-slice-04-20260731`。 +- 完整证据:`docs/migration/evidence/slice-04/README.md`。 + +已完成切片:`slice-05-auction-themes-popularity-dragon-tiger`。 + +- 原版基线:提交`814e757`,即切片04回档点。 +- 迁移范围:共享市场洞察服务、竞价/题材/人气入口、龙虎榜与游资档案服务、7个相关持久化方法。 +- 兼容边界:根级`market_insights.py`保留同一类对象别名;竞价与人气共用候选热度逻辑,不复制第二套实现。 +- API与数据库:7个真实API逐字段一致,仅排除每次请求必然变化的`request_id`;62个schema对象与5张关键表完全一致。 +- 验收:258项Python测试、6项切片源码等价测试、45项Playwright测试及四个真实页面流程通过。 +- 回档:标签`xiaobai-preservation-slice-05-20260731`。 +- 完整证据:`docs/migration/evidence/slice-05/README.md`。 + +已完成切片:`slice-06-screener-custom-tracking`。 + +- 原版基线:提交`cf2aad2`,即切片05回档点。 +- 迁移范围:完整选股引擎、29套高级策略、策略编译器、13个页面服务方法、25个持久化方法及持续跟踪。 +- 兼容边界:三个根级模块指向正式模块对象;选股引擎保留原版数据客户端依赖,不为通过边界测试改写算法。 +- API与数据库:3个真实API业务JSON一致;62个schema对象和13张关键表逐行一致。 +- 验收:原版231项、迁移版265项Python测试、7项切片源码等价测试、45项Playwright及四个真实工作区通过。 +- 回档:标签`xiaobai-preservation-slice-06-20260731`。 +- 完整证据:`docs/migration/evidence/slice-06/README.md`。 + +已完成切片:`slice-07-mentor-skills-llm-streaming`。 + +- 原版基线:提交`4bab921`,即切片06回档点。 +- 迁移范围:问师Agent、Skill注册表、8个服务方法、5个持久化方法、模型解析、额度、回退、流式累积器和调用审计。 +- 兼容边界:根级`mentor_agent.py`和`llm_stream.py`指向正式模块对象;系统管理继续独占模型池配置。 +- API与数据库:2个真实API完全一致;62个schema对象及5张关键表逐行一致;190个公开Skill文件哈希一致。 +- 验收:原版231项、迁移版273项Python测试、8项切片源码等价测试、45项Playwright及真实问师页面通过。 +- 回档:标签`xiaobai-preservation-slice-07-20260731`。 +- 完整证据:`docs/migration/evidence/slice-07/README.md`。 + +已完成切片:`slice-08-heaven-trend-fortune-heart`。 + +- 原版基线:提交`2919229`,即切片07回档点。 +- 迁移范围:问天Agent、历法/卦象引擎、20个服务方法、5个持久化方法及3个HTTP入口。 +- 兼容边界:根级`heaven_agent.py`和`heaven_engine.py`指向正式模块对象;原动画、DOM、JS与CSS未改动。 +- API与数据库:6个固定输入真实API完全一致;62个schema对象及6张关键表逐行一致。 +- 验收:原版231项、迁移版280项Python测试、7项切片源码等价测试、45项Playwright及1080P三模式动画页面通过。 +- 回档:标签`xiaobai-preservation-slice-08-20260731`。 +- 完整证据:`docs/migration/evidence/slice-08/README.md`。 + +已完成切片:`slice-09-review-watchlist-notes-journal-alerts-assistant`。 + +- 原版基线:提交`b3df070`,即切片08回档点。 +- 迁移范围:复盘助手Agent、5个提醒编排方法、8个复盘编排方法、19个持久化方法及5个HTTP入口。 +- 兼容边界:根级`assistant_agent.py`指向正式模块对象;提醒与交易日志旧服务继续作为唯一底层实现;包导出采用延迟加载以切断初始化循环。 +- API与数据库:5个固定输入真实API完全一致;62个schema对象及8张关键表逐行一致。 +- 验收:原版231项、迁移版287项Python测试、7项切片源码等价测试、45项Playwright及1920×1080会员/非会员真实页面通过。 +- 回档:标签`xiaobai-preservation-slice-09-20260731`。 +- 完整证据:`docs/migration/evidence/slice-09/README.md`。 + +已完成切片:`slice-10-frontend-shell-pages-components-css-mobile`。 + +- 原版基线:提交`38de3de`,即切片09回档点。 +- 迁移范围:完整前端DOM、9,283行运行时、页面脚本、共享脚本、七层CSS、问天加载动画、供应商资产及移动端规则。 +- 兼容边界:运行时只切换静态根目录和资源路径;原DOM、样式、动画及JS源码内容不重写。 +- 源码证明:原`app.js`与全部拆分片段重组后的SHA-256均为`c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6`。 +- API与数据库:21个全站只读API全部一致;62个schema对象和21张关键表逐行一致。 +- 验收:85项前端专项、原版231项、迁移版294项Python测试、45项Playwright(2.3分钟),以及1920×1080日间/夜间、390×844移动端和问天动画真实浏览器差分通过。 +- 验收限制:同主机不同端口共享登录Cookie,双服务比较必须逐服务重新登录;动态动画帧、焦点和状态文字不以截图文件哈希作为唯一标准。 +- 回档:标签`xiaobai-preservation-slice-10-20260731`。 +- 完整证据:`docs/migration/evidence/slice-10/README.md`。 + +已完成切片:`slice-11-uncertain-code-audit-final-acceptance-handoff`。 + +- 原版基线:提交`dec3cd12365df5e7d4c391369f14c457cf56d7d9`,即切片10回档点。 +- 审计范围:`demo_data.py`、旧问天加载动画、5个无引用前端函数及`wencai_saved_queries`兼容责任。 +- 处置:前3类先进入可单项回档的候选试删,并在2026-08-01人工验收后确认废弃; + `wencai_saved_queries`表和方法因旧库兼容与用户隔离继续保留。 +- 保护方式:全部历史前端源码测试统一使用审计白名单比较,只允许登记的原`app.js`行号缺失,其他差异仍会失败。 +- API与数据库:21个全站只读API全部一致;62个schema对象和21张关键表逐行一致。 +- 验收:原版231项、迁移版302项Python测试、24个JavaScript语法检查和45项Playwright通过;桌面日间/夜间、390×844五个移动入口和问天三页真实检查通过。 +- 部署边界:未改正式数据库、`.env`、`8765`、Docker或NAS;原版仍是正式运行基线。 +- 候选回档:标签`xiaobai-preservation-slice-11-candidate-20260731`;最终完成标签 + `xiaobai-preservation-complete-20260801`;试删恢复能力继续由切片10标签保留。 +- 完整证据:`docs/migration/evidence/slice-11/README.md`。 +- 维护交接:`docs/migration/人工维护与本地切换指南.md`。 + +严格完成审计补充: + +- 发现候选`verify_baseline.py`、架构清单和原样资产清单仍带有旧`static/`、候选`docs/`及 + `app/app`路径假设;产品运行不受影响,但人工接管命令不可用,因此属于迁移目标缺口。 +- 修复后统一验证命令检查302项Python测试、两个生成注册表、24个前端脚本、Git空白、SQLite + 完整性和45项Playwright,并在Windows上显式管理8876测试服务器生命周期。 +- 21个固定API使用同一数据库时点重新比较全部一致;数据库62个schema对象和21张关键表一致, + 仅按既有规则排除内置策略启动校准的`updated_at`。 +- 候选自身的16页、64类路由匹配、36张表、数据/LLM/CSS入口和热点文件登记在 + `app/config/architecture-inventory.json`;逐项结论见`completion-audit.md`。 +- 严格审计回档标签为`xiaobai-preservation-slice-11-audit-candidate-20260731`。该标签只代表自动 + 候选;最终人工验收与删除裁决记录在完成标签中。 +- 2026-08-01跨日复验发现原版与候选共用的一项实时详情测试夹具依赖运行日,进入周六后会把 + 非交易日误作预期合并日;两版测试夹具同步固定到明确工作日,产品代码和日期规则未改。 +- 跨日修复后统一验收再次通过302项Python测试、24个JavaScript文件、SQLite完整性和45项 + Playwright;新增候选回档标签`xiaobai-preservation-slice-11-audit-candidate-20260801`。 +- 纯`app/`导出在系统临时目录成功运行统一维护命令:242项候选测试、注册表、24个JavaScript + 文件和SQLite检查通过;正式仓库保真套件为305项Python测试及45项Playwright通过。 +- 日常前端契约不再隐式读取旧`static/app.js`,只有迁移差分断言保留原版依赖;架构热点大小 + 按归一化UTF-8统计,CRLF/LF跨环境结果一致。 +- 切片10集合竞价候选截图实际为登录失效页,已重命名并撤销证明力;九组有效截图像素差异 + 低于动态渲染噪声阈值。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切换仍需用户单独批准。 + +## 决策记录 + +| 日期 | 决策 | 原因 | +|---|---|---| +| 2026-07-30 | 原版运行行为优先,规格书只用于盘点 | 防止再次依据文字重新开发 | +| 2026-07-30 | 不使用`next/`作为后续迁移起点 | 用户确认其视觉、布局和基础功能不可用 | +| 2026-07-30 | 目标结构确认前不创建业务目录 | 防止目录先行后再次补写功能 | +| 2026-07-30 | 目标目录确定为`app/` | 使用长期正式名称,不再建立新的版本式重写目录 | +| 2026-07-30 | 先建立原样可运行副本,再迁移目录职责 | 保证整理的是原件,而不是依据规格书重新实现 | +| 2026-07-30 | 不确定代码进入待删账本并延迟试删 | 删除必须能够单独回档并经自动与人工验收 | +| 2026-08-03 | `app/`成为唯一正式源码边界 | 用户确认结构重铸与迁移目标基本完成并要求独立化收口 | +| 2026-08-03 | 迁移比较脚手架退出日常测试 | 父目录删除后测试集合必须保持完整且确定,不能静默跳过 | + +## 恢复工作检查 + +- [x] 已阅读`AGENTS.md`与保真迁移总纲。 +- [x] 已读取状态JSON和本账本最后一项。 +- [x] 已确认Git工作区和基线提交。 +- [x] 已确认没有修改`next/`或正式数据。 +- [x] 已说明当前切片及新旧等价证据。 +- [x] 已在编辑前确认没有未关闭差异。 diff --git a/docs/migration/原版保真迁移总纲.md b/docs/migration/原版保真迁移总纲.md new file mode 100644 index 0000000..ecab050 --- /dev/null +++ b/docs/migration/原版保真迁移总纲.md @@ -0,0 +1,128 @@ +# 小白复盘原版保真迁移总纲 + +> 状态:正式迁移 +> 建立日期:2026-07-30 +> 迁移性质:保行为、保视觉、保数据语义的源代码整理 + +## 1. 唯一目标 + +以当前可运行原版`webapp`为唯一母版,在用户批准的`app/`目录中建立更容易检索、理解和人工维护的 +代码结构。迁移后的系统必须继续使用原版已经验收的功能、视觉、布局、动画、计算逻辑和交互, +不得依据说明文字重新开发一个相似产品。 + +本次工作相当于把原文件柜中的有效原件逐项分类搬入新文件柜,而不是重新制作原件。 + +## 2. 事实裁决顺序 + +发生不一致时按以下顺序处理: + +1. 用户在当前或后续对话中的明确决定。 +2. 原版在相同代码、数据、账号、配置、日期、主题和视口下的真实运行行为。 +3. 原版源码、数据库结构、静态资产和现有测试共同证明的行为。 +4. 《小白复盘完整产品规格说明书》用于盘点和解释,不得自行覆盖原版行为。 +5. 无法确定时记录为待裁决,保持原状,不推测、不补全。 + +只有用户明确指出原版是Bug或要求改变时,才允许产生用户可观察差异,并必须单独记录。 + +## 3. 必须保持不变 + +- 全部页面、入口、功能和细节能力。 +- PC与移动端布局、尺寸、字体、颜色、间距、滚动和响应式行为。 +- 日间、夜间、加载、空、错误、禁用、悬停、选中和完成状态。 +- 动画素材、形态、时序、过场、循环、静音和减少动态效果行为。 +- API路径、请求、响应、错误语义和流式传输行为。 +- 数据来源职责、日期、单位、复权、缺失、覆盖率、新鲜度和降级规则。 +- 权限、会员、管理员标识、账户隔离和私有数据边界。 +- 情绪、竞价、股池、选股、问天及统计计算结果。 +- 数据库现有记录、唯一约束、历史兼容和后台任务语义。 + +## 4. 允许与禁止 + +允许: + +- 移动文件并调整导入路径。 +- 将巨型文件按已经存在的职责拆分。 +- 抽出实际重复且行为相同的实现,让原调用方指向唯一实现。 +- 为原行为增加刻画测试、API快照、数据库对比和视觉回归。 +- 删除已证明无引用、无运行路径、无视觉影响、无数据兼容责任的代码。 + +禁止: + +- 更换前端框架、后端框架、数据库或主要技术栈。 +- 重写已经存在的页面、样式、动画、公式或数据流程。 +- 以新的设计令牌、组件库或架构偏好改变最终视觉。 +- 先建立空架构,再依据规格书补写功能。 +- 以“更合理”为由修复未被用户确认的原版行为。 +- 复制`next/`中的产品实现进入新的迁移目录。 +- 为追求行数、文件数或测试数量而删除有效代码或制造空抽象。 + +## 5. 迁移单位 + +代码不能像独立照片一样任意逐文件搬运。每次迁移一个完整纵向切片: + +```text +用户入口 -> 页面结构 -> 样式与动画 -> 前端状态 -> API -> 业务计算 -> 数据库/外部数据 +``` + +切片内部可以先原样复制,再在保持输出不变的前提下拆分。不得只搬页面而稍后重写接口,也不得先 +重建全部后端再补前端。 + +## 6. 固定工作流 + +1. 冻结原版基线提交,使用数据副本,不写正式数据。 +2. 建立资产清单和依赖图,逐项标记保留、移动、合并、待定或确认废弃。 +3. 先规划目标目录职责;未确认前不建立业务代码。 +4. 为待迁移切片记录原版API、数据库副作用、页面状态、截图和交互流程。 +5. 从原版复制对应实现和资产,只调整迁移所必需的路径与依赖。 +6. 对新旧版本执行同输入差异测试,结果不等价则回退本切片。 +7. 等价后才允许拆分或去重;每次拆分再次执行同一组差异测试。 +8. 更新迁移账本、状态文件和Git回档点,再进入下一个切片。 +9. 所有切片完成后执行全量并行验收,用户确认前不切换部署。 + +## 7. 等价证据 + +每个切片至少同时具备: + +| 证据 | 要求 | +|---|---| +| 源码映射 | 原文件、符号和资产到新位置的逐项记录 | +| API差异 | 同请求的状态码、字段、值、顺序和错误一致 | +| 数据库差异 | 同操作的新增、修改、删除和事务结果一致 | +| 计算差异 | 固定输入得到逐字段相同结果 | +| 页面差异 | 同数据、主题和视口的截图及结构比较 | +| 交互差异 | 点击、键盘、滚动、弹窗、动画和刷新流程一致 | +| 人工确认 | 用户确认视觉与使用感受没有偏差 | + +新版本自身的单元测试只能作为辅助,不能代替新旧差异证据。 + +## 8. 删除规则 + +任何代码只有同时满足以下条件才可不迁移或删除: + +1. 静态引用和动态注册扫描均无消费者。 +2. 运行覆盖和真实浏览器流程未经过该路径。 +3. 不承担数据库迁移、历史兼容、配置读取或资源加载责任。 +4. 删除后原版与迁移版的全量差异测试仍一致。 +5. 迁移账本记录理由、证据和恢复提交。 + +条件不足时标记`待定`并保留,不能凭代码外观判断。 + +## 9. 上下文恢复协议 + +每次新任务、上下文压缩或执行中断后,必须先完成: + +1. 读取仓库根目录`AGENTS.md`。 +2. 读取本总纲、`保真迁移状态.json`及迁移账本。 +3. 确认`next/`仍处于失败冻结状态。 +4. 检查Git状态、当前基线提交和最后回档点。 +5. 查看正在迁移切片的原版证据与未关闭差异。 +6. 在继续编辑前向用户简述当前阶段、硬约束和下一步。 + +不得根据聊天摘要重新发明阶段、技术栈或验收口径。 + +## 10. 当前边界 + +- `next/`已失败冻结,不是迁移起点。 +- 新目录名称、目标结构和迁移顺序已于2026-07-30获得用户确认。 +- 第一切片必须先建立原样可运行副本,不允许直接开始重新实现或视觉重构。 +- 原版保持唯一可运行产品,不执行Docker切换或数据清理。 diff --git a/docs/migration/原版资产清单.json b/docs/migration/原版资产清单.json new file mode 100644 index 0000000..6c2f0c5 --- /dev/null +++ b/docs/migration/原版资产清单.json @@ -0,0 +1,3124 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-30T23:50:41+08:00", + "source_commit": "41329943c4878fc09ed82ec376eb93ab151e4092", + "source_root": ".", + "target_root": "app", + "excluded": [ + "next/", + "data/review.db and SQLite sidecars", + "data/private-mentor-skills/", + ".env", + "node_modules/", + "logs, caches and generated test results" + ], + "asset_count": 388, + "mismatch_count": 0, + "mismatches": [], + "assets": [ + { + "source": ".dockerignore", + "target": "app/.dockerignore", + "bytes": 219, + "sha256": "230cd9e5af6325f17eecc8b5ca6c712a9c1bd9d620b433491278667e749fba89", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": ".env.example", + "target": "app/.env.example", + "bytes": 970, + "sha256": "fe631a02f1045a425d8010ee3f2a5daed1fc8f7ade6167462542b6e867ec927b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": ".gitignore", + "target": "app/.gitignore", + "bytes": 337, + "sha256": "ca86e6925e5a6461648bf326fd783fe5ecc582b598d985829c83fee5684ab0f8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "ARCHITECTURE.md", + "target": "app/ARCHITECTURE.md", + "bytes": 2136, + "sha256": "acb72a863c6c4399708f2274efb130be5c0fa8ecb1b98c1699ec426c8b0a0476", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "DOCKER_DEPLOY.md", + "target": "app/DOCKER_DEPLOY.md", + "bytes": 7720, + "sha256": "776554204f9141abb000cafc525db3c1e593f831a61ab856fd43655ce79c239c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "Dockerfile", + "target": "app/Dockerfile", + "bytes": 1052, + "sha256": "ef7d39e102adccd4b98a590f956e1df0da3aa6c32f1a4244d82610359682b08a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "README.md", + "target": "app/README.md", + "bytes": 7242, + "sha256": "aa6ba22255e38515d8ab74985f8f1c13dd0fc8746561081baea8b71e4f0ae719", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "THIRD_PARTY_NOTICES.md", + "target": "app/THIRD_PARTY_NOTICES.md", + "bytes": 3468, + "sha256": "eaea9b91da8005c358460d4265091e3567a81d3438a2e0905911f2e96960608f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "advanced_strategies.py", + "target": "app/advanced_strategies.py", + "bytes": 24071, + "sha256": "1ff016053040a7f50e0b4cc408aa8b8914a517df36c8e851acab2a258d9c721a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "alert_service.py", + "target": "app/alert_service.py", + "bytes": 85, + "sha256": "0da2bd5cd9849f5b10cabc27b1ba8519df1ad35a71d4301191b0f1e1d59948f8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "api_access.py", + "target": "app/api_access.py", + "bytes": 415, + "sha256": "0bdc0587af5227fa27df1fd88e5873f21811fc02cdc3b9f7d79870da414593d5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "app_config.py", + "target": "app/app_config.py", + "bytes": 4253, + "sha256": "db6fc5f3908188ac45e00748e110803a1c84b824a7a468c983c0ae62d466506e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "assistant_agent.py", + "target": "app/assistant_agent.py", + "bytes": 3800, + "sha256": "f15909d0ac458fd4f139817c216616fce79a9887a3ee204999d881a4c0b7864c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/__init__.py", + "target": "app/backend/__init__.py", + "bytes": 66, + "sha256": "69dc41de4c00462e70b3122f0903088aa9ada7cfcfa82c7cd755ab84218e4923", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/bootstrap/__init__.py", + "target": "app/backend/bootstrap/__init__.py", + "bytes": 264, + "sha256": "029186d951646a1aeafbd507ce3f84a09ae0a9949322add1dc415edc345592e7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/bootstrap/container.py", + "target": "app/backend/bootstrap/container.py", + "bytes": 2351, + "sha256": "07ae53f271c7ba4d325afeda39a2bc16eb7d32f6a2e75a0c7f65d1d3572f95ca", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/bootstrap/settings.py", + "target": "app/backend/bootstrap/settings.py", + "bytes": 2021, + "sha256": "53daa80401d5da3c11b7249677d5ac18da5da8435dd244411d0861700e6d1ef1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/__init__.py", + "target": "app/backend/data/__init__.py", + "bytes": 392, + "sha256": "38e69d6d4064186f10354e9a304b7ab1bb320e33a8dd17ecffef9651989f3d5c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/contracts.py", + "target": "app/backend/data/contracts.py", + "bytes": 554, + "sha256": "4ad31091e938e63e450e585abeb089dc1084f47fbf12e8b4c16a1d3e00ada440", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/gateway.py", + "target": "app/backend/data/gateway.py", + "bytes": 2886, + "sha256": "3f40d213e628378ddff568d2d09af01d38d99cf2e8d6ff1d026651d7581afdde", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/policy.py", + "target": "app/backend/data/policy.py", + "bytes": 2743, + "sha256": "0805ccff95297601538718f619ef6b2a60f4007d8444cf79537f5a98c6dcbea3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/providers/__init__.py", + "target": "app/backend/data/providers/__init__.py", + "bytes": 118, + "sha256": "6ef39fc5ef59644d6cf341d73a637796caa6bd4f7263ade491fa5bbf46dda9bf", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/providers/ifind.py", + "target": "app/backend/data/providers/ifind.py", + "bytes": 335, + "sha256": "a7a54c9615e9a7c7aea0e6c2ee5ab5e1e77fb49088fd203eead0582fc8948193", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/providers/tushare.py", + "target": "app/backend/data/providers/tushare.py", + "bytes": 513, + "sha256": "c30cd00bb0f1edbd01a793ba3126eeda6625169d6bf39af4403f9158b7aa3510", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/data/quality.py", + "target": "app/backend/data/quality.py", + "bytes": 7390, + "sha256": "3e77a94f06522ee7be69cca0c6c6638a5b65d328cefe5eadd91372128c17a081", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/__init__.py", + "target": "app/backend/database/__init__.py", + "bytes": 297, + "sha256": "ddab1f010e11ab8919d61228aacc2f981251c5ad8a9c9d51ebd2fcf782fd58d8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/connection.py", + "target": "app/backend/database/connection.py", + "bytes": 952, + "sha256": "26b54bac0c28a09d2177c5fef96413157d10c6a7d8428008df1329e3dccdc7dc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/migrations/__init__.py", + "target": "app/backend/database/migrations/__init__.py", + "bytes": 385, + "sha256": "75ee014cf939f853d118ac4b6a8c4d0e2edc45de891fc74e69ca5a040368d426", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/migrations/m0001_adopt_legacy.py", + "target": "app/backend/database/migrations/m0001_adopt_legacy.py", + "bytes": 996, + "sha256": "ed8d2de1fbbf55ba94ab8164a760a559e252d843338e523032827af9db96afbd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/migrations/m0002_job_runs.py", + "target": "app/backend/database/migrations/m0002_job_runs.py", + "bytes": 1385, + "sha256": "aff3a0ea344c3e0486cbfa2d4412495df84056934909adcd55f9f5098fbb37e0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/migrations/m0003_llm_audit.py", + "target": "app/backend/database/migrations/m0003_llm_audit.py", + "bytes": 965, + "sha256": "b67b751e280cf5ac74bcdda2783fadf665b24e9875ff8fa125e75ba091e821f6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/migrations/runner.py", + "target": "app/backend/database/migrations/runner.py", + "bytes": 3321, + "sha256": "f7852c1fb60a7c20bfdb7936d8da20c193e0eda1c6bd4ffc8535068b7b9cf2c9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/repositories/__init__.py", + "target": "app/backend/database/repositories/__init__.py", + "bytes": 567, + "sha256": "df670b3230a0e07d8ef93372b2d9288137f11e112fb7f316e14efd0aefc0bc18", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/repositories/ports.py", + "target": "app/backend/database/repositories/ports.py", + "bytes": 1714, + "sha256": "38a12a0e948527d45817c6da700a56159f2257dd38bec291f0dbf888985dcec9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/database/repositories/sqlite.py", + "target": "app/backend/database/repositories/sqlite.py", + "bytes": 3865, + "sha256": "dc3ab283514e9cc9ff3196394091a05ad68f9a4fc0576a12daa10887ce9efc77", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/__init__.py", + "target": "app/backend/features/__init__.py", + "bytes": 42, + "sha256": "249745623a86d1c73442579db98eb344089f0467566522d53953d7d26e013693", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/alerts/__init__.py", + "target": "app/backend/features/alerts/__init__.py", + "bytes": 62, + "sha256": "d56494519b770e53eec0e382d7d2c2f2d0f16d5687977c5576a3bf8e031eed8d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/alerts/service.py", + "target": "app/backend/features/alerts/service.py", + "bytes": 4034, + "sha256": "2a86ad62f7c41d9213f8e593c599137864fd7e4b20ef1dd6daa92c5e88a3fc42", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/review/__init__.py", + "target": "app/backend/features/review/__init__.py", + "bytes": 136, + "sha256": "c909a3bc99df5603ef76ee5e9afa0ea485e6f592b85774216222f595c96e0434", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/review/trade_journal.py", + "target": "app/backend/features/review/trade_journal.py", + "bytes": 5154, + "sha256": "e9284031d32a49cf3a1ac1a077a4ac21279bb1f6c90347dd08f1f734c6d6dc47", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/screener/__init__.py", + "target": "app/backend/features/screener/__init__.py", + "bytes": 85, + "sha256": "fb59da8f630d23fc4c93e47110cd7527a42d024b8127bc2c1f70aee7f2d5f2ce", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/features/screener/tracking.py", + "target": "app/backend/features/screener/tracking.py", + "bytes": 5433, + "sha256": "bd2c556cd711cb46158637228a74b9719b0b17bd01aafa09e505dcaa3ccea6f8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/http/__init__.py", + "target": "app/backend/http/__init__.py", + "bytes": 295, + "sha256": "c36c4e75ccd7e2ffd1e8c5b7ca6c527d40ff43655f4e05b0a1a7f5a646eb7115", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/http/context.py", + "target": "app/backend/http/context.py", + "bytes": 284, + "sha256": "daf0ef3d1676924402117682a88f9740e36cc14e964c03728f0634cddfb1fda4", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/http/errors.py", + "target": "app/backend/http/errors.py", + "bytes": 973, + "sha256": "55d9de615eae59279ba2c803c0a59302a87c46a132463a6c0857ef63dedd5cae", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/http/router.py", + "target": "app/backend/http/router.py", + "bytes": 2582, + "sha256": "ff32a18d94b3ab541e9b2fd9aa0770692d1238eae08d4884a0261c783e2299c6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/jobs/__init__.py", + "target": "app/backend/jobs/__init__.py", + "bytes": 227, + "sha256": "5f420b83fd39c384b890884d6a7c05f5fb2f63ebc6225a4ecae8d8408438cbc5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/jobs/registry.py", + "target": "app/backend/jobs/registry.py", + "bytes": 1865, + "sha256": "51121fdbc765a2a0ec67fb2aeaef839087b817e7111e13f65da4dcff9ceed29f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/jobs/repository.py", + "target": "app/backend/jobs/repository.py", + "bytes": 3221, + "sha256": "497c7f5883e504a78374b1fdf8690ef64f1d217fd115156c58f76201ac8c559d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/jobs/runner.py", + "target": "app/backend/jobs/runner.py", + "bytes": 4415, + "sha256": "1a6015fa52b2f8f483016c994d39ad2f2c2e4b8c00e8998fe17f69e4514655fe", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/llm/__init__.py", + "target": "app/backend/llm/__init__.py", + "bytes": 230, + "sha256": "1a16a907d571eb36006d753739cf590add12576d97b91ad727197e59dce8f6ce", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "backend/llm/gateway.py", + "target": "app/backend/llm/gateway.py", + "bytes": 8404, + "sha256": "91cf3eae73451cb5a1d9fd22419a118ab6b2d2979cfe78cbaedc479c7b521d55", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "chart_data_provider.py", + "target": "app/chart_data_provider.py", + "bytes": 19518, + "sha256": "64a132efe600527d600f22eb655620b2a3ec0bdb6f6514efa93d697bebf6545f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "compose.yaml", + "target": "app/compose.yaml", + "bytes": 765, + "sha256": "601cdbd18b86eb6fedf5f88d57bc49eac3a1bad7f6c5e77344c2d6db4cd0c7fd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/README.md", + "target": "app/config/README.md", + "bytes": 1270, + "sha256": "f2f03f2f510430e96cd53a9ebc2ce13d0d3133c5287bd2054c29fc3adb9858f6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/api.config.json", + "target": "app/config/api.config.json", + "bytes": 11980, + "sha256": "328d31b3ce30028c55e675735b64d93d7a75185b4e6cbe6404ea3c3954e2e5c0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/data-fields.config.json", + "target": "app/config/data-fields.config.json", + "bytes": 4788, + "sha256": "bf227b9adfa97f3beb14e6819dd0cb62df3b14b535104debe7b27a6845ee7596", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/data-quality.config.json", + "target": "app/config/data-quality.config.json", + "bytes": 3960, + "sha256": "6871daef7457c6315b230c5d1d95609ce314d57021e3b8d7e9d24e778986fee5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/features.config.json", + "target": "app/config/features.config.json", + "bytes": 2421, + "sha256": "2996c83e3ac22da068e37cad51821e872e0ca6245b191751ab0298a9c6572fc1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/jobs.config.json", + "target": "app/config/jobs.config.json", + "bytes": 1103, + "sha256": "666a3b19b2f1bb447f20ff21ce54efac607772aea68973b420842ddda11bf6fe", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "config/pages.config.json", + "target": "app/config/pages.config.json", + "bytes": 3136, + "sha256": "9c5db4d15c3a8de20fe6de92e506f17367a2c58e17795887712d5d57381e2340", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "database.py", + "target": "app/database.py", + "bytes": 121546, + "sha256": "c288aa49fc1cf7917653a4e083c19e4ea1d576f12f1f9aece6045b7ada999821", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "demo_data.py", + "target": "app/demo_data.py", + "bytes": 17182, + "sha256": "49ed0c838e3012090888779504920bc96e03843cce4c2748c7d8f112747d2608", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "heaven_agent.py", + "target": "app/heaven_agent.py", + "bytes": 7088, + "sha256": "9395a18877ae9484a2f8688accaa5d16ba91aa1e81cda0fcfe05d5fb2a12377c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "heaven_engine.py", + "target": "app/heaven_engine.py", + "bytes": 51692, + "sha256": "f0225c01f1c70c9e8ac90b7b1f1c5d2a72375943615dad808be0b6e4a57af3b2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "ifind_client.py", + "target": "app/ifind_client.py", + "bytes": 14265, + "sha256": "ab52168d029806906a35bab4acda38e781f690c2dd6178154cda6da11f7d9067", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "llm_strategy.py", + "target": "app/llm_strategy.py", + "bytes": 5184, + "sha256": "f49cd6fed3c3b46e709bfdd7b5d996354401d4c132d24b203520c8bbc468cac0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "llm_stream.py", + "target": "app/llm_stream.py", + "bytes": 1313, + "sha256": "5d5ebc1c87f60bb318179c6209393b09591b8649d7c0c7d1b45d73ad5d427ddb", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "market_insights.py", + "target": "app/market_insights.py", + "bytes": 58066, + "sha256": "1ebf176ca1722cf82551538a23331b240f9c37208379ed0fc26f9a01ab06bf4f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "mentor_agent.py", + "target": "app/mentor_agent.py", + "bytes": 12450, + "sha256": "004fc49b2e5c380f9aa5a228b65a904236d2b611e38421d0d64aa723500c758c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "package-lock.json", + "target": "app/package-lock.json", + "bytes": 2197, + "sha256": "902f01c525c3d1a6846589fce88aef58aa53b8a7a630b5a0d2d733b9f57341a7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "package.json", + "target": "app/package.json", + "bytes": 170, + "sha256": "44ea3af86defaa076e9d2ab5486b13032b224c0db525b774797fc21fff082afb", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "playwright.config.js", + "target": "app/playwright.config.js", + "bytes": 538, + "sha256": "3519d87fd12d41351452d23fa1b106f45f2f1f4e60f036d8cbffb20fa9d4e6fd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "realtime_aggregator.py", + "target": "app/realtime_aggregator.py", + "bytes": 17288, + "sha256": "d6085e3787f1b25a096059484149d484946a2f3726e3cbea426d292d8f732d96", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "requirements.txt", + "target": "app/requirements.txt", + "bytes": 21, + "sha256": "d5b937414245e4fb32db031c55333d8b10144cdde9e4b9ec2f4574a887eb9ec9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "screener.py", + "target": "app/screener.py", + "bytes": 108535, + "sha256": "0e3964a9ed339020b8b192e14a6f711cd09f937a940bf9ce2a4b5c29fdf7f6da", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "security.py", + "target": "app/security.py", + "bytes": 2224, + "sha256": "1d9a195e80dcb30aa23e7a742e20c4f3c8f21cdb1adf11363a9ba96781d7b461", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "sentiment_engine.py", + "target": "app/sentiment_engine.py", + "bytes": 21197, + "sha256": "5e105cd1cd2f4d61acf15214ebbfb3fd1801a6e2a9469b25d24bcf26e96f83c7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "server.py", + "target": "app/server.py", + "bytes": 263768, + "sha256": "2fb23eb826db12bd5527b07a492dca43e50f1bfcaac34d433313d69f225ff1f5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/app.js", + "target": "app/static/app.js", + "bytes": 441313, + "sha256": "c020d0bc68f8a96b352a0ffa62c913a0ea3f5ca0afc41f70969f833e447166a6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/design-system.css", + "target": "app/static/design-system.css", + "bytes": 53995, + "sha256": "52e8b20a2d41b66187146325d866af917e7906056dbb868faf0ce86fac9e1d1b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/heaven-loading-v2.js", + "target": "app/static/heaven-loading-v2.js", + "bytes": 32088, + "sha256": "0ee0f78232b01cd00820e1de7ec004689624884d1bf19262b0a593c504f0c73e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/heaven-loading.js", + "target": "app/static/heaven-loading.js", + "bytes": 30563, + "sha256": "ee914762b893a89f745e9e705cac6840c1dbd6c5b68dc7a47620b8c4db3615c7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/index.html", + "target": "app/static/index.html", + "bytes": 134831, + "sha256": "e3dd495c1f5ba4fd3d4d1d2468f4ac7c525fbd316ec68a52ccf50e3e8b367e3f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages.config.js", + "target": "app/static/pages.config.js", + "bytes": 2581, + "sha256": "ad383f066d3bb90bca871a49362ba2a4823f09609ba4e65df99595b72ecb4e7c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/auction/page.js", + "target": "app/static/pages/auction/page.js", + "bytes": 122, + "sha256": "9b15f32b15d51c0f6463c4f7a30e27ebbee05eb47c0edab72fe4046aee961fa5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/dragon-tiger/page.js", + "target": "app/static/pages/dragon-tiger/page.js", + "bytes": 103, + "sha256": "c3f63538eec7eab0a19f3945c0bb68af31b9e709054f0ff6c6aa95994a362595", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/heaven/page.js", + "target": "app/static/pages/heaven/page.js", + "bytes": 117, + "sha256": "498f9801231c4a3f48025153e9211bf830848f2ffbbafdf150a1764dde8c391a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/ladder/page.js", + "target": "app/static/pages/ladder/page.js", + "bytes": 62, + "sha256": "bb70bf421c7af5fdb22905e3cdbdd24644291e353b7660a3b04884667c1c8b36", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/mentor/page.js", + "target": "app/static/pages/mentor/page.js", + "bytes": 92, + "sha256": "62f205db8c6bdf696d243818ad93e51f7b16bba53cc27a7e406266cef281eaef", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/pools/page.js", + "target": "app/static/pages/pools/page.js", + "bytes": 135, + "sha256": "186523861e11b775ad704d0a6c5fa8d7bd3cf3fa00db154ec8508ab69be497be", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/popularity/page.js", + "target": "app/static/pages/popularity/page.js", + "bytes": 104, + "sha256": "87c31aaf6c69cce7bb8bd1cc4e1aab8a2ef5e9bb084ad251b0741fa453bc8133", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/review/page.js", + "target": "app/static/pages/review/page.js", + "bytes": 101, + "sha256": "7b341760a193194ac65ed01d5271e8649d8ffec32e0ed8787f0da02c3f514944", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/rotation/page.js", + "target": "app/static/pages/rotation/page.js", + "bytes": 98, + "sha256": "e762d4208c61018996a796b06b7b827fc91cb7ff444bda5b1d62732568f57c52", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/runtime.js", + "target": "app/static/pages/runtime.js", + "bytes": 2069, + "sha256": "48f967396bdc4af1a4953828a46c2520bea17dbaa1996ff7a570c47620285c23", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/screener/page.js", + "target": "app/static/pages/screener/page.js", + "bytes": 173, + "sha256": "94e8a7fc23b6d42a590c46fadc03da5e909bc3feee935c3358ec1d20897f736c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/sentiment/page.js", + "target": "app/static/pages/sentiment/page.js", + "bytes": 106, + "sha256": "b3aaa1edc1a9a98a10a87f050bef54674a942cd1c85786daff386ef85a61de7a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/pages/themes/page.js", + "target": "app/static/pages/themes/page.js", + "bytes": 98, + "sha256": "04fab6f00b1230bbc340f05525736fb6e695ec5f7d228a7cba2b273692149efb", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/redesign-v2.css", + "target": "app/static/redesign-v2.css", + "bytes": 263539, + "sha256": "74d5a0356fb3d4b8e5c5b8b23eb91e5a2ef95678dcf8a6c6bd062a4ca12b6b44", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/renovation.css", + "target": "app/static/renovation.css", + "bytes": 83949, + "sha256": "8a12c5f8dd016e1940477f85a7b311e28598c88a83fcc326197698742d42032e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/shared/api.js", + "target": "app/static/shared/api.js", + "bytes": 3349, + "sha256": "aee776d9b75c5a83e92c1601bdf3691ca3be1f73c8e9e0cf474f8084acb5e355", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/shared/components.js", + "target": "app/static/shared/components.js", + "bytes": 1902, + "sha256": "97b83973c160045cf5e0881ddc2699a567ed47385d6bbfcec90ab29ed0c31c65", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/shared/shell.js", + "target": "app/static/shared/shell.js", + "bytes": 7909, + "sha256": "37e1ad3ae6243f6478b75ea85d0ac5a101b5684d8b051a4a78bdf1387f950933", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/shared/state.js", + "target": "app/static/shared/state.js", + "bytes": 1414, + "sha256": "2ddec021bc52cae1c20e04458ae594f4ca9d5c2c2baacedae32db75d583146a2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/shared/tokens.css", + "target": "app/static/shared/tokens.css", + "bytes": 13250, + "sha256": "564910e5a8110fa9c7baa4c6d91f9fedb99b659ba25f412cf10b171527c120be", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/styles.css", + "target": "app/static/styles.css", + "bytes": 361780, + "sha256": "d84aebcaad44fbf8c9a8a97995477664bd33b0d5034df1dbaf82c71d6203efb1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/theme.css", + "target": "app/static/theme.css", + "bytes": 36427, + "sha256": "86bd8b62ff3b1feadfd6ded506d175fb91cb0d62a18bf68ed15b18f569498501", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/ui-core.js", + "target": "app/static/ui-core.js", + "bytes": 1832, + "sha256": "ddcbfbf7622c1aa8b59cef62bcb36b2158ff15714732096a809b26205a8b177a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/vendor/lucide.min.js", + "target": "app/static/vendor/lucide.min.js", + "bytes": 357796, + "sha256": "3411692820cb8d47543f69496aa25fd603a358f4498046f41c508a5a3342210e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "static/wentian-v2.css", + "target": "app/static/wentian-v2.css", + "bytes": 73222, + "sha256": "5c5c6d09a8b4587b2a9b9580bafd0e73687323f2a13546089b386c1cffcec618", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "strategy_tracking.py", + "target": "app/strategy_tracking.py", + "bytes": 110, + "sha256": "97107279d65f8e459ea9e74d343d4488d0de732989d1209441efed5ddd1e6b5d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "sync_data.py", + "target": "app/sync_data.py", + "bytes": 1052, + "sha256": "fcd7cf110c2a2c4e4eb1871a7287c02cfc1eecc61dfa36a3c16e1b180aa1d392", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/e2e/app-shell.spec.js", + "target": "app/tests/e2e/app-shell.spec.js", + "bytes": 150708, + "sha256": "177cadf9230ac8690ceb9465f7fc56d0b4fb809a22880b1eaa5bceedc3e767fd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_account_access.py", + "target": "app/tests/test_account_access.py", + "bytes": 9315, + "sha256": "76a7c85725a712c7420dada09fdfc2109ca7d94f85b25f60cd6a997eb3eeea64", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_account_data_boundaries.py", + "target": "app/tests/test_account_data_boundaries.py", + "bytes": 13169, + "sha256": "c164630f70cba5817f19476e052c4207f62ced8ef8e4cc198d20d29b6b2f369b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_alerts.py", + "target": "app/tests/test_alerts.py", + "bytes": 3793, + "sha256": "9447345a9c315f9ed3f87dffa4e98f44bc1c9d6902831c37f5a6822817aeb32b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_api_access.py", + "target": "app/tests/test_api_access.py", + "bytes": 2452, + "sha256": "5f8d05519abadf65cf0c2bd373b398753fad32096bfe9aa730894dedafbb51a5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_bootstrap_container.py", + "target": "app/tests/test_bootstrap_container.py", + "bytes": 2282, + "sha256": "65025b49dd8fb48ca8cf822814f195fe53f2825ca53d9e0eda986a37e4061bfb", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_chart_data_provider.py", + "target": "app/tests/test_chart_data_provider.py", + "bytes": 4738, + "sha256": "6dcb83012ccc2151ca6dbe2b0a38b8948411c0d8740a0b5b1e8729c91fd1d852", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_css_governance.py", + "target": "app/tests/test_css_governance.py", + "bytes": 2628, + "sha256": "7e0df2d4fd817c934338e70cc7ec319b381ffa4e382d98906291b5b0a474c723", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_curated_screener.py", + "target": "app/tests/test_curated_screener.py", + "bytes": 23775, + "sha256": "e30555f618a7d189f0f6a465d97bd94fd3baeec8469c7ac35b891e99da3137a8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_dashboard_cache.py", + "target": "app/tests/test_dashboard_cache.py", + "bytes": 3998, + "sha256": "f8bbd089fc5b06dd41ad2011297f3890378130f07493765d2e3a6ac3e8823da9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_data_gateway.py", + "target": "app/tests/test_data_gateway.py", + "bytes": 5840, + "sha256": "26fe24e5125af24f6fb4270aade75bdde95e05854398de8f0a204b60a5df45d3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_database_migrations.py", + "target": "app/tests/test_database_migrations.py", + "bytes": 3285, + "sha256": "610a1580225a5ebe2e7eb4572bc9dc437fe95354537d24daf8bbc25a17eb905a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_deployment_contract.py", + "target": "app/tests/test_deployment_contract.py", + "bytes": 1430, + "sha256": "a3ac62b9a21e584a1a5b3222c6823a86782392eb4eee36512989d1c78d347a24", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_feature_boundaries.py", + "target": "app/tests/test_feature_boundaries.py", + "bytes": 2204, + "sha256": "447f2ed2586498acdd8ea3e48017440cb2c7df35fca6d35b008440f0fd1e1158", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_five_phase_weights.py", + "target": "app/tests/test_five_phase_weights.py", + "bytes": 3671, + "sha256": "53590ff4c8a8a7a6383b958a45ba3714f3eb59b80bdb8b4c86f56d8c72cccd02", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_frontend_boundaries.py", + "target": "app/tests/test_frontend_boundaries.py", + "bytes": 6152, + "sha256": "2910dfac2557326a5430776641f21791b3a42390c473c80520d1ae24a23487a8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_frontend_contract.py", + "target": "app/tests/test_frontend_contract.py", + "bytes": 18737, + "sha256": "1e9bdd4a33ff538472d4e811069969cc4c887bbddf238bfde33566005253d11d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_global_search.py", + "target": "app/tests/test_global_search.py", + "bytes": 3785, + "sha256": "425708b2924c49267e4fdd66c3305d43f347ca9a0a617bb105287ab2e437abbd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_governance_registries.py", + "target": "app/tests/test_governance_registries.py", + "bytes": 4488, + "sha256": "6a2d3933f03657d0a7315f405a8b9c5651a2b5a76932c354dcb8a707b0856019", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_heaven_readings.py", + "target": "app/tests/test_heaven_readings.py", + "bytes": 4681, + "sha256": "ff26a05e7dd283fc934ae2b9252b79ca9a64dc8f3c4f773629e4d9463456e63c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_heaven_realtime.py", + "target": "app/tests/test_heaven_realtime.py", + "bytes": 15458, + "sha256": "f3bda7f81e2e7395709933872fc9f8add5385a750f2d2e404caccd8105b13191", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_hot_money_profiles.py", + "target": "app/tests/test_hot_money_profiles.py", + "bytes": 2519, + "sha256": "db9317910f6442d0814c47e4594f0fdbffe6e061f7e3b2faecba85d40259373e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_http_governance.py", + "target": "app/tests/test_http_governance.py", + "bytes": 2067, + "sha256": "9bf16d2ccbb6b4e5740fc59abe5eedf8740bd547bc81f7786de8af064f27c5a9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_ifind_client.py", + "target": "app/tests/test_ifind_client.py", + "bytes": 1808, + "sha256": "0229b9bfd323f5d3abeb62b8891602646457d4a0fa63d558e219da8518607e72", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_ifind_features.py", + "target": "app/tests/test_ifind_features.py", + "bytes": 6736, + "sha256": "c2cccf4a1c2859589c1205cbc699596cca8a244da50797830b8fe47e5bc6c7fe", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_job_runner.py", + "target": "app/tests/test_job_runner.py", + "bytes": 2672, + "sha256": "4a8c8adef5c82509080973308c704d1fc22024beb8aea195a9efb9694f89ac3f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_llm_gateway.py", + "target": "app/tests/test_llm_gateway.py", + "bytes": 3957, + "sha256": "a45e2c3ccfd226d7119bc2c9bd1661a00a07da7a9a5700462c48897237f157d5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_llm_stream.py", + "target": "app/tests/test_llm_stream.py", + "bytes": 1332, + "sha256": "dddc286e8bc8314271e7392709557f252b4b73588512af001c0c636c0d798791", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_market_insights.py", + "target": "app/tests/test_market_insights.py", + "bytes": 15299, + "sha256": "a6eb57ad2c46f6c2aaa6249dd667123fa5704529768851302242fa31b37964f4", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_market_mode.py", + "target": "app/tests/test_market_mode.py", + "bytes": 8424, + "sha256": "32df6d91dc33fb918ea705cb5a10f7d97583f153ef3b5bdd6473093c44752dd0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_mentor_registry.py", + "target": "app/tests/test_mentor_registry.py", + "bytes": 3715, + "sha256": "d63b48ed654ff8f32cfaf2002a31ecdad90fe3648c037a4c00c2d6765b09deb4", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_mentor_stream.py", + "target": "app/tests/test_mentor_stream.py", + "bytes": 3733, + "sha256": "d1b55a7d5a4821064f507b6abf72216762ed5d34dfe80c83855bd11d7db5cae8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_realtime_dashboard.py", + "target": "app/tests/test_realtime_dashboard.py", + "bytes": 5191, + "sha256": "22eb373954f6aaf1b7507493a7a678a2471efd3f0cfbef1e69a5bba2bf7e30e2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_repository_boundaries.py", + "target": "app/tests/test_repository_boundaries.py", + "bytes": 2202, + "sha256": "d24dca7d4b92804d26ad0048318a1e65224a3ce8560d6e0f36399fc8e267f492", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_review_assistant.py", + "target": "app/tests/test_review_assistant.py", + "bytes": 3805, + "sha256": "83e2c091aace162cfb4e7144a310c54d7c0c3cfe5bea592ffb024e7f7787a04c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_sentiment_engine.py", + "target": "app/tests/test_sentiment_engine.py", + "bytes": 1308, + "sha256": "323922a011df5cc77bef60f81defb1a38debb879ed5559e90b01d20e56c4cb72", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_stock_detail_realtime.py", + "target": "app/tests/test_stock_detail_realtime.py", + "bytes": 6008, + "sha256": "3fd337c5cd45072f091b5f7cb7b096736931d6b6a3a81e9c388e3075f0e48852", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_strategy_tracking.py", + "target": "app/tests/test_strategy_tracking.py", + "bytes": 5965, + "sha256": "c0762fa26d4b842f04328b767a80faa649dfcc4e7083c4a813644c437373dea9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tests/test_trade_journal.py", + "target": "app/tests/test_trade_journal.py", + "bytes": 3417, + "sha256": "1da802488dcea0f6e8912b5bf352a784f394dd603e7af1bf1e4998a78b9e824c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tools/build_api_registry.py", + "target": "app/tools/build_api_registry.py", + "bytes": 3940, + "sha256": "5b44d5681ed5e788ea9e93bfcbe5feb501729956af4121d6776fbe03fa07f0ea", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tools/build_architecture_inventory.py", + "target": "app/tools/build_architecture_inventory.py", + "bytes": 5879, + "sha256": "2f0dd25cd841029662e2faca6f0e95fae0d07c2e50945b3310d1f30e9847e8da", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tools/build_preservation_manifest.py", + "target": "app/tools/build_preservation_manifest.py", + "bytes": 3707, + "sha256": "497aec086254a14129616a942cf0726a2d373eed7766000ae1fb226054df0d7a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tools/verify_baseline.py", + "target": "app/tools/verify_baseline.py", + "bytes": 1908, + "sha256": "89a4b17efb34acda0f9d8d6e1f2efc4b5950f16ab1be853f7ee2e5f3c7c19b38", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "trade_journal.py", + "target": "app/trade_journal.py", + "bytes": 159, + "sha256": "880810f655c274a9679fca8f7e8029157a93a748cb23fd520a1c4160ba815446", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "tushare_client.py", + "target": "app/tushare_client.py", + "bytes": 94312, + "sha256": "2715ea6e6d821daa4a07f81b5d9ad40c0eb00e2f4d0d10f8e3588c5363dae204", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/INSTALLER", + "target": "app/vendor/lunar_python-1.4.8.dist-info/INSTALLER", + "bytes": 4, + "sha256": "ceebae7b8927a3227e5303cf5e0f1f7b34bb542ad7250ac03fbcde36ec2f1508", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/METADATA", + "target": "app/vendor/lunar_python-1.4.8.dist-info/METADATA", + "bytes": 501, + "sha256": "ffa362fe5922f833bca013f34bf58f4b77430be92e5a43a49fb3726d78466386", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/RECORD", + "target": "app/vendor/lunar_python-1.4.8.dist-info/RECORD", + "bytes": 5379, + "sha256": "10add0b9df7496da8797309482702687a6841828d0d46ce2a5e5081f4ffe334e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/REQUESTED", + "target": "app/vendor/lunar_python-1.4.8.dist-info/REQUESTED", + "bytes": 0, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/WHEEL", + "target": "app/vendor/lunar_python-1.4.8.dist-info/WHEEL", + "bytes": 91, + "sha256": "2b6eb4118ce7cd7b09601406aa623c553c4476265836f0d9c16f5c061f7efcc0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/licenses/LICENSE", + "target": "app/vendor/lunar_python-1.4.8.dist-info/licenses/LICENSE", + "bytes": 1061, + "sha256": "a9d04f47f0615c0ce48bdbe2ff58d5c174279d9f20f044ef29249632302a4ab3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python-1.4.8.dist-info/top_level.txt", + "target": "app/vendor/lunar_python-1.4.8.dist-info/top_level.txt", + "bytes": 13, + "sha256": "0696650a295408c6e6f9c12dd5b475ba3cccb96b419670aa66770f809656a9e8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/EightChar.py", + "target": "app/vendor/lunar_python/EightChar.py", + "bytes": 13761, + "sha256": "d38241e75b693f25fc8c2a3e1587fd028a0a52cc504f9d5c7b47326eef051c0c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Foto.py", + "target": "app/vendor/lunar_python/Foto.py", + "bytes": 3848, + "sha256": "94e3e72fb625f0e60a474d420f7ceb24bca8ee647243d7f1b20140b9238b2cf8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/FotoFestival.py", + "target": "app/vendor/lunar_python/FotoFestival.py", + "bytes": 946, + "sha256": "44d2d1853e47b5155b1090b832ecb60a446f64e4654b419f46f2be908ef03798", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Fu.py", + "target": "app/vendor/lunar_python/Fu.py", + "bytes": 768, + "sha256": "ef61ccbfa86e7d2481dda0e0e3d413dc3ede1d3045518a45b71a00c4de518a54", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Holiday.py", + "target": "app/vendor/lunar_python/Holiday.py", + "bytes": 1263, + "sha256": "109a40074b0dd53d50353157fb808e30565f9b8fde198eb79153e2110105a3f2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/JieQi.py", + "target": "app/vendor/lunar_python/JieQi.py", + "bytes": 1383, + "sha256": "c4d760d317d09053b228dd851616ba68a5455482f206e08ac750ca6148daf459", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Lunar.py", + "target": "app/vendor/lunar_python/Lunar.py", + "bytes": 48684, + "sha256": "489a1725a2ba443f9e8acf0534f05a0ee28c24f8904adb129c8a30daa3ed7079", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/LunarMonth.py", + "target": "app/vendor/lunar_python/LunarMonth.py", + "bytes": 5419, + "sha256": "4559d617666bb00ad8db1633bd59332242795b81bd049643a64cfe6aff47ab28", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/LunarTime.py", + "target": "app/vendor/lunar_python/LunarTime.py", + "bytes": 5108, + "sha256": "63f073d30c524cc25abf8c58adcc02d6fe836eba1f450254c7f231fd8103f91a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/LunarYear.py", + "target": "app/vendor/lunar_python/LunarYear.py", + "bytes": 12228, + "sha256": "84308cf06c2e7666401676b58fdbc1a9a81491584b710cd9927fd5d2d579bbb5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/NineStar.py", + "target": "app/vendor/lunar_python/NineStar.py", + "bytes": 5148, + "sha256": "b52c00d12d5c8080a27d9f34afba3a8e4e3c6e7941033fb55fb5a507ab5cd1ec", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/ShuJiu.py", + "target": "app/vendor/lunar_python/ShuJiu.py", + "bytes": 577, + "sha256": "af852d2db583d0b08757ebdf39d1064a0e6101506e976f6d02ba32e4bdb5952a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Solar.py", + "target": "app/vendor/lunar_python/Solar.py", + "bytes": 14919, + "sha256": "8fa3df760f59ea937dae7125d49406985df90d92178a0741d6d4c2ff85b26d5e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/SolarHalfYear.py", + "target": "app/vendor/lunar_python/SolarHalfYear.py", + "bytes": 1690, + "sha256": "569c98ab78e137aebf431ca85512a2e2610c637d2d747ed9ffde707aeebfd3c3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/SolarMonth.py", + "target": "app/vendor/lunar_python/SolarMonth.py", + "bytes": 2196, + "sha256": "f3957598018c35114099cd48030fa287c1767f69b63dd84c51dd999c5997827b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/SolarSeason.py", + "target": "app/vendor/lunar_python/SolarSeason.py", + "bytes": 1638, + "sha256": "4fdaca631c3f28112e4d557f8ce88037b31c85fd257db5872f10aa5a68e38192", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/SolarWeek.py", + "target": "app/vendor/lunar_python/SolarWeek.py", + "bytes": 5707, + "sha256": "f90bc349f31d52bc25646957b443b8c9e41d68874b714d947915d1535588291b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/SolarYear.py", + "target": "app/vendor/lunar_python/SolarYear.py", + "bytes": 1130, + "sha256": "66e87edb42688fc805bcfe39bce4958fe810a76fed294aaa514402d6f6304a10", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/Tao.py", + "target": "app/vendor/lunar_python/Tao.py", + "bytes": 3803, + "sha256": "bed11b1de9d83a4381029d3c7b57325eff7943a74fe52abf69255ba351044f37", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/TaoFestival.py", + "target": "app/vendor/lunar_python/TaoFestival.py", + "bytes": 597, + "sha256": "d3fc8f576c74dc5e83b7233d20873f094b23e0f3e2a0680b54d4d54450e0f9ea", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/__init__.py", + "target": "app/vendor/lunar_python/__init__.py", + "bytes": 638, + "sha256": "fddc55f6b0bd5cc424f4d861d910fa938f94bd5c5761517672a279f32854f5c7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/DaYun.py", + "target": "app/vendor/lunar_python/eightchar/DaYun.py", + "bytes": 2611, + "sha256": "039749b92900097bcfa40e8b67b44eca7b7b48ecb3af0ce1b8c554a762c82ce7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/LiuNian.py", + "target": "app/vendor/lunar_python/eightchar/LiuNian.py", + "bytes": 1447, + "sha256": "a069ceca115f0ddeba0b720bf3edf20c882aec88c25140478d4742218b26678b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/LiuYue.py", + "target": "app/vendor/lunar_python/eightchar/LiuYue.py", + "bytes": 1642, + "sha256": "4aa59e23263a1b3ac6979ff57a0abf4f15f3cb10976e6e1305308d0d3f8f0a46", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/XiaoYun.py", + "target": "app/vendor/lunar_python/eightchar/XiaoYun.py", + "bytes": 1334, + "sha256": "a1ea1352db91f967ca6b77ebf2827f789aeeb715cfdbe4664dfc8a1ee5009ffd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/Yun.py", + "target": "app/vendor/lunar_python/eightchar/Yun.py", + "bytes": 3423, + "sha256": "b532004acb38a3caa7f399e3a3d5269c586538f9acf95e526be8bbd2d31bf696", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/eightchar/__init__.py", + "target": "app/vendor/lunar_python/eightchar/__init__.py", + "bytes": 155, + "sha256": "b2ea776642cb827d79ec1110cdfdcf8ad98eec63bc8f90a83ece496bb378cfbb", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/FotoUtil.py", + "target": "app/vendor/lunar_python/util/FotoUtil.py", + "bytes": 14678, + "sha256": "e07a7f395082e46c691bf6579a096b9b71755aee7152d98b3a89b7ca74323e74", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/HolidayUtil.py", + "target": "app/vendor/lunar_python/util/HolidayUtil.py", + "bytes": 21983, + "sha256": "e62b6e8d796ac8df6a058788133f336f47cf320196f45ed03cc7e99ef86b6ae6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/LunarUtil.py", + "target": "app/vendor/lunar_python/util/LunarUtil.py", + "bytes": 109420, + "sha256": "87a84818a3ddd10925cbb28ec04a048b27e1ac46f291ff4d9a861f15fb59aa00", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/ShouXingUtil.py", + "target": "app/vendor/lunar_python/util/ShouXingUtil.py", + "bytes": 67277, + "sha256": "5247110bf663bce23e9185cc9d4022950a9f7be5664a619286c1b657476ad9d6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/SolarUtil.py", + "target": "app/vendor/lunar_python/util/SolarUtil.py", + "bytes": 10017, + "sha256": "bba034d893ef74e4f219452ed143302082532990b2aafc852f74c90bfb25a7a3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/TaoUtil.py", + "target": "app/vendor/lunar_python/util/TaoUtil.py", + "bytes": 8061, + "sha256": "20ea01c41caa09affbaac0da793b88efb7e1cc4bb363aeb6d1edef2114493dee", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "vendor/lunar_python/util/__init__.py", + "target": "app/vendor/lunar_python/util/__init__.py", + "bytes": 226, + "sha256": "8beb7a058bcf5862b53441beb41855140e8ef4c914f59eef22cfb81f1f4ad8f2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/SKILL.md", + "target": "app/游资skills/asking-perspective/SKILL.md", + "bytes": 5884, + "sha256": "9b0769b3eae7b14a4bc5e3c7a07fde9fc63be1bff420e2601c99270705d867f5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/01-writings.md", + "target": "app/游资skills/asking-perspective/references/research/01-writings.md", + "bytes": 580, + "sha256": "1a3b2d9c06a6475e8bd98e881821c8b442d732aecf6d94a880ddf4b5755a077f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/02-conversations.md", + "target": "app/游资skills/asking-perspective/references/research/02-conversations.md", + "bytes": 318, + "sha256": "4cc528c0155a0169ab2c56596132d6d7a0b69b2589cfb4493fe6d7669705b592", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/asking-perspective/references/research/03-expression-dna.md", + "bytes": 299, + "sha256": "9cb50c8b88bc4f7762396981d55a60108ed7f185fced45de0e09c366df035c3e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/04-external-views.md", + "target": "app/游资skills/asking-perspective/references/research/04-external-views.md", + "bytes": 425, + "sha256": "e7ab1d2957e2184d009f50c433e54fc9b041e8f059a7d04d727b4ac78215d8b6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/05-decisions.md", + "target": "app/游资skills/asking-perspective/references/research/05-decisions.md", + "bytes": 251, + "sha256": "0cb6624e1e831084d39b86fc598bac1628bc8cff3eba9c7d543678ab56f8c989", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/asking-perspective/references/research/06-timeline.md", + "target": "app/游资skills/asking-perspective/references/research/06-timeline.md", + "bytes": 347, + "sha256": "ec41a6f746f61036c455f0011e57c191ad8101e3fda84972e1d03b4f93da2a6a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/SKILL.md", + "target": "app/游资skills/beijingchaojia-perspective/SKILL.md", + "bytes": 6402, + "sha256": "96c8a8a190f4ab9664ba8eb5dc0cd31cd04050648a50baab74ea1e4f53b8e36a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/01-writings.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/01-writings.md", + "bytes": 596, + "sha256": "ebc22af57a65c9ec99359d2f6c9fbc49e18a72104ca65ccf2c0b8a00a3ac725b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/02-conversations.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/02-conversations.md", + "bytes": 349, + "sha256": "ea1f331a52859f573c820e47094570583f867c607b98a82ec44ab798acfd4a9b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/03-expression-dna.md", + "bytes": 258, + "sha256": "944741a48ee08772a174f7035977039b4397f398d67f783b55390ea2258455d5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/04-external-views.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/04-external-views.md", + "bytes": 389, + "sha256": "92856851c57aca51583f8ebfcacb84a668ffa75212a1e0328ae2f3a48771518d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/05-decisions.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/05-decisions.md", + "bytes": 314, + "sha256": "1eed09c2b3485496b825d3a8750bf92944e46e2d72c962c585f7cff37c73794d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/beijingchaojia-perspective/references/research/06-timeline.md", + "target": "app/游资skills/beijingchaojia-perspective/references/research/06-timeline.md", + "bytes": 294, + "sha256": "fc8064596034837cfbabf175d434e1c7391c16a9f1fdc43946433cb01a027294", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/SKILL.md", + "target": "app/游资skills/chuangshiji-perspective/SKILL.md", + "bytes": 5620, + "sha256": "c3c859b14a8efb69b45e377467c3478a3a2b03a902b1e979fbf83cc7d6f5593f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/01-writings.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/01-writings.md", + "bytes": 604, + "sha256": "4f090872df953dce61c2486232633bb808fbcf07ff9c549e096a193da4e202d9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/02-conversations.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/02-conversations.md", + "bytes": 313, + "sha256": "6d9de4ffd9c802377a94f9c4fd802504a237aae55e9af2917d599e7773ff32c9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/03-expression-dna.md", + "bytes": 219, + "sha256": "9a0453edfced6046d3ac8489e4f00534a796f99208faefda92d02d10af85c0be", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/04-external-views.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/04-external-views.md", + "bytes": 294, + "sha256": "e1d8db2648d25ededf80c7c83fb5f997f9179abea677ef71718c1536caec7b59", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/05-decisions.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/05-decisions.md", + "bytes": 264, + "sha256": "dc37d0618ccff499426e744db69a6c156f6235ebcb8ae833920e67526ea87081", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/chuangshiji-perspective/references/research/06-timeline.md", + "target": "app/游资skills/chuangshiji-perspective/references/research/06-timeline.md", + "bytes": 312, + "sha256": "6fc099bf2b45bdda27446b9dccb8c84647ddd7115ca925568bbbc64c480cd806", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/SKILL.md", + "target": "app/游资skills/fangxinxia-perspective/SKILL.md", + "bytes": 5872, + "sha256": "681e6e85e5275125f35ba292609b870c4d6de17a5f4c78e410a90059e705a132", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/01-writings.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/01-writings.md", + "bytes": 481, + "sha256": "1240440ad2821a8644f9535d60ab18ce8037349fd8d59f3b1d813440e1250bd7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/02-conversations.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/02-conversations.md", + "bytes": 275, + "sha256": "75851cb3f0651fd05f32a53772d24d7441dd3c29012d834e54970a3121747194", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/03-expression-dna.md", + "bytes": 230, + "sha256": "62ba52fd1ac0b8744fa4c32fd6d2b4f079a1c26cdff07e2333c737a8325801d5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/04-external-views.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/04-external-views.md", + "bytes": 354, + "sha256": "76207223af30123c4f0dfaaf0110b73f5a978b37ffc8512913a1918b9fd23c1b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/05-decisions.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/05-decisions.md", + "bytes": 284, + "sha256": "ed53e477e1fa4e207bbf4c3cb296c8eb798162760d0a0d1de8723cc43e881071", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/fangxinxia-perspective/references/research/06-timeline.md", + "target": "app/游资skills/fangxinxia-perspective/references/research/06-timeline.md", + "bytes": 328, + "sha256": "37676d025478be36f96a06c7ea44e0f064877881cc490d5c9d7611914cff36b9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/SKILL.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/SKILL.md", + "bytes": 5237, + "sha256": "e5fed1282bdce0ca670afae01b0d5c30c1710bd775a2d99a15530464afb15e60", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/01-writings.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/01-writings.md", + "bytes": 457, + "sha256": "b8931d32779d90e58251ca2914d4bcb6843639e8afec6e435e8eadfcae760d68", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/02-conversations.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/02-conversations.md", + "bytes": 242, + "sha256": "081cab05ca41e1c28d7359a66ed03e5ca9c21370e7124057276b7cb6bc0c297b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/03-expression-dna.md", + "bytes": 245, + "sha256": "1748a3b388e629e22a6493caaa76537acfc4051eb187191ab79e39a7e7021b10", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/04-external-views.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/04-external-views.md", + "bytes": 489, + "sha256": "73d69c85014eed320465c8f7afefca60585b19b94ec9fd7ced09b4d9b531aefc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/05-decisions.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/05-decisions.md", + "bytes": 383, + "sha256": "4c72ed226eb493fa6a06d8299e6d1a474b59ef43f7c6558575e220c4209d50f6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/foshanwuyingjiao-perspective/references/research/06-timeline.md", + "target": "app/游资skills/foshanwuyingjiao-perspective/references/research/06-timeline.md", + "bytes": 326, + "sha256": "95bc2386e7dac54694aa5363f24dc09925d06148c3b1a8c24f9fc22bc42e1442", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/SKILL.md", + "target": "app/游资skills/kobe92-perspective/SKILL.md", + "bytes": 6190, + "sha256": "66885b0e1081d49c48204e099b1df6d39c73aa83263cb20110cc884bb07dcd59", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/01-writings.md", + "target": "app/游资skills/kobe92-perspective/references/research/01-writings.md", + "bytes": 645, + "sha256": "45f8d800dd8bf2913f1cff85233c5e68513b6255a113e3570bb40b1a9b0d9f0d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/02-conversations.md", + "target": "app/游资skills/kobe92-perspective/references/research/02-conversations.md", + "bytes": 339, + "sha256": "51073d902ca69e5e7dc599bf70facc0506566c03390e7722760e46df98490951", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/kobe92-perspective/references/research/03-expression-dna.md", + "bytes": 253, + "sha256": "5968ead3a3170279cba6df2c0af36611b05625fd7014b95bcdfebfa12515343a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/04-external-views.md", + "target": "app/游资skills/kobe92-perspective/references/research/04-external-views.md", + "bytes": 420, + "sha256": "c06f1ac90ae600f14a16eb98fcebd8415049bea84ddbff0e4d1c2eca7f363643", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/05-decisions.md", + "target": "app/游资skills/kobe92-perspective/references/research/05-decisions.md", + "bytes": 326, + "sha256": "a6500e97c9f837c72057a7e9e36a08156e8d03e0687d623148cf5d8b68077741", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/kobe92-perspective/references/research/06-timeline.md", + "target": "app/游资skills/kobe92-perspective/references/research/06-timeline.md", + "bytes": 362, + "sha256": "5db2b257c855ee7a3b74c0f788c151abb4d50da26c6ff798b396b6d6a31576a1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/SKILL.md", + "target": "app/游资skills/longfeihu-perspective/SKILL.md", + "bytes": 6083, + "sha256": "8cabd76e9fc80e29c296f4c9ffb9bc30db3e545b8b5d5d0eacbec0ef2a9bc2d6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/01-writings.md", + "target": "app/游资skills/longfeihu-perspective/references/research/01-writings.md", + "bytes": 548, + "sha256": "9a35de2785d7fcb3b0b2d8825a0e3d797df21a1dfd7ca4d72e885626edd3b53f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/02-conversations.md", + "target": "app/游资skills/longfeihu-perspective/references/research/02-conversations.md", + "bytes": 392, + "sha256": "e939e231e85e94336aa04d8038bad634fbbc6608746cf2bf05f39541dc2b3cbc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/longfeihu-perspective/references/research/03-expression-dna.md", + "bytes": 298, + "sha256": "f5d2071326c1cebb284a9812718e9ef6736c7772af59d15f40cefd621a02130b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/04-external-views.md", + "target": "app/游资skills/longfeihu-perspective/references/research/04-external-views.md", + "bytes": 321, + "sha256": "83b3df46b34bb2a519edb81def1d814474ca367de9cd33cd9dd8fc37e556c46d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/05-decisions.md", + "target": "app/游资skills/longfeihu-perspective/references/research/05-decisions.md", + "bytes": 342, + "sha256": "1052fd2e18069926fc015aedbd2768c7a46eaa39fb511ce70bcd1e45e99a1eb2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/longfeihu-perspective/references/research/06-timeline.md", + "target": "app/游资skills/longfeihu-perspective/references/research/06-timeline.md", + "bytes": 398, + "sha256": "5a96420d63144d25399aed24bc348d68fe4ed652f1b0d64e39a33a73cf53c7f2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/mentor_catalog.json", + "target": "app/游资skills/mentor_catalog.json", + "bytes": 4518, + "sha256": "7e192e37d460a109f9c97e666ebe39ac1bcf1555a006a965d171350e62cb3d9b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/SKILL.md", + "target": "app/游资skills/niepanchongsheng-perspective/SKILL.md", + "bytes": 7130, + "sha256": "cd7b7e47e70101ea7ffc38d90277efb8d66fb55a9a8b17709d1b7cee5cc57991", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/01-writings.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/01-writings.md", + "bytes": 786, + "sha256": "e5d47a201a18f6967592f674016a83bb23d86976886a0209d401aab496f39411", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/02-conversations.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/02-conversations.md", + "bytes": 348, + "sha256": "7d4e52787afdc55622e379bf6cb6da93ddb6be6a68124077b896900b4a830918", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/03-expression-dna.md", + "bytes": 362, + "sha256": "ad5eb4f1fc1cec01de6836e95eb09a23eb18034d04cad84cd846ec095f23fbc8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/04-external-views.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/04-external-views.md", + "bytes": 515, + "sha256": "49a432b656672ad11ca2542d3ec438750635d47e3ded1dcf73f94c04169918d9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/05-decisions.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/05-decisions.md", + "bytes": 393, + "sha256": "46eff643e1b96f32a65f146a092b7d3f9553643fbbf0ecd3a5ce7b3722293917", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/niepanchongsheng-perspective/references/research/06-timeline.md", + "target": "app/游资skills/niepanchongsheng-perspective/references/research/06-timeline.md", + "bytes": 383, + "sha256": "9577219e99e2d0a76ace87d78092fd8e36f6dd1f621a1d44031d4b98beb99a5f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/SKILL.md", + "target": "app/游资skills/qiaobangzhu-perspective/SKILL.md", + "bytes": 6078, + "sha256": "d46be5a1ebeedd8bea181c115c4d9b162e749a3270d2be1681820348304303b6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/01-writings.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/01-writings.md", + "bytes": 635, + "sha256": "01f7963379f403811cfa5a9c6a0530e0ba9b9a5ad72328fbc62bdd376ffdd7e8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/02-conversations.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/02-conversations.md", + "bytes": 305, + "sha256": "4f7dae9a2c1e48af7d2fffb6358b595032c44aae5669366c8b94050c6189a27c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/03-expression-dna.md", + "bytes": 324, + "sha256": "8270f8e44aedc5cfe0daae4ddea7220cce5e25f462685ce20228c885df1d616f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/04-external-views.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/04-external-views.md", + "bytes": 301, + "sha256": "ddfa1e53d1103ab0a90bbca5fdd1b5cbfdc9d629514d0e1ea81227e7dffe2006", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/05-decisions.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/05-decisions.md", + "bytes": 320, + "sha256": "ddc4df06c064914241d8a2670f816f6551db8449b836b9ec32eacf5e309e0058", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/qiaobangzhu-perspective/references/research/06-timeline.md", + "target": "app/游资skills/qiaobangzhu-perspective/references/research/06-timeline.md", + "bytes": 372, + "sha256": "cc94f779716053313b684f013882323ae76908c48902a9d1f05e780561d1931a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/SKILL.md", + "target": "app/游资skills/ruihexian-perspective/SKILL.md", + "bytes": 6108, + "sha256": "3357d3a4e6522cbf30abc741a6f1db4778869f0ecf6f57c1450a1028b3c126b2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/01-writings.md", + "target": "app/游资skills/ruihexian-perspective/references/research/01-writings.md", + "bytes": 598, + "sha256": "343823b2f136e0938f665caca4feda14b9e7e2ca3837da5ad138f0729e478a9e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/02-conversations.md", + "target": "app/游资skills/ruihexian-perspective/references/research/02-conversations.md", + "bytes": 351, + "sha256": "f87b80b20a80409194c84daafb18957a15bf001ef25c51841e01c91afb43a2c4", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/ruihexian-perspective/references/research/03-expression-dna.md", + "bytes": 270, + "sha256": "ff0c6d39b604a11103d95226a23d849dbac92d57d0595e8d9a4c0679358a1cb2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/04-external-views.md", + "target": "app/游资skills/ruihexian-perspective/references/research/04-external-views.md", + "bytes": 372, + "sha256": "e3e3bbc69e76549c25f96b167ba96a92f303f677edb39359854403e9f1a03a68", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/05-decisions.md", + "target": "app/游资skills/ruihexian-perspective/references/research/05-decisions.md", + "bytes": 305, + "sha256": "c97e19375902e0ed1e2a6136268e54e4468ef89645f07f3b6830a020454c8706", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/ruihexian-perspective/references/research/06-timeline.md", + "target": "app/游资skills/ruihexian-perspective/references/research/06-timeline.md", + "bytes": 366, + "sha256": "177e7481ba670ef111f4a880cee68672217b213b8fc159f4167a9ac4402bfac7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/SKILL.md", + "target": "app/游资skills/shuipi-perspective/SKILL.md", + "bytes": 9443, + "sha256": "9138be6dc9500b4f6095a11cfe909f24216dc86ae42db203ffcc6a7e8cdab272", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/01-writings.md", + "target": "app/游资skills/shuipi-perspective/references/research/01-writings.md", + "bytes": 1574, + "sha256": "933dd729a8eb03aeda5256fb9d2d198c65f326e693b21d845df7dc7ea96e795c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/02-conversations.md", + "target": "app/游资skills/shuipi-perspective/references/research/02-conversations.md", + "bytes": 542, + "sha256": "6655dd705da90a015dcfe5206319db7afa145338199ff436560e70ccb8191661", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/shuipi-perspective/references/research/03-expression-dna.md", + "bytes": 690, + "sha256": "177b23692a1cf55a628655bbd55a54c1e22ed18889ae5f153e42e181d1ec4b4e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/04-external-views.md", + "target": "app/游资skills/shuipi-perspective/references/research/04-external-views.md", + "bytes": 740, + "sha256": "d7d4eb08910349e1da91bb663959be053c3cd876a0ac470135057e52aae85ef7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/05-decisions.md", + "target": "app/游资skills/shuipi-perspective/references/research/05-decisions.md", + "bytes": 967, + "sha256": "3b895972afdf1dda5befb24c1a4f1eac93da12e6d130d5d00ec7d13a9899f45b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/shuipi-perspective/references/research/06-timeline.md", + "target": "app/游资skills/shuipi-perspective/references/research/06-timeline.md", + "bytes": 530, + "sha256": "16a7c268575fb15f36abb318d11979fa4415b5aa9f0d9277cf7bf6d36e9abd15", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/SKILL.md", + "target": "app/游资skills/sunge-perspective/SKILL.md", + "bytes": 5907, + "sha256": "fa2e1501a712c689dd8beaa7b2c90e4920e163dad9ead6e30dcd9565019f1b09", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/01-writings.md", + "target": "app/游资skills/sunge-perspective/references/research/01-writings.md", + "bytes": 716, + "sha256": "ad4ad5b6ae03def94c3a0f73c7941d001cc6596f9440f6a723b2d5bb72ff7d96", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/02-conversations.md", + "target": "app/游资skills/sunge-perspective/references/research/02-conversations.md", + "bytes": 285, + "sha256": "e190b12d34933905149462796be3126ebb25f3ae1ac8a853895849f7c1ba5a6e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/sunge-perspective/references/research/03-expression-dna.md", + "bytes": 221, + "sha256": "e4bdce779230bda607aa385081b3ff336a915c06009df9de42329b1826eb5312", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/04-external-views.md", + "target": "app/游资skills/sunge-perspective/references/research/04-external-views.md", + "bytes": 351, + "sha256": "3a49d9d5d7e1a67de357699e26c1a8bbd5d7109d3acf08d4d8bb64d477e4256e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/05-decisions.md", + "target": "app/游资skills/sunge-perspective/references/research/05-decisions.md", + "bytes": 269, + "sha256": "22e9bddc154b1647e7107df9455b74f3de5339671b6236d50e26a0e041679879", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/sunge-perspective/references/research/06-timeline.md", + "target": "app/游资skills/sunge-perspective/references/research/06-timeline.md", + "bytes": 358, + "sha256": "3cc5e5a0bdb945b766e7a28e3505ba182562ecd29f023ca171341d36ebaa2ee0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/SKILL.md", + "target": "app/游资skills/xiaoe-perspective/SKILL.md", + "bytes": 5396, + "sha256": "ac1c8b9e90e03ee02f1f83f8500cff08f4a33539dc705d1472e15b7d95ecd9f2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/01-writings.md", + "target": "app/游资skills/xiaoe-perspective/references/research/01-writings.md", + "bytes": 534, + "sha256": "51926097f3f3662e2d1476a783ab161cf92fc74b5af9f34015a13b1fa69c08c6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/02-conversations.md", + "target": "app/游资skills/xiaoe-perspective/references/research/02-conversations.md", + "bytes": 265, + "sha256": "c22526a4867ddf793c94bd20967ef7c4d217edc3044c06b4a08e6ef53443a6fe", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/xiaoe-perspective/references/research/03-expression-dna.md", + "bytes": 188, + "sha256": "b62b50b1407ca10d53aa60dfa73acfab23e40437dfb7a906df978951382feaf0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/04-external-views.md", + "target": "app/游资skills/xiaoe-perspective/references/research/04-external-views.md", + "bytes": 438, + "sha256": "de9259718eb975181da8104a3c87c897d69462f372b93d568346204a0fd4e94e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/05-decisions.md", + "target": "app/游资skills/xiaoe-perspective/references/research/05-decisions.md", + "bytes": 260, + "sha256": "061a9427db899ae42ca1d5c40863cb17341c02927a5e638debd12541f0d37215", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xiaoe-perspective/references/research/06-timeline.md", + "target": "app/游资skills/xiaoe-perspective/references/research/06-timeline.md", + "bytes": 348, + "sha256": "cdf95e4c6e431bb4d111a9860e9eee71c16be3370980fa309f0e7c532f60cf65", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/SKILL.md", + "target": "app/游资skills/xuxiang-perspective/SKILL.md", + "bytes": 6253, + "sha256": "43433473f68bf7160d3395b305b668e6028388f71db07447a45db1a702844ed8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/01-writings.md", + "target": "app/游资skills/xuxiang-perspective/references/research/01-writings.md", + "bytes": 658, + "sha256": "bb63a21e80a6954d86166d558ac18f1ba35dc41b0bcf13189ffd0c31818e4718", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/02-conversations.md", + "target": "app/游资skills/xuxiang-perspective/references/research/02-conversations.md", + "bytes": 383, + "sha256": "32d7d0db40cefc02606ee92cdbc6f6290ac3a781f9e170863467f2d54c7a720b", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/xuxiang-perspective/references/research/03-expression-dna.md", + "bytes": 254, + "sha256": "315af2a7a8ac479902895f3ae1b347ac1fbdc2d681ea0835cb2d7f1e42b54d2e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/04-external-views.md", + "target": "app/游资skills/xuxiang-perspective/references/research/04-external-views.md", + "bytes": 550, + "sha256": "dac268c0699ab3ae33aa9a18225559fd2292532a8e1a2dd69555c3386e1dba97", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/05-decisions.md", + "target": "app/游资skills/xuxiang-perspective/references/research/05-decisions.md", + "bytes": 409, + "sha256": "30a8dedfa9fbc1a9d6001a7be1a4f50254ead84856900acdb846e2552758c881", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/xuxiang-perspective/references/research/06-timeline.md", + "target": "app/游资skills/xuxiang-perspective/references/research/06-timeline.md", + "bytes": 377, + "sha256": "eae3b31aa2350778e5bf28c685561aefcfc17312bae7191e2e09330dc78b9102", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/SKILL.md", + "target": "app/游资skills/zhangdetao-perspective/SKILL.md", + "bytes": 15908, + "sha256": "30d645e0d143032ebe0563191cbf3d8f01ac522012156a3fe91aaa2951cfca58", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/01-writings.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/01-writings.md", + "bytes": 3566, + "sha256": "b2f4dceec178f83d3ca73c0a9c3e3d253cef49cd38ada0319a48463b8f64daa5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/02-conversations.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/02-conversations.md", + "bytes": 1891, + "sha256": "7702e345e81c831fbee6de42c61651ad41e4f555a52edbac335228093e195a6d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/03-expression-dna.md", + "bytes": 1844, + "sha256": "67261e4f352ef418465e662f5924c6418da2e32d732c4defe89643e8ad86aa03", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/04-external-views.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/04-external-views.md", + "bytes": 1258, + "sha256": "d390f4949f2a2f6f47bee5eeff8c5f7633ac30970bf0263a2371c94afa294ff9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/05-decisions.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/05-decisions.md", + "bytes": 2054, + "sha256": "07a7ab4e45d8cf1eea09d0e7192d59c3a2ebe12db5769b1113cd3d062c148788", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/research/06-timeline.md", + "target": "app/游资skills/zhangdetao-perspective/references/research/06-timeline.md", + "bytes": 705, + "sha256": "5afc9141722b2796ae5b502ceb04a41fbb1a939e63d10db1e7b7d0d6cca3f054", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/10第十节:心法与量的结合运用(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/10第十节:心法与量的结合运用(录播).txt", + "bytes": 61922, + "sha256": "b22c873bc779500ccc8e49d49d3a9f30aea4377eb0e9039891771192524e4103", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/11第十一节:主力资金潜伏解析(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/11第十一节:主力资金潜伏解析(录播).txt", + "bytes": 57316, + "sha256": "f194c2cd8122eab90ddbcd01ad42d4778b15fb329dd2a8344bd0605ed8e02f1a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/12第十二节:主线擒龙之首期龙(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/12第十二节:主线擒龙之首期龙(录播).txt", + "bytes": 51832, + "sha256": "b66b4c516f3c5b9fe2d5fd4ceadecc1e84d9fcfe33e7961f67f9da52564ab8fc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/13第十三节:主线擒龙之补涨龙(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/13第十三节:主线擒龙之补涨龙(录播).txt", + "bytes": 94061, + "sha256": "7d33c020056262109119477f39d99161ed6ce683a5038f936d0df0cbc7b152fa", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/14第十四节:无为战法之登高望远(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/14第十四节:无为战法之登高望远(录播).txt", + "bytes": 61015, + "sha256": "74821310485b93b3d4373721c15c6fb553ad2adc4abe0fb99371453ed7d777ee", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/1第一节:投资的系统思维(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/1第一节:投资的系统思维(录播).txt", + "bytes": 90200, + "sha256": "37e5867ae67750b18dd804f4ca7f12ba4c8db40629d72e8d0bec4aa9f88d71f2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/2第二节:无为心法之速度(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/2第二节:无为心法之速度(录播).txt", + "bytes": 67391, + "sha256": "386b70a2d42cb788d2fd242563f73eb5e0c90e1567b46610ad7f9e2c23e75f44", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/3第三节:无为心法之角度强化(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/3第三节:无为心法之角度强化(录播).txt", + "bytes": 61273, + "sha256": "184720869b51299efe26a47a66cf60902fde64b04d3cd1424878693399391a6f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/4第四节:无为心法之弧度下篇(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/4第四节:无为心法之弧度下篇(录播).txt", + "bytes": 57897, + "sha256": "b78158c535e67e7e0ede06b75f8bf362152263315101385bedb4895b4e0342f6", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/5第五节:主线板块的选择(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/5第五节:主线板块的选择(录播).txt", + "bytes": 52504, + "sha256": "11dc441ea5e86cd00cf4450bfd25fbe952003d4130bcb28cf4707aeafb4e6146", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/6第六节:板块轮动逻辑与分类(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/6第六节:板块轮动逻辑与分类(录播).txt", + "bytes": 85408, + "sha256": "39ed0704377494981693c9ec052a4e9525028302af3205ff299444242fff659e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/7第七节:把握主线板块轮动(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/7第七节:把握主线板块轮动(录播).txt", + "bytes": 66922, + "sha256": "add6e027056ffaa1f47f30626486c4ba81fd1d7e0f5cd0252ac1eb17717ac0b1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/8第八节:成交量的本质与运用(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/8第八节:成交量的本质与运用(录播).txt", + "bytes": 90088, + "sha256": "bd4454aa929b739d17d6e0ecb8ac8538414ce68a71053f92b6948852783cc6f3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/9第九节:主力资金沉淀(录播).txt", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/9第九节:主力资金沉淀(录播).txt", + "bytes": 60887, + "sha256": "82d92dd866b9906132c8735c6cc6c59014aec1e2d0aab4acf69739bcb1a0a8b2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangdetao-perspective/references/sources/transcripts/_status.json", + "target": "app/游资skills/zhangdetao-perspective/references/sources/transcripts/_status.json", + "bytes": 1129, + "sha256": "f4b29daa4b4d83883a4ef692d540ef6bbd027790d8c6db917c9fc44fcbddbb53", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/SKILL.md", + "target": "app/游资skills/zhangmengzhu-perspective/SKILL.md", + "bytes": 5879, + "sha256": "8ce979f067bf2c786a05664fc3ed8a49fcf1c97f5018c2e03e71b608b95b2988", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/01-writings.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/01-writings.md", + "bytes": 621, + "sha256": "edabfd14df9645893164129574921afc634050f0a5321aad32549dffa0703bcd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/02-conversations.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/02-conversations.md", + "bytes": 311, + "sha256": "59c836cdae7393edf89ec9fb66b71ba09be2c637f380d4b67a60f19c37a11be3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/03-expression-dna.md", + "bytes": 288, + "sha256": "695453213593d924b4bc49e063252ba01ccca9925af523e4b27c75da0216fe7d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/04-external-views.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/04-external-views.md", + "bytes": 464, + "sha256": "a4ce100e1062238aa7e3313b46d4ecde792bbf1307173e6f184e5567f4adff61", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/05-decisions.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/05-decisions.md", + "bytes": 388, + "sha256": "cb73d04c3a126f3f5fb565b8f4b3d822a010456d0fdd9f7abc3664ce84d00b66", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhangmengzhu-perspective/references/research/06-timeline.md", + "target": "app/游资skills/zhangmengzhu-perspective/references/research/06-timeline.md", + "bytes": 367, + "sha256": "d60d612efb85b881e82588676b0aa87c2a90af3f306eef3bf4e97da7587e40d5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/SKILL.md", + "target": "app/游资skills/zhaolaoge-perspective/SKILL.md", + "bytes": 6429, + "sha256": "b92d3072581c3f7f0178980ffbe8e38094b5b12490e0cadd8fb44bfe702e2dce", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/01-writings.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/01-writings.md", + "bytes": 521, + "sha256": "e56860be39a4cdc18a64695794796ee6033224abf42e4b4da42fe654365d6141", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/02-conversations.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/02-conversations.md", + "bytes": 336, + "sha256": "7a2ea62fbe67e76b31239b15c54decc762b6473895cfbe7ea5aa02e85d858845", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/03-expression-dna.md", + "bytes": 269, + "sha256": "1ec03cc9ab0634c1e048e52ee8f658b4d25f98cac049c9e0814a5d70c73ef9d0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/04-external-views.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/04-external-views.md", + "bytes": 552, + "sha256": "b284fb48327a4ef6f2e014a7b17b3fb404f7b4d184f39d83ab28659bf63fd13d", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/05-decisions.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/05-decisions.md", + "bytes": 418, + "sha256": "a3af9a1ec222944c327251aa0587537743e2027fff7c225ab16ce62ef66bcc49", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhaolaoge-perspective/references/research/06-timeline.md", + "target": "app/游资skills/zhaolaoge-perspective/references/research/06-timeline.md", + "bytes": 365, + "sha256": "1254e68558795696061dcca22260aeef56693adfb91783b326802d743bb18e9c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/SKILL.md", + "target": "app/游资skills/zhiyechaoshou-perspective/SKILL.md", + "bytes": 6531, + "sha256": "940e232d8fcda5b293499075ed7bfa0be1fd14cf54d6489f4cef97d2fca66db8", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/01-writings.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/01-writings.md", + "bytes": 654, + "sha256": "65a5e323a4b030c730bbf6d1c7c342fe1c2be4d6d515535356842b7c14bfc4f5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/02-conversations.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/02-conversations.md", + "bytes": 338, + "sha256": "6a31c92aff32b19e0f489c6985e4d624e69334b29f8d98891205e725cdfc5bf4", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/03-expression-dna.md", + "bytes": 279, + "sha256": "8b842df2b0ba30fce2d22820cca8a5b5cad9fc5ecf639584184cfe4c3b72c1fa", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/04-external-views.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/04-external-views.md", + "bytes": 386, + "sha256": "402ec2809c50048a7a76dc50d931616c23279effea0f81ff288cc94575152b09", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/05-decisions.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/05-decisions.md", + "bytes": 315, + "sha256": "09c8ed46634b7995da9a238a91a0f99f255706303b89bb6e09b0cc8c52a0e8c9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zhiyechaoshou-perspective/references/research/06-timeline.md", + "target": "app/游资skills/zhiyechaoshou-perspective/references/research/06-timeline.md", + "bytes": 394, + "sha256": "a87fa93e554b01de17ead743d50053259a4f216e07d5cb6ed4ba648cac74d6a2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/SKILL.md", + "target": "app/游资skills/zuoshouxinyi-perspective/SKILL.md", + "bytes": 5856, + "sha256": "42bc86eadbdb49bf1d612d6b6219011d743ad82903b2df5a26734a56990346c7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/01-writings.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/01-writings.md", + "bytes": 529, + "sha256": "45a525a0e84c4b404ce00f394c183cc606c45a8d3fb2df8ee9c73e54d8c733cd", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/02-conversations.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/02-conversations.md", + "bytes": 279, + "sha256": "f6c7b4ccf86163c4bc4d8280098197cd2710453de97f3d04c328939fb27c9416", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/03-expression-dna.md", + "bytes": 221, + "sha256": "2256eb194351b03c9cd896756870e64cf1f576a69c52ad87270693baf8b25c1f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/04-external-views.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/04-external-views.md", + "bytes": 370, + "sha256": "5e913c25ae42f65805c57aab95c2d001d351e5ab16aae6ce6a99390a5768b5d1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/05-decisions.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/05-decisions.md", + "bytes": 329, + "sha256": "47097f6a15acfbce2cb555ced5a038f28911de958b014f60759d27a41b46dc0a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/zuoshouxinyi-perspective/references/research/06-timeline.md", + "target": "app/游资skills/zuoshouxinyi-perspective/references/research/06-timeline.md", + "bytes": 321, + "sha256": "fe9d073824d5cddd74cbaebe3fdacb13de5ec0a3db5767129f2fc475fa009cec", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/SKILL.md", + "target": "app/游资skills/六一中路-perspective/SKILL.md", + "bytes": 12747, + "sha256": "7872a0544e15eb77ed072b11ccc5ffea67b20da7fdc63ee63b1785c224192e9f", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/phase4-validation.md", + "target": "app/游资skills/六一中路-perspective/references/phase4-validation.md", + "bytes": 6315, + "sha256": "8dd9aede5b3226a825e83223637402d7b86cdb568c9ded6b43f7e512a4818a97", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/phase5-creator.md", + "target": "app/游资skills/六一中路-perspective/references/phase5-creator.md", + "bytes": 8248, + "sha256": "188d1282cbeeb570d5cd20fce8802eb19f753e0698c0daebe99ed8d6ac9f5ca2", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/phase5-optimizer.md", + "target": "app/游资skills/六一中路-perspective/references/phase5-optimizer.md", + "bytes": 15454, + "sha256": "286afd9e438669516065f385858e8ed366c0dff5d59557c28c45d49980ac2f8e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/research/01-writings.md", + "target": "app/游资skills/六一中路-perspective/references/research/01-writings.md", + "bytes": 9496, + "sha256": "c4849305411fe5b01f48476b854d9e507e8a345497498d7407f0249ca5eeaaae", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/research/02-conversations.md", + "target": "app/游资skills/六一中路-perspective/references/research/02-conversations.md", + "bytes": 16207, + "sha256": "618d8f3b31afeca184f6be5cd8607fa0a990892b39e61a8d06de3fe40154191a", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/六一中路-perspective/references/research/03-expression-dna.md", + "bytes": 11307, + "sha256": "059d80ac08e2ce9edaac379bfe3a80a5bd0a8b8906212b2174f55f23d43c8235", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/research/04-external-views.md", + "target": "app/游资skills/六一中路-perspective/references/research/04-external-views.md", + "bytes": 14787, + "sha256": "09a27c7acd943652435e4255f1109b609e60a77f30ad5666833465d91e5cea59", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/六一中路-perspective/references/research/05-decisions.md", + "target": "app/游资skills/六一中路-perspective/references/research/05-decisions.md", + "bytes": 8566, + "sha256": "2a2f92f416374b5571bcd88d2114dc02037171ea09e68fdb4b50a71cfb4e582c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/SKILL.md", + "target": "app/游资skills/炒股养家-perspective/SKILL.md", + "bytes": 19405, + "sha256": "a92b35d24c5c6c15823b26e31609398fcfce9e30a750352b12b165a765fe695e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/phase2-synthesis.md", + "target": "app/游资skills/炒股养家-perspective/references/phase2-synthesis.md", + "bytes": 13972, + "sha256": "7f888cfa608b30c773a7623ecee899d6f0d5f556958d2eaefbabf660bbee618c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/phase4-validation.md", + "target": "app/游资skills/炒股养家-perspective/references/phase4-validation.md", + "bytes": 10164, + "sha256": "2607d01ba601d97fbcf2edcb2da33df98b349b87cf6674175851577c83c5a948", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/phase5-creator.md", + "target": "app/游资skills/炒股养家-perspective/references/phase5-creator.md", + "bytes": 11691, + "sha256": "ccc598c8e14a07851c8de57bad09c68ba794519556e195fda74aafdad27d23c3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/phase5-optimizer.md", + "target": "app/游资skills/炒股养家-perspective/references/phase5-optimizer.md", + "bytes": 11006, + "sha256": "d4d9d783faab35ec72d9c3276406b00524a0e7af39ec63906e1342217eebeb11", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/01-writings.md", + "target": "app/游资skills/炒股养家-perspective/references/research/01-writings.md", + "bytes": 16659, + "sha256": "b37940446e0e4fe7467bee51771e80635d2594bb16b560a6e0e435900601ed47", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/02-conversations.md", + "target": "app/游资skills/炒股养家-perspective/references/research/02-conversations.md", + "bytes": 12632, + "sha256": "a17df50371327585f73208b4034de660532f8bf6eeed47c6b2db02b1657e3b88", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/炒股养家-perspective/references/research/03-expression-dna.md", + "bytes": 14717, + "sha256": "ae8c11ea3a9d914802348bf94140c251fad7bfe0446a63e74e94ac2e9a9a5b04", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/04-external-views.md", + "target": "app/游资skills/炒股养家-perspective/references/research/04-external-views.md", + "bytes": 16203, + "sha256": "853fd4569f107bf33cac91d2a941b24569113b184ec9affddfa2b20ebd8e2a75", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/05-decisions.md", + "target": "app/游资skills/炒股养家-perspective/references/research/05-decisions.md", + "bytes": 16528, + "sha256": "205c05b7eabd34065184b96cc65408e52595118b02b50aeef6e4242b4a27de4c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/炒股养家-perspective/references/research/06-timeline.md", + "target": "app/游资skills/炒股养家-perspective/references/research/06-timeline.md", + "bytes": 17590, + "sha256": "9a7e9ea0b99762cfc0a391105e26c41fd3a60b295761824fc546e811e6058edc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/蒸馏总报告.md", + "target": "app/游资skills/蒸馏总报告.md", + "bytes": 3934, + "sha256": "f7630559c26103935ac37d2ef440c0d26377fdfe2b1c70c3e4fd81512899fd22", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/SKILL.md", + "target": "app/游资skills/退学炒股-perspective/SKILL.md", + "bytes": 17206, + "sha256": "8d03ac0d97a50704bfa86e6ad094d319b0b3253763d039a5ec0ff03ee0f097e5", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/phase4-validation.md", + "target": "app/游资skills/退学炒股-perspective/references/phase4-validation.md", + "bytes": 8758, + "sha256": "02721a90443b608f80dadc2c217e13f3dd8f9f7433959b6c08d629f7137200dc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/phase5-creator.md", + "target": "app/游资skills/退学炒股-perspective/references/phase5-creator.md", + "bytes": 10468, + "sha256": "eda63414fcd98c7dd48fc308f05b4cedba0f4c57c47ada0a67dc7d8c91f2f071", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/phase5-optimizer.md", + "target": "app/游资skills/退学炒股-perspective/references/phase5-optimizer.md", + "bytes": 12960, + "sha256": "14f5cd96df15a618a72134297328d037158914d1bc49af7aa9830a0c396f64e9", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/01-writings.md", + "target": "app/游资skills/退学炒股-perspective/references/research/01-writings.md", + "bytes": 17247, + "sha256": "33fdf9bd6d3ca87a7b0568c0c7616c2d40366ddf90ad405bebe704d6b3192fef", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/02-conversations.md", + "target": "app/游资skills/退学炒股-perspective/references/research/02-conversations.md", + "bytes": 18114, + "sha256": "c14cbadd99ff3f814b63bb74ccc7618203268e1f91c3e8df012c17130b6a15e1", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/退学炒股-perspective/references/research/03-expression-dna.md", + "bytes": 17213, + "sha256": "12a291f343325987a2088af2db8993efbaaa54e18b1efb39b34b63f3bb7e5c0c", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/04-external-views.md", + "target": "app/游资skills/退学炒股-perspective/references/research/04-external-views.md", + "bytes": 14536, + "sha256": "dd4dae6a20fd2ce6cf6f1568ad4bbf4750dac029a33bddb47c6a86d683eb2cf0", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/05-decisions.md", + "target": "app/游资skills/退学炒股-perspective/references/research/05-decisions.md", + "bytes": 11622, + "sha256": "27bf1f29b138d9287c7698ab8c7abc7c8a8d1f0d67da68c9dd1be54c753289da", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/退学炒股-perspective/references/research/06-timeline.md", + "target": "app/游资skills/退学炒股-perspective/references/research/06-timeline.md", + "bytes": 17610, + "sha256": "399b3cc79a141ec3d129f66038823365c38cfa6e72dd983a78079bf026084f5e", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/SKILL.md", + "target": "app/游资skills/陈小群-perspective/SKILL.md", + "bytes": 15215, + "sha256": "c9542b536bc9726172a487f1d78cb7f48b576c699c26b7399d068ab12555ab86", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/phase4-validation.md", + "target": "app/游资skills/陈小群-perspective/references/phase4-validation.md", + "bytes": 7672, + "sha256": "4fe261d8eb054d90c851694544bef8604f7877a2bd2578c68e0af99aa2672313", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/phase5-creator.md", + "target": "app/游资skills/陈小群-perspective/references/phase5-creator.md", + "bytes": 5520, + "sha256": "54bc27f45ed839a8977ff178efb5aea8d38b1eddb24d8b290088b714c09ddee7", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/phase5-optimizer.md", + "target": "app/游资skills/陈小群-perspective/references/phase5-optimizer.md", + "bytes": 10734, + "sha256": "86db34b32faa9c967ab3a87ffb9f12d77c114eaadf90b5f50f28e0bd11bd8467", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/01-writings.md", + "target": "app/游资skills/陈小群-perspective/references/research/01-writings.md", + "bytes": 16604, + "sha256": "47dbf99ab6e3aff6c4008f169f39363f36171c0c8cfc43ef3d4d642dae9ba771", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/02-conversations.md", + "target": "app/游资skills/陈小群-perspective/references/research/02-conversations.md", + "bytes": 17531, + "sha256": "a99d6df8f30176d8d3d30f67c3f86e7e3c82c0239a0e96d5930d0486dc4978f3", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/03-expression-dna.md", + "target": "app/游资skills/陈小群-perspective/references/research/03-expression-dna.md", + "bytes": 13726, + "sha256": "597e20a12add17b7cec27b0d80bfef5ca0edcb94cf3bb1cca8e19464737c64bc", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/04-external-views.md", + "target": "app/游资skills/陈小群-perspective/references/research/04-external-views.md", + "bytes": 15980, + "sha256": "3fe56f885f49eaa961cee492ad2166d9d2322ec5a6f68b41da9abd94dc37a961", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/05-decisions.md", + "target": "app/游资skills/陈小群-perspective/references/research/05-decisions.md", + "bytes": 19735, + "sha256": "e9878d86a8787c988026a003823fcd9b4bea8e0efe7094062ffa694d1b1149ae", + "disposition": "original_copy_pending_move", + "status": "identical" + }, + { + "source": "游资skills/陈小群-perspective/references/research/06-timeline.md", + "target": "app/游资skills/陈小群-perspective/references/research/06-timeline.md", + "bytes": 23133, + "sha256": "6ff62c5a5aa04901729420c91b40f5d8fb1c7e681ae81ac1f07c5295015ab48d", + "disposition": "original_copy_pending_move", + "status": "identical" + } + ] +} diff --git a/docs/migration/目标目录与切片顺序.md b/docs/migration/目标目录与切片顺序.md new file mode 100644 index 0000000..e0af03b --- /dev/null +++ b/docs/migration/目标目录与切片顺序.md @@ -0,0 +1,71 @@ +# 保真迁移目标目录与切片顺序 + +> 状态:已由用户确认 +> 生效日期:2026-07-30 + +本文件把已批准的`app/`目标结构和迁移顺序固化为可恢复约束。目录只表达职责,迁移时从 +原版复制、移动和拆分真实实现;禁止先建立空业务骨架后按规格书重写。 + +## 目标目录 + +```text +app/ + server.py # 最终仅保留进程入口与HTTP服务器组装 + backend/ + bootstrap/ # 路径、环境、配置和依赖容器 + http/ # 路由、鉴权、响应、静态资源和通用传输能力 + features/ + accounts/ # 登录、账号、会员及用户身份 + system/ # 管理员配置和运行状态 + market/ # 公共行情、搜索、详情和图表 + sentiment/ # 情绪周期 + pools/ # 涨停、炸板、跌停、昨日涨停和涨停表现 + ladder/ # 市场天梯 + rotation/ # 板块轮动 + auction/ # 集合竞价 + themes/ # 题材库 + popularity/ # 人气热榜 + dragon_tiger/ # 龙虎榜与游资档案 + screener/ # 阶段、策略、自定义选股及持续跟踪 + mentor/ # 问师 + heaven/ # 观势、观气、观心 + review/ # 自选、复盘笔记、交易日志和复盘助手 + alerts/ # 提醒中心 + data/ # 统一数据网关、口径、质量和供应商适配 + database/ # 连接、迁移和领域Repository + jobs/ # 后台任务定义、状态、调度和重试 + llm/ # 唯一模型网关、流式协议和调用审计 + frontend/ + index.html # 原版DOM骨架;仅在等价验证后拆分 + shared/ # API、状态、Shell、弹窗和通用组件 + pages// # 页面自己的行为与样式 + styles/ # 令牌、基础层、Shell和经验证后的公共样式 + vendor/ # 浏览器端第三方静态资产 + config/ # 页面、功能、API、任务及数据字段注册表 + data/ # SQLite及私有运行数据,保持Git忽略 + tests/ # 单元、契约、差异和浏览器回归 + tools/ # 清查、迁移、差异验证和维护工具 + vendor/ # Python离线依赖 + 游资skills/ # 原版公开Skill;私有Skill仍位于data/ +``` + +允许迁移期间保留根级兼容外壳;外壳只能转发到唯一实现,并须在账本登记删除条件。 + +## 固定切片顺序 + +| 切片 | 完整纵向范围 | +|---:|---| +| 00 | 原版可运行副本、资产清单、数据库副本和视觉基线 | +| 01 | 启动、HTTP通用能力、登录账号、会员和系统管理 | +| 02 | 公共行情、全局搜索、详情、悬浮图表、数据网关和数据质量 | +| 03 | 情绪周期、五类股池和涨停表现 | +| 04 | 市场天梯和板块轮动 | +| 05 | 集合竞价、题材库、人气热榜和龙虎榜 | +| 06 | 智能选股、自定义选股和策略持续跟踪 | +| 07 | 问师、模型Skill和LLM流式链路 | +| 08 | 问天:观势、观气和观心 | +| 09 | 我的复盘、自选、笔记、交易日志、提醒和复盘助手 | +| 10 | 前端Shell、页面文件、共享组件、CSS层级和移动端职责归位 | +| 11 | 待定代码试删、全量并行验收、维护文档和切换准备 | + +每个切片先记录原版证据,再移动实现,再执行同输入差异;未通过时不得进入下一切片。 diff --git a/docs/migration/重建迁移章程.md b/docs/migration/重建迁移章程.md new file mode 100644 index 0000000..cd3154f --- /dev/null +++ b/docs/migration/重建迁移章程.md @@ -0,0 +1,271 @@ +# 小白复盘重建迁移章程(本轮实施失败冻结) + +> 状态:失败冻结,禁止继续或部署 +> 建立日期:2026-07-30 +> 产品真相源:`docs/product/小白复盘-完整产品规格说明书.md` + +> 2026-07-30用户人工验收确认`next/`的视觉、布局和基础功能未达到原系统等价要求。 +> 本章程后续阶段记录只作为失败过程留档,不再构成完成证明。权威处置记录见 +> [`next失败冻结记录.md`](next失败冻结记录.md)。 + +## 1. 唯一目标 + +在当前仓库的`next/`目录内,从零重建小白复盘。新系统必须完整实现产品规格说明书定义的功能、权限、数据口径、交互状态、视觉和终端行为,同时显著减少重复实现、补丁层和隐式依赖,使后续人工维护可操作。 + +迁移不是对旧系统继续重构,也不是增加新产品功能。旧系统在迁移期间保持可运行,只用于读取业务事实、算法、必要数据和视觉资产。 + +## 2. 不可违反的约束 + +1. 产品规格说明书中的“已确认产品标准”优先于旧代码现状。 +2. 旧系统文件不移动、不批量改名、不作为新系统运行时依赖。 +3. 新系统开发数据库独立,不直接写入旧系统数据库。 +4. 不整文件复制旧`server.py`、`app.js`、样式覆盖层或巨型业务模块。 +5. 每项业务规则只保留一个权威实现。 +6. 全站只有一个浏览器API出口、一个数据网关、一个LLM网关、一个弹窗管理器和一套设计令牌。 +7. 页面只负责取数、组织状态和组合组件,不拥有跨页面业务计算。 +8. 外部数据源只能通过数据网关访问;公开网页源不得进入正式计算。 +9. LLM只解释或编译受控公式,不参与确定性行情、情绪、选股和问天计算。 +10. 用户私有数据的所有读写必须在服务端验证账号所有权。 +11. 兼容层只能用于迁移,必须登记删除条件;最终交付不得保留双实现。 +12. 不以“测试通过”替代产品验收;测试必须实际覆盖对应规格。 +13. 不以“目录已建立”或“接口已预留”宣称功能迁移完成。 +14. 不自动替换NAS生产容器;最终切换在全量验收后单独确认。 + +## 3. 技术与结构决策 + +### 3.1 技术栈 + +- 后端:Python、FastAPI、Pydantic。 +- 数据库:SQLite,显式Repository与有序Migration,不引入ORM双重抽象。 +- 前端:Vue 3、TypeScript、Vite、Pinia。 +- 单元与集成测试:pytest。 +- 浏览器验收:Playwright。 +- 部署:多阶段Docker构建,运行时保持单容器模块化单体。 + +### 3.2 目标一级结构 + +```text +next/ + frontend/ + src/ + shared/ + pages/ + app/ + backend/ + bootstrap/ + http/ + features/ + data/ + database/ + jobs/ + llm/ + config/ + tests/ + tools/ + docs/ +``` + +只预建稳定的一级边界。二、三级业务目录随纵向切片建立,不创建无真实职责的空目录树。 + +### 3.3 依赖方向 + +```text +浏览器页面 -> 前端共享层 -> HTTP接口 +HTTP/后台任务 -> 业务服务 -> Repository/DataGateway/LLMGateway -> 基础设施 +``` + +禁止反向依赖、跨功能直接读表、页面直接访问外部数据源,以及Controller中编写评分和持久化逻辑。 + +## 4. “做减法”验收标准 + +每个迁移切片必须证明: + +- 旧系统同一行为的多个实现已经在新系统收敛为一个。 +- 没有为了快速兼容复制第二份公式、请求逻辑或CSS组件。 +- 新增共享抽象至少有两个真实调用方,或明确消除一个高风险全局出口。 +- 页面专属样式不修改其他页面和全局Shell。 +- 临时代码有明确删除条件,不使用永久`legacy`、`v2-fix`、`override-final`式补丁层。 +- 重建后的文件按职责可读,不用巨型文件重新制造旧问题。 +- 仅统计代码行数下降不能证明成功;功能等价、唯一职责和可删除旧实现同时成立才算减法。 + +建议性文件规模门禁: + +- 前端页面容器目标不超过400行,超出时按稳定子组件拆分。 +- 后端业务服务目标不超过500行,复杂确定性算法可独立成纯计算模块。 +- 单个CSS文件目标不超过600行,按令牌、Shell、共享组件、页面、移动端分层。 +- 超过门禁必须在本章程记录原因,不能静默放宽。 + +## 5. 迁移方法 + +采用纵向切片。每个切片同时交付: + +1. 产品行为与状态。 +2. 权限和账号数据边界。 +3. 后端接口与Schema。 +4. 业务计算与数据访问。 +5. 前端页面、日间/夜间和响应式行为。 +6. 单元、契约、集成和浏览器测试。 +7. 与旧系统和产品规格的差异报告。 +8. Git提交和远端回档节点。 + +禁止先迁移全部HTML、再迁移全部接口、最后补业务逻辑。这会产生长期空壳和无法验收的中间状态。 + +## 6. 执行阶段 + +| 阶段 | 内容 | 完成证据 | 状态 | +|---:|---|---|---| +| 0 | 产品规格、迁移章程、旧系统只读基线 | 文档检查、Git节点 | 已完成 | +| 1 | `next/`最小可运行骨架与工具链 | 前后端启动、健康检查、基础测试 | 已完成 | +| 2 | 配置、错误、日志、数据库迁移、Repository基础 | 迁移回退和错误契约测试 | 已完成 | +| 3 | 账户、会话、权限、会员和系统配置 | 权限矩阵与跨账号测试 | 已完成 | +| 4 | 前端Shell、路由、状态、API、弹窗、主题和令牌 | 1080P/4K/390px Shell截图 | 已完成 | +| 5 | 数据网关、日期、快照、图表和搜索 | 来源/时间/缺失/降级测试 | 已完成 | +| 6 | 情绪周期、四类股池与涨停表现 | 算法固定样本、页面E2E | 已完成 | +| 7 | 市场天梯和板块轮动;回归涨停表现 | 结构统计与展开滚动验收 | 已完成 | +| 8 | 集合竞价、题材库、人气热榜和龙虎榜 | 生命周期、口径和空态验收 | 已完成 | +| 9 | 智能选股、36套策略、自定义选股和持续跟踪 | 109因子、确定性和隔离测试 | 已完成 | +| 10 | 问师、模型Skill、LLM流式网关 | 上下文路由、去重和回退测试 | 已完成 | +| 11 | 问天观势、观气、观心 | 公式、安全门、动画和历史验收 | 已完成 | +| 12 | 我的复盘、提醒和复盘助手 | 私有数据、汇总和弹窗验收 | 已完成 | +| 13 | 全站移动端重组和无障碍 | 320/390/430/768/横屏验收 | 已完成 | +| 14 | 数据迁移、Docker、备份恢复和性能安全 | 旧库副本迁移、回滚演练 | 已完成 | +| 15 | 全量验收、减法审计和切换准备 | 规格覆盖矩阵、最终报告 | 已完成 | + +阶段编号不会因为上下文压缩重新规划。只有发现产品规格自身矛盾时,才记录决策并调整阶段内容;不得通过新增阶段掩盖未完成工作。 + +## 7. 每阶段质量门禁 + +每次提交前至少执行: + +1. 格式、类型和静态检查。 +2. 受影响模块的单元测试。 +3. API契约和账号边界测试。 +4. 数据库迁移前进/回退测试(涉及数据库时)。 +5. Playwright关键流程(涉及运行时或前端时)。 +6. 日间/夜间和目标视口截图(涉及视觉时)。 +7. 浏览器控制台无未处理错误。 +8. 密钥、Token、日志和生成物扫描。 +9. 与产品规格固定验收案例的可追溯检查。 +10. `git diff --check`和工作区来源确认。 + +## 8. Git与回档 + +- 当前`main`保留旧系统可运行状态和新系统迁移过程。 +- 每个阶段至少一个独立提交,提交信息使用`rebuild(stage-N): ...`。 +- 阶段门禁通过后推送Gitea。 +- 不重写已推送历史,不使用破坏性重置。 +- 大阶段内可有多个小提交,但最终必须有明确阶段完成节点。 +- 规格说明书和本章程的变更与相应行为变更同提交或先提交。 + +## 9. 数据迁移与最终切换 + +- 开发期间只使用旧数据库的只读副本或脱敏样本。 +- 迁移工具可重复运行,并输出逐表数量、校验和、跳过项和失败项。 +- 首次完整迁移后进行账号、权限、共享快照和私有数据抽样比对。 +- 最终切换前停止旧系统写入,执行最终增量迁移,再启动新容器。 +- 保留旧镜像、旧数据库一致性备份和加密密钥,验证回退路径。 +- 未经最终确认,不停止或替换NAS现有容器。 + +## 10. 持续状态记录 + +每完成一个阶段,在本节追加: + +- 提交哈希和推送状态。 +- 实际完成证据。 +- 删除或避免的重复代码。 +- 未完成项和残余风险。 +- 下一阶段入口条件。 + +### 当前状态 + +- 阶段0已完成:产品规格说明书和迁移章程通过结构、表格和密钥扫描。 +- 产品规格说明书包含16个工作区、36套策略、109个因子和85个固定验收案例。 +- 旧系统保持原状。 +- 阶段1已完成,代码提交为`603e73d`:建立隔离的FastAPI与Vue 3/TypeScript/Vite骨架、唯一前端API客户端、统一安全错误外壳、设计令牌起点及前后端基础测试。 +- 后端门禁:Ruff通过,pytest为2项通过;存在1项FastAPI 0.141测试客户端上游弃用警告,阶段2改用显式ASGI传输层消除。 +- 前端门禁:类型检查通过,Vitest为1个文件/2项通过,Vite生产构建通过;依赖安装审计为0个已知漏洞。 +- 运行验收:`8780/api/health`返回开发环境健康状态,`5173`代理访问成功;1280x720浏览器实测无控制台错误、无横向溢出。 +- 减法证据:删除未使用的`jsdom`及37个传递依赖;禁止TypeScript生成重复JavaScript测试产物;未复制旧`server.py`、`app.js`或CSS覆盖层。 +- 阶段2已完成,代码提交为`d969d2c`:配置从环境唯一加载,SQLite连接启用WAL/外键/忙等待,迁移支持前进、显式回退、原子失败、校验和和连续历史校验。 +- Repository基础只建立被健康检查真实使用的数据库状态Repository,未创建通用CRUD或空业务目录;数据库维护CLI支持状态、升级及需二次确认的回退。 +- HTTP错误统一为`code/message/request_id`,404、验证错误、业务错误和未知异常均通过安全契约;请求编号同时写入响应头,未知异常不向前端泄露原文。 +- 日志采用结构化JSON、`Asia/Shanghai`时间、10MB/3份轮转和嵌套/字符串敏感值脱敏;运行健康检查可区分进程和数据库状态。 +- 阶段2门禁:Ruff通过,pytest为20项通过,前端2项测试、类型检查和生产构建通过;真实服务健康响应、请求编号及`+08:00`启动日志通过运行验收。 +- 阶段3已完成,代码提交为`f69972c`并已推送:首个注册账号成为管理员,后续账号为普通用户;管理员权限与会员状态独立,管理员开通会员后同时具有管理员和会员标识。 +- 会话Cookie为HttpOnly,CSRF使用Cookie与请求头双重校验;服务端只保存会话和CSRF哈希。修改密码保留当前会话并撤销其他会话,账号出生资料和系统凭据均加密落库且按权限隔离。 +- 会员管理支持1个月、3个月、12个月、3年和永久,续期从有效到期日继续计算;会员每日智能调用上限进入会员配置。管理员无需伪装成会员即可使用智能功能,普通非会员被服务端拒绝。 +- 模型池限制20个模型,首个模型自动成为主模型,支持独立选择主模型和辅助模型;选中模型不可删除,密钥不会通过管理接口返回。模型连通测试明确留给阶段10的唯一LLM网关,系统管理不建立第二个模型调用出口。 +- 减法证据:HTTP Schema从路由中拆出,路由由412行降至314行;系统凭据职责从账户服务拆出,最长业务服务由429行降至382行。未建立通用CRUD、旧账号兼容层、第二套权限判断或明文密钥读取接口。 +- 阶段3门禁:Ruff通过,pytest为40项通过;前端2项测试、类型检查和生产构建继续通过;数据库版本1和2的前进及倒序回退通过,真实账号密码和已提供令牌未进入`next/`。 +- 阶段3残余边界:账号与系统管理的前端交互属于阶段4;LLM成功调用计次和模型连通性属于阶段10,不在阶段3提前实现。 +- 阶段4已完成,代码提交为`3b75bf2`并已推送:登录注册、16项工作区注册表、PC/移动Shell、顶栏、摘要条、固定状态栏、主题、日期、账户菜单和系统管理前端均已接入。 +- 账户五项菜单独立可达;浏览器实际保存并重读个人出生资料。会员状态完整显示开通状态、到期、剩余时长、今日用量、今日剩余、功能对比和暂不可用加油包。 +- 管理员可按分类维护平台凭据、模型池、主辅模型与会员;非管理员不显示后台刷新和系统管理,直达管理路由会返回情绪周期,智能工作区保留同结构会员锁定态。 +- 前端只有一个工作区注册表、API客户端、会话Store、主题Store、弹窗Host和Toast Host。响应式规则从五处分散媒体查询收敛到唯一`mobile.css`;最长前端文件332行,组件样式未出现令牌文件之外的色值。 +- 阶段4门禁:Ruff和40项pytest通过;Vue类型检查、2个文件5项Vitest、Vite生产构建通过;2项Playwright覆盖管理员/普通用户、真实写入、Ctrl+K、主题持久化、弹窗、系统管理和多视口。 +- 视觉证据已保存于`next/docs/evidence/stage-4/`:日间/夜间1920×1080、夜间3840×2160和390×844;实测无横向溢出、弹窗居中、Shell尺寸与内容边距符合规范。 +- 阶段4残余边界:行情摘要值、真实搜索结果、后台刷新和各工作区业务内容属于阶段5及后续阶段;移动端逐页业务重组属于阶段13,未用占位页冒充完成。 +- 阶段5已完成,代码提交为`cf0ab70`并已推送:建立唯一`DataGateway`、四源职责策略、观测元信息、失败关闭质量门,以及交易日历、标的目录、行情摘要和图表序列的版本3数据库结构。 +- Tushare只承担权威交易日历、股票目录和日线入口;iFinD优先承担日K与分时展示,并支持顶层表格响应和Access Token失效后的Refresh Token重试;东方财富只允许作为展示分时兜底,策略层禁止其进入正式计算。腾讯职责已在策略中登记,但尚无本阶段真实消费者,因此未创建空Provider。 +- 日期契约区分请求日期、实际数据日期和观测时间;盘前空今日K线会被剔除,沿用最近真实快照时明确返回真实日期和说明,从未同步时不生成模拟数据。 +- 全局搜索按股票、板块、题材、指数固定分组,具备160毫秒防抖、键盘循环选择和明确状态;搜索预览与行情详情共用同一图表组件,日K上涨空心且影线不穿实体,分时范围固定9:30至15:00并包含昨收零轴和均价线。 +- 管理员行情管理增加交易日历和股票目录的真实同步入口;普通用户响应不显示供应商工程名称。完整行情后台任务、情绪摘要写入和各实体业务详情仍属于后续阶段,没有用本阶段基础页冒充完成。 +- 阶段5门禁:Ruff通过,pytest为46项通过;Vue类型检查、2个文件5项Vitest和Vite生产构建通过;3项Playwright覆盖既有Shell回归、摘要、搜索、日K/分时、日夜主题、1920×1080和390×844视口。组件CSS无令牌外色值,已提供密码和令牌未进入`next/`。 +- 减法证据:未复制旧`TushareClient`、`IfindHttpClient`、`MarketChartClient`或供应商缓存堆叠;所有外部访问收敛到一个网关和一份策略,搜索预览与详情没有重复图表实现;新增后端最长文件379行,低于章程400行目标。 +- 新系统进入阶段6:使用阶段5的交易日期、摘要存储和质量门迁移情绪周期与五类股池的确定性公式、固定样本和页面。 +- 阶段6已完成,代码提交为`59f6011`并已推送:以唯一日度市场快照交付情绪周期、涨停池、炸板池、跌停池、昨日涨停和涨停表现;管理员后台刷新接入同一快照任务,普通用户仍无管理入口。 +- 情绪温度严格实现五维权重、自适应历史百分位、系统健康门控、极端风险上限和六阶段状态机;权威涨跌停事件不完整或日线覆盖率低于98%时失败关闭并保留旧快照,未以日线推导或公开网页源静默补造事件。 +- 四类股票明细共用`PoolPage`和`DataTable`的搜索、筛选、排序、空态与CSV导出;涨停表现直接消费后端一次聚合的晋级、红盘、断板、炸板和跌停统计,7板以上动态扩展,不在前端复制分类公式。原因和风险线索无权威字段时保持空值。 +- 阶段6门禁:Ruff通过,pytest为49项通过;Vue类型检查、2个文件5项Vitest和Vite生产构建通过;4项Playwright覆盖阶段4/5回归及情绪、股池、动态7板、日夜主题、1920×1080、3840×2160和390×844视口,控制台无未处理错误且无页面横向溢出。 +- 阶段6视觉与验收证据位于`next/docs/evidence/stage-6/`。新增页面最长207行,页面CSS 118行;六个页面只消费一份`market_summaries.payload_json`,没有逐页重复表或第二套市场快照。 +- 原阶段表把“涨停表现”同时包含在阶段6“五类股池”和阶段7,属于范围重复。其完整实现已随单一快照在阶段6交付;阶段7只迁移市场天梯和板块轮动,同时把涨停表现纳入跨页回归,不再创建重复实现。 +- 新系统进入阶段7:复用阶段6的涨停结构快照,迁移市场天梯的动态梯队、展开收起与排序,以及板块轮动的九日Top12、跨日高亮、排序和成分股。 +- 阶段7已完成,代码提交为`a76d344`并已推送:市场天梯支持动态层级、断层、等宽个股单元格、两种排序、展开收起和导出;板块轮动支持九日Top12、日期正逆序、跨日高亮和指定交易日申万二级成分股。 +- 天梯、轮动、情绪和股池继续共用唯一日度市场快照;涨停表现只回归阶段6实现。板块成分股只增加版本4的专用快照缓存,未建立第二套轮动表或通用缓存框架。 +- 行情快照采集已从直接创建供应商客户端收敛到`DataGateway`;供应商策略、计算用途限制和成分股读取均经同一网关,消除业务服务绕过数据治理层的出口。 +- 阶段7门禁:Ruff通过,pytest为50项通过;Vue类型检查、2个文件5项Vitest和Vite生产构建通过;5项Playwright覆盖阶段4至7回归、日夜主题、展开滚动、1920×1080和390×844视口,控制台无未处理错误且无横向溢出。 +- 阶段7视觉与验收证据位于`next/docs/evidence/stage-7/`。新增页面分别为106行和126行,市场页面样式总计415行,均低于章程门禁;已提供密码和令牌未进入`next/`。 +- 阶段8已完成,代码提交为`976a5ca`并已推送:集合竞价实现盘前归档、9:15至9:25动态观察、9:25选定及盘后归档,重点异动、我的自选、全部候选、竞价一字和成交额历史共用同一标准化口径。 +- 题材库区分题材行情与成分股缺失状态,题材悬浮预览复用阶段5的统一图表组件;人气热榜区分同花顺、东方财富和双榜共识,单源数据不会伪装成共识;龙虎榜明确区分无上榜、明细缺失、全部未识别、请求不可用及部分/完整识别状态。 +- 竞价共享归档不保存用户私有内容,“我的自选”在响应边界按认证账号即时合成。自选股使用独立版本6 Migration,避免修改已执行迁移;跨账号Repository测试确认记录互不可见。 +- 阶段8门禁:Ruff通过,pytest为58项通过;Vue类型检查、2个文件5项Vitest和Vite生产构建通过;6项Playwright覆盖阶段4至8回归、日夜主题、1920×1080和390×844视口,控制台无未处理错误且无移动端横向溢出。 +- 阶段8视觉与验收证据位于`next/docs/evidence/stage-8/`。新增页面最长108行、页面样式168行,后端最长纯计算模块492行;没有复制旧系统巨型文件、通用CRUD、通用缓存、供应商客户端或兼容层。 +- 新系统进入阶段9:迁移智能选股、36套策略、自定义选股和持续跟踪;以产品规格定义的109个因子、盘后自动执行边界、策略确定性、无信号与数据不足分离、账户隔离作为完成条件,不以精简策略或占位接口冒充迁移完成。 +- 阶段9已完成:建立109因子目录、7套阶段策略、29套独立策略、受控确定性公式、自定义选股、盘后调度和手动持续跟踪;阶段策略受实际情绪阶段匹配,29套策略不受阶段门控。 +- 选股结果严格区分数据不完整与完整数据下无信号;财务因子遵守公告日期,缺失值不转为零,同分按标识稳定排序。公开网页数据和LLM均不参与候选计算。 +- 自定义策略、运行结果和跟踪记录按账号隔离,运行选股不会自动加入跟踪;跟踪统计包含T+1开盘/收盘、T+3/T+5收盘、最大涨幅和最大回撤,T+1/T+5事件具备持久唯一性。 +- 阶段9门禁:Ruff通过,pytest为69项通过;Vue类型检查、2个文件5项Vitest和Vite生产构建通过;8项Playwright覆盖阶段4至9全量回归、非会员锁定、日夜主题、1920×1080与390×844,控制台无未处理错误且无移动端横向溢出。 +- 阶段9视觉与验收证据位于`next/docs/evidence/stage-9/`。未复制旧系统2118行选股实现;后端最长文件448行、页面容器最长169行、选股样式102行,均低于章程门禁。 +- 阶段9残余边界:自然语言转公式必须在阶段10经唯一LLM网关实现;完整IC动态加权未以基础多因子冒充。NAS生产容器保持不变。 +- 新系统进入阶段10:迁移问师、思维模型Skill、数据上下文路由和唯一LLM流式网关,并验证重复输出、计次、限流、主辅模型回退和中断恢复。 +- 阶段10已完成,代码提交为`f1fa104`并已推送:23个公开思维模型由文件目录自动发现,私有Skill使用受Git忽略的数据目录且仅管理员可见;前端只显示A/B/C证据等级。 +- 问师上下文按情绪、首板、龙头、趋势、低吸承接和宏观六类路由,龙虎榜按问题触发,单次最多匹配两个标的;上下文仅读取本地快照和缓存图表,不因提问触发全市场外部刷新。 +- 唯一LLM网关统一会员鉴权、每日额度、主辅模型、请求与尝试审计和安全错误;仅首字前失败允许回退,首字后中断保留部分结果且不重放,一次业务请求最多结算一次成功额度。 +- 会话、顺序和置顶按账号隔离,会话进一步按模型和交易日隔离;清空只作用于当前范围。前端复用唯一API客户端和全局弹窗管理器,支持真实NDJSON流、停止、重试和去重。 +- 阶段10门禁:Ruff通过,pytest为78项通过;Vue类型检查、2个文件6项Vitest和Vite生产构建通过;阶段4至10共10项Playwright通过,覆盖1920×1080、3840×2160、390×844、日夜模式和非会员同结构锁定态。 +- 阶段10视觉与验收证据位于`next/docs/evidence/stage-10/`。新增最长后端文件262行、页面容器230行、页面样式289行;新系统未导入旧`mentor_agent.py`或建立第二套流式、配额和弹窗实现。 +- 阶段11已完成:观势实现正式日线与盘中实时两种严格模式、六爻确定性公式、安全门和客观行业数据补录;观气实现确定性历法、固定权重、三层气机和个人合参;观心实现40秒呼吸、一次投掷只生成一爻及六爻成卦。 +- 问天三种历史按账号隔离;每日解运具有数据库唯一约束和并发复用,已完成结果不会重复调用LLM或扣减额度。原始出生资料不在问天响应中回显。 +- 三种智能解读复用阶段10唯一LLM网关、配额、流协议和弹窗;页面保留已经验收的星空、六爻、铜钱与循环加载动画,没有复制旧`heaven_agent.py`、`heaven_engine.py`或建立第二套解读实现。 +- 阶段11门禁:Ruff通过,pytest为89项通过;Vue类型检查、3个文件7项Vitest和Vite生产构建通过;阶段4至11共12项Playwright通过,覆盖1920×1080、390×844、日夜模式、加载弹窗和非会员同结构锁定态。 +- 阶段11视觉与验收证据位于`next/docs/evidence/stage-11/`。市场情绪计算从业务域收敛到数据层,问势实时组装从通用网关拆到专用数据模块;NAS生产容器保持不变。 +- 阶段12已完成:我的复盘交付账户独立的自选追踪、交易日志、每日复盘、历史记录和个股复盘笔记;重复加入自选不会清空既有备注。 +- 提醒中心支持手动提醒、策略T+1/T+5提醒、未读状态和持久化去重;复盘助手复用唯一LLM网关并只读取本地市场快照和当前账号私有记录,非会员保留同结构灰色锁定态。 +- 阶段12门禁:Ruff通过,pytest为92项通过;Vue类型检查、3个文件7项Vitest和Vite生产构建通过;阶段4至12共14项Playwright通过,覆盖1920×1080和390×844、日夜模式、全局弹窗与私有数据流程。 +- 阶段12视觉与验收证据位于`next/docs/evidence/stage-12/`。新增业务Service 368行、页面样式397行;未导入旧复盘、自选或助手实现,也未建立第二套弹窗、LLM、配额或流协议。 +- 阶段13已完成:移动端保留五个一级入口,市场复盘通过统一选择器访问12个子页;桌面和移动端共用唯一交易日期组件,复盘页补齐提醒中心与复盘助手入口。 +- 430px以下通用宽表重组为字段明确的纵向记录,平板和横屏保留局部横滚;底部导航正确识别所有市场子页,横屏股池工具栏收为一行。 +- 阶段13门禁:Ruff和92项pytest通过;Vue类型检查、3个文件7项Vitest和Vite生产构建通过;阶段4至13共17项Playwright通过。320、375、390、430、768、844×390横屏及1024至4K断点均无页面横向溢出,16个工作区在320px逐页可达。 +- 可访问性补齐跳到主内容、排序`aria-sort`、弹窗焦点回收、44px触控目标和有效的1ms减少动态效果令牌。移动规则集中在537行的唯一`mobile.css`,未复制页面、API或业务状态。 +- 阶段13视觉与验收证据位于`next/docs/evidence/stage-13/`。 +- 新系统进入阶段14:交付可重复的旧库迁移、Docker运行、备份恢复、升级回退及性能安全检查,所有演练仅作用于副本和新系统容器资产。 +- 阶段14已完成:一次性迁移工具以只读 URI 打开旧库,真实副本迁移保留3个账号、会员与模型配置、全部已归属私有记录、196次历史选股、16条跟踪及受控展示归档;完整性检查通过且外键违规为0。 +- 备份使用SQLite在线备份API并将数据库、环境密钥材料和私有Skill归入同一带SHA-256清单的归档;恢复默认拒绝覆盖并在落盘前校验安全路径、文件摘要和数据库完整性。真实恢复演练通过。 +- 生产前端改为FastAPI同端口提供,SPA深链接与API 404边界通过测试;新容器定义采用多阶段构建、非root、只读根文件系统、持久卷和健康检查。开发机没有Docker,实际镜像构建和容器重启恢复列为NAS切换前阻断验收项。 +- 阶段14门禁:Ruff、96项pytest、Vue类型检查、7项Vitest、Vite生产构建及17项Playwright全部通过;真实迁移库副本行情摘要读取P95为23.55ms。证据位于`next/docs/evidence/stage-14/`。 +- 新系统进入阶段15:执行规格覆盖矩阵、减法审计、性能安全总验收、人工维护演练和最终切换准备;阶段15不自动切换NAS生产容器。 +- 阶段15已完成:85个固定产品案例逐项映射到确定性/API或真实浏览器证据;重建运行时代码由旧版61,793行收敛为25,151行,减少约59.3%,未复制旧巨型文件和CSS覆盖层。 +- 结构审计消除LLM网关对账户具体类的反向依赖;数据库状态命令修复为真正只读且能识别已有migration。统一生产安全头、静态缓存边界、维护手册和NAS切换/回退清单已交付。 +- 阶段15门禁:Ruff、99项pytest、Vue类型检查、7项Vitest、Vite生产构建和17项Playwright全部通过;npm与Python生产依赖已知漏洞均为0,敏感值扫描通过。 +- 新系统代码迁移工作完成。正式NAS切换仍被Docker实构建、容器重启恢复和用户切换窗口确认三类人工条件阻断,不在本阶段自动执行。 +- 生产切换明确保留为最终人工确认项。 diff --git a/docs/product/小白复盘-完整产品规格说明书.md b/docs/product/小白复盘-完整产品规格说明书.md new file mode 100644 index 0000000..853f564 --- /dev/null +++ b/docs/product/小白复盘-完整产品规格说明书.md @@ -0,0 +1,2000 @@ +# 小白复盘完整产品规格说明书 + +> **2026-07-30保真迁移补充裁决:** 当前任务不再是依据本文“从零重建”,而是整理迁移原版源码。 +> 原版真实运行行为、源码、样式和资产是保真基线;本文只用于功能盘点和解释。本文与原版不一致时 +> 不得由实施者自行按本文重写,必须保持原状并交由用户裁决。完整约束见 +> [`../migration/原版保真迁移总纲.md`](../migration/原版保真迁移总纲.md)。 + +> 文档性质:产品事实与重建规格(Single Source of Product Truth) +> 适用场景:在不读取旧代码、不依赖历史对话的前提下,从零重建“小白复盘” +> 基准日期:2026-07-29 +> 文档版本:1.0 + +## 0. 文档效力与使用方法 + +### 0.1 重建目标 + +本说明书定义“小白复盘”所有必须被用户观察到的行为,包括功能、业务口径、权限、数据真相、交互状态、视觉层级、终端行为、异常处理、持久化边界和验收案例。技术栈、目录、类、函数、数据库品牌和部署工具均可更换,但不得改变本说明书中标为“已确认产品标准”的行为。 + +一个没有任何项目上下文的实现者,应能依照本文完成以下工作: + +1. 还原完整的信息架构、页面与交互。 +2. 还原全部角色权限和用户数据隔离边界。 +3. 还原行情日期、数据来源、缺失和降级规则。 +4. 还原情绪周期、集合竞价、智能选股和问天的确定性计算。 +5. 还原日间、夜间、1080P、4K和移动端的目标行为。 +6. 依据文末验收案例判断重建是否合格。 + +### 0.2 三类状态 + +本文只使用以下三类状态,实施者不得混淆: + +| 状态 | 含义 | 实施要求 | +|---|---|---| +| 已确认产品标准 | 产品最终应具有的行为 | 必须实现;优先级高于旧版本现状 | +| 当前实现基线 | 当前版本已经具备、重建时不得无意丢失的能力 | 应保留;与已确认标准冲突时以已确认标准为准 | +| 待设计或待升级 | 已识别但尚未定稿的能力 | 不得擅自补全为正式功能;应保留扩展位并明确显示不可用或不展示 | + +除明确标注外,本文正文均为“已确认产品标准”。 + +### 0.3 冲突裁决顺序 + +发生冲突时,按以下顺序裁决: + +1. 本文中的已确认产品标准。 +2. 本文中的固定验收案例。 +3. 本文中的当前实现基线。 +4. 视觉参考图和历史版本仅用于辅助比对,不得覆盖前三项。 +5. 旧代码中的偶然行为、补丁行为和已知缺陷不构成产品标准。 + +### 0.4 产品边界 + +小白复盘是面向A股盘前观察、盘后复盘、策略筛选和个人记录的工作台,不是券商交易终端。系统: + +- 不连接券商账户,不自动下单,不代用户执行交易。 +- 不承诺收益,不把统计相关性表述为因果关系。 +- 不使用模拟行情伪装真实数据。 +- 不允许LLM改变确定性计算结果。 +- 不向普通用户暴露数据供应商、模型名称、接口名称、Token、错误堆栈等工程信息。 +- 所有页面底部常驻“股市有风险,投资需谨慎”。 + +## 1. 产品信息架构 + +### 1.1 主工作区 + +侧栏按以下顺序提供16个主工作区,登录后默认进入“情绪周期”: + +| 序号 | 工作区 | 核心目的 | +|---:|---|---| +| 1 | 情绪周期 | 判断全市场情绪温度、方向和六阶段 | +| 2 | 涨停池 | 查看当日封板股票及封板结构 | +| 3 | 炸板池 | 查看触板未封股票及炸板原因 | +| 4 | 跌停板 | 查看跌停风险与风险聚集 | +| 5 | 昨日涨停 | 观察昨日涨停股的次日反馈 | +| 6 | 涨停表现 | 按昨日高度观察晋级、兑现和断板 | +| 7 | 市场天梯 | 查看当日连板梯队与市场高度 | +| 8 | 板块轮动 | 查看多日热点迁移与板块成分 | +| 9 | 集合竞价 | 查看题材承接、竞价异动和成交额变化 | +| 10 | 题材库 | 查看题材排行、基础行情与成分股 | +| 11 | 人气热榜 | 查看同花顺、东方财富及双榜共识 | +| 12 | 龙虎榜 | 查看上榜明细、活跃游资和游资档案 | +| 13 | 智能选股 | 阶段选股、策略选股、自定义选股 | +| 14 | 问师 | 使用蒸馏的思维模型进行复盘对话 | +| 15 | 问天 | 观势、观气、观心三项传统文化观察 | +| 16 | 我的复盘 | 自选、每日复盘、交易日志和个人历史 | + +### 1.2 内部页面与全局能力 + +“策略持续跟踪”是智能选股内部页面,不在侧栏显示;仅由智能选股页面的显著入口进入。 + +全局能力包括: + +- 登录、注册、退出、切换账号。 +- 顶部交易日期选择和页面刷新。 +- 全局搜索和`Ctrl+K`。 +- 日间/夜间模式。 +- 提醒中心。 +- 复盘助手。 +- 账户菜单。 +- 管理员后台刷新和系统管理。 +- 股票、板块、题材、指数行情悬浮预览与详情。 +- 统一弹窗、Toast、加载态、空态、错误态和底部状态栏。 + +## 2. 角色、身份与数据边界 + +### 2.1 身份模型 + +“管理员权限”和“会员状态”是两个独立维度,不是互斥角色: + +- 普通用户:已登录,但没有有效会员。 +- 会员:拥有有效会员订阅。 +- 管理员:拥有系统管理权限。 +- 管理员可以同时是会员;同时满足时,顶部必须同时显示“管理员”和“会员”两个标识。 +- 管理员即使没有会员订阅,也可访问智能功能进行管理和测试,但界面不得因此把其会员状态伪装为“已开通会员”。 + +### 2.2 权限矩阵 + +| 功能 | 普通用户 | 会员 | 管理员 | +|---|:---:|:---:|:---:| +| 共享行情、股池、情绪、板块、竞价、题材、热榜、龙虎榜 | 可用 | 可用 | 可用 | +| 搜索、行情悬浮、详情页 | 可用 | 可用 | 可用 | +| 自选、个股笔记、每日复盘、交易日志、提醒 | 可用 | 可用 | 可用 | +| 智能选股、问师、问天、复盘助手、策略跟踪 | 只展示锁定态 | 可用 | 可用 | +| 配置个人LLM | 不提供 | 不提供 | 不提供 | +| 后台刷新、系统管理 | 不显示 | 不显示 | 可用 | +| 行情凭据、历史回补、模型池、会员管理 | 不显示 | 不显示 | 可用 | +| 原因修订、席位归类、五行行业归类 | 不显示 | 不显示 | 可用 | + +普通用户进入会员功能时,不使用空白页或单独营销页。页面结构与会员一致,顶部显示“仅对会员开放”,下方内容灰化、不可操作,并提供进入“会员状态”的明确入口。 + +### 2.3 用户私有数据 + +以下数据必须带有明确账号所有权;任何读取、更新、删除和导出都必须验证当前账号,两个用户永远不能互相看到: + +- 自选股和自选备注。 +- 每日复盘。 +- 个股复盘笔记。 +- 交易日志。 +- 提醒。 +- 问师对话、置顶和自定义排序。 +- 问天历史与当天解运结果。 +- 自定义选股策略、个人手动执行结果。 +- 策略持续跟踪。 +- 复盘助手对话。 +- 出生日期、时辰、性别及其派生信息。 + +### 2.4 系统共享数据 + +以下数据为平台共享,不按用户重复保存: + +- 行情快照、交易日历、股票主表。 +- 情绪、股池、天梯、板块、竞价、题材、热榜、龙虎榜。 +- 系统内置策略和盘后自动候选池。 +- 平台模型池、系统数据凭据和刷新配置。 +- 管理员维护的事件原因、席位别名和行业归类。 + +## 3. 账户、会员与系统管理 + +### 3.1 注册与登录 + +- 账号名长度3至30位,可使用中文、字母、数字、下划线和连字符。 +- 密码长度8至128位,至少包含字母、数字、符号中的两类。 +- 首个成功注册的账号自动成为管理员。 +- 后续注册账号默认为普通用户。 +- 登录失败只显示安全的用户提示,不说明账号是否存在。 +- 修改密码必须输入当前密码、新密码和确认密码;成功后给出Toast反馈。 +- “切换账号”返回登录界面,但不能删除本账号数据。 + +### 3.2 顶部身份标识与账户菜单 + +- 有效会员显示设计感明确的金色V标识;非会员不显示会员标识或显示克制的“普通用户”状态,不能显示会员。 +- 管理员显示管理员标识;管理员兼会员时两个标识并存且不覆盖。 +- 点击会员/普通用户标识直接打开“会员状态”。 +- 点击账户设置后出现下拉菜单,依次为:个人资料、会员状态、修改密码、切换账号、退出。 +- 五项均为真实可点击操作,不得合并成一个长页面后失去独立入口。 + +### 3.3 个人资料 + +- 可录入出生日期、出生时间和性别。 +- 明确说明个人信息私密性:原始资料加密保存,仅当前账号可见。 +- 问天只读取排盘后的派生信息,不在问天页面回显原始出生日期、时辰和性别。 +- 用户可以删除出生资料;删除后个人合参显示未配置状态。 + +### 3.4 会员状态 + +会员状态页必须包含: + +- 会员说明和普通用户/会员功能对比。 +- 文案:“开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。” +- 开通状态:未开通、有效、停用。 +- 会员到期时间和剩余时长。 +- 今日智能分析已用次数、每日上限、剩余次数。 +- 智能分析加油包入口;当前状态固定为“暂不可用”,不可购买。 + +默认会员每日LLM调用上限为50次,管理员可设置1至1000次。管理员调用不受会员日额度限制。额度当前按成功发起的一次业务调用计数,不按Token计费;未来如改为Token计费,必须另行升级本规格。 + +### 3.5 管理员会员管理 + +- 可按账号开通、续期和停用会员。 +- 时长选项固定为1个月、3个月、12个月、3年、永久。 +- 有效会员续期从现有到期时间继续累计。 +- 已过期账号续期从当前时间重新计算。 +- 永久会员不显示误导性的剩余天数。 +- 列表显示账号、会员状态、到期时间和每日智能分析上限。 + +### 3.6 系统管理 + +系统管理以分类选择切换内容,不把所有功能堆在一个页面: + +1. 行情管理:平台行情凭据、刷新状态、历史数据回补。 +2. 模型池:模型列表、添加/编辑/删除/测试、主模型和辅助模型选择。 +3. 会员管理:会员时长、到期时间、停用和每日LLM上限。 + +模型池规则: + +- 最多20个模型。 +- 每个模型包含显示名称、服务地址、模型标识和密钥。 +- 主模型必选;辅助模型可以关闭。 +- 主模型和辅助模型不能选择同一模型。 +- 每个模型可以独立测试连通性。 +- 密钥保存后不再返回浏览器,只显示已配置状态。 + +## 4. 全局页面骨架与交互 + +### 4.1 PC页面骨架 + +PC端统一采用以下固定骨架: + +- 侧栏宽度200px。 +- 顶栏高度46px。 +- 市场摘要条收起高度约36px。 +- 主内容区上内边距14px、左右内边距16px。 +- 同级模块间距12px。 +- 底部状态栏高度30px。 +- 页面切换时侧栏、顶栏、摘要条和状态栏不得发生位移。 +- 问天可拥有独立星空气质,但必须使用同一外壳、上边距和左右边距。 + +### 4.2 顶栏 + +顶栏包含: + +- 当前页面标题或品牌识别。 +- 交易日期选择。 +- 顶部搜索图标。 +- 提醒中心。 +- 日间/夜间切换。 +- 会员/普通用户身份标识。 +- 管理员标识。 +- 账户菜单。 +- 仅管理员显示“后台刷新”和“系统管理”。 + +图标应使用熟悉、克制的通用图形,不能使用过度花哨、难辨认的装饰图标。图标按钮必须有中文Tooltip和无障碍名称。 + +### 4.3 市场摘要条 + +摘要条显示:市场情绪、涨停、跌停、炸板、封板率、两市成交、数据日期时间。 + +- 市场情绪前的圆点在展开态具有常驻且克制的呼吸动效。 +- 收起态不显示会变形的情绪圆柱。 +- “市场情绪”和其后的“情绪偏弱/偏强”等状态文字字号、基线和字形一致,只通过颜色区分状态。 +- 展开内容使用清晰分组,不把工程字段暴露给用户。 +- 折叠与展开不能改变主内容横向位置。 + +### 4.4 底部状态栏 + +- “股市有风险,投资需谨慎”位于固定状态栏中。 +- 无论表格只有1行、筛选到3板+、展开更多或页面滚动,状态栏位置都不能随数据量上移。 +- 在全页滚动页面中,状态栏可以位于视口底部或页面壳底部,但必须保持一致,不能覆盖内容。 + +### 4.5 弹窗、Toast与加载 + +- 所有模态弹窗默认在视口中心,不能出现在左上角。 +- 同一时间只允许一个无关模态弹窗打开。 +- 支持Escape关闭、遮罩层关闭(危险操作确认除外)和键盘焦点回收。 +- 表单验证错误显示在对应字段附近,并可同时给出简短Toast。 +- “保存成功”“交易记录已保存”等反馈必须是内容自适应Toast,不能生成超长空弹窗。 +- 加载背景必须跟随日间/夜间主题,不能闪白。 +- 主题切换必须原子完成;表格和卡片不能晚一拍变色。 + +## 5. 日期、刷新与行情真相 + +### 5.1 默认日期 + +- 每次打开网站默认选择“最新可用交易日期”,不是沿用浏览器上次停留的过期日期。 +- 默认页面为情绪周期。 +- 选择历史日期后,所有以顶栏日期为准的页面显示该日期数据。 +- 悬浮日K是例外:永远显示最近真实行情,不严格跟随页面历史日期。 + +### 5.2 盘前、盘中、收盘和非交易日 + +| 场景 | 展示规则 | +|---|---| +| 交易日9:15前请求今天 | 显示最近有效交易日15:00收盘快照 | +| 交易日9:15至收盘前 | 显示最近一次真实刷新数据及其真实时间 | +| 交易日收盘后 | 使用当日最终数据,数据时间显示15:00 | +| 周末、节假日 | 显示最近有效交易日15:00收盘快照 | +| 选择历史日期 | 只显示该日期或该日期之前的真实归档,不混入当前实时数据 | +| 当前日期取数失败 | 可沿用最近真实收盘快照,但必须标明实际数据日期和“沿用最近快照” | +| 从未成功同步 | 明确提示等待管理员首次同步,不显示模拟数据 | + +不得把上一交易日数据标成今天,不得为了图形完整制造不存在的今日K线。 + +### 5.3 数据日期时间 + +- 历史日显示`YYYY-MM-DD 15:00`。 +- 今天尚未收盘显示最近一次真实刷新时间。 +- 今天收盘后显示`YYYY-MM-DD 15:00`。 +- 沿用旧快照时,显示旧快照真实日期时间。 +- 页面中的“数据日期”和数据本身必须来自同一快照。 + +### 5.4 刷新语义 + +- 普通“刷新”重新读取最新服务端快照,保持当前页面和合理的筛选状态。 +- 管理员“后台刷新”启动服务端数据同步,但不主动重绘当前用户页面。 +- 数据同步失败不能导致已存在的最后真实快照消失。 +- 后台实时刷新窗口为交易日9:15至11:35、12:55至15:05。 +- 实时快照约每8秒达到一次刷新条件;实际调用频率必须受供应商额度和限流约束。 +- 盘后自动智能选股在15:10之后、当日行情和因子确认完成后执行。 + +## 6. 数据来源、单位与质量标准 + +### 6.1 数据源职责 + +| 数据源 | 可用于正式计算 | 主要职责 | +|---|:---:|---| +| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 | +| iFinD | 是,限许可字段 | 实时快照、动态竞价、分时、图表和经批准的事件补充 | +| 东方财富公开数据 | 否 | 分时图等纯展示兜底 | +| 腾讯公开数据 | 否 | 实时指数观察的纯展示兜底 | +| 本地确定性派生 | 是 | 情绪、选股、问天等计算结果 | + +东方财富和腾讯的公开网页数据不得进入情绪、智能选股、回测或问天的确定性计算。若未来要升级为正式计算源,必须逐字段完成授权、单位、复权、新鲜度和一致性验收,不能整体替换。 + +### 6.2 数据质量元信息 + +每一个进入计算的观测值必须可追溯: + +- 数据实体和交易日期。 +- 真实观测时间。 +- 数据来源。 +- 单位和精度。 +- 复权方式。 +- 覆盖率和新鲜度。 +- 是否最终值、实时值或归档值。 + +计算数据缺失时采用“失败关闭”:不能用口径不同的代理值静默继续,并仍声称原算法已经完成。展示数据缺失时显示“暂不可用”及可理解原因,不生成模拟值。 + +### 6.3 关键覆盖率与新鲜度 + +| 数据集 | 最低覆盖率 | 新鲜度/特别要求 | +|---|---:|---| +| 交易日历 | 100% | 日期链必须连续可验证 | +| 股票主表 | 98% | 退市、上市状态明确 | +| 股票日线 | 98% | 正式计算使用同一复权口径 | +| 日度估值 | 95% | 单位转换一致 | +| 财务指标 | 90% | 必须按公告时间做时点对齐,防止未来函数 | +| 资金流 | 90% | 不得混用不同供应商口径 | +| 申万行业 | 95% | 必须明确申万层级与成分覆盖 | +| 涨跌停事件 | 100% | 触板、封板和连板口径一致 | +| 最终竞价 | 90% | 使用9:25最终竞价 | +| 动态竞价 | 80% | 快照新鲜度目标10秒 | +| 人气榜 | 95% | 标明榜单真实日期 | +| 龙虎榜 | 95% | 区分无上榜与席位无法归类 | +| 分时图 | 100%单标的序列 | 盘中新鲜度目标30秒 | +| 实时指数观察 | 三大指数100% | 新鲜度目标90秒 | + +### 6.4 单位与空值 + +- 价格:元/股。 +- 成交量:股。 +- 底层成交额:元;界面按亿或万统一换算。 +- 涨跌幅、换手率、财务比率:百分比。 +- 总市值和流通市值展示统一为亿元。 +- 日期使用`YYYY-MM-DD`,时间使用24小时制。 +- 金额大于等于1亿元显示`X.XX 亿`,否则显示`X.XX 万`。 +- 表格空值留空;空态说明原因。不得满屏使用`--`掩盖缺失。 +- 数值列右对齐并使用等宽数字;单位进入表头。 + +### 6.5 尚未接入的数据 + +以下能力当前标为“待设计或待升级”,不得伪装已完成: + +- 分析师一致预期、目标价和评级变化。 +- Level-2逐笔成交、逐笔委托、委托队列和未匹配订单。 +- 稳定、完整的政策、隔夜资讯、汇率、利率和商品宏观序列。 +- 隔夜消息面对竞价反应的正式量化。 + +## 7. 全局搜索、行情预览与详情 + +### 7.1 全局搜索 + +- 顶栏提供搜索图标,支持`Ctrl+K`打开。 +- 不提供用户自定义快捷键。 +- 若按键事件已被其他功能占用,提示“Ctrl+K已被其他功能占用,请点击顶部搜索按钮”。 +- 输入后约160毫秒防抖搜索。 +- 结果按股票、板块、题材、指数四组固定顺序展示。 +- 支持名称、代码和合理的模糊匹配。 +- 键盘上下方向键循环移动选择,Enter打开,Escape关闭。 +- 无输入、正在搜索、无结果、请求失败必须分别有明确状态。 +- 点击股票打开完整个股详情;点击板块、题材或指数打开对应实体详情。 + +### 7.2 行情悬浮预览 + +适用于股票代码、板块名称、题材名称和指数入口。 + +- 桌面端悬浮触发;默认优先显示最新日K,可切换分时。 +- 悬浮预览不严格跟随页面选择的历史日期,永远寻找最近真实行情。 +- 鼠标从触发元素移动到浮层时,浮层不能立即消失;离开两者后延迟关闭。 +- 移动端不能依赖Hover,点击进入底部预览或详情。 +- 开盘前不得生成今天的空K线;展示最后一个真实交易日。 +- 盘中只有开、高、低、现价、成交量和成交额均有效时,才可合并当日实时K线。 +- 分时取最近一个有真实数据的交易日;横轴固定9:30至15:00,未发生时段保留空白。 +- 分时以昨收为0轴,显示均价线。 +- 日K上涨红色空心,下跌绿色实心;上下影线不能穿过空心实体。 +- 加载态和错误态跟随主题,不能出现白色闪屏。 +- 图表显示真实数据日期,不向普通用户显示供应商名称。 + +### 7.3 个股详情 + +个股详情必须与原有完整详情功能一致,至少包含: + +- 股票名称、代码、价格、正确涨跌幅、行业、连板信息。 +- 日K/分时切换。 +- 资金流。 +- 涨停、炸板、跌停等事件逻辑或原因。 +- 现有交易数据。 +- 当前账号的个股复盘笔记,支持保存、更新和删除。 +- 添加/移除自选。 +- 进入“观势”的入口。 + +个股涨跌幅必须使用对应交易日的正确前收盘价计算,不得使用错误基准或把实时价格与历史前收混用。 + +### 7.4 板块、题材与指数详情 + +- 展示名称、代码或唯一标识、涨跌幅、现有交易指标、日K和分时。 +- 行情图默认日K,分时范围和0轴规则与个股一致。 +- 不显示个股事件逻辑。 +- 不显示个股复盘笔记。 +- 题材详情另外显示成分股列表。 + +## 8. 情绪周期 + +### 8.1 页面目标与结构 + +情绪周期用于说明市场整体处于什么温度、朝哪个方向变化、位于六阶段中的哪一阶段。页面采用全页滚动,保证1920×1080下交易日明细具有可用高度。 + +页面包含: + +1. 温度走势图:情绪温度、五日均线和阶段转折标记。 +2. 当前阶段:阶段、置信度、温度、较前日、升降温、封板率、涨停/炸板、昨日涨停反馈和样本数。 +3. 五维评分构成。 +4. 交易日明细:支持10日、20日、60日切换,默认20日。 + +已确认删除“判定口径”独立模块。当前阶段黄色警示只描述情绪,例如“情绪指标继续走弱”,不得夹带“提高选股门槛”“接受无候选”等选股建议。 + +### 8.2 情绪温度总公式 + +总温度由五项组成: + +| 维度 | 权重 | +|---|---:| +| 市场宽度 | 20% | +| 涨停生态 | 25% | +| 赚钱效应 | 30% | +| 连板结构 | 15% | +| 成交活跃度 | 10% | + +最终温度先计算五项加权和,再乘系统健康门控,并执行极端风险上限。分数四舍五入为0至100整数。 + +### 8.3 基础统计 + +- 市场宽度 = 上涨家数 ÷(上涨家数 + 下跌家数)×100。平盘家数不进入分母。 +- 封板率 = 涨停数 ÷(涨停数 + 炸板数)×100。 +- 昨日涨停红盘率 = 昨日涨停且今日涨幅>0的数量 ÷ 昨日涨停样本数 ×100。 +- 晋级率 = 昨日涨停且今日结果为晋级的数量 ÷ 昨日涨停样本数 ×100。 +- 重亏率 = 昨日涨停且今日涨幅≤-5%的数量 ÷ 昨日涨停样本数 ×100。 +- 梯队完整度 = 从首板到最高板之间实际存在的高度层级数 ÷ 最高板高度 ×100。 +- 近20日成交基线只使用此前有效交易日,不混入未来数据。 + +### 8.4 自适应评分 + +- 历史样本不足20个交易日:使用固定锚点线性评分。 +- 历史样本达到20日:自适应得分 = 固定锚点得分×25% + 最近最多250日历史百分位×75%。 +- 百分位采用“小于当前值的样本数 + 等于当前值样本数×0.5”除以样本数。 + +固定锚点: + +- 涨停强度:涨停10家映射0分,100家映射100分。 +- 跌停压力:跌停0家映射0分,50家映射100分,之后反向为跌停缓解分。 +- 封板质量:封板率35%映射0分,90%映射100分。 +- 最高板:1板映射0分,7板映射100分。 + +### 8.5 五维细项 + +涨停生态: + +- 涨停强度35%。 +- 封板质量35%。 +- 跌停缓解30%。 + +赚钱效应: + +- 昨日涨停红盘率30%。 +- 昨日涨停涨幅中位分25%,中位分 = 截断到0至100的`50 + 中位涨幅×7`。 +- 昨日涨停平均涨幅分10%,平均分 = 截断到0至100的`50 + 平均涨幅×6`。 +- 晋级率分20%,晋级率×2.5后截断到0至100。 +- 尾部安全15%。尾部安全 = 重亏安全×70% + 跌停安全×30%;重亏安全=`100-重亏率×3`,跌停安全=`100-昨日涨停中跌停占比×700`,均截断到0至100。 +- 昨日涨停样本为0时,赚钱效应暂记50分并明确标注缺少样本。 + +连板结构: + +- 最大高度分30%。 +- 晋级密度分25%,晋级密度 =(二板数 + 三板及以上数)÷ 涨停数;评分为密度×3后截断。 +- 三板以上分25%,结合三板以上密度固定分和历史百分位。 +- 梯队完整度20%。 + +成交活跃度: + +- 当日总成交额相对近20日均值70%;金额评分=`50 +(当日/均值-1)×100`后截断。 +- 涨停股成交额占市场成交额30%;占比×20后截断。 + +系统健康门控: + +- 系统健康度 = 市场宽度×60% + 跌停缓解×40%。 +- 健康度≥35时门控系数为1。 +- 健康度<35时,门控系数=`0.35 + 健康度/35×0.65`。 +- 市场宽度≤15且跌停≥100时,最终温度上限15。 +- 市场宽度≤25且跌停≥50时,最终温度上限24。 + +### 8.6 标签、方向与阶段 + +温度标签: + +- 80及以上:情绪高涨。 +- 60至79:情绪偏强。 +- 40至59:情绪中性。 +- 20至39:情绪偏弱。 +- 20以下:情绪冰点。 + +方向使用当前分数相对最近连续最多3个交易日均值的变化: + +- 大于3:升温。 +- 小于-3:降温。 +- 其余:持平。 + +六阶段为冰点、修复、发酵、高潮、分化、退潮。阶段必须经过状态机确认,不能把温度区间直接等同于阶段: + +- 冰点或退潮转修复:单日升温≥6、温度≥25、系统健康度≥24。 +- 修复转发酵:发酵原始信号连续两个交易日成立;单日偶发不能直接切换。 +- 高潮:温度≥80、赚钱效应≥60、系统健康度≥60、涨停生态≥70。 +- 高潮条件消失但未崩塌:转分化。 +- 发酵温度跌破45且继续转弱,或系统健康度明显下降:转退潮/分化。 +- 任何阶段触发极端冰点条件:转冰点。 +- 首个连续交易日可采用原始阶段信号,但必须标明样本不足。 + +### 8.7 明细表 + +交易日明细必须至少包含日期、温度、阶段、方向、红盘率、封板率、涨停、炸板、跌停、最高板、成交额和昨日涨停反馈。温度、阶段、方向和红盘率列的表头及单元格对齐方式一致;数值右对齐,阶段文本左对齐。 + +## 9. 市场股池与市场结构 + +### 9.1 通用表格规则 + +- 代码和股票名称分列,代码在名称左侧。 +- 第一列标题为“序号”,不显示`#`。 +- 主要数值列宽度按内容类型等宽;原因、逻辑、线索列可更宽。 +- 所有列合计占满可用宽度,不能前部拥挤、右侧大量空白。 +- 首封、最后封板、首次触板等时间列统一对齐。 +- 表头数值列可排序;支持代码/名称/板块搜索和CSV导出。 +- 表格日间、夜间均不得出现白边或主题延迟。 + +### 9.2 涨停池 + +- 筛选:全部、首板、2板、3板+。 +- 表格:序号、代码、股票、连板、涨幅、价格、板块、首封、最后封板、开板次数、换手率、成交额、封单额、涨停原因。 +- 右侧显示连板高度和热点板块。 +- 搜索和筛选后表格仍保持统一列宽。 + +### 9.3 炸板池 + +- 表格:序号、代码、股票、现价涨幅、距涨停、价格、板块、首次触板、开板次数、换手率、成交额、炸板原因。 +- “距涨停”使用当日正确涨停价格计算。 + +### 9.4 跌停池 + +- 表格:序号、代码、股票、跌幅、价格、板块、换手率、成交额、连续跌停天数、风险线索。 +- 页面显示风险行业聚集提示,但不得把聚集统计写成个股确定性结论。 + +### 9.5 昨日涨停 + +- 结果分类:晋级、红盘、断板、炸板、跌停。 +- 摘要筛选:全部、晋级、红盘、断板、炸板+跌停。 +- 红盘兑现率 = 昨日涨停中今日涨幅>0的数量 ÷ 昨日涨停总数。 +- 表格:序号、代码、股票、昨日高度、今日涨幅、今日结果、当前高度、板块、涨停逻辑。 + +### 9.6 涨停表现 + +- 按昨日连板高度显示今日晋级、红盘、断板、炸板和兑现情况。 +- 高度不限制为5板;超过5板时上方结构动态扩展或折行,不能挤出右侧“今日结论”。 +- 下方市场宽度显示上涨、平盘、下跌、红盘率及比例条。 +- 右侧根据真实统计生成当天结论。 + +### 9.7 市场天梯 + +- 按首板、2板、3板、4板、5板+形成梯队。 +- 支持按封板时间、开板次数排序。 +- 每层默认限制显示数量,提供“展开更多”和“收起”。 +- 股票单元格在同一栅格中等宽;不是每行只放一个单元格。 +- 展开内容超过视口时页面正常滚动。 +- 右侧显示市场高度、梯队完整度和昨日梯队到今日的结构提示。 + +### 9.8 板块轮动 + +- 展示最近9个交易日,每日Top12热点。 +- 支持由远到近、由近到远排序。 +- 点击板块后,同一板块在其他日期中高亮,便于观察连续性。 +- 强度色阶:90及以上高、70至89中、70以下低;使用纯色背景,不增加高亮阴影。 +- 下方显示所选板块在目标交易日的有效申万成分股,不再显示“当日轮动明细”。 +- 成分表:序号、代码、股票、涨跌幅、开盘、收盘、成交额、行情状态。 +- 页面采用全页滚动。 + +## 10. 集合竞价 + +### 10.1 产品定位 + +集合竞价中心的核心不是9:30后的静态涨跌分类,而是: + +1. 监控昨日热门强势题材今天的承接,以及今天是否出现新强势题材。 +2. 在昨日涨停、昨日炸板、热榜和自选范围内识别竞价异动;这是核心能力。 +3. 观察集合竞价市场总成交额及多日变化。 +4. 隔夜消息面的竞价反应;当前只保留能力定义,未接入稳定消息接口时不作为主模块。 + +### 10.2 生命周期 + +| 时间 | 状态 | 行为 | +|---|---|---| +| 9:15前 | 待开始 | 显示前一交易日归档,并明确不是今日竞价结果 | +| 9:15至9:25 | 观察中 | 有iFinD许可数据时展示动态快照 | +| 9:25至9:30 | 筛选确认 | 使用9:25最终竞价快照形成结果 | +| 9:30后 | 已归档 | 冻结当日竞价结果,用于复盘 | +| 历史日期 | 历史归档 | 只读取该日归档 | + +iFinD动态竞价不可用时,不能把Tushare最终竞价伪装成动态监控;可在9:25后使用最终竞价,或显示已归档数据。 + +### 10.3 候选基数 + +- 昨日涨停池。 +- 昨日炸板池。 +- 同花顺和东方财富热榜;只纳入热榜前20。单榜排名超过10、又非双榜共识且非已有市场核心时剔除。 +- 自选股单独列出,不与公共候选混为同一身份。 + +市场核心标签包括:人气前5、三板以上、市场最高板、题材核心、市场领涨。所有市场核心必须保留,不能因普通候选数量上限被删除。 + +### 10.4 预期中枢 + +基础中枢: + +- 无连板基准:0.5%。 +- 昨日首板:1.5%。 +- 昨日2板:3.0%。 +- 昨日3板:4.0%。 +- 昨日4板及以上:5.0%。 +- 双榜共识:加0.8个百分点。 +- 单榜前10:加0.7个百分点;其他榜位按名次递减。 +- 总预期中枢上限6.5%。 + +成交确认修正: + +- 竞价量比≥2:加0.6;≥1.2:加0.3;<0.6:减0.5。 +- 竞价换手率≥0.15%:加0.25;<0.03%:减0.25。 +- 竞价成交额≥2000万元:加0.3;≥500万元:加0.15;<100万元:减0.3。 + +实际竞价涨幅相对修正后预期中枢: + +- 高至少1.5个百分点:超预期。 +- 低至少1.5个百分点:低于预期。 +- 其余:符合预期。 + +### 10.5 关注分与重点异动 + +关注分满分100,由以下部分组成: + +- 身份:14至35分。 +- 预期偏差:最多30分。 +- 量比:最多10分。 +- 成交额:最多6分。 +- 换手:最多4分。 +- 题材:最多15分。 + +重点异动规则: + +- 所有市场核心直接保留。 +- 非核心关注分≥55且标签不是“符合预期”时保留。 +- “符合预期”中按关注分最多保留前20。 +- 非核心整体以30只为目标上限,市场核心不受该上限影响。 + +### 10.6 一字板和涨跌幅制度 + +- 竞价价格封于当日涨停价时,单独进入“竞价一字”。 +- 一字板不参加普通异动评分和排序。 +- 新规ST股票涨跌幅限制按10%,不是旧制度的5%。 +- 其他股票按交易所、板块和上市状态计算当日有效涨跌停价。 + +### 10.7 页面布局与核对 + +- 数据集顺序:重点异动、我的自选、全部候选、竞价一字。 +- 二级预期筛选:全部、超预期、符合预期、低于预期;只作为筛选标签,不显示会误导的全池数量。 +- “竞价覆盖、重点异动、竞价一字、竞价成交额”摘要集成到数据集标签同一行最右侧,右侧不留多余间距。 +- 支持搜索和CSV导出。 +- 右侧展示题材承接、今日新线索、近10日竞价成交额和5日均值。 +- 删除占空间的隔夜消息反馈主模块;仅在未来数据可用后重新设计入口。 +- 当日竞价成交额摘要与柱状图必须使用同一时间、同一股票范围、同一去重口径,禁止一个显示169.33亿而图形显示另一口径。 + +## 11. 题材库、人气热榜与龙虎榜 + +### 11.1 题材库 + +- 左侧题材排行,右侧为题材基础行情和成分股。 +- 左侧题材排行宽度小于右侧,为成分股留出主要空间。 +- 固定题材日K模块取消。 +- 悬浮左侧题材名称时,显示与个股悬浮窗同规格的日K/分时预览。 +- 前三名强化名次和热度层级,但不使用夸张装饰。 +- 支持按题材名称或代码搜索。 +- 题材基础指标至少包括:成分股数、有行情数、上涨数、下跌数、换手率。 +- 成分股表:序号、代码、股票、涨跌幅、收盘价、成交额。 +- 切换题材时,右侧加载骨架必须随日间/夜间主题变化,不能闪白。 +- 无题材行情和无成分数据是两个不同空态,必须分别说明。 + +### 11.2 人气热榜 + +- 三个来源视图:双榜综合、同花顺、东方财富。 +- 顶部摘要:同花顺热度Top3、东方财富热度Top3、双榜共识。 +- 三个摘要模块之间没有白色衬底、连接阴影或视觉缝隙;模块自身保留既定填充色。 +- 综合排序中,同花顺排名贡献权重0.5,东方财富排名贡献权重0.25,双榜共识优先。 +- 双榜缺一时仍显示单榜真实结果,但不得伪装为双榜共识。 +- 当日榜单尚未生成时可显示最近有效榜单,但必须标注实际榜单日期。 +- 支持按代码、名称和概念搜索。 + +### 11.3 龙虎榜每日明细 + +- 视图:每日明细、游资档案。 +- 每日摘要、全部/净买入/净卖出/待归类筛选、搜索。 +- 活跃游资模块固定在其区域,不跟随操作明细内部滚动。 +- 仅“当日操作明细”内部滚动;游资介绍文字应有可读字号。 +- 点击游资后显示该游资当日操作明细。 +- 表格最左侧依次为序号、代码、股票,之后为买卖方向、金额、净额、上榜原因等实际字段。 +- 管理员可把待归类营业部设置为游资别名;同名席位合并统计。 + +龙虎榜空态必须区分: + +1. 当日没有股票上榜。 +2. 当日有上榜股票,但接口席位明细缺失。 +3. 当日有席位数据,但没有可识别游资,全部处于待归类。 +4. 数据接口请求失败。 + +不能在“有72/76只股票上榜”时只显示“暂无龙虎榜数据”。应显示实际股票数、席位数据覆盖情况、待归类数量,并提供查看前一交易日和重新检查。 + +### 11.4 游资档案 + +- 名录以Tushare收录席位和管理员归类为基础。 +- 页面采用左侧名录、右侧详情结构。 +- 详情显示游资名称、简介、关联营业部、近期可用统计和数据覆盖说明。 +- 不使用超长弹出框。 +- 当前重要性低于每日龙虎榜,但入口和数据结构保留。 + +## 12. 智能选股 + +### 12.1 权限与模式隔离 + +智能选股是会员功能,分为三个互相独立的模式: + +1. 阶段选股。 +2. 策略选股。 +3. 自定义选股(原“量化选股”)。 + +三个模式的选择状态、加载状态和候选结果分别保存。刷新页面、切换模式、切换阶段或切换策略后,不能让一个模式的结果覆盖另外两个模式,也不能让结果同时出现在三个界面。 + +### 12.2 阶段选股 + +- 每个交易日15:10后,行情和因子定格后由后台自动执行。 +- 用户不手动“执行选股”,不手动“同步因子数据”。 +- 取消“更换策略”和“编辑/自定义阶段策略”。 +- 当前情绪阶段只影响阶段选股,不影响策略选股。 +- 六阶段各有一套策略:冰点抗跌先手、修复先锋、主线发酵跟随、高潮核心去后排、分化承接回流、退潮防守观察。 +- 竞价强势确认作为独立的竞价阶段策略。 +- 页面显示四步:阶段识别、策略匹配、执行选股、结果与回测。 +- 四步状态必须来自真实任务状态;未执行、执行中、失败、已完成分别显示,不能永远是对号。 +- 当前阶段卡与匹配策略卡紧凑排列;情绪温度信息只显示一次,不能重复两套文案。 +- 无结果且数据完整时显示“暂无符合条件个股”;数据缺失时列出缺失字段。 + +### 12.3 策略选股 + +- 盘后由后台对每套内置精选策略独立运行。 +- 任何精选策略都不受当前情绪阶段门控。 +- 适用阶段只作为说明和风险提示,不决定是否执行。 +- 每套策略均显示适用环境、失效风险、数据状态、准入条件、评分权重、频率和风险等级。 +- 数据不完整时显示具体缺少的数据;不能将缺数据伪装为“暂无信号”。 +- 数据完整但无股票通过时显示“暂无符合条件个股”。 + +页面结构: + +- 左侧策略库:搜索、流派筛选、列表/图标排列。 +- 左侧小卡只显示标题、流派、等级/风险和信号状态,避免策略越多卡片越大。 +- 右侧策略详情:完整说明、适用环境、失效风险、条件和权重。 +- 下方候选结果:明确标注策略来源、选股日期和数据状态。 +- 点击策略卡本身即切换高亮与详情,不要求再次点击“条件”。 +- 条件详情如用弹窗,必须在视口中心。 + +### 12.4 自定义选股 + +自定义选股由用户手动执行,包含: + +- 因子与权重。 +- 过滤条件。 +- 上市天数下限。 +- 输出数量。 +- 最低综合分。 +- 剔除ST/退市开关。 +- 自然语言转换为受控公式。 +- 直接编辑、保存和删除个人公式。 +- 滚动回测。 + +交互要求: + +- 因子使用清晰分组,权重可用滑块或数值输入控制。 +- 权重总和必须校验;不等于100%时禁止执行并说明。 +- 过滤条件模块保持紧凑,不能占据整页主要面积。 +- 成交额、站上20日线等输入控件按内容自适应宽度,后方允许留白。 +- 执行按钮使用正常命令宽度,不能横跨整页形成巨长按钮。 +- 候选表必须在标准桌面宽度显示完整核心列;次要列可进入详情或局部横滚。 + +### 12.5 统一筛选与评分 + +所有阶段策略、精选策略和自定义策略按以下顺序执行: + +1. 确定选股日期和可用因子快照。 +2. 执行股票范围:上市状态、上市天数、是否排除ST/退市。 +3. 检查本策略所有过滤字段和评分字段。任一必需字段缺失,该股票不得进入候选,并计入数据缺失统计。 +4. 所有过滤条件同时成立的股票进入评分池。 +5. 对每个评分字段在当前合格股票横截面内计算百分位。 +6. “高优”因子值越大百分位越高;“低优”因子值越小百分位越高。 +7. 综合分 = 各因子百分位×权重之和 ÷ 权重总和。 +8. 低于策略最低综合分的股票剔除。 +9. 按综合分降序取策略规定的最大数量。 +10. 输出逐股综合分、主要贡献、风险提示、策略来源和数据日期。 + +若有历史回测: + +- 至少20个有效历史样本才可显示相对稳定的历史估计。 +- 历史样本≥20时,历史估计 = 回测胜率×65% + 当次综合分×35%。 +- 样本不足时明确标注“小样本”,不能用精确百分数营造可靠性。 + +### 12.6 自动执行、缓存与确定性 + +- 相同策略、相同选股日期、相同因子版本和相同策略版本必须得到相同结果。 +- 页面刷新不能重新随机执行,也不能清空已归档结果。 +- 数据同步完成后,后台任务以幂等键防止同日重复写入。 +- 结果记录策略版本、因子快照版本、实际数据日期、完成时间、数据覆盖率和失败原因。 +- LLM不参与候选计算。LLM仅可把自然语言转换为受控公式;最终仍由确定性筛选器执行。 + +### 12.7 策略持续跟踪 + +- 仅跟踪用户从候选结果中手动加入的股票。 +- 运行选股或后台自动生成候选,均不能自动加入跟踪。 +- 跟踪数据按账号隔离。 +- 基准价格为用户加入时记录的候选入选价。 +- 跟踪T+1开盘、T+1收盘、T+3收盘、T+5收盘、区间最大涨幅和最大回撤。 +- 支持手动刷新和移除。 +- T+1已有反馈和T+5全部完成时可生成站内提醒;同一批次同类提醒只能生成一次。 + +## 13. 内置策略业务定义 + +### 13.1 解释规则 + +本节定义36套可执行策略的当前正式口径。字段名称是产品级标准字段,不是特定代码实现。符号含义: + +- `between [a,b]`:包含上下边界。 +- `==1`:条件成立。 +- 高优:字段越大得分越高。 +- 低优:字段越小得分越高。 +- 最低分使用0至1的小数表示。 +- 除表中说明外,所有策略均排除名称含ST或退市风险标记的股票。 + +### 13.2 六阶段与竞价策略 + +| 策略 | 适用阶段/频率 | 股票范围 | 全部准入条件 | 评分权重 | 最低分/上限 | +|---|---|---|---|---|---| +| 冰点抗跌先手 | 冰点;每日 | 上市≥120日 | 当日涨幅[-3,7]%;5日涨幅≥-5%;成交额≥1亿;10日波动率≤7 | 相对强度30%高优;板块强度25%高优;5日量比20%高优;10日波动15%低优;成交额10%高优 | 0.58/12 | +| 修复先锋 | 修复;每日 | 上市≥120日 | 当日涨幅[1,9.7]%;5日涨幅>0;站上20日线;5日量比≥1.05 | 板块强度28%;相对强度24%;5日量比18%;主力净流入16%;成交额14%,均高优 | 0.54/15 | +| 主线发酵跟随 | 发酵;每日 | 上市≥120日 | 当日涨幅[0,9.8]%;5日涨幅≥3%;站上20日线;成交额≥2亿 | 板块涨停数25%;板块强度24%;10日涨幅20%;成交额16%;大单净流入15%,均高优 | 0.55/15 | +| 高潮核心去后排 | 高潮;每日 | 上市≥120日 | 当日涨幅[-2,7]%;10日涨幅≥5%;站上20日线;成交额≥5亿 | 成交额28%;板块强度22%;相对强度20%;波动率15%低优;连板高度15%高优 | 0.62/10 | +| 分化承接回流 | 分化;每日 | 上市≥120日 | 当日涨幅[-3,7]%;5日涨幅>0;站上20日线;5日量比[0.7,3.5] | 相对强度28%;板块强度24%;主力净流入20%;波动率16%低优;成交额12%高优 | 0.57/12 | +| 退潮防守观察 | 退潮;每日 | 上市≥180日 | 当日涨幅[-2,4]%;5日涨幅≥-2%;站上20日线;10日波动率≤4.5;成交额≥2亿 | 波动率30%低优;相对强度25%;成交额20%;板块强度15%;主力净流入10%高优 | 0.68/8 | +| 竞价强势确认 | 修复/发酵/分化;9:25后 | 上市≥120日 | 竞价涨幅[1,7]%;竞价成交额≥300万;竞价量比≥0.8;日成交额≥1亿 | 竞价额26%;竞价量比22%;竞价涨幅18%;板块强度18%;相对强度16%,均高优 | 0.56/15 | + +### 13.3 精选策略一览 + +| # | 策略 | 流派 | 频率 | 上市天数 | 最低分/上限 | +|---:|---|---|---|---:|---| +| 1 | 连续分红质量 | 红利价值 | 月度 | 1095 | 0.52/20 | +| 2 | ROIC质量低波 | 质量价值 | 月度 | 730 | 0.54/20 | +| 3 | 低估值现金流白马 | 现金流价值 | 月度 | 730 | 0.53/20 | +| 4 | 高增长合理估值 | 成长质量 | 月度 | 365 | 0.55/20 | +| 5 | 行业宽度主线 | 行业轮动 | 每周 | 180 | 0.56/20 | +| 6 | 首板低开 | 短线竞价 | 每日9:25 | 250 | 0.50/12 | +| 7 | 小碎步临界突破 | 形态突破 | 每日 | 250 | 0.54/15 | +| 8 | 连板龙头 | 连板接力 | 每日 | 120 | 0.50/10 | +| 9 | 微盘三正 | 小盘质量 | 每周 | 365 | 0.52/20 | +| 10 | 首板高开弱转强 | 短线竞价 | 每日9:25 | 120 | 0.52/15 | +| 11 | 中期动量·强者恒强 | 动量反转 | 每周 | 180 | 0.50/25 | +| 12 | 强者回调 | 动量反转 | 每日 | 180 | 0.48/20 | +| 13 | 超跌反转 | 动量反转 | 每日 | 180 | 0.50/10 | +| 14 | 相对强度新高 | 动量反转 | 每周 | 250 | 0.52/20 | +| 15 | 均线多头排列 | 趋势追踪 | 每周 | 365 | 0.50/30 | +| 16 | 唐奇安通道突破 | 趋势追踪 | 每日 | 180 | 0.52/15 | +| 17 | 周线趋势·日线买点 | 趋势追踪 | 每周 | 365 | 0.52/20 | +| 18 | 空间板 | 连板接力 | 每日 | 120 | 0.45/5 | +| 19 | 龙头首阴 | 低吸反核 | 每日 | 120 | 0.48/5 | +| 20 | 断板反包 | 低吸反核 | 每日 | 120 | 0.46/5 | +| 21 | 核按钮反核 | 低吸反核 | 每日 | 120 | 0.48/5 | +| 22 | 景气-趋势-拥挤三维行业打分 | 行业轮动 | 双周 | 180 | 0.50/12 | +| 23 | 大小盘/成长价值风格切换(元策略) | 元策略 | 每周 | 250 | 0.52/20 | +| 24 | 业绩超预期漂移(SUE/PEAD) | 业绩事件 | 事件驱动 | 180 | 0.50/15 | +| 25 | 多因子综合打分(IC动态加权) | 多因子 | 每周 | 250 | 0.55/30 | +| 26 | 热度突增潜伏(另类数据) | 热度观察 | 每日 | 120 | 0.48/10 | +| 27 | 机构榜溢价 | 资金席位 | 每日 | 180 | 0.48/10 | +| 28 | 行业动量轮动 | 行业轮动 | 双周 | 180 | 0.48/12 | +| 29 | 主力资金行业流入 | 行业轮动 | 每周 | 180 | 0.48/15 | + +### 13.4 精选策略准入与评分 + +| 策略 | 全部准入条件 | 评分字段与权重 | +|---|---|---| +| 连续分红质量 | 持续分红年数≥4;股息率TTM≥2%;ROE≥6%;PB在0.1至4 | 股息率30%高优;ROE 24%高优;经营现金流质量18%高优;10日波动16%低优;总市值12%高优 | +| ROIC质量低波 | ROIC≥6%;毛利率≥15%;PE TTM在1至45;成交额≥1亿 | ROIC 28%高优;毛利率22%高优;PS TTM 18%低优;10日波动18%低优;总市值14%高优 | +| 低估值现金流白马 | PB在0.1至1.8;ROA≥3%;经营现金流质量>0;净利同比≥-15%;总市值≥100亿 | ROA 26%高优;现金流质量24%高优;PB 20%低优;总市值16%高优;波动率14%低优 | +| 高增长合理估值 | PE TTM在1至35;营收同比≥10%;净利同比≥15%;ROE≥5%;成交额≥1亿 | 净利增速27%;营收增速23%;ROE 20%高优;PE 16%低优;相对强度14%高优 | +| 行业宽度主线 | 行业站上20日线宽度≥55;板块强度≥55;个股站上20日线;成交额≥2亿 | 行业宽度28%;板块强度24%;相对强度20%;板块涨停数16%;成交额12%,均高优 | +| 首板低开 | 昨日首板;竞价涨幅[-4.5,-2.5]%;60日位置≤0.55;昨日成交额≥1亿 | 竞价额28%高优;昨日成交额24%高优;60日位置20%低优;板块强度16%高优;竞价量比12%高优 | +| 小碎步临界突破 | 近30日无涨停;近80日的较早区间曾涨停;15日最大绝对涨跌≤3%;收盘/15日高点≥0.98;收盘/60日高点≥0.90 | 距15日高点26%高优;5日量比22%高优;相对强度20%高优;15日最大波动18%低优;流通市值14%低优 | +| 连板龙头 | 昨日连板高度≥2;昨日成交额≥1亿 | 昨日连板34%;板块涨停数24%;昨日成交额18%;换手率14%;板块强度10%,均高优 | +| 微盘三正 | PB>0;ROE>0;经营现金流质量>0;流通市值5至100亿;成交额≥0.5亿 | 流通市值32%低优;ROE 24%高优;现金流质量20%高优;换手14%高优;相对强度10%高优 | +| 首板高开弱转强 | 昨日涨停或触板;竞价涨幅[1,6]%;竞价量比≥0.8;昨日成交额3至25亿 | 竞价额28%;竞价量比24%;竞价涨幅18%;板块强度17%;相对强度13%,均高优 | +| 中期动量·强者恒强 | 价格3至100元;中期动量排名前10%;当日不是涨停 | 中期动量55%;相对强度25%;成交额20%,均高优 | +| 强者回调 | 中期动量排名前30%;5日涨幅排名后20%;站上20日线;RSI(6)≤30;近20日无跌停 | 中期动量42%高优;5日涨幅33%低优;成交额25%高优 | +| 超跌反转 | 5日涨幅排名后5%;5日累计换手≥30%;60日涨幅≥-40%;无财务风险;当日非跌停 | 5日涨幅45%低优;5日换手30%高优;成交额25%高优 | +| 相对强度新高 | 成交额≥1亿;相对沪深300的RS线创120日新高;60日超额收益≥10%;60日线斜率>0 | 60日超额收益50%;60日线斜率25%;成交额25%,均高优 | +| 均线多头排列 | MA5>MA10>MA20>MA60;20日线5日斜率>0;距250日高点回撤≤20% | 20日线斜率38%高优;高点回撤32%低优;相对强度30%高优 | +| 唐奇安通道突破 | 收盘突破此前20日高点≥2%;5日量比≥1.8;此前20日振幅≤35% | 量比40%高优;突破幅度35%高优;振幅25%低优 | +| 周线趋势·日线买点 | 周线MACD DIF和DEA均>0;日线金叉或回踩20日线收阳;本周成交额≥此前4周均值 | 20日线斜率35%;相对强度35%;成交额30%,均高优 | +| 空间板 | 当日为市场最高板;相对前一日为新晋空间板;所属板块涨停≥3 | 连板高度50%;板块涨停数30%;成交额20%,均高优 | +| 龙头首阴 | 近10日最高连板≥3;当日为断板后的首次阴线;跌幅≥-7%;成交量/前日≤0.8 | 历史连板45%高优;量比前日30%低优;板块强度25%高优 | +| 断板反包 | 已识别断板反包;断板后1至3日;收复断板日高点;成交量/断板日≥1 | 断板后天数35%低优;相对断板日量能35%高优;板块强度30%高优 | +| 核按钮反核 | 近5日至少涨停1次;盘中最大跌幅≤-7%;收盘涨幅≥-3%;下影/实体≥2;成交量/前日≤1.1 | 下影实体比42%高优;盘中最低涨幅30%低优;板块强度28%高优 | +| 景气-趋势-拥挤三维行业打分 | 行业综合分≥0.58;行业拥挤排名≤0.90;行业内个股动量排名≥0.50;成交额≥1亿 | 行业综合分55%高优;行业内动量25%高优;拥挤排名20%低优 | +| 大小盘/成长价值风格切换 | 当前风格匹配度≥0.65;成交额≥1亿 | 风格匹配度70%;相对强度30%,均高优 | +| 业绩超预期漂移 | 超预期幅度≥10%;营收同比>0;业绩事件质量通过;公告后1至5个交易日 | 超预期幅度60%;相对强度25%;成交额15%,均高优 | +| 多因子综合打分 | 动态多因子综合分≥0.65;无财务风险;成交额≥1亿 | 多因子分75%;相对强度15%;成交额10%,均高优 | +| 热度突增潜伏 | 人气综合分≥15;10日涨幅≤5%;近5日无涨停;成交额≥0.5亿 | 人气分50%;排名跃升25%;双榜共识10%;成交额15%,均高优 | +| 机构榜溢价 | 机构净买入≥3000万元;机构席位≥1;60日涨幅≤30%;昨日连板≤2 | 机构净买入55%;席位数15%;60日位置20%低优;成交额10%高优 | +| 行业动量轮动 | 行业20日动量排名前10%;行业内个股动量前20%;成交额≥1亿 | 行业20日涨幅38%;个股20日涨幅32%;总市值18%;成交额12%,均高优 | +| 主力资金行业流入 | 行业资金流排名前15%;行业5日净流入>0;行业5日涨幅≤8%;个股5日净流入/流通市值>0;成交额≥1亿 | 资金流占流通市值42%高优;行业5日净流入30%高优;行业5日涨幅16%低优;成交额12%高优 | + +### 13.5 适用环境与失效风险 + +每个精选策略详情必须展示以下语义,不得只显示“适用某阶段”: + +| 策略族 | 适用环境 | 主要失效风险 | +|---|---|---| +| 红利、质量、现金流价值 | 防守市、低利率、估值修复、重视回撤的环境 | 风险偏好快速上升时弹性落后;低估值可能是价值陷阱 | +| 成长质量 | 业绩驱动、成长占优且趋势确认 | 增长预期下修或估值收缩 | +| 行业宽度/动量/资金流 | 主线清晰、行业趋势可持续、资金先于价格 | 快速轮动、资金流口径噪声、财务披露滞后 | +| 竞价与连板 | 情绪修复、主线发酵、承接明确 | 退潮、高位补跌、竞价强势转盘中兑现 | +| 趋势与突破 | 趋势延续、整理末端、放量突破 | 无趋势震荡、假突破、均线滞后 | +| 低吸反核 | 核心辨识度仍在、恐慌释放后出现承接 | 题材退潮、深水拉回仅为日内脉冲 | +| 风格切换 | 大小盘或成长价值风格持续分化 | 风格快速往返导致20日信号滞后 | +| 业绩事件 | 披露窗口、上修且价格未充分兑现 | 披露口径不同、公告后高开兑现 | +| 人气与机构席位 | 热度早期扩散、低位机构净买入 | 讨论噪声、高位兑现、席位净买不等于锁仓 | +| 多因子 | 多个因子表现具有延续性 | 单一极端主题和风格突变导致历史有效性失效 | + +每套策略的具体文案基线: + +| 策略 | 适用环境 | 失效风险 | +|---|---|---| +| 连续分红质量 | 防守市、低利率环境与中长期配置窗口 | 风险偏好快速上升时,稳健资产的价格弹性通常落后 | +| ROIC质量低波 | 震荡偏弱、重视盈利质量与回撤控制的市场 | 主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向 | +| 低估值现金流白马 | 估值修复、价值回归及防守配置阶段 | 低估值可能来自基本面持续走弱,需警惕价值陷阱 | +| 高增长合理估值 | 业绩驱动、成长风格占优且趋势获得确认的阶段 | 增长预期下修或估值快速收缩时,回撤可能明显放大 | +| 行业宽度主线 | 主线清晰、行业内部多数个股同步走强的行情 | 板块快速轮动时,宽度信号容易在确认后迅速衰减 | +| 首板低开 | 情绪修复期的分歧转一致与首板次日承接 | 退潮加速或低开缺少量能承接时,弱势可能继续扩大 | +| 小碎步临界突破 | 趋势蓄势、波动收敛后临近突破的结构市 | 无量突破或指数剧烈震荡时,容易形成冲高回落 | +| 连板龙头 | 高度拓展、题材梯队完整且接力情绪活跃的阶段 | 亏钱效应扩散或高位股集中退潮时,接力风险很高 | +| 微盘三正 | 小盘风格活跃、流动性宽松且风险偏好较高的行情 | 风格切向大盘或微盘流动性收缩时,组合波动会显著上升 | +| 首板高开弱转强 | 竞价承接明确、短线情绪修复或主线发酵阶段 | 高开缺乏板块共振时,竞价强势可能转为盘中兑现 | +| 中期动量·强者恒强 | 趋势延续、主升段及强弱分化清晰的行情 | 无趋势震荡或快速轮动中,动量信号容易反复失效 | +| 强者回调 | 主升趋势未破、强势股完成良性回踩的窗口 | 趋势已反转时,回调信号可能演变为下跌中继 | +| 超跌反转 | 急跌后恐慌释放充分、市场进入修复预期的阶段 | 单边下跌初段容易过早介入,超跌不等于止跌 | +| 相对强度新高 | 指数偏弱但结构性主线明确,或机构抱团强化的行情 | 基准快速补涨或强势方向瓦解时,相对优势可能迅速消失 | +| 均线多头排列 | 中期趋势向上、回撤有序的趋势市与主升段 | 高位趋势末端或宽幅震荡中,均线信号通常反应滞后 | +| 唐奇安通道突破 | 整理末端、放量突破并启动新趋势的行情 | 无量突破和宽幅震荡环境中,假突破出现概率较高 | +| 周线趋势·日线买点 | 中期趋势稳定、日线回踩或再启动的多周期共振阶段 | 周线拐点尚未确认时,日线信号可能只是短暂反抽 | +| 空间板 | 市场高度持续拓展、板块梯队完整的强接力环境 | 高度压缩或亏钱效应扩散时,最高板的补跌风险极高 | +| 龙头首阴 | 主线龙头仍有辨识度、首次分歧后存在回流预期的阶段 | 题材退潮或龙头地位被替代后,首阴可能只是下跌起点 | +| 断板反包 | 强势题材分歧后快速修复、核心股重新获得资金承接时 | 板块强度不足或反包缩量时,形态持续性通常较弱 | +| 核按钮反核 | 恐慌释放后出现明确承接、短线情绪转暖的窗口 | 系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高 | +| 景气-趋势-拥挤三维行业打分 | 行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市 | 财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢 | +| 大小盘/成长价值风格切换(元策略) | 大小盘或成长价值风格形成持续相对强弱的阶段 | 风格快速往返切换时,近20日相对表现容易产生滞后信号 | +| 业绩超预期漂移(SUE/PEAD) | 业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时 | 预告与快报口径可能不同,公告后高开兑现会削弱漂移效应 | +| 多因子综合打分(IC动态加权) | 因子表现具备一定延续性、市场并非由单一极端主题主导时 | 近期有效因子可能快速失效,动态权重不能消除风格突变风险 | +| 热度突增潜伏(另类数据) | 人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期 | 榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高 | +| 机构榜溢价 | 机构专用席位在相对低位形成明确净买入、且成交承载正常时 | 高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓 | +| 行业动量轮动 | 主线相对清晰、行业趋势能够延续两周以上的结构市 | 行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减 | +| 主力资金行业流入 | 板块轮动初期、资金先于价格形成连续净流入的阶段 | 资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势 | + +### 13.6 多因子策略的当前标准与升级目标 + +当前实现基线: + +- 五类因子:价值、成长、质量、动量、交易情绪。 +- 各类内部先转横截面百分位,再取可用因子的均值。 +- 价值:PE低优、PB低优、股息率高优。 +- 成长:营收同比、净利同比高优。 +- 质量:ROE、ROIC、毛利率高优。 +- 动量:中期动量、相对强度高优。 +- 情绪:换手率、5日量比高优。 +- 当前动态权重以同一截面的20日收益排名相关性作为参考,负相关和过低权重被下限0.05截断,再归一化。 + +待设计或待升级,尚不能宣称已实现: + +- 行业内去极值。 +- z-score标准化。 +- 行业和市值中性化。 +- 使用过去12个月每个因子与下一期收益的Rank IC均值定权。 +- 每季度重算权重。 +- 输出全市场综合得分前5%。 + +在上述升级通过独立回测、未来函数审查和人工验收之前,界面不应把策略命名包装成“完整IC动态加权”。可显示“动态多因子(基础版)”并在详情说明当前口径。 + +## 14. 问师 + +### 14.1 页面与权限 + +- 问师为会员功能。 +- 左侧为思维模型库,右侧为流式对话。 +- 模型筛选:全部、A级、B级、C级;支持搜索姓名、模式或标签。 +- 等级只表示蒸馏素材证据和结构质量,不代表人物能力、收益率或推荐等级。 +- 前端只显示A/B/C,不显示普通用户无法理解的`6/6`、`5/6`等工程评分。 +- 支持置顶、自定义排序、拖拽和上下箭头微调。 +- 底部输入区紧凑,长回答行距适中,段落之间不得出现过大空白。 + +### 14.2 思维模型注册和私密性 + +- 一个公开思维模型由独立Skill资料、显示元数据和证据等级组成。 +- 有效Skill被放入公共模型库后自动出现在前端,不应要求每增加一个模型就修改页面代码。 +- 公共模型所有用户可见。 +- “小白”是管理员根据个人复盘蒸馏的私有模型,只保存在私有存储,不进入Git、镜像或公开目录,只对管理员可见。 +- 私有ID与公开ID冲突时只显示一份,优先私有版本。 +- 张德涛(无为校长)归类为趋势/资金流派,水皮归类为宏观大局观,不归类为游资;两者证据等级为A级。 + +### 14.3 对话隔离与流式规则 + +- 对话按账号、思维模型、交易日期三者隔离保存。 +- 清空只清当前账号、当前模型、当前日期的对话。 +- 每次调用最多带入最近10条历史消息,并设置总字符上限,避免无限增长。 +- 支持快捷问题、停止生成和失败后重试。 +- 回答必须是真流式:每个文本片段只追加一次;流结束后不能再次追加完整答案,避免双份结果。 +- 主模型在尚未输出任何内容前失败时可切换辅助模型。 +- 一旦已经向用户输出文本,中途失败只能提示连接中断,不能切换模型后重复整个回答。 +- 回答必须声明这是基于公开资料蒸馏的思维模型,不是真人本人,不构成投资建议。 + +### 14.4 按模型类型提供数据 + +所有模型不再共用完全相同的市场上下文。系统根据Skill的关注点选择数据: + +| 模型类型 | 默认上下文 | +|---|---| +| 情绪流 | 情绪温度、阶段、涨跌停、昨日反馈、天梯、板块轮动 | +| 首板流 | 首板环境、封板率、炸板、热点板块 | +| 龙头流 | 连板梯队、多板龙头、热点、轮动、人气榜核心 | +| 趋势流 | 宽基指数动量、资金面、板块轮动、市场宽度 | +| 低吸流 | 昨日涨停反馈、炸板、热点与承接 | +| 宏观流 | 上证、深证、创业板、上证50、沪深300、中证500、中证1000、中证2000及核心ETF | + +附加规则: + +- 问题涉及龙虎榜、机构、游资或席位时,追加相关龙虎榜数据。 +- 问题中识别到股票代码或名称时,最多追加2个标的的详情、资金流和近20日日K。 +- 当前没有稳定政策、隔夜资讯、汇率、利率和商品宏观序列时,必须明确数据缺失,不得编造。 +- Skill中的历史案例只能作为方法论,不能当作当前行情。 + +## 15. 问天 + +### 15.1 共用规则 + +问天为会员功能,包含观势、观气、观心: + +- 保留星空背景、过场、载入、起卦和智能解读加载动画。 +- 页面外壳遵循全站200px侧栏、46px顶栏和内容边距;内容区允许独立星空气质。 +- 日间使用与全站一致的宣纸浅色基底;夜间与全站夜色一致并增加克制星空。 +- 1080P下必须可完整滚动,不能隐藏页面滚动条导致下方不可达。 +- 历史记录按账号隔离。 +- 智能解读使用居中模态弹窗,包含“本次解读”和“历史记录”。 +- 解读加载动画循环直到结果出现;动画、标题和底部文字不得重叠。 +- 观势/解卦使用六爻推演动画;观气使用五运六气主题动画。 +- LLM只解释程序已经算出的结构,不能起卦、改卦、修改干支、行情或五运六气。 +- 所有结果明确属于传统文化和娱乐化观察,不构成预测或投资建议。 + +### 15.2 观势输入与状态 + +- 未输入时只显示“请输入股票代码或股票名称”。 +- 未输入时不显示“暂不成卦”、缺失原因或六爻校验。 +- 输入代码或名称后点击载入,解析唯一股票;重名时让用户选择。 +- 当前标的不使用背景框,左对齐显示“当前标的:股票名称 申万二级·行业名称”;名称和行业加粗并使用更明显颜色。 +- 只有输入标的但数据不足时才显示“暂不成卦”和详细原因。 + +### 15.3 观势三才六爻 + +六爻自下而上: + +| 爻位 | 含义 | 数据层 | +|---|---|---| +| 初爻 | 个股内核 | 个股成交活跃与承接 | +| 二爻 | 个股外显 | 个股涨跌、连板和事件状态 | +| 三爻 | 行业内核 | 申万二级行业成分宽度与换手/等权表现 | +| 四爻 | 行业外显 | 申万二级行业涨跌及领涨股 | +| 五爻 | 市场内核 | 情绪、封板率、成交、宽度、涨跌停平衡 | +| 上爻 | 指数外显 | 上证、深证、创业板平均涨跌 | + +所有标准化分值截断到[-1,1]。分值转爻: + +- 分值≥0.72:老阳,数9,动爻。 +- 0≤分值<0.72:少阳,数7。 +- 分值≤-0.72:老阴,数6,动爻。 +- -0.72<分值<0:少阴,数8。 + +势值 = 六爻标准化分值平均值×100。显示本卦图形、箭头、之卦图形、势值和三才状态;本卦后不得出现多余圆圈。 + +### 15.4 观势六爻公式 + +初爻,个股内核: + +- 盘中且活跃度数据有效:`35%×成交额全市场分位的[-1,1]映射 + 35%×相对市场换手 + 30%×同进度量能`。 +- 相对市场换手 = 截断`(个股换手/市场换手 - 1)/1.5`。 +- 同进度量能 = 截断`(当前量能/历史同进度量能 - 1)/1.5`。 +- 收盘/历史:先计算`32%×成交额分位 + 22%×换手率/20 + 25%×封单额/1.5亿 + 21%×(1-开板次数/6)`,各项截断到[0,1],再映射为`原值×2-1`。 + +二爻,个股外显: + +- `70%×(涨跌幅/10) + 20%×(连板高度/5) + 状态修正`,截断到[-1,1]。 +- 状态修正:跌停-0.70,炸板-0.25,其他+0.15。 + +三爻,行业内核: + +- 盘中:行业宽度60% + 相对市场换手40%。 +- 行业宽度 =(上涨成分数-下跌成分数)÷(上涨+下跌)。 +- 相对市场换手分 = 截断`(行业换手/市场换手-1)/1.5`。 +- 收盘/历史:行业宽度60% + 成分股等权涨跌标准化35% + 领涨股涨跌标准化5%。 +- 成分股等权涨跌和行业涨跌均按5%映射到[-1,1];领涨股按10%映射。 + +四爻,行业外显: + +- `90%×申万二级行业涨跌/5 + 10%×领涨股涨跌/10`,截断到[-1,1]。 + +五爻,市场内核: + +- 情绪标准化 = 情绪温度/100×2-1。 +- 封板标准化 = 封板率/100×2-1。 +- 成交变化 = 截断`(当日成交额/近期平均成交额-1)×3`。 +- 市场宽度分 = 截断`(上涨占比-0.5)×2`。 +- 涨跌停平衡 =(涨停数-跌停数)÷(涨停数+跌停数)。 +- 五爻 = 情绪35% + 封板20% + 成交变化20% + 市场宽度15% + 涨跌停平衡10%。 + +上爻,指数外显: + +- 三大指数数据完整时:上证指数、深证成指、创业板指涨跌幅的算术平均,按3%映射到[-1,1]。 +- 三大指数不完整时不得静默用市场宽度替代并仍声称指数外显已通过;应触发安全门失败。历史版本的替代公式只作为故障诊断,不是正式标准。 + +### 15.5 观势安全门和手动补录 + +只有六爻全部通过安全门才成卦。成卦下方可展开“六爻数据校验”,每爻显示:绿色通过、红色缺失/失败、用户补录。 + +安全门: + +- 个股交易日期必须匹配目标日期。 +- 盘中个股必须是真实实时快照;收盘和历史必须是正式日线。 +- 行业必须是申万二级,行业日期必须匹配。 +- 盘中行业需要申万实时行业涨跌、成分覆盖、相对市场换手。 +- 历史行业不得混入实时数据。 +- 收盘实时行业必须是15:00最终快照。 +- 小样本行业不能机械要求固定数量。成员少但全体有有效行情时允许通过;覆盖率必须达到安全阈值并显示实际`有效数/成员数`。 +- 指数必须有上证、深证、创业板三条,日期一致且完整。 +- 历史指数必须使用正式日线。 + +手动补录规则: + +- 用户补录客观量化数据,不允许直接选择阴/阳或爻数。 +- 可补录字段包括行业名称、行业涨跌、上涨/下跌成分数、成员总数、成分覆盖率、行业等权涨跌、相对换手、领涨股及涨跌,以及失败爻所需的其他客观值。 +- 补录只替换失败字段;通过的数据不被覆盖。 +- 补录后仍使用同一公式、安全范围和安全门重新计算。 +- 结果明确标记“用户补录”,支持“恢复自动数据”。 + +### 15.6 观气 + +载入时显示基础信息: + +- 观测日期、农历、干支、节气。 +- 当日复合断语,使用“湿热交蒸·燥中夹滞”这类复合气候表达,不能只写“土气偏显”。 +- 三层气机。 +- 个人合参;不显示原始出生信息,且模块可无背景以保持轻量。 +- 五行对应行业位于基础模块下方,等宽、默认折叠;折叠时页面无需滚动,展开后允许全页滚动。 + +确定性权重基线: + +- 中运30。 +- 司天/在泉:岁半前15/5,岁半后5/15。 +- 主气20。 +- 客气25。 +- 日干2.5。 +- 日支2.5。 + +主页面不展示权重条。五行对应行业是传统取象,不是行情旁证;显示字体必须可读。 + +解运结果包含:三层气机、复合断语、情绪与判断偏差、操作惯性、个人影响、今日生克断语和制衡动作。同一账号同一天成功解运一次后保存;再次点击直接显示“已解运”和已保存结果,不重复调用LLM。旧版被截断的结果只允许重新生成一次。 + +### 15.7 观心 + +观心有五阶段:静心、呼吸、起卦、察念、解卦。 + +- 用户不输入问题,只在心中默念。 +- 呼吸开始前准备1秒。 +- 每息:吸3秒、顿2秒、呼4秒,共9秒;5轮,总45秒。 +- 呼吸阶段只显示“吸”“顿”“呼”,不显示轮数或倒计时。 +- 呼气时光圈向内收;吸气向外扩;保留波纹沉浸动效。 +- 保留一炷香进度和真实随时间变化的动效。 +- “静心完成,开始起卦”按钮位于阶段内容下方,不与动效重叠。 + +起卦: + +- 三枚铜钱具有有设计感的字面和背面,不使用单一贴图或黑色瑕疵圆点。 +- 支持按住摇爻或明确的投掷操作。 +- 每次投掷只产生一爻,自初爻到上爻依次累积,共六次。 +- 未投掷的爻只显示“初爻/二爻…上爻”和“未得”,不得预先画出阴爻。 +- 第一次投掷时绝不能把六爻全部生成。 +- 六次完成后显示本卦、之卦和卦辞;不再显示六个单独爻解释卡。 +- 用户先记录或确认“第一念”,之后才允许解卦。 +- 支持返回、重新观心、历史记录和静音。 +- 夜间模式中提示文字、历史记录、静音和重新观心必须具有足够对比度。 +- 用户启用减少动态效果时,提供静态进度和文本反馈,但完整流程仍可完成。 + +## 16. 我的复盘、提醒与复盘助手 + +### 16.1 自选追踪 + +- 支持输入股票代码或名称搜索并添加。 +- 每账号独立。 +- 显示:标记、代码、股票、板块、今日涨幅、5日涨幅、竞价关注分、跟踪备注、操作。 +- 支持编辑备注、移除、从个股详情加入/移除。 +- 无行情的字段留空,不用错误的0代替。 +- 表格列宽按内容分配,股票、代码和操作不能挤压数值列。 + +### 16.2 每日复盘 + +- 日期默认跟随顶栏,也可在模块中选择。 +- 三个独立输入框:今日盘面一句话;今日做对了什么/做错了什么;明日策略。 +- 三个输入框不能合并成一个文本区。 +- 同日期再次保存更新原记录,不产生重复日期。 +- 最近复盘只显示每日复盘,不混入个股笔记。 +- 历史记录可展开和收起;超出视口时可滚动。 + +### 16.3 个股笔记 + +- 只在对应股票详情查看。 +- 包含复盘内容和明日计划。 +- 按用户隔离,可保存、更新和删除。 +- 个股笔记不自动进入“最近复盘”。 + +### 16.4 交易日志 + +- 主页面显示“交易日志”按钮,点击后打开录入弹窗。 +- 保存后记录留在当前页面;日志区域与每日复盘底端对齐。 +- 无记录时保留合理空白;记录超过区域时仅日志内部滚动。 +- 字段:交易日、代码、名称、动作、价格、数量、仓位、盈亏金额、盈亏百分比、情绪、标签、交易逻辑、执行复核。 +- 动作:买入、卖出、加仓、减仓、观察。 +- 情绪:平静、笃定、犹豫、焦虑、冲动。 +- 支持编辑和删除。 +- 摘要:总记录、已实现记录数、胜率、累计盈亏、平均仓位。 +- 胜率只统计已填写盈亏金额或盈亏百分比的记录;盈亏>0为胜,等于0不计胜;未实现记录不进胜率。 +- 累计盈亏只加总已填写的盈亏金额;平均仓位只对有效仓位取算术平均。 + +### 16.5 提醒中心 + +- 站内提醒按账号隔离。 +- 支持全部/未读筛选、全部标为已读。 +- 手动提醒字段:标题(必填,最多80字)、提醒日期、股票代码(可选,最多12字)、内容(可选,最多500字)。 +- 支持单条标为已读和删除。 +- 到期前提醒显示“未到期”;只有到期提醒计入当前未读角标。 +- 策略跟踪有T+1反馈和T+5全部完成时自动生成提醒,使用批次幂等键去重。 + +### 16.6 复盘助手 + +- 非会员显示与会员相同界面,顶部提示“复盘助手仅对会员开放”,下方内容灰化不可用。 +- 读取共享市场统计,以及当前账号的策略跟踪、提醒、每日复盘、自选和交易日志。 +- 流式回答并按账号保存历史。 +- 快捷问题:市场位置、市场主线、交易复盘、明日清单。 +- 不修改用户数据,不执行交易。 +- 回答区分“市场事实”“用户记录”“推断”,只给条件化计划。 +- 默认回答不超过800个中文字符,除非用户明确要求展开。 + +## 17. 选股因子数据字典 + +### 17.1 通用约定 + +- 百分比字段使用百分数值,例如5%保存为5,而不是0.05;明确标为0至1排名/比例的字段除外。 +- 排名字段统一为0至1,1表示横截面最靠前。 +- 布尔信号使用成立/不成立,不得把缺失值当作不成立。 +- “相对强度”必须在同一交易日、同一股票范围内计算。 +- 财务字段按最近一个在选股日期之前已经公告的报告期取值,禁止未来函数。 +- 股票历史不足某因子窗口时,该因子为空,不以0代替。 + +### 17.2 行情动量 + +| 标准字段 | 中文含义 | 口径 | +|---|---|---| +| close | 收盘价 | 目标日收盘或有效实时价,元 | +| pct_chg | 当日涨幅 | 相对正确前收盘,% | +| return_5d | 5日涨幅 | 当前收盘相对5个交易日前收盘,% | +| return_10d | 10日涨幅 | 当前收盘相对10个交易日前收盘,% | +| return_20d | 20日涨幅 | 当前收盘相对20个交易日前收盘,% | +| return_60d | 60日涨幅 | 当前收盘相对60个交易日前收盘,% | +| return_5d_rank | 5日涨幅排名 | 全市场横截面0至1 | +| momentum_60_5 | 中期动量 | `第5日前收盘/第60日前收盘-1`,% | +| momentum_60_5_rank | 中期动量排名 | 全市场横截面0至1 | +| above_ma20 | 站上20日线 | 收盘价>MA20 | +| rsi_6 | RSI(6) | 标准6日RSI,0至100 | +| ma60_slope | 60日线斜率 | 当前MA60相对5日前MA60变化,% | +| ma20_slope_5d | 20日线5日斜率 | 当前MA20相对5日前MA20变化,% | +| ma_bull_alignment | 均线多头排列 | MA5>MA10>MA20>MA60 | +| drawdown_from_high_250 | 距250日高点回撤 | `1-收盘/250日最高价`,% | +| donchian_breakout_pct | 唐奇安突破幅度 | 收盘相对前20日最高价的超越幅度,% | +| range_20d | 20日振幅 | 前20日最高/最低-1,% | +| rs_high_120 | RS线120日新高 | 股票价格/沪深300价格之比是否创120日新高 | +| excess_return_60d | 60日超额收益 | 股票60日收益-沪深300 60日收益,百分点 | +| weekly_trend_signal | 周线趋势信号 | 周线MACD的DIF>0且DEA>0 | +| daily_buy_trigger | 日线买点 | 日线MACD金叉,或回踩MA20后收阳 | +| weekly_amount_trend | 周成交趋势 | 本周成交额≥此前4周平均 | +| relative_strength | 相对强度 | 当前股票在全市场/基准下的标准化强弱,0至100或统一横截面分 | +| relative_position_60 | 60日相对位置 | `(收盘-60日最低)/(60日最高-60日最低)`,0至1 | +| max_abs_change_15d | 15日最大波动 | 近15日单日涨跌幅绝对值最大值,% | +| close_to_high_15d | 距15日高点 | 收盘/15日最高价,0至1 | +| close_to_high_60d | 距60日高点 | 收盘/60日最高价,0至1 | + +### 17.3 量价与资金 + +| 标准字段 | 中文含义 | 口径/单位 | +|---|---|---| +| volume_ratio_5d | 5日量比 | 当日量/此前5日平均量 | +| turnover_5d | 5日累计换手 | 最近5日换手率之和,% | +| volatility_10d | 10日波动率 | 最近10个单日涨跌幅的总体标准差 | +| amount_billion | 成交额 | 亿元 | +| turnover_rate | 换手率 | % | +| circ_mv_billion | 流通市值 | 亿元 | +| total_mv_billion | 总市值 | 亿元 | +| net_flow_million | 主力净流入 | 百万元,供应商统一口径 | +| large_flow_million | 大单净流入 | 百万元 | +| net_flow_5d_million | 5日主力净流入 | 最近5日主力净流入之和,百万元 | +| flow_to_circ_mv_5d | 5日净流入占流通市值 | 5日净流入/流通市值,% | +| previous_amount_billion | 昨日成交额 | 亿元 | +| intraday_min_pct | 盘中最大跌幅 | 当日最低相对前收,% | +| lower_shadow_ratio | 下影线实体比 | 下影长度/实体绝对长度;无实体但有下影时上限值10 | +| vol_vs_previous | 较前日量能 | 当日成交量/前日成交量 | +| vol_vs_broken_day | 较断板日量能 | 当日成交量/断板日成交量 | + +### 17.4 板块、涨跌停与形态 + +| 标准字段 | 中文含义 | +|---|---| +| sector_strength | 板块强度 | +| sector_return_5d | 行业5日涨幅 | +| sector_return_20d | 行业20日涨幅 | +| sector_momentum_rank | 行业20日动量排名,0至1 | +| sector_stock_momentum_rank | 行业内个股动量排名,0至1 | +| sector_net_flow_5d_million | 行业5日主力净流入,百万元 | +| sector_flow_rank | 行业资金流排名,0至1 | +| sector_prosperity_rank | 行业景气度排名,0至1 | +| sector_trend_rank | 行业趋势排名,0至1 | +| sector_crowding_rank | 行业拥挤度排名,0至1,越高越拥挤 | +| sector_composite_score | 行业三维综合分 = 景气40% + 趋势30% + (1-拥挤)30% | +| sector_limit_count | 板块涨停数 | +| sector_up_count | 板块强势股数 | +| sector_breadth_ma20 | 行业成分站上20日线比例,% | +| limit_streak | 当日连板高度 | +| previous_limit_streak | 昨日连板高度 | +| previous_first_limit | 昨日是否首板 | +| previous_limit_signal | 昨日是否涨停或触板 | +| is_limit_up_today/is_limit_down_today | 当日是否涨停/跌停,按当日有效制度计算 | +| no_limit_30d | 近30日无涨停 | +| had_limit_80d | 近80日较早区间曾涨停,用于活跃记忆 | +| no_limit_down_20d | 近20日无跌停 | +| financial_risk | 财务风险标记:ST/退市或净利同比≤-100%等已确认风险 | +| is_market_height | 是否当前市场最高板,且市场高度至少2板 | +| new_space_board | 是否相对前日新晋空间板 | +| max_continuous_board_10d | 近10日最高连续涨停数 | +| dragon_first_yin | 三板及以上断板后的首次阴线 | +| yin_day_pct | 首阴当日涨跌幅,% | +| broken_reversal | 是否满足断板后反包结构 | +| days_since_broken | 断板后交易日数 | +| close_above_broken_high | 是否收复断板日高点 | +| recent_limit_up_5d | 近5日涨停次数 | + +### 17.5 竞价、估值与财务 + +| 标准字段 | 中文含义 | 单位/规则 | +|---|---|---| +| auction_change | 竞价涨幅 | 9:25竞价相对前收,% | +| auction_amount_million | 竞价成交额 | 百万元 | +| auction_turnover_rate | 竞价换手率 | % | +| auction_volume_ratio | 竞价量比 | 倍 | +| 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 | 业绩事件质量 | 公告日未出现放量阴线等否决条件;缺数据为空 | + +### 17.6 人气、席位、风格与多因子 + +| 标准字段 | 中文含义 | +|---|---| +| popularity_score | 同花顺/东方财富人气综合分 | +| popularity_rank_change | 人气排名跃升幅度 | +| popularity_dual_source | 是否双榜共识 | +| institution_net_buy_million | 机构席位净买入,百万元 | +| institution_seat_count | 机构专用席位数量 | +| style_size_fit | 当前大小盘风格匹配度,0至1 | +| style_growth_fit | 当前成长/价值风格匹配度,0至1 | +| style_fit_score | 上述两类可用匹配度的均值 | +| factor_value_score | PE、PB低优与股息率高优的百分位均值 | +| factor_growth_score | 营收、净利同比高优的百分位均值 | +| factor_quality_score | ROE、ROIC、毛利率高优的百分位均值 | +| factor_momentum_score | 中期动量、相对强度高优的百分位均值 | +| factor_sentiment_score | 换手率、5日量比高优的百分位均值 | +| multi_factor_composite | 五类因子按当前动态权重合成,0至1 | + +风格匹配当前基线: + +- 按总市值横截面前30%定义大盘、后30%定义小盘,比较两组20日平均收益,选择占优方向。 +- 成长因子分前30%和价值因子分前30%分别计算20日平均收益,选择占优方向。 +- 单股大小盘匹配度与成长/价值匹配度取均值形成总匹配度。 + +## 18. LLM统一行为 + +### 18.1 适用功能 + +问师、问天解读、复盘助手和自然语言策略编译共用统一LLM行为:鉴权、会员检查、额度、模型选择、流式协议、取消、审计和错误转换。 + +### 18.2 调用与回退 + +- 调用前验证登录、功能权限、会员/管理员访问权和当日额度。 +- 主模型失败且尚未输出任何内容时,可尝试辅助模型一次。 +- 已开始输出后禁止切换模型重放答案。 +- 用户停止生成后,尽快终止上游读取,保留已输出内容并标记已停止。 +- 超时、模型满载、限流、认证失败和网络失败使用不同的用户安全提示。 +- “Selected model is at capacity”表示上游模型容量不足,不是业务数据错误;可在未输出前切辅助模型。 +- 同一次业务请求只结算一次额度,主/辅助重试不重复扣次。 + +### 18.3 审计与隐私 + +每次调用记录:账号、功能、模型池条目、主/辅助角色、开始时间、耗时、成功/失败、错误类型、提示词版本、输入输出量和关联业务ID。日志不得记录API Key、访问Token、原始出生资料或完整私密Skill内容。 + +用户界面不得显示:模型真实名称、服务地址、供应商错误原文、Tushare/iFinD接口名、数据库名或堆栈。管理员诊断页可查看脱敏的错误类型、提供方和关联ID。 + +## 19. 视觉、主题与可访问性 + +### 19.1 视觉目标 + +整体是安静、克制、适合长时间复盘的专业工作台,不是营销落地页。页面不能像无样式Excel,也不能用大量装饰卡片制造层级。视觉层级来自: + +- 页面标题与核心指标。 +- 清晰的模块分区和12px间距。 +- 统一字号、字重和对齐。 +- 有节制的状态色。 +- 数据密度和留白平衡。 + +同级模块之间保留明确空隙,不允许直接粘连成一整块表格。标准卡片圆角为10px;单元格圆润但不松散。Hover移动和阴影变化平滑,不能突然跳动。 + +### 19.2 视觉令牌 + +日间色板: + +| 语义 | 色值 | +|---|---| +| 主操作/激活 | `#2563eb` | +| 主操作Hover | `#1d4ed8` | +| 激活浅底 | `#eff4ff` | +| 激活描边 | `#c7d8fb` | +| 上涨/积极 | `#e04536`,浅底`#fdecea` | +| 下跌/消极 | `#16a34a`,浅底`#e9f7ee` | +| 警示 | `#b45309`,浅底`#fdf3e3` | +| 主文字 | `#1f2937` | +| 次文字 | `#6b7280` | +| 辅助文字 | `#9ca3af` | +| 边框 | `#e5e7eb` | +| 行分隔 | `#eef0f3` | +| 页面背景 | `#f4f5f7` | +| 卡片背景 | `#ffffff` | + +夜间色板: + +| 语义 | 色值 | +|---|---| +| 页面背景 | `#121416` | +| 卡片背景 | `#1b1e21` | +| 次级表面 | `#202428`;更高层`#24282d` | +| 边框 | `#343a40`;强调边框`#474f57` | +| 主文字 | `#e8eaed` | +| 次文字 | `#adb5bd` | +| 辅助文字 | `#7f8993` | +| 主操作 | `#6ca8e8`;Hover`#8bbcf0`;浅底`#23364a` | +| 上涨/积极 | `#f06d73`;浅底`#40262a` | +| 下跌/消极 | `#43bc8a`;浅底`#1d382f` | +| 警示 | `#e2ad58`;浅底`#3d3220` | + +问天夜间外壳仍使用全站夜间页面背景;星空为低透明叠层,不另起一块不协调的深色页面。问天传统文化强调色可使用鎏金`#c9a55c`,装饰星空透明度不高于0.15。 + +尺寸令牌: + +- 间距只使用4、8、10、12、14、16、22、26、34px。 +- 卡片头内边距11px 14px;卡片内容垂直12至14px、水平14至16px。 +- 表头8px 12px;舒适表格单元格9px 12px;紧凑5px 12px。 +- 常规按钮6px 13px;小按钮4px 9px;标签1.5px 7px。 +- 卡片圆角10px;按钮和输入框7px;标签5px;徽章4至6px。 +- 卡片阴影`0 1px 2px rgba(16,24,40,.05)`;浮层阴影`0 12px 32px rgba(0,0,0,.18)`。 +- 过渡150至250毫秒ease,只用于opacity、transform和background;禁止全局`transition: all`和大位移。 + +### 19.3 字体与数字 + +- 正文以易读的中文无衬线字体为主;问天标题和传统文化文案可使用统一衬线字体。 +- 页面主标题17px/800,一页只允许一个。 +- 大数字18至26px/800,一页不超过4个。 +- 卡片标题14px/700。 +- 正文和按钮13px/400至500。 +- 表格正文12.5px/400;股票名称13px/700。 +- 表头12px/600。 +- 次要说明11.5至12px/400;辅助/来源/占位10.5至11px/400。 +- 不随视口宽度连续缩放字号。 +- 字间距为0,不使用负字距。 +- 数值使用等宽数字。 +- 表格数值右对齐,文本左对齐,短状态可居中。 +- 长文字允许换行或省略并提供完整查看,不能遮挡相邻列。 + +### 19.4 表格 + +- 列宽按内容类型分配:序号/标签最窄,代码固定适中,名称适中,数值等宽,原因/逻辑/线索最宽。 +- 单位写入表头,例如“成交额(亿)”“涨跌幅(%)”。 +- 空值留空;整表无数据时显示空态。 +- 可排序列在表头显示克制的排序图标和当前方向。 +- 表头在日间和夜间均与表体有清晰但不刺眼的层级。 +- 仅数据区允许局部滚动;如果页面被明确规定为全页滚动,则不能再形成令人困惑的双滚动。 + +通用页面网格只使用以下模式:主表+372px右栏、主表+320px窄右栏、主区+340至400px中右栏、双卡1:1、全宽单列;间距均为12px。集合竞价优先372px右栏,天梯/股池优先320px,情绪周期约340px,题材库右侧为主区且左侧排行更窄。1400px以下右栏可收窄20至32px,不能压缩主表到不可读。 + +### 19.5 日间与夜间 + +- 日间是清爽宣纸浅色,不使用大面积纯白卡片叠纸效果。 +- 夜间是中性深色,不使用单一深蓝/紫色覆盖全站。 +- 红涨绿跌在两种主题中保持一致且对比达标。 +- 所有搜索框、表头、空态、加载骨架、弹窗、策略库、标签、图表背景和Hover都必须有双主题样式。 +- 主题切换一次性应用,目标为无白闪、无表格滞后、无明显卡顿。 +- 用户主题选择持久化到本机;登录前后保持一致。 + +### 19.6 交互与可访问性 + +- 所有可点击元素有明确Hover、Focus和Disabled状态。 +- 键盘可到达搜索、导航、标签、表格操作和弹窗。 +- Disabled不能只降低透明度到不可辨认,仍需保持文字可读并说明原因。 +- 颜色不是唯一状态信号;涨跌、通过/失败同时使用文字或图标。 +- 常规正文与背景对比度目标≥4.5:1,大字≥3:1。 +- 动效遵守系统“减少动态效果”偏好。 +- 加载动画不能阻止用户取消LLM请求。 + +## 20. 分辨率与移动端规范 + +### 20.1 已确认标准 + +- PC与移动端使用相同功能权限、数据口径、账号数据和业务结果。 +- 移动端允许独立页面壳、布局和交互,不要求像素复刻PC。 +- 移动端不能只是缩小字体、卡片和桌面表格。 +- 页面本身不得横向溢出。 +- 宽表可在局部横向滚动,或重组为摘要列表+详情。 +- 触控目标至少44×44px。 +- 固定栏预留安全区。 +- 手机不使用Hover作为唯一入口。 +- 系统管理、复杂模型池和高密度自定义因子配置可以定义为“PC完整支持”;移动端提供查看、简化编辑或提示使用PC。 + +### 20.2 PC分辨率行为 + +- 1920×1080是最低完整可用桌面基线:核心操作在无需极端缩放的情况下可见,长页采用清晰的全页滚动。 +- 1280至1536宽度:保持200px侧栏;必要时次要列隐藏到详情,不能压缩到文字重叠。 +- 4K:限制主内容最大可读宽度或增加信息密度,不把字号、卡片和留白简单放大两倍。 +- 高度不足时,优先全页滚动;不要固定上半页后只给表格几行高度。 +- 情绪周期、板块轮动、智能选股和问天已经确认采用可达的全页滚动策略。 + +### 20.3 移动端目标信息架构 + +手机底部五个一级入口: + +1. 行情。 +2. 选股。 +3. 问师。 +4. 问天。 +5. 复盘。 + +“行情”下的情绪、股池、天梯、板块、竞价、题材、热榜、龙虎榜通过顶部选择器或抽屉切换,不在底部放16个入口。 + +移动页面原则: + +- 主内容单列。 +- 核心摘要优先,详细表格按需展开。 +- 表格在320px宽度下至少能看到名称、关键状态和主数值;其余进入详情。 +- 弹窗改为底部抽屉或全屏页时,必须保留关闭和返回路径。 +- 键盘弹出后输入框和发送按钮保持可见。 +- 问师和复盘助手对话输入区不遮挡最后一条消息。 +- 问天动画适配窄屏且不裁切关键文字。 + +### 20.4 当前实现基线与待设计项 + +当前实现基线:PC端已具备多档宽度适配和部分移动端规则,但整体移动交互仍未达到可用验收标准。 + +待设计或待升级: + +- 逐页移动版信息层级和组件打样。 +- 复杂股池表、竞价和自定义选股的移动交互。 +- 手机横屏策略。 +- 平板分栏策略。 + +因此,重建者不得把现有移动布局原样当作最终标准;必须依据本节目标单独设计,并经人工验收。 + +### 20.5 验收视口 + +至少测试:320、375、390、430、768、1024、1280、1440、1536、1920×1080、3840×2160,以及手机横屏。每个视口检查:无页面横向溢出、无内容遮挡、无不可达操作、无双滚动冲突、文字不溢出、主题完整。 + +## 21. 状态、异常与反馈规则 + +### 21.1 统一页面状态 + +每个数据模块必须有以下互斥状态: + +1. 初始未操作。 +2. 加载中。 +3. 成功且有数据。 +4. 成功但无符合条件数据。 +5. 所需数据缺失。 +6. 权限不足/会员锁定。 +7. 上游暂不可用。 +8. 请求超时。 +9. 使用最近真实快照。 + +“暂无符合条件”只用于数据完整且条件没有命中的情况;“数据缺失”必须列出缺什么;“上游失败”不能覆盖成空结果。 + +### 21.2 用户错误信息 + +- 使用用户能理解的业务语言。 +- 不显示本机路径、HTTP堆栈、Token、模型名、接口URL或供应商原始响应。 +- 提供可执行下一步:重试、查看前一交易日、联系管理员同步、补录数据或稍后再试。 +- 同一失败不能同时弹多个Toast、空态和异常长弹窗。 + +### 21.3 网络与服务恢复 + +- 页面请求可设置超时;用户可重试。 +- 读请求失败不清空屏幕上已有的最后成功数据,除非用户切换到不同实体/日期。 +- 写请求超时后必须先查询是否已保存,避免用户重复提交。 +- 后台任务失败记录失败原因、时间、数据覆盖和重试状态。 +- 进程重启后,已完成的盘后任务不能重复产生不同结果。 + +### 21.4 已知异常弹窗防线 + +历史版本曾出现“正常业务弹窗关闭后又出现超长空弹窗,无法关闭,只能刷新”的缺陷。最终标准: + +- 业务成功反馈统一使用Toast。 +- 打开弹窗前校验内容和弹窗类型。 +- 弹窗内容区必须有最大宽高和可控滚动。 +- 关闭后清理内容、遮罩和焦点状态。 +- 全站扫描所有保存、删除、添加、导出和LLM完成场景,禁止把普通返回对象当作弹窗HTML渲染。 + +## 22. 数据持久化、备份与安全 + +### 22.1 持久化要求 + +- 当前适合局域网单实例部署,持久化数据库支持事务和写前日志。 +- 所有用户私有记录保留账号所有权和创建/更新时间。 +- 共享快照按交易日和数据版本唯一归档。 +- 盘后计算记录输入快照、算法版本和输出版本。 +- 数据库结构升级必须有顺序版本、幂等迁移和升级记录。 +- 未来迁移到其他数据库时,不改变产品数据边界和业务结果。 + +### 22.2 认证与会话 + +- 密码使用现代抗暴力哈希和独立盐,不可逆保存。 +- 登录会话使用高熵随机令牌,服务端只保存哈希。 +- 所有写操作验证CSRF令牌。 +- Cookie使用HttpOnly和SameSite;HTTPS公网部署时必须启用Secure。 +- 会话到期后回到登录页,未提交内容给出合理提示。 +- 不允许仅依靠前端隐藏按钮实现权限控制;服务端再次鉴权。 + +### 22.3 密钥与敏感资料 + +- 行情Token、iFinD令牌、模型API Key、加密密钥不得进入Git、浏览器存储或普通日志。 +- 系统凭据和出生资料加密保存。 +- 加密密钥与数据库必须一起备份;丢失密钥后不得伪称可恢复密文。 +- 私有“小白”Skill不进入公开仓库和镜像。 +- 普通用户不能读取或修改系统级凭据。 + +### 22.4 备份与恢复 + +- 每次升级前备份数据库、加密密钥、环境配置和私有Skill。 +- 数据库备份必须使用一致性快照方式,不能在写入中只复制单个主文件而遗漏写前日志。 +- 至少保留最近7个日备份、4个周备份;具体保留期可由部署者提高。 +- 恢复演练至少验证:账号登录、共享快照、用户自选/复盘、会员状态、模型配置、问天历史和策略结果。 +- 回退代码时不能自动回退已迁移数据;每个破坏性迁移必须提供明确恢复方案。 + +### 22.5 运行与部署基线 + +- 时区固定为Asia/Shanghai。 +- 默认服务端口8765,可通过部署配置更改。 +- 数据目录必须持久化,容器重建不能丢数据。 +- 运行进程使用非管理员用户、只读应用文件系统、最小权限和自动重启。 +- 提供健康检查,区分进程、数据库、数据源、后台任务和模型状态。 +- 数据源降级不等同于整个网站不可用;已归档数据仍应可读。 +- 日志轮转,默认单文件不超过10MB、保留3个;不得记录密钥。 +- 局域网HTTP可运行;转向公网前必须增加反向代理、TLS、可信Host、限流、审计、集中密钥和正式备份。 + +## 23. 后台任务与运行状态 + +### 23.1 行情刷新 + +- 输入为请求交易日。 +- 同一时间只能有一个行情刷新任务。 +- 任务记录开始、完成、失败、来源覆盖、输出版本和耗时。 +- 超时目标120秒;实时轮询任务失败不立即覆盖最后成功快照。 + +### 23.2 盘后智能选股 + +- 交易日15:10后且当日行情刷新成功、因子达到最低覆盖后执行。 +- 同一交易日、同一策略版本只完成一次。 +- 最长运行目标15分钟;超过后标记失败并保留逐策略状态。 +- 单个策略数据缺失不阻止其他数据完整的策略运行。 +- 阶段策略、29套精选策略分别记录结果。 + +### 23.3 事件补充 + +- 涨停、炸板、跌停原因等事件数据在收盘后按需补充。 +- 补充来源不能覆盖管理员人工修订;人工修订具有更高优先级并保留修订记录。 +- iFinD试用或令牌到期时,显示事件补充暂不可用,不影响已有Tushare快照。 + +## 24. 当前实现基线与已知待改进 + +本节不是最终标准,只用于避免重建时误判现状。 + +### 24.1 当前实现基线 + +- 单体Web应用可在本机或局域网容器运行。 +- 已有16个工作区、内部策略跟踪页、账户、会员和系统管理。 +- 已有共享行情快照、用户私有数据、日间/夜间、搜索、图表和LLM流式能力。 +- 已有36套内置策略和自定义公式执行。 +- 已有问天三模块和确定性历法/卦象计算。 + +### 24.2 不得继承为标准的缺陷 + +- 移动端当前总体不可用或交互较差。 +- 部分页面仍可能存在多层CSS覆盖和历史布局残留。 +- 旧说明中“策略运行即自动跟踪”已经废止。 +- 旧说明中“用户手动同步因子、手动执行阶段/策略选股”已经废止。 +- 旧观心30秒/40秒呼吸说明已经废止,当前标准为准备1秒+5轮×9秒。 +- 历史上用公开网页源参与观势替代计算的行为已经收紧;安全门不通过应失败关闭。 +- 多因子“IC动态加权”仍是基础版,不得夸大。 + +### 24.3 待设计或待升级清单 + +- 完整移动端逐页设计与验收。 +- 多因子12个月Rank IC季度重算及中性化。 +- 稳定的隔夜消息量化和政策/宏观序列。 +- Level-2动态竞价委托队列。 +- 分析师一致预期。 +- 游资档案的更完整历史画像;当前不是高优先级。 +- 公网部署的多实例、PostgreSQL、队列、缓存和集中监控;当前不在局域网版本范围内。 + +## 25. 固定验收案例 + +以下案例全部通过,才可声称产品行为重建完成。 + +### 25.1 账户、权限与隔离 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| A01 | 空系统注册第一个账号 | 该账号成为管理员,能看到后台刷新和系统管理 | +| A02 | 注册第二个账号 | 第二个账号为普通用户,不显示管理员按钮 | +| A03 | 管理员同时开通会员 | 顶部同时显示管理员和会员标识 | +| A04 | 管理员未开会员 | 可测试智能功能,但会员页仍显示未开通,不伪装会员 | +| A05 | 普通用户打开智能选股 | 页面结构完整,顶部会员提示,下方灰化不可操作 | +| A06 | 用户甲、乙分别添加不同自选 | 甲乙互相看不到对方自选 | +| A07 | 用户甲写每日复盘、个股笔记和交易日志 | 用户乙任何入口均无法读取、修改或删除 | +| A08 | 非管理员直接请求系统管理写操作 | 服务端拒绝,即使手工构造请求也不能成功 | +| A09 | 修改密码时当前密码错误 | 不修改密码,显示安全提示 | +| A10 | 点击账户菜单五项 | 个人资料、会员状态、修改密码、切换账号、退出均独立可用 | + +### 25.2 日期与数据真相 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| D01 | 2026-07-22 9:15前打开网站 | 显示2026-07-21 15:00真实收盘数据,不显示模拟数据 | +| D02 | 周六打开网站 | 显示最近交易日15:00并标真实日期 | +| D03 | 盘中10:23有最新快照 | 数据时间显示最近刷新时刻,不提前显示15:00 | +| D04 | 收盘后最终同步完成 | 数据时间显示当日15:00 | +| D05 | 选择历史日期 | 不混入今天实时行情 | +| D06 | 今天取数失败但有昨日快照 | 页面仍可用,明确显示昨日日期和“沿用最近快照” | +| D07 | 全新系统无任何快照 | 显示等待管理员首次同步,无演示数字 | +| D08 | 点击后台刷新 | 当前页面不自动跳转或重绘;普通刷新后读取新快照 | +| D09 | 7月29日打开,最新可用为29日 | 顶栏默认29日,默认页面为情绪周期 | + +### 25.3 图表与详情 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| C01 | 开盘前悬浮股票代码 | 日K止于最后真实交易日,不制造今天空K线 | +| C02 | 页面选择历史日后悬浮股票 | 悬浮日K仍显示最近真实行情 | +| C03 | 切换分时 | 横轴固定9:30至15:00,未到时间段保留空白,有昨收0轴 | +| C04 | 查看上涨K线 | 红色空心,影线不穿过实体 | +| C05 | 夜间首次加载图表 | 加载背景不闪白 | +| C06 | 打开个股详情 | 有完整个股笔记、资金流、事件逻辑和观势入口 | +| C07 | 打开板块/题材/指数详情 | 有行情图,无个股笔记和个股事件逻辑 | +| C08 | 检查涨跌幅 | 使用正确前收,详情摘要与K线数据一致 | + +### 25.4 情绪与市场页面 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| M01 | 市场宽度≤15且跌停≥100 | 情绪温度不超过15,阶段进入/保持冰点 | +| M02 | 冰点后单日只升3分 | 不直接转修复 | +| M03 | 发酵条件仅一天成立 | 不直接从修复转发酵 | +| M04 | 当前阶段为退潮 | 警示只描述情绪走弱,不出现选股建议 | +| M05 | 切换10/20/60日 | 明细范围改变,默认20日;表格最后一行可完整查看 | +| M06 | 涨停池筛选3板+仅1行 | 底部风险提示仍在固定状态栏,不随表格上移 | +| M07 | 市场天梯展开很多股票 | 可滚动且可收起,单元格保持等宽栅格 | +| M08 | 市场高度7板 | 涨停表现动态容纳7板,右侧今日结论不留异常空缺 | +| M09 | 点击板块轮动某板块 | 下方显示该日申万成分股,并跨日期高亮同名板块 | + +### 25.5 集合竞价、题材、热榜、龙虎榜 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| X01 | 9:20且动态iFinD可用 | 状态为观察中,显示真实快照时间 | +| X02 | 9:20但动态数据不可用 | 不把Tushare最终竞价伪装为动态数据 | +| X03 | 9:26 | 使用9:25最终竞价形成筛选结果 | +| X04 | 一字板候选 | 单独进入竞价一字,不参与普通异动评分 | +| X05 | 新规ST股票 | 涨跌停阈值按10%处理 | +| X06 | 市场核心超过普通上限 | 核心股票全部保留 | +| X07 | 核对竞价成交额 | 摘要数值、柱图和5日均线使用同一口径 | +| X08 | 悬浮题材名称 | 出现统一日K/分时预览;固定题材K线模块不存在 | +| X09 | 当日热榜未发布 | 显示最近有效榜单并标实际日期 | +| X10 | 龙虎榜有76只上榜但席位不可识别 | 明确显示有榜单、待归类/席位缺失,不显示“无龙虎榜数据” | +| X11 | 点击游资档案 | 进入左名录右详情,不弹超长框 | + +### 25.6 智能选股 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| S01 | 当日阶段为退潮 | 只影响阶段选股;29套精选策略仍逐套运行 | +| S02 | 精选策略数据完整但无命中 | 显示“暂无符合条件个股” | +| S03 | 精选策略缺ROIC数据 | 显示缺少ROIC/财务覆盖,不显示“暂无信号” | +| S04 | 同策略同日期刷新前后 | 候选顺序和分数一致 | +| S05 | 先运行/查看退潮策略,再切分化 | 两个阶段结果分别保留 | +| S06 | 在策略选股切三个策略 | 各策略候选保留且明确标来源 | +| S07 | 阶段/策略/自定义三模式切换 | 结果不会同时出现在三页或互相覆盖 | +| S08 | 盘后任务未执行 | 四步状态显示未执行,不显示已完成对号 | +| S09 | 用户执行自定义选股 | 由条件和数据确定计算,不调用LLM筛选 | +| S10 | 自定义权重不等于100% | 禁止执行并指出权重总和问题 | +| S11 | 运行策略产生候选 | 不自动进入持续跟踪 | +| S12 | 用户手动加入候选 | 进入该用户跟踪,并记录入选价 | +| S13 | 用户甲加入跟踪 | 用户乙看不到该记录 | +| S14 | T+1和T+5条件满足并多次刷新提醒 | 每类提醒只生成一次 | + +### 25.7 问师、问天与LLM + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| L01 | 问师流式完成 | 每段只出现一次,不在结尾重复完整答案 | +| L02 | 主模型输出前失败 | 可切辅助模型,用户只看到一份答案 | +| L03 | 主模型输出一半失败 | 不切辅助重放,提示连接中断并保留已输出 | +| L04 | 选择宏观模型询问市场 | 上下文含宽基指数和ETF,不强塞短线席位数据 | +| L05 | 普通用户打开问师/问天/助手 | 同结构锁定态,不能发起LLM调用 | +| W01 | 观势未输入股票 | 只提示输入代码或名称,不显示暂不成卦 | +| W02 | 中国平安所属保险行业仅5只成分且5只有行情 | 小样本但覆盖完整时行业安全门可通过 | +| W03 | 行业缺涨跌但用户补录客观值 | 按原公式重算;用户不能直接选阴阳 | +| W04 | 三大指数缺一 | 上爻安全门失败,不静默替代成卦 | +| W05 | 观气当天已解运 | 再次点击直接显示已保存结果,不重复扣额度 | +| W06 | 观心开始呼吸 | 准备1秒后5轮吸3/顿2/呼4;无轮数和倒计时 | +| W07 | 观心第一次投币 | 只出现初爻,其余显示未得 | +| W08 | 观心投满六次 | 显示本卦/之卦/卦辞,不显示六个爻解释卡 | +| W09 | 夜间打开观心 | 提示、按钮、历史和静音文字清晰可读 | + +### 25.8 复盘、主题和响应式 + +| 编号 | 前置条件与操作 | 预期结果 | +|---|---|---| +| R01 | 保存交易日志 | 当前页出现记录,只出现正常Toast,无超长空弹窗 | +| R02 | 交易日志无盈亏 | 不进入胜率分母 | +| R03 | 同日期重复保存每日复盘 | 更新原记录,不新增重复项 | +| R04 | 展开很多历史复盘 | 页面或区域可滚动且可收起 | +| U01 | 日间切夜间 | 表格、卡片、弹窗和图表同时切换,无白闪 | +| U02 | 1920×1080打开情绪周期 | 可通过全页滚动完整查看交易日明细 | +| U03 | 1920×1080打开问天观气/观心 | 下方内容可达,滚动条存在且背景一致 | +| U04 | 3840×2160打开页面 | 内容不被粗暴等比放大,信息密度合理 | +| U05 | 390×844打开 | 无页面横向溢出,底部五入口可用,触控目标≥44px | +| U06 | 320px打开宽表页 | 使用摘要列表或局部横滚,页面本身不横滚 | + +## 26. 页面覆盖矩阵 + +每一行均需针对“权限、正常数据、空数据、数据缺失、加载、失败、持久化、日间、夜间、1080P、4K、390px”完成测试。 + +| 页面/能力 | 普通 | 会员 | 管理员 | 共享/私有 | 关键持久化 | +|---|:---:|:---:|:---:|---|---| +| 情绪周期 | 可用 | 可用 | 可用 | 共享 | 日度情绪结果与版本 | +| 四类股池 | 可用 | 可用 | 可用 | 共享 | 日度快照、原因修订 | +| 涨停表现/天梯 | 可用 | 可用 | 可用 | 共享 | 日度结构结果 | +| 板块轮动 | 可用 | 可用 | 可用 | 共享 | 多日热点、成分缓存 | +| 集合竞价 | 可用 | 可用 | 可用 | 共享 | 动态快照、9:25归档 | +| 题材库/热榜 | 可用 | 可用 | 可用 | 共享 | 榜单与成分归档 | +| 龙虎榜/游资档案 | 可用 | 可用 | 可用 | 共享 | 明细、别名、档案 | +| 智能选股 | 锁定 | 可用 | 可用 | 共享结果+私有自定义 | 策略/因子版本和结果 | +| 策略跟踪 | 锁定 | 可用 | 可用 | 私有 | 跟踪批次与行情反馈 | +| 问师 | 锁定 | 可用 | 可用 | 私有对话 | 模型偏好、消息、排序 | +| 问天 | 锁定 | 可用 | 可用 | 私有历史 | 卦象、解读、当天解运 | +| 我的复盘 | 可用 | 可用 | 可用 | 私有 | 自选、复盘、日志、笔记 | +| 提醒中心 | 可用 | 可用 | 可用 | 私有 | 提醒、已读状态、去重键 | +| 复盘助手 | 锁定 | 可用 | 可用 | 私有 | 对话历史 | +| 账户/会员 | 可用 | 可用 | 可用 | 私有 | 资料、会员、使用量 | +| 系统管理 | 隐藏 | 隐藏 | 可用 | 系统 | 凭据、模型池、任务状态 | + +## 27. 重建交付门槛 + +### 27.1 功能完整性 + +- 16个主工作区、1个内部页和所有全局能力均有实现。 +- 本文全部权限和账号隔离测试通过。 +- 所有确定性算法有版本并能用固定输入复现固定输出。 +- 36套策略的准入、权重、阈值、频率和上限与本文一致。 +- 所有数据缺失与失败状态可区分。 + +### 27.2 数据与算法 + +- 每个计算字段可追溯来源、日期、单位、新鲜度和覆盖率。 +- 不存在模拟行情、静默跨源混算和未来函数。 +- 情绪、竞价、选股、观势提供固定样本回归测试。 +- 页面摘要与图表使用同一口径。 + +### 27.3 视觉与终端 + +- 逐页完成日间/夜间、1920×1080、4K和390px截图验收。 +- 无左上角弹窗、超长空弹窗、白闪、内容遮挡和不可达区域。 +- 页面边距和骨架一致,问天仅内容气质例外。 +- 移动端需人工验收后才能标记完成;不能以“有媒体查询”代替可用性验收。 + +### 27.4 安全与运维 + +- 私有数据跨账号测试通过。 +- 密钥不进入仓库、日志和浏览器。 +- 备份和恢复演练成功。 +- 升级失败可回退应用,并可恢复匹配的数据备份。 +- 局域网服务重启后,账号、行情和个人记录不丢失。 + +### 27.5 禁止的验收方式 + +以下情况不能算完成: + +- 只检查页面能打开。 +- 只测试管理员账号。 +- 只测试有数据的正常态。 +- 只在4K显示器检查比例。 +- 用旧代码行为反驳本文已确认标准。 +- 用页面隐藏代替服务端权限。 +- 用策略名称代替完整算法。 +- 用“技术上预留”冒充用户可用功能。 + +## 28. 规格变更规则 + +- 新增功能前先更新本说明书的产品行为、权限、数据和验收案例,再实施。 +- 修改算法必须增加版本号,历史结果保留原版本,不静默重算成新口径。 +- 修改数据源必须逐字段验证,不允许整站无审查切源。 +- 修改视觉必须同时验收日间、夜间、1080P、4K和移动端。 +- 删除功能前确认其数据保留、导出和回退策略。 +- “待设计或待升级”转为正式标准必须经过用户确认,并从待设计清单移入对应正文。 + +--- + +本说明书的完成标准不是“与旧代码一致”,而是:一个新的实现者只阅读本文,能够知道每个用户在每种时间、权限、数据状态和终端下应该看到什么、能做什么、系统如何计算,以及如何证明结果正确。 diff --git a/frontend/app.js b/frontend/app.js index b3552c7..0fbfd37 100644 --- a/frontend/app.js +++ b/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/frontend/bootstrap.js b/frontend/bootstrap.js new file mode 100644 index 0000000..ab70307 --- /dev/null +++ b/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/frontend/index.html b/frontend/index.html index 2996b1f..3449e83 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,12 +18,28 @@ })(); - - - - - - + + + + + + + + + + + + + + + + + + + + + +
@@ -47,8 +63,8 @@
-
-
+
+
上涨 -- 下跌 -- 涨停 -- @@ -90,9 +106,9 @@
-