Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcab3064c | ||
|
|
b402b3b2a8 | ||
|
|
3eaa36a8d5 | ||
|
|
3203574b6a | ||
|
|
35ee43ea02 | ||
|
|
78931f9c42 | ||
|
|
6f99ee9d9e | ||
|
|
9b2f0993d3 | ||
|
|
3bafd30aad | ||
|
|
d9358ab377 | ||
|
|
31da37b49e | ||
|
|
2e9442ac5a | ||
|
|
ad70d8fccc | ||
|
|
4a90c32fcc | ||
|
|
5385bad9ec | ||
|
|
955785b9fd | ||
|
|
88411efa1c | ||
|
|
c3881af708 | ||
|
|
5f343a6bae | ||
|
|
abee8306be | ||
|
|
cb45d742da | ||
|
|
16843bdc7e | ||
|
|
b640e77264 | ||
|
|
b950ea4344 | ||
|
|
41f8509a98 | ||
|
|
c9e2d30780 | ||
|
|
8a7d1f3698 | ||
|
|
100752f43c | ||
|
|
0b8419abca | ||
|
|
ef13d6feb5 | ||
|
|
b5d65ecb41 | ||
|
|
3e828b346c | ||
|
|
c8a9376adb | ||
|
|
1c2f2ac057 | ||
|
|
5d3465987d | ||
|
|
dd89a09643 | ||
|
|
a043bc9eb1 | ||
|
|
acde4de40d | ||
|
|
3d2c1252f1 | ||
|
|
605f97e5df | ||
|
|
16ba83ec01 | ||
|
|
1c740a9d48 | ||
|
|
75c2e33b68 | ||
|
|
32f565ecb9 | ||
|
|
16841e9ae3 | ||
|
|
bed6450992 | ||
|
|
c9892050c3 | ||
|
|
a836cda1b2 | ||
|
|
25ff6bbe06 | ||
|
|
5085cacf0d |
+13
-7
@@ -1,18 +1,24 @@
|
|||||||
# Generated automatically when omitted. Back it up together with the database.
|
# Generated automatically when omitted. Back it up together with the database.
|
||||||
APP_ENCRYPTION_KEY=
|
APP_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Initial shared market-data credential. After first launch it is encrypted into
|
# Market-source credentials are consumed and encrypted only by xiaobai-datahub.
|
||||||
# the system settings; all accounts use the same backend market snapshot.
|
# compose.yaml masks them from the xiaobai-review website process.
|
||||||
TUSHARE_TOKEN=your_tushare_token_here
|
TUSHARE_TOKEN=your_tushare_token_here
|
||||||
|
|
||||||
# Optional xiaobai-datahub client. All DATAHUB_READ_* / DATAHUB_SHADOW_* flags
|
# Official xiaobai-datahub client. Read flags default on in config/datahub.config.json.
|
||||||
# default off in config/datahub.config.json, so the website keeps using Tushare.
|
# compose.yaml pins every DATAHUB_READ_* to 1. The website has no provider
|
||||||
|
# fallback; source selection and failover happen inside xiaobai-datahub.
|
||||||
|
# DATAHUB_SHADOW_* can still override a single dataset.
|
||||||
DATAHUB_BASE_URL=http://127.0.0.1:8766
|
DATAHUB_BASE_URL=http://127.0.0.1:8766
|
||||||
DATAHUB_TOKEN=
|
DATAHUB_TOKEN=
|
||||||
|
|
||||||
# Optional iFinD HTTP credential. The backend exchanges it for a short-lived
|
# 数据中枢控制台(8766)用它调用 /api/hub-admin/* 校验主站会话、读写模型池与
|
||||||
# access token and never exposes either token to browsers.
|
# 会员/邀请码。两个服务必须填同一个值;32+ 随机字节,未设置则桥接直接拒绝。
|
||||||
IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
|
HUB_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# iFinD credentials live on xiaobai-datahub, not the website process.
|
||||||
|
# IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
|
||||||
|
# IFIND_ACCESS_TOKEN=
|
||||||
|
|
||||||
# Initial platform member models (OpenAI-compatible). After first launch these
|
# Initial platform member models (OpenAI-compatible). After first launch these
|
||||||
# are encrypted into system settings and used only by admins and active members.
|
# are encrypted into system settings and used only by admins and active members.
|
||||||
|
|||||||
+11
-4
@@ -38,10 +38,15 @@ background scheduler
|
|||||||
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
|
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
|
||||||
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
|
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
|
||||||
or deterministic calculation code for that product area.
|
or deterministic calculation code for that product area.
|
||||||
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
|
- `backend/data/` owns the website-side DataHub client, stable dataset contracts, provenance,
|
||||||
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
|
units, freshness, coverage, display-versus-calculation eligibility, and shared numeric
|
||||||
|
normalization policies. The website process does not construct or configure external market
|
||||||
|
providers; provider credentials, source selection, retries, fallbacks, caching, and backfill
|
||||||
|
belong exclusively to the `xiaobai-datahub` service.
|
||||||
- `backend/data/providers/tushare_client.py` is the stable public `TushareClient` facade and
|
- `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
|
is retained as the dataset-contract compatibility surface and isolated test facade. Production
|
||||||
|
website services never instantiate it directly: its query methods are served by the DataHub
|
||||||
|
proxy. Its split modules document the stable contract: Tushare HTTP transport belongs to
|
||||||
`tushare_transport.py`; market overview and realtime breadth belong to
|
`tushare_transport.py`; market overview and realtime breadth belong to
|
||||||
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
|
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
|
||||||
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
|
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
|
||||||
@@ -54,7 +59,9 @@ background scheduler
|
|||||||
feature repository mixins; do not add feature queries to it.
|
feature repository mixins; do not add feature queries to it.
|
||||||
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
|
- `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
|
`backend/jobs/service.py` is the application-facing owner of scheduler start/stop, manual
|
||||||
refresh submission, and periodic refresh coordination.
|
refresh submission, and periodic refresh coordination. `backend/jobs/refresh.py` owns
|
||||||
|
whether a dashboard payload is a usable refresh result versus a failed job, and whether
|
||||||
|
after-hours official catch-up is due.
|
||||||
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
|
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
|
||||||
streaming rules, and call audit. Feature agents only prepare messages and interpret
|
streaming rules, and call audit. Feature agents only prepare messages and interpret
|
||||||
feature-specific results.
|
feature-specific results.
|
||||||
|
|||||||
+22
-4
@@ -22,9 +22,10 @@ xiaobai-review 容器 :8765
|
|||||||
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
|
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
|
||||||
```
|
```
|
||||||
|
|
||||||
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
|
账号、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。外部行情源
|
||||||
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与
|
Token 只允许保存在 `xiaobai-datahub` 的环境或凭据库,网站进程不读取、不保存,也不向
|
||||||
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。
|
提供方直接发请求。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与密钥必须成对
|
||||||
|
备份,任意一个丢失都无法恢复账号内的加密资料。
|
||||||
|
|
||||||
问天静态知识文件:
|
问天静态知识文件:
|
||||||
|
|
||||||
@@ -41,7 +42,9 @@ xiaobai-review 容器 :8765
|
|||||||
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
|
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
|
||||||
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
|
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
|
||||||
|
|
||||||
首个注册账号自动成为管理员。管理员在“系统管理”中配置全站共享行情、后台刷新、平台会员模型及手动会员;普通用户的“账号设置”用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
|
首个注册账号自动成为管理员。管理员在网站“系统管理”中查看数据中枢状态并配置后台刷新、
|
||||||
|
平台会员模型及手动会员;行情源凭据和调度策略在数据中枢后台统一管理。普通用户的“账号设置”
|
||||||
|
用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
|
||||||
|
|
||||||
## 2. 服务器要求
|
## 2. 服务器要求
|
||||||
|
|
||||||
@@ -271,3 +274,18 @@ docker compose restart xiaobai-review
|
|||||||
当前部署使用局域网 HTTP,账号密码和会话只适合可信内网使用。不要直接将
|
当前部署使用局域网 HTTP,账号密码和会话只适合可信内网使用。不要直接将
|
||||||
`8765` 暴露到互联网。以后需要公网访问时,应在容器前增加 Caddy 或 Nginx,
|
`8765` 暴露到互联网。以后需要公网访问时,应在容器前增加 Caddy 或 Nginx,
|
||||||
启用 HTTPS,并限制可信来源。
|
启用 HTTPS,并限制可信来源。
|
||||||
|
|
||||||
|
### 数据中枢控制台(8766)
|
||||||
|
|
||||||
|
数据中枢是管理员的统一配置入口(数据源凭证、模型池、会员与邀请码),叠加
|
||||||
|
`compose.datahub.yaml` 部署,容器名 `xiaobai-datahub`:
|
||||||
|
|
||||||
|
- 它没有独立账号,用主站管理员账号进入;未登录会跳主站登录页,非管理员一律 403。
|
||||||
|
每个页面与接口都在服务端校验,前端隐藏不作为权限依据。
|
||||||
|
- 两侧 `.env` 的 `HUB_ADMIN_TOKEN` 必须填成同一个随机值(服务间桥接令牌)。缺失或
|
||||||
|
不一致时控制台无法校验会话,页面会停在门禁面板。
|
||||||
|
- `REVIEW_BASE_URL` 是中枢访问主站的地址(同一 compose 网络内用服务名
|
||||||
|
`http://xiaobai-review:8765`);`REVIEW_PUBLIC_URL` 是浏览器可达的主站地址,
|
||||||
|
留空则按当前主机名推导。
|
||||||
|
- 会话 cookie 靠"同主机不同端口"共享,因此主站与中枢必须对浏览器暴露在同一主机名下;
|
||||||
|
8766 与 8765 同样只在可信内网开放。
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- **题材库 / 人气热榜 / 龙虎榜**:题材成分、双榜人气、席位与游资档案
|
- **题材库 / 人气热榜 / 龙虎榜**:题材成分、双榜人气、席位与游资档案
|
||||||
- **智能选股**(会员):六阶段策略、精选策略库、自然语言编译为受控公式后的确定性筛选与滚动回测;候选需手动加入后才进入五交易日跟踪
|
- **智能选股**(会员):六阶段策略、精选策略库、自然语言编译为受控公式后的确定性筛选与滚动回测;候选需手动加入后才进入五交易日跟踪
|
||||||
- **问师**(会员):按选定的游资思维 Skill 单师对话;新增公开角色时在 `游资skills` 下增加含 `SKILL.md` 的目录,并在 `游资skills/mentor_catalog.json` 登记。管理员私有角色放在 `data/private-mentor-skills`(不进 Git / 镜像)
|
- **问师**(会员):按选定的游资思维 Skill 单师对话;新增公开角色时在 `游资skills` 下增加含 `SKILL.md` 的目录,并在 `游资skills/mentor_catalog.json` 登记。管理员私有角色放在 `data/private-mentor-skills`(不进 Git / 镜像)
|
||||||
- **问天**(会员,冻结区,勿改代码):观势 / 观气 / 观心。卦象、干支、节气与气机由本地程序确定性计算,大模型只负责文字解释
|
- **问天**(会员):观势 / 观气 / 观心。卦象、干支、节气与气机由本地程序确定性计算,大模型只负责文字解释。此前仅冻结过界面视觉方案,现已解冻;问天可纳入后续数据与功能迁移,本阶段不主动重做视觉。
|
||||||
- **我的复盘**:手工交易日志、每日复盘、提醒中心与复盘助手;不接券商、不自动下单
|
- **我的复盘**:手工交易日志、每日复盘、提醒中心与复盘助手;不接券商、不自动下单
|
||||||
|
|
||||||
全局能力:日间 / 夜间主题、股票代码悬停预览日 K 与分时、`Ctrl + K` 全局搜索。图表数据不写入主行情,也不参与情绪、选股或问天计算。
|
全局能力:日间 / 夜间主题、股票代码悬停预览日 K 与分时、`Ctrl + K` 全局搜索。图表数据不写入主行情,也不参与情绪、选股或问天计算。
|
||||||
@@ -51,6 +51,8 @@ python server.py
|
|||||||
|
|
||||||
默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。
|
默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。
|
||||||
|
|
||||||
|
第一个账号之外的注册都必须填写一次性邀请码:管理员在数据中枢「会员管理」里生成,一个码只能成功注册一次,已使用或已作废的码不再可用。
|
||||||
|
|
||||||
主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。
|
主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。
|
||||||
|
|
||||||
可选参数:
|
可选参数:
|
||||||
@@ -115,9 +117,11 @@ compose.yaml
|
|||||||
.env.example 环境变量模板(复制为 .env 后填写)
|
.env.example 环境变量模板(复制为 .env 后填写)
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员通过页面右上角「系统管理」保存公共 Tushare Token、平台主/辅助模型、会员每日额度和后台刷新开关。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,指向 8766):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
||||||
|
|
||||||
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员可开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
|
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。
|
||||||
|
|
||||||
|
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员在数据中枢开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
|
||||||
|
|
||||||
相关文档:
|
相关文档:
|
||||||
|
|
||||||
@@ -132,7 +136,7 @@ compose.yaml
|
|||||||
- 本项目是个人研究与复盘工具,全部数据、指标、候选与文字分析均不构成投资建议、证券推荐或买卖要约。
|
- 本项目是个人研究与复盘工具,全部数据、指标、候选与文字分析均不构成投资建议、证券推荐或买卖要约。
|
||||||
- 不接券商、不代为下单。交易日志只做手工记录与统计,不代表实际成交。
|
- 不接券商、不代为下单。交易日志只做手工记录与统计,不代表实际成交。
|
||||||
- 情绪温度、阶段判定、连板梯队、策略筛选等均为基于公开数据的统计与规则计算,不预测走势,不保证收益。
|
- 情绪温度、阶段判定、连板梯队、策略筛选等均为基于公开数据的统计与规则计算,不预测走势,不保证收益。
|
||||||
- 「问天」属于传统文化视角的观察工具,不具备预测功能,不得作为投资依据;该模块为冻结区,不要改其代码。
|
- 「问天」属于传统文化视角的观察工具,不具备预测功能,不得作为投资依据。问天不是永久冻结区:此前只冻结过界面视觉方案,现已解冻,后续数据与功能迁移可以纳入。
|
||||||
- 行情来自第三方接口,可能延迟、缺失或口径调整;不可用时页面会明确提示,请以交易所与券商正式披露为准。
|
- 行情来自第三方接口,可能延迟、缺失或口径调整;不可用时页面会明确提示,请以交易所与券商正式披露为准。
|
||||||
- 不要把服务端口直接暴露到公网。不要把 Token、密码、密钥、数据库或 `.env` 提交进 Git。
|
- 不要把服务端口直接暴露到公网。不要把 Token、密码、密钥、数据库或 `.env` 提交进 Git。
|
||||||
- 股市有风险,入市需谨慎。投资决策及其后果由使用者本人承担。
|
- 股市有风险,入市需谨慎。投资决策及其后果由使用者本人承担。
|
||||||
|
|||||||
+3
-17
@@ -50,9 +50,11 @@ from backend.features.themes.routes import ThemeRoutesMixin
|
|||||||
from backend.http import HttpTransportMixin
|
from backend.http import HttpTransportMixin
|
||||||
from backend.http.dispatch import (
|
from backend.http.dispatch import (
|
||||||
AUTHENTICATED_POST_HANDLERS,
|
AUTHENTICATED_POST_HANDLERS,
|
||||||
|
HUB_SERVICE_HANDLERS,
|
||||||
PUBLIC_POST_HANDLERS,
|
PUBLIC_POST_HANDLERS,
|
||||||
ApplicationHttpDispatchMixin,
|
ApplicationHttpDispatchMixin,
|
||||||
)
|
)
|
||||||
|
from backend.http.hubadmin import HubAdminHttpMixin
|
||||||
from backend.jobs.service import JobServiceMixin
|
from backend.jobs.service import JobServiceMixin
|
||||||
from backend.llm import LLMGateway
|
from backend.llm import LLMGateway
|
||||||
from backend.llm.http import LLMHttpMixin
|
from backend.llm.http import LLMHttpMixin
|
||||||
@@ -60,22 +62,6 @@ from backend.llm.service import LLMServiceMixin
|
|||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
|
|
||||||
|
|
||||||
LEGACY_SECRET_KEYS = {
|
|
||||||
"TUSHARE_TOKEN",
|
|
||||||
"IFIND_REFRESH_TOKEN",
|
|
||||||
"IFIND_ACCESS_TOKEN",
|
|
||||||
"LLM_API_KEY",
|
|
||||||
"LLM_BASE_URL",
|
|
||||||
"LLM_MODEL",
|
|
||||||
"LLM_PRIMARY_API_KEY",
|
|
||||||
"LLM_PRIMARY_BASE_URL",
|
|
||||||
"LLM_PRIMARY_MODEL",
|
|
||||||
"LLM_FALLBACK_API_KEY",
|
|
||||||
"LLM_FALLBACK_BASE_URL",
|
|
||||||
"LLM_FALLBACK_MODEL",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardService(
|
class DashboardService(
|
||||||
SystemServiceMixin,
|
SystemServiceMixin,
|
||||||
AccountApplicationMixin,
|
AccountApplicationMixin,
|
||||||
@@ -121,7 +107,6 @@ class DashboardService(
|
|||||||
self._system_credentials,
|
self._system_credentials,
|
||||||
MENTOR_SKILLS_DIR,
|
MENTOR_SKILLS_DIR,
|
||||||
PRIVATE_MENTOR_SKILLS_DIR,
|
PRIVATE_MENTOR_SKILLS_DIR,
|
||||||
lambda: self.token,
|
|
||||||
)
|
)
|
||||||
self.data_gateway = self.container.data_gateway
|
self.data_gateway = self.container.data_gateway
|
||||||
self.ifind = self.container.ifind
|
self.ifind = self.container.ifind
|
||||||
@@ -169,6 +154,7 @@ class RequestHandler(
|
|||||||
AlertHttpMixin,
|
AlertHttpMixin,
|
||||||
ReviewHttpMixin,
|
ReviewHttpMixin,
|
||||||
LLMHttpMixin,
|
LLMHttpMixin,
|
||||||
|
HubAdminHttpMixin,
|
||||||
ApplicationHttpDispatchMixin,
|
ApplicationHttpDispatchMixin,
|
||||||
HttpTransportMixin,
|
HttpTransportMixin,
|
||||||
BaseHTTPRequestHandler,
|
BaseHTTPRequestHandler,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from backend.data import DataGateway, build_data_gateway
|
from backend.data import DataGateway, build_data_gateway
|
||||||
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
||||||
@@ -13,8 +12,8 @@ from backend.features.screener.engine import ScreenerEngine
|
|||||||
from backend.features.screener.tracking import StrategyTrackingService
|
from backend.features.screener.tracking import StrategyTrackingService
|
||||||
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
from backend.data.realtime import WebRealtimeAggregator
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
from backend.features.market.charts import MarketChartClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
|
|
||||||
|
|
||||||
@@ -23,13 +22,13 @@ class ApplicationContainer:
|
|||||||
database: ReviewDatabase
|
database: ReviewDatabase
|
||||||
repositories: RepositoryBundle
|
repositories: RepositoryBundle
|
||||||
data_gateway: DataGateway
|
data_gateway: DataGateway
|
||||||
ifind: IfindHttpClient
|
ifind: HubIfindProxy
|
||||||
screener: ScreenerEngine
|
screener: ScreenerEngine
|
||||||
strategy_tracking: StrategyTrackingService
|
strategy_tracking: StrategyTrackingService
|
||||||
alert_service: AlertService
|
alert_service: AlertService
|
||||||
trade_journal: TradeJournalService
|
trade_journal: TradeJournalService
|
||||||
mentor_skills: MentorSkillRegistry
|
mentor_skills: MentorSkillRegistry
|
||||||
realtime_aggregator: WebRealtimeAggregator
|
realtime_aggregator: HubRealtimeProxy
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
jobs: InProcessJobRunner
|
jobs: InProcessJobRunner
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ def build_application_container(
|
|||||||
credentials: dict[str, object],
|
credentials: dict[str, object],
|
||||||
mentor_skills_dir: Path,
|
mentor_skills_dir: Path,
|
||||||
private_mentor_skills_dir: Path,
|
private_mentor_skills_dir: Path,
|
||||||
tushare_token_supplier: Callable[[], str] | None = None,
|
|
||||||
) -> ApplicationContainer:
|
) -> ApplicationContainer:
|
||||||
data_gateway = build_data_gateway(credentials, tushare_token_supplier)
|
data_gateway = build_data_gateway(credentials)
|
||||||
repositories = build_repository_bundle(database)
|
repositories = build_repository_bundle(database)
|
||||||
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
|
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
|
||||||
return ApplicationContainer(
|
return ApplicationContainer(
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
from http.server import ThreadingHTTPServer
|
from http.server import ThreadingHTTPServer
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
"""让 INFO 级结构化日志(含 datahub 影子对比报告)落到容器日志。"""
|
||||||
|
if logging.getLogger().handlers:
|
||||||
|
return
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None:
|
def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None:
|
||||||
|
configure_logging()
|
||||||
if handler_class is None or service is None:
|
if handler_class is None or service is None:
|
||||||
from backend.application import RequestHandler, SERVICE
|
from backend.application import RequestHandler, SERVICE
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ from backend.features.accounts.security import SecretVault
|
|||||||
|
|
||||||
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
|
|
||||||
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
|
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
|
||||||
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
|
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
|
||||||
"ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(),
|
|
||||||
"ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(),
|
|
||||||
"platform_llm_primary_api_key": str(
|
"platform_llm_primary_api_key": str(
|
||||||
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
|
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
|
|||||||
+403
-47
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Callable
|
from threading import Lock
|
||||||
|
from typing import Any, Callable, ClassVar
|
||||||
|
|
||||||
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
||||||
from backend.data.datahub.compare import compare_rows
|
from backend.data.datahub.compare import compare_rows
|
||||||
@@ -16,15 +17,47 @@ from backend.data.datahub.native import (
|
|||||||
yyyymmdd,
|
yyyymmdd,
|
||||||
)
|
)
|
||||||
from backend.data.datahub.redact import redact_text, redact_value
|
from backend.data.datahub.redact import redact_text, redact_value
|
||||||
|
from backend.data.datahub.route_state import LEDGER
|
||||||
from backend.data.datahub.settings import DatahubSettings
|
from backend.data.datahub.settings import DatahubSettings
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
from backend.data.providers.tushare_daily import DailyMarketMixin
|
||||||
|
from backend.data.providers.tushare_dashboard import DashboardMixin
|
||||||
|
from backend.data.providers.tushare_dragon_tiger import DragonTigerMixin
|
||||||
|
from backend.data.providers.tushare_indices import IndexMixin
|
||||||
|
from backend.data.providers.tushare_industries import ShenwanIndustryMixin
|
||||||
|
from backend.data.providers.tushare_sectors import SectorMixin
|
||||||
|
from backend.data.providers.tushare_stocks import StockMixin
|
||||||
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
LOGGER = logging.getLogger("xiaobai.datahub")
|
LOGGER = logging.getLogger("xiaobai.datahub")
|
||||||
ShadowSink = Callable[[dict[str, Any]], None]
|
ShadowSink = Callable[[dict[str, Any]], None]
|
||||||
EMPTY_FAIL_DATASETS = {"stocks", "daily", "index_daily", "valuation", "moneyflow", "auction"}
|
|
||||||
|
|
||||||
|
def _usable_intraday_points(rows: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
points: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
close = float(row.get("close") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
close = 0.0
|
||||||
|
if close <= 0:
|
||||||
|
continue
|
||||||
|
point = dict(row)
|
||||||
|
if "average" not in point and point.get("avg_price") is not None:
|
||||||
|
point["average"] = point.get("avg_price")
|
||||||
|
points.append(point)
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_FAIL_DATASETS = {
|
||||||
|
"stocks", "daily", "index_daily", "valuation", "moneyflow", "auction",
|
||||||
|
"limit_events", "sector_daily",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def looks_like_heaven(module_name: str, filename: str = "") -> bool:
|
def looks_like_heaven(module_name: str, filename: str = "") -> bool:
|
||||||
|
"""问天调用栈识别(诊断用)。问天按数据集依赖接入,不再整栈强制旧链路。"""
|
||||||
path = filename.replace("\\", "/")
|
path = filename.replace("\\", "/")
|
||||||
return module_name.startswith("backend.features.heaven") or "/features/heaven/" in path
|
return module_name.startswith("backend.features.heaven") or "/features/heaven/" in path
|
||||||
|
|
||||||
@@ -87,52 +120,228 @@ class DatahubBridge:
|
|||||||
self._log_failure("status", exc)
|
self._log_failure("status", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def try_intraday(self, code: str) -> dict[str, Any] | None:
|
||||||
|
flags = self.settings.flags("intraday")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.intraday_points(code=code)
|
||||||
|
data = response.data
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise DatahubError("EMPTY", "datahub intraday payload invalid")
|
||||||
|
points = _usable_intraday_points(data.get("points") or [])
|
||||||
|
if not points:
|
||||||
|
raise DatahubError("EMPTY", "datahub intraday empty")
|
||||||
|
if (response.meta or {}).get("stale"):
|
||||||
|
raise DatahubError("STALE", "datahub intraday stale")
|
||||||
|
self._record_route("intraday", "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return {
|
||||||
|
"entity_type": str(data.get("entity_type") or "stock"),
|
||||||
|
"identifier": str(data.get("identifier") or code),
|
||||||
|
"name": str(data.get("name") or ""),
|
||||||
|
"code": str(data.get("code") or code),
|
||||||
|
"trade_date": str(data.get("trade_date") or points[-1].get("date") or ""),
|
||||||
|
"previous_close": float(data.get("previous_close") or 0),
|
||||||
|
"points": points,
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("intraday", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
|
return self._try_quote_rows("quotes", {}, expected_date=trade_date, minimum=200)
|
||||||
|
|
||||||
|
def try_quotes(self, codes: list[str]) -> list[dict[str, Any]] | None:
|
||||||
|
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return self._try_quote_rows("quotes", {"codes": ",".join(cleaned)}, minimum=1)
|
||||||
|
|
||||||
|
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
||||||
|
flags = self.settings.flags("index_quotes")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.index_quotes()
|
||||||
|
rows = [dict(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
if len(rows) < 3:
|
||||||
|
raise DatahubError("EMPTY", "datahub index quotes incomplete")
|
||||||
|
if (response.meta or {}).get("stale"):
|
||||||
|
raise DatahubError("STALE", "datahub index quotes stale")
|
||||||
|
self._record_route(
|
||||||
|
"index_quotes",
|
||||||
|
"datahub",
|
||||||
|
str((response.meta or {}).get("source") or "datahub"),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("index_quotes", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_sector_quote(self, code: str, trade_date: str = "") -> dict[str, Any] | None:
|
||||||
|
flags = self.settings.flags("quotes")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.sector_quote(code, trade_date)
|
||||||
|
data = response.data
|
||||||
|
if not isinstance(data, dict) or not data:
|
||||||
|
raise DatahubError("EMPTY", "datahub sector quote empty")
|
||||||
|
row = dict(data)
|
||||||
|
if (response.meta or {}).get("stale"):
|
||||||
|
row["delayed"] = True
|
||||||
|
row["delay_seconds"] = int((response.meta or {}).get("staleness_seconds") or 0)
|
||||||
|
row["delay_notice"] = str((response.meta or {}).get("delay_notice") or "")
|
||||||
|
self._record_route("quotes", "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return row
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("quotes", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
|
flags = self.settings.flags("limit_events")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.limit_pool(trade_date)
|
||||||
|
rows = [dict(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
if not rows:
|
||||||
|
raise DatahubError("EMPTY", "datahub limit pool empty")
|
||||||
|
self._record_route(
|
||||||
|
"limit_events",
|
||||||
|
"datahub",
|
||||||
|
str((response.meta or {}).get("source") or "datahub"),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("limit_events", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_daily_chart(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
end_date: str,
|
||||||
|
limit: int = 90,
|
||||||
|
dataset: str = "daily",
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
flags = self.settings.flags(dataset)
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
compact_end = yyyymmdd(end_date)
|
||||||
|
if not compact_end:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
start = _shift_yyyymmdd(compact_end, -max(190, int(limit) * 3))
|
||||||
|
if dataset == "index_daily":
|
||||||
|
response = self._paginate(
|
||||||
|
self.client.index_bars,
|
||||||
|
{"code": code, "from": start, "to": compact_end},
|
||||||
|
)
|
||||||
|
elif dataset == "sector_daily":
|
||||||
|
response = self._paginate(
|
||||||
|
self.client.sectors,
|
||||||
|
{"code": code, "from": start, "to": compact_end},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = self._paginate(
|
||||||
|
self.client.daily_bars,
|
||||||
|
{"code": code, "from": start, "to": compact_end, "adjust": "none"},
|
||||||
|
)
|
||||||
|
# Charts can use a partial history window; do not discard usable bars
|
||||||
|
# just because the requested lookback is not fully covered.
|
||||||
|
self._validate_usable(
|
||||||
|
dataset,
|
||||||
|
list(response.data or []),
|
||||||
|
response,
|
||||||
|
require_complete=False,
|
||||||
|
)
|
||||||
|
rows = _chart_bars(list(response.data or []))
|
||||||
|
if not rows:
|
||||||
|
raise DatahubError("EMPTY", f"{dataset} chart empty")
|
||||||
|
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return rows[-max(1, int(limit)):]
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure(dataset, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
||||||
|
self._record_route(dataset, "legacy", source, error)
|
||||||
|
|
||||||
|
def route_snapshot(self) -> list[dict[str, Any]]:
|
||||||
|
return LEDGER.snapshot()
|
||||||
|
|
||||||
|
def _try_quote_rows(
|
||||||
|
self,
|
||||||
|
dataset: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
expected_date: str = "",
|
||||||
|
minimum: int = 1,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
flags = self.settings.flags(dataset)
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.quotes_latest(**params)
|
||||||
|
rows = [_native_quote(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
rows = [item for item in rows if item]
|
||||||
|
want = yyyymmdd(expected_date)
|
||||||
|
if want:
|
||||||
|
dated = [item for item in rows if not item.get("quote_date") or item.get("quote_date") == want]
|
||||||
|
if dated:
|
||||||
|
rows = dated
|
||||||
|
if len(rows) < minimum:
|
||||||
|
raise DatahubError("EMPTY", f"datahub {dataset} empty")
|
||||||
|
stale = bool((response.meta or {}).get("stale"))
|
||||||
|
delay = int((response.meta or {}).get("staleness_seconds") or 0)
|
||||||
|
notice = str((response.meta or {}).get("delay_notice") or "")
|
||||||
|
source = str((response.meta or {}).get("source") or "datahub")
|
||||||
|
if stale:
|
||||||
|
for item in rows:
|
||||||
|
item["delayed"] = True
|
||||||
|
item["delay_seconds"] = delay
|
||||||
|
item["delay_notice"] = notice
|
||||||
|
item["source"] = source
|
||||||
|
self._record_route(dataset, "datahub", source)
|
||||||
|
return rows
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure(dataset, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
api_name: str,
|
api_name: str,
|
||||||
params: dict[str, Any] | None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str,
|
fields: str = "",
|
||||||
legacy_query: Callable[..., list[dict[str, Any]]],
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
if api_name == "rt_sw_k":
|
||||||
|
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||||
dataset = API_TO_DATASET.get(api_name)
|
dataset = API_TO_DATASET.get(api_name)
|
||||||
if not dataset or self.heaven_guard():
|
if dataset:
|
||||||
return legacy_query(api_name, params, fields)
|
|
||||||
flags = self.settings.flags(dataset)
|
flags = self.settings.flags(dataset)
|
||||||
if not flags.read and not flags.shadow:
|
if flags.read:
|
||||||
return legacy_query(api_name, params, fields)
|
|
||||||
|
|
||||||
hub_rows: list[dict[str, Any]] | None = None
|
|
||||||
hub_meta: dict[str, Any] = {}
|
|
||||||
hub_error: str | None = None
|
|
||||||
hub_canonical: list[dict[str, Any]] = []
|
|
||||||
try:
|
try:
|
||||||
response = self._fetch_dataset(dataset, params or {})
|
response = self._fetch_dataset(dataset, params or {}, api_name=api_name)
|
||||||
hub_canonical = self._extract_rows(dataset, response, params or {})
|
hub_canonical = self._extract_rows(dataset, response, params or {})
|
||||||
hub_rows = to_native_rows(dataset, hub_canonical)
|
hub_rows = to_native_rows(dataset, hub_canonical)
|
||||||
hub_meta = dict(response.meta)
|
|
||||||
self._validate_usable(dataset, hub_rows, response)
|
self._validate_usable(dataset, hub_rows, response)
|
||||||
|
self._record_route(dataset, "datahub", str(response.meta.get("source") or "datahub"))
|
||||||
|
return project_fields(hub_rows, fields)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
hub_error = self._error_text(exc)
|
|
||||||
self._log_failure(dataset, exc)
|
self._log_failure(dataset, exc)
|
||||||
|
|
||||||
if flags.shadow:
|
|
||||||
try:
|
try:
|
||||||
legacy_rows = legacy_query(api_name, params, fields)
|
response = self.client.query_api(api_name, params or {}, fields)
|
||||||
|
rows = [dict(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
if dataset:
|
||||||
|
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
else:
|
||||||
|
self._record_route(api_name, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return rows if not fields else project_fields(rows, fields)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
self._log_failure(dataset or api_name, exc)
|
||||||
self._emit_shadow(compare_rows(dataset, [], hub_canonical, hub_meta, self._error_text(exc)))
|
raise TushareError(self._error_text(exc)) from exc
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
raise
|
|
||||||
self._emit_shadow(compare_rows(dataset, legacy_rows, hub_canonical, hub_meta, hub_error))
|
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
return legacy_rows
|
|
||||||
|
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
def _fetch_dataset(self, dataset: str, params: dict[str, Any], api_name: str = "") -> DatahubResponse:
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
return legacy_query(api_name, params, fields)
|
|
||||||
|
|
||||||
def _fetch_dataset(self, dataset: str, params: dict[str, Any]) -> DatahubResponse:
|
|
||||||
date = yyyymmdd(params.get("trade_date") or params.get("date"))
|
date = yyyymmdd(params.get("trade_date") or params.get("date"))
|
||||||
start = yyyymmdd(params.get("start_date") or params.get("from") or date)
|
start = yyyymmdd(params.get("start_date") or params.get("from") or date)
|
||||||
end = yyyymmdd(params.get("end_date") or params.get("to") or date)
|
end = yyyymmdd(params.get("end_date") or params.get("to") or date)
|
||||||
@@ -149,6 +358,10 @@ class DatahubBridge:
|
|||||||
"valuation": self.client.valuation,
|
"valuation": self.client.valuation,
|
||||||
"moneyflow": self.client.moneyflow,
|
"moneyflow": self.client.moneyflow,
|
||||||
"auction": self.client.auction,
|
"auction": self.client.auction,
|
||||||
|
"limit_events": self.client.limit_events,
|
||||||
|
"popularity": self.client.popularity,
|
||||||
|
"dragon_tiger": self.client.dragon_tiger,
|
||||||
|
"sector_daily": self.client.sectors,
|
||||||
}
|
}
|
||||||
fetcher = fetchers[dataset]
|
fetcher = fetchers[dataset]
|
||||||
query: dict[str, Any] = {}
|
query: dict[str, Any] = {}
|
||||||
@@ -163,6 +376,23 @@ class DatahubBridge:
|
|||||||
query["to"] = end
|
query["to"] = end
|
||||||
if dataset == "daily":
|
if dataset == "daily":
|
||||||
query["adjust"] = "none"
|
query["adjust"] = "none"
|
||||||
|
if dataset == "limit_events":
|
||||||
|
limit_type = str(params.get("limit_type") or "").strip().upper()
|
||||||
|
if limit_type:
|
||||||
|
query["limit_type"] = limit_type
|
||||||
|
if dataset == "popularity":
|
||||||
|
if api_name == "ths_hot":
|
||||||
|
query["source"] = "ths"
|
||||||
|
elif api_name == "dc_hot":
|
||||||
|
query["source"] = "dc"
|
||||||
|
if dataset == "sector_daily":
|
||||||
|
family = {
|
||||||
|
"ths_daily": "ths",
|
||||||
|
"dc_index": "dc",
|
||||||
|
"sw_daily": "sw",
|
||||||
|
}.get(api_name, "")
|
||||||
|
if family:
|
||||||
|
query["family"] = family
|
||||||
return self._paginate(fetcher, query)
|
return self._paginate(fetcher, query)
|
||||||
|
|
||||||
def _paginate(self, fetcher: Callable[..., DatahubResponse], params: dict[str, Any]) -> DatahubResponse:
|
def _paginate(self, fetcher: Callable[..., DatahubResponse], params: dict[str, Any]) -> DatahubResponse:
|
||||||
@@ -199,13 +429,23 @@ class DatahubBridge:
|
|||||||
return filter_stock_rows(rows, params)
|
return filter_stock_rows(rows, params)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def _validate_usable(self, dataset: str, rows: list[dict[str, Any]], response: DatahubResponse) -> None:
|
def _validate_usable(
|
||||||
|
self,
|
||||||
|
dataset: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
response: DatahubResponse,
|
||||||
|
require_complete: bool = True,
|
||||||
|
) -> None:
|
||||||
meta = response.meta or {}
|
meta = response.meta or {}
|
||||||
stale_seconds = int(meta.get("staleness_seconds") or 0)
|
stale_seconds = int(meta.get("staleness_seconds") or 0)
|
||||||
if meta.get("stale") or stale_seconds > self.settings.stale_seconds_max:
|
if meta.get("stale") or stale_seconds > self.settings.stale_seconds_max:
|
||||||
raise DatahubError("STALE", f"{dataset} data is stale")
|
raise DatahubError("STALE", f"{dataset} data is stale")
|
||||||
if dataset in EMPTY_FAIL_DATASETS and not rows:
|
if dataset in EMPTY_FAIL_DATASETS and not rows:
|
||||||
raise DatahubError("EMPTY", f"{dataset} returned no rows")
|
raise DatahubError("EMPTY", f"{dataset} returned no rows")
|
||||||
|
coverage = meta.get("coverage") if isinstance(meta.get("coverage"), dict) else {}
|
||||||
|
if require_complete and (meta.get("incomplete") is True or coverage.get("complete") is False):
|
||||||
|
missing = coverage.get("missing_count")
|
||||||
|
raise DatahubError("INCOMPLETE", f"{dataset} range is incomplete missing={missing}")
|
||||||
|
|
||||||
def _require_fresh(self, response: DatahubResponse, dataset: str) -> DatahubResponse:
|
def _require_fresh(self, response: DatahubResponse, dataset: str) -> DatahubResponse:
|
||||||
self._validate_usable(dataset, list(response.data or []) if isinstance(response.data, list) else [], response)
|
self._validate_usable(dataset, list(response.data or []) if isinstance(response.data, list) else [], response)
|
||||||
@@ -218,11 +458,12 @@ class DatahubBridge:
|
|||||||
self.shadow_sink(report)
|
self.shadow_sink(report)
|
||||||
|
|
||||||
def _log_failure(self, dataset: str, exc: Exception) -> None:
|
def _log_failure(self, dataset: str, exc: Exception) -> None:
|
||||||
LOGGER.warning(
|
error = redact_text(self._error_text(exc), self.settings.secrets())
|
||||||
"datahub fallback dataset=%s error=%s",
|
LOGGER.warning("datahub unavailable dataset=%s error=%s", dataset, error)
|
||||||
dataset,
|
self._record_route(dataset, "datahub", "unavailable", error)
|
||||||
redact_text(self._error_text(exc), self.settings.secrets()),
|
|
||||||
)
|
def _record_route(self, dataset: str, route: str, source: str = "", error: str = "") -> None:
|
||||||
|
LEDGER.record(dataset, route, source, redact_text(error, self.settings.secrets()))
|
||||||
|
|
||||||
def _error_text(self, exc: Exception) -> str:
|
def _error_text(self, exc: Exception) -> str:
|
||||||
if isinstance(exc, DatahubError):
|
if isinstance(exc, DatahubError):
|
||||||
@@ -232,10 +473,110 @@ class DatahubBridge:
|
|||||||
return redact_text(text, self.settings.secrets())
|
return redact_text(text, self.settings.secrets())
|
||||||
|
|
||||||
|
|
||||||
class DatahubAwareTushareClient:
|
def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None:
|
ts_code = str(row.get("ts_code") or "").strip()
|
||||||
self._legacy = legacy
|
close = _finite(row.get("close") if row.get("close") not in (None, "") else row.get("price"))
|
||||||
self._bridge = bridge
|
previous = _finite(
|
||||||
|
row.get("pre_close") if row.get("pre_close") not in (None, "") else row.get("previous_close")
|
||||||
|
)
|
||||||
|
if not ts_code or close <= 0 or previous <= 0:
|
||||||
|
return None
|
||||||
|
volume = _finite(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume"))
|
||||||
|
payload = {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": str(row.get("name") or ts_code).strip(),
|
||||||
|
"pre_close": previous,
|
||||||
|
"open": _finite(row.get("open")),
|
||||||
|
"high": _finite(row.get("high")),
|
||||||
|
"low": _finite(row.get("low")),
|
||||||
|
"close": close,
|
||||||
|
"vol": volume,
|
||||||
|
"amount": _finite(row.get("amount")),
|
||||||
|
"num": 0,
|
||||||
|
"quote_date": yyyymmdd(row.get("quote_date") or row.get("trade_date")),
|
||||||
|
"source": str(row.get("source") or "datahub"),
|
||||||
|
}
|
||||||
|
if row.get("delayed"):
|
||||||
|
payload["delayed"] = True
|
||||||
|
payload["delay_seconds"] = int(row.get("delay_seconds") or 0)
|
||||||
|
payload["delay_notice"] = str(row.get("delay_notice") or "")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _chart_bars(rows: list[Any]) -> list[dict[str, Any]]:
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
compact = yyyymmdd(row.get("trade_date"))
|
||||||
|
close = _finite(row.get("close"))
|
||||||
|
if len(compact) != 8 or close <= 0:
|
||||||
|
continue
|
||||||
|
volume = _finite(row.get("volume") if row.get("volume") not in (None, "") else row.get("vol"))
|
||||||
|
amount = _finite(row.get("amount"))
|
||||||
|
if volume and volume < close * 10 and amount > 1000:
|
||||||
|
volume = volume * 100
|
||||||
|
trade_date = f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
|
||||||
|
previous = normalized[-1]["close"] if normalized else 0.0
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"open": _finite(row.get("open")),
|
||||||
|
"high": _finite(row.get("high")),
|
||||||
|
"low": _finite(row.get("low")),
|
||||||
|
"close": close,
|
||||||
|
"change": round((close / previous - 1) * 100, 4) if previous else _finite(row.get("pct_chg")),
|
||||||
|
"volume": volume,
|
||||||
|
"amount_billion": amount / 100_000_000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_yyyymmdd(value: str, days: int) -> str:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
stamp = datetime.strptime(value, "%Y%m%d")
|
||||||
|
return (stamp + timedelta(days=days)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _finite(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class DatahubAwareTushareClient(
|
||||||
|
DashboardMixin,
|
||||||
|
IndexMixin,
|
||||||
|
ShenwanIndustryMixin,
|
||||||
|
SectorMixin,
|
||||||
|
DragonTigerMixin,
|
||||||
|
StockMixin,
|
||||||
|
DailyMarketMixin,
|
||||||
|
):
|
||||||
|
"""Website market facade. Mixins call query(); query talks only to the hub."""
|
||||||
|
|
||||||
|
_realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_realtime_reference_lock: ClassVar[Lock] = Lock()
|
||||||
|
_capital_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_listing_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||||
|
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||||
|
_suspension_lock: ClassVar[Lock] = Lock()
|
||||||
|
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||||
|
|
||||||
|
def __init__(self, first: Any, second: Any | None = None) -> None:
|
||||||
|
# Production: DatahubAwareTushareClient(bridge)
|
||||||
|
# Older tests: DatahubAwareTushareClient(unused_legacy, bridge)
|
||||||
|
self._bridge = second if second is not None else first
|
||||||
|
self.token = "datahub"
|
||||||
|
self.timeout = 30
|
||||||
|
self.realtime_aggregator = None
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
@@ -243,7 +584,22 @@ class DatahubAwareTushareClient:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str = "",
|
fields: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return self._bridge.query(api_name, params, fields, self._legacy.query)
|
return self._bridge.query(api_name, params, fields)
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
return getattr(self._legacy, name)
|
return self._bridge.try_market_quotes(trade_date)
|
||||||
|
|
||||||
|
def try_quotes(self, codes: list[str]) -> list[dict[str, Any]] | None:
|
||||||
|
return self._bridge.try_quotes(codes)
|
||||||
|
|
||||||
|
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
||||||
|
return self._bridge.try_index_quotes()
|
||||||
|
|
||||||
|
def try_sector_quote(self, code: str, trade_date: str = "") -> dict[str, Any] | None:
|
||||||
|
return self._bridge.try_sector_quote(code, trade_date)
|
||||||
|
|
||||||
|
def try_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
|
return self._bridge.try_limit_pool(trade_date)
|
||||||
|
|
||||||
|
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
||||||
|
self._bridge.record_legacy(dataset, source, error)
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ class DatahubClient:
|
|||||||
def auction(self, **params: Any) -> DatahubResponse:
|
def auction(self, **params: Any) -> DatahubResponse:
|
||||||
return self.get("/v1/auction", params)
|
return self.get("/v1/auction", params)
|
||||||
|
|
||||||
|
def limit_events(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/limit-events", params)
|
||||||
|
|
||||||
|
def popularity(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/popularity", params)
|
||||||
|
|
||||||
|
def dragon_tiger(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/dragon-tiger", params)
|
||||||
|
|
||||||
|
def sectors(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/sectors", params)
|
||||||
|
|
||||||
|
def quotes_latest(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/quotes/latest", params)
|
||||||
|
|
||||||
|
def index_quotes(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/indexes/quotes", params)
|
||||||
|
|
||||||
|
def intraday_points(self, **params: Any) -> DatahubResponse:
|
||||||
|
return self.get("/v1/intraday/points", params)
|
||||||
|
|
||||||
def dataset_status(self, date: str) -> DatahubResponse:
|
def dataset_status(self, date: str) -> DatahubResponse:
|
||||||
return self.get("/v1/datasets/status", {"date": date})
|
return self.get("/v1/datasets/status", {"date": date})
|
||||||
|
|
||||||
@@ -69,6 +90,24 @@ class DatahubClient:
|
|||||||
params["dataset"] = dataset
|
params["dataset"] = dataset
|
||||||
return self.get("/v1/batches", params)
|
return self.get("/v1/batches", params)
|
||||||
|
|
||||||
|
def query_api(self, api_name: str, params: dict[str, Any] | None = None, fields: str = "") -> DatahubResponse:
|
||||||
|
return self.post(
|
||||||
|
"/v1/query",
|
||||||
|
{"api_name": api_name, "params": params or {}, "fields": fields},
|
||||||
|
)
|
||||||
|
|
||||||
|
def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
|
||||||
|
payload: dict[str, Any] = {"code": code}
|
||||||
|
if date:
|
||||||
|
payload["date"] = date
|
||||||
|
return self.get("/v1/sectors/quote", payload)
|
||||||
|
|
||||||
|
def limit_pool(self, trade_date: str = "") -> DatahubResponse:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if trade_date:
|
||||||
|
params["date"] = trade_date
|
||||||
|
return self.get("/v1/limit-pool", params)
|
||||||
|
|
||||||
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
if not self.settings.token:
|
if not self.settings.token:
|
||||||
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
||||||
@@ -97,15 +136,41 @@ class DatahubClient:
|
|||||||
)
|
)
|
||||||
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
||||||
|
|
||||||
def _request(self, url: str) -> DatahubResponse:
|
def post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
request = urllib.request.Request(
|
if not self.settings.token:
|
||||||
url,
|
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
||||||
|
url = self.settings.base_url + path
|
||||||
|
attempts = 1 + max(0, self.settings.retries)
|
||||||
|
last_error: DatahubError | None = None
|
||||||
|
payload = json.dumps(body or {}, ensure_ascii=False).encode("utf-8")
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
return self._request(url, method="POST", data=payload)
|
||||||
|
except DatahubError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if exc.code not in {"TIMEOUT", "UNAVAILABLE"} or attempt + 1 >= attempts:
|
||||||
|
raise
|
||||||
|
LOGGER.warning(
|
||||||
|
"datahub retry %s/%s %s",
|
||||||
|
attempt + 1,
|
||||||
|
attempts,
|
||||||
|
redact_text(str(exc), self.settings.secrets()),
|
||||||
|
)
|
||||||
|
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
||||||
|
|
||||||
|
def _request(self, url: str, method: str = "GET", data: bytes | None = None) -> DatahubResponse:
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"X-Datahub-Token": self.settings.token,
|
"X-Datahub-Token": self.settings.token,
|
||||||
"User-Agent": "XiaobaiReviewDatahub/1.0",
|
"User-Agent": "XiaobaiReviewDatahub/1.0",
|
||||||
},
|
}
|
||||||
method="GET",
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
|
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from backend.data.datahub.native import SCALE_FIELDS, row_key, to_canonical_row, yyyymmdd
|
from backend.data.datahub.native import SCALE_FIELDS, row_key, to_canonical_row, yyyymmdd
|
||||||
|
|
||||||
NUMERIC_TOLERANCE = 1e-4
|
NUMERIC_TOLERANCE = 1e-4
|
||||||
|
CANONICAL_ALIASES = {"volume": "vol"}
|
||||||
|
|
||||||
|
|
||||||
def compare_rows(
|
def compare_rows(
|
||||||
@@ -13,8 +14,10 @@ def compare_rows(
|
|||||||
hub_rows: list[dict[str, Any]] | None,
|
hub_rows: list[dict[str, Any]] | None,
|
||||||
hub_meta: dict[str, Any] | None = None,
|
hub_meta: dict[str, Any] | None = None,
|
||||||
hub_error: str | None = None,
|
hub_error: str | None = None,
|
||||||
|
fields: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
hub = hub_rows or []
|
hub = hub_rows or []
|
||||||
|
requested = _requested_fields(fields)
|
||||||
legacy_map = {row_key(dataset, row): row for row in legacy_rows}
|
legacy_map = {row_key(dataset, row): row for row in legacy_rows}
|
||||||
hub_map = {row_key(dataset, _align_hub_row(row)): row for row in hub}
|
hub_map = {row_key(dataset, _align_hub_row(row)): row for row in hub}
|
||||||
missing_hub = sorted(key for key in legacy_map if key not in hub_map)
|
missing_hub = sorted(key for key in legacy_map if key not in hub_map)
|
||||||
@@ -26,7 +29,7 @@ def compare_rows(
|
|||||||
hub_row = hub_map.get(key)
|
hub_row = hub_map.get(key)
|
||||||
if hub_row is None:
|
if hub_row is None:
|
||||||
continue
|
continue
|
||||||
field_report = _compare_fields(dataset, legacy, hub_row)
|
field_report = _compare_fields(dataset, legacy, hub_row, requested)
|
||||||
if field_report["unit_conversion"]:
|
if field_report["unit_conversion"]:
|
||||||
unit_conversion.append({"key": list(key), "fields": field_report["unit_conversion"]})
|
unit_conversion.append({"key": list(key), "fields": field_report["unit_conversion"]})
|
||||||
if field_report["value_diff"]:
|
if field_report["value_diff"]:
|
||||||
@@ -53,6 +56,7 @@ def compare_rows(
|
|||||||
"published_at": (hub_meta or {}).get("published_at"),
|
"published_at": (hub_meta or {}).get("published_at"),
|
||||||
"trade_date": yyyymmdd((hub_meta or {}).get("trade_date")),
|
"trade_date": yyyymmdd((hub_meta or {}).get("trade_date")),
|
||||||
"hub_error": hub_error,
|
"hub_error": hub_error,
|
||||||
|
"fields_compared": sorted(requested) if requested is not None else None,
|
||||||
"equal": (
|
"equal": (
|
||||||
not hub_error
|
not hub_error
|
||||||
and not missing_hub
|
and not missing_hub
|
||||||
@@ -71,13 +75,36 @@ def _align_hub_row(row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return aligned
|
return aligned
|
||||||
|
|
||||||
|
|
||||||
def _compare_fields(dataset: str, legacy: dict[str, Any], hub: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
|
def _requested_fields(fields: str) -> list[str] | None:
|
||||||
|
"""Fields the website actually asked for; None means "no projection"."""
|
||||||
|
keys = [item.strip() for item in str(fields or "").split(",") if item.strip()]
|
||||||
|
if not keys:
|
||||||
|
return None
|
||||||
|
seen: list[str] = []
|
||||||
|
for key in keys:
|
||||||
|
canonical = CANONICAL_ALIASES.get(key, key)
|
||||||
|
if canonical not in seen:
|
||||||
|
seen.append(canonical)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_fields(
|
||||||
|
dataset: str,
|
||||||
|
legacy: dict[str, Any],
|
||||||
|
hub: dict[str, Any],
|
||||||
|
requested: list[str] | None = None,
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
canonical_legacy = to_canonical_row(dataset, legacy)
|
canonical_legacy = to_canonical_row(dataset, legacy)
|
||||||
hub_canonical = _hub_canonical(dataset, hub)
|
hub_canonical = _hub_canonical(dataset, hub)
|
||||||
native_hub = _align_hub_row(hub)
|
native_hub = _align_hub_row(hub)
|
||||||
value_diff: list[dict[str, Any]] = []
|
value_diff: list[dict[str, Any]] = []
|
||||||
unit_conversion: list[dict[str, Any]] = []
|
unit_conversion: list[dict[str, Any]] = []
|
||||||
keys = (set(canonical_legacy) | set(hub_canonical)) - {"batch_id", "updated_at", "volume"}
|
keys = (set(canonical_legacy) | set(hub_canonical)) - {"batch_id", "updated_at", "volume"}
|
||||||
|
if requested is not None:
|
||||||
|
# Compare only what the website asked for. Extra hub columns are
|
||||||
|
# transport detail, not business differences; a requested field still
|
||||||
|
# alarms when it is missing or holds a different value.
|
||||||
|
keys = set(requested) - {"batch_id", "updated_at", "volume"}
|
||||||
scales = SCALE_FIELDS.get(dataset) or {}
|
scales = SCALE_FIELDS.get(dataset) or {}
|
||||||
for field in sorted(keys):
|
for field in sorted(keys):
|
||||||
left = canonical_legacy.get(field)
|
left = canonical_legacy.get(field)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.datahub.bridge import DatahubBridge
|
||||||
|
from backend.data.datahub.errors import DatahubError
|
||||||
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
|
class HubIfindProxy:
|
||||||
|
"""Website-facing iFinD facade. Talks only to xiaobai-datahub."""
|
||||||
|
|
||||||
|
def __init__(self, datahub: DatahubBridge) -> None:
|
||||||
|
self._datahub = datahub
|
||||||
|
self._status: dict[str, Any] | None = None
|
||||||
|
self._status_at = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.status().get("configured"))
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._status is not None and now - self._status_at < 30:
|
||||||
|
return dict(self._status)
|
||||||
|
fallback = {"configured": False, "access_ready": False, "access_expires_at": ""}
|
||||||
|
if not self._datahub.settings.token:
|
||||||
|
self._status = fallback
|
||||||
|
self._status_at = now
|
||||||
|
return dict(fallback)
|
||||||
|
try:
|
||||||
|
rows = self._rows("ifind_status", {})
|
||||||
|
except IfindError:
|
||||||
|
self._status = fallback
|
||||||
|
self._status_at = now
|
||||||
|
return dict(fallback)
|
||||||
|
row = rows[0] if rows else {}
|
||||||
|
status = {
|
||||||
|
"configured": bool(row.get("configured")),
|
||||||
|
"access_ready": bool(row.get("access_ready")),
|
||||||
|
"access_expires_at": str(row.get("access_expires_at") or ""),
|
||||||
|
}
|
||||||
|
self._status = status
|
||||||
|
self._status_at = now
|
||||||
|
return dict(status)
|
||||||
|
|
||||||
|
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_wencai",
|
||||||
|
{"query": query, "search_type": search_type, "cache_ttl": cache_ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshots(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 8,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_snapshots",
|
||||||
|
{
|
||||||
|
"codes": codes,
|
||||||
|
"indicators": indicators,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def history(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
cache_ttl: int = 300,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_history",
|
||||||
|
{
|
||||||
|
"codes": codes,
|
||||||
|
"indicators": indicators,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def real_time(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
cache_ttl: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_realtime",
|
||||||
|
{"codes": codes, "indicators": indicators, "cache_ttl": cache_ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
def intraday(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_intraday",
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connection(self) -> dict[str, Any]:
|
||||||
|
payload = self.real_time(
|
||||||
|
"000001.SH",
|
||||||
|
["open", "high", "low", "latest", "preClose"],
|
||||||
|
cache_ttl=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": bool(payload),
|
||||||
|
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = self._datahub.client.query_api(api_name, params)
|
||||||
|
except DatahubError as exc:
|
||||||
|
raise IfindError(str(exc) or "iFinD 数据中枢暂不可用") from exc
|
||||||
|
data = response.data
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [dict(item) for item in data if isinstance(item, dict)]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [dict(data)]
|
||||||
|
return []
|
||||||
@@ -17,6 +17,13 @@ API_TO_DATASET = {
|
|||||||
"index_daily": "index_daily",
|
"index_daily": "index_daily",
|
||||||
"moneyflow": "moneyflow",
|
"moneyflow": "moneyflow",
|
||||||
"stk_auction": "auction",
|
"stk_auction": "auction",
|
||||||
|
"limit_list_d": "limit_events",
|
||||||
|
"ths_hot": "popularity",
|
||||||
|
"dc_hot": "popularity",
|
||||||
|
"hm_detail": "dragon_tiger",
|
||||||
|
"ths_daily": "sector_daily",
|
||||||
|
"dc_index": "sector_daily",
|
||||||
|
"sw_daily": "sector_daily",
|
||||||
}
|
}
|
||||||
|
|
||||||
SCALE_FIELDS = {
|
SCALE_FIELDS = {
|
||||||
@@ -35,6 +42,16 @@ SCALE_FIELDS = {
|
|||||||
"net_mf_amount": AMOUNT_WAN_YUAN,
|
"net_mf_amount": AMOUNT_WAN_YUAN,
|
||||||
},
|
},
|
||||||
"auction": {"vol": VOLUME_LOT, "float_share": AMOUNT_WAN_YUAN},
|
"auction": {"vol": VOLUME_LOT, "float_share": AMOUNT_WAN_YUAN},
|
||||||
|
"limit_events": {
|
||||||
|
"limit_amount": AMOUNT_WAN_YUAN,
|
||||||
|
"float_mv": AMOUNT_WAN_YUAN,
|
||||||
|
"total_mv": AMOUNT_WAN_YUAN,
|
||||||
|
},
|
||||||
|
"dragon_tiger": {
|
||||||
|
"buy_amount": AMOUNT_WAN_YUAN,
|
||||||
|
"sell_amount": AMOUNT_WAN_YUAN,
|
||||||
|
"net_amount": AMOUNT_WAN_YUAN,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +84,16 @@ def to_native_row(dataset: str, row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
converted[field] = _unscale(converted.get(field), factor)
|
converted[field] = _unscale(converted.get(field), factor)
|
||||||
if dataset == "stocks":
|
if dataset == "stocks":
|
||||||
converted.pop("updated_at", None)
|
converted.pop("updated_at", None)
|
||||||
|
if dataset == "popularity":
|
||||||
|
# keep hub source; callers filter ths/dc themselves when needed
|
||||||
|
if converted.get("ts_name") and not converted.get("name"):
|
||||||
|
converted["name"] = converted.get("ts_name")
|
||||||
|
if dataset == "dragon_tiger":
|
||||||
|
if converted.get("ts_name") and not converted.get("name"):
|
||||||
|
converted["name"] = converted.get("ts_name")
|
||||||
|
if dataset == "sector_daily":
|
||||||
|
if converted.get("pct_change") is not None and converted.get("pct_chg") is None:
|
||||||
|
converted["pct_chg"] = converted.get("pct_change")
|
||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +123,30 @@ def row_key(dataset: str, row: dict[str, Any]) -> tuple[str, ...]:
|
|||||||
return (str(row.get("ts_code") or "").upper(),)
|
return (str(row.get("ts_code") or "").upper(),)
|
||||||
if dataset == "status":
|
if dataset == "status":
|
||||||
return (str(row.get("dataset") or ""), yyyymmdd(row.get("trade_date")))
|
return (str(row.get("dataset") or ""), yyyymmdd(row.get("trade_date")))
|
||||||
|
if dataset == "limit_events":
|
||||||
|
return (
|
||||||
|
str(row.get("ts_code") or "").upper(),
|
||||||
|
yyyymmdd(row.get("trade_date")),
|
||||||
|
str(row.get("limit_type") or ""),
|
||||||
|
)
|
||||||
|
if dataset == "popularity":
|
||||||
|
return (
|
||||||
|
str(row.get("ts_code") or "").upper(),
|
||||||
|
yyyymmdd(row.get("trade_date")),
|
||||||
|
str(row.get("source") or ""),
|
||||||
|
)
|
||||||
|
if dataset == "dragon_tiger":
|
||||||
|
return (
|
||||||
|
str(row.get("ts_code") or "").upper(),
|
||||||
|
yyyymmdd(row.get("trade_date")),
|
||||||
|
str(row.get("hm_name") or ""),
|
||||||
|
)
|
||||||
|
if dataset == "sector_daily":
|
||||||
|
return (
|
||||||
|
str(row.get("ts_code") or "").upper(),
|
||||||
|
yyyymmdd(row.get("trade_date")),
|
||||||
|
str(row.get("family") or ""),
|
||||||
|
)
|
||||||
return (str(row.get("ts_code") or "").upper(), yyyymmdd(row.get("trade_date")))
|
return (str(row.get("ts_code") or "").upper(), yyyymmdd(row.get("trade_date")))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.datahub.bridge import DatahubBridge
|
||||||
|
from backend.data.realtime import RealtimeAggregateError
|
||||||
|
|
||||||
|
|
||||||
|
class HubRealtimeProxy:
|
||||||
|
"""Realtime observation facade. Talks only to xiaobai-datahub."""
|
||||||
|
|
||||||
|
def __init__(self, datahub: DatahubBridge) -> None:
|
||||||
|
self._datahub = datahub
|
||||||
|
|
||||||
|
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
|
||||||
|
started = datetime.now().astimezone()
|
||||||
|
indices: list[dict[str, Any]] = []
|
||||||
|
error = ""
|
||||||
|
try:
|
||||||
|
indices = self.tencent_indices()
|
||||||
|
except RealtimeAggregateError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
epochs = [int(item.get("quote_time_epoch") or 0) for item in indices]
|
||||||
|
max_skew = 120
|
||||||
|
index_consistent = bool(epochs) and max(epochs) - min(epochs) <= max_skew
|
||||||
|
ready = len(indices) == 3 and index_consistent
|
||||||
|
return {
|
||||||
|
"ready": ready,
|
||||||
|
"isolated": True,
|
||||||
|
"generated_at": started.isoformat(timespec="seconds"),
|
||||||
|
"elapsed_ms": 0,
|
||||||
|
"indices": indices,
|
||||||
|
"index_consistent": index_consistent,
|
||||||
|
"sector": None,
|
||||||
|
"sources": {
|
||||||
|
"datahub_indices": {
|
||||||
|
"ok": ready,
|
||||||
|
"error": error,
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observations": {},
|
||||||
|
"policy": {
|
||||||
|
"integration": "datahub_exclusive",
|
||||||
|
"max_index_time_skew_seconds": max_skew,
|
||||||
|
"notice": "实时观察只走数据中枢,主网站不再直连东财/腾讯。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self._datahub.try_index_quotes() or []
|
||||||
|
result = [_as_index(item) for item in rows if _as_index(item)]
|
||||||
|
wanted = {"000001", "399001", "399006"}
|
||||||
|
result = [item for item in result if item.get("code") in wanted]
|
||||||
|
result.sort(key=lambda item: str(item.get("code") or ""))
|
||||||
|
if len(result) != 3:
|
||||||
|
raise RealtimeAggregateError(f"datahub returned {len(result)}/3 indices")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def eastmoney_indices(self) -> list[dict[str, Any]]:
|
||||||
|
return self.tencent_indices()
|
||||||
|
|
||||||
|
def tencent_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
return self._stock_quote(code, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
return self._stock_quote(code, expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
minimum: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._stock_quotes(codes, expected_date, minimum)
|
||||||
|
|
||||||
|
def eastmoney_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._stock_quotes(codes, expected_date, None)
|
||||||
|
|
||||||
|
def eastmoney_shenwan_quote(self, ts_code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
quote = self._datahub.try_sector_quote(ts_code, expected_date)
|
||||||
|
if not quote:
|
||||||
|
raise RealtimeAggregateError(f"datahub shenwan quote unavailable for {ts_code}")
|
||||||
|
return quote
|
||||||
|
|
||||||
|
def _stock_quote(self, code: str, expected_date: str) -> dict[str, Any]:
|
||||||
|
rows = self._stock_quotes([code], expected_date, 1)
|
||||||
|
if not rows:
|
||||||
|
raise RealtimeAggregateError(f"datahub stock quote unavailable for {code}")
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
def _stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str,
|
||||||
|
minimum: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
|
||||||
|
rows = self._datahub.try_quotes(cleaned) if cleaned else (self._datahub.try_market_quotes(expected_date) or [])
|
||||||
|
quotes = [_as_stock(item) for item in (rows or []) if _as_stock(item)]
|
||||||
|
if expected_date:
|
||||||
|
compact = str(expected_date).replace("-", "")
|
||||||
|
quotes = [
|
||||||
|
item
|
||||||
|
for item in quotes
|
||||||
|
if not item.get("quote_date") or str(item.get("quote_date") or "").replace("-", "") == compact
|
||||||
|
]
|
||||||
|
if minimum is not None and len(quotes) < minimum:
|
||||||
|
raise RealtimeAggregateError(f"datahub returned {len(quotes)} quotes, need {minimum}")
|
||||||
|
return quotes
|
||||||
|
|
||||||
|
|
||||||
|
def _as_index(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
code = str(row.get("code") or str(row.get("ts_code") or "").split(".")[0] or "")
|
||||||
|
price = _number(row.get("price") if row.get("price") not in (None, "") else row.get("close"))
|
||||||
|
if not code or price <= 0:
|
||||||
|
return None
|
||||||
|
epoch = int(_number(row.get("quote_time_epoch")))
|
||||||
|
amount = _number(row.get("amount_billion"))
|
||||||
|
if amount <= 0:
|
||||||
|
amount = round(_number(row.get("amount")) / 100_000_000, 2)
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"name": row.get("name") or code,
|
||||||
|
"price": price,
|
||||||
|
"change": _number(row.get("change") if row.get("change") not in (None, "") else row.get("pct_chg")),
|
||||||
|
"change_amount": _number(row.get("change_amount")),
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"previous_close": _number(
|
||||||
|
row.get("previous_close") if row.get("previous_close") not in (None, "") else row.get("pre_close")
|
||||||
|
),
|
||||||
|
"amount_billion": amount,
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"source": str(row.get("source") or "datahub"),
|
||||||
|
"cache_age_seconds": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_stock(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
close = _number(row.get("close") if row.get("close") not in (None, "") else row.get("price"))
|
||||||
|
if close <= 0:
|
||||||
|
return None
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
code = str(row.get("code") or ts_code.split(".")[0] or "")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code or code,
|
||||||
|
"code": code,
|
||||||
|
"name": row.get("name") or "",
|
||||||
|
"close": close,
|
||||||
|
"pre_close": _number(
|
||||||
|
row.get("pre_close") if row.get("pre_close") not in (None, "") else row.get("previous_close")
|
||||||
|
),
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"volume": _number(row.get("volume") if row.get("volume") not in (None, "") else row.get("vol")),
|
||||||
|
"vol": _number(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume")),
|
||||||
|
"amount": _number(row.get("amount")),
|
||||||
|
"quote_time_epoch": int(_number(row.get("quote_time_epoch"))),
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"quote_date": str(row.get("quote_date") or ""),
|
||||||
|
"source": str(row.get("source") or "datahub"),
|
||||||
|
"delayed": bool(row.get("delayed")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.datahub.settings import DATASETS
|
||||||
|
|
||||||
|
DATASET_LABELS = {
|
||||||
|
"calendar": "交易日历",
|
||||||
|
"stocks": "股票主档",
|
||||||
|
"daily": "个股日K",
|
||||||
|
"index_daily": "指数日K",
|
||||||
|
"valuation": "估值",
|
||||||
|
"moneyflow": "资金流",
|
||||||
|
"auction": "竞价",
|
||||||
|
"limit_events": "涨停池",
|
||||||
|
"popularity": "人气榜",
|
||||||
|
"dragon_tiger": "龙虎榜",
|
||||||
|
"sector_daily": "题材板块",
|
||||||
|
"quotes": "全市场实时行情",
|
||||||
|
"index_quotes": "指数实时行情",
|
||||||
|
"intraday": "分时",
|
||||||
|
"status": "数据集状态",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DatahubRouteLedger:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = Lock()
|
||||||
|
self._rows: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def record(self, dataset: str, route: str, source: str = "", error: str = "") -> None:
|
||||||
|
name = str(dataset or "").strip() or "unknown"
|
||||||
|
with self._lock:
|
||||||
|
self._rows[name] = {
|
||||||
|
"dataset": name,
|
||||||
|
"label": DATASET_LABELS.get(name, name),
|
||||||
|
"route": "legacy" if route == "legacy" else "datahub",
|
||||||
|
"source": str(source or "").strip(),
|
||||||
|
"error": str(error or "").strip(),
|
||||||
|
"at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
rows = [dict(item) for item in self._rows.values()]
|
||||||
|
order = {name: index for index, name in enumerate(DATASETS)}
|
||||||
|
rows.sort(key=lambda item: (order.get(str(item.get("dataset")), 99), str(item.get("dataset"))))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._rows.clear()
|
||||||
|
|
||||||
|
|
||||||
|
LEDGER = DatahubRouteLedger()
|
||||||
@@ -17,6 +17,13 @@ DATASETS = (
|
|||||||
"valuation",
|
"valuation",
|
||||||
"moneyflow",
|
"moneyflow",
|
||||||
"auction",
|
"auction",
|
||||||
|
"limit_events",
|
||||||
|
"popularity",
|
||||||
|
"dragon_tiger",
|
||||||
|
"sector_daily",
|
||||||
|
"quotes",
|
||||||
|
"index_quotes",
|
||||||
|
"intraday",
|
||||||
"status",
|
"status",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,6 +35,13 @@ ENV_DATASET = {
|
|||||||
"valuation": "VALUATION",
|
"valuation": "VALUATION",
|
||||||
"moneyflow": "MONEYFLOW",
|
"moneyflow": "MONEYFLOW",
|
||||||
"auction": "AUCTION",
|
"auction": "AUCTION",
|
||||||
|
"limit_events": "LIMIT_EVENTS",
|
||||||
|
"popularity": "POPULARITY",
|
||||||
|
"dragon_tiger": "DRAGON_TIGER",
|
||||||
|
"sector_daily": "SECTOR_DAILY",
|
||||||
|
"quotes": "QUOTES",
|
||||||
|
"index_quotes": "INDEX_QUOTES",
|
||||||
|
"intraday": "INTRADAY",
|
||||||
"status": "STATUS",
|
"status": "STATUS",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-23
@@ -1,43 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.contracts import DataUsage
|
from backend.data.contracts import DataUsage
|
||||||
from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, DatahubClient, DatahubSettings
|
from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, DatahubClient, DatahubSettings
|
||||||
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import IfindProvider, TushareProvider
|
from backend.data.providers import IfindProvider
|
||||||
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
|
||||||
from backend.data.realtime import WebRealtimeAggregator
|
|
||||||
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DataGateway:
|
class DataGateway:
|
||||||
policy: DataSourcePolicy
|
policy: DataSourcePolicy
|
||||||
quality: DataQualityGate
|
quality: DataQualityGate
|
||||||
tushare_provider: TushareProvider
|
|
||||||
ifind_provider: IfindProvider
|
ifind_provider: IfindProvider
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
realtime_observer: WebRealtimeAggregator
|
realtime_observer: HubRealtimeProxy
|
||||||
datahub: DatahubBridge
|
datahub: DatahubBridge
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ifind(self) -> IfindHttpClient:
|
def ifind(self) -> HubIfindProxy:
|
||||||
return self.ifind_provider.client
|
return self.ifind_provider.client
|
||||||
|
|
||||||
def tushare(
|
def tushare(
|
||||||
self,
|
self,
|
||||||
dataset_id: str = "",
|
dataset_id: str = "",
|
||||||
usage: DataUsage = "calculation",
|
usage: DataUsage = "calculation",
|
||||||
) -> TushareClient:
|
) -> DatahubAwareTushareClient:
|
||||||
if dataset_id:
|
if dataset_id:
|
||||||
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
||||||
return DatahubAwareTushareClient(self.tushare_provider.client(), self.datahub)
|
return DatahubAwareTushareClient(self.datahub)
|
||||||
|
|
||||||
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
||||||
return self.datahub.dataset_status(trade_date)
|
return self.datahub.dataset_status(trade_date)
|
||||||
@@ -45,6 +42,31 @@ class DataGateway:
|
|||||||
def batches(self, trade_date: str, dataset: str = "") -> list[dict[str, Any]] | None:
|
def batches(self, trade_date: str, dataset: str = "") -> list[dict[str, Any]] | None:
|
||||||
return self.datahub.batches(trade_date, dataset)
|
return self.datahub.batches(trade_date, dataset)
|
||||||
|
|
||||||
|
def datahub_status(self) -> dict[str, Any]:
|
||||||
|
from backend.data.datahub.route_state import DATASET_LABELS, LEDGER
|
||||||
|
from backend.data.datahub.settings import DATASETS
|
||||||
|
|
||||||
|
settings = self.datahub.settings
|
||||||
|
flags = []
|
||||||
|
enabled = 0
|
||||||
|
for name in DATASETS:
|
||||||
|
read = bool(settings.flags(name).read)
|
||||||
|
if read:
|
||||||
|
enabled += 1
|
||||||
|
flags.append({"dataset": name, "label": DATASET_LABELS.get(name, name), "read": read})
|
||||||
|
routes = LEDGER.snapshot()
|
||||||
|
fallbacks = [item for item in routes if item.get("route") == "legacy"]
|
||||||
|
return {
|
||||||
|
"configured": bool(settings.token and settings.base_url),
|
||||||
|
"base_url": settings.base_url,
|
||||||
|
"enabled_reads": enabled,
|
||||||
|
"total_reads": len(DATASETS),
|
||||||
|
"flags": flags,
|
||||||
|
"routes": routes,
|
||||||
|
"fallback_count": len(fallbacks),
|
||||||
|
"fallback_labels": [str(item.get("label") or item.get("dataset")) for item in fallbacks],
|
||||||
|
}
|
||||||
|
|
||||||
def assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None:
|
def assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None:
|
||||||
self.policy.assert_allowed(dataset_id, provider_id, usage)
|
self.policy.assert_allowed(dataset_id, provider_id, usage)
|
||||||
|
|
||||||
@@ -72,25 +94,18 @@ class DataGateway:
|
|||||||
|
|
||||||
def build_data_gateway(
|
def build_data_gateway(
|
||||||
credentials: dict[str, object],
|
credentials: dict[str, object],
|
||||||
tushare_token_supplier: Callable[[], str] | None = None,
|
|
||||||
datahub_settings: DatahubSettings | None = None,
|
datahub_settings: DatahubSettings | None = None,
|
||||||
) -> DataGateway:
|
) -> DataGateway:
|
||||||
ifind = IfindHttpClient(
|
|
||||||
str(credentials.get("ifind_refresh_token") or ""),
|
|
||||||
str(credentials.get("ifind_access_token") or ""),
|
|
||||||
)
|
|
||||||
token_supplier = tushare_token_supplier or (
|
|
||||||
lambda: str(credentials.get("tushare_token") or "")
|
|
||||||
)
|
|
||||||
policy = DataSourcePolicy.load()
|
policy = DataSourcePolicy.load()
|
||||||
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
||||||
datahub_client = DatahubClient(settings)
|
datahub_client = DatahubClient(settings)
|
||||||
|
datahub = DatahubBridge(settings, datahub_client)
|
||||||
|
ifind = HubIfindProxy(datahub)
|
||||||
return DataGateway(
|
return DataGateway(
|
||||||
policy=policy,
|
policy=policy,
|
||||||
quality=DataQualityGate.load(policy),
|
quality=DataQualityGate.load(policy),
|
||||||
tushare_provider=TushareProvider(token_supplier),
|
|
||||||
ifind_provider=IfindProvider(ifind),
|
ifind_provider=IfindProvider(ifind),
|
||||||
chart_data=MarketChartClient(ifind, EastmoneyChartClient()),
|
chart_data=MarketChartClient(datahub),
|
||||||
realtime_observer=WebRealtimeAggregator(),
|
realtime_observer=HubRealtimeProxy(datahub),
|
||||||
datahub=DatahubBridge(settings, datahub_client),
|
datahub=datahub,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class IfindProvider:
|
class IfindProvider:
|
||||||
def __init__(self, client: IfindHttpClient) -> None:
|
def __init__(self, client: Any) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
|
||||||
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
||||||
self.client.set_credentials(refresh_token, access_token)
|
setter = getattr(self.client, "set_credentials", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(refresh_token, access_token)
|
||||||
|
|||||||
@@ -66,3 +66,5 @@ class TushareClient(
|
|||||||
_stock_listing_lock: ClassVar[Lock] = Lock()
|
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||||
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||||
_suspension_lock: ClassVar[Lock] = Lock()
|
_suspension_lock: ClassVar[Lock] = Lock()
|
||||||
|
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
from backend.data.providers.tushare_helpers import _display_time, _prices_equal
|
from backend.data.providers.tushare_helpers import (
|
||||||
|
_display_time,
|
||||||
|
_optional_number,
|
||||||
|
_prices_equal,
|
||||||
|
calendar_is_open,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DailyMarketMixin:
|
class DailyMarketMixin:
|
||||||
@@ -17,7 +22,11 @@ class DailyMarketMixin:
|
|||||||
trade_date = requested
|
trade_date = requested
|
||||||
else:
|
else:
|
||||||
row = requested_rows[0]
|
row = requested_rows[0]
|
||||||
trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested)
|
trade_date = (
|
||||||
|
row["cal_date"]
|
||||||
|
if calendar_is_open(row.get("is_open"))
|
||||||
|
else row.get("pretrade_date", requested)
|
||||||
|
)
|
||||||
|
|
||||||
resolved_rows = self.query(
|
resolved_rows = self.query(
|
||||||
"trade_cal",
|
"trade_cal",
|
||||||
@@ -129,7 +138,75 @@ class DailyMarketMixin:
|
|||||||
)
|
)
|
||||||
item["capital_trade_date"] = str(capital.get("trade_date") or "")
|
item["capital_trade_date"] = str(capital.get("trade_date") or "")
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
return self._overlay_board_fields(result, trade_date)
|
||||||
|
|
||||||
|
def _overlay_board_fields(
|
||||||
|
self,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
trade_date: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not rows:
|
||||||
|
return rows
|
||||||
|
official = self._official_board_map(trade_date)
|
||||||
|
free = self._free_board_map(trade_date) if not official else {}
|
||||||
|
merged: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("ts_code") or "")
|
||||||
|
extra = official.get(code) or free.get(code) or {}
|
||||||
|
if not extra:
|
||||||
|
merged.append(row)
|
||||||
|
continue
|
||||||
|
item = dict(row)
|
||||||
|
for key in (
|
||||||
|
"first_time",
|
||||||
|
"last_time",
|
||||||
|
"fd_amount",
|
||||||
|
"open_times",
|
||||||
|
"limit_times",
|
||||||
|
"turnover_ratio",
|
||||||
|
):
|
||||||
|
incoming = extra.get(key)
|
||||||
|
current = item.get(key)
|
||||||
|
if incoming in (None, "", "--"):
|
||||||
|
continue
|
||||||
|
# The daily fallback and the board pool can arrive at different
|
||||||
|
# times. Keep the stronger streak instead of freezing the
|
||||||
|
# provisional default at one or letting a stale pool lower it.
|
||||||
|
if key == "limit_times":
|
||||||
|
item[key] = max(
|
||||||
|
1,
|
||||||
|
int(_number(current, 1)),
|
||||||
|
int(_number(incoming, 1)),
|
||||||
|
)
|
||||||
|
elif current in (None, "", "--", 0, 0.0):
|
||||||
|
item[key] = incoming
|
||||||
|
merged.append(item)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _official_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
mapped: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
for row in self._load_limit_lists(trade_date):
|
||||||
|
code = str(row.get("ts_code") or "")
|
||||||
|
if code:
|
||||||
|
mapped[code] = row
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
def _free_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
loader = getattr(self, "try_limit_pool", None)
|
||||||
|
if not callable(loader):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
rows = loader(trade_date) or []
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(row.get("ts_code") or ""): row
|
||||||
|
for row in rows
|
||||||
|
if row.get("ts_code")
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
|
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
|
||||||
@@ -154,7 +231,11 @@ class DailyMarketMixin:
|
|||||||
"turnover_source": row.get("turnover_source") or "provider",
|
"turnover_source": row.get("turnover_source") or "provider",
|
||||||
"capital_trade_date": row.get("capital_trade_date") or "",
|
"capital_trade_date": row.get("capital_trade_date") or "",
|
||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0),
|
"seal_amount_million": (
|
||||||
|
round(fd / 10000, 0)
|
||||||
|
if (fd := _optional_number(row.get("fd_amount"))) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
|
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ from backend.data.providers.tushare_transport import TushareError
|
|||||||
|
|
||||||
|
|
||||||
class DashboardMixin:
|
class DashboardMixin:
|
||||||
|
def _now(self) -> datetime:
|
||||||
|
clock = getattr(self, "clock", None)
|
||||||
|
if callable(clock):
|
||||||
|
return clock()
|
||||||
|
return datetime.now().astimezone()
|
||||||
|
|
||||||
def dashboard(self, requested_date: str) -> dict[str, Any]:
|
def dashboard(self, requested_date: str) -> dict[str, Any]:
|
||||||
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
|
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
|
||||||
if self.should_use_realtime(requested_date, trade_date):
|
if self.should_use_realtime(requested_date, trade_date):
|
||||||
@@ -26,11 +32,12 @@ class DashboardMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
daily = self._load_daily(trade_date)
|
daily = self._load_daily(trade_date)
|
||||||
|
now = self._now()
|
||||||
if (
|
if (
|
||||||
not daily
|
not daily
|
||||||
and requested_date == datetime.now().astimezone().strftime("%Y%m%d")
|
and requested_date == now.strftime("%Y%m%d")
|
||||||
and trade_date == requested_date
|
and trade_date == requested_date
|
||||||
and datetime.now().astimezone().time().replace(tzinfo=None) >= dt_time(9, 15)
|
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
|
||||||
):
|
):
|
||||||
return self._realtime_dashboard(
|
return self._realtime_dashboard(
|
||||||
requested_date,
|
requested_date,
|
||||||
@@ -48,16 +55,24 @@ class DashboardMixin:
|
|||||||
if not limit_rows:
|
if not limit_rows:
|
||||||
limit_data_source = "derived"
|
limit_data_source = "derived"
|
||||||
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
limit_rows = self._derive_limits(
|
||||||
|
trade_date,
|
||||||
|
daily,
|
||||||
|
previous_limit_rows=previous_limit_rows,
|
||||||
|
)
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
limit_data_source = "derived"
|
limit_data_source = "derived"
|
||||||
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
|
||||||
previous_daily = self._load_daily(previous_trade_date)
|
previous_daily = self._load_daily(previous_trade_date)
|
||||||
previous_limit_rows = [
|
previous_limit_rows = [
|
||||||
row for row in self._derive_limits(previous_trade_date, previous_daily)
|
row for row in self._derive_limits(previous_trade_date, previous_daily)
|
||||||
if row.get("limit_type") == "U"
|
if row.get("limit_type") == "U"
|
||||||
]
|
]
|
||||||
|
limit_rows = self._derive_limits(
|
||||||
|
trade_date,
|
||||||
|
daily,
|
||||||
|
previous_limit_rows=previous_limit_rows,
|
||||||
|
)
|
||||||
|
|
||||||
up_rows = [row for row in limit_rows 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"]
|
down_rows = [row for row in limit_rows if row.get("limit_type") == "D"]
|
||||||
@@ -98,15 +113,14 @@ class DashboardMixin:
|
|||||||
}
|
}
|
||||||
return apply_sentiment_to_dashboard(dashboard)
|
return apply_sentiment_to_dashboard(dashboard)
|
||||||
|
|
||||||
@staticmethod
|
def should_use_realtime(self, requested_date: str, trade_date: str) -> bool:
|
||||||
def should_use_realtime(requested_date: str, trade_date: str) -> bool:
|
"""Use live quotes for today's open session until official daily settles."""
|
||||||
"""Use rt_k for today's open market until end-of-day datasets settle."""
|
now = self._now()
|
||||||
now = datetime.now().astimezone()
|
|
||||||
today = now.strftime("%Y%m%d")
|
today = now.strftime("%Y%m%d")
|
||||||
return (
|
return (
|
||||||
requested_date == today
|
requested_date == today
|
||||||
and trade_date == today
|
and trade_date == today
|
||||||
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30)
|
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _realtime_dashboard(
|
def _realtime_dashboard(
|
||||||
@@ -122,7 +136,7 @@ class DashboardMixin:
|
|||||||
)
|
)
|
||||||
if not codes:
|
if not codes:
|
||||||
raise TushareError("No active stock codes available for rt_k")
|
raise TushareError("No active stock codes available for rt_k")
|
||||||
quotes = self.query("rt_k", {"ts_code": codes})
|
quotes, quote_source = self._load_realtime_quotes(codes, trade_date)
|
||||||
if not quotes:
|
if not quotes:
|
||||||
raise TushareError(f"No realtime data returned for {trade_date}")
|
raise TushareError(f"No realtime data returned for {trade_date}")
|
||||||
|
|
||||||
@@ -178,14 +192,35 @@ class DashboardMixin:
|
|||||||
)
|
)
|
||||||
sectors = _build_sectors(limits)
|
sectors = _build_sectors(limits)
|
||||||
previous_sectors = _build_sectors(previous_limits)
|
previous_sectors = _build_sectors(previous_limits)
|
||||||
now = datetime.now().astimezone()
|
now = self._now()
|
||||||
market_status = _realtime_market_status(now.time().replace(tzinfo=None))
|
market_status = _realtime_market_status(now.time().replace(tzinfo=None))
|
||||||
|
if quote_source == "datahub":
|
||||||
|
notice = (
|
||||||
|
"盘中行情由数据中枢统一提供;涨停原因、封板时间和开板次数以盘后榜单校正为准。"
|
||||||
|
)
|
||||||
|
source_name = "datahub"
|
||||||
|
elif quote_source == "eastmoney_clist":
|
||||||
|
notice = (
|
||||||
|
"盘中行情由东财免费实时快照计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。"
|
||||||
|
)
|
||||||
|
source_name = "eastmoney"
|
||||||
|
elif quote_source == "tencent_qt":
|
||||||
|
notice = (
|
||||||
|
"盘中行情由腾讯免费实时行情计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。"
|
||||||
|
)
|
||||||
|
source_name = "tencent"
|
||||||
|
else:
|
||||||
|
notice = (
|
||||||
|
"盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。"
|
||||||
|
)
|
||||||
|
source_name = "tushare"
|
||||||
dashboard = {
|
dashboard = {
|
||||||
"meta": {
|
"meta": {
|
||||||
"requested_date": _display_date(requested_date),
|
"requested_date": _display_date(requested_date),
|
||||||
"trade_date": _display_date(trade_date),
|
"trade_date": _display_date(trade_date),
|
||||||
"previous_trade_date": _display_date(previous_trade_date),
|
"previous_trade_date": _display_date(previous_trade_date),
|
||||||
"source": "tushare",
|
"source": source_name,
|
||||||
|
"quote_source": quote_source,
|
||||||
"mode": "realtime",
|
"mode": "realtime",
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
"market_status": market_status,
|
"market_status": market_status,
|
||||||
@@ -193,7 +228,8 @@ class DashboardMixin:
|
|||||||
"auto_refresh": False,
|
"auto_refresh": False,
|
||||||
"quote_count": len(daily),
|
"quote_count": len(daily),
|
||||||
"updated_at": now.isoformat(timespec="seconds"),
|
"updated_at": now.isoformat(timespec="seconds"),
|
||||||
"notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
|
"notice": notice,
|
||||||
|
"indices": self._free_realtime_indices() if quote_source != "tushare_rt_k" else [],
|
||||||
},
|
},
|
||||||
"overview": _build_overview(daily, up_rows, down_rows, broken_rows),
|
"overview": _build_overview(daily, up_rows, down_rows, broken_rows),
|
||||||
"limits": limits,
|
"limits": limits,
|
||||||
@@ -207,6 +243,62 @@ class DashboardMixin:
|
|||||||
}
|
}
|
||||||
return apply_sentiment_to_dashboard(dashboard)
|
return apply_sentiment_to_dashboard(dashboard)
|
||||||
|
|
||||||
|
def _realtime_aggregator(self):
|
||||||
|
aggregator = getattr(self, "realtime_aggregator", None)
|
||||||
|
if aggregator is None:
|
||||||
|
raise TushareError("免费实时源未配置")
|
||||||
|
return aggregator
|
||||||
|
|
||||||
|
def _load_realtime_quotes(
|
||||||
|
self,
|
||||||
|
codes: str,
|
||||||
|
trade_date: str,
|
||||||
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
hub = getattr(self, "try_market_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
quotes = hub(trade_date)
|
||||||
|
if quotes:
|
||||||
|
return list(quotes), "datahub"
|
||||||
|
named = getattr(self, "try_quotes", None)
|
||||||
|
code_list = [item for item in str(codes or "").split(",") if item]
|
||||||
|
if callable(named) and code_list:
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(code_list), 60):
|
||||||
|
collected.extend(named(code_list[index:index + 60]) or [])
|
||||||
|
if collected:
|
||||||
|
delayed = any(item.get("delayed") for item in collected)
|
||||||
|
return collected, "datahub_delayed" if delayed else "datahub"
|
||||||
|
try:
|
||||||
|
quotes = self.query("rt_k", {"ts_code": codes})
|
||||||
|
if quotes:
|
||||||
|
delayed = any(item.get("delayed") for item in quotes)
|
||||||
|
return list(quotes), "datahub_delayed" if delayed else "datahub"
|
||||||
|
except TushareError as exc:
|
||||||
|
raise TushareError(f"当天盘中实时行情不可用:{exc}") from exc
|
||||||
|
raise TushareError("当天盘中实时行情不可用:数据中枢未返回可用行情")
|
||||||
|
|
||||||
|
def _mark_quote_legacy(self, source: str, error: str = "") -> None:
|
||||||
|
marker = getattr(self, "record_datahub_legacy", None)
|
||||||
|
if callable(marker):
|
||||||
|
marker("quotes", source, error)
|
||||||
|
|
||||||
|
def _free_realtime_quotes(
|
||||||
|
self,
|
||||||
|
trade_date: str,
|
||||||
|
codes: str = "",
|
||||||
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
del trade_date, codes
|
||||||
|
raise TushareError("主网站不再直连免费行情源,请走数据中枢")
|
||||||
|
|
||||||
|
def _free_realtime_indices(self) -> list[dict[str, Any]]:
|
||||||
|
hub = getattr(self, "try_index_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
rows = hub()
|
||||||
|
converted = [item for item in (_hub_index_quote(row) for row in rows or []) if item]
|
||||||
|
if converted:
|
||||||
|
return converted
|
||||||
|
return []
|
||||||
|
|
||||||
def _load_realtime_reference(
|
def _load_realtime_reference(
|
||||||
self,
|
self,
|
||||||
trade_date: str,
|
trade_date: str,
|
||||||
@@ -234,7 +326,7 @@ class DashboardMixin:
|
|||||||
{"trade_date": previous_trade_date},
|
{"trade_date": previous_trade_date},
|
||||||
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
|
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
|
||||||
)
|
)
|
||||||
if not basic_rows or not price_limits:
|
if not basic_rows:
|
||||||
raise TushareError(f"Realtime reference data is incomplete for {trade_date}")
|
raise TushareError(f"Realtime reference data is incomplete for {trade_date}")
|
||||||
result = {
|
result = {
|
||||||
"basic_rows": basic_rows,
|
"basic_rows": basic_rows,
|
||||||
@@ -254,10 +346,9 @@ class DashboardMixin:
|
|||||||
ts_code: str,
|
ts_code: str,
|
||||||
reference_date: str = "",
|
reference_date: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
rows = self.query("rt_k", {"ts_code": ts_code})
|
row = self._realtime_quote_row(ts_code, reference_date)
|
||||||
if not rows:
|
if not row:
|
||||||
raise TushareError(f"No realtime quote returned for {ts_code}")
|
raise TushareError(f"No realtime quote returned for {ts_code}")
|
||||||
row = rows[0]
|
|
||||||
close = _number(row.get("close"))
|
close = _number(row.get("close"))
|
||||||
previous_close = _number(row.get("pre_close"))
|
previous_close = _number(row.get("pre_close"))
|
||||||
if close <= 0 or previous_close <= 0:
|
if close <= 0 or previous_close <= 0:
|
||||||
@@ -285,6 +376,11 @@ class DashboardMixin:
|
|||||||
basic = basics[0] if basics else {}
|
basic = basics[0] if basics else {}
|
||||||
capital = self._latest_capital(ts_code, reference_date)
|
capital = self._latest_capital(ts_code, reference_date)
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
|
if not float_share and previous_close:
|
||||||
|
# The hub's canonical valuation table stores market values but not
|
||||||
|
# share counts. circ_mv (10k CNY) / price (CNY) is float shares in
|
||||||
|
# 10k shares, the same unit used by the turnover formula below.
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous_close
|
||||||
# rt_k volume is shares; daily_basic float_share is reported in 10k shares.
|
# 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
|
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")
|
market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d")
|
||||||
@@ -306,6 +402,13 @@ class DashboardMixin:
|
|||||||
for item in market_rows:
|
for item in market_rows:
|
||||||
item_capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
item_capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
item_float_share = _number(item_capital.get("float_share"))
|
item_float_share = _number(item_capital.get("float_share"))
|
||||||
|
item_previous = _number(
|
||||||
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not item_float_share and item_previous:
|
||||||
|
item_float_share = _number(item_capital.get("circ_mv")) / item_previous
|
||||||
if item_float_share:
|
if item_float_share:
|
||||||
market_turnovers.append(_number(item.get("vol")) / item_float_share / 100)
|
market_turnovers.append(_number(item.get("vol")) / item_float_share / 100)
|
||||||
market_turnover = (
|
market_turnover = (
|
||||||
@@ -317,6 +420,10 @@ class DashboardMixin:
|
|||||||
market_date,
|
market_date,
|
||||||
_number(row.get("vol")) / 100,
|
_number(row.get("vol")) / 100,
|
||||||
)
|
)
|
||||||
|
trade_time = str(row.get("trade_time") or row.get("quote_time") or "")
|
||||||
|
trade_date = str(row.get("quote_date") or "").replace("-", "")[:8]
|
||||||
|
if not trade_date and trade_time:
|
||||||
|
trade_date = trade_time[:10].replace("-", "")
|
||||||
return {
|
return {
|
||||||
"code": ts_code.split(".")[0],
|
"code": ts_code.split(".")[0],
|
||||||
"ts_code": ts_code,
|
"ts_code": ts_code,
|
||||||
@@ -341,10 +448,26 @@ class DashboardMixin:
|
|||||||
"float_share_10k": float_share,
|
"float_share_10k": float_share,
|
||||||
"capital_trade_date": str(capital.get("trade_date") or ""),
|
"capital_trade_date": str(capital.get("trade_date") or ""),
|
||||||
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
|
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
|
||||||
"data_source": "tushare",
|
"data_source": str(row.get("source") or "tushare"),
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"trade_time": trade_time,
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _realtime_quote_row(self, ts_code: str, reference_date: str = "") -> dict[str, Any]:
|
||||||
|
hub = getattr(self, "try_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
rows = hub([ts_code]) or []
|
||||||
|
if rows:
|
||||||
|
return dict(rows[0])
|
||||||
|
try:
|
||||||
|
rows = self.query("rt_k", {"ts_code": ts_code})
|
||||||
|
if rows:
|
||||||
|
return dict(rows[0])
|
||||||
|
except TushareError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
def _stock_activity_metrics(
|
def _stock_activity_metrics(
|
||||||
self,
|
self,
|
||||||
ts_code: str,
|
ts_code: str,
|
||||||
@@ -364,7 +487,10 @@ class DashboardMixin:
|
|||||||
{
|
{
|
||||||
"ts_code": ts_code,
|
"ts_code": ts_code,
|
||||||
"start_date": (end - timedelta(days=30)).strftime("%Y%m%d"),
|
"start_date": (end - timedelta(days=30)).strftime("%Y%m%d"),
|
||||||
"end_date": reference_date,
|
# Intraday bars are not official daily history yet. Asking
|
||||||
|
# the hub for today's daily row makes a complete historical
|
||||||
|
# range look incomplete and discards otherwise valid data.
|
||||||
|
"end_date": (end - timedelta(days=1)).strftime("%Y%m%d"),
|
||||||
},
|
},
|
||||||
"ts_code,trade_date,vol,amount",
|
"ts_code,trade_date,vol,amount",
|
||||||
)
|
)
|
||||||
@@ -462,7 +588,7 @@ class DashboardMixin:
|
|||||||
for row in reference.get("basic_rows") or []
|
for row in reference.get("basic_rows") or []
|
||||||
if row.get("ts_code")
|
if row.get("ts_code")
|
||||||
]
|
]
|
||||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
quotes, quote_source = self._load_realtime_quotes(",".join(codes), trade_date)
|
||||||
rows = [
|
rows = [
|
||||||
row for row in quotes
|
row for row in quotes
|
||||||
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
||||||
@@ -489,7 +615,13 @@ class DashboardMixin:
|
|||||||
start_date = (end - timedelta(days=20)).strftime("%Y%m%d")
|
start_date = (end - timedelta(days=20)).strftime("%Y%m%d")
|
||||||
rows = self.query(
|
rows = self.query(
|
||||||
"daily_basic",
|
"daily_basic",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": end_date},
|
{
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"start_date": start_date,
|
||||||
|
# Same rule as price history: today's official valuation is
|
||||||
|
# unavailable during the session, so use the latest prior row.
|
||||||
|
"end_date": (end - timedelta(days=1)).strftime("%Y%m%d"),
|
||||||
|
},
|
||||||
"ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share,"
|
"ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share,"
|
||||||
"free_share,total_mv,circ_mv",
|
"free_share,total_mv,circ_mv",
|
||||||
)
|
)
|
||||||
@@ -608,6 +740,31 @@ def _build_yesterday_performance(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _hub_index_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
code = str(row.get("code") or ts_code.split(".")[0])
|
||||||
|
close = _number(row.get("price") if row.get("price") not in (None, "") else row.get("close"))
|
||||||
|
previous = _number(
|
||||||
|
row.get("previous_close") if row.get("previous_close") not in (None, "") else row.get("pre_close")
|
||||||
|
)
|
||||||
|
if close <= 0 or previous <= 0:
|
||||||
|
return None
|
||||||
|
amount = _number(row.get("amount"))
|
||||||
|
amount_billion = _number(row.get("amount_billion"))
|
||||||
|
if not amount_billion and amount:
|
||||||
|
amount_billion = round(amount / 100_000_000, 2)
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"name": str(row.get("name") or code),
|
||||||
|
"price": close,
|
||||||
|
"change": _number(row.get("pct_chg") if row.get("pct_chg") not in (None, "") else row.get("change")),
|
||||||
|
"previous_close": previous,
|
||||||
|
"amount_billion": amount_billion,
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _build_limit_performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
result = []
|
result = []
|
||||||
for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True):
|
for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True):
|
||||||
|
|||||||
@@ -6,12 +6,72 @@ from typing import Any
|
|||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
|
|
||||||
|
|
||||||
|
def calendar_is_open(value: Any) -> bool:
|
||||||
|
if value in (True, 1, "1", "Y", "y"):
|
||||||
|
return True
|
||||||
|
if value in (False, 0, "0", "N", "n", None, ""):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(value) == 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any) -> str:
|
def _text(value: Any) -> str:
|
||||||
if isinstance(value, (list, tuple, set)):
|
if isinstance(value, (list, tuple, set)):
|
||||||
return "、".join(str(item).strip() for item in value if str(item).strip())
|
return "、".join(str(item).strip() for item in value if str(item).strip())
|
||||||
return str(value or "").strip()
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_number(value: Any) -> float | None:
|
||||||
|
if value in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
number = _number(value, default=float("nan"))
|
||||||
|
if number != number:
|
||||||
|
return None
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _moneyflow_payload(flow: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not flow:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"net_million": None,
|
||||||
|
"large_million": None,
|
||||||
|
"medium_million": None,
|
||||||
|
"small_million": None,
|
||||||
|
}
|
||||||
|
net = _optional_number(flow.get("net_mf_amount"))
|
||||||
|
buy_lg = _optional_number(flow.get("buy_lg_amount"))
|
||||||
|
sell_lg = _optional_number(flow.get("sell_lg_amount"))
|
||||||
|
buy_elg = _optional_number(flow.get("buy_elg_amount"))
|
||||||
|
sell_elg = _optional_number(flow.get("sell_elg_amount"))
|
||||||
|
buy_md = _optional_number(flow.get("buy_md_amount"))
|
||||||
|
sell_md = _optional_number(flow.get("sell_md_amount"))
|
||||||
|
buy_sm = _optional_number(flow.get("buy_sm_amount"))
|
||||||
|
sell_sm = _optional_number(flow.get("sell_sm_amount"))
|
||||||
|
large = None
|
||||||
|
if None not in (buy_lg, sell_lg, buy_elg, sell_elg):
|
||||||
|
large = (buy_lg + buy_elg - sell_lg - sell_elg)
|
||||||
|
elif _optional_number(flow.get("large_amount")) is not None:
|
||||||
|
large = _optional_number(flow.get("large_amount"))
|
||||||
|
medium = None if None in (buy_md, sell_md) else (buy_md - sell_md)
|
||||||
|
if medium is None:
|
||||||
|
medium = _optional_number(flow.get("medium_amount"))
|
||||||
|
small = None if None in (buy_sm, sell_sm) else (buy_sm - sell_sm)
|
||||||
|
if small is None:
|
||||||
|
small = _optional_number(flow.get("small_amount"))
|
||||||
|
if net is None and large is None and medium is None and small is None:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"net_million": None if net is None else round(net / 100, 2),
|
||||||
|
"large_million": None if large is None else round(large / 100, 2),
|
||||||
|
"medium_million": None if medium is None else round(medium / 100, 2),
|
||||||
|
"small_million": None if small is None else round(small / 100, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _prices_equal(left: Any, right: Any) -> bool:
|
def _prices_equal(left: Any, right: Any) -> bool:
|
||||||
if left is None or right is None:
|
if left is None or right is None:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -59,6 +59,73 @@ class IndexMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
def realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
||||||
|
hub = getattr(self, "try_index_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
rows = hub()
|
||||||
|
if rows:
|
||||||
|
return self._hub_realtime_market_indices(requested_date, rows)
|
||||||
|
raise TushareError("Realtime index quotes are incomplete")
|
||||||
|
|
||||||
|
def _hub_realtime_market_indices(
|
||||||
|
self,
|
||||||
|
requested_date: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
trade_date, _ = self.resolve_trade_context(requested_date)
|
||||||
|
index_names = {
|
||||||
|
"000001.SH": "上证指数",
|
||||||
|
"399001.SZ": "深证成指",
|
||||||
|
"399006.SZ": "创业板指",
|
||||||
|
}
|
||||||
|
by_code = {str(row.get("ts_code") or ""): row for row in rows}
|
||||||
|
by_symbol = {str(row.get("code") or ""): row for row in rows}
|
||||||
|
indices = []
|
||||||
|
for ts_code, name in index_names.items():
|
||||||
|
row = by_code.get(ts_code) or by_symbol.get(ts_code.split(".")[0])
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
close = _number(row.get("price") if row.get("price") not in (None, "") else row.get("close"))
|
||||||
|
previous_close = _number(
|
||||||
|
row.get("previous_close") if row.get("previous_close") not in (None, "") else row.get("pre_close")
|
||||||
|
)
|
||||||
|
if close <= 0 or previous_close <= 0:
|
||||||
|
continue
|
||||||
|
amount = _number(row.get("amount"))
|
||||||
|
amount_billion = _number(row.get("amount_billion"))
|
||||||
|
if not amount_billion and amount:
|
||||||
|
amount_billion = round(amount / 100_000_000, 2)
|
||||||
|
indices.append(
|
||||||
|
{
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": str(row.get("name") or name).strip(),
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"close": close,
|
||||||
|
"pct_chg": round(
|
||||||
|
_number(row.get("pct_chg")) or (close / previous_close - 1) * 100,
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
"return_5d": 0,
|
||||||
|
"amount_billion": amount_billion,
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(indices) != 3:
|
||||||
|
raise TushareError("Realtime index quotes are incomplete")
|
||||||
|
return {
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"source": "datahub",
|
||||||
|
"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": 0,
|
||||||
|
"average_return_20d": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _tushare_realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
||||||
trade_date, _ = self.resolve_trade_context(requested_date)
|
trade_date, _ = self.resolve_trade_context(requested_date)
|
||||||
index_names = {
|
index_names = {
|
||||||
"000001.SH": "上证指数",
|
"000001.SH": "上证指数",
|
||||||
@@ -116,3 +183,7 @@ class IndexMixin:
|
|||||||
"average_return_20d": 0,
|
"average_return_20d": 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _free_realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
||||||
|
del requested_date
|
||||||
|
raise TushareError("主网站不再直连免费行情源,请走数据中枢")
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
from backend.data.providers.tushare_transport import TushareError
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
|
_SW_MEMBER_TTL = timedelta(hours=24)
|
||||||
|
_SW_MEMBER_DIR = Path(__file__).resolve().parents[3] / "data" / "cache" / "sw_members"
|
||||||
|
|
||||||
|
|
||||||
class ShenwanIndustryMixin:
|
class ShenwanIndustryMixin:
|
||||||
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
||||||
@@ -132,21 +137,73 @@ class ShenwanIndustryMixin:
|
|||||||
actual_trade_date = str(daily.get("trade_date") or "")
|
actual_trade_date = str(daily.get("trade_date") or "")
|
||||||
outer_precise = actual_trade_date == trade_date
|
outer_precise = actual_trade_date == trade_date
|
||||||
outer_error = "" if outer_precise else (
|
outer_error = "" if outer_precise else (
|
||||||
f"No Shenwan daily returned for {sector_code} on {trade_date}"
|
f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||||
)
|
)
|
||||||
|
outer_source = "tushare_sw_daily" if outer_precise else "unavailable"
|
||||||
if not outer_precise and allow_realtime_close:
|
if not outer_precise and allow_realtime_close:
|
||||||
|
inner_ok = bool(member_rows) and not coverage_issue
|
||||||
|
if inner_ok:
|
||||||
|
sw_row, rt_source, rt_error = self._sw_outer_realtime(
|
||||||
|
sector_code,
|
||||||
|
str(industry.get("l2_name") or ""),
|
||||||
|
trade_date,
|
||||||
|
finalized=True,
|
||||||
|
)
|
||||||
|
if sw_row:
|
||||||
|
daily = sw_row
|
||||||
|
actual_trade_date = str(
|
||||||
|
sw_row.get("quote_date") or sw_row.get("trade_date") or ""
|
||||||
|
)
|
||||||
|
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||||||
|
quote_clock = (
|
||||||
|
trade_time[11:19]
|
||||||
|
if len(trade_time) >= 19
|
||||||
|
else str(sw_row.get("quote_clock") or "")
|
||||||
|
)
|
||||||
|
outer_precise = actual_trade_date == trade_date
|
||||||
|
if quote_clock and quote_clock < "15:00:00":
|
||||||
|
outer_precise = False
|
||||||
|
outer_source = rt_source or "eastmoney_sw"
|
||||||
|
outer_error = "" if outer_precise else (
|
||||||
|
rt_error or f"申万行业 {sector_code} 免费实时尚未形成收盘快照"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
outer_error = rt_error or outer_error
|
||||||
|
if not outer_precise:
|
||||||
|
# The exact SW L2 member set and every member's final daily bar
|
||||||
|
# are already available. Use their equal-weight move as an
|
||||||
|
# explicitly labelled provisional outer value until Tushare's
|
||||||
|
# official sw_daily row is published.
|
||||||
|
daily = {"change": equal_change}
|
||||||
|
actual_trade_date = trade_date
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_daily"
|
||||||
|
outer_error = ""
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
return self._sw_realtime_sector_snapshot(
|
snapshot = self._sw_realtime_sector_snapshot(
|
||||||
industry,
|
industry,
|
||||||
members,
|
members,
|
||||||
trade_date,
|
trade_date,
|
||||||
previous_trade_date,
|
previous_trade_date,
|
||||||
finalized=True,
|
finalized=True,
|
||||||
)
|
)
|
||||||
except TushareError as exc:
|
snapshot.update({
|
||||||
outer_error = f"{outer_error}; realtime close fallback failed: {exc}"
|
"raw_member_count": raw_member_count,
|
||||||
|
"excluded_member_count": len(excluded_members),
|
||||||
|
"excluded_members": excluded_members,
|
||||||
|
})
|
||||||
|
return snapshot
|
||||||
|
except TushareError:
|
||||||
|
outer_error = f"{outer_error}; 免费实时成分暂不可用"
|
||||||
|
|
||||||
official_change = _number(daily.get("pct_change")) if outer_precise else None
|
official_change = None
|
||||||
|
if outer_precise:
|
||||||
|
official_change = _number(
|
||||||
|
daily.get("pct_change")
|
||||||
|
if daily.get("pct_change") not in (None, "")
|
||||||
|
else daily.get("change")
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"code": sector_code,
|
"code": sector_code,
|
||||||
"name": industry.get("l2_name") or daily.get("name") or sector_code,
|
"name": industry.get("l2_name") or daily.get("name") or sector_code,
|
||||||
@@ -173,9 +230,9 @@ class ShenwanIndustryMixin:
|
|||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"count": 0,
|
"count": 0,
|
||||||
"max_streak": 0,
|
"max_streak": 0,
|
||||||
"source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily",
|
"source": f"{outer_source}+tushare_member_daily" if outer_precise else "tushare_member_daily",
|
||||||
"inner_source": "tushare_member_daily",
|
"inner_source": "tushare_member_daily",
|
||||||
"outer_source": "tushare_sw_daily" if outer_precise else "unavailable",
|
"outer_source": outer_source,
|
||||||
"taxonomy": "sw_l2",
|
"taxonomy": "sw_l2",
|
||||||
"industry": industry,
|
"industry": industry,
|
||||||
"trade_date": trade_date,
|
"trade_date": trade_date,
|
||||||
@@ -189,7 +246,7 @@ class ShenwanIndustryMixin:
|
|||||||
"inner_error": inner_error,
|
"inner_error": inner_error,
|
||||||
"outer_error": outer_error,
|
"outer_error": outer_error,
|
||||||
"schema_version": 6,
|
"schema_version": 6,
|
||||||
"methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合",
|
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核优先使用当日成分日线,不调用 rt_sw_k",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _sw_sector_members(
|
def _sw_sector_members(
|
||||||
@@ -197,7 +254,11 @@ class ShenwanIndustryMixin:
|
|||||||
sector_code: str,
|
sector_code: str,
|
||||||
trade_date: str,
|
trade_date: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = []
|
cached_rows = self._read_local_sw_members(sector_code)
|
||||||
|
if cached_rows is not None:
|
||||||
|
return _active_members(cached_rows, trade_date)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
for is_new in ("Y", "N"):
|
for is_new in ("Y", "N"):
|
||||||
rows.extend(
|
rows.extend(
|
||||||
self.query(
|
self.query(
|
||||||
@@ -206,14 +267,87 @@ class ShenwanIndustryMixin:
|
|||||||
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
deduped: dict[str, dict[str, Any]] = {}
|
except TushareError:
|
||||||
for row in _reconcile_membership_rows(rows):
|
stale = self._read_local_sw_members(sector_code, allow_stale=True) or []
|
||||||
code = str(row.get("ts_code") or "")
|
if stale:
|
||||||
if code and _membership_active_on(row, trade_date):
|
return _active_members(stale, trade_date)
|
||||||
current = deduped.get(code)
|
raise
|
||||||
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
reconciled = _reconcile_membership_rows(rows)
|
||||||
deduped[code] = row
|
self._write_local_sw_members(sector_code, reconciled)
|
||||||
return list(deduped.values())
|
return _active_members(reconciled, trade_date)
|
||||||
|
|
||||||
|
def _read_local_sw_members(
|
||||||
|
self,
|
||||||
|
sector_code: str,
|
||||||
|
allow_stale: bool = False,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
cache = getattr(self, "_sw_member_cache", None)
|
||||||
|
lock = getattr(self, "_sw_member_lock", None)
|
||||||
|
if isinstance(cache, dict) and lock is not None:
|
||||||
|
with lock:
|
||||||
|
packed = cache.get(sector_code)
|
||||||
|
if isinstance(packed, dict):
|
||||||
|
loaded_at = packed.get("loaded_at")
|
||||||
|
rows = packed.get("rows")
|
||||||
|
fresh = (
|
||||||
|
isinstance(loaded_at, datetime)
|
||||||
|
and now - loaded_at < _SW_MEMBER_TTL
|
||||||
|
)
|
||||||
|
if isinstance(rows, list) and (fresh or allow_stale):
|
||||||
|
return [dict(item) for item in rows]
|
||||||
|
path = _sw_member_path(sector_code)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
rows = list(payload.get("rows") or [])
|
||||||
|
updated = str(payload.get("updated_at") or "")
|
||||||
|
fresh = False
|
||||||
|
try:
|
||||||
|
stamped = datetime.fromisoformat(updated)
|
||||||
|
if stamped.tzinfo is None:
|
||||||
|
stamped = stamped.replace(tzinfo=now.tzinfo)
|
||||||
|
fresh = now - stamped.astimezone(now.tzinfo) < _SW_MEMBER_TTL
|
||||||
|
except ValueError:
|
||||||
|
fresh = False
|
||||||
|
if rows and (fresh or allow_stale):
|
||||||
|
self._remember_sw_members(sector_code, rows)
|
||||||
|
return rows
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_local_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
packed = [dict(item) for item in rows]
|
||||||
|
self._remember_sw_members(sector_code, packed)
|
||||||
|
path = _sw_member_path(sector_code)
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sector_code": sector_code,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"rows": packed,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _remember_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
cache = getattr(self, "_sw_member_cache", None)
|
||||||
|
lock = getattr(self, "_sw_member_lock", None)
|
||||||
|
if not isinstance(cache, dict) or lock is None:
|
||||||
|
return
|
||||||
|
with lock:
|
||||||
|
cache[sector_code] = {
|
||||||
|
"loaded_at": datetime.now().astimezone(),
|
||||||
|
"rows": [dict(item) for item in rows],
|
||||||
|
}
|
||||||
|
|
||||||
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
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 constituents active in a Shenwan L2 industry on the target date."""
|
||||||
@@ -311,37 +445,37 @@ class ShenwanIndustryMixin:
|
|||||||
finalized: bool = False,
|
finalized: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
sector_code = str(industry.get("l2_code") or "")
|
sector_code = str(industry.get("l2_code") or "")
|
||||||
sw_rows = self.query(
|
sw_row, outer_source, outer_error = self._sw_outer_realtime(
|
||||||
"rt_sw_k",
|
sector_code,
|
||||||
{"ts_code": sector_code},
|
str(industry.get("l2_name") or ""),
|
||||||
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
|
trade_date,
|
||||||
|
finalized=finalized,
|
||||||
)
|
)
|
||||||
sw_row = sw_rows[0] if sw_rows else {}
|
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||||||
trade_time = str(sw_row.get("trade_time") or "")
|
quote_date = str(sw_row.get("quote_date") or trade_time[:10].replace("-", ""))
|
||||||
quote_date = trade_time[:10].replace("-", "")
|
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else str(sw_row.get("quote_clock") or "")
|
||||||
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else ""
|
|
||||||
outer_precise = bool(sw_row and quote_date == trade_date)
|
outer_precise = bool(sw_row and quote_date == trade_date)
|
||||||
if finalized and (not quote_clock or quote_clock < "15:00:00"):
|
if finalized and quote_clock and quote_clock < "15:00:00":
|
||||||
outer_precise = False
|
outer_precise = False
|
||||||
official_change = _number(sw_row.get("pct_change"))
|
official_change = _number(sw_row.get("pct_change") if sw_row.get("pct_change") not in (None, "") else sw_row.get("change"))
|
||||||
if not official_change:
|
if not official_change:
|
||||||
close = _number(sw_row.get("close"))
|
close = _number(sw_row.get("close") if sw_row.get("close") not in (None, "") else sw_row.get("price"))
|
||||||
pre_close = _number(sw_row.get("pre_close"))
|
pre_close = _number(sw_row.get("pre_close") if sw_row.get("pre_close") not in (None, "") else sw_row.get("previous_close"))
|
||||||
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
|
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
|
||||||
if not outer_precise:
|
if not outer_precise:
|
||||||
official_change = None
|
official_change = None
|
||||||
outer_error = ""
|
if not sw_row and not outer_error:
|
||||||
if not sw_row:
|
outer_error = f"申万行业 {sector_code} 当日外显待盘后正式数据或免费实时源"
|
||||||
outer_error = f"No Shenwan realtime index returned for {sector_code}"
|
elif quote_date and quote_date != trade_date:
|
||||||
elif quote_date != trade_date:
|
outer_error = f"申万实时行业日期是 {quote_date},期望 {trade_date}"
|
||||||
outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}"
|
elif finalized and quote_clock and quote_clock < "15:00:00":
|
||||||
elif finalized and (not quote_clock or quote_clock < "15:00:00"):
|
outer_error = f"申万行业尚未形成收盘快照({trade_time})"
|
||||||
outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})"
|
|
||||||
|
|
||||||
valid: list[dict[str, Any]] = []
|
valid: list[dict[str, Any]] = []
|
||||||
codes: list[str] = []
|
codes: list[str] = []
|
||||||
reference: dict[str, Any] = {}
|
reference: dict[str, Any] = {}
|
||||||
inner_error = ""
|
inner_error = ""
|
||||||
|
inner_source = "unavailable"
|
||||||
try:
|
try:
|
||||||
reference = self._load_realtime_reference(trade_date, previous_trade_date)
|
reference = self._load_realtime_reference(trade_date, previous_trade_date)
|
||||||
active_codes = {
|
active_codes = {
|
||||||
@@ -352,20 +486,27 @@ class ShenwanIndustryMixin:
|
|||||||
codes = [
|
codes = [
|
||||||
str(row.get("ts_code") or "")
|
str(row.get("ts_code") or "")
|
||||||
for row in members
|
for row in members
|
||||||
if str(row.get("ts_code") or "") in active_codes
|
if str(row.get("ts_code") or "")
|
||||||
]
|
]
|
||||||
if codes:
|
if active_codes:
|
||||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
listed = [code for code in codes if code in active_codes]
|
||||||
|
if listed:
|
||||||
|
codes = listed
|
||||||
|
quotes, inner_source = self._load_member_realtime_quotes(codes, trade_date)
|
||||||
for row in quotes:
|
for row in quotes:
|
||||||
close = _number(row.get("close"))
|
close = _number(row.get("close"))
|
||||||
previous_close = _number(row.get("pre_close"))
|
previous_close = _number(row.get("pre_close"))
|
||||||
if close <= 0 or previous_close <= 0:
|
if close <= 0 or previous_close <= 0:
|
||||||
continue
|
continue
|
||||||
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
||||||
else:
|
if not codes:
|
||||||
inner_error = f"No active Shenwan members returned for {sector_code}"
|
inner_error = f"No active Shenwan members returned for {sector_code}"
|
||||||
|
elif not quotes:
|
||||||
|
inner_error = f"申万成分实时行情暂不可用:{sector_code}"
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
inner_error = str(exc)
|
inner_error = str(exc)
|
||||||
|
if "rt_k" in inner_error or "权限" in inner_error:
|
||||||
|
inner_error = "申万成分实时行情暂不可用,已避开无权限接口"
|
||||||
|
|
||||||
coverage = len(valid) / max(len(codes), 1) * 100
|
coverage = len(valid) / max(len(codes), 1) * 100
|
||||||
valid_codes = {str(item.get("ts_code") or "") for item in valid}
|
valid_codes = {str(item.get("ts_code") or "") for item in valid}
|
||||||
@@ -389,17 +530,25 @@ class ShenwanIndustryMixin:
|
|||||||
for item in members
|
for item in members
|
||||||
}
|
}
|
||||||
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
||||||
|
if not outer_precise and inner_precise:
|
||||||
|
official_change = equal_change
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_realtime"
|
||||||
|
outer_error = ""
|
||||||
|
quote_date = trade_date
|
||||||
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
||||||
|
market_rows: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
self._ensure_realtime_market_cache(trade_date)
|
market_rows = 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:
|
except TushareError as exc:
|
||||||
market_rows = []
|
message = str(exc)
|
||||||
|
if "rt_k" in message or "权限" in message:
|
||||||
|
market_error = "全市场实时行情暂不可用,已避开无权限接口"
|
||||||
|
else:
|
||||||
|
market_error = message
|
||||||
|
if not valid:
|
||||||
inner_precise = False
|
inner_precise = False
|
||||||
inner_error = inner_error or str(exc)
|
inner_error = inner_error or market_error
|
||||||
capital_map = {
|
capital_map = {
|
||||||
str(item.get("ts_code") or ""): item
|
str(item.get("ts_code") or ""): item
|
||||||
for item in reference.get("capital_rows") or []
|
for item in reference.get("capital_rows") or []
|
||||||
@@ -408,20 +557,42 @@ class ShenwanIndustryMixin:
|
|||||||
for item in valid:
|
for item in valid:
|
||||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
if float_share:
|
previous = _number(
|
||||||
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not float_share and previous:
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous
|
||||||
|
volume = _number(item.get("vol"))
|
||||||
|
if float_share and volume:
|
||||||
|
# 免费源成交量为股;daily_basic.float_share 为万股。
|
||||||
|
sector_turnovers.append(volume / float_share / 100)
|
||||||
market_turnovers = []
|
market_turnovers = []
|
||||||
for item in market_rows:
|
for item in market_rows:
|
||||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
if float_share:
|
previous = _number(
|
||||||
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not float_share and previous:
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous
|
||||||
|
volume = _number(item.get("vol"))
|
||||||
|
if float_share and volume:
|
||||||
|
market_turnovers.append(volume / float_share / 100)
|
||||||
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
|
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
|
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
|
||||||
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
||||||
if not relative_turnover:
|
delayed = "delayed" in str(inner_source) or any(item.get("delayed") for item in valid)
|
||||||
inner_precise = False
|
delay_seconds = max((int(item.get("delay_seconds") or 0) for item in valid), default=0)
|
||||||
inner_error = inner_error or "Shenwan member relative turnover is unavailable"
|
delay_notice = ""
|
||||||
|
if delayed:
|
||||||
|
delay_notice = next(
|
||||||
|
(str(item.get("delay_notice") or "") for item in valid if item.get("delay_notice")),
|
||||||
|
"",
|
||||||
|
) or f"主备免费行情均暂不可用,显示最近一次真实快照(延迟 {delay_seconds} 秒)"
|
||||||
return {
|
return {
|
||||||
"code": sector_code,
|
"code": sector_code,
|
||||||
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
||||||
@@ -447,9 +618,9 @@ class ShenwanIndustryMixin:
|
|||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"count": sum(item["change"] >= 9.5 for item in valid),
|
"count": sum(item["change"] >= 9.5 for item in valid),
|
||||||
"max_streak": 0,
|
"max_streak": 0,
|
||||||
"source": "tushare_rt_sw_k+sw_members_rt_k",
|
"source": f"{outer_source or 'unavailable'}+{inner_source}",
|
||||||
"inner_source": "tushare_sw_members+rt_k",
|
"inner_source": inner_source,
|
||||||
"outer_source": "tushare_rt_sw_k",
|
"outer_source": outer_source or "unavailable",
|
||||||
"taxonomy": "sw_l2",
|
"taxonomy": "sw_l2",
|
||||||
"industry": industry,
|
"industry": industry,
|
||||||
"trade_date": trade_date,
|
"trade_date": trade_date,
|
||||||
@@ -463,10 +634,101 @@ class ShenwanIndustryMixin:
|
|||||||
"precise": inner_precise and outer_precise,
|
"precise": inner_precise and outer_precise,
|
||||||
"inner_error": inner_error,
|
"inner_error": inner_error,
|
||||||
"outer_error": outer_error,
|
"outer_error": outer_error,
|
||||||
|
"delayed": delayed,
|
||||||
|
"delay_seconds": delay_seconds,
|
||||||
|
"delay_notice": delay_notice,
|
||||||
"schema_version": 6,
|
"schema_version": 6,
|
||||||
"methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合",
|
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核使用数据中枢/免费实时成分,不调用 rt_sw_k",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _sw_outer_realtime(
|
||||||
|
self,
|
||||||
|
sector_code: str,
|
||||||
|
sector_name: str,
|
||||||
|
trade_date: str,
|
||||||
|
finalized: bool = False,
|
||||||
|
) -> tuple[dict[str, Any], str, str]:
|
||||||
|
hub = getattr(self, "try_sector_quote", None)
|
||||||
|
if callable(hub):
|
||||||
|
try:
|
||||||
|
row = hub(sector_code, "" if finalized else trade_date)
|
||||||
|
except Exception as exc:
|
||||||
|
message = str(exc)
|
||||||
|
if finalized:
|
||||||
|
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
||||||
|
return {}, "", f"数据中枢申万实时暂不可用:{message[:180]}"
|
||||||
|
if row:
|
||||||
|
actual_name = str(row.get("name") or "").strip()
|
||||||
|
if sector_name and actual_name != str(sector_name).strip():
|
||||||
|
return {}, "", (
|
||||||
|
f"数据中枢返回的行业名称不匹配:期望 {sector_name},实际 {actual_name or '--'}"
|
||||||
|
)
|
||||||
|
return dict(row), str(row.get("source") or "datahub"), ""
|
||||||
|
if finalized:
|
||||||
|
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||||
|
return {}, "", f"申万行业 {sector_code} 当日外显待补充"
|
||||||
|
|
||||||
|
def _load_member_realtime_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
trade_date: str,
|
||||||
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
wanted = [str(code).strip() for code in codes if str(code or "").strip()]
|
||||||
|
if not wanted:
|
||||||
|
return [], "unavailable"
|
||||||
|
best_rows: list[dict[str, Any]] = []
|
||||||
|
best_source = "unavailable"
|
||||||
|
|
||||||
|
def consider(rows: list[dict[str, Any]] | None, source: str) -> list[dict[str, Any]]:
|
||||||
|
nonlocal best_rows, best_source
|
||||||
|
filtered = _filter_quotes_for_codes(rows, wanted)
|
||||||
|
if len(filtered) > len(best_rows):
|
||||||
|
best_rows = filtered
|
||||||
|
best_source = source
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
hub_market = getattr(self, "try_market_quotes", None)
|
||||||
|
if callable(hub_market):
|
||||||
|
filtered = consider(hub_market(trade_date) or [], "datahub")
|
||||||
|
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||||
|
delayed = any(item.get("delayed") for item in filtered)
|
||||||
|
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||||
|
|
||||||
|
hub = getattr(self, "try_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(wanted), _QUOTE_BATCH):
|
||||||
|
collected.extend(hub(wanted[index:index + _QUOTE_BATCH]) or [])
|
||||||
|
filtered = consider(collected, "datahub")
|
||||||
|
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||||
|
delayed = any(item.get("delayed") for item in filtered)
|
||||||
|
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||||
|
|
||||||
|
if best_rows:
|
||||||
|
delayed = any(item.get("delayed") for item in best_rows)
|
||||||
|
if delayed and not str(best_source).endswith("_delayed"):
|
||||||
|
return best_rows, f"{best_source}_delayed"
|
||||||
|
return best_rows, best_source
|
||||||
|
return [], "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
_QUOTE_BATCH = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_quotes_for_codes(
|
||||||
|
rows: list[dict[str, Any]] | None,
|
||||||
|
codes: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
wanted = {str(code) for code in codes if code}
|
||||||
|
filtered: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows or []:
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
if ts_code in wanted and ts_code not in seen:
|
||||||
|
seen.add(ts_code)
|
||||||
|
filtered.append(row)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def _filter_members_by_listing(
|
def _filter_members_by_listing(
|
||||||
members: list[dict[str, Any]],
|
members: list[dict[str, Any]],
|
||||||
@@ -568,6 +830,22 @@ def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any
|
|||||||
return list(reconciled.values())
|
return list(reconciled.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _sw_member_path(sector_code: str) -> Path:
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(sector_code or ""))
|
||||||
|
return _SW_MEMBER_DIR / f"{safe or 'unknown'}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _active_members(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in 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] = dict(row)
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from typing import Any
|
|||||||
|
|
||||||
from backend.bootstrap.config import display_compact_date as _display_date
|
from backend.bootstrap.config import display_compact_date as _display_date
|
||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
|
from backend.data.providers.tushare_helpers import _moneyflow_payload
|
||||||
|
|
||||||
|
|
||||||
class StockMixin:
|
class StockMixin:
|
||||||
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||||
trade_date, _ = self.resolve_trade_context(requested_date)
|
trade_date, _ = self.resolve_trade_context(requested_date)
|
||||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||||
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
|
start_date = (end - timedelta(days=400)).strftime("%Y%m%d")
|
||||||
daily = self.query(
|
daily = self.query(
|
||||||
"daily",
|
"daily",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
||||||
@@ -41,7 +42,7 @@ class StockMixin:
|
|||||||
factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors}
|
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
|
latest_factor = max(factor_map.values(), default=1) or 1
|
||||||
prices = []
|
prices = []
|
||||||
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]:
|
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-250:]:
|
||||||
factor = factor_map.get(row.get("trade_date"), latest_factor)
|
factor = factor_map.get(row.get("trade_date"), latest_factor)
|
||||||
ratio = factor / latest_factor
|
ratio = factor / latest_factor
|
||||||
prices.append(
|
prices.append(
|
||||||
@@ -56,7 +57,7 @@ class StockMixin:
|
|||||||
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
|
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
flow = moneyflow[0] if moneyflow else {}
|
flow = moneyflow[0] if moneyflow else None
|
||||||
basic = basics[0] if basics else {}
|
basic = basics[0] if basics else {}
|
||||||
daily_basic = daily_basics[0] if daily_basics else {}
|
daily_basic = daily_basics[0] if daily_basics else {}
|
||||||
latest = prices[-1] if prices else {}
|
latest = prices[-1] if prices else {}
|
||||||
@@ -87,22 +88,7 @@ class StockMixin:
|
|||||||
"amount_billion": latest.get("amount_billion", 0),
|
"amount_billion": latest.get("amount_billion", 0),
|
||||||
},
|
},
|
||||||
"prices": prices,
|
"prices": prices,
|
||||||
"moneyflow": {
|
"moneyflow": _moneyflow_payload(flow),
|
||||||
"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]:
|
def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class TushareTransportMixin:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str = "",
|
fields: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
if api_name == "rt_sw_k":
|
||||||
|
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{
|
{
|
||||||
"api_name": api_name,
|
"api_name": api_name,
|
||||||
|
|||||||
+521
-4
@@ -19,8 +19,22 @@ class RealtimeAggregateError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
||||||
|
EASTMONEY_STOCK_URL = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||||
|
EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168,f62,f66,f72,f78,f84"
|
||||||
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
||||||
|
EASTMONEY_ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
||||||
|
EASTMONEY_ZB_POOL_URL = "https://push2ex.eastmoney.com/getTopicZBPool"
|
||||||
|
EASTMONEY_A_SHARE_BOARDS = (
|
||||||
|
"m:0+t:6",
|
||||||
|
"m:0+t:80",
|
||||||
|
"m:1+t:2",
|
||||||
|
"m:1+t:23",
|
||||||
|
"m:0+t:81",
|
||||||
|
)
|
||||||
|
EASTMONEY_QUOTE_FIELDS = "f12,f13,f14,f2,f3,f4,f5,f6,f15,f16,f17,f18,f8,f124"
|
||||||
|
EASTMONEY_MARKET_PAGE_SIZE = 100
|
||||||
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
|
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
|
||||||
|
TENCENT_QUOTE_URL = "https://qt.gtimg.cn/q="
|
||||||
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
|
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
|
||||||
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
|
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
|
||||||
BROWSER_USER_AGENT = (
|
BROWSER_USER_AGENT = (
|
||||||
@@ -134,6 +148,312 @@ class WebRealtimeAggregator:
|
|||||||
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
|
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def eastmoney_market_quotes(self, expected_date: str = "") -> list[dict[str, Any]]:
|
||||||
|
"""Full A-share snapshot via Eastmoney clist, used when Tushare rt_k is unavailable."""
|
||||||
|
now = time.time()
|
||||||
|
cache_key = "assembled:eastmoney_market"
|
||||||
|
with self._response_cache_lock:
|
||||||
|
cached = self._response_cache.get(cache_key)
|
||||||
|
cache_age = now - float((cached or {}).get("created_at") or 0)
|
||||||
|
if cached and cache_age <= min(20, self.response_cache_ttl_seconds):
|
||||||
|
quotes = list(cached.get("payload") or [])
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
board_errors: list[str] = []
|
||||||
|
for board in EASTMONEY_A_SHARE_BOARDS:
|
||||||
|
try:
|
||||||
|
rows.extend(self._eastmoney_board_quotes(board))
|
||||||
|
except Exception as exc:
|
||||||
|
board_errors.append(f"{board}:{exc}")
|
||||||
|
quotes = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
quote = _normalize_eastmoney_quote(row)
|
||||||
|
ts_code = str((quote or {}).get("ts_code") or "")
|
||||||
|
if not quote or ts_code in seen:
|
||||||
|
continue
|
||||||
|
seen.add(ts_code)
|
||||||
|
quotes.append(quote)
|
||||||
|
if len(quotes) < 200:
|
||||||
|
detail = f";{'; '.join(board_errors)}" if board_errors else ""
|
||||||
|
raise RealtimeAggregateError(
|
||||||
|
f"Eastmoney market snapshot too small: {len(quotes)}{detail}"
|
||||||
|
)
|
||||||
|
quotes = self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
with self._response_cache_lock:
|
||||||
|
self._response_cache[cache_key] = {"created_at": now, "payload": quotes}
|
||||||
|
return quotes
|
||||||
|
|
||||||
|
def _eastmoney_board_quotes(self, board: str) -> list[dict[str, Any]]:
|
||||||
|
first = self._eastmoney_market_page(board, 1)
|
||||||
|
data = first.get("data") or {}
|
||||||
|
rows = _diff_rows(data)
|
||||||
|
total = int(_number(data.get("total")))
|
||||||
|
page_count = 1
|
||||||
|
if total > 0:
|
||||||
|
page_count = max(1, (total + EASTMONEY_MARKET_PAGE_SIZE - 1) // EASTMONEY_MARKET_PAGE_SIZE)
|
||||||
|
for page in range(2, min(page_count, 40) + 1):
|
||||||
|
payload = self._eastmoney_market_page(board, page)
|
||||||
|
rows.extend(_diff_rows(payload.get("data") or {}))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _eastmoney_market_page(self, board: str, page: int) -> dict[str, Any]:
|
||||||
|
return self._get_json(
|
||||||
|
EASTMONEY_SECTOR_URL,
|
||||||
|
{
|
||||||
|
"pn": str(page),
|
||||||
|
"pz": str(EASTMONEY_MARKET_PAGE_SIZE),
|
||||||
|
"po": "1",
|
||||||
|
"np": "1",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fid": "f12",
|
||||||
|
"fs": board,
|
||||||
|
"fields": EASTMONEY_QUOTE_FIELDS,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/center/gridlist.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _filter_quotes_by_date(
|
||||||
|
self,
|
||||||
|
quotes: list[dict[str, Any]],
|
||||||
|
expected_date: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
want = str(expected_date or "").replace("-", "")
|
||||||
|
if not want or not quotes:
|
||||||
|
return quotes
|
||||||
|
dated = [item for item in quotes if str(item.get("quote_date") or "") == want]
|
||||||
|
if dated and len(dated) >= max(100, int(len(quotes) * 0.2)):
|
||||||
|
return dated
|
||||||
|
if dated:
|
||||||
|
return dated
|
||||||
|
if all(not item.get("quote_date") for item in quotes):
|
||||||
|
return quotes
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney quotes are not for {want}")
|
||||||
|
|
||||||
|
def tencent_market_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
quotes = self.tencent_stock_quotes(codes, expected_date="", minimum=200)
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
minimum: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
symbols: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in codes:
|
||||||
|
ts = str(raw or "").strip().upper()
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
symbol = ts.split(".")[0]
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6 or symbol in seen:
|
||||||
|
continue
|
||||||
|
seen.add(symbol)
|
||||||
|
if ts.endswith(".SH") or symbol.startswith(("5", "6", "9")):
|
||||||
|
symbols.append(f"sh{symbol}")
|
||||||
|
elif ts.endswith(".BJ") or symbol.startswith(("4", "8")):
|
||||||
|
symbols.append(f"bj{symbol}")
|
||||||
|
else:
|
||||||
|
symbols.append(f"sz{symbol}")
|
||||||
|
if not symbols:
|
||||||
|
raise RealtimeAggregateError("No stock codes available for Tencent quotes")
|
||||||
|
|
||||||
|
quotes: list[dict[str, Any]] = []
|
||||||
|
batch_size = 80
|
||||||
|
|
||||||
|
def load_batch(batch: list[str]) -> list[dict[str, Any]]:
|
||||||
|
raw, _cache_age = self._get_text(
|
||||||
|
f"{TENCENT_QUOTE_URL}{','.join(batch)}",
|
||||||
|
referer="https://gu.qq.com/",
|
||||||
|
encoding="gb18030",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
quote
|
||||||
|
for line in raw.splitlines()
|
||||||
|
if (quote := _parse_tencent_stock_quote(line))
|
||||||
|
]
|
||||||
|
|
||||||
|
batches = [symbols[index:index + batch_size] for index in range(0, len(symbols), batch_size)]
|
||||||
|
errors: list[str] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
for result in executor.map(self._capture, [lambda batch=batch: load_batch(batch) for batch in batches]):
|
||||||
|
rows, status = result
|
||||||
|
if status.get("ok") and rows:
|
||||||
|
quotes.extend(rows)
|
||||||
|
elif not status.get("ok"):
|
||||||
|
errors.append(str(status.get("error") or "batch failed"))
|
||||||
|
floor = minimum if minimum is not None else max(1, int(len(symbols) * 0.5))
|
||||||
|
if len(quotes) < floor:
|
||||||
|
detail = f";{'; '.join(errors[:3])}" if errors else ""
|
||||||
|
raise RealtimeAggregateError(
|
||||||
|
f"Tencent quotes too small: {len(quotes)}/{len(symbols)}{detail}"
|
||||||
|
)
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
symbol, _secid, ts_code = _a_share_identity(code)
|
||||||
|
raw, _cache_age = self._get_text(
|
||||||
|
f"{TENCENT_QUOTE_URL}{symbol}",
|
||||||
|
referer="https://gu.qq.com/",
|
||||||
|
encoding="gb18030",
|
||||||
|
)
|
||||||
|
quote = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for line in raw.splitlines()
|
||||||
|
if (item := _parse_tencent_stock_quote(line))
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not quote:
|
||||||
|
raise RealtimeAggregateError(f"Tencent stock quote unavailable for {ts_code}")
|
||||||
|
return _require_quote_date(quote, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
_symbol, secid, ts_code = _a_share_identity(code)
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_STOCK_URL,
|
||||||
|
{
|
||||||
|
"secid": secid,
|
||||||
|
"invt": "2",
|
||||||
|
"fltt": "2",
|
||||||
|
"fields": EASTMONEY_STOCK_FIELDS,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
quote = _normalize_eastmoney_stock_quote(payload.get("data") or {}, ts_code)
|
||||||
|
if not quote:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney stock quote unavailable for {ts_code}")
|
||||||
|
return _require_quote_date(quote, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
secids = []
|
||||||
|
for code in codes:
|
||||||
|
try:
|
||||||
|
_symbol, secid, _ts = _a_share_identity(code)
|
||||||
|
except RealtimeAggregateError:
|
||||||
|
continue
|
||||||
|
secids.append(secid)
|
||||||
|
quotes: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(secids), 60):
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_INDEX_URL,
|
||||||
|
{
|
||||||
|
"secids": ",".join(secids[index:index + 60]),
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fields": EASTMONEY_QUOTE_FIELDS,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
for row in _diff_rows(payload.get("data") or {}):
|
||||||
|
quote = _normalize_eastmoney_quote(row)
|
||||||
|
if quote:
|
||||||
|
quotes.append(quote)
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_shenwan_quote(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = str(ts_code or "").split(".")[0]
|
||||||
|
if not code:
|
||||||
|
raise RealtimeAggregateError("Invalid Shenwan code")
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_INDEX_URL,
|
||||||
|
{
|
||||||
|
"secids": f"90.{code}",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f8,f104,f105,f128,f136,f140,f124",
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
row = next((item for item in _diff_rows(payload.get("data") or {}) if item), None)
|
||||||
|
if not row:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney Shenwan quote missing for {code}")
|
||||||
|
epoch = int(_number(row.get("f124")))
|
||||||
|
quote_time = (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
close = _number(row.get("f2"))
|
||||||
|
previous = _number(row.get("f18"))
|
||||||
|
if close <= 0 or previous <= 0:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney Shenwan quote empty for {code}")
|
||||||
|
result = {
|
||||||
|
"ts_code": f"{code}.SI",
|
||||||
|
"code": f"{code}.SI",
|
||||||
|
"name": row.get("f14") or code,
|
||||||
|
"price": close,
|
||||||
|
"close": close,
|
||||||
|
"pre_close": previous,
|
||||||
|
"previous_close": previous,
|
||||||
|
"open": _number(row.get("f17")),
|
||||||
|
"high": _number(row.get("f15")),
|
||||||
|
"low": _number(row.get("f16")),
|
||||||
|
"change": _number(row.get("f3")),
|
||||||
|
"pct_change": _number(row.get("f3")),
|
||||||
|
"amount": _number(row.get("f6")),
|
||||||
|
"leader": row.get("f128") or "--",
|
||||||
|
"leader_code": row.get("f140") or "",
|
||||||
|
"leading_pct": _number(row.get("f136")),
|
||||||
|
"up_count": int(_number(row.get("f104"))),
|
||||||
|
"down_count": int(_number(row.get("f105"))),
|
||||||
|
"quote_time": quote_time,
|
||||||
|
"trade_time": quote_time,
|
||||||
|
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
}
|
||||||
|
return _require_quote_date(result, expected_date) if expected_date else result
|
||||||
|
|
||||||
|
def eastmoney_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]]:
|
||||||
|
day = str(trade_date or "").replace("-", "")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for url, limit_type in (
|
||||||
|
(EASTMONEY_ZT_POOL_URL, "U"),
|
||||||
|
(EASTMONEY_ZB_POOL_URL, "Z"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = self._get_json(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"ut": "7eea3edcaed734bea9cbfc24409ed989",
|
||||||
|
"dpt": "wz.ztzt",
|
||||||
|
"PageIndex": "0",
|
||||||
|
"PageSize": "200",
|
||||||
|
"sort": "fbt:asc",
|
||||||
|
"date": day,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/ztb/detail",
|
||||||
|
)
|
||||||
|
except RealtimeAggregateError:
|
||||||
|
continue
|
||||||
|
pool = (payload.get("data") or {}).get("pool") or []
|
||||||
|
if isinstance(pool, dict):
|
||||||
|
pool = list(pool.values())
|
||||||
|
for item in pool:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
parsed = _normalize_eastmoney_limit_row(item, limit_type)
|
||||||
|
if parsed:
|
||||||
|
rows.append(parsed)
|
||||||
|
return rows
|
||||||
|
|
||||||
def tencent_indices(self) -> list[dict[str, Any]]:
|
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||||
raw, cache_age = self._get_text(
|
raw, cache_age = self._get_text(
|
||||||
TENCENT_INDEX_URL,
|
TENCENT_INDEX_URL,
|
||||||
@@ -185,11 +505,17 @@ class WebRealtimeAggregator:
|
|||||||
if not matched:
|
if not matched:
|
||||||
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
||||||
epoch = int(_number(matched.get("f124")))
|
epoch = int(_number(matched.get("f124")))
|
||||||
|
quote_time = (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch else ""
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"code": matched.get("f12") or "",
|
"code": matched.get("f12") or "",
|
||||||
"name": matched.get("f14") or query,
|
"name": matched.get("f14") or query,
|
||||||
"price": _number(matched.get("f2")),
|
"price": _number(matched.get("f2")),
|
||||||
|
"close": _number(matched.get("f2")),
|
||||||
"change": _number(matched.get("f3")),
|
"change": _number(matched.get("f3")),
|
||||||
|
"pct_change": _number(matched.get("f3")),
|
||||||
"change_amount": _number(matched.get("f4")),
|
"change_amount": _number(matched.get("f4")),
|
||||||
"turnover_rate": _number(matched.get("f8")),
|
"turnover_rate": _number(matched.get("f8")),
|
||||||
"up_count": int(_number(matched.get("f104"))),
|
"up_count": int(_number(matched.get("f104"))),
|
||||||
@@ -198,10 +524,9 @@ class WebRealtimeAggregator:
|
|||||||
"leader_code": matched.get("f140") or "",
|
"leader_code": matched.get("f140") or "",
|
||||||
"leading_pct": _number(matched.get("f136")),
|
"leading_pct": _number(matched.get("f136")),
|
||||||
"quote_time_epoch": epoch,
|
"quote_time_epoch": epoch,
|
||||||
"quote_time": (
|
"quote_time": quote_time,
|
||||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
"trade_time": quote_time,
|
||||||
if epoch else ""
|
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||||
),
|
|
||||||
"source": "eastmoney_push2",
|
"source": "eastmoney_push2",
|
||||||
"match_query": query,
|
"match_query": query,
|
||||||
}
|
}
|
||||||
@@ -397,6 +722,198 @@ class WebRealtimeAggregator:
|
|||||||
) from last_error
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_rows(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
diff = data.get("diff") or []
|
||||||
|
if isinstance(diff, dict):
|
||||||
|
return [row for row in diff.values() if isinstance(row, dict)]
|
||||||
|
return [row for row in diff if isinstance(row, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _a_share_identity(code: str) -> tuple[str, str, str]:
|
||||||
|
raw = str(code or "").strip().upper()
|
||||||
|
symbol = raw.split(".")[0]
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6:
|
||||||
|
raise RealtimeAggregateError("Invalid stock code")
|
||||||
|
if raw.endswith(".SH") or symbol.startswith(("5", "6", "9")):
|
||||||
|
return f"sh{symbol}", f"1.{symbol}", f"{symbol}.SH"
|
||||||
|
if raw.endswith(".BJ") or symbol.startswith(("4", "8")):
|
||||||
|
return f"bj{symbol}", f"0.{symbol}", f"{symbol}.BJ"
|
||||||
|
return f"sz{symbol}", f"0.{symbol}", f"{symbol}.SZ"
|
||||||
|
|
||||||
|
|
||||||
|
def _require_quote_date(quote: dict[str, Any], expected_date: str) -> dict[str, Any]:
|
||||||
|
want = str(expected_date or "").replace("-", "")
|
||||||
|
got = str(quote.get("quote_date") or "")
|
||||||
|
if want and got != want:
|
||||||
|
raise RealtimeAggregateError(f"quote date {got or 'empty'} is not {want}")
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_eastmoney_stock_quote(
|
||||||
|
row: dict[str, Any], ts_code: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
close = _number(row.get("f43"))
|
||||||
|
previous_close = _number(row.get("f60"))
|
||||||
|
if close <= 0 or previous_close <= 0:
|
||||||
|
return None
|
||||||
|
epoch = int(_number(row.get("f86")))
|
||||||
|
quote_date = ""
|
||||||
|
if epoch > 0:
|
||||||
|
quote_date = datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": row.get("f58") or ts_code.split(".")[0],
|
||||||
|
"pre_close": previous_close,
|
||||||
|
"open": _number(row.get("f46")),
|
||||||
|
"high": _number(row.get("f44")),
|
||||||
|
"low": _number(row.get("f45")),
|
||||||
|
"close": close,
|
||||||
|
"vol": _number(row.get("f47")) * 100,
|
||||||
|
"amount": _number(row.get("f48")),
|
||||||
|
"num": 0,
|
||||||
|
"quote_date": quote_date,
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"turnover_rate": _number(row.get("f168")),
|
||||||
|
"net_mf_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||||
|
"large_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||||
|
"medium_amount": _eastmoney_flow_wan(row.get("f78")),
|
||||||
|
"small_amount": _eastmoney_flow_wan(row.get("f84")),
|
||||||
|
"source": "eastmoney_stock",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_tencent_stock_quote(line: str) -> dict[str, Any] | None:
|
||||||
|
if '="' not in line:
|
||||||
|
return None
|
||||||
|
prefix, payload = line.split('="', 1)
|
||||||
|
fields = payload.rsplit('";', 1)[0].split("~")
|
||||||
|
if len(fields) < 38:
|
||||||
|
return None
|
||||||
|
symbol = fields[2]
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6:
|
||||||
|
return None
|
||||||
|
close = _number(fields[3])
|
||||||
|
previous_close = _number(fields[4])
|
||||||
|
if close <= 0 or previous_close <= 0:
|
||||||
|
return None
|
||||||
|
marker = prefix.lower()
|
||||||
|
if "sh" in marker:
|
||||||
|
ts_code = f"{symbol}.SH"
|
||||||
|
elif "bj" in marker:
|
||||||
|
ts_code = f"{symbol}.BJ"
|
||||||
|
else:
|
||||||
|
ts_code = f"{symbol}.SZ"
|
||||||
|
try:
|
||||||
|
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S")
|
||||||
|
quote_date = quote_time.strftime("%Y%m%d")
|
||||||
|
epoch = int(quote_time.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
quote_date = ""
|
||||||
|
epoch = 0
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": fields[1] or symbol,
|
||||||
|
"pre_close": previous_close,
|
||||||
|
"open": _number(fields[5]),
|
||||||
|
"high": _number(fields[33]),
|
||||||
|
"low": _number(fields[34]),
|
||||||
|
"close": close,
|
||||||
|
"vol": _number(fields[6]) * 100,
|
||||||
|
"amount": _number(fields[37]) * 10000,
|
||||||
|
"num": 0,
|
||||||
|
"quote_date": quote_date,
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"source": "tencent_qt",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_eastmoney_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
symbol = str(row.get("f12") or "").strip()
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6:
|
||||||
|
return None
|
||||||
|
close = _number(row.get("f2"))
|
||||||
|
previous_close = _number(row.get("f18"))
|
||||||
|
if close <= 0 or previous_close <= 0:
|
||||||
|
return None
|
||||||
|
market = int(_number(row.get("f13")))
|
||||||
|
if market == 1 or symbol.startswith(("5", "6", "9")):
|
||||||
|
ts_code = f"{symbol}.SH"
|
||||||
|
elif symbol.startswith(("4", "8")):
|
||||||
|
ts_code = f"{symbol}.BJ"
|
||||||
|
else:
|
||||||
|
ts_code = f"{symbol}.SZ"
|
||||||
|
epoch = int(_number(row.get("f124")))
|
||||||
|
quote_date = ""
|
||||||
|
if epoch > 0:
|
||||||
|
quote_date = datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": row.get("f14") or symbol,
|
||||||
|
"pre_close": previous_close,
|
||||||
|
"open": _number(row.get("f17")),
|
||||||
|
"high": _number(row.get("f15")),
|
||||||
|
"low": _number(row.get("f16")),
|
||||||
|
"close": close,
|
||||||
|
"vol": _number(row.get("f5")) * 100,
|
||||||
|
"amount": _number(row.get("f6")),
|
||||||
|
"num": 0,
|
||||||
|
"quote_date": quote_date,
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"source": "eastmoney_clist",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _eastmoney_flow_wan(value: Any) -> float | None:
|
||||||
|
if value in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
amount = _number(value, default=float("nan"))
|
||||||
|
if amount != amount:
|
||||||
|
return None
|
||||||
|
return amount / 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _board_clock(value: Any) -> str:
|
||||||
|
digits = "".join(character for character in str(value or "") if character.isdigit())
|
||||||
|
if len(digits) >= 6:
|
||||||
|
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||||
|
if len(digits) == 5:
|
||||||
|
digits = digits.zfill(6)
|
||||||
|
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||||
|
if len(digits) == 4:
|
||||||
|
return f"{digits[:2]}:{digits[2:]}:00"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_eastmoney_limit_row(row: dict[str, Any], limit_type: str) -> dict[str, Any] | None:
|
||||||
|
symbol = str(row.get("c") or row.get("code") or "").strip()
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6:
|
||||||
|
return None
|
||||||
|
market = int(_number(row.get("m") if row.get("m") not in (None, "") else row.get("market")))
|
||||||
|
if market == 1 or symbol.startswith(("5", "6", "9")):
|
||||||
|
ts_code = f"{symbol}.SH"
|
||||||
|
elif symbol.startswith(("4", "8")):
|
||||||
|
ts_code = f"{symbol}.BJ"
|
||||||
|
else:
|
||||||
|
ts_code = f"{symbol}.SZ"
|
||||||
|
first_time = _board_clock(row.get("fbt") if row.get("fbt") not in (None, "") else row.get("first_time"))
|
||||||
|
last_time = _board_clock(row.get("lbt") if row.get("lbt") not in (None, "") else row.get("last_time"))
|
||||||
|
fund = row.get("fund")
|
||||||
|
if fund in (None, ""):
|
||||||
|
fund = row.get("fd_amount")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": row.get("n") or row.get("name") or symbol,
|
||||||
|
"limit_type": limit_type,
|
||||||
|
"first_time": first_time or None,
|
||||||
|
"last_time": last_time or None,
|
||||||
|
"open_times": int(_number(row.get("zbc") if row.get("zbc") not in (None, "") else row.get("open_times"))),
|
||||||
|
"limit_times": max(1, int(_number(row.get("lbc") if row.get("lbc") not in (None, "") else 1))),
|
||||||
|
"turnover_ratio": _number(row.get("hs") if row.get("hs") not in (None, "") else row.get("turnover_ratio")),
|
||||||
|
"fd_amount": _number(fund) if fund not in (None, "", "-") else None,
|
||||||
|
"source": "eastmoney_zt_pool",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sector(value: Any) -> str:
|
def _normalize_sector(value: Any) -> str:
|
||||||
text = str(value or "").strip().replace(" ", "")
|
text = str(value or "").strip().replace(" ", "")
|
||||||
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
|
|||||||
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
|
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
|
||||||
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
|
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
|
||||||
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
|
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
|
||||||
|
from .m0006_invite_codes import MIGRATION as M0006_INVITE_CODES
|
||||||
from .runner import Migration, MigrationError, MigrationRunner
|
from .runner import Migration, MigrationError, MigrationRunner
|
||||||
|
|
||||||
MIGRATIONS = (
|
MIGRATIONS = (
|
||||||
@@ -11,6 +12,7 @@ MIGRATIONS = (
|
|||||||
M0003_LLM_AUDIT,
|
M0003_LLM_AUDIT,
|
||||||
M0004_MENTOR_NOTES,
|
M0004_MENTOR_NOTES,
|
||||||
M0005_ACCOUNT_SWITCH_GRANTS,
|
M0005_ACCOUNT_SWITCH_GRANTS,
|
||||||
|
M0006_INVITE_CODES,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
|
|
||||||
|
def create_invite_codes(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unused',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
used_by INTEGER,
|
||||||
|
used_at TEXT NOT NULL DEFAULT '',
|
||||||
|
revoked_at TEXT NOT NULL DEFAULT '',
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_codes_status
|
||||||
|
ON invite_codes(status, created_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION = Migration(
|
||||||
|
version="0006",
|
||||||
|
name="create_invite_codes",
|
||||||
|
action=create_invite_codes,
|
||||||
|
signature="invite-codes:v1:code,status,note,created,used,revoked",
|
||||||
|
)
|
||||||
@@ -28,8 +28,14 @@ class AccountApplicationMixin:
|
|||||||
def update_membership(self, payload: dict[str, Any]) -> None:
|
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||||
self.accounts.update_membership(payload)
|
self.accounts.update_membership(payload)
|
||||||
|
|
||||||
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def register_account(
|
||||||
return self.accounts.register(username, password, device_hash)
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
device_hash: str = "",
|
||||||
|
invite_code: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.accounts.register(username, password, device_hash, invite_code)
|
||||||
|
|
||||||
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||||
return self.accounts.login(username, password, device_hash)
|
return self.accounts.login(username, password, device_hash)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class AccountHttpMixin:
|
|||||||
str(body.get("username") or ""),
|
str(body.get("username") or ""),
|
||||||
str(body.get("password") or ""),
|
str(body.get("password") or ""),
|
||||||
token_hash(device_raw),
|
token_hash(device_raw),
|
||||||
|
str(body.get("invite_code") or ""),
|
||||||
)
|
)
|
||||||
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
||||||
except (ValueError, json.JSONDecodeError) as exc:
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
INVITE_CONSUMED_MESSAGE = "邀请码无效或已被使用,请联系管理员重新获取。"
|
||||||
|
|
||||||
|
|
||||||
class AccountRepositoryMixin:
|
class AccountRepositoryMixin:
|
||||||
"""Original SQLite account persistence methods, moved without query changes."""
|
"""Original SQLite account persistence methods, moved without query changes."""
|
||||||
|
|
||||||
@@ -23,11 +26,14 @@ class AccountRepositoryMixin:
|
|||||||
username: str,
|
username: str,
|
||||||
password_salt: str,
|
password_salt: str,
|
||||||
password_hash: str,
|
password_hash: str,
|
||||||
|
invite_code: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
try:
|
try:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
||||||
|
if invite_code and not self._consume_invite_code(connection, invite_code, now):
|
||||||
|
raise ValueError(INVITE_CONSUMED_MESSAGE)
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users
|
INSERT INTO users
|
||||||
@@ -37,10 +43,91 @@ class AccountRepositoryMixin:
|
|||||||
(username, password_salt, password_hash, role, now, now),
|
(username, password_salt, password_hash, role, now, now),
|
||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid)
|
user_id = int(cursor.lastrowid)
|
||||||
|
if invite_code:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE invite_codes SET used_by = ? WHERE code = ?",
|
||||||
|
(user_id, invite_code),
|
||||||
|
)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise ValueError("该账号名已被使用。") from exc
|
raise ValueError("该账号名已被使用。") from exc
|
||||||
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _consume_invite_code(
|
||||||
|
connection: sqlite3.Connection, code: str, used_at: str
|
||||||
|
) -> bool:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invite_codes SET status = 'used', used_at = ?
|
||||||
|
WHERE code = ? AND status = 'unused'
|
||||||
|
""",
|
||||||
|
(used_at, code),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def invite_code(self, code: str) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT code, status, note, created_at, used_at, revoked_at, used_by
|
||||||
|
FROM invite_codes WHERE code = ?
|
||||||
|
""",
|
||||||
|
(code,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def create_invite_codes(
|
||||||
|
self, codes: list[str], note: str, created_by: int
|
||||||
|
) -> list[str]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
for code in codes:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invite_codes (code, status, note, created_by, created_at)
|
||||||
|
VALUES (?, 'unused', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(code, note, created_by or None, now),
|
||||||
|
)
|
||||||
|
return list(codes)
|
||||||
|
|
||||||
|
def revoke_invite_code(self, code: str) -> bool:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invite_codes SET status = 'revoked', revoked_at = ?
|
||||||
|
WHERE code = ? AND status = 'unused'
|
||||||
|
""",
|
||||||
|
(now, code),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def list_invite_codes(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.code, c.status, c.note, c.created_at, c.used_at, c.revoked_at,
|
||||||
|
u.username AS used_by_username
|
||||||
|
FROM invite_codes AS c
|
||||||
|
LEFT JOIN users AS u ON u.id = c.used_by
|
||||||
|
ORDER BY c.created_at DESC, c.code
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(max(1, min(500, int(limit))),),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def count_invite_codes(self) -> dict[str, int]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT status, COUNT(*) AS total FROM invite_codes GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
counts = {"unused": 0, "used": 0, "revoked": 0}
|
||||||
|
for row in rows:
|
||||||
|
counts[str(row["status"])] = int(row["total"])
|
||||||
|
return counts
|
||||||
|
|
||||||
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -82,16 +83,123 @@ class AccountService:
|
|||||||
MAX_GRANTS_PER_DEVICE = 5
|
MAX_GRANTS_PER_DEVICE = 5
|
||||||
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
||||||
|
|
||||||
def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
INVITE_ALPHABET = "ACDEFGHJKLMNPQRTUVWXY34679"
|
||||||
|
INVITE_MAX_BATCH = 20
|
||||||
|
INVITE_LIST_LIMIT = 500
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
device_hash: str = "",
|
||||||
|
invite_code: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
username = username.strip()
|
username = username.strip()
|
||||||
self.validate_input(username, password)
|
self.validate_input(username, password)
|
||||||
with self.auth_lock:
|
with self.auth_lock:
|
||||||
|
code = self.checked_invite_code(invite_code)
|
||||||
salt, password_digest = hash_password(password)
|
salt, password_digest = hash_password(password)
|
||||||
user = self.database.create_user(username, salt, password_digest)
|
user = self.database.create_user(username, salt, password_digest, code)
|
||||||
result = self.create_session(user)
|
result = self.create_session(user)
|
||||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_invite_code(cls, value: str) -> str:
|
||||||
|
raw = "".join(
|
||||||
|
character
|
||||||
|
for character in str(value or "").upper()
|
||||||
|
if character.isalnum()
|
||||||
|
)
|
||||||
|
if raw.startswith("XB") and len(raw) == 14:
|
||||||
|
body = raw[2:]
|
||||||
|
return f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
|
||||||
|
return raw[:64]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mask_invite_code(code: str) -> str:
|
||||||
|
groups = str(code or "").split("-")
|
||||||
|
if len(groups) < 3:
|
||||||
|
return str(code or "")
|
||||||
|
return f"{groups[0]}-{groups[1]}-••••"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invite_handle(code: str) -> str:
|
||||||
|
return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
def checked_invite_code(self, invite_code: str) -> str:
|
||||||
|
if self.database.count_users() == 0:
|
||||||
|
return ""
|
||||||
|
code = self.normalize_invite_code(invite_code)
|
||||||
|
if not code:
|
||||||
|
raise ValueError("请填写邀请码,注册需要管理员发放的一次性邀请码。")
|
||||||
|
record = self.database.invite_code(code)
|
||||||
|
status = str((record or {}).get("status") or "")
|
||||||
|
if not record:
|
||||||
|
raise ValueError("邀请码不存在,请向管理员确认。")
|
||||||
|
if status == "used":
|
||||||
|
raise ValueError("该邀请码已被使用。")
|
||||||
|
if status != "unused":
|
||||||
|
raise ValueError("该邀请码已作废。")
|
||||||
|
return code
|
||||||
|
|
||||||
|
def generate_invite_codes(
|
||||||
|
self, count: int, note: str = "", created_by: int = 0
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
total = int(count or 1)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("生成数量不正确。") from exc
|
||||||
|
if total < 1 or total > self.INVITE_MAX_BATCH:
|
||||||
|
raise ValueError(f"每次最多生成 {self.INVITE_MAX_BATCH} 个邀请码。")
|
||||||
|
codes: list[str] = []
|
||||||
|
while len(codes) < total:
|
||||||
|
body = "".join(secrets.choice(self.INVITE_ALPHABET) for _ in range(12))
|
||||||
|
code = f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
|
||||||
|
if code in codes or self.database.invite_code(code):
|
||||||
|
continue
|
||||||
|
codes.append(code)
|
||||||
|
self.database.create_invite_codes(codes, str(note or "").strip()[:60], created_by)
|
||||||
|
return [{"code": code, "code_id": self.invite_handle(code)} for code in codes]
|
||||||
|
|
||||||
|
def _stored_invite_code(self, reference: str) -> str:
|
||||||
|
normalized = self.normalize_invite_code(reference)
|
||||||
|
if normalized and self.database.invite_code(normalized):
|
||||||
|
return normalized
|
||||||
|
handle = str(reference or "").strip().lower()
|
||||||
|
for row in self.database.list_invite_codes(self.INVITE_LIST_LIMIT):
|
||||||
|
if self.invite_handle(str(row["code"])) == handle:
|
||||||
|
return str(row["code"])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def revoke_invite_code(self, reference: str) -> None:
|
||||||
|
code = self._stored_invite_code(reference)
|
||||||
|
record = self.database.invite_code(code) if code else None
|
||||||
|
if not record:
|
||||||
|
raise ValueError("邀请码不存在。")
|
||||||
|
if str(record.get("status")) == "used":
|
||||||
|
raise ValueError("该邀请码已被使用,无法作废。")
|
||||||
|
if not self.database.revoke_invite_code(code):
|
||||||
|
raise ValueError("该邀请码已作废。")
|
||||||
|
|
||||||
|
def invite_overview(self, limit: int = 100) -> dict[str, Any]:
|
||||||
|
codes = []
|
||||||
|
for row in self.database.list_invite_codes(limit):
|
||||||
|
code = str(row["code"])
|
||||||
|
codes.append(
|
||||||
|
{
|
||||||
|
"code_id": self.invite_handle(code),
|
||||||
|
"code_masked": self.mask_invite_code(code),
|
||||||
|
"status": str(row["status"]),
|
||||||
|
"note": str(row.get("note") or ""),
|
||||||
|
"created_at": str(row.get("created_at") or ""),
|
||||||
|
"used_at": str(row.get("used_at") or ""),
|
||||||
|
"revoked_at": str(row.get("revoked_at") or ""),
|
||||||
|
"used_by_username": str(row.get("used_by_username") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"summary": self.database.count_invite_codes(), "codes": codes}
|
||||||
|
|
||||||
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||||
username = username.strip()
|
username = username.strip()
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
|
|||||||
@@ -802,7 +802,11 @@ def _market_line_scores(
|
|||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
||||||
actual_sector_source = str(sector.get("source") or "").startswith("tushare_")
|
sector_source = str(sector.get("source") or "")
|
||||||
|
actual_sector_source = (
|
||||||
|
sector_source.startswith("tushare_")
|
||||||
|
or "sw_member_equal_" in sector_source
|
||||||
|
)
|
||||||
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
||||||
sector_change = float(sector.get("change") or 0)
|
sector_change = float(sector.get("change") or 0)
|
||||||
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
||||||
@@ -831,8 +835,13 @@ def _market_line_scores(
|
|||||||
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
||||||
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
||||||
]
|
]
|
||||||
|
sector_change_label = (
|
||||||
|
"申万二级行业成分等权涨跌"
|
||||||
|
if "sw_member_equal_" in sector_source
|
||||||
|
else "申万二级行业官方涨跌"
|
||||||
|
)
|
||||||
sector_outer_evidence = [
|
sector_outer_evidence = [
|
||||||
f"申万二级行业官方涨跌 {sector_change:+.2f}%",
|
f"{sector_change_label} {sector_change:+.2f}%",
|
||||||
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
||||||
]
|
]
|
||||||
elif actual_sector_source or sector_quantitative_mode == "historical":
|
elif actual_sector_source or sector_quantitative_mode == "historical":
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class HeavenManualMixin:
|
|||||||
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
||||||
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
||||||
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"sector_change": {"line": 4, "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},
|
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "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_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
|
||||||
|
|||||||
@@ -202,8 +202,7 @@ class HeavenMarketContextMixin:
|
|||||||
}
|
}
|
||||||
if len(quotes) != 3 or quote_dates != {trade_date}:
|
if len(quotes) != 3 or quote_dates != {trade_date}:
|
||||||
raise ValueError("腾讯三大指数日期与目标交易日不一致")
|
raise ValueError("腾讯三大指数日期与目标交易日不一致")
|
||||||
now = datetime.now().astimezone()
|
max_skew = 120
|
||||||
max_skew = 120 if now.hour >= 15 else 15
|
|
||||||
if max(epochs) - min(epochs) > max_skew:
|
if max(epochs) - min(epochs) > max_skew:
|
||||||
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
|
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
|
||||||
|
|
||||||
@@ -217,11 +216,14 @@ class HeavenMarketContextMixin:
|
|||||||
start_date = (
|
start_date = (
|
||||||
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
|
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
|
||||||
).strftime("%Y%m%d")
|
).strftime("%Y%m%d")
|
||||||
|
history_end = (
|
||||||
|
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=1)
|
||||||
|
).strftime("%Y%m%d")
|
||||||
for quote in quotes:
|
for quote in quotes:
|
||||||
ts_code = code_map[str(quote.get("code") or "")]
|
ts_code = code_map[str(quote.get("code") or "")]
|
||||||
history = client.query(
|
history = client.query(
|
||||||
"index_daily",
|
"index_daily",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
{"ts_code": ts_code, "start_date": start_date, "end_date": history_end},
|
||||||
"ts_code,trade_date,close,pct_chg",
|
"ts_code,trade_date,close,pct_chg",
|
||||||
)
|
)
|
||||||
history.sort(key=lambda item: str(item.get("trade_date") or ""))
|
history.sort(key=lambda item: str(item.get("trade_date") or ""))
|
||||||
@@ -283,9 +285,9 @@ class HeavenMarketContextMixin:
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Return the Shenwan L2 sector context for heaven trend.
|
"""Return the Shenwan L2 sector context for heaven trend.
|
||||||
|
|
||||||
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
|
观势行业层只使用申万二级行业。外显优先使用已发布的 sw_daily,
|
||||||
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
|
盘中及收盘过渡期使用免费申万实时行情;内核使用数据中枢或免费
|
||||||
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
|
实时成分行情。不再调用无权限的 rt_sw_k / rt_k。
|
||||||
"""
|
"""
|
||||||
cache_key = f"{trade_date}:{identifier.strip().lower()}"
|
cache_key = f"{trade_date}:{identifier.strip().lower()}"
|
||||||
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
|
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
|
||||||
@@ -299,6 +301,14 @@ class HeavenMarketContextMixin:
|
|||||||
and not cached.get("realtime")
|
and not cached.get("realtime")
|
||||||
and int(cached.get("schema_version") or 0) >= 6
|
and int(cached.get("schema_version") or 0) >= 6
|
||||||
)
|
)
|
||||||
|
cached_quotes = int((cached or {}).get("quote_count") or 0)
|
||||||
|
cached_lkg = bool(
|
||||||
|
cached
|
||||||
|
and cached_date == trade_date
|
||||||
|
and cached.get("taxonomy") == "sw_l2"
|
||||||
|
and cached_quotes > 0
|
||||||
|
and int(cached.get("schema_version") or 0) >= 6
|
||||||
|
)
|
||||||
if market_mode != "intraday" and cached_valid:
|
if market_mode != "intraday" and cached_valid:
|
||||||
return cached
|
return cached
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
@@ -311,8 +321,12 @@ class HeavenMarketContextMixin:
|
|||||||
allow_realtime_close=market_mode == "closed",
|
allow_realtime_close=market_mode == "closed",
|
||||||
)
|
)
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
if cached_valid:
|
if cached_lkg:
|
||||||
return cached
|
delayed = dict(cached)
|
||||||
|
delayed["delayed"] = True
|
||||||
|
delayed["delay_notice"] = "主备免费行情均暂不可用,显示最近一次真实快照"
|
||||||
|
delayed["realtime"] = market_mode == "intraday"
|
||||||
|
return delayed
|
||||||
return {
|
return {
|
||||||
"name": "",
|
"name": "",
|
||||||
"code": "",
|
"code": "",
|
||||||
@@ -323,12 +337,16 @@ class HeavenMarketContextMixin:
|
|||||||
"precise": False,
|
"precise": False,
|
||||||
"inner_precise": False,
|
"inner_precise": False,
|
||||||
"outer_precise": False,
|
"outer_precise": False,
|
||||||
"coverage": 0,
|
|
||||||
"member_count": 0,
|
|
||||||
"quote_count": 0,
|
|
||||||
"error": f"申万二级行业数据获取失败:{exc}",
|
"error": f"申万二级行业数据获取失败:{exc}",
|
||||||
}
|
}
|
||||||
if not payload.get("realtime") and payload.get("precise"):
|
if int(payload.get("quote_count") or 0) > 0:
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"heaven_sector",
|
||||||
|
cache_key,
|
||||||
|
str(payload.get("source") or "tushare"),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
elif not payload.get("realtime") and payload.get("precise"):
|
||||||
self.database.save_data_snapshot(
|
self.database.save_data_snapshot(
|
||||||
"heaven_sector",
|
"heaven_sector",
|
||||||
cache_key,
|
cache_key,
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ class HeavenTrendMixin:
|
|||||||
"detail": (
|
"detail": (
|
||||||
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
|
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
|
||||||
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
|
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
|
||||||
|
+ (";延迟快照" if sector.get("delayed") or sector.get("delay_notice") else "")
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,7 +342,9 @@ class HeavenTrendMixin:
|
|||||||
issues.append("行业外显缺少申万官方行情")
|
issues.append("行业外显缺少申万官方行情")
|
||||||
if sector and sector_coverage_issue:
|
if sector and sector_coverage_issue:
|
||||||
issues.append(sector_coverage_issue)
|
issues.append(sector_coverage_issue)
|
||||||
if sector.get("realtime") and not sector.get("relative_turnover"):
|
if sector.get("delay_notice"):
|
||||||
|
issues.append(str(sector.get("delay_notice")))
|
||||||
|
if sector.get("realtime") and not sector.get("relative_turnover") and not sector.get("delayed"):
|
||||||
issues.append("行业内核缺少相对全市场换手活跃度")
|
issues.append("行业内核缺少相对全市场换手活跃度")
|
||||||
|
|
||||||
stock = stock or {}
|
stock = stock or {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -13,14 +14,20 @@ from threading import Lock
|
|||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from backend.bootstrap.config import tushare_code as _stock_market_code
|
from backend.bootstrap.config import tushare_code as _stock_market_code
|
||||||
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("xiaobai.charts")
|
||||||
|
|
||||||
|
|
||||||
class ChartDataError(RuntimeError):
|
class ChartDataError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DAILY_CHART_LIMIT = 45
|
||||||
|
|
||||||
|
|
||||||
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
||||||
|
HIS_TRENDS_URL = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
|
||||||
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
||||||
BROWSER_USER_AGENT = (
|
BROWSER_USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
@@ -35,55 +42,186 @@ INDEX_SECIDS = {
|
|||||||
|
|
||||||
|
|
||||||
class MarketChartClient:
|
class MarketChartClient:
|
||||||
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
|
"""Display charts are served by the data hub only."""
|
||||||
|
|
||||||
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
|
def __init__(self, datahub: Any = None) -> None:
|
||||||
self.ifind = ifind
|
self.datahub = datahub
|
||||||
self.fallback = fallback
|
self.ifind = None
|
||||||
|
self.fallback = None
|
||||||
|
|
||||||
def stock_intraday(self, code: str) -> dict[str, Any]:
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
||||||
normalized = str(code or "").strip()
|
normalized = str(code or "").strip()
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
raise ChartDataError("Invalid stock code")
|
raise ChartDataError("Invalid stock code")
|
||||||
ifind_code = _stock_market_code(normalized)
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
try:
|
if hub_chart is not None:
|
||||||
return self._ifind_intraday(ifind_code, "stock", normalized)
|
return hub_chart
|
||||||
except (IfindError, ChartDataError):
|
raise ChartDataError("分时图数据中枢暂不可用")
|
||||||
return self.fallback.stock_intraday(normalized)
|
|
||||||
|
|
||||||
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def stock_daily(self, code: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
|
||||||
normalized = str(code or "").strip()
|
normalized = str(code or "").strip()
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
raise ChartDataError("Invalid stock code")
|
raise ChartDataError("Invalid stock code")
|
||||||
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "daily")
|
||||||
|
if hub_rows:
|
||||||
|
return hub_rows
|
||||||
|
raise ChartDataError("日K数据中枢暂不可用")
|
||||||
|
|
||||||
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def index_daily(self, identifier: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
if normalized not in INDEX_SECIDS:
|
if normalized not in INDEX_SECIDS:
|
||||||
raise ChartDataError("Unsupported index")
|
raise ChartDataError("Unsupported index")
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "index_daily")
|
||||||
|
if hub_rows:
|
||||||
|
return hub_rows
|
||||||
|
raise ChartDataError("指数日K数据中枢暂不可用")
|
||||||
|
|
||||||
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ChartDataError("Invalid board code")
|
raise ChartDataError("Invalid board code")
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "sector_daily")
|
||||||
|
if hub_rows:
|
||||||
|
return hub_rows
|
||||||
|
raise ChartDataError("板块日K数据中枢暂不可用")
|
||||||
|
|
||||||
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
if normalized not in INDEX_SECIDS:
|
if normalized not in INDEX_SECIDS:
|
||||||
raise ChartDataError("Unsupported index")
|
raise ChartDataError("Unsupported index")
|
||||||
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
|
if hub_chart is not None:
|
||||||
|
return hub_chart
|
||||||
|
raise ChartDataError("指数分时数据中枢暂不可用")
|
||||||
|
|
||||||
|
def _datahub_intraday(self, code: str) -> dict[str, Any] | None:
|
||||||
|
if self.datahub is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return self._ifind_intraday(normalized, "index", normalized)
|
chart = self.datahub.try_intraday(code)
|
||||||
except (IfindError, ChartDataError):
|
except Exception as exc:
|
||||||
return self.fallback.index_intraday(normalized)
|
LOGGER.warning("datahub intraday unexpected error: %s", exc)
|
||||||
|
return None
|
||||||
|
if not chart:
|
||||||
|
return None
|
||||||
|
points = list(chart.get("points") or [])
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
return chart
|
||||||
|
|
||||||
|
def _datahub_daily(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
end_date: str,
|
||||||
|
limit: int,
|
||||||
|
dataset: str,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
if self.datahub is None or not hasattr(self.datahub, "try_daily_chart"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
rows = self.datahub.try_daily_chart(code, end_date, limit, dataset)
|
||||||
|
except Exception as exc:
|
||||||
|
LOGGER.warning("datahub daily unexpected error: %s", exc)
|
||||||
|
rows = None
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
compact_end = str(end_date or "").replace("-", "")
|
||||||
|
market_now = datetime.now().astimezone()
|
||||||
|
today = market_now.strftime("%Y%m%d")
|
||||||
|
market_open = (
|
||||||
|
market_now.weekday() < 5
|
||||||
|
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||||
|
)
|
||||||
|
if compact_end == today and market_open:
|
||||||
|
overlay = self._datahub_today_bar(code, dataset, rows)
|
||||||
|
if overlay:
|
||||||
|
if rows and rows[-1]["trade_date"] == overlay["trade_date"]:
|
||||||
|
rows[-1] = overlay
|
||||||
|
else:
|
||||||
|
rows.append(overlay)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _datahub_today_bar(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
dataset: str,
|
||||||
|
history: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
today_display = datetime.now().astimezone().date().isoformat()
|
||||||
|
previous = history[-1]["close"] if history and history[-1]["trade_date"] != today_display else (
|
||||||
|
history[-2]["close"] if len(history) >= 2 else 0.0
|
||||||
|
)
|
||||||
|
quote = None
|
||||||
|
if dataset == "index_daily" and hasattr(self.datahub, "try_index_quotes"):
|
||||||
|
quotes = self.datahub.try_index_quotes() or []
|
||||||
|
quote = next(
|
||||||
|
(
|
||||||
|
item for item in quotes
|
||||||
|
if str(item.get("ts_code") or "") == code or str(item.get("code") or "") == code.split(".")[0]
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif hasattr(self.datahub, "try_quotes"):
|
||||||
|
quotes = self.datahub.try_quotes([code]) or []
|
||||||
|
quote = quotes[0] if quotes else None
|
||||||
|
if quote:
|
||||||
|
close = _number(quote.get("close") if quote.get("close") not in (None, "") else quote.get("price"))
|
||||||
|
open_price = _number(quote.get("open"))
|
||||||
|
high = _number(quote.get("high"))
|
||||||
|
low = _number(quote.get("low"))
|
||||||
|
previous_close = _number(
|
||||||
|
quote.get("pre_close") if quote.get("pre_close") not in (None, "") else quote.get("previous_close")
|
||||||
|
) or previous
|
||||||
|
volume = _number(quote.get("vol") if quote.get("vol") not in (None, "") else quote.get("volume"))
|
||||||
|
amount = _number(quote.get("amount"))
|
||||||
|
if close > 0 and open_price > 0:
|
||||||
|
return {
|
||||||
|
"trade_date": today_display,
|
||||||
|
"open": open_price,
|
||||||
|
"high": high or close,
|
||||||
|
"low": low or close,
|
||||||
|
"close": close,
|
||||||
|
"change": round((close / previous_close - 1) * 100, 4) if previous_close else 0.0,
|
||||||
|
"volume": volume,
|
||||||
|
"amount_billion": amount / 100_000_000,
|
||||||
|
"realtime": True,
|
||||||
|
}
|
||||||
|
chart = self._datahub_intraday(code)
|
||||||
|
points = list((chart or {}).get("points") or [])
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
closes = [_number(point.get("close")) for point in points if _number(point.get("close")) > 0]
|
||||||
|
if not closes:
|
||||||
|
return None
|
||||||
|
opens = [_number(point.get("open")) for point in points if _number(point.get("open")) > 0]
|
||||||
|
highs = [_number(point.get("high")) for point in points if _number(point.get("high")) > 0]
|
||||||
|
lows = [_number(point.get("low")) for point in points if _number(point.get("low")) > 0]
|
||||||
|
volume = sum(_number(point.get("volume")) for point in points)
|
||||||
|
amount = sum(_number(point.get("amount")) for point in points)
|
||||||
|
previous_close = _number((chart or {}).get("previous_close")) or previous
|
||||||
|
close = closes[-1]
|
||||||
|
open_price = opens[0] if opens else closes[0]
|
||||||
|
return {
|
||||||
|
"trade_date": today_display,
|
||||||
|
"open": open_price,
|
||||||
|
"high": max(highs or closes),
|
||||||
|
"low": min(lows or closes),
|
||||||
|
"close": close,
|
||||||
|
"change": round((close / previous_close - 1) * 100, 4) if previous_close else 0.0,
|
||||||
|
"volume": volume,
|
||||||
|
"amount_billion": amount / 100_000_000,
|
||||||
|
"realtime": True,
|
||||||
|
}
|
||||||
|
|
||||||
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
try:
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
return self._ifind_intraday(normalized, "board", normalized, name)
|
if hub_chart is not None:
|
||||||
except (IfindError, ChartDataError):
|
if name:
|
||||||
return self.fallback.board_intraday(normalized, name)
|
hub_chart = dict(hub_chart)
|
||||||
|
hub_chart["name"] = name
|
||||||
|
return hub_chart
|
||||||
|
raise ChartDataError("板块分时数据中枢暂不可用")
|
||||||
|
|
||||||
def _ifind_intraday(
|
def _ifind_intraday(
|
||||||
self,
|
self,
|
||||||
@@ -92,7 +230,7 @@ class MarketChartClient:
|
|||||||
identifier: str,
|
identifier: str,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self.ifind.configured:
|
if not self.ifind or not self.ifind.configured:
|
||||||
raise ChartDataError("iFinD is not configured")
|
raise ChartDataError("iFinD is not configured")
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
@@ -129,7 +267,7 @@ class MarketChartClient:
|
|||||||
def _ifind_daily(
|
def _ifind_daily(
|
||||||
self, ifind_code: str, end_date: str, limit: int
|
self, ifind_code: str, end_date: str, limit: int
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
if not self.ifind.configured:
|
if not self.ifind or not self.ifind.configured:
|
||||||
raise ChartDataError("iFinD is not configured")
|
raise ChartDataError("iFinD is not configured")
|
||||||
compact_end = str(end_date or "").replace("-", "")
|
compact_end = str(end_date or "").replace("-", "")
|
||||||
if not re.fullmatch(r"\d{8}", compact_end):
|
if not re.fullmatch(r"\d{8}", compact_end):
|
||||||
@@ -231,9 +369,11 @@ class MarketChartClient:
|
|||||||
pass
|
pass
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ChartDataError("No iFinD daily chart data returned")
|
raise ChartDataError("No iFinD daily chart data returned")
|
||||||
return normalized[-max(20, min(180, int(limit))):]
|
return normalized[-max(1, int(limit)):]
|
||||||
|
|
||||||
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
||||||
|
if not self.ifind:
|
||||||
|
return fallback
|
||||||
today = datetime.now().astimezone().date().isoformat()
|
today = datetime.now().astimezone().date().isoformat()
|
||||||
if trade_date == today:
|
if trade_date == today:
|
||||||
try:
|
try:
|
||||||
@@ -305,21 +445,29 @@ class EastmoneyChartClient:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
payload = self._request_json(
|
params = {
|
||||||
TRENDS_URL,
|
|
||||||
{
|
|
||||||
"secid": secid,
|
"secid": secid,
|
||||||
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
|
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
|
||||||
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
|
||||||
"iscr": "0",
|
"iscr": "0",
|
||||||
"ndays": "1",
|
}
|
||||||
},
|
last_error: Exception | None = None
|
||||||
"https://quote.eastmoney.com/",
|
data: dict[str, Any] = {}
|
||||||
)
|
points: list[dict[str, Any]] = []
|
||||||
|
for url, ndays in ((TRENDS_URL, "1"), (TRENDS_URL, "5"), (HIS_TRENDS_URL, "5")):
|
||||||
|
request_params = {**params, "ndays": ndays}
|
||||||
|
try:
|
||||||
|
payload = self._request_json(url, request_params, "https://quote.eastmoney.com/")
|
||||||
|
except ChartDataError as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
data = payload.get("data") or {}
|
data = payload.get("data") or {}
|
||||||
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
|
parsed = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
|
||||||
|
points = _latest_session(parsed)
|
||||||
|
if points:
|
||||||
|
break
|
||||||
if not points:
|
if not points:
|
||||||
raise ChartDataError("No intraday chart data returned")
|
raise ChartDataError("No intraday chart data returned") from last_error
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"entity_type": entity_type,
|
"entity_type": entity_type,
|
||||||
@@ -433,6 +581,15 @@ class EastmoneyChartClient:
|
|||||||
raise ChartDataError("Intraday chart request failed") from last_error
|
raise ChartDataError("Intraday chart request failed") from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_session(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
latest = max(str(point.get("date") or "") for point in points)
|
||||||
|
if not latest:
|
||||||
|
return points
|
||||||
|
return [point for point in points if str(point.get("date") or "") == latest]
|
||||||
|
|
||||||
|
|
||||||
def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
def _parse_trend(raw: Any) -> dict[str, Any] | None:
|
||||||
fields = str(raw or "").split(",")
|
fields = str(raw or "").split(",")
|
||||||
if len(fields) < 8 or " " not in fields[0]:
|
if len(fields) < 8 or " " not in fields[0]:
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from backend.bootstrap.config import (
|
|||||||
validate_text,
|
validate_text,
|
||||||
)
|
)
|
||||||
from backend.data.providers.ifind_client import IfindError
|
from backend.data.providers.ifind_client import IfindError
|
||||||
from backend.data.providers.tushare_client import TushareClient, TushareError
|
from backend.data.providers.tushare_client import TushareError
|
||||||
|
from backend.data.providers.tushare_helpers import _moneyflow_payload, _optional_number
|
||||||
|
from backend.data.realtime import RealtimeAggregateError
|
||||||
from backend.features.market.backfill_history import (
|
from backend.features.market.backfill_history import (
|
||||||
DEFAULT_RECENT_TRADING_DAYS,
|
DEFAULT_RECENT_TRADING_DAYS,
|
||||||
MAX_RANGE_TRADING_DAYS,
|
MAX_RANGE_TRADING_DAYS,
|
||||||
@@ -26,7 +28,7 @@ from backend.features.market.backfill_history import (
|
|||||||
select_open_trade_dates,
|
select_open_trade_dates,
|
||||||
select_open_trade_dates_in_range,
|
select_open_trade_dates_in_range,
|
||||||
)
|
)
|
||||||
from backend.features.market.charts import ChartDataError
|
from backend.features.market.charts import ChartDataError, DAILY_CHART_LIMIT
|
||||||
from backend.features.market.insights import MarketInsightsService
|
from backend.features.market.insights import MarketInsightsService
|
||||||
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
|
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ SEARCH_TYPE_LABELS = {
|
|||||||
"theme": "题材",
|
"theme": "题材",
|
||||||
"index": "指数",
|
"index": "指数",
|
||||||
}
|
}
|
||||||
|
TODAY_DAILY_UNAVAILABLE_NOTICE = "今日日K暂不可用,仍显示最近收盘K线。"
|
||||||
THS_SEARCH_TYPES = {
|
THS_SEARCH_TYPES = {
|
||||||
"I": ("sector", "行业板块"),
|
"I": ("sector", "行业板块"),
|
||||||
"R": ("sector", "地域板块"),
|
"R": ("sector", "地域板块"),
|
||||||
@@ -58,16 +61,40 @@ class MarketServiceMixin:
|
|||||||
self._tushare_client(),
|
self._tushare_client(),
|
||||||
ifind=self.ifind,
|
ifind=self.ifind,
|
||||||
)
|
)
|
||||||
def _tushare_client(self) -> TushareClient:
|
def _tushare_client(self) -> Any:
|
||||||
|
override = getattr(self, "_market_client_override", None)
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
gateway = getattr(self, "data_gateway", None)
|
gateway = getattr(self, "data_gateway", None)
|
||||||
if gateway is not None:
|
if gateway is None:
|
||||||
|
raise RuntimeError("数据中枢尚未装配。")
|
||||||
return gateway.tushare()
|
return gateway.tushare()
|
||||||
# Compatibility for isolated legacy unit-test service stubs.
|
|
||||||
return TushareClient(self.token)
|
def _now(self) -> datetime:
|
||||||
|
clock = getattr(self, "clock", None)
|
||||||
|
if callable(clock):
|
||||||
|
return clock()
|
||||||
|
return datetime.now().astimezone()
|
||||||
|
|
||||||
|
def _is_requested_open_session(self, requested_date: str) -> bool:
|
||||||
|
now = self._now()
|
||||||
|
if requested_date != now.strftime("%Y%m%d"):
|
||||||
|
return False
|
||||||
|
if now.time().replace(tzinfo=None) < dt_time(9, 15):
|
||||||
|
return False
|
||||||
|
client = self._tushare_client() if self.configured else None
|
||||||
|
resolve = getattr(client, "resolve_trade_context", None) if client else None
|
||||||
|
if resolve is None:
|
||||||
|
return now.weekday() < 5
|
||||||
|
try:
|
||||||
|
trade_date, _ = resolve(requested_date)
|
||||||
|
except Exception:
|
||||||
|
return now.weekday() < 5
|
||||||
|
return str(trade_date or "") == requested_date
|
||||||
|
|
||||||
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
|
||||||
normalized_date = normalize_date(trade_date)
|
normalized_date = normalize_date(trade_date)
|
||||||
now = datetime.now().astimezone()
|
now = self._now()
|
||||||
if (
|
if (
|
||||||
normalized_date == now.strftime("%Y%m%d")
|
normalized_date == now.strftime("%Y%m%d")
|
||||||
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
|
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
|
||||||
@@ -79,6 +106,8 @@ class MarketServiceMixin:
|
|||||||
if not force:
|
if not force:
|
||||||
snapshot = self.database.get_snapshot(normalized_date)
|
snapshot = self.database.get_snapshot(normalized_date)
|
||||||
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
|
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
|
||||||
|
if self._should_retry_incomplete_snapshot(snapshot, normalized_date):
|
||||||
|
return self.sync_dashboard(normalized_date)
|
||||||
snapshot = copy.deepcopy(snapshot)
|
snapshot = copy.deepcopy(snapshot)
|
||||||
if normalized_date != now.strftime("%Y%m%d"):
|
if normalized_date != now.strftime("%Y%m%d"):
|
||||||
snapshot.setdefault("meta", {}).update(
|
snapshot.setdefault("meta", {}).update(
|
||||||
@@ -97,6 +126,8 @@ class MarketServiceMixin:
|
|||||||
"dashboard_request_v1", normalized_date
|
"dashboard_request_v1", normalized_date
|
||||||
)
|
)
|
||||||
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
|
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
|
||||||
|
if self._should_retry_incomplete_snapshot(resolved, normalized_date):
|
||||||
|
return self.sync_dashboard(normalized_date)
|
||||||
resolved = copy.deepcopy(resolved)
|
resolved = copy.deepcopy(resolved)
|
||||||
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
|
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
|
||||||
normalized_date
|
normalized_date
|
||||||
@@ -138,6 +169,70 @@ class MarketServiceMixin:
|
|||||||
def _display_compact_date(compact: str) -> str:
|
def _display_compact_date(compact: str) -> str:
|
||||||
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
|
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _chinese_month_day(value: str) -> str:
|
||||||
|
compact = str(value or "").replace("-", "").replace("/", "")
|
||||||
|
if len(compact) < 8 or not compact[:8].isdigit():
|
||||||
|
return "最近可用交易日"
|
||||||
|
return f"{int(compact[4:6])} 月 {int(compact[6:8])} 日"
|
||||||
|
|
||||||
|
def _preparing_display_notice(self, actual_date: str, requested_date: str) -> str:
|
||||||
|
shown = self._chinese_month_day(actual_date)
|
||||||
|
requested = str(requested_date or "").replace("-", "")
|
||||||
|
if requested == self._now().strftime("%Y%m%d"):
|
||||||
|
return f"今日数据正在准备,当前展示 {shown}"
|
||||||
|
return f"所选日期数据尚未到齐,当前展示 {shown}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot_age_seconds(meta: dict[str, Any]) -> float:
|
||||||
|
raw = str(meta.get("updated_at") or "")
|
||||||
|
if not raw:
|
||||||
|
return 10**9
|
||||||
|
try:
|
||||||
|
updated_at = datetime.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
return 10**9
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if updated_at.tzinfo is None:
|
||||||
|
updated_at = updated_at.replace(tzinfo=now.tzinfo)
|
||||||
|
return (now - updated_at.astimezone(now.tzinfo)).total_seconds()
|
||||||
|
|
||||||
|
def _should_retry_incomplete_snapshot(
|
||||||
|
self, snapshot: dict[str, Any], requested_date: str
|
||||||
|
) -> bool:
|
||||||
|
if requested_date != self._now().strftime("%Y%m%d"):
|
||||||
|
return False
|
||||||
|
meta = snapshot.get("meta") or {}
|
||||||
|
actual = str(meta.get("trade_date") or "").replace("-", "")
|
||||||
|
stale_carry = bool(meta.get("carried_forward") or actual != requested_date)
|
||||||
|
if stale_carry and self._is_requested_open_session(requested_date):
|
||||||
|
return True
|
||||||
|
incomplete = meta.get("limit_data_source") == "derived" or stale_carry
|
||||||
|
return incomplete and self._snapshot_age_seconds(meta) >= 60
|
||||||
|
|
||||||
|
def _annotate_data_status(self, dashboard: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
meta = dashboard.setdefault("meta", {})
|
||||||
|
notice = str(meta.get("notice") or "")
|
||||||
|
requested = str(meta.get("requested_date") or "").replace("-", "")
|
||||||
|
actual = str(meta.get("trade_date") or "").replace("-", "")
|
||||||
|
if meta.get("limit_data_source") == "derived" and not meta.get("carried_forward"):
|
||||||
|
meta["data_status"] = "partial"
|
||||||
|
meta["display_notice"] = notice or "部分正式数据尚未到齐,当前展示日线推算结果"
|
||||||
|
elif meta.get("carried_forward"):
|
||||||
|
if "非交易日" in notice or "盘前" in notice:
|
||||||
|
meta["data_status"] = "carried"
|
||||||
|
meta["display_notice"] = notice
|
||||||
|
else:
|
||||||
|
meta["data_status"] = "preparing"
|
||||||
|
meta["display_notice"] = self._preparing_display_notice(actual, requested)
|
||||||
|
elif meta.get("realtime"):
|
||||||
|
meta["data_status"] = "intraday"
|
||||||
|
meta.setdefault("display_notice", "")
|
||||||
|
else:
|
||||||
|
meta["data_status"] = "official"
|
||||||
|
meta.setdefault("display_notice", "")
|
||||||
|
return dashboard
|
||||||
|
|
||||||
def _carry_dashboard(
|
def _carry_dashboard(
|
||||||
self, snapshot: dict[str, Any], requested_date: str, reason: str
|
self, snapshot: dict[str, Any], requested_date: str, reason: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -152,16 +247,16 @@ class MarketServiceMixin:
|
|||||||
"notice": reason,
|
"notice": reason,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return carried
|
return self._annotate_data_status(carried)
|
||||||
|
|
||||||
def _realtime_snapshot_due(
|
def _realtime_snapshot_due(
|
||||||
self,
|
self,
|
||||||
normalized_date: str,
|
normalized_date: str,
|
||||||
snapshot: dict[str, Any],
|
snapshot: dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
|
if not self.configured or normalized_date != self._now().strftime("%Y%m%d"):
|
||||||
return False
|
return False
|
||||||
now = datetime.now().astimezone()
|
now = self._now()
|
||||||
local_time = now.time().replace(tzinfo=None)
|
local_time = now.time().replace(tzinfo=None)
|
||||||
realtime_start = datetime.strptime("09:15", "%H:%M").time()
|
realtime_start = datetime.strptime("09:15", "%H:%M").time()
|
||||||
morning_end = datetime.strptime("11:35", "%H:%M").time()
|
morning_end = datetime.strptime("11:35", "%H:%M").time()
|
||||||
@@ -197,19 +292,28 @@ class MarketServiceMixin:
|
|||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise TushareError("公共行情尚未配置")
|
raise TushareError("公共行情尚未配置")
|
||||||
dashboard = self._tushare_client().dashboard(normalized_date)
|
dashboard = self._tushare_client().dashboard(normalized_date)
|
||||||
|
meta = dashboard.setdefault("meta", {})
|
||||||
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived":
|
quote_source = str(meta.get("quote_source") or "")
|
||||||
raise TushareError(
|
meta["source"] = source
|
||||||
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回")
|
if quote_source:
|
||||||
|
meta["quote_source"] = quote_source
|
||||||
|
meta["requested_date"] = self._display_compact_date(normalized_date)
|
||||||
|
if meta.get("limit_data_source") == "derived":
|
||||||
|
meta.setdefault(
|
||||||
|
"notice",
|
||||||
|
"涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
|
||||||
)
|
)
|
||||||
|
|
||||||
dashboard["meta"]["source"] = source
|
|
||||||
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
|
|
||||||
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
|
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
|
||||||
record_count = self._record_count(dashboard)
|
record_count = self._record_count(dashboard)
|
||||||
actual_date = normalize_date(
|
actual_date = normalize_date(
|
||||||
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
|
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
|
||||||
)
|
)
|
||||||
|
if actual_date != normalized_date and self._is_requested_open_session(
|
||||||
|
normalized_date
|
||||||
|
):
|
||||||
|
raise TushareError(
|
||||||
|
f"Intraday dashboard resolved {actual_date} instead of {normalized_date}"
|
||||||
|
)
|
||||||
self.database.save_snapshot(actual_date, source, dashboard)
|
self.database.save_snapshot(actual_date, source, dashboard)
|
||||||
if actual_date != normalized_date:
|
if actual_date != normalized_date:
|
||||||
dashboard.setdefault("meta", {}).update(
|
dashboard.setdefault("meta", {}).update(
|
||||||
@@ -231,10 +335,37 @@ class MarketServiceMixin:
|
|||||||
)
|
)
|
||||||
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
|
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
|
if self._is_requested_open_session(normalized_date):
|
||||||
|
existing = self.database.get_snapshot(normalized_date)
|
||||||
|
existing_date = str(
|
||||||
|
((existing or {}).get("meta") or {}).get("trade_date") or ""
|
||||||
|
).replace("-", "")
|
||||||
|
if existing and existing_date == normalized_date:
|
||||||
|
kept = copy.deepcopy(existing)
|
||||||
|
kept.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"requested_date": self._display_compact_date(normalized_date),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.database.finish_sync(
|
||||||
|
sync_id,
|
||||||
|
"fallback",
|
||||||
|
self._record_count(kept),
|
||||||
|
str(exc),
|
||||||
|
"tushare",
|
||||||
|
)
|
||||||
|
return self._apply_reason_overrides(
|
||||||
|
self._with_storage(kept, cached=True)
|
||||||
|
)
|
||||||
|
self.database.finish_sync(sync_id, "failed", message=str(exc))
|
||||||
|
raise ValueError("当天盘中行情暂时不可用,请稍后重试。") from exc
|
||||||
fallback = self.database.get_latest_real_snapshot(normalized_date)
|
fallback = self.database.get_latest_real_snapshot(normalized_date)
|
||||||
if fallback:
|
if fallback:
|
||||||
|
actual = str((fallback.get("meta") or {}).get("trade_date") or "")
|
||||||
carried = self._carry_dashboard(
|
carried = self._carry_dashboard(
|
||||||
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
|
fallback,
|
||||||
|
normalized_date,
|
||||||
|
self._preparing_display_notice(actual, normalized_date),
|
||||||
)
|
)
|
||||||
self.database.finish_sync(
|
self.database.finish_sync(
|
||||||
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
|
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
|
||||||
@@ -544,7 +675,7 @@ class MarketServiceMixin:
|
|||||||
"index_daily",
|
"index_daily",
|
||||||
{
|
{
|
||||||
"ts_code": basic["id"],
|
"ts_code": basic["id"],
|
||||||
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
|
"start_date": (end - timedelta(days=400)).strftime("%Y%m%d"),
|
||||||
"end_date": resolved_date,
|
"end_date": resolved_date,
|
||||||
},
|
},
|
||||||
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||||
@@ -560,10 +691,10 @@ class MarketServiceMixin:
|
|||||||
"change": float(row.get("pct_chg") or 0),
|
"change": float(row.get("pct_chg") or 0),
|
||||||
"volume": float(row.get("vol") or 0),
|
"volume": float(row.get("vol") or 0),
|
||||||
}
|
}
|
||||||
for row in rows[-90:]
|
for row in rows[-DAILY_CHART_LIMIT:]
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
|
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, DAILY_CHART_LIMIT)
|
||||||
if chart_series:
|
if chart_series:
|
||||||
series = chart_series
|
series = chart_series
|
||||||
except (AttributeError, ChartDataError):
|
except (AttributeError, ChartDataError):
|
||||||
@@ -671,7 +802,7 @@ class MarketServiceMixin:
|
|||||||
result = copy.deepcopy(payload)
|
result = copy.deepcopy(payload)
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
try:
|
try:
|
||||||
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
|
result["prices"] = self.chart_data.stock_daily(code, requested_date, DAILY_CHART_LIMIT)
|
||||||
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
||||||
except (AttributeError, ChartDataError):
|
except (AttributeError, ChartDataError):
|
||||||
pass
|
pass
|
||||||
@@ -683,27 +814,28 @@ class MarketServiceMixin:
|
|||||||
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
|
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
|
||||||
}
|
}
|
||||||
today = now.strftime("%Y%m%d")
|
today = now.strftime("%Y%m%d")
|
||||||
|
latest_bar = (result.get("prices") or [{}])[-1] if result.get("prices") else {}
|
||||||
|
official_today = (
|
||||||
|
actual_date == today and not bool(latest_bar.get("realtime"))
|
||||||
|
)
|
||||||
|
after_close = now.time().replace(tzinfo=None) >= dt_time(15, 0)
|
||||||
should_merge = (
|
should_merge = (
|
||||||
requested_date == today
|
requested_date == today
|
||||||
and actual_date <= today
|
and actual_date <= today
|
||||||
and now.weekday() < 5
|
and now.weekday() < 5
|
||||||
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
|
||||||
|
and not (official_today and after_close)
|
||||||
)
|
)
|
||||||
if should_merge:
|
if should_merge:
|
||||||
quote = self._ifind_realtime_stock_quote(code)
|
quote = self._resolve_today_daily_quote(code, today, result)
|
||||||
if quote and self._valid_realtime_stock_quote(quote, today):
|
if quote and self._valid_realtime_stock_quote(quote, today):
|
||||||
self._merge_realtime_stock_detail(result, quote, requested_date)
|
self._merge_realtime_stock_detail(result, quote, requested_date)
|
||||||
elif self.configured and actual_date < today:
|
elif actual_date < today:
|
||||||
client = self._tushare_client()
|
result["meta"] = {
|
||||||
try:
|
**(result.get("meta") or {}),
|
||||||
resolved_date, _ = client.resolve_trade_context(requested_date)
|
"notice": TODAY_DAILY_UNAVAILABLE_NOTICE,
|
||||||
if resolved_date == today:
|
}
|
||||||
quote = client.realtime_stock_quote(tushare_code(code), requested_date)
|
return self._enrich_stock_detail(result, requested_date)
|
||||||
if self._valid_realtime_stock_quote(quote, today):
|
|
||||||
self._merge_realtime_stock_detail(result, quote, requested_date)
|
|
||||||
except TushareError:
|
|
||||||
pass
|
|
||||||
return self._enrich_stock_detail(result)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_stock_detail_prices(
|
def _sanitize_stock_detail_prices(
|
||||||
@@ -817,6 +949,138 @@ class MarketServiceMixin:
|
|||||||
"quote_time": str(row.get("time") or ""),
|
"quote_time": str(row.get("time") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _resolve_today_daily_quote(
|
||||||
|
self, code: str, today: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
quote = self._ifind_realtime_stock_quote(code)
|
||||||
|
if quote and self._valid_realtime_stock_quote(quote, today):
|
||||||
|
return quote
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
client = self._tushare_client()
|
||||||
|
resolve = getattr(client, "resolve_trade_context", None)
|
||||||
|
resolved = today
|
||||||
|
if callable(resolve):
|
||||||
|
resolved, _ = resolve(today)
|
||||||
|
if str(resolved or "") == today:
|
||||||
|
quote = client.realtime_stock_quote(tushare_code(code), today)
|
||||||
|
if self._valid_realtime_stock_quote(quote, today):
|
||||||
|
return quote
|
||||||
|
except TushareError:
|
||||||
|
pass
|
||||||
|
quote = self._free_realtime_stock_quote(code, today)
|
||||||
|
if quote and self._valid_realtime_stock_quote(quote, today):
|
||||||
|
return quote
|
||||||
|
return self._intraday_realtime_stock_quote(code, today, payload)
|
||||||
|
|
||||||
|
def _free_realtime_stock_quote(self, code: str, today: str) -> dict[str, Any] | None:
|
||||||
|
aggregator = getattr(self, "realtime_aggregator", None)
|
||||||
|
if aggregator is None:
|
||||||
|
return None
|
||||||
|
ts_code = tushare_code(code)
|
||||||
|
for loader in (
|
||||||
|
getattr(aggregator, "tencent_stock_quote", None),
|
||||||
|
getattr(aggregator, "eastmoney_stock_quote", None),
|
||||||
|
):
|
||||||
|
if not callable(loader):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = loader(ts_code, expected_date=today)
|
||||||
|
except (RealtimeAggregateError, Exception):
|
||||||
|
continue
|
||||||
|
quote = self._quote_from_free_row(code, today, row)
|
||||||
|
if quote:
|
||||||
|
return quote
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _quote_from_free_row(
|
||||||
|
self, code: str, today: str, row: dict[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
price = float(row.get("close") or 0)
|
||||||
|
previous_close = float(row.get("pre_close") or 0)
|
||||||
|
if price <= 0 or previous_close <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
name, sector = self._stock_identity(code, today)
|
||||||
|
except Exception:
|
||||||
|
name, sector = "--", "其他"
|
||||||
|
epoch = int(row.get("quote_time_epoch") or 0)
|
||||||
|
if epoch > 0:
|
||||||
|
quote_time = datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
else:
|
||||||
|
quote_date = str(row.get("quote_date") or today)
|
||||||
|
quote_time = f"{quote_date[:4]}-{quote_date[4:6]}-{quote_date[6:]}"
|
||||||
|
quote = {
|
||||||
|
"name": str(row.get("name") or name or "--"),
|
||||||
|
"sector": sector,
|
||||||
|
"price": price,
|
||||||
|
"open": float(row.get("open") or 0),
|
||||||
|
"high": float(row.get("high") or 0),
|
||||||
|
"low": float(row.get("low") or 0),
|
||||||
|
"change": round((price / previous_close - 1) * 100, 4),
|
||||||
|
"volume": float(row.get("vol") or 0),
|
||||||
|
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
|
||||||
|
"turnover_rate": float(row.get("turnover_rate") or 0),
|
||||||
|
"quote_time": quote_time,
|
||||||
|
}
|
||||||
|
flow = _moneyflow_payload(row)
|
||||||
|
if flow.get("available"):
|
||||||
|
quote["moneyflow"] = flow
|
||||||
|
return quote
|
||||||
|
|
||||||
|
def _intraday_realtime_stock_quote(
|
||||||
|
self, code: str, today: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
chart_data = getattr(self, "chart_data", None)
|
||||||
|
if chart_data is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
chart = chart_data.stock_intraday(code)
|
||||||
|
except (AttributeError, ChartDataError, Exception):
|
||||||
|
return None
|
||||||
|
points = [
|
||||||
|
point
|
||||||
|
for point in list(chart.get("points") or [])
|
||||||
|
if str(point.get("date") or "").replace("-", "") == today
|
||||||
|
]
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
opens = [float(point.get("open") or 0) for point in points if float(point.get("open") or 0) > 0]
|
||||||
|
highs = [float(point.get("high") or 0) for point in points if float(point.get("high") or 0) > 0]
|
||||||
|
lows = [float(point.get("low") or 0) for point in points if float(point.get("low") or 0) > 0]
|
||||||
|
closes = [float(point.get("close") or 0) for point in points if float(point.get("close") or 0) > 0]
|
||||||
|
if not opens or not highs or not lows or not closes:
|
||||||
|
return None
|
||||||
|
price = closes[-1]
|
||||||
|
previous_close = float(chart.get("previous_close") or 0)
|
||||||
|
if previous_close <= 0:
|
||||||
|
history = list(payload.get("prices") or [])
|
||||||
|
previous_close = float((history[-1] if history else {}).get("close") or 0)
|
||||||
|
if previous_close <= 0:
|
||||||
|
return None
|
||||||
|
volume = sum(float(point.get("volume") or 0) for point in points)
|
||||||
|
amount = sum(float(point.get("amount") or 0) for point in points)
|
||||||
|
if volume <= 0 and amount <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
name, sector = self._stock_identity(code, today)
|
||||||
|
except Exception:
|
||||||
|
name, sector = "--", "其他"
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sector": sector,
|
||||||
|
"price": price,
|
||||||
|
"open": opens[0],
|
||||||
|
"high": max(highs),
|
||||||
|
"low": min(lows),
|
||||||
|
"change": round((price / previous_close - 1) * 100, 4),
|
||||||
|
"volume": volume,
|
||||||
|
"volume_unit": "lots",
|
||||||
|
"amount_billion": amount / 100_000_000,
|
||||||
|
"turnover_rate": 0.0,
|
||||||
|
"quote_time": str(points[-1].get("date") or today),
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _merge_realtime_stock_detail(
|
def _merge_realtime_stock_detail(
|
||||||
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
|
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
|
||||||
@@ -838,23 +1102,29 @@ class MarketServiceMixin:
|
|||||||
prices[-1] = realtime_bar
|
prices[-1] = realtime_bar
|
||||||
else:
|
else:
|
||||||
prices.append(realtime_bar)
|
prices.append(realtime_bar)
|
||||||
payload["prices"] = prices[-90:]
|
payload["prices"] = prices[-DAILY_CHART_LIMIT:]
|
||||||
stock = dict(payload.get("stock") or {})
|
stock = dict(payload.get("stock") or {})
|
||||||
stock.update(
|
updates = {
|
||||||
{
|
|
||||||
"name": quote["name"],
|
"name": quote["name"],
|
||||||
"industry": quote["sector"],
|
"industry": quote["sector"],
|
||||||
"price": quote["price"],
|
"price": quote["price"],
|
||||||
"change": quote["change"],
|
"change": quote["change"],
|
||||||
"amount_billion": quote["amount_billion"],
|
"amount_billion": quote["amount_billion"],
|
||||||
"turnover_rate": quote["turnover_rate"],
|
|
||||||
}
|
}
|
||||||
)
|
quote_turnover = _optional_number(quote.get("turnover_rate"))
|
||||||
|
if quote_turnover:
|
||||||
|
updates["turnover_rate"] = quote_turnover
|
||||||
|
stock.update(updates)
|
||||||
payload["stock"] = stock
|
payload["stock"] = stock
|
||||||
|
quote_flow = quote.get("moneyflow")
|
||||||
|
current_flow = payload.get("moneyflow") or {}
|
||||||
|
if isinstance(quote_flow, dict) and quote_flow.get("available") and not current_flow.get("available"):
|
||||||
|
payload["moneyflow"] = quote_flow
|
||||||
payload["meta"] = {
|
payload["meta"] = {
|
||||||
**(payload.get("meta") or {}),
|
**(payload.get("meta") or {}),
|
||||||
"trade_date": display_date,
|
"trade_date": display_date,
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
|
"notice": "",
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +1158,7 @@ class MarketServiceMixin:
|
|||||||
intraday_status = "unavailable"
|
intraday_status = "unavailable"
|
||||||
intraday_notice = "分时行情暂不可用,请稍后重试。"
|
intraday_notice = "分时行情暂不可用,请稍后重试。"
|
||||||
|
|
||||||
prices = list(detail.get("prices") or [])[-60:]
|
prices = list(detail.get("prices") or [])[-DAILY_CHART_LIMIT:]
|
||||||
stock = dict(detail.get("stock") or {"code": code})
|
stock = dict(detail.get("stock") or {"code": code})
|
||||||
realtime = bool(detail_meta.get("realtime"))
|
realtime = bool(detail_meta.get("realtime"))
|
||||||
return {
|
return {
|
||||||
@@ -1140,10 +1410,40 @@ class MarketServiceMixin:
|
|||||||
return item["name"], item["sector"] or "其他"
|
return item["name"], item["sector"] or "其他"
|
||||||
return "--", "其他"
|
return "--", "其他"
|
||||||
|
|
||||||
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _enrich_stock_detail(
|
||||||
|
self, payload: dict[str, Any], trade_date: str = ""
|
||||||
|
) -> dict[str, Any]:
|
||||||
result = dict(payload)
|
result = dict(payload)
|
||||||
stock = dict(payload.get("stock") or {})
|
stock = dict(payload.get("stock") or {})
|
||||||
code = str(stock.get("code") or "")
|
code = str(stock.get("code") or "")
|
||||||
|
compact_date = normalize_date(
|
||||||
|
str((payload.get("meta") or {}).get("trade_date") or trade_date)
|
||||||
|
)
|
||||||
|
board = self._limit_event_for_stock(code, compact_date)
|
||||||
|
if board:
|
||||||
|
if not stock.get("first_time") or stock.get("first_time") == "--":
|
||||||
|
stock["first_time"] = board.get("first_time") or "--"
|
||||||
|
if not stock.get("last_time") or stock.get("last_time") == "--":
|
||||||
|
stock["last_time"] = board.get("last_time") or "--"
|
||||||
|
if not stock.get("open_times"):
|
||||||
|
stock["open_times"] = board.get("open_times") or 0
|
||||||
|
if _optional_number(stock.get("seal_amount_million")) is None:
|
||||||
|
stock["seal_amount_million"] = board.get("seal_amount_million")
|
||||||
|
if not _optional_number(stock.get("turnover_rate")) and _optional_number(board.get("turnover_rate")):
|
||||||
|
stock["turnover_rate"] = board.get("turnover_rate")
|
||||||
|
flow = result.get("moneyflow") or {}
|
||||||
|
if not flow.get("available"):
|
||||||
|
live_flow = self._live_moneyflow_for_stock(code, compact_date)
|
||||||
|
if live_flow.get("available"):
|
||||||
|
result["moneyflow"] = live_flow
|
||||||
|
else:
|
||||||
|
result["moneyflow"] = {
|
||||||
|
"available": False,
|
||||||
|
"net_million": None,
|
||||||
|
"large_million": None,
|
||||||
|
"medium_million": None,
|
||||||
|
"small_million": None,
|
||||||
|
}
|
||||||
watched = {
|
watched = {
|
||||||
item["code"]: item
|
item["code"]: item
|
||||||
for item in self.database.list_watchlist(self.current_user_id)
|
for item in self.database.list_watchlist(self.current_user_id)
|
||||||
@@ -1153,6 +1453,45 @@ class MarketServiceMixin:
|
|||||||
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
|
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _limit_event_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
if not code or not trade_date:
|
||||||
|
return {}
|
||||||
|
ts_code = tushare_code(code)
|
||||||
|
client = self._tushare_client() if self.configured else None
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
rows = client._load_limit_type(trade_date, "U") + client._load_limit_type(trade_date, "Z")
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
if not rows:
|
||||||
|
try:
|
||||||
|
rows = list((client._free_board_map(trade_date) or {}).values())
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
match = next((row for row in rows if str(row.get("ts_code") or "") == ts_code), None)
|
||||||
|
if not match:
|
||||||
|
return {}
|
||||||
|
fd = _optional_number(match.get("fd_amount"))
|
||||||
|
return {
|
||||||
|
"first_time": match.get("first_time") or "--",
|
||||||
|
"last_time": match.get("last_time") or "--",
|
||||||
|
"open_times": match.get("open_times") or 0,
|
||||||
|
"seal_amount_million": None if fd is None else round(fd / 10000, 0),
|
||||||
|
"turnover_rate": _optional_number(match.get("turnover_ratio")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _live_moneyflow_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
aggregator = getattr(self, "realtime_aggregator", None)
|
||||||
|
loader = getattr(aggregator, "eastmoney_stock_quote", None) if aggregator else None
|
||||||
|
if not callable(loader) or not code:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
try:
|
||||||
|
quote = loader(tushare_code(code), expected_date=trade_date)
|
||||||
|
except Exception:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
return _moneyflow_payload(quote)
|
||||||
|
|
||||||
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
|
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
|
||||||
result = dict(dashboard)
|
result = dict(dashboard)
|
||||||
result["meta"] = {
|
result["meta"] = {
|
||||||
@@ -1160,7 +1499,7 @@ class MarketServiceMixin:
|
|||||||
"storage": "sqlite",
|
"storage": "sqlite",
|
||||||
"cached": cached,
|
"cached": cached,
|
||||||
}
|
}
|
||||||
return result
|
return self._annotate_data_status(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _record_count(dashboard: dict[str, Any]) -> int:
|
def _record_count(dashboard: dict[str, Any]) -> int:
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.bootstrap.config import TOKEN_PATTERN, validate_text
|
from backend.bootstrap.config import validate_text
|
||||||
|
|
||||||
|
|
||||||
|
MARKET_SOURCE_SECRET_KEYS = {
|
||||||
|
"tushare_token",
|
||||||
|
"ifind_refresh_token",
|
||||||
|
"ifind_access_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SystemServiceMixin:
|
class SystemServiceMixin:
|
||||||
@@ -18,9 +25,6 @@ class SystemServiceMixin:
|
|||||||
first_encrypted = self.database.get_user_credentials(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 {}
|
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
|
||||||
defaults = {
|
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_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_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_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
|
||||||
@@ -34,6 +38,10 @@ class SystemServiceMixin:
|
|||||||
if key not in current:
|
if key not in current:
|
||||||
current[key] = value
|
current[key] = value
|
||||||
changed = True
|
changed = True
|
||||||
|
for key in MARKET_SOURCE_SECRET_KEYS:
|
||||||
|
if key in current:
|
||||||
|
current.pop(key, None)
|
||||||
|
changed = True
|
||||||
if not isinstance(current.get("llm_models"), list):
|
if not isinstance(current.get("llm_models"), list):
|
||||||
migrated_models: list[dict[str, str]] = []
|
migrated_models: list[dict[str, str]] = []
|
||||||
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
|
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
|
||||||
@@ -56,26 +64,27 @@ class SystemServiceMixin:
|
|||||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
|
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
|
||||||
for row in self.database.list_user_credentials():
|
for row in self.database.list_user_credentials():
|
||||||
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
|
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
|
||||||
if "tushare_token" in personal:
|
if any(key in personal for key in MARKET_SOURCE_SECRET_KEYS):
|
||||||
personal.pop("tushare_token", None)
|
for key in MARKET_SOURCE_SECRET_KEYS:
|
||||||
|
personal.pop(key, None)
|
||||||
self.database.save_user_credentials(
|
self.database.save_user_credentials(
|
||||||
int(row["user_id"]), self.vault.encrypt_json(personal)
|
int(row["user_id"]), self.vault.encrypt_json(personal)
|
||||||
)
|
)
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
|
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
|
||||||
|
sanitized = {
|
||||||
|
key: value
|
||||||
|
for key, value in credentials.items()
|
||||||
|
if key not in MARKET_SOURCE_SECRET_KEYS
|
||||||
|
}
|
||||||
with self.system_lock:
|
with self.system_lock:
|
||||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
|
self.database.save_system_setting("credentials", self.vault.encrypt_json(sanitized))
|
||||||
self._system_credentials = dict(credentials)
|
self._system_credentials = dict(sanitized)
|
||||||
if hasattr(self, "ifind"):
|
|
||||||
self.ifind.set_credentials(
|
|
||||||
str(credentials.get("ifind_refresh_token") or ""),
|
|
||||||
str(credentials.get("ifind_access_token") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.token)
|
return bool(self._datahub_status().get("configured"))
|
||||||
|
|
||||||
def _credentials(self) -> dict[str, str]:
|
def _credentials(self) -> dict[str, str]:
|
||||||
credentials = getattr(self._request_context, "credentials", {})
|
credentials = getattr(self._request_context, "credentials", {})
|
||||||
@@ -99,7 +108,7 @@ class SystemServiceMixin:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def token(self) -> str:
|
def token(self) -> str:
|
||||||
return str(self._system_credentials.get("tushare_token") or "")
|
return "datahub" if self.configured else ""
|
||||||
|
|
||||||
def system_status(self) -> dict[str, Any]:
|
def system_status(self) -> dict[str, Any]:
|
||||||
platform = self._platform_llm_profile()
|
platform = self._platform_llm_profile()
|
||||||
@@ -119,6 +128,7 @@ class SystemServiceMixin:
|
|||||||
"base_url": profile["base_url"],
|
"base_url": profile["base_url"],
|
||||||
"model": profile["model"],
|
"model": profile["model"],
|
||||||
"configured": self._profile_configured(profile),
|
"configured": self._profile_configured(profile),
|
||||||
|
"api_key_last4": profile["api_key"][-4:],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -130,6 +140,7 @@ class SystemServiceMixin:
|
|||||||
),
|
),
|
||||||
**self.database.status(),
|
**self.database.status(),
|
||||||
"jobs": self.jobs.repository.recent(12),
|
"jobs": self.jobs.repository.recent(12),
|
||||||
|
"datahub": self._datahub_status(),
|
||||||
},
|
},
|
||||||
"llm": {
|
"llm": {
|
||||||
"primary_configured": self._profile_configured(platform["primary"]),
|
"primary_configured": self._profile_configured(platform["primary"]),
|
||||||
@@ -145,21 +156,24 @@ class SystemServiceMixin:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _datahub_status(self) -> dict[str, Any]:
|
||||||
|
gateway = getattr(self, "data_gateway", None)
|
||||||
|
reporter = getattr(gateway, "datahub_status", None)
|
||||||
|
if callable(reporter):
|
||||||
|
return reporter()
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"base_url": "",
|
||||||
|
"enabled_reads": 0,
|
||||||
|
"total_reads": 0,
|
||||||
|
"flags": [],
|
||||||
|
"routes": [],
|
||||||
|
"fallback_count": 0,
|
||||||
|
"fallback_labels": [],
|
||||||
|
}
|
||||||
|
|
||||||
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
current = dict(self._system_credentials)
|
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 = {
|
existing_models = {
|
||||||
str(item.get("id") or ""): item
|
str(item.get("id") or ""): item
|
||||||
for item in current.get("llm_models") or []
|
for item in current.get("llm_models") or []
|
||||||
@@ -221,8 +235,6 @@ class SystemServiceMixin:
|
|||||||
raise ValueError("会员每日额度应为 1 至 1000。") from exc
|
raise ValueError("会员每日额度应为 1 至 1000。") from exc
|
||||||
current.update(
|
current.update(
|
||||||
{
|
{
|
||||||
"tushare_token": token,
|
|
||||||
"ifind_refresh_token": ifind_refresh_token,
|
|
||||||
"llm_models": models,
|
"llm_models": models,
|
||||||
"primary_model_id": primary_model_id,
|
"primary_model_id": primary_model_id,
|
||||||
"fallback_model_id": fallback_model_id,
|
"fallback_model_id": fallback_model_id,
|
||||||
@@ -242,7 +254,7 @@ class SystemServiceMixin:
|
|||||||
llm_access = self.llm_access_status()
|
llm_access = self.llm_access_status()
|
||||||
return {
|
return {
|
||||||
"configured": self.configured,
|
"configured": self.configured,
|
||||||
"mode": "tushare" if self.configured else "unavailable",
|
"mode": "datahub" if self.configured else "unavailable",
|
||||||
"llm_configured": self.llm_configured,
|
"llm_configured": self.llm_configured,
|
||||||
"llm_model": self.llm_primary_model if self.llm_configured else "",
|
"llm_model": self.llm_primary_model if self.llm_configured else "",
|
||||||
"llm_fallback_configured": self.llm_fallback_configured,
|
"llm_fallback_configured": self.llm_fallback_configured,
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ PUBLIC_POST_HANDLERS = {
|
|||||||
"/api/auth/forget": "auth_forget",
|
"/api/auth/forget": "auth_forget",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service-to-service bridge for the data hub console. These paths are guarded by
|
||||||
|
# the shared HUB_ADMIN_TOKEN header instead of a browser session, so they stay
|
||||||
|
# out of the user-facing route registry on purpose.
|
||||||
|
HUB_SERVICE_HANDLERS = {
|
||||||
|
"/api/hub-admin/session": "hub_session_check",
|
||||||
|
"/api/hub-admin/session/logout": "hub_session_logout",
|
||||||
|
"/api/hub-admin/password/check": "hub_password_check",
|
||||||
|
"/api/hub-admin/status": "hub_system_status",
|
||||||
|
"/api/hub-admin/settings/save": "hub_save_settings",
|
||||||
|
"/api/hub-admin/settings/test": "hub_test_model",
|
||||||
|
"/api/hub-admin/models/fetch": "hub_fetch_models",
|
||||||
|
"/api/hub-admin/members": "hub_members",
|
||||||
|
"/api/hub-admin/membership/save": "hub_save_membership",
|
||||||
|
"/api/hub-admin/invites": "hub_invites",
|
||||||
|
"/api/hub-admin/invites/create": "hub_create_invites",
|
||||||
|
"/api/hub-admin/invites/revoke": "hub_revoke_invite",
|
||||||
|
}
|
||||||
|
|
||||||
AUTHENTICATED_POST_HANDLERS = {
|
AUTHENTICATED_POST_HANDLERS = {
|
||||||
"/api/auth/logout": "auth_logout",
|
"/api/auth/logout": "auth_logout",
|
||||||
"/api/account/birth-profile": "save_birth_profile",
|
"/api/account/birth-profile": "save_birth_profile",
|
||||||
@@ -81,6 +99,10 @@ class ApplicationHttpDispatchMixin:
|
|||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path in HUB_SERVICE_HANDLERS:
|
||||||
|
if self.require_service_token():
|
||||||
|
self._dispatch_named_handler(parsed.path, HUB_SERVICE_HANDLERS)
|
||||||
|
return
|
||||||
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
|
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
|
||||||
return
|
return
|
||||||
if not self.require_auth() or not self.require_csrf():
|
if not self.require_auth() or not self.require_csrf():
|
||||||
|
|||||||
+17
-1
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -62,6 +63,14 @@ class HttpTransportMixin:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def require_service_token(self) -> bool:
|
||||||
|
expected = str(os.environ.get("HUB_ADMIN_TOKEN") or "").strip()
|
||||||
|
supplied = self.headers.get("X-Hub-Admin-Token", "")
|
||||||
|
if not expected or not supplied or not secrets.compare_digest(supplied, expected):
|
||||||
|
self.send_json({"error": "服务令牌校验失败。"}, HTTPStatus.UNAUTHORIZED)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def require_admin(self) -> bool:
|
def require_admin(self) -> bool:
|
||||||
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
|
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
|
||||||
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
|
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
|
||||||
@@ -109,7 +118,14 @@ class HttpTransportMixin:
|
|||||||
return {}
|
return {}
|
||||||
if length <= 0 or length > 65536:
|
if length <= 0 or length > 65536:
|
||||||
raise ValueError("请求内容为空或过大。")
|
raise ValueError("请求内容为空或过大。")
|
||||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
raw = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
raise ValueError("请求不是合法 JSON。") from None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("请求不是合法 JSON。")
|
||||||
|
return payload
|
||||||
|
|
||||||
def serve_static(self, request_path: str) -> None:
|
def serve_static(self, request_path: str) -> None:
|
||||||
relative = unquote(request_path).lstrip("/") or "index.html"
|
relative = unquote(request_path).lstrip("/") or "index.html"
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from backend.features.accounts.security import token_hash, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
class HubAdminHttpMixin:
|
||||||
|
"""Service-to-service bridge used by the data hub console (port 8766).
|
||||||
|
|
||||||
|
Every handler here is reached only after `require_service_token`, so the
|
||||||
|
shared `HUB_ADMIN_TOKEN` is the single trust boundary and no browser
|
||||||
|
session or CSRF token is involved. The data hub still verifies the site
|
||||||
|
session of the operator through `hub_session_check` before it exposes any
|
||||||
|
of these results to a page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _hub_body(self) -> dict:
|
||||||
|
return self.read_json_body(allow_empty=True)
|
||||||
|
|
||||||
|
def _hub_failure(self, exc: Exception) -> None:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def hub_session_check(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
raw_token = str(body.get("session_token") or "")
|
||||||
|
user = (
|
||||||
|
self.application_service.database.session_user(token_hash(raw_token))
|
||||||
|
if raw_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
self.send_json({"ok": True, "authenticated": False})
|
||||||
|
return
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"user": {
|
||||||
|
"id": int(user["id"]),
|
||||||
|
"username": str(user["username"]),
|
||||||
|
"role": str(user.get("role") or "user"),
|
||||||
|
"is_admin": str(user.get("role") or "user") == "admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def hub_session_logout(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
raw_token = str(body.get("session_token") or "")
|
||||||
|
if raw_token:
|
||||||
|
self.application_service.database.delete_session(token_hash(raw_token))
|
||||||
|
self.send_json({"ok": True})
|
||||||
|
|
||||||
|
def hub_password_check(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
user_id = int(body.get("user_id") or 0)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
stored = self.application_service.database.user_password(user_id)
|
||||||
|
verified = bool(
|
||||||
|
stored
|
||||||
|
and verify_password(
|
||||||
|
str(body.get("password") or ""),
|
||||||
|
str(stored.get("password_salt") or ""),
|
||||||
|
str(stored.get("password_hash") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "verified": verified})
|
||||||
|
|
||||||
|
def hub_system_status(self) -> None:
|
||||||
|
service = self.application_service
|
||||||
|
self.send_json({"ok": True, **service.system_status(), "users": service.admin_users()})
|
||||||
|
|
||||||
|
def hub_save_settings(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.application_service.save_system_settings(self._hub_body())
|
||||||
|
self.send_json({"ok": True, **result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_test_model(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
result = self.application_service.test_system_llm_profile(
|
||||||
|
str(body.get("model_id") or ""), body.get("profile") or {}
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "result": result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_fetch_models(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
models = self.application_service.fetch_llm_models(
|
||||||
|
str(body.get("base_url") or ""),
|
||||||
|
str(body.get("api_key") or ""),
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "models": models})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_members(self) -> None:
|
||||||
|
service = self.application_service
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"users": service.admin_users(),
|
||||||
|
"membership": service.system_status()["membership"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def hub_save_membership(self) -> None:
|
||||||
|
try:
|
||||||
|
service = self.application_service
|
||||||
|
service.update_membership(self._hub_body())
|
||||||
|
self.send_json({"ok": True, "users": service.admin_users()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_invites(self) -> None:
|
||||||
|
self.send_json({"ok": True, **self.application_service.accounts.invite_overview()})
|
||||||
|
|
||||||
|
def hub_create_invites(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
accounts = self.application_service.accounts
|
||||||
|
codes = accounts.generate_invite_codes(
|
||||||
|
body.get("count") or 1,
|
||||||
|
str(body.get("note") or ""),
|
||||||
|
int(body.get("created_by") or 0),
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "created": codes, **accounts.invite_overview()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_revoke_invite(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
accounts = self.application_service.accounts
|
||||||
|
accounts.revoke_invite_code(str(body.get("code_id") or body.get("code") or ""))
|
||||||
|
self.send_json({"ok": True, **accounts.invite_overview()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, time as dt_time
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard_has_usable_data(dashboard: dict[str, object]) -> bool:
|
||||||
|
if not isinstance(dashboard, dict) or dashboard.get("status") == "failed":
|
||||||
|
return False
|
||||||
|
meta = dashboard.get("meta") or {}
|
||||||
|
overview = dashboard.get("overview") or {}
|
||||||
|
if isinstance(meta, dict) and (meta.get("trade_date") or meta.get("carried_forward")):
|
||||||
|
return True
|
||||||
|
return bool(isinstance(overview, dict) and overview)
|
||||||
|
|
||||||
|
|
||||||
|
def verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Manual refresh and automatic catch-up share this rule.
|
||||||
|
|
||||||
|
Derived limit lists or a previous usable snapshot are not whole-job failures.
|
||||||
|
Only a payload with no displayable market data is recorded as failed.
|
||||||
|
"""
|
||||||
|
if dashboard_has_usable_data(dashboard):
|
||||||
|
return dashboard
|
||||||
|
meta = dashboard.get("meta") if isinstance(dashboard, dict) else None
|
||||||
|
notice = ""
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
notice = str(meta.get("notice") or meta.get("display_notice") or "")
|
||||||
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"error": notice or "未获取到可用行情",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def official_catchup_due(today: str, snapshot: dict[str, object]) -> bool:
|
||||||
|
now = datetime.now().astimezone().time().replace(tzinfo=None)
|
||||||
|
if not (dt_time(15, 5) <= now < dt_time(22, 0)):
|
||||||
|
return False
|
||||||
|
meta = snapshot.get("meta") if isinstance(snapshot.get("meta"), dict) else {}
|
||||||
|
actual = str(meta.get("trade_date") or "").replace("-", "")
|
||||||
|
if (
|
||||||
|
actual == today
|
||||||
|
and meta.get("limit_data_source") != "derived"
|
||||||
|
and not meta.get("carried_forward")
|
||||||
|
and not meta.get("realtime")
|
||||||
|
and meta.get("mode") != "realtime"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
+11
-12
@@ -5,16 +5,7 @@ import time
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from backend.bootstrap.config import normalize_date
|
from backend.bootstrap.config import normalize_date
|
||||||
|
from backend.jobs.refresh import official_catchup_due, verified_dashboard_result
|
||||||
|
|
||||||
def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
|
|
||||||
meta = dashboard.get("meta") or {}
|
|
||||||
if isinstance(meta, dict) and meta.get("carried_forward"):
|
|
||||||
return {
|
|
||||||
"status": "failed",
|
|
||||||
"error": str(meta.get("notice") or "未获取到所选日期的最新行情"),
|
|
||||||
}
|
|
||||||
return dashboard
|
|
||||||
|
|
||||||
|
|
||||||
class JobServiceMixin:
|
class JobServiceMixin:
|
||||||
@@ -36,7 +27,7 @@ class JobServiceMixin:
|
|||||||
started = self.jobs.submit(
|
started = self.jobs.submit(
|
||||||
"market.refresh",
|
"market.refresh",
|
||||||
key,
|
key,
|
||||||
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
|
lambda: verified_dashboard_result(self.sync_dashboard(normalized)),
|
||||||
{"trade_date": normalized, "trigger": "administrator"},
|
{"trade_date": normalized, "trigger": "administrator"},
|
||||||
)
|
)
|
||||||
return {"started": started, "job_key": key if started else ""}
|
return {"started": started, "job_key": key if started else ""}
|
||||||
@@ -54,7 +45,15 @@ class JobServiceMixin:
|
|||||||
self.jobs.submit(
|
self.jobs.submit(
|
||||||
"market.refresh",
|
"market.refresh",
|
||||||
f"realtime:{today}:{bucket}",
|
f"realtime:{today}:{bucket}",
|
||||||
lambda: self.sync_dashboard(today),
|
lambda: verified_dashboard_result(self.sync_dashboard(today)),
|
||||||
{"trade_date": today, "trigger": "realtime-poll"},
|
{"trade_date": today, "trigger": "realtime-poll"},
|
||||||
)
|
)
|
||||||
|
elif official_catchup_due(today, snapshot):
|
||||||
|
bucket = int(time.time() // 300)
|
||||||
|
self.jobs.submit(
|
||||||
|
"market.refresh",
|
||||||
|
f"catchup:{today}:{bucket}",
|
||||||
|
lambda: verified_dashboard_result(self.sync_dashboard(today)),
|
||||||
|
{"trade_date": today, "trigger": "official-catchup"},
|
||||||
|
)
|
||||||
self._schedule_automatic_screeners(today, snapshot)
|
self._schedule_automatic_screeners(today, snapshot)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from backend.bootstrap.config import validate_text
|
from backend.bootstrap.config import validate_text
|
||||||
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
|
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
|
||||||
|
from backend.llm import transport as llm_transport
|
||||||
|
|
||||||
|
|
||||||
class LLMServiceMixin:
|
class LLMServiceMixin:
|
||||||
@@ -208,6 +209,37 @@ class LLMServiceMixin:
|
|||||||
start.isoformat(timespec="seconds"),
|
start.isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def fetch_llm_models(self, base_url: str, api_key: str) -> list[str]:
|
||||||
|
base_url = str(base_url or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError("Base URL 格式不正确。")
|
||||||
|
key = str(api_key or "").strip()
|
||||||
|
if not key:
|
||||||
|
key = self._stored_api_key(base_url)
|
||||||
|
if not key:
|
||||||
|
raise ValueError("该供应商尚未保存 API Key,请先填写后再拉取模型列表。")
|
||||||
|
try:
|
||||||
|
return llm_transport.list_models(
|
||||||
|
api_key=key,
|
||||||
|
base_url=base_url,
|
||||||
|
timeout=15,
|
||||||
|
user_agent="XiaobaiReviewWeb/0.5",
|
||||||
|
)
|
||||||
|
except llm_transport.OpenAIHTTPError as exc:
|
||||||
|
raise ValueError(exc.describe("模型列表拉取失败")) from exc
|
||||||
|
except llm_transport.OpenAITransportError as exc:
|
||||||
|
raise ValueError(f"模型列表拉取失败:{exc}") from exc
|
||||||
|
|
||||||
|
def _stored_api_key(self, base_url: str) -> str:
|
||||||
|
for item in self._system_credentials.get("llm_models") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
stored = str(item.get("base_url") or "").strip().rstrip("/")
|
||||||
|
if stored == base_url and item.get("api_key"):
|
||||||
|
return str(item["api_key"])
|
||||||
|
return ""
|
||||||
|
|
||||||
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
current = next(
|
current = next(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -67,6 +67,37 @@ def chat_completion(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
timeout: int,
|
||||||
|
user_agent: str,
|
||||||
|
) -> list[str]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url.rstrip('/')}/models",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||||
|
raise OpenAITransportError(str(exc)) from exc
|
||||||
|
items = payload.get("data") if isinstance(payload, dict) else payload
|
||||||
|
models = []
|
||||||
|
for item in items or []:
|
||||||
|
name = str((item or {}).get("id") or "") if isinstance(item, dict) else str(item or "")
|
||||||
|
if name and name not in models:
|
||||||
|
models.append(name)
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
def stream_chat_completion(
|
def stream_chat_completion(
|
||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Start later (总工部署时) with:
|
# Start later (总工部署时) with:
|
||||||
# docker compose -f compose.yaml -f compose.datahub.yaml up -d
|
# docker compose -f compose.yaml -f compose.datahub.yaml up -d
|
||||||
#
|
#
|
||||||
# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, DATAHUB_ADMIN_PASSWORD, TUSHARE_TOKEN
|
# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, HUB_ADMIN_TOKEN, TUSHARE_TOKEN
|
||||||
|
# HUB_ADMIN_TOKEN 必须与主站 .env 里的同名变量一致:控制台靠它验证主站管理员会话。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
xiaobai-datahub:
|
xiaobai-datahub:
|
||||||
@@ -18,8 +19,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
|
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
|
||||||
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
||||||
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}"
|
||||||
|
REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}"
|
||||||
|
REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-}"
|
||||||
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
||||||
|
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
|
||||||
|
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
|
||||||
DATAHUB_DB_PATH: /app/data/datahub.db
|
DATAHUB_DB_PATH: /app/data/datahub.db
|
||||||
DATAHUB_BACKUP_DIR: /app/data/backups
|
DATAHUB_BACKUP_DIR: /app/data/backups
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
|
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
|
||||||
|
# Shared secret for /api/hub-admin/*: the data hub console (8766) uses it to
|
||||||
|
# verify this site's admin sessions and to read/write the model pool,
|
||||||
|
# members and invite codes. Unset means the bridge refuses every call.
|
||||||
|
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:-}"
|
||||||
|
# Provider credentials are consumed only by xiaobai-datahub.
|
||||||
|
TUSHARE_TOKEN: ""
|
||||||
|
IFIND_REFRESH_TOKEN: ""
|
||||||
|
IFIND_ACCESS_TOKEN: ""
|
||||||
|
DATAHUB_BASE_URL: "${DATAHUB_BASE_URL:-http://192.168.200.11:8766}"
|
||||||
|
DATAHUB_READ_CALENDAR: "1"
|
||||||
|
DATAHUB_READ_STOCKS: "1"
|
||||||
|
DATAHUB_READ_DAILY: "1"
|
||||||
|
DATAHUB_READ_INDEX_DAILY: "1"
|
||||||
|
DATAHUB_READ_VALUATION: "1"
|
||||||
|
DATAHUB_READ_MONEYFLOW: "1"
|
||||||
|
DATAHUB_READ_AUCTION: "1"
|
||||||
|
DATAHUB_READ_LIMIT_EVENTS: "1"
|
||||||
|
DATAHUB_READ_POPULARITY: "1"
|
||||||
|
DATAHUB_READ_DRAGON_TIGER: "1"
|
||||||
|
DATAHUB_READ_SECTOR_DAILY: "1"
|
||||||
|
DATAHUB_READ_QUOTES: "1"
|
||||||
|
DATAHUB_READ_INDEX_QUOTES: "1"
|
||||||
|
DATAHUB_READ_INTRADAY: "1"
|
||||||
|
DATAHUB_READ_STATUS: "1"
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
PYTHONUTF8: "1"
|
PYTHONUTF8: "1"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+6
-3
@@ -12,9 +12,12 @@ These registries describe the approved product surface of the standalone applica
|
|||||||
providers, model entry points, CSS layers, and remaining code hotspots.
|
providers, model entry points, CSS layers, and remaining code hotspots.
|
||||||
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
|
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
|
||||||
known blocked datasets.
|
known blocked datasets.
|
||||||
- `datahub.config.json`: optional read-only client for `xiaobai-datahub`. Each dataset has its
|
- `datahub.config.json`: official read-only client for `xiaobai-datahub`. Each dataset has its
|
||||||
own `read` / `shadow` flag, all default off. Environment variables `DATAHUB_READ_*` and
|
own `read` / `shadow` flag; official reads default on. `compose.yaml` pins every
|
||||||
`DATAHUB_SHADOW_*` can override a single dataset without a master switch.
|
`DATAHUB_READ_*` to `"1"` so a leftover `.env` `=0` cannot silently keep official
|
||||||
|
pages on the old APIs. Environment variables can still override a single
|
||||||
|
`DATAHUB_SHADOW_*` without a master switch. The old website APIs stay as
|
||||||
|
emergency fallback only.
|
||||||
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
|
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
|
||||||
fail-closed rules for every canonical data product.
|
fail-closed rules for every canonical data product.
|
||||||
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
|
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
|
||||||
|
|||||||
+113
-111
@@ -13,7 +13,7 @@
|
|||||||
"api_exact_paths": 56,
|
"api_exact_paths": 56,
|
||||||
"api_prefixes": 0,
|
"api_prefixes": 0,
|
||||||
"api_patterns": 11,
|
"api_patterns": 11,
|
||||||
"database_tables": 37,
|
"database_tables": 38,
|
||||||
"frontend_page_fragments": 12
|
"frontend_page_fragments": 12
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -193,6 +193,7 @@
|
|||||||
"heaven_readings",
|
"heaven_readings",
|
||||||
"job_runs",
|
"job_runs",
|
||||||
"account_switch_grants",
|
"account_switch_grants",
|
||||||
|
"invite_codes",
|
||||||
"schema_migrations"
|
"schema_migrations"
|
||||||
],
|
],
|
||||||
"background_job_methods": [
|
"background_job_methods": [
|
||||||
@@ -207,27 +208,22 @@
|
|||||||
{
|
{
|
||||||
"provider": "datahub",
|
"provider": "datahub",
|
||||||
"path": "backend/data/datahub/client.py",
|
"path": "backend/data/datahub/client.py",
|
||||||
"runtime_role": "optional official EOD read path behind per-dataset flags"
|
"runtime_role": "website-only read path; official EOD, live quotes, and licensed iFinD"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "ifind",
|
"provider": "ifind",
|
||||||
"path": "backend/data/providers/ifind_client.py",
|
"path": "xiaobai-datahub/datahub/adapters/ifind.py",
|
||||||
"runtime_role": "realtime, charts, snapshots, enrichment"
|
"runtime_role": "licensed iFinD source inside the data hub"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "eastmoney",
|
"provider": "eastmoney",
|
||||||
"path": "backend/features/market/charts.py",
|
"path": "xiaobai-datahub/datahub/adapters/eastmoney.py",
|
||||||
"runtime_role": "display chart fallback"
|
"runtime_role": "free realtime quotes and shenwan inside the data hub"
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider": "eastmoney",
|
|
||||||
"path": "backend/data/realtime.py",
|
|
||||||
"runtime_role": "isolated realtime observation"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "tencent",
|
"provider": "tencent",
|
||||||
"path": "backend/data/realtime.py",
|
"path": "xiaobai-datahub/datahub/adapters/tencent.py",
|
||||||
"runtime_role": "index observation fallback"
|
"runtime_role": "free index and stock quotes inside the data hub"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"provider_domains": [
|
"provider_domains": [
|
||||||
@@ -296,16 +292,16 @@
|
|||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client": "IfindHttpClient",
|
"client": "HubIfindProxy",
|
||||||
|
"owner": "backend/data/gateway.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client": "HubRealtimeProxy",
|
||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client": "MarketChartClient",
|
"client": "MarketChartClient",
|
||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "WebRealtimeAggregator",
|
|
||||||
"owner": "backend/data/gateway.py"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"heaven_service_owners": {
|
"heaven_service_owners": {
|
||||||
@@ -330,6 +326,7 @@
|
|||||||
"system_service": "backend/features/system/service.py",
|
"system_service": "backend/features/system/service.py",
|
||||||
"account_bridge": "backend/features/accounts/application.py",
|
"account_bridge": "backend/features/accounts/application.py",
|
||||||
"job_lifecycle": "backend/jobs/service.py",
|
"job_lifecycle": "backend/jobs/service.py",
|
||||||
|
"job_refresh_status": "backend/jobs/refresh.py",
|
||||||
"feature_routes": "backend/features/*/routes.py"
|
"feature_routes": "backend/features/*/routes.py"
|
||||||
},
|
},
|
||||||
"numeric_normalization": [
|
"numeric_normalization": [
|
||||||
@@ -462,28 +459,33 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/screener/foundation.css",
|
"path": "frontend/pages/screener/foundation.css",
|
||||||
"bytes": 103547,
|
"bytes": 103683,
|
||||||
"lines": 6576
|
"lines": 6582
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/heaven/page.js",
|
"path": "frontend/pages/heaven/page.js",
|
||||||
"bytes": 97268,
|
"bytes": 97770,
|
||||||
"lines": 2070
|
"lines": 2079
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/shell.css",
|
"path": "frontend/shared/shell.css",
|
||||||
"bytes": 63659,
|
"bytes": 64839,
|
||||||
"lines": 3763
|
"lines": 3794
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/engine.py",
|
"path": "backend/features/heaven/engine.py",
|
||||||
"bytes": 51764,
|
"bytes": 52046,
|
||||||
"lines": 1183
|
"lines": 1192
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/index.html",
|
"path": "frontend/index.html",
|
||||||
"bytes": 48254,
|
"bytes": 46900,
|
||||||
"lines": 664
|
"lines": 638
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_industries.py",
|
||||||
|
"bytes": 38936,
|
||||||
|
"lines": 894
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/catalog.py",
|
"path": "backend/features/screener/catalog.py",
|
||||||
@@ -495,6 +497,11 @@
|
|||||||
"bytes": 35247,
|
"bytes": 35247,
|
||||||
"lines": 2416
|
"lines": 2416
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_dashboard.py",
|
||||||
|
"bytes": 35001,
|
||||||
|
"lines": 805
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "database.py",
|
"path": "database.py",
|
||||||
"bytes": 32073,
|
"bytes": 32073,
|
||||||
@@ -505,16 +512,6 @@
|
|||||||
"bytes": 31756,
|
"bytes": 31756,
|
||||||
"lines": 562
|
"lines": 562
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_dashboard.py",
|
|
||||||
"bytes": 28234,
|
|
||||||
"lines": 648
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_industries.py",
|
|
||||||
"bytes": 26540,
|
|
||||||
"lines": 616
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/manual.py",
|
"path": "backend/features/heaven/manual.py",
|
||||||
"bytes": 24521,
|
"bytes": 24521,
|
||||||
@@ -532,13 +529,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/preview.js",
|
"path": "frontend/pages/market/preview.js",
|
||||||
"bytes": 18178,
|
"bytes": 18230,
|
||||||
"lines": 446
|
"lines": 447
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/trend.py",
|
"path": "backend/features/heaven/trend.py",
|
||||||
"bytes": 16772,
|
"bytes": 17005,
|
||||||
"lines": 370
|
"lines": 373
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_scoring.py",
|
"path": "backend/features/market/insights_auction_scoring.py",
|
||||||
@@ -547,8 +544,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/charts.js",
|
"path": "frontend/pages/market/charts.js",
|
||||||
"bytes": 15311,
|
"bytes": 15743,
|
||||||
"lines": 387
|
"lines": 401
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/dashboard.js",
|
||||||
|
"bytes": 15063,
|
||||||
|
"lines": 321
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/pools/page.html",
|
"path": "frontend/pages/pools/page.html",
|
||||||
@@ -560,25 +562,15 @@
|
|||||||
"bytes": 14743,
|
"bytes": 14743,
|
||||||
"lines": 342
|
"lines": 342
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/shared/admin.js",
|
|
||||||
"bytes": 14410,
|
|
||||||
"lines": 268
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/market_context.py",
|
"path": "backend/features/heaven/market_context.py",
|
||||||
"bytes": 13681,
|
"bytes": 14467,
|
||||||
"lines": 338
|
"lines": 356
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/session.js",
|
"path": "frontend/shared/session.js",
|
||||||
"bytes": 13219,
|
"bytes": 13633,
|
||||||
"lines": 289
|
"lines": 296
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/shared/dashboard.js",
|
|
||||||
"bytes": 12894,
|
|
||||||
"lines": 274
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_data.py",
|
"path": "backend/features/market/insights_auction_data.py",
|
||||||
@@ -587,8 +579,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/system/service.py",
|
"path": "backend/features/system/service.py",
|
||||||
"bytes": 12392,
|
"bytes": 12242,
|
||||||
"lines": 254
|
"lines": 266
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction.py",
|
"path": "backend/features/market/insights_auction.py",
|
||||||
@@ -605,6 +597,11 @@
|
|||||||
"bytes": 9876,
|
"bytes": 9876,
|
||||||
"lines": 224
|
"lines": 224
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_daily.py",
|
||||||
|
"bytes": 9539,
|
||||||
|
"lines": 241
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_themes.py",
|
"path": "backend/features/market/insights_themes.py",
|
||||||
"bytes": 9348,
|
"bytes": 9348,
|
||||||
@@ -612,7 +609,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/entity-detail.js",
|
"path": "frontend/pages/market/entity-detail.js",
|
||||||
"bytes": 9119,
|
"bytes": 9139,
|
||||||
"lines": 199
|
"lines": 199
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -625,6 +622,11 @@
|
|||||||
"bytes": 8562,
|
"bytes": 8562,
|
||||||
"lines": 238
|
"lines": 238
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_indices.py",
|
||||||
|
"bytes": 8447,
|
||||||
|
"lines": 189
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/mentor/page.html",
|
"path": "frontend/pages/mentor/page.html",
|
||||||
"bytes": 8357,
|
"bytes": 8357,
|
||||||
@@ -635,16 +637,6 @@
|
|||||||
"bytes": 6983,
|
"bytes": 6983,
|
||||||
"lines": 146
|
"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",
|
"path": "backend/features/market/insights_popularity.py",
|
||||||
"bytes": 6739,
|
"bytes": 6739,
|
||||||
@@ -655,14 +647,19 @@
|
|||||||
"bytes": 6547,
|
"bytes": 6547,
|
||||||
"lines": 220
|
"lines": 220
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/application.py",
|
||||||
|
"bytes": 6500,
|
||||||
|
"lines": 164
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/sentiment/page.html",
|
"path": "frontend/pages/sentiment/page.html",
|
||||||
"bytes": 6488,
|
"bytes": 6488,
|
||||||
"lines": 81
|
"lines": 81
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_stocks.py",
|
"path": "frontend/pages/market/stock-detail.js",
|
||||||
"bytes": 6244,
|
"bytes": 6325,
|
||||||
"lines": 137
|
"lines": 137
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -681,14 +678,9 @@
|
|||||||
"lines": 85
|
"lines": 85
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/stock-detail.js",
|
"path": "backend/data/providers/tushare_stocks.py",
|
||||||
"bytes": 5690,
|
"bytes": 5592,
|
||||||
"lines": 124
|
"lines": 123
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_indices.py",
|
|
||||||
"bytes": 5451,
|
|
||||||
"lines": 118
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages.config.js",
|
"path": "frontend/pages.config.js",
|
||||||
@@ -705,6 +697,11 @@
|
|||||||
"bytes": 5350,
|
"bytes": 5350,
|
||||||
"lines": 74
|
"lines": 74
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/http/dispatch.py",
|
||||||
|
"bytes": 5281,
|
||||||
|
"lines": 139
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/feedback.js",
|
"path": "frontend/shared/feedback.js",
|
||||||
"bytes": 5157,
|
"bytes": 5157,
|
||||||
@@ -720,6 +717,16 @@
|
|||||||
"bytes": 4712,
|
"bytes": 4712,
|
||||||
"lines": 106
|
"lines": 106
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_helpers.py",
|
||||||
|
"bytes": 4406,
|
||||||
|
"lines": 124
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/admin.js",
|
||||||
|
"bytes": 4376,
|
||||||
|
"lines": 107
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/routes.py",
|
"path": "backend/features/market/routes.py",
|
||||||
"bytes": 4276,
|
"bytes": 4276,
|
||||||
@@ -735,11 +742,6 @@
|
|||||||
"bytes": 4242,
|
"bytes": 4242,
|
||||||
"lines": 129
|
"lines": 129
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/http/dispatch.py",
|
|
||||||
"bytes": 4196,
|
|
||||||
"lines": 117
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/table.js",
|
"path": "frontend/shared/table.js",
|
||||||
"bytes": 3790,
|
"bytes": 3790,
|
||||||
@@ -765,16 +767,16 @@
|
|||||||
"bytes": 3316,
|
"bytes": 3316,
|
||||||
"lines": 55
|
"lines": 55
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/pages/rotation/page.html",
|
||||||
|
"bytes": 3299,
|
||||||
|
"lines": 57
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_context.py",
|
"path": "backend/features/market/insights_context.py",
|
||||||
"bytes": 3175,
|
"bytes": 3175,
|
||||||
"lines": 84
|
"lines": 84
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/pages/rotation/page.html",
|
|
||||||
"bytes": 3031,
|
|
||||||
"lines": 54
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/bindings.js",
|
"path": "frontend/pages/market/bindings.js",
|
||||||
"bytes": 2663,
|
"bytes": 2663,
|
||||||
@@ -782,8 +784,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/accounts/application.py",
|
"path": "backend/features/accounts/application.py",
|
||||||
"bytes": 2514,
|
"bytes": 2597,
|
||||||
"lines": 63
|
"lines": 69
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/jobs/service.py",
|
||||||
|
"bytes": 2337,
|
||||||
|
"lines": 59
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/mentor/routes.py",
|
"path": "backend/features/mentor/routes.py",
|
||||||
@@ -791,30 +798,20 @@
|
|||||||
"lines": 57
|
"lines": 57
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/jobs/service.py",
|
"path": "backend/data/providers/tushare_client.py",
|
||||||
"bytes": 2219,
|
"bytes": 2263,
|
||||||
"lines": 60
|
"lines": 70
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/regime.py",
|
"path": "backend/features/screener/regime.py",
|
||||||
"bytes": 2202,
|
"bytes": 2202,
|
||||||
"lines": 53
|
"lines": 53
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_client.py",
|
|
||||||
"bytes": 2166,
|
|
||||||
"lines": 68
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/popularity/page.html",
|
"path": "frontend/pages/popularity/page.html",
|
||||||
"bytes": 2165,
|
"bytes": 2165,
|
||||||
"lines": 35
|
"lines": 35
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_helpers.py",
|
|
||||||
"bytes": 2083,
|
|
||||||
"lines": 64
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/breadth.js",
|
"path": "frontend/pages/market/breadth.js",
|
||||||
"bytes": 2071,
|
"bytes": 2071,
|
||||||
@@ -825,6 +822,11 @@
|
|||||||
"bytes": 1919,
|
"bytes": 1919,
|
||||||
"lines": 45
|
"lines": 45
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/jobs/refresh.py",
|
||||||
|
"bytes": 1808,
|
||||||
|
"lines": 48
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/system/routes.py",
|
"path": "backend/features/system/routes.py",
|
||||||
"bytes": 1791,
|
"bytes": 1791,
|
||||||
@@ -840,6 +842,11 @@
|
|||||||
"bytes": 1642,
|
"bytes": 1642,
|
||||||
"lines": 53
|
"lines": 53
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_transport.py",
|
||||||
|
"bytes": 1592,
|
||||||
|
"lines": 50
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights.py",
|
"path": "backend/features/market/insights.py",
|
||||||
"bytes": 1580,
|
"bytes": 1580,
|
||||||
@@ -850,11 +857,6 @@
|
|||||||
"bytes": 1535,
|
"bytes": 1535,
|
||||||
"lines": 39
|
"lines": 39
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_transport.py",
|
|
||||||
"bytes": 1455,
|
|
||||||
"lines": 48
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/themes/routes.py",
|
"path": "backend/features/themes/routes.py",
|
||||||
"bytes": 1337,
|
"bytes": 1337,
|
||||||
|
|||||||
@@ -6,13 +6,20 @@
|
|||||||
"page_limit": 5000,
|
"page_limit": 5000,
|
||||||
"stale_seconds_max": 86400,
|
"stale_seconds_max": 86400,
|
||||||
"datasets": {
|
"datasets": {
|
||||||
"calendar": { "read": false, "shadow": false },
|
"calendar": { "read": true, "shadow": false },
|
||||||
"stocks": { "read": false, "shadow": false },
|
"stocks": { "read": true, "shadow": false },
|
||||||
"daily": { "read": false, "shadow": false },
|
"daily": { "read": true, "shadow": false },
|
||||||
"index_daily": { "read": false, "shadow": false },
|
"index_daily": { "read": true, "shadow": false },
|
||||||
"valuation": { "read": false, "shadow": false },
|
"valuation": { "read": true, "shadow": false },
|
||||||
"moneyflow": { "read": false, "shadow": false },
|
"moneyflow": { "read": true, "shadow": false },
|
||||||
"auction": { "read": false, "shadow": false },
|
"auction": { "read": true, "shadow": false },
|
||||||
"status": { "read": false, "shadow": false }
|
"limit_events": { "read": true, "shadow": false },
|
||||||
|
"popularity": { "read": true, "shadow": false },
|
||||||
|
"dragon_tiger": { "read": true, "shadow": false },
|
||||||
|
"sector_daily": { "read": true, "shadow": false },
|
||||||
|
"quotes": { "read": true, "shadow": false },
|
||||||
|
"index_quotes": { "read": true, "shadow": false },
|
||||||
|
"intraday": { "read": true, "shadow": false },
|
||||||
|
"status": { "read": true, "shadow": false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,4 +32,4 @@
|
|||||||
|
|
||||||
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
|
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
|
||||||
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
|
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
|
||||||
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它。
|
- 「问天」不是永久冻结区:此前只冻结过界面视觉方案,现已解冻。问天可纳入后续数据与功能迁移,不要再写成“永远不碰”。
|
||||||
|
|||||||
@@ -213,12 +213,12 @@
|
|||||||
{
|
{
|
||||||
"provider": "eastmoney",
|
"provider": "eastmoney",
|
||||||
"path": "realtime_aggregator.py",
|
"path": "realtime_aggregator.py",
|
||||||
"runtime_role": "isolated realtime observation"
|
"runtime_role": "isolated realtime observation and intraday dashboard fallback"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "tencent",
|
"provider": "tencent",
|
||||||
"path": "realtime_aggregator.py",
|
"path": "realtime_aggregator.py",
|
||||||
"runtime_role": "index observation fallback"
|
"runtime_role": "index observation and intraday quote fallback"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"llm_entrypoints": [
|
"llm_entrypoints": [
|
||||||
|
|||||||
@@ -320,6 +320,11 @@ PC端统一采用以下固定骨架:
|
|||||||
|
|
||||||
### 6.1 数据源职责
|
### 6.1 数据源职责
|
||||||
|
|
||||||
|
运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务
|
||||||
|
协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、
|
||||||
|
重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取
|
||||||
|
已经归档的真实快照,不能绕回旧提供方直连接口。
|
||||||
|
|
||||||
| 数据源 | 可用于正式计算 | 主要职责 |
|
| 数据源 | 可用于正式计算 | 主要职责 |
|
||||||
|---|:---:|---|
|
|---|:---:|---|
|
||||||
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
|
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
| 任务 | 说明 | 状态 |
|
| 任务 | 说明 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
|
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
|
||||||
|
| 行情刷新误报与旧数据提示 | HEL-412:高级接口未到齐不再记整次失败;今日正式数据晚到时提示当前展示日期 | 施工中 |
|
||||||
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
|
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
|
||||||
|
|
||||||
## 已做完
|
## 已做完
|
||||||
|
|||||||
+2
-2
@@ -29,11 +29,11 @@
|
|||||||
- **智能工具类(3 个)**:智能选股、问师、问天。
|
- **智能工具类(3 个)**:智能选股、问师、问天。
|
||||||
- **个人类(1 个)**:我的复盘。
|
- **个人类(1 个)**:我的复盘。
|
||||||
|
|
||||||
其中「问天」是冻结区(见下面的硬规矩)。
|
其中「问天」此前只在全站视觉改造阶段冻结过界面方案,现已解冻;问天可以纳入后续数据与功能迁移,但不等于本阶段要重做视觉。
|
||||||
|
|
||||||
## 几条硬规矩(不能破坏的边界)
|
## 几条硬规矩(不能破坏的边界)
|
||||||
|
|
||||||
- 「问天」板块是**冻结区**,任何改动都不许碰它。
|
- 「问天」板块**不是永久冻结区**:此前冻结的是界面视觉方案,现已解冻。问天现有功能与界面不要破坏;后续数据与功能迁移可以纳入,不主动重做视觉。
|
||||||
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
|
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
|
||||||
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
|
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
|
||||||
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
|
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
|
||||||
|
|||||||
+9
-35
@@ -73,6 +73,7 @@
|
|||||||
<label class="form-field"><span>账号名</span><input id="authUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>
|
<label class="form-field"><span>账号名</span><input id="authUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>
|
||||||
<label class="form-field"><span>密码</span><input id="authPassword" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>
|
<label class="form-field"><span>密码</span><input id="authPassword" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>
|
||||||
<label id="authConfirmField" class="form-field" hidden><span>确认密码</span><input id="authPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>
|
<label id="authConfirmField" class="form-field" hidden><span>确认密码</span><input id="authPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>
|
||||||
|
<label id="authInviteField" class="form-field" hidden><span>邀请码</span><input id="authInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>
|
||||||
<p id="authError" class="auth-error" hidden></p>
|
<p id="authError" class="auth-error" hidden></p>
|
||||||
<button id="authSubmitButton" class="button primary" type="submit">登录</button>
|
<button id="authSubmitButton" class="button primary" type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -156,7 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
||||||
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
||||||
<button id="settingsButton" class="button command-button" type="button" title="系统管理" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
|
<button id="marketAdminButton" class="button command-button" type="button" title="行情管理" hidden><i data-lucide="settings-2"></i><span>行情管理</span></button>
|
||||||
|
<button id="settingsButton" class="button command-button" type="button" title="数据中枢" hidden><i data-lucide="database"></i><span>数据中枢</span></button>
|
||||||
<div class="account-menu-shell">
|
<div class="account-menu-shell">
|
||||||
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
||||||
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
|
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
|
||||||
@@ -592,27 +594,21 @@
|
|||||||
|
|
||||||
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
|
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">系统配置</h2></div>
|
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">行情管理</h2></div>
|
||||||
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
|
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
|
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
|
||||||
<div class="admin-section-picker">
|
<p class="form-hint">模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。</p>
|
||||||
<label for="adminSectionSelect">管理项目</label>
|
|
||||||
<select id="adminSectionSelect">
|
|
||||||
<option value="market">行情管理</option>
|
|
||||||
<option value="models">模型池</option>
|
|
||||||
<option value="members">会员管理</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" data-admin-panel="market">
|
<div class="admin-panel" data-admin-panel="market">
|
||||||
<form id="systemMarketForm" class="settings-section">
|
<form id="systemMarketForm" class="settings-section">
|
||||||
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
|
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
|
||||||
<label class="form-field"><span>Tushare Token</span><input id="systemTokenInput" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token"></label>
|
<label class="form-field"><span>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
|
||||||
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
|
<label class="form-field"><span>实时来源凭据</span><input id="systemIfindTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
|
||||||
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
||||||
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
||||||
|
<div id="datahubRouteStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="database"></i><span>数据中枢线路待检查</span></div>
|
||||||
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
|
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
|
||||||
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
|
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存刷新设置</button></div>
|
||||||
</form>
|
</form>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
|
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
|
||||||
@@ -623,28 +619,6 @@
|
|||||||
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
|
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-panel" data-admin-panel="models" hidden>
|
|
||||||
<form id="systemModelsForm" class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>平台模型池</h3><span>会员共享</span></div>
|
|
||||||
<div class="model-role-selectors">
|
|
||||||
<label class="form-field"><span>主模型</span><select id="platformPrimaryModelSelect"></select></label>
|
|
||||||
<label class="form-field"><span>辅助模型</span><select id="platformFallbackModelSelect"><option value="">不启用辅助模型</option></select></label>
|
|
||||||
</div>
|
|
||||||
<div id="modelPoolList" class="model-pool-list"></div>
|
|
||||||
<div class="dialog-actions admin-inline-actions"><button id="addPlatformModel" class="button" type="button"><i data-lucide="plus"></i>添加模型</button><button class="button primary" type="submit">保存模型池</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" data-admin-panel="members" hidden>
|
|
||||||
<form id="membershipSettingsForm" class="settings-section membership-limit-form">
|
|
||||||
<div class="settings-section-heading"><h3>会员调用额度</h3><span>每日自动重置</span></div>
|
|
||||||
<label class="form-field compact-number-field"><span>会员每日智能分析上限</span><input id="memberDailyLimit" type="number" min="1" max="1000" value="50"></label>
|
|
||||||
<div class="dialog-actions"><button class="button primary" type="submit">保存调用额度</button></div>
|
|
||||||
</form>
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>会员账号</h3><span>手动开通与续期</span></div>
|
|
||||||
<div id="adminUsersList" class="admin-users-list"></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<div id="loadingOverlay" class="loading-overlay" hidden>
|
<div id="loadingOverlay" class="loading-overlay" hidden>
|
||||||
|
|||||||
+11
-2
@@ -15,6 +15,7 @@
|
|||||||
error: "",
|
error: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
inviteCode: "",
|
||||||
passwordVisible: false,
|
passwordVisible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
|
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
|
||||||
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
|
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
|
||||||
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
|
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
|
||||||
|
`<label class="form-field" id="loginInviteField"${registering ? "" : " hidden"}><span>邀请码</span><input id="loginInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX" value="${escapeHtml(state.inviteCode)}"${registering ? " required" : ""}></label>`,
|
||||||
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
|
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
|
||||||
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
|
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
|
||||||
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
|
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
|
||||||
@@ -289,9 +291,14 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const username = document.querySelector("#loginUsername").value.trim();
|
const username = document.querySelector("#loginUsername").value.trim();
|
||||||
const password = document.querySelector("#loginPassword").value;
|
const password = document.querySelector("#loginPassword").value;
|
||||||
|
const registering = state.mode === "register";
|
||||||
|
const inviteCode = registering
|
||||||
|
? document.querySelector("#loginInviteCode").value.trim()
|
||||||
|
: "";
|
||||||
state.username = username;
|
state.username = username;
|
||||||
state.password = password;
|
state.password = password;
|
||||||
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
|
state.inviteCode = inviteCode;
|
||||||
|
if (registering && password !== document.querySelector("#loginPasswordConfirm").value) {
|
||||||
setError("两次输入的密码不一致。");
|
setError("两次输入的密码不一致。");
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
@@ -300,7 +307,9 @@
|
|||||||
setError("");
|
setError("");
|
||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
|
await api.request(`/api/auth/${state.mode}`, "POST", registering
|
||||||
|
? { username, password, invite_code: inviteCode }
|
||||||
|
: { username, password });
|
||||||
await celebrateLogin();
|
await celebrateLogin();
|
||||||
enterApp();
|
enterApp();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -52,8 +52,7 @@
|
|||||||
{ key: "system/profile", label: "账号资料", icon: "user" },
|
{ key: "system/profile", label: "账号资料", icon: "user" },
|
||||||
{ key: "system/password", label: "修改密码", icon: "lock" },
|
{ key: "system/password", label: "修改密码", icon: "lock" },
|
||||||
{ key: "system/membership", label: "会员状态", icon: "gem" },
|
{ key: "system/membership", label: "会员状态", icon: "gem" },
|
||||||
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
|
{ key: "system/admin", label: "行情管理", icon: "sliders-horizontal", adminOnly: true }
|
||||||
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+67
-374
@@ -198,11 +198,7 @@
|
|||||||
system: {
|
system: {
|
||||||
account: null,
|
account: null,
|
||||||
admin: null,
|
admin: null,
|
||||||
adminTab: "market",
|
|
||||||
models: [],
|
|
||||||
accounts: [],
|
accounts: [],
|
||||||
editingModelId: "",
|
|
||||||
editingUserId: "",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -770,11 +766,33 @@
|
|||||||
scroll.classList.add("m-motion-fade-in");
|
scroll.classList.add("m-motion-fade-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dashboardFreshnessNotice() {
|
||||||
|
const meta = (state.dashboard && state.dashboard.meta) || {};
|
||||||
|
if (meta.display_notice) return String(meta.display_notice);
|
||||||
|
const requested = String(meta.requested_date || "").replace(/-/g, "");
|
||||||
|
const actual = String(meta.trade_date || "").replace(/-/g, "");
|
||||||
|
const compact = actual;
|
||||||
|
const shown = /^\d{8}$/.test(compact)
|
||||||
|
? (Number(compact.slice(4, 6)) + " 月 " + Number(compact.slice(6, 8)) + " 日")
|
||||||
|
: "";
|
||||||
|
if (meta.data_status === "preparing" || (meta.carried_forward && actual && requested && actual !== requested)) {
|
||||||
|
return shown ? ("今日数据正在准备,当前展示 " + shown) : "今日数据正在准备,当前展示最近可用数据";
|
||||||
|
}
|
||||||
|
if (meta.data_status === "partial" || meta.limit_data_source === "derived") {
|
||||||
|
return meta.notice || "部分正式数据尚未到齐,当前展示日线推算结果";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function renderTopArea(key) {
|
function renderTopArea(key) {
|
||||||
const page = document.querySelector(".m-page");
|
const page = document.querySelector(".m-page");
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
let top = page.querySelector(".m-top");
|
let top = page.querySelector(".m-top");
|
||||||
let html = buildStrip();
|
let html = buildStrip();
|
||||||
|
const freshness = dashboardFreshnessNotice();
|
||||||
|
if (freshness) {
|
||||||
|
html = '<div class="m-phase-notice"><strong>' + escapeHtml(freshness) + "</strong></div>" + html;
|
||||||
|
}
|
||||||
if (key === "market/performance") html += performanceConclusion();
|
if (key === "market/performance") html += performanceConclusion();
|
||||||
if (!top) {
|
if (!top) {
|
||||||
top = document.createElement("div");
|
top = document.createElement("div");
|
||||||
@@ -3115,7 +3133,6 @@
|
|||||||
"system/password": setupSystemPage,
|
"system/password": setupSystemPage,
|
||||||
"system/membership": setupSystemPage,
|
"system/membership": setupSystemPage,
|
||||||
"system/admin": setupSystemPage,
|
"system/admin": setupSystemPage,
|
||||||
"system/members": setupSystemPage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPLEX_LOADERS = {
|
const COMPLEX_LOADERS = {
|
||||||
@@ -3138,7 +3155,6 @@
|
|||||||
"system/password": loadSystem,
|
"system/password": loadSystem,
|
||||||
"system/membership": loadSystem,
|
"system/membership": loadSystem,
|
||||||
"system/admin": loadSystem,
|
"system/admin": loadSystem,
|
||||||
"system/members": loadSystem,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isComplexPage(key) {
|
function isComplexPage(key) {
|
||||||
@@ -3380,9 +3396,9 @@
|
|||||||
const payload = detail && detail.payload ? detail.payload : {};
|
const payload = detail && detail.payload ? detail.payload : {};
|
||||||
const meta = payload.meta || {};
|
const meta = payload.meta || {};
|
||||||
if (tab === "daily") {
|
if (tab === "daily") {
|
||||||
const bars = (payload.prices || []).slice(-48);
|
const bars = (payload.prices || []).slice(-45);
|
||||||
const last = bars.length ? bars[bars.length - 1].trade_date : "";
|
const last = bars.length ? bars[bars.length - 1].trade_date : "";
|
||||||
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
|
return "日线 · 近45根 · 至 " + (displayCompactDate(last) || "--");
|
||||||
}
|
}
|
||||||
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
|
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
|
||||||
return "分时 · " + (d || "--");
|
return "分时 · " + (d || "--");
|
||||||
@@ -3680,7 +3696,7 @@
|
|||||||
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
||||||
const pw = W - padL - padR;
|
const pw = W - padL - padR;
|
||||||
const ph = H - padT - padB;
|
const ph = H - padT - padB;
|
||||||
const prices = (payload.prices || []).slice(-48);
|
const prices = (payload.prices || []).slice(-45);
|
||||||
|
|
||||||
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
||||||
|
|
||||||
@@ -4916,7 +4932,7 @@
|
|||||||
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
||||||
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
||||||
"<p>仅管理员可访问</p>" +
|
"<p>仅管理员可访问</p>" +
|
||||||
"<small>系统设置和会员管理需要管理员权限。</small>" +
|
"<small>行情管理与数据中枢需要管理员权限。</small>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4952,7 +4968,6 @@
|
|||||||
state.sort = { key: "", dir: null };
|
state.sort = { key: "", dir: null };
|
||||||
state.sortTable = { cols: null, reapply: null };
|
state.sortTable = { cols: null, reapply: null };
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
if (key === "system/admin") state.system.adminTab = state.system.adminTab || "market";
|
|
||||||
document.getElementById("m-view").classList.add("m-view-feature");
|
document.getElementById("m-view").classList.add("m-view-feature");
|
||||||
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
||||||
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
||||||
@@ -4960,7 +4975,7 @@
|
|||||||
|
|
||||||
function loadSystem() {
|
function loadSystem() {
|
||||||
const key = state.key;
|
const key = state.key;
|
||||||
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
|
if (key === "system/admin" && !global.MobileSession.isAdmin()) {
|
||||||
systemFill(systemForbiddenHtml());
|
systemFill(systemForbiddenHtml());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4969,15 +4984,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seq = nextSeq();
|
const seq = nextSeq();
|
||||||
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
|
const url = key === "system/admin" ? "/api/admin/settings" : "/api/account/status";
|
||||||
global.MobileAPI.request(url).then(function (payload) {
|
return global.MobileAPI.request(url).then(function (payload) {
|
||||||
if (seq !== state.seq || state.key !== key) return;
|
if (seq !== state.seq || state.key !== key) return;
|
||||||
if (key === "system/admin" || key === "system/members") {
|
if (key === "system/admin") {
|
||||||
state.system.admin = payload || {};
|
state.system.admin = payload || {};
|
||||||
state.system.models = ((payload.llm && payload.llm.models) || []).map(function (item) {
|
renderSystemAdmin();
|
||||||
return Object.assign({}, item);
|
|
||||||
});
|
|
||||||
renderSystemAdmin(key);
|
|
||||||
} else {
|
} else {
|
||||||
state.system.account = payload || {};
|
state.system.account = payload || {};
|
||||||
if (key === "system/profile") renderSystemProfile();
|
if (key === "system/profile") renderSystemProfile();
|
||||||
@@ -5067,8 +5079,7 @@
|
|||||||
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
||||||
];
|
];
|
||||||
const adminRows = [
|
const adminRows = [
|
||||||
{ key: "system/admin", icon: "sliders-horizontal", label: "系统设置", hint: "行情数据 · 模型池" },
|
{ key: "system/admin", icon: "sliders-horizontal", label: "行情管理", hint: "数据源状态 · 后台刷新 · 历史回补" }
|
||||||
{ key: "system/members", icon: "users", label: "会员管理", hint: "开通 · 续期 · 额度" }
|
|
||||||
];
|
];
|
||||||
const html =
|
const html =
|
||||||
'<div class="m-sys-home" data-system-page="home">' +
|
'<div class="m-sys-home" data-system-page="home">' +
|
||||||
@@ -5082,7 +5093,7 @@
|
|||||||
'<h3 class="m-sys-group-title">偏好</h3>' +
|
'<h3 class="m-sys-group-title">偏好</h3>' +
|
||||||
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
||||||
(global.MobileSession.isAdmin()
|
(global.MobileSession.isAdmin()
|
||||||
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + "</div>"
|
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + dataHubRowHtml() + "</div>"
|
||||||
: "") +
|
: "") +
|
||||||
'<h3 class="m-sys-group-title">其他</h3>' +
|
'<h3 class="m-sys-group-title">其他</h3>' +
|
||||||
'<div class="m-card m-sys-list">' +
|
'<div class="m-card m-sys-list">' +
|
||||||
@@ -5095,7 +5106,7 @@
|
|||||||
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
||||||
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "</p>" +
|
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "行情管理与数据中枢仅管理员可见") + "</p>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
document.getElementById("m-view").innerHTML = html;
|
document.getElementById("m-view").innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -5185,41 +5196,37 @@
|
|||||||
systemFill(html);
|
systemFill(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminTabHtml() {
|
|
||||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
|
||||||
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
|
|
||||||
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
|
|
||||||
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
|
|
||||||
"</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusDot(ok) {
|
function statusDot(ok) {
|
||||||
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
|
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSystemAdmin(key) {
|
function datahubStatusText(hub) {
|
||||||
if (key === "system/members") {
|
const enabled = number(hub.enabled_reads);
|
||||||
renderSystemMembers();
|
const total = number(hub.total_reads) || enabled;
|
||||||
return;
|
const fallbacks = hub.fallback_labels || [];
|
||||||
|
if (fallbacks.length) return " 备用 " + fallbacks.join("、");
|
||||||
|
if (hub.configured) return " 主线路 " + enabled + "/" + total;
|
||||||
|
return " 未配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSystemAdmin() {
|
||||||
const payload = state.system.admin || {};
|
const payload = state.system.admin || {};
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
const ifind = data.ifind || {};
|
const ifind = data.ifind || {};
|
||||||
const llm = payload.llm || {};
|
|
||||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
|
||||||
const marketHtml =
|
const marketHtml =
|
||||||
'<div class="m-sys-body" data-system-admin-panel="market">' +
|
'<div class="m-sys-body" data-system-admin-panel="market">' +
|
||||||
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
|
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
|
||||||
'<div class="m-sys-status-list">' +
|
'<div class="m-sys-status-list">' +
|
||||||
'<div class="m-sys-status-item"><span>Tushare</span><span>' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "</span></div>" +
|
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(data.configured) + (data.configured ? " 已连接" : " 未连接") + "</span></div>" +
|
||||||
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
|
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
|
||||||
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
|
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
|
||||||
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
|
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
|
||||||
|
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" +
|
||||||
"</div></div>" +
|
"</div></div>" +
|
||||||
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
|
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
|
||||||
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空则保留现有 Token">', false) +
|
formFieldHtml("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
|
||||||
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空则保留现有 Token">', false) +
|
formFieldHtml("实时来源凭据", '<input id="m-sys-ifind" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-market>保存密钥</button></div>' +
|
'<button class="m-btn-primary" type="button" data-system-save-market>刷新状态</button></div>' +
|
||||||
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
|
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
|
||||||
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
|
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
|
||||||
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
|
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
|
||||||
@@ -5231,178 +5238,28 @@
|
|||||||
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
||||||
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
|
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
|
||||||
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
|
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
|
||||||
|
'<div class="m-card m-sys-section"><strong>模型池 · 会员 · 邀请码</strong>' +
|
||||||
|
'<p class="m-sys-hint">这些配置已统一在数据中枢管理。</p>' +
|
||||||
|
'<button class="m-btn-outline" type="button" data-system-datahub>打开数据中枢</button></div>' +
|
||||||
"</div>";
|
"</div>";
|
||||||
const models = state.system.models || [];
|
systemFill(marketHtml);
|
||||||
const modelsHtml =
|
|
||||||
'<div class="m-sys-body" data-system-admin-panel="models">' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>模型分工</strong>' +
|
|
||||||
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
|
|
||||||
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
|
|
||||||
'<p class="m-sys-hint">主模型不可用时自动改用辅助模型</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-models>保存分工</button></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>模型池 · ' + models.length + " 个</strong>" +
|
|
||||||
'<div id="m-sys-model-list">' + renderModelPoolHtml(models) + "</div>" +
|
|
||||||
'<p class="m-sys-hint">点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-add-model>+ 添加模型</button></div>' +
|
|
||||||
"</div>";
|
|
||||||
const page = document.querySelector(".m-page");
|
|
||||||
if (!page) return;
|
|
||||||
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>";
|
|
||||||
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostOfUrl(url) {
|
function dataHubConsoleUrl() {
|
||||||
try {
|
if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL);
|
||||||
return new URL(url).host;
|
return global.location.protocol + "//" + global.location.hostname + ":8766/admin/";
|
||||||
} catch (error) {
|
|
||||||
return String(url || "").replace(/^https?:\/\//, "").split("/")[0] || "--";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderModelPoolHtml(models) {
|
function openDataHubConsole() {
|
||||||
if (!models.length) {
|
if (!global.MobileSession.isAdmin()) return;
|
||||||
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
|
global.open(dataHubConsoleUrl(), "_blank", "noopener");
|
||||||
}
|
}
|
||||||
const llm = (state.system.admin && state.system.admin.llm) || {};
|
|
||||||
return models.map(function (item) {
|
function dataHubRowHtml() {
|
||||||
const badges = [];
|
return '<button class="m-sys-row" type="button" data-system-datahub>' +
|
||||||
if (item.id === llm.primary_model_id) badges.push('<span class="m-sys-badge m-sys-badge--admin">主模型</span>');
|
'<span class="m-sys-row-icon">' + icon("database", 18) + "</span>" +
|
||||||
if (item.id === llm.fallback_model_id) badges.push('<span class="m-sys-badge">辅助</span>');
|
'<span class="m-sys-row-body"><strong>数据中枢</strong><small>数据源 · 模型池 · 会员与邀请码</small></span>' +
|
||||||
badges.push('<span class="m-sys-badge' + (item.configured ? " m-sys-badge--ok" : "") + '">' + (item.configured ? "已配置" : "待配置") + "</span>");
|
|
||||||
return '<button class="m-sys-model-card" type="button" data-model-id="' + escapeHtml(item.id) + '" data-system-edit-model>' +
|
|
||||||
"<div><strong>" + escapeHtml(item.name || "未命名模型") + "</strong>" +
|
|
||||||
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
|
|
||||||
'<p class="m-sys-hint">' + escapeHtml(hostOfUrl(item.base_url) + " · " + (item.model || "未填写标识")) + "</p></div>" +
|
|
||||||
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
|
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSystemModelPool() {
|
|
||||||
return (state.system.models || []).map(function (item) {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name || "",
|
|
||||||
base_url: item.base_url || "",
|
|
||||||
model: item.model || "",
|
|
||||||
api_key: item.api_key || "",
|
|
||||||
configured: Boolean(item.configured)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSystemModelRoleOptions(primaryId, fallbackId) {
|
|
||||||
const models = collectSystemModelPool();
|
|
||||||
const options = models.map(function (item) {
|
|
||||||
return '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
|
|
||||||
}).join("");
|
|
||||||
const primary = document.getElementById("m-sys-primary-model");
|
|
||||||
const fallback = document.getElementById("m-sys-fallback-model");
|
|
||||||
if (!primary || !fallback) return;
|
|
||||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
|
||||||
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + options;
|
|
||||||
const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
|
|
||||||
primary.value = keepPrimary;
|
|
||||||
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModelEditSheet(modelId) {
|
|
||||||
const models = state.system.models || [];
|
|
||||||
const item = models.find(function (row) { return row.id === modelId; }) || {};
|
|
||||||
state.system.editingModelId = modelId;
|
|
||||||
openSheet(
|
|
||||||
'<div class="m-sheet-head"><h2>编辑模型</h2>' +
|
|
||||||
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
|
|
||||||
'<div class="m-sheet-body" data-model-id="' + escapeHtml(modelId) + '">' +
|
|
||||||
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
|
|
||||||
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
|
|
||||||
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
|
|
||||||
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空则保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
|
|
||||||
'<div class="m-sys-test-row"><button class="m-btn-outline" type="button" data-system-test-model>测试连接</button>' +
|
|
||||||
'<span data-model-test-status>未测试</span></div>' +
|
|
||||||
'<div class="m-sys-sheet-actions">' +
|
|
||||||
'<button class="m-btn-outline-danger" type="button" data-system-delete-model>删除模型</button>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-model>保存</button>' +
|
|
||||||
"</div></div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readModelSheetFields(root) {
|
|
||||||
function fieldValue(name) {
|
|
||||||
const input = root.querySelector("[data-model-field='" + name + "']");
|
|
||||||
return String(input && input.value != null ? input.value : "").trim();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: fieldValue("name"),
|
|
||||||
base_url: fieldValue("base_url"),
|
|
||||||
model: fieldValue("model"),
|
|
||||||
api_key: fieldValue("api_key")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSystemMembers() {
|
|
||||||
const payload = state.system.admin || {};
|
|
||||||
const membership = payload.membership || {};
|
|
||||||
const users = payload.users || [];
|
|
||||||
const userHtml = users.map(function (user) {
|
|
||||||
const admin = user.role === "admin";
|
|
||||||
const member = Boolean(user.membership_subscribed);
|
|
||||||
const badges = [];
|
|
||||||
if (admin) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
|
|
||||||
if (member) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员</span>');
|
|
||||||
else badges.push('<span class="m-sys-badge">普通用户</span>');
|
|
||||||
const expiry = member
|
|
||||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
|
||||||
: user.membership_status === "suspended"
|
|
||||||
? "会员已停用"
|
|
||||||
: "尚未开通";
|
|
||||||
return '<div class="m-sys-user-row" data-admin-user="' + number(user.id) + '">' +
|
|
||||||
'<div class="m-sys-row-body"><strong>' + escapeHtml(user.username) + "</strong>" +
|
|
||||||
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
|
|
||||||
'<p class="m-sys-hint">' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p></div>" +
|
|
||||||
'<button class="m-btn-outline" type="button" data-system-open-member>管理</button></div>';
|
|
||||||
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
|
|
||||||
const html =
|
|
||||||
'<div class="m-sys-body" data-system-page="members">' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>全局额度</strong>' +
|
|
||||||
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
|
|
||||||
'<p class="m-sys-hint">对所有会员生效,每日 0 点自动重置。</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-limit>保存额度</button></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>会员账号 · ' + users.length + " 个</strong>" +
|
|
||||||
userHtml +
|
|
||||||
'<p class="m-sys-hint">点「管理」为对应账号开通、续期或停用会员。</p></div>' +
|
|
||||||
"</div>";
|
|
||||||
systemFill(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMemberManageSheet(userId) {
|
|
||||||
const users = (state.system.admin && state.system.admin.users) || [];
|
|
||||||
const user = users.find(function (item) { return String(item.id) === String(userId); });
|
|
||||||
if (!user) return;
|
|
||||||
state.system.editingUserId = String(userId);
|
|
||||||
const member = Boolean(user.membership_subscribed);
|
|
||||||
const expiry = member
|
|
||||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
|
||||||
: user.membership_status === "suspended" ? "会员已停用" : "尚未开通";
|
|
||||||
openSheet(
|
|
||||||
'<div class="m-sheet-head"><h2>管理会员 · ' + escapeHtml(user.username) + "</h2>" +
|
|
||||||
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
|
|
||||||
'<div class="m-sheet-body" data-admin-user="' + number(user.id) + '">' +
|
|
||||||
'<p class="m-sys-lead">当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p>" +
|
|
||||||
formFieldHtml("会员状态", '<select data-member-status>' +
|
|
||||||
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
|
|
||||||
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
|
|
||||||
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
|
|
||||||
"</select>", false) +
|
|
||||||
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
|
|
||||||
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
|
|
||||||
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
|
|
||||||
'<option value="permanent">永久</option></select>', false) +
|
|
||||||
'<p class="m-sys-hint">从当前时间开始顺延;已有会员则叠加续期。</p>' +
|
|
||||||
'<div class="m-sys-sheet-actions">' +
|
|
||||||
'<button class="m-btn-outline" type="button" data-sheet-close>取消</button>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
|
|
||||||
"</div></div>"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldError(inputId, message) {
|
function setFieldError(inputId, message) {
|
||||||
@@ -5518,14 +5375,10 @@
|
|||||||
function saveSystemMarket() {
|
function saveSystemMarket() {
|
||||||
const button = document.querySelector("[data-system-save-market]");
|
const button = document.querySelector("[data-system-save-market]");
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
loadSystem().then(function () {
|
||||||
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
|
showToast("数据中枢状态已刷新");
|
||||||
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
|
|
||||||
}).then(function () {
|
|
||||||
showToast("行情密钥已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
showToast(error && error.message ? error.message : "系统配置保存失败");
|
showToast(error && error.message ? error.message : "数据中枢状态读取失败");
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
if (button) button.disabled = false;
|
if (button) button.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -5543,97 +5396,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSystemModels() {
|
|
||||||
const button = document.querySelector("[data-system-save-models]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
|
||||||
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
|
|
||||||
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
|
|
||||||
}).then(function () {
|
|
||||||
showToast("模型分工已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "模型分工保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistSystemModels(models, message) {
|
|
||||||
return global.MobileAPI.request("/api/admin/settings", "POST", { models: models }).then(function () {
|
|
||||||
showToast(message || "模型池已保存");
|
|
||||||
closeSheet();
|
|
||||||
loadSystem();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEditedSystemModel() {
|
|
||||||
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
|
|
||||||
if (!sheet) return;
|
|
||||||
const id = sheet.dataset.modelId;
|
|
||||||
const fields = readModelSheetFields(sheet);
|
|
||||||
const models = collectSystemModelPool().map(function (item) {
|
|
||||||
if (item.id !== id) return item;
|
|
||||||
return Object.assign({}, item, fields);
|
|
||||||
});
|
|
||||||
persistSystemModels(models, "模型已保存").catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "模型保存失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSystemModel() {
|
|
||||||
const models = collectSystemModelPool();
|
|
||||||
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
|
||||||
const created = { id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false };
|
|
||||||
state.system.models = models.concat([created]);
|
|
||||||
const list = document.getElementById("m-sys-model-list");
|
|
||||||
if (list) list.innerHTML = renderModelPoolHtml(state.system.models);
|
|
||||||
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
|
|
||||||
openModelEditSheet(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteSystemModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const id = row.dataset.modelId;
|
|
||||||
const primary = (document.getElementById("m-sys-primary-model") || {}).value;
|
|
||||||
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
|
|
||||||
const llm = (state.system.admin && state.system.admin.llm) || {};
|
|
||||||
if (id === primary || id === fallback || id === llm.primary_model_id || id === llm.fallback_model_id) {
|
|
||||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openConfirmSheet("删除模型?", "删除后该模型将从模型池移除,此操作不可恢复。", {
|
|
||||||
danger: true,
|
|
||||||
centered: true,
|
|
||||||
confirmLabel: "删除",
|
|
||||||
onConfirm: function () {
|
|
||||||
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
|
|
||||||
persistSystemModels(models, "模型已删除").catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "删除失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function testSystemModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const status = document.querySelector("[data-model-test-status]");
|
|
||||||
const button = document.querySelector("[data-system-test-model]");
|
|
||||||
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
|
|
||||||
const id = (sheet && sheet.dataset.modelId) || row.dataset.modelId;
|
|
||||||
const fields = sheet ? readModelSheetFields(sheet) : {};
|
|
||||||
const profile = Object.assign({}, collectSystemModelPool().find(function (item) { return item.id === id; }) || {}, fields, { id: id });
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
if (status) status.textContent = "连接中";
|
|
||||||
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: id, profile: profile }).then(function (payload) {
|
|
||||||
if (status) status.textContent = "上次测试:成功 · " + number(payload.result && payload.result.latency_ms) + "ms";
|
|
||||||
}).catch(function (error) {
|
|
||||||
if (status) status.textContent = error && error.message ? error.message : "测试失败";
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSystemRefresh() {
|
function startSystemRefresh() {
|
||||||
const button = document.querySelector("[data-system-refresh]");
|
const button = document.querySelector("[data-system-refresh]");
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
@@ -5671,56 +5433,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSystemMemberLimit() {
|
|
||||||
const button = document.querySelector("[data-system-save-limit]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
|
||||||
member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
|
|
||||||
}).then(function () {
|
|
||||||
showToast("会员调用额度已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "会员调用额度保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSystemMember(card) {
|
|
||||||
if (!card) return;
|
|
||||||
const status = (card.querySelector("[data-member-status]") || {}).value;
|
|
||||||
const apply = function () {
|
|
||||||
const button = card.querySelector("[data-system-save-member]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/membership", "POST", {
|
|
||||||
user_id: card.dataset.adminUser,
|
|
||||||
status: status,
|
|
||||||
duration: (card.querySelector("[data-member-duration]") || {}).value,
|
|
||||||
}).then(function (payload) {
|
|
||||||
if (state.system.admin) state.system.admin.users = payload.users || [];
|
|
||||||
closeSheet();
|
|
||||||
showToast("会员状态已更新");
|
|
||||||
renderSystemMembers();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "会员状态保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (status === "suspended") {
|
|
||||||
openConfirmSheet("停用该会员?", "停用后该账号将无法使用会员智能功能,可稍后重新开通。", {
|
|
||||||
danger: true,
|
|
||||||
centered: true,
|
|
||||||
confirmLabel: "停用",
|
|
||||||
onConfirm: apply
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- events */
|
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
window.addEventListener("hashchange", closeSheet);
|
window.addEventListener("hashchange", closeSheet);
|
||||||
|
|
||||||
@@ -5848,12 +5560,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemAdminTab = event.target.closest("[data-system-admin-tab]");
|
|
||||||
if (systemAdminTab) {
|
|
||||||
state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
|
|
||||||
renderSystemAdmin("system/admin");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
||||||
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
||||||
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
||||||
@@ -5861,22 +5567,9 @@
|
|||||||
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
||||||
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
||||||
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
|
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
|
||||||
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
|
|
||||||
if (event.target.closest("[data-system-save-model]")) { saveEditedSystemModel(); return; }
|
|
||||||
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
|
|
||||||
const editModel = event.target.closest("[data-system-edit-model]");
|
|
||||||
if (editModel) { openModelEditSheet(editModel.dataset.modelId); return; }
|
|
||||||
const deleteModel = event.target.closest("[data-system-delete-model]");
|
|
||||||
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
|
|
||||||
const testModel = event.target.closest("[data-system-test-model]");
|
|
||||||
if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
|
|
||||||
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
||||||
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
||||||
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
|
if (event.target.closest("[data-system-datahub]")) { openDataHubConsole(); return; }
|
||||||
const openMember = event.target.closest("[data-system-open-member]");
|
|
||||||
if (openMember) { openMemberManageSheet(openMember.closest("[data-admin-user]").dataset.adminUser); return; }
|
|
||||||
const saveMember = event.target.closest("[data-system-save-member]");
|
|
||||||
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
|
|
||||||
|
|
||||||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||||||
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
||||||
|
|||||||
@@ -263,6 +263,7 @@
|
|||||||
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
|
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
|
||||||
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
|
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
|
||||||
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
|
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
|
||||||
|
'<label class="m-form-field" id="m-auth-invite-field" hidden><span>邀请码</span><input id="m-auth-invite" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>',
|
||||||
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
|
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
|
||||||
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
|
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
|
||||||
"</form>",
|
"</form>",
|
||||||
@@ -280,6 +281,8 @@
|
|||||||
});
|
});
|
||||||
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
|
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
|
||||||
document.getElementById("m-auth-confirm").required = authMode === "register";
|
document.getElementById("m-auth-confirm").required = authMode === "register";
|
||||||
|
document.getElementById("m-auth-invite-field").hidden = authMode !== "register";
|
||||||
|
document.getElementById("m-auth-invite").required = authMode === "register";
|
||||||
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
|
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
|
||||||
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
|
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
|
||||||
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
|
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
|
||||||
@@ -346,7 +349,7 @@
|
|||||||
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
|
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
|
||||||
try {
|
try {
|
||||||
if (authMode === "register") {
|
if (authMode === "register") {
|
||||||
await global.MobileSession.register(username, password);
|
await global.MobileSession.register(username, password, document.getElementById("m-auth-invite").value.trim());
|
||||||
} else {
|
} else {
|
||||||
await global.MobileSession.login(username, password);
|
await global.MobileSession.login(username, password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
return applySession(payload);
|
return applySession(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username, password) {
|
async function register(username, password, inviteCode) {
|
||||||
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
|
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password,
|
||||||
|
invite_code: inviteCode || ""
|
||||||
});
|
});
|
||||||
return applySession(payload);
|
return applySession(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,10 @@
|
|||||||
"/pages/sentiment/page.js?v=20260729-1",
|
"/pages/sentiment/page.js?v=20260729-1",
|
||||||
"/pages/pools/page.js?v=20260820-1",
|
"/pages/pools/page.js?v=20260820-1",
|
||||||
"/pages/market/breadth.js?v=20260803-1",
|
"/pages/market/breadth.js?v=20260803-1",
|
||||||
"/pages/market/charts.js?v=20260803-1",
|
"/pages/market/charts.js?v=20260908-1",
|
||||||
"/pages/market/entity-detail.js?v=20260803-1",
|
"/pages/market/entity-detail.js?v=20260908-1",
|
||||||
"/pages/market/stock-detail.js?v=20260803-1",
|
"/pages/market/stock-detail.js?v=20260908-1",
|
||||||
"/pages/market/preview.js?v=20260806-1",
|
"/pages/market/preview.js?v=20260908-1",
|
||||||
"/pages/market/search.js?v=20260803-1",
|
"/pages/market/search.js?v=20260803-1",
|
||||||
"/pages/market/bindings.js?v=20260803-1",
|
"/pages/market/bindings.js?v=20260803-1",
|
||||||
"/pages/ladder/page.js?v=20260820-1",
|
"/pages/ladder/page.js?v=20260820-1",
|
||||||
|
|||||||
@@ -113,9 +113,12 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
document.querySelector("#resetHeavenCalibrationButton"),
|
document.querySelector("#resetHeavenCalibrationButton"),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
cancelHeavenPerformance();
|
cancelHeavenPerformance();
|
||||||
heavenView?.classList.add("heaven-data-loading");
|
const blocking = !state.heavenSetup;
|
||||||
|
if (blocking) heavenView?.classList.add("heaven-data-loading");
|
||||||
if (loadButton) loadButton.disabled = true;
|
if (loadButton) loadButton.disabled = true;
|
||||||
calibrationButtons.forEach((button) => { button.disabled = true; });
|
calibrationButtons.forEach((button) => { button.disabled = true; });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 25_000);
|
||||||
try {
|
try {
|
||||||
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
|
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
|
||||||
state.personalField = null;
|
state.personalField = null;
|
||||||
@@ -126,7 +129,7 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
if (sector) query.set("sector", sector);
|
if (sector) query.set("sector", sector);
|
||||||
if (stockCode) query.set("stock_code", stockCode);
|
if (stockCode) query.set("stock_code", stockCode);
|
||||||
if (manualData) query.set("manual_data", JSON.stringify(manualData));
|
if (manualData) query.set("manual_data", JSON.stringify(manualData));
|
||||||
const payload = await apiRequest(`/api/heaven/setup?${query}`);
|
const payload = await apiRequest(`/api/heaven/setup?${query}`, "GET", null, { signal: controller.signal });
|
||||||
if (
|
if (
|
||||||
requestSequence !== state.heavenRequestSequence
|
requestSequence !== state.heavenRequestSequence
|
||||||
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
|
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
|
||||||
@@ -152,9 +155,15 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
|
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestSequence !== state.heavenRequestSequence) return;
|
if (requestSequence !== state.heavenRequestSequence) return;
|
||||||
showHeavenNotice(error.message || "问天数据加载失败");
|
const aborted = error?.payload?.aborted || /abort|超时|cancel/i.test(String(error?.message || ""));
|
||||||
showToast(error.message || "问天数据加载失败");
|
const message = aborted
|
||||||
|
? "问天数据仍在准备,页面可继续输入和操作"
|
||||||
|
: (error.message || "问天数据加载失败");
|
||||||
|
showHeavenNotice(message);
|
||||||
|
if (!aborted) showToast(message);
|
||||||
|
if (!state.heavenSetup) renderHeavenWorkspace();
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
if (requestSequence === state.heavenRequestSequence) {
|
if (requestSequence === state.heavenRequestSequence) {
|
||||||
heavenView?.classList.remove("heaven-data-loading");
|
heavenView?.classList.remove("heaven-data-loading");
|
||||||
if (loadButton) loadButton.disabled = false;
|
if (loadButton) loadButton.disabled = false;
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
const DAILY_CHART_BARS = 45;
|
||||||
|
|
||||||
|
function visibleDailyPrices(prices) {
|
||||||
|
return (prices || []).slice(-DAILY_CHART_BARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyChartSourceLabel(prices, notice) {
|
||||||
|
const count = visibleDailyPrices(prices).length;
|
||||||
|
const base = `日 K 行情 · ${count} 个交易日`;
|
||||||
|
const text = String(notice || "").trim();
|
||||||
|
return text ? `${base} · ${text}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
function currentChartPalette() {
|
function currentChartPalette() {
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
|
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
|
||||||
@@ -56,7 +69,8 @@ function drawCandlestick(context, x, item, priceY, candleWidth, palette = curren
|
|||||||
|
|
||||||
function drawPriceChart(prices) {
|
function drawPriceChart(prices) {
|
||||||
const canvas = elements.priceChart;
|
const canvas = elements.priceChart;
|
||||||
if (!prices?.length) {
|
const visible = visibleDailyPrices(prices);
|
||||||
|
if (!visible.length) {
|
||||||
clearPriceChart("暂无日 K 数据");
|
clearPriceChart("暂无日 K 数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,15 +95,15 @@ function drawPriceChart(prices) {
|
|||||||
const gap = 12;
|
const gap = 12;
|
||||||
const priceBottom = height - bottom - volumeHeight - gap;
|
const priceBottom = height - bottom - volumeHeight - gap;
|
||||||
const plotWidth = width - left - right;
|
const plotWidth = width - left - right;
|
||||||
const highs = prices.map((item) => number(item.high));
|
const highs = visible.map((item) => number(item.high));
|
||||||
const lows = prices.map((item) => number(item.low));
|
const lows = visible.map((item) => number(item.low));
|
||||||
const maximum = Math.max(...highs);
|
const maximum = Math.max(...highs);
|
||||||
const minimum = Math.min(...lows);
|
const minimum = Math.min(...lows);
|
||||||
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
||||||
const volumes = prices.map((item) => number(item.volume));
|
const volumes = visible.map((item) => number(item.volume));
|
||||||
const maxVolume = Math.max(...volumes, 1);
|
const maxVolume = Math.max(...volumes, 1);
|
||||||
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
||||||
const step = plotWidth / prices.length;
|
const step = plotWidth / visible.length;
|
||||||
const candleWidth = clamp(step * 0.62, 2, 8);
|
const candleWidth = clamp(step * 0.62, 2, 8);
|
||||||
|
|
||||||
context.strokeStyle = palette.grid;
|
context.strokeStyle = palette.grid;
|
||||||
@@ -105,7 +119,7 @@ function drawPriceChart(prices) {
|
|||||||
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
|
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
prices.forEach((item, index) => {
|
visible.forEach((item, index) => {
|
||||||
const x = left + step * index + step / 2;
|
const x = left + step * index + step / 2;
|
||||||
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
||||||
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
||||||
@@ -117,10 +131,10 @@ function drawPriceChart(prices) {
|
|||||||
|
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.fillStyle = palette.axis;
|
context.fillStyle = palette.axis;
|
||||||
const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1];
|
const labelIndexes = [0, Math.floor((visible.length - 1) / 2), visible.length - 1];
|
||||||
labelIndexes.forEach((index) => {
|
labelIndexes.forEach((index) => {
|
||||||
const x = left + step * index + step / 2;
|
const x = left + step * index + step / 2;
|
||||||
context.fillText(String(prices[index].trade_date).slice(5), x, height - 5);
|
context.fillText(String(visible[index].trade_date).slice(5), x, height - 5);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +315,7 @@ function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) {
|
|||||||
|
|
||||||
function drawDailyPreviewChart(prices) {
|
function drawDailyPreviewChart(prices) {
|
||||||
const { context, width, height, palette } = prepareStockPreviewCanvas();
|
const { context, width, height, palette } = prepareStockPreviewCanvas();
|
||||||
const visible = prices.slice(-45);
|
const visible = visibleDailyPrices(prices);
|
||||||
const visibleStart = prices.length - visible.length;
|
const visibleStart = prices.length - visible.length;
|
||||||
const left = 45;
|
const left = 45;
|
||||||
const right = 10;
|
const right = 10;
|
||||||
|
|||||||
@@ -113,13 +113,13 @@ function renderEntityDetailMetrics(metrics) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
|
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
|
||||||
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
|
const candles = visibleDailyPrices((series || []).filter((item) => number(item.close) > 0).map((item) => {
|
||||||
const close = number(item.close);
|
const close = number(item.close);
|
||||||
const open = number(item.open) || close;
|
const open = number(item.open) || close;
|
||||||
const high = Math.max(number(item.high) || close, open, close);
|
const high = Math.max(number(item.high) || close, open, close);
|
||||||
const low = Math.min(number(item.low) || close, open, close);
|
const low = Math.min(number(item.low) || close, open, close);
|
||||||
return { ...item, open, high, low, close };
|
return { ...item, open, high, low, close };
|
||||||
});
|
}));
|
||||||
if (!candles.length) {
|
if (!candles.length) {
|
||||||
clearEntityDetailChart("暂无日 K 数据", canvas);
|
clearEntityDetailChart("暂无日 K 数据", canvas);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -367,7 +367,8 @@ function selectStockPreviewChart(chart) {
|
|||||||
}
|
}
|
||||||
} else if ((payload.prices || []).length) {
|
} else if ((payload.prices || []).length) {
|
||||||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||||||
setText("stockPreviewSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
|
const notice = String(payload.meta?.notice || "").trim();
|
||||||
|
setText("stockPreviewSource", dailyChartSourceLabel(payload.prices, notice));
|
||||||
drawDailyPreviewChart(payload.prices);
|
drawDailyPreviewChart(payload.prices);
|
||||||
} else {
|
} else {
|
||||||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||||||
|
|||||||
@@ -20,17 +20,9 @@ async function openStock(code, fallback = null) {
|
|||||||
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
|
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
|
||||||
setText("detailReason", row.reason || "--");
|
setText("detailReason", row.reason || "--");
|
||||||
setText("detailSector", row.sector || "其他");
|
setText("detailSector", row.sector || "其他");
|
||||||
setText("detailFirst", row.first_time || "--");
|
setStockBoardFields(row);
|
||||||
setText("detailLast", row.last_time || "--");
|
|
||||||
setText("detailOpen", `${number(row.open_times)} 次`);
|
|
||||||
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
|
|
||||||
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
|
|
||||||
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`);
|
|
||||||
setText("chartSource", "正在加载行情");
|
setText("chartSource", "正在加载行情");
|
||||||
setText("flowNet", "--");
|
renderMoneyflow({});
|
||||||
setText("flowLarge", "--");
|
|
||||||
setText("flowMedium", "--");
|
|
||||||
setText("flowSmall", "--");
|
|
||||||
document.querySelector("#reasonInput").value = row.reason || "";
|
document.querySelector("#reasonInput").value = row.reason || "";
|
||||||
document.querySelector("#stockNoteContent").value = "";
|
document.querySelector("#stockNoteContent").value = "";
|
||||||
document.querySelector("#stockNotePlan").value = "";
|
document.querySelector("#stockNotePlan").value = "";
|
||||||
@@ -48,11 +40,13 @@ async function openStock(code, fallback = null) {
|
|||||||
setText("detailName", stock.name || row.name);
|
setText("detailName", stock.name || row.name);
|
||||||
setText("detailPrice", formatNumber(stock.price || row.price, 2));
|
setText("detailPrice", formatNumber(stock.price || row.price, 2));
|
||||||
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
|
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
|
||||||
|
setStockBoardFields({ ...row, ...stock });
|
||||||
renderMoneyflow(payload.moneyflow || {});
|
renderMoneyflow(payload.moneyflow || {});
|
||||||
renderStockNotes(payload.notes || []);
|
renderStockNotes(payload.notes || []);
|
||||||
updateWatchButton();
|
updateWatchButton();
|
||||||
if (state.stockDetailChartMode === "daily") {
|
if (state.stockDetailChartMode === "daily") {
|
||||||
setText("chartSource", `日 K 行情 · ${payload.prices.length} 个交易日`);
|
const notice = String(payload.meta?.notice || "").trim();
|
||||||
|
setText("chartSource", dailyChartSourceLabel(payload.prices, notice));
|
||||||
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
|
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -69,7 +63,11 @@ async function selectStockDetailChart(mode) {
|
|||||||
syncDetailChartButtons("stock", selected);
|
syncDetailChartButtons("stock", selected);
|
||||||
if (selected === "daily") {
|
if (selected === "daily") {
|
||||||
const prices = state.stockDetail?.prices || [];
|
const prices = state.stockDetail?.prices || [];
|
||||||
setText("chartSource", prices.length ? `日 K 行情 · ${prices.length} 个交易日` : "正在加载行情");
|
const notice = String(state.stockDetail?.meta?.notice || "").trim();
|
||||||
|
setText(
|
||||||
|
"chartSource",
|
||||||
|
prices.length ? dailyChartSourceLabel(prices, notice) : "正在加载行情",
|
||||||
|
);
|
||||||
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
|
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
|
||||||
else clearPriceChart("正在加载日 K 数据");
|
else clearPriceChart("正在加载日 K 数据");
|
||||||
return;
|
return;
|
||||||
@@ -111,6 +109,21 @@ function renderStockDetailIntraday(payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStockBoardFields(row) {
|
||||||
|
const firstTime = String(row.first_time || "").trim();
|
||||||
|
const lastTime = String(row.last_time || "").trim();
|
||||||
|
setText("detailFirst", firstTime && firstTime !== "--" ? firstTime : "--");
|
||||||
|
setText("detailLast", lastTime && lastTime !== "--" ? lastTime : "--");
|
||||||
|
setText("detailOpen", row.open_times === null || row.open_times === undefined || row.open_times === "" ? "--" : `${number(row.open_times)} 次`);
|
||||||
|
setText("detailTurnover", presentMetric(row.turnover_rate) ? `${formatNumber(row.turnover_rate, 2)}%` : "--");
|
||||||
|
setText("detailAmount", presentMetric(row.amount_billion) ? `${formatNumber(row.amount_billion, 2)} 亿` : "--");
|
||||||
|
setText("detailSeal", presentMetric(row.seal_amount_million) ? `${formatNumber(row.seal_amount_million, 0)} 万` : "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentMetric(value) {
|
||||||
|
return meaningfulNumber(value) && Number(value) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openActiveStockInHeaven() {
|
function openActiveStockInHeaven() {
|
||||||
const code = state.activeStock?.code;
|
const code = state.activeStock?.code;
|
||||||
if (!/^\d{6}$/.test(String(code || ""))) return;
|
if (!/^\d{6}$/.test(String(code || ""))) return;
|
||||||
|
|||||||
Vendored
+10
-1
@@ -100,7 +100,12 @@ body[data-active-view="mentorView"] .app-page-context span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#mentorView .mentor-page-header {
|
#mentorView .mentor-page-header {
|
||||||
display: none;
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mentorView .mentor-page-title {
|
#mentorView .mentor-page-title {
|
||||||
@@ -1360,6 +1365,10 @@ body[data-active-view="mentorView"] .app-page-context span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
#mentorView.workspace-view {
|
||||||
|
padding: calc(var(--page-pad-y) + 40px) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
#mentorView .mentor-layout {
|
#mentorView .mentor-layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2065,7 +2065,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar, .admin-section-picker) {
|
:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
|
|||||||
@@ -408,8 +408,18 @@ async function saveReasonOverride(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMoneyflow(flow) {
|
function renderMoneyflow(flow) {
|
||||||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
const payload = flow || {};
|
||||||
|
const available = payload.available !== false && [
|
||||||
|
payload.net_million, payload.large_million, payload.medium_million, payload.small_million,
|
||||||
|
].some((value) => value !== null && value !== undefined && value !== "");
|
||||||
|
for (const [id, value] of [["flowNet", payload.net_million], ["flowLarge", payload.large_million], ["flowMedium", payload.medium_million], ["flowSmall", payload.small_million]]) {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
|
if (!element) continue;
|
||||||
|
if (!available || value === null || value === undefined || value === "") {
|
||||||
|
element.textContent = "--";
|
||||||
|
element.className = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
element.textContent = formatMoneyMillion(value);
|
element.textContent = formatMoneyMillion(value);
|
||||||
element.className = changeClass(value);
|
element.className = changeClass(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-79
@@ -28,14 +28,6 @@
|
|||||||
background: var(--selected);
|
background: var(--selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotation-sector-chip.selected {
|
|
||||||
border-color: var(--action);
|
|
||||||
|
|
||||||
background: var(--action-soft);
|
|
||||||
|
|
||||||
color: var(--action-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotation-sector-chip > span {
|
.rotation-sector-chip > span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
@@ -158,8 +150,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
||||||
background: color-mix(in srgb, var(--accent) calc(4% + var(--rotation-heat) * 46%), transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.redesigned-rotation-view {
|
.redesigned-rotation-view {
|
||||||
@@ -397,8 +387,12 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
row-gap: 4px;
|
||||||
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -434,18 +428,32 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.strong {
|
#rotationView .rotation-swatch.tier-1 {
|
||||||
background: var(--accent);
|
background: var(--rotation-tier1-bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.warm {
|
#rotationView .rotation-swatch.tier-2 {
|
||||||
background: var(--accent-soft);
|
background: var(--rotation-tier2-bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.mild {
|
#rotationView .rotation-swatch.tier-3 {
|
||||||
background: var(--surface-muted);
|
background: var(--rotation-tier3-bar);
|
||||||
|
}
|
||||||
|
|
||||||
border: 1px solid var(--border);
|
#rotationView .rotation-swatch.tier-4 {
|
||||||
|
background: var(--rotation-tier4-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-swatch.tier-5 {
|
||||||
|
background: var(--rotation-tier5-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-legend-note {
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
color: var(--r2-faint);
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-tracker {
|
#rotationView .rotation-tracker {
|
||||||
@@ -818,10 +826,59 @@
|
|||||||
|
|
||||||
#rotationView .rotation-history.tracking .rotation-sector-chip.selected {
|
#rotationView .rotation-history.tracking .rotation-sector-chip.selected {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
border-color: var(--r2-blue);
|
/* 追踪态:命中被追踪板块名、但不是当次点击的那一格 —— 保持档位底色,2px 同档位描边(含 80% 不透明度),
|
||||||
|
连点标记不做动画,仅描边淡入 200ms */
|
||||||
|
#rotationView .rotation-sector-chip.selected:not(.current-cell) {
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px var(--accent-soft);
|
border-color: color-mix(in srgb, var(--tier-bar) 80%, transparent);
|
||||||
|
|
||||||
|
transition: border-color 200ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态:仅当次点击的日期+板块这一格 —— 同档位实色渐变 + 2px 亮描边 + 克制光晕,全页最多一张 */
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell {
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
|
border-color: var(--tier-text);
|
||||||
|
|
||||||
|
background: linear-gradient(135deg, var(--tier-fill-start), var(--tier-fill-end));
|
||||||
|
|
||||||
|
box-shadow: 0 0 16px color-mix(in srgb, var(--tier-bar) var(--rotation-tier-glow-alpha), transparent);
|
||||||
|
|
||||||
|
color: var(--text-inverse);
|
||||||
|
|
||||||
|
transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell strong,
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell small,
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell small b {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-track-dot {
|
||||||
|
width: 6px;
|
||||||
|
|
||||||
|
height: 6px;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 6px;
|
||||||
|
|
||||||
|
right: 6px;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: var(--tier-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotation-cell-tooltip {
|
.rotation-cell-tooltip {
|
||||||
@@ -1050,40 +1107,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-mild {
|
/* HEL-524: 五档强度色 —— 同一强度值在 9 个日期列中颜色完全一致,仅代表强度档位,与涨跌无关。
|
||||||
border-color: var(--border);
|
普通卡只用低透明着色,页面/主容器/日期分栏的层级由 HEL-522 的明度骨架承担,这里不重复叠加。 */
|
||||||
|
#rotationView .rotation-sector-chip.heat-tier-1 {
|
||||||
background: var(--surface-muted);
|
--tier-bar: var(--rotation-tier1-bar);
|
||||||
|
--tier-bg: var(--rotation-tier1-bg);
|
||||||
box-shadow: none;
|
--tier-border: var(--rotation-tier1-border);
|
||||||
|
--tier-text: var(--rotation-tier1-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier1-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier1-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-strong {
|
#rotationView .rotation-sector-chip.heat-tier-2 {
|
||||||
border-color: var(--action-line);
|
--tier-bar: var(--rotation-tier2-bar);
|
||||||
|
--tier-bg: var(--rotation-tier2-bg);
|
||||||
background: var(--accent-soft);
|
--tier-border: var(--rotation-tier2-border);
|
||||||
|
--tier-text: var(--rotation-tier2-text);
|
||||||
box-shadow: none;
|
--tier-fill-start: var(--rotation-tier2-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier2-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-warm {
|
#rotationView .rotation-sector-chip.heat-tier-3 {
|
||||||
border-color: var(--action-line);
|
--tier-bar: var(--rotation-tier3-bar);
|
||||||
|
--tier-bg: var(--rotation-tier3-bg);
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
--tier-border: var(--rotation-tier3-border);
|
||||||
|
--tier-text: var(--rotation-tier3-text);
|
||||||
box-shadow: none;
|
--tier-fill-start: var(--rotation-tier3-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier3-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip:hover {
|
#rotationView .rotation-sector-chip.heat-tier-4 {
|
||||||
|
--tier-bar: var(--rotation-tier4-bar);
|
||||||
|
--tier-bg: var(--rotation-tier4-bg);
|
||||||
|
--tier-border: var(--rotation-tier4-border);
|
||||||
|
--tier-text: var(--rotation-tier4-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier4-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier4-fill-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip.heat-tier-5 {
|
||||||
|
--tier-bar: var(--rotation-tier5-bar);
|
||||||
|
--tier-bg: var(--rotation-tier5-bg);
|
||||||
|
--tier-border: var(--rotation-tier5-border);
|
||||||
|
--tier-text: var(--rotation-tier5-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier5-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier5-fill-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"] {
|
||||||
|
border-color: var(--tier-border);
|
||||||
|
|
||||||
|
background: var(--tier-bg);
|
||||||
|
|
||||||
|
box-shadow: inset 4px 0 0 0 var(--tier-bar);
|
||||||
|
|
||||||
|
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms var(--ease-out), border-color 120ms var(--ease-out), box-shadow 120ms var(--ease-out), filter 240ms, opacity 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"] .rotation-strength {
|
||||||
|
color: var(--tier-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停态:卡底与描边 alpha 适度提升,120ms ease-out,可打断,不打乱档位色含义;
|
||||||
|
已经处于选中/追踪态的卡片保持其自身样式,不叠加普通悬停处理 */
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"]:hover:not(.selected) {
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
|
|
||||||
border-color: var(--accent);
|
background: color-mix(in srgb, var(--tier-bar) 8%, var(--tier-bg) 92%);
|
||||||
|
|
||||||
|
border-color: color-mix(in srgb, var(--tier-bar) 26%, var(--tier-border) 74%);
|
||||||
|
|
||||||
filter: saturate(1.06);
|
filter: saturate(1.06);
|
||||||
|
|
||||||
transform: translate3d(2px, -2px, 0px) scale(1.015);
|
transform: translate3d(2px, -2px, 0px) scale(1.015);
|
||||||
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-table {
|
#rotationView .rotation-table {
|
||||||
@@ -1207,30 +1303,6 @@
|
|||||||
box-shadow: var(--control-shadow);
|
box-shadow: var(--control-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-strong {
|
|
||||||
border-color: var(--blue-line);
|
|
||||||
|
|
||||||
background: var(--heat-strong-bg);
|
|
||||||
|
|
||||||
color: var(--heat-strong-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-warm {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
|
|
||||||
background: var(--heat-warm-bg);
|
|
||||||
|
|
||||||
color: var(--heat-warm-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-mild {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
background: var(--heat-mild-bg);
|
|
||||||
|
|
||||||
color: var(--heat-mild-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-rank, .rotation-table thead th, .trend-flat) {
|
:root[data-theme="dark"] #rotationView :is(.rotation-rank, .rotation-table thead th, .trend-flat) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
@@ -1243,20 +1315,6 @@
|
|||||||
background: var(--action-soft);
|
background: var(--action-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-swatch.warm) {
|
|
||||||
border-color: var(--blue-line);
|
|
||||||
|
|
||||||
background: var(--action-soft);
|
|
||||||
|
|
||||||
color: var(--action);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-swatch.mild) {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
#rotationView :is(.rotation-table-frame, .rotation-detail-card) {
|
#rotationView :is(.rotation-table-frame, .rotation-detail-card) {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
<span class="rotation-top-tag">每日 Top 12 热点</span>
|
<span class="rotation-top-tag">每日 Top 12 热点</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="rotation-legend" aria-label="板块强度图例">
|
<div class="rotation-legend" aria-label="板块强度图例">
|
||||||
<span><i class="rotation-swatch strong"></i>强度高(90+)</span>
|
<span><i class="rotation-swatch tier-1"></i>低·青(<65)</span>
|
||||||
<span><i class="rotation-swatch warm"></i>强度中(70–89)</span>
|
<span><i class="rotation-swatch tier-2"></i>中低·蓝(65–74)</span>
|
||||||
<span><i class="rotation-swatch mild"></i>强度低(<70)</span>
|
<span><i class="rotation-swatch tier-3"></i>中高·紫(75–84)</span>
|
||||||
|
<span><i class="rotation-swatch tier-4"></i>高·橙(85–94)</span>
|
||||||
|
<span><i class="rotation-swatch tier-5"></i>最高·红(95+)</span>
|
||||||
|
<span class="rotation-legend-note">颜色=强度档位,与涨跌无关</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="rotationTracker" class="rotation-tracker" hidden></div>
|
<div id="rotationTracker" class="rotation-tracker" hidden></div>
|
||||||
<div id="rotationHistory" class="rotation-history"><div class="empty-state">正在读取轮动历史</div></div>
|
<div id="rotationHistory" class="rotation-history"><div class="empty-state">正在读取轮动历史</div></div>
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
|||||||
enter: ["loadRotation"],
|
enter: ["loadRotation"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HEL-524: 五档强度色固定映射,所有日期列共用同一套阈值 —— <65 青,65-74 蓝,75-84 紫,85-94 橙,95+ 红。
|
||||||
|
function rotationHeatTier(strengthValue) {
|
||||||
|
const value = clamp(number(strengthValue), 0, 100);
|
||||||
|
if (value >= 95) return 5;
|
||||||
|
if (value >= 85) return 4;
|
||||||
|
if (value >= 75) return 3;
|
||||||
|
if (value >= 65) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRotationHistory(force = false) {
|
async function loadRotationHistory(force = false) {
|
||||||
if (!state.dashboard || state.rotationLoading) return;
|
if (!state.dashboard || state.rotationLoading) return;
|
||||||
const key = `${elements.tradeDate.value}:9`;
|
const key = `${elements.tradeDate.value}:9`;
|
||||||
@@ -87,10 +97,14 @@ function renderRotationHistory() {
|
|||||||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||||||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||||||
const strength = clamp(number(sector.strength), 0, 100);
|
const strength = clamp(number(sector.strength), 0, 100);
|
||||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
const heatClass = `heat-tier-${rotationHeatTier(strength)}`;
|
||||||
|
const isTrackedName = Boolean(selected) && selected === sector.name;
|
||||||
|
const isCurrentCell = isTrackedName && day.trade_date === state.rotationSelectedDate;
|
||||||
|
const stateClass = isCurrentCell ? "selected current-cell" : isTrackedName ? "selected" : "";
|
||||||
return `
|
return `
|
||||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
<button type="button" class="rotation-sector-chip ${heatClass} ${stateClass}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · <b class="rotation-strength">${formatNumber(sector.strength, 0)}</b></small>
|
||||||
|
${isTrackedName && !isCurrentCell ? '<span class="rotation-track-dot" aria-hidden="true"></span>' : ""}
|
||||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join("")}</div>
|
}).join("")}</div>
|
||||||
|
|||||||
+17
-11
@@ -2287,8 +2287,6 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(#screenerView) .curated-library-pane {
|
:where(#screenerView) .curated-library-pane {
|
||||||
@@ -2546,7 +2544,7 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
gap: 2px;
|
gap: 8px;
|
||||||
|
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
|
|
||||||
@@ -2566,11 +2564,11 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
|
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
||||||
border: 0px;
|
border: 1px solid var(--control-border);
|
||||||
|
|
||||||
border-radius: 0;
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
background: transparent;
|
background: var(--control-surface);
|
||||||
|
|
||||||
color: var(--r2-sub);
|
color: var(--r2-sub);
|
||||||
|
|
||||||
@@ -2586,15 +2584,19 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .screener-mode-tabs button:hover {
|
#screenerView .screener-mode-tabs button:hover {
|
||||||
background: var(--surface-hover);
|
border-color: var(--r2-blue-line);
|
||||||
|
|
||||||
|
background: var(--control-hover);
|
||||||
|
|
||||||
color: var(--r2-ink);
|
color: var(--r2-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .screener-mode-tabs button.active {
|
#screenerView .screener-mode-tabs button.active {
|
||||||
background: transparent;
|
border-color: var(--scr-blue);
|
||||||
|
|
||||||
color: var(--scr-blue);
|
background: var(--scr-blue);
|
||||||
|
|
||||||
|
color: var(--on-action);
|
||||||
|
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
@@ -3135,7 +3137,11 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-screener-panel {
|
#screenerView .curated-screener-panel {
|
||||||
display: block;
|
display: flex;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-workspace {
|
#screenerView .curated-workspace {
|
||||||
@@ -6184,7 +6190,7 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #screenerView :is(.screener-mode-tabs, .screener-stepper, .regime-selector, .regime-option, .screener-results-view, .result-toolbar) {
|
:root[data-theme="dark"] #screenerView :is(.screener-stepper, .regime-selector, .regime-option, .screener-results-view, .result-toolbar) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
+2
-2
@@ -1596,7 +1596,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.sentiment-cycle-toolbar, .tbl-tools) {
|
:root[data-theme="dark"] :is(.tbl-tools) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -1610,7 +1610,7 @@
|
|||||||
color: var(--warning-color);
|
color: var(--warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #sentimentCycleView :is(.sentiment-current-tag, .sentiment-auto-tag, .sentiment-detail-toolbar, .section-toolbar) {
|
:root[data-theme="dark"] #sentimentCycleView :is(.sentiment-current-tag, .sentiment-auto-tag, .sentiment-detail-toolbar) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
|
|||||||
+26
-187
@@ -39,142 +39,43 @@ async function openAdminSettings(refreshOnly = false) {
|
|||||||
const payload = await apiRequest("/api/admin/settings");
|
const payload = await apiRequest("/api/admin/settings");
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
const ifind = data.ifind || {};
|
const ifind = data.ifind || {};
|
||||||
const llm = payload.llm || {};
|
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
||||||
const membership = payload.membership || {};
|
|
||||||
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
|
||||||
status.classList.toggle("connected", Boolean(data.configured));
|
status.classList.toggle("connected", Boolean(data.configured));
|
||||||
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
||||||
document.querySelector("#systemTokenInput").value = "";
|
renderDatahubRouteStatus(data.datahub || {});
|
||||||
document.querySelector("#systemIfindTokenInput").value = "";
|
|
||||||
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
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) {
|
} catch (error) {
|
||||||
status.textContent = error.message || "系统配置读取失败";
|
status.textContent = error.message || "系统配置读取失败";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAdminPanel(panel) {
|
function dataHubConsoleUrl() {
|
||||||
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
|
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
|
||||||
document.querySelector("#adminSectionSelect").value = selected;
|
return `${window.location.protocol}//${window.location.hostname}:8766/admin/`;
|
||||||
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
|
|
||||||
item.hidden = item.dataset.adminPanel !== selected;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderModelPool(models, primaryId = "", fallbackId = "") {
|
function openDataHubConsole() {
|
||||||
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
|
if (state.user?.role !== "admin") return;
|
||||||
const container = document.querySelector("#modelPoolList");
|
window.open(dataHubConsoleUrl(), "_blank", "noopener");
|
||||||
container.innerHTML = state.adminModels.map((item, index) => `
|
|
||||||
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
|
|
||||||
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
|
|
||||||
<div class="model-pool-fields">
|
|
||||||
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
|
|
||||||
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
|
|
||||||
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
|
|
||||||
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
|
|
||||||
</div>
|
|
||||||
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
|
|
||||||
</article>
|
|
||||||
`).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() {
|
function renderDatahubRouteStatus(hub) {
|
||||||
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
|
const box = document.querySelector("#datahubRouteStatus");
|
||||||
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
|
if (!box) return;
|
||||||
id: row.dataset.modelId,
|
const label = box.querySelector("span");
|
||||||
name: row.querySelector("[data-model-field='name']").value.trim(),
|
const enabled = number(hub.enabled_reads);
|
||||||
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
|
const total = number(hub.total_reads) || enabled;
|
||||||
model: row.querySelector("[data-model-field='model']").value.trim(),
|
const fallbacks = hub.fallback_labels || [];
|
||||||
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
|
if (fallbacks.length) {
|
||||||
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
|
box.dataset.tone = "warning";
|
||||||
}));
|
if (label) label.textContent = `数据中枢主线路 ${enabled}/${total} · 备用 ${fallbacks.length} 类:${fallbacks.join("、")}`;
|
||||||
}
|
|
||||||
|
|
||||||
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
|
|
||||||
const models = collectModelPool();
|
|
||||||
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
|
|
||||||
const primary = document.querySelector("#platformPrimaryModelSelect");
|
|
||||||
const fallback = document.querySelector("#platformFallbackModelSelect");
|
|
||||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
|
||||||
fallback.innerHTML = `<option value="">不启用辅助模型</option>${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;
|
return;
|
||||||
}
|
}
|
||||||
const models = collectModelPool().filter((item) => item.id !== id);
|
box.dataset.tone = hub.configured ? "success" : "idle";
|
||||||
renderModelPool(models, primary, fallback);
|
if (label) {
|
||||||
}
|
label.textContent = hub.configured
|
||||||
|
? `数据中枢主线路 ${enabled}/${total},当前无备用`
|
||||||
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 `<article class="admin-user-row" data-admin-user="${number(user.id)}">
|
|
||||||
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
|
|
||||||
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
|
|
||||||
<form class="membership-form">
|
|
||||||
<input type="hidden" name="user_id" value="${number(user.id)}">
|
|
||||||
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
|
|
||||||
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
|
|
||||||
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
|
|
||||||
<button class="button" type="submit">应用</button>
|
|
||||||
</form>
|
|
||||||
</article>`;
|
|
||||||
}).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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,13 +85,9 @@ async function saveMarketSettings(event) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
await apiRequest("/api/admin/settings", "POST", {
|
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,
|
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
||||||
});
|
});
|
||||||
document.querySelector("#systemTokenInput").value = "";
|
showToast("行情刷新设置已保存");
|
||||||
document.querySelector("#systemIfindTokenInput").value = "";
|
|
||||||
showToast("行情配置已保存");
|
|
||||||
await openAdminSettings(true);
|
await openAdminSettings(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || "系统配置保存失败");
|
showToast(error.message || "系统配置保存失败");
|
||||||
@@ -199,70 +96,12 @@ async function saveMarketSettings(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 bindAdminEvents() {
|
function bindAdminEvents() {
|
||||||
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
|
document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole);
|
||||||
|
document.querySelector("#marketAdminButton").addEventListener("click", () => openAdminSettings());
|
||||||
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
||||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||||||
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
|
|
||||||
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
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);
|
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, requestOptions(method, body, options.signal));
|
response = await fetch(url, requestOptions(method, body, options.signal));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
throw new ApiError("请求已取消或超时", 0, { aborted: true });
|
||||||
|
}
|
||||||
throw new ApiError(readableRequestError(error), 0, null);
|
throw new ApiError(readableRequestError(error), 0, null);
|
||||||
}
|
}
|
||||||
const payload = await parseJson(response);
|
const payload = await parseJson(response);
|
||||||
|
|||||||
@@ -125,50 +125,6 @@
|
|||||||
width: min(1060px, -24px + 100vw);
|
width: min(1060px, -24px + 100vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-picker {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 96px minmax(220px, 360px);
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker select {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-height: 32px;
|
|
||||||
|
|
||||||
padding: 0px 11px;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface);
|
|
||||||
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
font-size: var(--font-size-label);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker select:focus-visible {
|
|
||||||
outline: 2px solid var(--action);
|
|
||||||
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel[hidden] {
|
.admin-panel[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -220,161 +176,6 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-number-field {
|
|
||||||
width: min(260px, 100%);
|
|
||||||
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-users-list {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-row {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 160px 100px minmax(0px, 1fr);
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
padding: 13px 0px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-identity strong {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-identity small,
|
|
||||||
.admin-user-identity span {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-usage {
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
padding: 0px;
|
|
||||||
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 100px minmax(170px, 0.8fr) minmax(160px, 1fr) auto;
|
|
||||||
|
|
||||||
align-items: end;
|
|
||||||
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form label {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form label span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form input,
|
|
||||||
.membership-form select {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
padding: 0px 8px;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
gap: 5px;
|
|
||||||
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry strong {
|
|
||||||
min-height: 34px;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.admin-user-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-row .membership-form {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form .button {
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
grid-template-columns: minmax(0px, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status {
|
.connection-status {
|
||||||
margin: 18px 18px 0px;
|
margin: 18px 18px 0px;
|
||||||
|
|
||||||
@@ -399,7 +200,6 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .membership-form,
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form,
|
||||||
.settings-dialog.admin-dialog > form {
|
.settings-dialog.admin-dialog > form {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
@@ -559,7 +359,6 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .membership-form,
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form,
|
||||||
.settings-dialog.admin-dialog > form {
|
.settings-dialog.admin-dialog > form {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
@@ -1173,12 +972,6 @@ button.account-role-badge:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.admin-section-picker {
|
|
||||||
grid-template-columns: minmax(0px, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.settings-dialog .form-field {
|
.settings-dialog .form-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1520,32 +1313,6 @@ button.account-role-badge:focus-visible {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker {
|
|
||||||
grid-template-columns: auto minmax(220px, 300px);
|
|
||||||
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
padding: 12px 20px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--dialog-line);
|
|
||||||
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker label {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker select {
|
|
||||||
min-height: 32px;
|
|
||||||
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
font-size: var(--font-size-label);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-panel {
|
.admin-dialog .admin-panel {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
|
|
||||||
@@ -1556,24 +1323,6 @@ button.account-role-badge:focus-visible {
|
|||||||
border-top: 1px solid var(--dialog-line);
|
border-top: 1px solid var(--dialog-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-users-list {
|
|
||||||
border-color: var(--dialog-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row {
|
|
||||||
grid-template-columns: 150px 90px minmax(0px, 1fr);
|
|
||||||
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
padding: 12px 0px;
|
|
||||||
|
|
||||||
border-color: var(--dialog-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .membership-form {
|
|
||||||
grid-template-columns: 92px minmax(135px, 0.8fr) minmax(140px, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.account-settings-dialog .membership-status-grid {
|
.account-settings-dialog .membership-status-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
||||||
@@ -1582,22 +1331,6 @@ button.account-role-badge:focus-visible {
|
|||||||
.account-settings-dialog .account-birth-form {
|
.account-settings-dialog .account-birth-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row .membership-form {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
|
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
@media (max-width: 460px) {
|
||||||
@@ -1612,10 +1345,6 @@ button.account-role-badge:focus-visible {
|
|||||||
.account-settings-dialog .membership-comparison > div {
|
.account-settings-dialog .membership-comparison > div {
|
||||||
min-width: 510px;
|
min-width: 510px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-user-row .membership-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) {
|
:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) {
|
||||||
|
|||||||
@@ -1028,64 +1028,6 @@
|
|||||||
vertical-align: -1px;
|
vertical-align: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model {
|
|
||||||
padding: 12px 14px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--line-soft);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model:hover {
|
|
||||||
background: var(--table-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 .nm {
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 .lv {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r2 {
|
|
||||||
font-size: 11.5px;
|
|
||||||
|
|
||||||
color: var(--sub);
|
|
||||||
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r3 {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
margin-top: 6px;
|
|
||||||
|
|
||||||
font-size: 10.5px;
|
|
||||||
|
|
||||||
color: var(--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r3 .q {
|
|
||||||
border-bottom: 1px dashed var(--faint);
|
|
||||||
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) {
|
:root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
|
|||||||
@@ -29,22 +29,6 @@
|
|||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .model-role-selectors {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .model-pool-list {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .model-row {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes overlay-enter {
|
@keyframes overlay-enter {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -66,11 +66,18 @@ async function startAdminRefresh() {
|
|||||||
const requestedCompact = requestedDate.replaceAll("-", "");
|
const requestedCompact = requestedDate.replaceAll("-", "");
|
||||||
const actualCompact = actualDate.replaceAll("-", "");
|
const actualCompact = actualDate.replaceAll("-", "");
|
||||||
const updated = formatTimestamp(meta.updated_at);
|
const updated = formatTimestamp(meta.updated_at);
|
||||||
if (actualCompact !== requestedCompact || meta.carried_forward) {
|
const freshness = dashboardFreshnessMessage(meta);
|
||||||
const reason = meta.notice ? `;${meta.notice}` : "";
|
if (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
|
||||||
setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert");
|
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的盘中行情,更新时间 ${updated}`, "circle-check");
|
||||||
showToast("刷新完成,但未获取到所选日期的最新行情");
|
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
|
||||||
} else if (meta.notice) {
|
return;
|
||||||
|
}
|
||||||
|
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
|
||||||
|
setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert");
|
||||||
|
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (meta.notice) {
|
||||||
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
|
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
|
||||||
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
||||||
} else {
|
} else {
|
||||||
@@ -105,6 +112,37 @@ async function waitForAdminRefresh(jobKey) {
|
|||||||
throw new Error("刷新等待超时,请稍后重试");
|
throw new Error("刷新等待超时,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dashboardCatchupTimer = 0;
|
||||||
|
|
||||||
|
function chineseMonthDay(value) {
|
||||||
|
const compact = String(value || "").replaceAll("-", "").replaceAll("/", "");
|
||||||
|
if (!/^\d{8}/.test(compact)) return "";
|
||||||
|
return `${Number(compact.slice(4, 6))} 月 ${Number(compact.slice(6, 8))} 日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardFreshnessMessage(meta = {}) {
|
||||||
|
if (meta.display_notice) return String(meta.display_notice);
|
||||||
|
const requested = String(meta.requested_date || "").replaceAll("-", "");
|
||||||
|
const actual = String(meta.trade_date || "").replaceAll("-", "");
|
||||||
|
const shown = chineseMonthDay(actual);
|
||||||
|
if (meta.data_status === "preparing" || (meta.carried_forward && actual && requested && actual !== requested)) {
|
||||||
|
return shown ? `今日数据正在准备,当前展示 ${shown}` : "今日数据正在准备,当前展示最近可用数据";
|
||||||
|
}
|
||||||
|
if (meta.data_status === "partial" || meta.limit_data_source === "derived") {
|
||||||
|
return meta.notice || "部分正式数据尚未到齐,当前展示日线推算结果";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDashboardCatchup(meta = {}) {
|
||||||
|
window.clearTimeout(dashboardCatchupTimer);
|
||||||
|
const status = String(meta.data_status || "");
|
||||||
|
if (status !== "preparing" && status !== "partial") return;
|
||||||
|
dashboardCatchupTimer = window.setTimeout(() => {
|
||||||
|
loadDashboard(false, true, false);
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
function applyDashboard(payload, background = false) {
|
function applyDashboard(payload, background = false) {
|
||||||
state.dashboard = payload;
|
state.dashboard = payload;
|
||||||
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
|
||||||
@@ -112,7 +150,11 @@ function applyDashboard(payload, background = false) {
|
|||||||
document.querySelector("#qiObservationDate").value = selectedDate;
|
document.querySelector("#qiObservationDate").value = selectedDate;
|
||||||
document.querySelector("#journalDate").value = selectedDate;
|
document.querySelector("#journalDate").value = selectedDate;
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`);
|
const freshness = dashboardFreshnessMessage(payload.meta || {});
|
||||||
|
setStatus(freshness || `${dashboardSourceLabel(payload.meta)} · 数据已更新`);
|
||||||
|
const updatedAt = document.querySelector("#updatedAt");
|
||||||
|
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
|
||||||
|
scheduleDashboardCatchup(payload.meta || {});
|
||||||
if (!background) {
|
if (!background) {
|
||||||
if (state.activeView === "dragonView") loadDragonTiger();
|
if (state.activeView === "dragonView") loadDragonTiger();
|
||||||
if (state.activeView === "screenerView") loadScreenerSetup();
|
if (state.activeView === "screenerView") loadScreenerSetup();
|
||||||
@@ -180,7 +222,12 @@ function renderDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateSentimentGauge(overview.sentiment_score);
|
updateSentimentGauge(overview.sentiment_score);
|
||||||
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
|
const freshness = dashboardFreshnessMessage(meta);
|
||||||
|
setText("updatedAt", freshness
|
||||||
|
? freshness
|
||||||
|
: `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
|
||||||
|
const updatedAt = document.querySelector("#updatedAt");
|
||||||
|
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
|
||||||
|
|
||||||
renderLimitTable();
|
renderLimitTable();
|
||||||
renderLadderMini(ladders || []);
|
renderLadderMini(ladders || []);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function selectAuthMode(mode) {
|
|||||||
const registering = state.authMode === "register";
|
const registering = state.authMode === "register";
|
||||||
document.querySelector("#authConfirmField").hidden = !registering;
|
document.querySelector("#authConfirmField").hidden = !registering;
|
||||||
document.querySelector("#authPasswordConfirm").required = registering;
|
document.querySelector("#authPasswordConfirm").required = registering;
|
||||||
|
document.querySelector("#authInviteField").hidden = !registering;
|
||||||
|
document.querySelector("#authInviteCode").required = registering;
|
||||||
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
||||||
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
||||||
document.querySelector("#authError").hidden = true;
|
document.querySelector("#authError").hidden = true;
|
||||||
@@ -24,7 +26,11 @@ async function submitAuthForm(event) {
|
|||||||
const button = document.querySelector("#authSubmitButton");
|
const button = document.querySelector("#authSubmitButton");
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
|
const registering = state.authMode === "register";
|
||||||
|
const credentials = registering
|
||||||
|
? { username, password, invite_code: document.querySelector("#authInviteCode").value.trim() }
|
||||||
|
: { username, password };
|
||||||
|
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", credentials);
|
||||||
document.querySelector("#authForm").reset();
|
document.querySelector("#authForm").reset();
|
||||||
await applyAuthenticatedSession(session);
|
await applyAuthenticatedSession(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -42,6 +48,7 @@ async function applyAuthenticatedSession(session) {
|
|||||||
const isAdmin = session.user?.role === "admin";
|
const isAdmin = session.user?.role === "admin";
|
||||||
updateAccountIdentityBadges(session.user?.membership || {});
|
updateAccountIdentityBadges(session.user?.membership || {});
|
||||||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||||||
|
document.querySelector("#marketAdminButton").hidden = !isAdmin;
|
||||||
document.querySelector("#syncButton").hidden = !isAdmin;
|
document.querySelector("#syncButton").hidden = !isAdmin;
|
||||||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||||||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||||||
|
|||||||
@@ -921,6 +921,10 @@ body.sidebar-collapsed .app-main {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-bar #updatedAt[data-tone="warning"] {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.status-bar .risk-note {
|
.status-bar .risk-note {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@@ -3761,3 +3765,30 @@ body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip[dat
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HEL-524: 侧栏去掉作为整块独立色板的背景,融入页面底层;品牌区与顶栏保持同层;
|
||||||
|
收起侧栏整行不再单独着色。展开/收起态均生效。「问天」冻结路由维持原有外观。 */
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav,
|
||||||
|
body.sidebar-collapsed:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav {
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-brand {
|
||||||
|
background: var(--header-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-collapse-button {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav {
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-brand {
|
||||||
|
background-color: var(--header-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) :is(.nav-group, .sidebar-collapse-button) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,40 @@
|
|||||||
--r2-radius: var(--size-radius-lg);
|
--r2-radius: var(--size-radius-lg);
|
||||||
--r2-shadow: var(--elevation-card);
|
--r2-shadow: var(--elevation-card);
|
||||||
|
|
||||||
|
/* HEL-524: 板块轮动五档强度色(日间)。五档单调递进:青→蓝→紫→橙→红,冷到暖;
|
||||||
|
同一强度值在所有日期列使用完全相同的颜色,颜色仅代表强度档位,与涨跌无关。 */
|
||||||
|
--rotation-tier1-bar: #0D9488;
|
||||||
|
--rotation-tier1-bg: rgba(13, 148, 136, .08);
|
||||||
|
--rotation-tier1-border: rgba(13, 148, 136, .25);
|
||||||
|
--rotation-tier1-text: #0F766E;
|
||||||
|
--rotation-tier1-fill-start: #11766D;
|
||||||
|
--rotation-tier1-fill-end: #0C443F;
|
||||||
|
--rotation-tier2-bar: #2563EB;
|
||||||
|
--rotation-tier2-bg: rgba(37, 99, 235, .07);
|
||||||
|
--rotation-tier2-border: rgba(37, 99, 235, .22);
|
||||||
|
--rotation-tier2-text: #1D4ED8;
|
||||||
|
--rotation-tier2-fill-start: #2059D7;
|
||||||
|
--rotation-tier2-fill-end: #1D46A2;
|
||||||
|
--rotation-tier3-bar: #7C3AED;
|
||||||
|
--rotation-tier3-bg: rgba(124, 58, 237, .07);
|
||||||
|
--rotation-tier3-border: rgba(124, 58, 237, .22);
|
||||||
|
--rotation-tier3-text: #6D28D9;
|
||||||
|
--rotation-tier3-fill-start: #6F2DE0;
|
||||||
|
--rotation-tier3-fill-end: #5720B5;
|
||||||
|
--rotation-tier4-bar: #EA580C;
|
||||||
|
--rotation-tier4-bg: rgba(234, 88, 12, .08);
|
||||||
|
--rotation-tier4-border: rgba(234, 88, 12, .25);
|
||||||
|
--rotation-tier4-text: #C2410C;
|
||||||
|
--rotation-tier4-fill-start: #C85215;
|
||||||
|
--rotation-tier4-fill-end: #913E13;
|
||||||
|
--rotation-tier5-bar: #DC2626;
|
||||||
|
--rotation-tier5-bg: rgba(220, 38, 38, .08);
|
||||||
|
--rotation-tier5-border: rgba(220, 38, 38, .25);
|
||||||
|
--rotation-tier5-text: #B91C1C;
|
||||||
|
--rotation-tier5-fill-start: #BE2B2B;
|
||||||
|
--rotation-tier5-fill-end: #8C2424;
|
||||||
|
--rotation-tier-glow-alpha: 22%;
|
||||||
|
|
||||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -557,6 +591,40 @@
|
|||||||
--heat-warm-ink: #dfeaf7;
|
--heat-warm-ink: #dfeaf7;
|
||||||
--heat-mild-bg: #293440;
|
--heat-mild-bg: #293440;
|
||||||
--heat-mild-ink: #c7d2dc;
|
--heat-mild-ink: #c7d2dc;
|
||||||
|
|
||||||
|
/* HEL-524: 板块轮动五档强度色(夜间)。五档单调递进:青→蓝→紫→橙→红,冷到暖;
|
||||||
|
同一强度值在所有日期列使用完全相同的颜色,颜色仅代表强度档位,与涨跌无关。 */
|
||||||
|
--rotation-tier1-bar: #2DD4BF;
|
||||||
|
--rotation-tier1-bg: rgba(45, 212, 191, .09);
|
||||||
|
--rotation-tier1-border: rgba(45, 212, 191, .20);
|
||||||
|
--rotation-tier1-text: #5EEAD4;
|
||||||
|
--rotation-tier1-fill-start: #32B6A5;
|
||||||
|
--rotation-tier1-fill-end: #2A867A;
|
||||||
|
--rotation-tier2-bar: #60A5FA;
|
||||||
|
--rotation-tier2-bg: rgba(96, 165, 250, .10);
|
||||||
|
--rotation-tier2-border: rgba(96, 165, 250, .22);
|
||||||
|
--rotation-tier2-text: #93C5FD;
|
||||||
|
--rotation-tier2-fill-start: #5098F1;
|
||||||
|
--rotation-tier2-fill-end: #217AE7;
|
||||||
|
--rotation-tier3-bar: #A78BFA;
|
||||||
|
--rotation-tier3-bg: rgba(167, 139, 250, .11);
|
||||||
|
--rotation-tier3-border: rgba(167, 139, 250, .24);
|
||||||
|
--rotation-tier3-text: #C4B5FD;
|
||||||
|
--rotation-tier3-fill-start: #9879F2;
|
||||||
|
--rotation-tier3-fill-end: #724AE9;
|
||||||
|
--rotation-tier4-bar: #FB923C;
|
||||||
|
--rotation-tier4-bg: rgba(251, 146, 60, .12);
|
||||||
|
--rotation-tier4-border: rgba(251, 146, 60, .26);
|
||||||
|
--rotation-tier4-text: #FDBA74;
|
||||||
|
--rotation-tier4-fill-start: #F07C2D;
|
||||||
|
--rotation-tier4-fill-end: #D05A14;
|
||||||
|
--rotation-tier5-bar: #F87171;
|
||||||
|
--rotation-tier5-bg: rgba(248, 113, 113, .13);
|
||||||
|
--rotation-tier5-border: rgba(248, 113, 113, .28);
|
||||||
|
--rotation-tier5-text: #FCA5A5;
|
||||||
|
--rotation-tier5-fill-start: #EF6161;
|
||||||
|
--rotation-tier5-fill-end: #E53333;
|
||||||
|
--rotation-tier-glow-alpha: 35%;
|
||||||
--heaven-field-bg: #23241f;
|
--heaven-field-bg: #23241f;
|
||||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
|
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
|
||||||
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
|
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
|
||||||
|
|||||||
@@ -632,6 +632,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag
|
|||||||
await expect(page.locator("#authGate")).toBeHidden();
|
await expect(page.locator("#authGate")).toBeHidden();
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await page.locator("#alertButton").click();
|
await page.locator("#alertButton").click();
|
||||||
@@ -1260,9 +1261,9 @@ test("sector rotation transfers the nine-day matrix, tracking and sortable detai
|
|||||||
await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/);
|
await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/);
|
||||||
await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 =");
|
await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 =");
|
||||||
const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip");
|
const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip");
|
||||||
await expect(firstDayCells.nth(0)).toHaveClass(/heat-strong/);
|
await expect(firstDayCells.nth(0)).toHaveClass(/heat-tier-4/); // strength 92 → 85-94 档(高·橙)
|
||||||
await expect(firstDayCells.nth(1)).toHaveClass(/heat-warm/);
|
await expect(firstDayCells.nth(1)).toHaveClass(/heat-tier-3/); // strength 76 → 75-84 档(中高·紫)
|
||||||
await expect(firstDayCells.nth(2)).toHaveClass(/heat-mild/);
|
await expect(firstDayCells.nth(2)).toHaveClass(/heat-tier-1/); // strength 58 → <65 档(低·青)
|
||||||
const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => {
|
const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => {
|
||||||
const style = getComputedStyle(cell);
|
const style = getComputedStyle(cell);
|
||||||
return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration };
|
return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration };
|
||||||
@@ -1753,6 +1754,7 @@ test("regular account cannot see admin controls and member features are gated",
|
|||||||
await mockApplication(page, session("user", false));
|
await mockApplication(page, session("user", false));
|
||||||
await page.goto("/index.html");
|
await page.goto("/index.html");
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
@@ -3332,9 +3334,9 @@ test("global dialogs share the stage 18 geometry without changing account or adm
|
|||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
|
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
|
||||||
await expect(page.locator("#adminSectionSelect")).toBeVisible();
|
await expect(page.locator("#systemMarketForm")).toBeVisible();
|
||||||
const adminBox = await page.locator("#adminDialog").boundingBox();
|
const adminBox = await page.locator("#adminDialog").boundingBox();
|
||||||
expect(adminBox.width).toBeLessThanOrEqual(902);
|
expect(adminBox.width).toBeLessThanOrEqual(902);
|
||||||
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
||||||
@@ -3510,7 +3512,7 @@ test("B-199 screener review and account surfaces fit day night viewports", async
|
|||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
await expect(page.locator("#adminDialog")).toBeVisible();
|
||||||
await shot("admin-1600-day");
|
await shot("admin-1600-day");
|
||||||
await page.locator("#closeAdminDialog").click();
|
await page.locator("#closeAdminDialog").click();
|
||||||
@@ -3609,6 +3611,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountButton")).toBeVisible();
|
await expect(page.locator("#accountButton")).toBeVisible();
|
||||||
@@ -3621,7 +3624,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
||||||
await page.locator("#refreshButton").click();
|
await page.locator("#refreshButton").click();
|
||||||
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
await expect(page.locator("#adminDialog")).toBeVisible();
|
||||||
await page.locator("#closeAdminDialog").click();
|
await page.locator("#closeAdminDialog").click();
|
||||||
await page.locator("#accountButton").click();
|
await page.locator("#accountButton").click();
|
||||||
@@ -3668,6 +3671,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
||||||
|
|||||||
@@ -264,8 +264,7 @@ const SYSTEM_PAGES = [
|
|||||||
["system/profile", "账号资料"],
|
["system/profile", "账号资料"],
|
||||||
["system/password", "修改密码"],
|
["system/password", "修改密码"],
|
||||||
["system/membership", "会员状态"],
|
["system/membership", "会员状态"],
|
||||||
["system/admin", "系统设置"],
|
["system/admin", "行情管理"],
|
||||||
["system/members", "会员管理"],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||||
@@ -360,8 +359,7 @@ test("system management pages render real content instead of placeholders", asyn
|
|||||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
await navigateToFeature(page, "system/admin");
|
||||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
await expect(page.locator("#m-sys-token")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/members");
|
await expect(page.locator("[data-system-datahub]")).toBeVisible();
|
||||||
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
||||||
@@ -403,10 +401,7 @@ test("system settings tabs, model editor, delete confirm and theme toggle work",
|
|||||||
await page.locator("[data-theme-toggle]").click();
|
await page.locator("[data-theme-toggle]").click();
|
||||||
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
|
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
|
||||||
|
|
||||||
await navigateToFeature(page, "system/members");
|
await expect(page.locator("[data-system-datahub]").first()).toBeVisible();
|
||||||
await page.locator("[data-system-open-member]").click();
|
|
||||||
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
|
|
||||||
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
|
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
|
||||||
@@ -439,7 +434,7 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
|
|||||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||||
await expect(page.locator("[data-system-page='home']")).toBeVisible();
|
await expect(page.locator("[data-system-page='home']")).toBeVisible();
|
||||||
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
|
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
|
||||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
await navigateToFeature(page, "system/admin");
|
||||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ class AccountSwitchGrantTests(unittest.TestCase):
|
|||||||
self.bound_user_id = int(user_id)
|
self.bound_user_id = int(user_id)
|
||||||
|
|
||||||
def _register(self, username: str, device_hash: str = "") -> dict:
|
def _register(self, username: str, device_hash: str = "") -> dict:
|
||||||
return self.service.register(username, "Password123", device_hash or self.device_a)
|
invite = ""
|
||||||
|
if self.database.count_users():
|
||||||
|
invite = self.service.generate_invite_codes(1)[0]["code"]
|
||||||
|
return self.service.register(
|
||||||
|
username, "Password123", device_hash or self.device_a, invite
|
||||||
|
)
|
||||||
|
|
||||||
def test_login_records_accounts_for_the_current_device_only(self) -> None:
|
def test_login_records_accounts_for_the_current_device_only(self) -> None:
|
||||||
first = self._register("alpha_user")
|
first = self._register("alpha_user")
|
||||||
|
|||||||
@@ -1,23 +1,452 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import date, datetime, timedelta, timezone, time as dt_time
|
||||||
|
from unittest.mock import patch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.jobs.service import _verified_dashboard_result
|
from backend.features.market.service import MarketServiceMixin
|
||||||
|
from backend.jobs.refresh import (
|
||||||
|
dashboard_has_usable_data,
|
||||||
|
official_catchup_due,
|
||||||
|
verified_dashboard_result,
|
||||||
|
)
|
||||||
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
|
|
||||||
class AdminRefreshStatusTests(unittest.TestCase):
|
class AdminRefreshStatusTests(unittest.TestCase):
|
||||||
def test_carried_snapshot_is_reported_as_failed_job(self):
|
def test_carried_snapshot_is_usable_not_failed_job(self):
|
||||||
result = _verified_dashboard_result(
|
result = verified_dashboard_result(
|
||||||
{"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}}
|
{
|
||||||
|
"meta": {
|
||||||
|
"trade_date": "2026-09-01",
|
||||||
|
"requested_date": "2026-09-02",
|
||||||
|
"carried_forward": True,
|
||||||
|
"notice": "今日数据正在准备,当前展示 9 月 1 日",
|
||||||
|
"data_status": "preparing",
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 12},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["status"], "failed")
|
self.assertNotEqual(result.get("status"), "failed")
|
||||||
self.assertEqual(result["error"], "官方涨跌停数据尚未返回")
|
self.assertEqual(result["meta"]["data_status"], "preparing")
|
||||||
|
self.assertTrue(dashboard_has_usable_data(result))
|
||||||
|
|
||||||
|
def test_derived_limit_snapshot_is_usable_not_failed_job(self):
|
||||||
|
dashboard = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": "2026-09-02",
|
||||||
|
"limit_data_source": "derived",
|
||||||
|
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
|
||||||
|
"data_status": "partial",
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertIs(verified_dashboard_result(dashboard), dashboard)
|
||||||
|
|
||||||
def test_current_snapshot_is_reported_as_successful_job(self):
|
def test_current_snapshot_is_reported_as_successful_job(self):
|
||||||
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
|
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
|
||||||
|
|
||||||
self.assertIs(_verified_dashboard_result(dashboard), dashboard)
|
self.assertIs(verified_dashboard_result(dashboard), dashboard)
|
||||||
|
|
||||||
|
def test_empty_payload_is_still_failed(self):
|
||||||
|
result = verified_dashboard_result({"meta": {}, "overview": {}})
|
||||||
|
self.assertEqual(result["status"], "failed")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSyncDatabase:
|
||||||
|
def __init__(self, latest=None):
|
||||||
|
self.latest = latest
|
||||||
|
self.saved = []
|
||||||
|
self.finished = []
|
||||||
|
|
||||||
|
def start_sync(self, *_args, **_kwargs):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def save_snapshot(self, trade_date, source, payload):
|
||||||
|
self.saved.append((trade_date, source, copy.deepcopy(payload)))
|
||||||
|
|
||||||
|
def save_data_snapshot(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def finish_sync(self, *args, **kwargs):
|
||||||
|
self.finished.append((args, kwargs))
|
||||||
|
|
||||||
|
def get_latest_real_snapshot(self, *_args, **_kwargs):
|
||||||
|
return copy.deepcopy(self.latest)
|
||||||
|
|
||||||
|
def get_snapshot(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_data_snapshot(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reason_overrides(self, *_args, **_kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDerivedClient:
|
||||||
|
def dashboard(self, trade_date: str):
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||||
|
"limit_data_source": "derived",
|
||||||
|
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 3},
|
||||||
|
"limits": [{"code": "000001"}],
|
||||||
|
"broken": [],
|
||||||
|
"down_limits": [],
|
||||||
|
"yesterday_limits": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SHANGHAI = timezone(timedelta(hours=8))
|
||||||
|
TRADE_DAY = date(2026, 9, 8)
|
||||||
|
|
||||||
|
|
||||||
|
def at_clock(hour: int, minute: int, day: date = TRADE_DAY) -> datetime:
|
||||||
|
return datetime(day.year, day.month, day.day, hour, minute, tzinfo=SHANGHAI)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMissingDailyClient:
|
||||||
|
def __init__(self, open_today: bool = True):
|
||||||
|
self.open_today = open_today
|
||||||
|
|
||||||
|
def dashboard(self, trade_date: str):
|
||||||
|
raise TushareError(f"No daily data returned for {trade_date}")
|
||||||
|
|
||||||
|
def resolve_trade_context(self, requested: str):
|
||||||
|
if self.open_today:
|
||||||
|
return requested, "20260907"
|
||||||
|
return "20260907", "20260904"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRealtimeTodayClient:
|
||||||
|
def dashboard(self, trade_date: str):
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||||
|
"requested_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||||
|
"realtime": True,
|
||||||
|
"mode": "realtime",
|
||||||
|
"market_status": "trading",
|
||||||
|
"notice": "盘中行情由 Tushare rt_k 实时计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 15},
|
||||||
|
"limits": [{"code": "000001"}],
|
||||||
|
"broken": [],
|
||||||
|
"down_limits": [],
|
||||||
|
"yesterday_limits": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve_trade_context(self, requested: str):
|
||||||
|
return requested, "20260907"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFreeRealtimeTodayClient:
|
||||||
|
def dashboard(self, trade_date: str):
|
||||||
|
return {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||||
|
"requested_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
|
||||||
|
"realtime": True,
|
||||||
|
"mode": "realtime",
|
||||||
|
"quote_source": "eastmoney_clist",
|
||||||
|
"source": "eastmoney",
|
||||||
|
"market_status": "trading",
|
||||||
|
"notice": "盘中行情由东财免费实时快照计算;涨停原因、封板时间和开板次数以盘后榜单校正为准。",
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"indices": [{"code": "000001", "price": 3800.1, "change": 0.5}],
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 18, "up_count": 2100, "amount_billion": 12345.6},
|
||||||
|
"limits": [{"code": "000001"}],
|
||||||
|
"broken": [],
|
||||||
|
"down_limits": [],
|
||||||
|
"yesterday_limits": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve_trade_context(self, requested: str):
|
||||||
|
return requested, "20260907"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncHarness(MarketServiceMixin):
|
||||||
|
def __init__(self, client, latest=None, clock=None):
|
||||||
|
self.configured = True
|
||||||
|
self.sync_lock = threading.Lock()
|
||||||
|
self.database = FakeSyncDatabase(latest)
|
||||||
|
self._client = client
|
||||||
|
self.current_user_id = 1
|
||||||
|
self.clock = clock
|
||||||
|
|
||||||
|
def _tushare_client(self):
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def _enrich_dashboard_sentiment(self, dashboard, _trade_date):
|
||||||
|
return dashboard
|
||||||
|
|
||||||
|
def _apply_reason_overrides(self, dashboard):
|
||||||
|
return dashboard
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardFreshnessTests(unittest.TestCase):
|
||||||
|
def test_derived_limits_are_kept_as_partial_success(self):
|
||||||
|
today = date.today().strftime("%Y%m%d")
|
||||||
|
harness = SyncHarness(FakeDerivedClient())
|
||||||
|
payload = harness.sync_dashboard(today)
|
||||||
|
meta = payload["meta"]
|
||||||
|
|
||||||
|
self.assertEqual(meta["limit_data_source"], "derived")
|
||||||
|
self.assertEqual(meta["data_status"], "partial")
|
||||||
|
self.assertFalse(meta.get("carried_forward"))
|
||||||
|
self.assertIn("日线数据推算", meta["display_notice"])
|
||||||
|
self.assertEqual(harness.database.finished[0][0][1], "success")
|
||||||
|
self.assertEqual(verified_dashboard_result(payload), payload)
|
||||||
|
|
||||||
|
def test_intraday_refresh_keeps_today_and_does_not_fall_back_to_yesterday(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
latest = {
|
||||||
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
||||||
|
"overview": {"limit_up_count": 20},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeRealtimeTodayClient(),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
payload = harness.sync_dashboard(today)
|
||||||
|
meta = payload["meta"]
|
||||||
|
|
||||||
|
self.assertFalse(meta.get("carried_forward"))
|
||||||
|
self.assertTrue(meta["realtime"])
|
||||||
|
self.assertEqual(meta["data_status"], "intraday")
|
||||||
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
||||||
|
self.assertNotIn("今日数据正在准备", meta.get("display_notice") or "")
|
||||||
|
self.assertEqual(harness.database.saved[0][0], today)
|
||||||
|
|
||||||
|
def test_intraday_free_source_keeps_today_and_indices(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
latest = {
|
||||||
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
||||||
|
"overview": {"limit_up_count": 20},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeFreeRealtimeTodayClient(),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
payload = harness.sync_dashboard(today)
|
||||||
|
meta = payload["meta"]
|
||||||
|
self.assertFalse(meta.get("carried_forward"))
|
||||||
|
self.assertTrue(meta["realtime"])
|
||||||
|
self.assertEqual(meta["data_status"], "intraday")
|
||||||
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
||||||
|
self.assertEqual(meta["quote_source"], "eastmoney_clist")
|
||||||
|
self.assertEqual(payload["overview"]["amount_billion"], 12345.6)
|
||||||
|
self.assertEqual(meta["indices"][0]["price"], 3800.1)
|
||||||
|
self.assertEqual(harness.database.saved[0][0], today)
|
||||||
|
|
||||||
|
def test_intraday_missing_quotes_do_not_carry_yesterday(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
latest = {
|
||||||
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
||||||
|
"overview": {"limit_up_count": 20},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeMissingDailyClient(),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
harness.sync_dashboard(today)
|
||||||
|
self.assertIn("当天盘中行情", str(ctx.exception))
|
||||||
|
self.assertFalse(harness.database.saved)
|
||||||
|
|
||||||
|
def test_intraday_keeps_existing_today_snapshot_when_refresh_fails(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
existing = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": "2026-09-08",
|
||||||
|
"realtime": True,
|
||||||
|
"mode": "realtime",
|
||||||
|
"source": "tushare",
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 11},
|
||||||
|
"limits": [{"code": "600000"}],
|
||||||
|
"broken": [],
|
||||||
|
"down_limits": [],
|
||||||
|
"yesterday_limits": [],
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeMissingDailyClient(),
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(existing)
|
||||||
|
payload = harness.sync_dashboard(today)
|
||||||
|
meta = payload["meta"]
|
||||||
|
self.assertEqual(str(meta["trade_date"]).replace("-", ""), today)
|
||||||
|
self.assertTrue(meta["realtime"])
|
||||||
|
self.assertEqual(meta["data_status"], "intraday")
|
||||||
|
self.assertFalse(meta.get("carried_forward"))
|
||||||
|
|
||||||
|
def test_lunch_and_after_hours_keep_today_until_official_arrives(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
for clock in (lambda: at_clock(12, 0), lambda: at_clock(16, 10)):
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeRealtimeTodayClient(),
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
|
payload = harness.sync_dashboard(today)
|
||||||
|
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
|
||||||
|
self.assertFalse(payload["meta"].get("carried_forward"))
|
||||||
|
|
||||||
|
def test_preopen_and_weekend_still_carry_last_session(self):
|
||||||
|
latest = {
|
||||||
|
"meta": {"trade_date": "2026-09-07", "source": "tushare"},
|
||||||
|
"overview": {"limit_up_count": 20},
|
||||||
|
}
|
||||||
|
preopen = SyncHarness(
|
||||||
|
FakeMissingDailyClient(),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(8, 30),
|
||||||
|
)
|
||||||
|
preopen_payload = preopen.sync_dashboard(TRADE_DAY.strftime("%Y%m%d"))
|
||||||
|
self.assertTrue(preopen_payload["meta"]["carried_forward"])
|
||||||
|
self.assertEqual(preopen_payload["meta"]["data_status"], "preparing")
|
||||||
|
self.assertIn("今日数据正在准备,当前展示", preopen_payload["meta"]["display_notice"])
|
||||||
|
|
||||||
|
weekend = SyncHarness(
|
||||||
|
FakeMissingDailyClient(open_today=False),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(10, 5, date(2026, 9, 5)),
|
||||||
|
)
|
||||||
|
weekend_payload = weekend.sync_dashboard("20260905")
|
||||||
|
self.assertTrue(weekend_payload["meta"]["carried_forward"])
|
||||||
|
|
||||||
|
def test_history_date_still_uses_official_or_preparing_notice(self):
|
||||||
|
latest = {
|
||||||
|
"meta": {"trade_date": "2026-09-01", "source": "tushare"},
|
||||||
|
"overview": {"limit_up_count": 8},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeMissingDailyClient(),
|
||||||
|
latest,
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
payload = harness.sync_dashboard("20260902")
|
||||||
|
self.assertTrue(payload["meta"]["carried_forward"])
|
||||||
|
self.assertIn("所选日期数据尚未到齐", payload["meta"]["display_notice"])
|
||||||
|
|
||||||
|
def test_carried_today_snapshot_is_retried_immediately_in_session(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
snapshot = {
|
||||||
|
"meta": {
|
||||||
|
"source": "tushare",
|
||||||
|
"trade_date": "2026-09-07",
|
||||||
|
"carried_forward": True,
|
||||||
|
"requested_date": "2026-09-08",
|
||||||
|
"updated_at": at_clock(10, 0).isoformat(),
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 1},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(
|
||||||
|
FakeRealtimeTodayClient(),
|
||||||
|
clock=lambda: at_clock(10, 5),
|
||||||
|
)
|
||||||
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(snapshot)
|
||||||
|
payload = harness.get_dashboard(today)
|
||||||
|
self.assertEqual(str(payload["meta"]["trade_date"]).replace("-", ""), today)
|
||||||
|
self.assertEqual(payload["meta"]["data_status"], "intraday")
|
||||||
|
self.assertTrue(harness.database.saved)
|
||||||
|
|
||||||
|
def test_weekend_carry_is_not_labeled_as_preparing(self):
|
||||||
|
snapshot = {
|
||||||
|
"meta": {"trade_date": "2026-07-24", "source": "tushare", "updated_at": "2026-07-24T15:00:00+08:00"},
|
||||||
|
"overview": {"limit_up_count": 1},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(FakeMissingDailyClient())
|
||||||
|
carried = harness._carry_dashboard(snapshot, "20260725", "非交易日沿用最近交易日收盘行情")
|
||||||
|
self.assertEqual(carried["meta"]["data_status"], "carried")
|
||||||
|
self.assertIn("非交易日", carried["meta"]["display_notice"])
|
||||||
|
|
||||||
|
def test_stale_derived_snapshot_is_retried(self):
|
||||||
|
today = date.today().strftime("%Y%m%d")
|
||||||
|
old = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
snapshot = {
|
||||||
|
"meta": {
|
||||||
|
"source": "tushare",
|
||||||
|
"trade_date": f"{today[:4]}-{today[4:6]}-{today[6:8]}",
|
||||||
|
"limit_data_source": "derived",
|
||||||
|
"updated_at": old.isoformat(),
|
||||||
|
},
|
||||||
|
"overview": {"limit_up_count": 1},
|
||||||
|
}
|
||||||
|
harness = SyncHarness(FakeDerivedClient())
|
||||||
|
harness.database.get_snapshot = lambda *_args, **_kwargs: copy.deepcopy(snapshot)
|
||||||
|
payload = harness.get_dashboard(today)
|
||||||
|
self.assertEqual(payload["meta"]["data_status"], "partial")
|
||||||
|
self.assertTrue(harness.database.saved)
|
||||||
|
|
||||||
|
def test_official_catchup_skips_complete_today_snapshot(self):
|
||||||
|
today = date.today().strftime("%Y%m%d")
|
||||||
|
iso = f"{today[:4]}-{today[4:6]}-{today[6:8]}"
|
||||||
|
due = official_catchup_due(
|
||||||
|
today,
|
||||||
|
{"meta": {"trade_date": iso, "limit_data_source": "official"}},
|
||||||
|
)
|
||||||
|
derived_due = official_catchup_due(
|
||||||
|
today,
|
||||||
|
{"meta": {"trade_date": iso, "limit_data_source": "derived"}},
|
||||||
|
)
|
||||||
|
now = datetime.now().astimezone().time().replace(tzinfo=None)
|
||||||
|
if dt_time(15, 5) <= now < dt_time(22, 0):
|
||||||
|
self.assertFalse(due)
|
||||||
|
self.assertTrue(derived_due)
|
||||||
|
else:
|
||||||
|
self.assertFalse(due)
|
||||||
|
self.assertFalse(derived_due)
|
||||||
|
|
||||||
|
def test_official_catchup_is_due_for_intraday_snapshot_after_close(self):
|
||||||
|
today = TRADE_DAY.strftime("%Y%m%d")
|
||||||
|
snapshot = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": "2026-09-08",
|
||||||
|
"realtime": True,
|
||||||
|
"mode": "realtime",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with patch("backend.jobs.refresh.datetime") as mocked:
|
||||||
|
mocked.now.return_value = at_clock(16, 10)
|
||||||
|
mocked.strptime = datetime.strptime
|
||||||
|
self.assertTrue(official_catchup_due(today, snapshot))
|
||||||
|
official = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": "2026-09-08",
|
||||||
|
"limit_data_source": "official",
|
||||||
|
"realtime": False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.assertFalse(official_catchup_due(today, official))
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendRefreshCopyTests(unittest.TestCase):
|
||||||
|
def test_dashboard_script_distinguishes_partial_from_failure(self):
|
||||||
|
script = (Path(__file__).resolve().parents[1] / "frontend" / "shared" / "dashboard.js").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("今日数据正在准备,当前展示", script)
|
||||||
|
self.assertIn("部分正式数据尚未到齐", script)
|
||||||
|
self.assertIn("盘中行情", script)
|
||||||
|
self.assertIn("meta.realtime && actualCompact === requestedCompact", script)
|
||||||
|
self.assertIn('job.status === "failed"', script)
|
||||||
|
failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0]
|
||||||
|
self.assertIn("后台刷新失败", failed_block)
|
||||||
|
success_block = script.split("const freshness = dashboardFreshnessMessage(meta);", 1)[1]
|
||||||
|
self.assertNotIn("后台刷新失败", success_block.split("} else {", 1)[0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from database import ReviewDatabase
|
|||||||
|
|
||||||
|
|
||||||
class BootstrapContainerTests(unittest.TestCase):
|
class BootstrapContainerTests(unittest.TestCase):
|
||||||
def test_environment_credentials_preserve_legacy_model_fallbacks(self) -> None:
|
def test_environment_credentials_exclude_provider_secrets_and_preserve_llm_fallbacks(self) -> None:
|
||||||
result = environment_credentials(
|
result = environment_credentials(
|
||||||
{
|
{
|
||||||
"TUSHARE_TOKEN": " tushare ",
|
"TUSHARE_TOKEN": " tushare ",
|
||||||
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
|
|||||||
"LLM_MODEL": "legacy-model",
|
"LLM_MODEL": "legacy-model",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertEqual(result["tushare_token"], "tushare")
|
self.assertNotIn("tushare_token", result)
|
||||||
self.assertEqual(result["ifind_refresh_token"], "refresh")
|
self.assertNotIn("ifind_refresh_token", result)
|
||||||
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
|
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
|
||||||
self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1")
|
self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1")
|
||||||
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
|
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
|
||||||
@@ -45,8 +45,9 @@ class BootstrapContainerTests(unittest.TestCase):
|
|||||||
self.assertIs(container.strategy_tracking.repository.database, database)
|
self.assertIs(container.strategy_tracking.repository.database, database)
|
||||||
self.assertIs(container.alert_service.repository.database, database)
|
self.assertIs(container.alert_service.repository.database, database)
|
||||||
self.assertIs(container.trade_journal.repository.database, database)
|
self.assertIs(container.trade_journal.repository.database, database)
|
||||||
self.assertIs(container.chart_data.ifind, container.ifind)
|
self.assertIs(container.ifind, container.data_gateway.ifind)
|
||||||
self.assertTrue(container.ifind.configured)
|
self.assertIs(container.chart_data.datahub, container.data_gateway.datahub)
|
||||||
|
self.assertIsNone(container.chart_data.ifind)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from backend.bootstrap.runtime import configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureLoggingTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._saved_handlers = logging.getLogger().handlers[:]
|
||||||
|
self._saved_level = logging.getLogger().level
|
||||||
|
logging.getLogger().handlers.clear()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
logging.getLogger().handlers[:] = self._saved_handlers
|
||||||
|
logging.getLogger().setLevel(self._saved_level)
|
||||||
|
|
||||||
|
def test_configures_root_logger_at_info(self) -> None:
|
||||||
|
configure_logging()
|
||||||
|
root = logging.getLogger()
|
||||||
|
self.assertTrue(root.handlers)
|
||||||
|
self.assertEqual(root.level, logging.INFO)
|
||||||
|
with self.assertLogs("xiaobai.datahub", level="INFO") as captured:
|
||||||
|
logging.getLogger("xiaobai.datahub").info("datahub shadow %s", {"dataset": "daily"})
|
||||||
|
self.assertIn("datahub shadow", captured.output[0])
|
||||||
|
|
||||||
|
def test_keeps_existing_configuration(self) -> None:
|
||||||
|
handler = logging.NullHandler()
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
configure_logging()
|
||||||
|
self.assertEqual(logging.getLogger().handlers, [handler])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from backend.features.market.charts import ChartDataError, EastmoneyChartClient
|
from backend.features.market.charts import ChartDataError, EastmoneyChartClient, HIS_TRENDS_URL, MarketChartClient, TRENDS_URL
|
||||||
from server import DashboardService
|
from server import DashboardService
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +72,170 @@ class ChartDataProviderTests(unittest.TestCase):
|
|||||||
self.client.stock_intraday("abc")
|
self.client.stock_intraday("abc")
|
||||||
|
|
||||||
|
|
||||||
|
class LookbackChartClient(EastmoneyChartClient):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(cache_ttl_seconds=20)
|
||||||
|
self.requests: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
||||||
|
def _request_json(self, url, params, referer):
|
||||||
|
self.requests.append((url, params))
|
||||||
|
if url == TRENDS_URL and params.get("ndays") == "1":
|
||||||
|
return {"data": {"code": "601318", "name": "中国平安", "preClose": 56.0, "trends": []}}
|
||||||
|
if url == TRENDS_URL and params.get("ndays") == "5":
|
||||||
|
return {"data": {"code": "601318", "name": "中国平安", "preClose": 56.0, "trends": []}}
|
||||||
|
if url == HIS_TRENDS_URL:
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"code": "601318",
|
||||||
|
"name": "中国平安",
|
||||||
|
"preClose": 55.8,
|
||||||
|
"trends": [
|
||||||
|
"2026-09-07 09:30,55.80,55.90,56.00,55.70,100,5580.00,55.900",
|
||||||
|
"2026-09-07 15:00,56.10,56.20,56.30,56.00,200,11240.00,56.150",
|
||||||
|
"2026-09-08 09:30,0,0,0,0,0,0.00,0",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raise ChartDataError("unexpected url")
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLookbackTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
EastmoneyChartClient._cache.clear()
|
||||||
|
self.client = LookbackChartClient()
|
||||||
|
|
||||||
|
def test_empty_today_falls_back_to_latest_available_session(self):
|
||||||
|
payload = self.client.stock_intraday("601318")
|
||||||
|
urls = [url for url, _ in self.client.requests]
|
||||||
|
self.assertEqual(urls[0], TRENDS_URL)
|
||||||
|
self.assertEqual(self.client.requests[0][1]["ndays"], "1")
|
||||||
|
self.assertEqual(urls[1], TRENDS_URL)
|
||||||
|
self.assertEqual(self.client.requests[1][1]["ndays"], "5")
|
||||||
|
self.assertEqual(urls[2], HIS_TRENDS_URL)
|
||||||
|
self.assertEqual(payload["trade_date"], "2026-09-07")
|
||||||
|
self.assertEqual([point["time"] for point in payload["points"]], ["09:30", "15:00"])
|
||||||
|
self.assertEqual(payload["points"][0]["close"], 55.9)
|
||||||
|
|
||||||
|
def test_delay_multiday_can_recover_without_his(self):
|
||||||
|
class DelayFive(EastmoneyChartClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(cache_ttl_seconds=20)
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def _request_json(self, url, params, referer):
|
||||||
|
self.requests.append((url, params))
|
||||||
|
if params.get("ndays") == "1":
|
||||||
|
return {"data": {"code": "000001", "name": "平安银行", "preClose": 11.7, "trends": []}}
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"code": "000001",
|
||||||
|
"name": "平安银行",
|
||||||
|
"preClose": 11.5,
|
||||||
|
"trends": [
|
||||||
|
"2026-09-07 09:30,11.50,11.60,11.70,11.40,100,1160.00,11.600",
|
||||||
|
"2026-09-07 15:00,11.70,11.80,11.90,11.60,200,2360.00,11.750",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EastmoneyChartClient._cache.clear()
|
||||||
|
client = DelayFive()
|
||||||
|
payload = client.stock_intraday("000001")
|
||||||
|
self.assertEqual(payload["trade_date"], "2026-09-07")
|
||||||
|
self.assertEqual(len(payload["points"]), 2)
|
||||||
|
self.assertEqual([url for url, _ in client.requests], [TRENDS_URL, TRENDS_URL])
|
||||||
|
|
||||||
|
def test_sh_sz_cyb_codes_use_correct_secid(self):
|
||||||
|
for code, secid in (("601318", "1.601318"), ("000001", "0.000001"), ("300750", "0.300750")):
|
||||||
|
EastmoneyChartClient._cache.clear()
|
||||||
|
client = LookbackChartClient()
|
||||||
|
client.stock_intraday(code)
|
||||||
|
self.assertEqual(client.requests[0][1]["secid"], secid)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHub:
|
||||||
|
def __init__(self, chart=None, error=None, daily=None):
|
||||||
|
self.chart = chart
|
||||||
|
self.error = error
|
||||||
|
self.daily = daily
|
||||||
|
self.calls: list[str] = []
|
||||||
|
self.legacy: list[str] = []
|
||||||
|
|
||||||
|
def try_intraday(self, code):
|
||||||
|
self.calls.append(code)
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.chart
|
||||||
|
|
||||||
|
def try_daily_chart(self, code, end_date, limit=90, dataset="daily"):
|
||||||
|
self.calls.append(f"{dataset}:{code}")
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
return self.daily
|
||||||
|
|
||||||
|
def record_legacy(self, dataset, source="", error=""):
|
||||||
|
self.legacy.append(dataset)
|
||||||
|
|
||||||
|
|
||||||
|
class DatahubChartFallbackTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
EastmoneyChartClient._cache.clear()
|
||||||
|
|
||||||
|
def test_datahub_success_skips_old_channel(self):
|
||||||
|
hub = FakeHub(
|
||||||
|
{
|
||||||
|
"entity_type": "stock",
|
||||||
|
"identifier": "601318",
|
||||||
|
"name": "中国平安",
|
||||||
|
"code": "601318",
|
||||||
|
"trade_date": "2026-09-08",
|
||||||
|
"previous_close": 56.36,
|
||||||
|
"points": [{"date": "2026-09-08", "time": "09:30", "close": 56.5, "average": 56.4}],
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fallback = LookbackChartClient()
|
||||||
|
client = MarketChartClient(hub)
|
||||||
|
payload = client.stock_intraday("601318")
|
||||||
|
self.assertEqual(payload["source"], "datahub")
|
||||||
|
self.assertEqual(hub.calls, ["601318"])
|
||||||
|
self.assertEqual(fallback.requests, [])
|
||||||
|
|
||||||
|
def test_datahub_timeout_or_empty_does_not_use_old_channel(self):
|
||||||
|
fallback = LookbackChartClient()
|
||||||
|
for hub in (
|
||||||
|
FakeHub(chart=None),
|
||||||
|
FakeHub(error=RuntimeError("timeout")),
|
||||||
|
FakeHub(error=RuntimeError("datahub exploded")),
|
||||||
|
FakeHub(chart={"points": []}),
|
||||||
|
):
|
||||||
|
EastmoneyChartClient._cache.clear()
|
||||||
|
fallback.requests.clear()
|
||||||
|
client = MarketChartClient(hub)
|
||||||
|
with self.assertRaises(ChartDataError):
|
||||||
|
client.stock_intraday("000001")
|
||||||
|
self.assertEqual(fallback.requests, [])
|
||||||
|
|
||||||
|
def test_datahub_daily_skips_ifind(self):
|
||||||
|
hub = FakeHub(
|
||||||
|
daily=[
|
||||||
|
{
|
||||||
|
"trade_date": "2026-09-07",
|
||||||
|
"open": 10.0,
|
||||||
|
"high": 10.4,
|
||||||
|
"low": 9.9,
|
||||||
|
"close": 10.2,
|
||||||
|
"volume": 1000,
|
||||||
|
"amount_billion": 0.02,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client = MarketChartClient(hub)
|
||||||
|
rows = client.stock_daily("600000", "20260907")
|
||||||
|
self.assertEqual(rows[-1]["trade_date"], "2026-09-07")
|
||||||
|
self.assertIn("daily:600000", hub.calls)
|
||||||
|
|
||||||
|
|
||||||
class ChartServiceStub:
|
class ChartServiceStub:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _payload(code: str, name: str):
|
def _payload(code: str, name: str):
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ MODULE_STYLESHEETS = (
|
|||||||
"pages/review/foundation.css",
|
"pages/review/foundation.css",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 问天是冻结区:即便某条规则在当前运行时里已经没人引用,也不在这里做陈旧清理,
|
||||||
|
# 免得为了让门禁变绿去动冻结代码。其余门禁(归属唯一、无空声明等)照常覆盖它。
|
||||||
|
FROZEN_STYLESHEETS = ("pages/heaven/foundation.css",)
|
||||||
|
|
||||||
RETIRED_STYLESHEETS = (
|
RETIRED_STYLESHEETS = (
|
||||||
"styles/styles.css",
|
"styles/styles.css",
|
||||||
"styles/renovation.css",
|
"styles/renovation.css",
|
||||||
@@ -559,6 +563,8 @@ class CssGovernanceTests(unittest.TestCase):
|
|||||||
def test_every_selector_has_a_runtime_consumer(self) -> None:
|
def test_every_selector_has_a_runtime_consumer(self) -> None:
|
||||||
stale: list[str] = []
|
stale: list[str] = []
|
||||||
for relative, keys in self.rule_keys.items():
|
for relative, keys in self.rule_keys.items():
|
||||||
|
if relative in FROZEN_STYLESHEETS:
|
||||||
|
continue
|
||||||
for contexts, selector in keys:
|
for contexts, selector in keys:
|
||||||
if selector.lower().startswith(("@keyframes", "@-webkit-keyframes")):
|
if selector.lower().startswith(("@keyframes", "@-webkit-keyframes")):
|
||||||
continue
|
continue
|
||||||
|
|||||||
+38
-15
@@ -12,6 +12,7 @@ from backend.data import (
|
|||||||
QualityEvidence,
|
QualityEvidence,
|
||||||
build_data_gateway,
|
build_data_gateway,
|
||||||
)
|
)
|
||||||
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
from backend.data.quality import market_timezone
|
from backend.data.quality import market_timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -35,16 +36,30 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
with self.assertRaises(DataPolicyError):
|
with self.assertRaises(DataPolicyError):
|
||||||
policy.assert_allowed("market.level2", "unresolved", "display")
|
policy.assert_allowed("market.level2", "unresolved", "display")
|
||||||
|
|
||||||
def test_gateway_uses_live_token_supplier_and_shared_ifind(self) -> None:
|
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
|
||||||
token = {"value": "first"}
|
settings = DatahubSettings(
|
||||||
gateway = build_data_gateway(
|
base_url="http://127.0.0.1:8766",
|
||||||
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
token="hub-token",
|
||||||
lambda: token["value"],
|
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
|
||||||
)
|
)
|
||||||
self.assertEqual(gateway.tushare().token, "first")
|
gateway = build_data_gateway(
|
||||||
token["value"] = "second"
|
{},
|
||||||
self.assertEqual(gateway.tushare().token, "second")
|
datahub_settings=settings,
|
||||||
self.assertIs(gateway.chart_data.ifind, gateway.ifind)
|
)
|
||||||
|
client = gateway.tushare()
|
||||||
|
self.assertEqual(client.token, "datahub")
|
||||||
|
self.assertIsNone(client.realtime_aggregator)
|
||||||
|
self.assertFalse(hasattr(client, "_legacy"))
|
||||||
|
self.assertIs(gateway.ifind, gateway.ifind_provider.client)
|
||||||
|
self.assertIs(gateway.chart_data.datahub, gateway.datahub)
|
||||||
|
self.assertIsNone(gateway.chart_data.ifind)
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||||
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
|
|
||||||
|
self.assertIsInstance(client, DatahubAwareTushareClient)
|
||||||
|
self.assertIsInstance(gateway.ifind, HubIfindProxy)
|
||||||
|
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
|
||||||
|
|
||||||
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
||||||
source = (
|
source = (
|
||||||
@@ -54,24 +69,29 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
/ "market"
|
/ "market"
|
||||||
/ "service.py"
|
/ "service.py"
|
||||||
).read_text(encoding="utf-8")
|
).read_text(encoding="utf-8")
|
||||||
self.assertEqual(source.count("TushareClient(self.token)"), 1)
|
self.assertNotIn("TushareClient(self.token)", source)
|
||||||
self.assertIn("return gateway.tushare()", source)
|
self.assertIn("return gateway.tushare()", source)
|
||||||
|
|
||||||
def test_provider_construction_has_unique_declared_owners(self) -> None:
|
def test_provider_construction_has_unique_declared_owners(self) -> None:
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
owners = {
|
owners = {
|
||||||
"EastmoneyChartClient": {"backend/data/gateway.py"},
|
|
||||||
"IfindHttpClient": {"backend/data/gateway.py"},
|
|
||||||
"IfindProvider": {"backend/data/gateway.py"},
|
"IfindProvider": {"backend/data/gateway.py"},
|
||||||
"MarketChartClient": {"backend/data/gateway.py"},
|
"MarketChartClient": {"backend/data/gateway.py"},
|
||||||
"TushareClient": {"backend/features/market/service.py"},
|
"TushareClient": set(),
|
||||||
"TushareProvider": {"backend/data/gateway.py"},
|
|
||||||
"WebRealtimeAggregator": {"backend/data/gateway.py"},
|
|
||||||
"DatahubClient": {"backend/data/gateway.py"},
|
"DatahubClient": {"backend/data/gateway.py"},
|
||||||
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
||||||
"DatahubBridge": {"backend/data/gateway.py"},
|
"DatahubBridge": {"backend/data/gateway.py"},
|
||||||
|
"HubIfindProxy": {"backend/data/gateway.py"},
|
||||||
|
"HubRealtimeProxy": {"backend/data/gateway.py"},
|
||||||
}
|
}
|
||||||
found = {name: set() for name in owners}
|
found = {name: set() for name in owners}
|
||||||
|
forbidden = {
|
||||||
|
"IfindHttpClient": set(),
|
||||||
|
"EastmoneyChartClient": set(),
|
||||||
|
"WebRealtimeAggregator": set(),
|
||||||
|
"TushareProvider": set(),
|
||||||
|
}
|
||||||
|
found_forbidden = {name: set() for name in forbidden}
|
||||||
for path in (root / "backend").rglob("*.py"):
|
for path in (root / "backend").rglob("*.py"):
|
||||||
relative = path.relative_to(root).as_posix()
|
relative = path.relative_to(root).as_posix()
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
@@ -81,7 +101,10 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
||||||
if name in found:
|
if name in found:
|
||||||
found[name].add(relative)
|
found[name].add(relative)
|
||||||
|
if name in found_forbidden:
|
||||||
|
found_forbidden[name].add(relative)
|
||||||
self.assertEqual(found, owners)
|
self.assertEqual(found, owners)
|
||||||
|
self.assertEqual(found_forbidden, forbidden)
|
||||||
provider_source = (root / "backend/data/providers/tushare.py").read_text(
|
provider_source = (root / "backend/data/providers/tushare.py").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
("0003", "extend_llm_audit"),
|
("0003", "extend_llm_audit"),
|
||||||
("0004", "add_mentor_note"),
|
("0004", "add_mentor_note"),
|
||||||
("0005", "create_account_switch_grants"),
|
("0005", "create_account_switch_grants"),
|
||||||
|
("0006", "create_invite_codes"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -40,7 +41,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
count = connection.execute(
|
count = connection.execute(
|
||||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
self.assertEqual(count, 5)
|
self.assertEqual(count, 6)
|
||||||
|
|
||||||
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
||||||
self,
|
self,
|
||||||
@@ -61,7 +62,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
count = connection.execute(
|
count = connection.execute(
|
||||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
self.assertEqual(count, 5)
|
self.assertEqual(count, 6)
|
||||||
|
|
||||||
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
@@ -88,7 +89,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
"PRAGMA table_info(mentor_preferences)"
|
"PRAGMA table_info(mentor_preferences)"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
|
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005", "0006"})
|
||||||
self.assertIn("note", note_rows)
|
self.assertIn("note", note_rows)
|
||||||
|
|
||||||
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
||||||
|
|||||||
+391
-34
@@ -12,6 +12,8 @@ from backend.data.datahub.client import DatahubClient, DatahubResponse
|
|||||||
from backend.data.datahub.compare import compare_rows
|
from backend.data.datahub.compare import compare_rows
|
||||||
from backend.data.datahub.errors import DatahubError
|
from backend.data.datahub.errors import DatahubError
|
||||||
from backend.data.datahub.native import to_canonical_row, to_native_row
|
from backend.data.datahub.native import to_canonical_row, to_native_row
|
||||||
|
from backend.data.datahub.route_state import LEDGER
|
||||||
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -64,10 +66,19 @@ class FakeClient(DatahubClient):
|
|||||||
meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0},
|
meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0},
|
||||||
)
|
)
|
||||||
self.paths: list[str] = []
|
self.paths: list[str] = []
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
|
return self._record(path, params)
|
||||||
|
|
||||||
|
def post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
|
return self._record(path, body)
|
||||||
|
|
||||||
|
def _record(self, path: str, payload: dict[str, Any] | None) -> DatahubResponse:
|
||||||
self.paths.append(path)
|
self.paths.append(path)
|
||||||
if TOKEN in json.dumps(params or {}) or TOKEN in path:
|
self.calls.append((path, {key: value for key, value in (payload or {}).items()}))
|
||||||
|
packed = json.dumps(payload or {})
|
||||||
|
if TOKEN in packed or TOKEN in path:
|
||||||
raise AssertionError("token leaked into url")
|
raise AssertionError("token leaked into url")
|
||||||
if self.error:
|
if self.error:
|
||||||
raise self.error
|
raise self.error
|
||||||
@@ -82,17 +93,21 @@ def flags(**enabled: tuple[bool, bool]) -> DatahubSettings:
|
|||||||
|
|
||||||
|
|
||||||
class DatahubBridgeTests(unittest.TestCase):
|
class DatahubBridgeTests(unittest.TestCase):
|
||||||
def test_default_config_keeps_legacy_and_does_not_call_datahub(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
LEDGER.clear()
|
||||||
|
|
||||||
|
def test_default_config_enables_official_reads(self) -> None:
|
||||||
settings = DatahubSettings.load(environ={}, credentials={})
|
settings = DatahubSettings.load(environ={}, credentials={})
|
||||||
self.assertFalse(settings.any_enabled())
|
self.assertTrue(settings.any_enabled())
|
||||||
self.assertTrue(all(not settings.flags(name).read and not settings.flags(name).shadow for name in DATASETS))
|
self.assertTrue(all(settings.flags(name).read and not settings.flags(name).shadow for name in DATASETS))
|
||||||
client = FakeClient(error=DatahubError("INTERNAL", "should not be called"))
|
client = FakeClient()
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, client))
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, client))
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,vol,amount")
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,vol,amount")
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
self.assertEqual(client.paths, [])
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertEqual(legacy.calls, [])
|
||||||
|
self.assertEqual(LEDGER.snapshot()[0]["route"], "datahub")
|
||||||
|
|
||||||
def test_each_dataset_has_independent_read_flag(self) -> None:
|
def test_each_dataset_has_independent_read_flag(self) -> None:
|
||||||
settings = flags(daily=(True, False), auction=(False, False))
|
settings = flags(daily=(True, False), auction=(False, False))
|
||||||
@@ -102,6 +117,13 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
source = (ROOT / "config" / "datahub.config.json").read_text(encoding="utf-8")
|
source = (ROOT / "config" / "datahub.config.json").read_text(encoding="utf-8")
|
||||||
self.assertNotIn("master", source)
|
self.assertNotIn("master", source)
|
||||||
self.assertNotIn("DATAHUB_READ_ALL", source)
|
self.assertNotIn("DATAHUB_READ_ALL", source)
|
||||||
|
compose = (ROOT / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
for env_key in (
|
||||||
|
"CALENDAR", "STOCKS", "DAILY", "INDEX_DAILY", "VALUATION", "MONEYFLOW",
|
||||||
|
"AUCTION", "LIMIT_EVENTS", "POPULARITY", "DRAGON_TIGER", "SECTOR_DAILY",
|
||||||
|
"QUOTES", "INDEX_QUOTES", "INTRADAY", "STATUS",
|
||||||
|
):
|
||||||
|
self.assertIn(f'DATAHUB_READ_{env_key}: "1"', compose)
|
||||||
|
|
||||||
def test_read_flag_replaces_only_that_dataset_and_converts_units(self) -> None:
|
def test_read_flag_replaces_only_that_dataset_and_converts_units(self) -> None:
|
||||||
shadows: list[dict[str, Any]] = []
|
shadows: list[dict[str, Any]] = []
|
||||||
@@ -117,16 +139,22 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertEqual(legacy.calls, [])
|
self.assertEqual(legacy.calls, [])
|
||||||
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
|
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
|
||||||
calendar_client = FakeClient(error=DatahubError("INTERNAL", "nope"))
|
calendar_client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=[{"cal_date": "20240902", "is_open": 1, "pretrade_date": "20240830"}],
|
||||||
|
meta={"source": "datahub", "stale": False, "staleness_seconds": 0},
|
||||||
|
)
|
||||||
|
)
|
||||||
calendar_wrapped = DatahubAwareTushareClient(
|
calendar_wrapped = DatahubAwareTushareClient(
|
||||||
calendar_legacy,
|
calendar_legacy,
|
||||||
DatahubBridge(flags(daily=(True, False)), calendar_client),
|
DatahubBridge(flags(daily=(True, False)), calendar_client),
|
||||||
)
|
)
|
||||||
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
|
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
|
||||||
self.assertEqual(calendar[0]["is_open"], 1)
|
self.assertEqual(calendar[0]["is_open"], 1)
|
||||||
self.assertEqual(calendar_client.paths, [])
|
self.assertEqual(calendar_legacy.calls, [])
|
||||||
|
self.assertEqual(calendar_client.paths, ["/v1/query"])
|
||||||
|
|
||||||
def test_fallback_on_down_401_timeout_empty_unpublished_and_stale(self) -> None:
|
def test_hub_failure_does_not_call_website_legacy(self) -> None:
|
||||||
cases = [
|
cases = [
|
||||||
DatahubError("UNAVAILABLE", "down"),
|
DatahubError("UNAVAILABLE", "down"),
|
||||||
DatahubError("UNAUTHORIZED", "401"),
|
DatahubError("UNAUTHORIZED", "401"),
|
||||||
@@ -134,27 +162,25 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
DatahubError("EMPTY", "no rows"),
|
DatahubError("EMPTY", "no rows"),
|
||||||
DatahubError("DATASET_NOT_PUBLISHED", "not ready"),
|
DatahubError("DATASET_NOT_PUBLISHED", "not ready"),
|
||||||
DatahubError("STALE", "old"),
|
DatahubError("STALE", "old"),
|
||||||
|
DatahubError("INCOMPLETE", "truncated"),
|
||||||
]
|
]
|
||||||
for error in cases:
|
for error in cases:
|
||||||
with self.subTest(error=error.code):
|
with self.subTest(error=error.code):
|
||||||
if error.code == "EMPTY":
|
|
||||||
client = FakeClient(response=DatahubResponse(data=[], meta={"stale": False, "staleness_seconds": 0}))
|
|
||||||
elif error.code == "STALE":
|
|
||||||
client = FakeClient(response=DatahubResponse(
|
|
||||||
data=[dict(HUB_DAILY)],
|
|
||||||
meta={"stale": True, "staleness_seconds": 999999},
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
client = FakeClient(error=error)
|
client = FakeClient(error=error)
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
with self.assertRaises(TushareError):
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertEqual(legacy.calls, [])
|
||||||
|
|
||||||
def test_shadow_compares_without_replacing_and_survives_hub_failure(self) -> None:
|
def test_shadow_mode_no_longer_calls_website_tushare(self) -> None:
|
||||||
reports: list[dict[str, Any]] = []
|
reports: list[dict[str, Any]] = []
|
||||||
client = FakeClient()
|
client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=[dict(LEGACY_DAILY)],
|
||||||
|
meta={"source": "tushare", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
|
||||||
|
)
|
||||||
|
)
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(
|
wrapped = DatahubAwareTushareClient(
|
||||||
legacy,
|
legacy,
|
||||||
@@ -162,21 +188,19 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertEqual(legacy.calls, [])
|
||||||
self.assertEqual(reports[0]["equal"], True)
|
self.assertEqual(client.paths, ["/v1/query"])
|
||||||
self.assertEqual(reports[0]["matched"], 1)
|
|
||||||
|
|
||||||
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
|
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
|
||||||
fail_reports: list[dict[str, Any]] = []
|
|
||||||
fail_legacy = FakeLegacy([LEGACY_DAILY])
|
fail_legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
fail_wrapped = DatahubAwareTushareClient(
|
fail_wrapped = DatahubAwareTushareClient(
|
||||||
fail_legacy,
|
fail_legacy,
|
||||||
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=fail_reports.append),
|
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=reports.append),
|
||||||
)
|
)
|
||||||
again = fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
with self.assertRaises(TushareError):
|
||||||
self.assertEqual(again[0]["amount"], 2000.0)
|
fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
||||||
self.assertTrue(fail_reports[0]["hub_error"])
|
self.assertEqual(fail_legacy.calls, [])
|
||||||
self.assertNotIn(TOKEN, json.dumps(fail_reports[0]))
|
self.assertNotIn(TOKEN, str(failed.calls))
|
||||||
|
|
||||||
def test_compare_classifies_unit_conversion_missing_row_and_value_diff(self) -> None:
|
def test_compare_classifies_unit_conversion_missing_row_and_value_diff(self) -> None:
|
||||||
equal = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 0})
|
equal = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 0})
|
||||||
@@ -190,6 +214,86 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
skew = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 12})
|
skew = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 12})
|
||||||
self.assertTrue(skew["time_skew"])
|
self.assertTrue(skew["time_skew"])
|
||||||
|
|
||||||
|
def test_shadow_extra_hub_columns_are_not_false_diffs_when_projected(self) -> None:
|
||||||
|
hub_full = {**HUB_DAILY, "adj_factor": 1.1}
|
||||||
|
legacy_close_only = {k: LEGACY_DAILY[k] for k in ("ts_code", "trade_date", "close")}
|
||||||
|
report = compare_rows(
|
||||||
|
"daily", [legacy_close_only], [hub_full],
|
||||||
|
{"stale": False, "staleness_seconds": 0},
|
||||||
|
fields="ts_code,trade_date,close",
|
||||||
|
)
|
||||||
|
self.assertTrue(report["equal"])
|
||||||
|
self.assertEqual(report["value_diff_count"], 0)
|
||||||
|
self.assertEqual(report["fields_compared"], ["close", "trade_date", "ts_code"])
|
||||||
|
# without projection the same pair shows the historic false diff
|
||||||
|
unprojected = compare_rows("daily", [legacy_close_only], [hub_full])
|
||||||
|
self.assertFalse(unprojected["equal"])
|
||||||
|
|
||||||
|
legacy_stocks = {"ts_code": "600000.SH", "name": "浦发银行"}
|
||||||
|
hub_stocks = {
|
||||||
|
"ts_code": "600000.SH", "symbol": "600000", "name": "浦发银行", "area": "上海",
|
||||||
|
"industry": "银行", "market": "主板", "list_status": "L", "list_date": "19991110",
|
||||||
|
}
|
||||||
|
stocks = compare_rows("stocks", [legacy_stocks], [hub_stocks], {}, fields="ts_code,name")
|
||||||
|
self.assertTrue(stocks["equal"])
|
||||||
|
|
||||||
|
legacy_cal = {"cal_date": "20240902", "is_open": 1}
|
||||||
|
hub_cal = {
|
||||||
|
"cal_date": "20240902", "is_open": True,
|
||||||
|
"pretrade_date": "20240830", "prev_open": "20240830",
|
||||||
|
}
|
||||||
|
calendar = compare_rows(
|
||||||
|
"calendar", [legacy_cal], [hub_cal], {}, fields="cal_date,is_open"
|
||||||
|
)
|
||||||
|
self.assertTrue(calendar["equal"])
|
||||||
|
|
||||||
|
def test_shadow_projection_still_alarms_on_requested_field_problems(self) -> None:
|
||||||
|
hub_missing_field = {k: v for k, v in HUB_DAILY.items() if k != "close"}
|
||||||
|
legacy_close_only = {k: LEGACY_DAILY[k] for k in ("ts_code", "trade_date", "close")}
|
||||||
|
lost = compare_rows(
|
||||||
|
"daily", [legacy_close_only], [hub_missing_field], fields="ts_code,trade_date,close"
|
||||||
|
)
|
||||||
|
self.assertFalse(lost["equal"])
|
||||||
|
self.assertEqual(lost["value_diff_count"], 1)
|
||||||
|
|
||||||
|
changed = compare_rows(
|
||||||
|
"daily", [legacy_close_only], [{**HUB_DAILY, "close": 99.0}],
|
||||||
|
fields="ts_code,trade_date,close",
|
||||||
|
)
|
||||||
|
self.assertFalse(changed["equal"])
|
||||||
|
self.assertEqual(changed["value_diff_count"], 1)
|
||||||
|
self.assertEqual(changed["value_diffs"][0]["fields"][0]["field"], "close")
|
||||||
|
|
||||||
|
gone = compare_rows("daily", [LEGACY_DAILY], [], fields="ts_code,trade_date,close")
|
||||||
|
self.assertEqual(gone["missing_hub_count"], 1)
|
||||||
|
self.assertFalse(gone["equal"])
|
||||||
|
|
||||||
|
unit = compare_rows(
|
||||||
|
"daily", [LEGACY_DAILY], [{**HUB_DAILY, "amount": 2000.0, "volume": 1000.0}],
|
||||||
|
fields="ts_code,trade_date,vol,amount",
|
||||||
|
)
|
||||||
|
self.assertGreater(unit["unit_conversion_count"], 0)
|
||||||
|
self.assertFalse(unit["equal"])
|
||||||
|
|
||||||
|
def test_bridge_shadow_report_uses_website_request_fields(self) -> None:
|
||||||
|
hub_full = {**HUB_DAILY, "adj_factor": 1.1}
|
||||||
|
legacy_close_only = {k: LEGACY_DAILY[k] for k in ("ts_code", "trade_date", "close", "vol", "amount")}
|
||||||
|
reports: list[dict[str, Any]] = []
|
||||||
|
client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=[hub_full],
|
||||||
|
meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wrapped = DatahubAwareTushareClient(
|
||||||
|
FakeLegacy([legacy_close_only]),
|
||||||
|
DatahubBridge(flags(daily=(True, False)), client, shadow_sink=reports.append),
|
||||||
|
)
|
||||||
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,trade_date,close,vol,amount")
|
||||||
|
self.assertEqual(rows[0]["close"], 10.20)
|
||||||
|
self.assertEqual(rows[0]["vol"], 1000.0)
|
||||||
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
|
|
||||||
def test_native_roundtrip_matches_known_scales(self) -> None:
|
def test_native_roundtrip_matches_known_scales(self) -> None:
|
||||||
native = to_native_row("daily", HUB_DAILY)
|
native = to_native_row("daily", HUB_DAILY)
|
||||||
self.assertEqual(native["vol"], 1000.0)
|
self.assertEqual(native["vol"], 1000.0)
|
||||||
@@ -198,7 +302,8 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertEqual(canonical["vol"], 100000.0)
|
self.assertEqual(canonical["vol"], 100000.0)
|
||||||
self.assertEqual(canonical["amount"], 2000000.0)
|
self.assertEqual(canonical["amount"], 2000000.0)
|
||||||
|
|
||||||
def test_heaven_keeps_legacy_even_when_read_flag_is_on(self) -> None:
|
def test_heaven_can_use_hub_when_dataset_flag_is_on(self) -> None:
|
||||||
|
"""问天按数据依赖接入:已映射 API 跟随开关,不再整栈强制旧链路。"""
|
||||||
self.assertTrue(looks_like_heaven("backend.features.heaven.market_context", "backend/features/heaven/market_context.py"))
|
self.assertTrue(looks_like_heaven("backend.features.heaven.market_context", "backend/features/heaven/market_context.py"))
|
||||||
self.assertFalse(looks_like_heaven("backend.features.market.service", "backend/features/market/service.py"))
|
self.assertFalse(looks_like_heaven("backend.features.market.service", "backend/features/market/service.py"))
|
||||||
client = FakeClient()
|
client = FakeClient()
|
||||||
@@ -209,7 +314,8 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
self.assertEqual(client.paths, [])
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
|
self.assertEqual(legacy.calls, [])
|
||||||
|
|
||||||
def test_status_flag_does_not_run_when_off_and_falls_back_when_on(self) -> None:
|
def test_status_flag_does_not_run_when_off_and_falls_back_when_on(self) -> None:
|
||||||
off = DatahubBridge(flags(), FakeClient(error=DatahubError("UNAVAILABLE", "down")))
|
off = DatahubBridge(flags(), FakeClient(error=DatahubError("UNAVAILABLE", "down")))
|
||||||
@@ -234,6 +340,257 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertIsInstance(client, DatahubAwareTushareClient)
|
self.assertIsInstance(client, DatahubAwareTushareClient)
|
||||||
self.assertFalse(gateway.datahub.settings.any_enabled())
|
self.assertFalse(gateway.datahub.settings.any_enabled())
|
||||||
|
|
||||||
|
def test_stock_detail_range_query_is_not_silently_accepted_when_incomplete(self) -> None:
|
||||||
|
source = (ROOT / "backend" / "data" / "providers" / "tushare_stocks.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('"daily"', source)
|
||||||
|
self.assertIn("start_date", source)
|
||||||
|
self.assertIn("end_date", source)
|
||||||
|
client = FakeClient(error=DatahubError("INCOMPLETE", "truncated"))
|
||||||
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||||
|
with self.assertRaises(TushareError):
|
||||||
|
wrapped.query(
|
||||||
|
"daily",
|
||||||
|
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
|
||||||
|
"ts_code,amount",
|
||||||
|
)
|
||||||
|
self.assertEqual(legacy.calls, [])
|
||||||
|
self.assertIn("/v1/query", client.paths)
|
||||||
|
|
||||||
|
def test_try_intraday_respects_switch_and_falls_back_on_bad_payload(self) -> None:
|
||||||
|
closed = DatahubBridge(flags(), FakeClient(error=DatahubError("INTERNAL", "should not run")))
|
||||||
|
self.assertIsNone(closed.try_intraday("601318"))
|
||||||
|
|
||||||
|
empty = DatahubBridge(
|
||||||
|
flags(intraday=(True, False)),
|
||||||
|
FakeClient(response=DatahubResponse(data={"points": []}, meta={"stale": False})),
|
||||||
|
)
|
||||||
|
self.assertIsNone(empty.try_intraday("601318"))
|
||||||
|
|
||||||
|
stale = DatahubBridge(
|
||||||
|
flags(intraday=(True, False)),
|
||||||
|
FakeClient(response=DatahubResponse(
|
||||||
|
data={
|
||||||
|
"entity_type": "stock",
|
||||||
|
"code": "601318",
|
||||||
|
"trade_date": "2026-09-07",
|
||||||
|
"previous_close": 55.8,
|
||||||
|
"points": [{"date": "2026-09-07", "time": "09:30", "close": 55.9, "avg_price": 55.85}],
|
||||||
|
},
|
||||||
|
meta={"stale": True},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
self.assertIsNone(stale.try_intraday("601318"))
|
||||||
|
|
||||||
|
ok = DatahubBridge(
|
||||||
|
flags(intraday=(True, False)),
|
||||||
|
FakeClient(response=DatahubResponse(
|
||||||
|
data={
|
||||||
|
"entity_type": "stock",
|
||||||
|
"identifier": "601318",
|
||||||
|
"name": "中国平安",
|
||||||
|
"code": "601318",
|
||||||
|
"trade_date": "2026-09-08",
|
||||||
|
"previous_close": 56.36,
|
||||||
|
"points": [
|
||||||
|
{"date": "2026-09-08", "time": "09:30", "close": 0},
|
||||||
|
{"date": "2026-09-08", "time": "09:31", "close": 56.5, "avg_price": 56.4},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
meta={"stale": False},
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
chart = ok.try_intraday("601318")
|
||||||
|
self.assertEqual(chart["source"], "datahub")
|
||||||
|
self.assertEqual(len(chart["points"]), 1)
|
||||||
|
self.assertEqual(chart["points"][0]["average"], 56.4)
|
||||||
|
self.assertEqual(ok.client.paths, ["/v1/intraday/points"])
|
||||||
|
self.assertEqual(ok.client.calls, [("/v1/intraday/points", {"code": "601318"})])
|
||||||
|
self.assertNotIn("date", ok.client.calls[0][1])
|
||||||
|
|
||||||
|
timeout = DatahubBridge(
|
||||||
|
flags(intraday=(True, False)),
|
||||||
|
FakeClient(error=DatahubError("TIMEOUT", "datahub request timed out")),
|
||||||
|
)
|
||||||
|
self.assertIsNone(timeout.try_intraday("601318"))
|
||||||
|
broken = DatahubBridge(
|
||||||
|
flags(intraday=(True, False)),
|
||||||
|
FakeClient(error=DatahubError("INTERNAL", "datahub exploded")),
|
||||||
|
)
|
||||||
|
self.assertIsNone(broken.try_intraday("601318"))
|
||||||
|
self.assertTrue(DatahubSettings.load(environ={}, credentials={}).flags("intraday").read)
|
||||||
|
|
||||||
|
def test_try_market_quotes_and_visible_fallback(self) -> None:
|
||||||
|
quotes = [
|
||||||
|
{
|
||||||
|
"ts_code": f"{600000 + index:06d}.SH",
|
||||||
|
"name": f"股票{index}",
|
||||||
|
"close": 10.2,
|
||||||
|
"pre_close": 10.0,
|
||||||
|
"open": 10.1,
|
||||||
|
"high": 10.3,
|
||||||
|
"low": 9.9,
|
||||||
|
"vol": 1000,
|
||||||
|
"amount": 2000000,
|
||||||
|
"quote_date": "20240902",
|
||||||
|
}
|
||||||
|
for index in range(220)
|
||||||
|
]
|
||||||
|
ok = DatahubBridge(
|
||||||
|
flags(quotes=(True, False)),
|
||||||
|
FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=quotes,
|
||||||
|
meta={"stale": False, "staleness_seconds": 0, "source": "eastmoney:clist"},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rows = ok.try_market_quotes("20240902")
|
||||||
|
self.assertEqual(len(rows), 220)
|
||||||
|
self.assertEqual(rows[0]["pre_close"], 10.0)
|
||||||
|
self.assertEqual(ok.client.paths, ["/v1/quotes/latest"])
|
||||||
|
self.assertEqual(LEDGER.snapshot()[0]["route"], "datahub")
|
||||||
|
|
||||||
|
failed = DatahubBridge(
|
||||||
|
flags(quotes=(True, False)),
|
||||||
|
FakeClient(error=DatahubError("UNAVAILABLE", "down")),
|
||||||
|
)
|
||||||
|
self.assertIsNone(failed.try_market_quotes("20240902"))
|
||||||
|
snap = next(item for item in LEDGER.snapshot() if item["dataset"] == "quotes")
|
||||||
|
self.assertEqual(snap["route"], "datahub")
|
||||||
|
self.assertEqual(snap["source"], "unavailable")
|
||||||
|
|
||||||
|
gateway = build_data_gateway({}, datahub_settings=flags(quotes=(True, False)))
|
||||||
|
status = gateway.datahub_status()
|
||||||
|
self.assertEqual(status["enabled_reads"], 1)
|
||||||
|
self.assertEqual(status["total_reads"], len(DATASETS))
|
||||||
|
self.assertEqual(status["fallback_count"], 0)
|
||||||
|
|
||||||
|
def test_try_daily_chart_converts_hub_bars(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"trade_date": "20240901",
|
||||||
|
"open": 10.0,
|
||||||
|
"high": 10.4,
|
||||||
|
"low": 9.9,
|
||||||
|
"close": 10.2,
|
||||||
|
"volume": 100000,
|
||||||
|
"amount": 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"trade_date": "20240902",
|
||||||
|
"open": 10.2,
|
||||||
|
"high": 10.5,
|
||||||
|
"low": 10.1,
|
||||||
|
"close": 10.4,
|
||||||
|
"volume": 120000,
|
||||||
|
"amount": 2400000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
hub = DatahubBridge(
|
||||||
|
flags(daily=(True, False)),
|
||||||
|
FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=rows,
|
||||||
|
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
chart = hub.try_daily_chart("600000.SH", "20240902", 90, "daily")
|
||||||
|
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
||||||
|
self.assertEqual(chart[-1]["close"], 10.4)
|
||||||
|
self.assertAlmostEqual(chart[-1]["amount_billion"], 0.024)
|
||||||
|
|
||||||
|
def test_try_daily_chart_keeps_usable_bars_when_coverage_incomplete(self) -> None:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"trade_date": "20240901",
|
||||||
|
"open": 10.0,
|
||||||
|
"high": 10.4,
|
||||||
|
"low": 9.9,
|
||||||
|
"close": 10.2,
|
||||||
|
"volume": 100000,
|
||||||
|
"amount": 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts_code": "000001.SZ",
|
||||||
|
"trade_date": "20240902",
|
||||||
|
"open": 10.2,
|
||||||
|
"high": 10.5,
|
||||||
|
"low": 10.1,
|
||||||
|
"close": 10.4,
|
||||||
|
"volume": 120000,
|
||||||
|
"amount": 2400000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
hub = DatahubBridge(
|
||||||
|
flags(daily=(True, False)),
|
||||||
|
FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=rows,
|
||||||
|
meta={
|
||||||
|
"stale": False,
|
||||||
|
"staleness_seconds": 0,
|
||||||
|
"incomplete": True,
|
||||||
|
"coverage": {"complete": False, "missing_count": 127},
|
||||||
|
"source": "tushare:daily",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
chart = hub.try_daily_chart("000001.SZ", "20240902", 90, "daily")
|
||||||
|
self.assertIsNotNone(chart)
|
||||||
|
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
||||||
|
self.assertEqual(chart[-1]["close"], 10.4)
|
||||||
|
|
||||||
|
def test_gateway_tushare_facade_has_no_legacy_client(self) -> None:
|
||||||
|
quotes = [
|
||||||
|
{
|
||||||
|
"ts_code": f"{index:06d}.SZ",
|
||||||
|
"name": f"S{index}",
|
||||||
|
"pre_close": 10.0,
|
||||||
|
"open": 10.0,
|
||||||
|
"high": 10.5,
|
||||||
|
"low": 9.8,
|
||||||
|
"close": 10.2,
|
||||||
|
"vol": 100.0,
|
||||||
|
"amount": 1000.0,
|
||||||
|
"quote_date": "20240902",
|
||||||
|
}
|
||||||
|
for index in range(1, 221)
|
||||||
|
]
|
||||||
|
hub_client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=quotes,
|
||||||
|
meta={"stale": False, "staleness_seconds": 0, "source": "eastmoney_clist"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gateway = build_data_gateway(
|
||||||
|
{"tushare_token": "tok"},
|
||||||
|
datahub_settings=flags(quotes=(True, False), daily=(True, False)),
|
||||||
|
)
|
||||||
|
gateway.datahub.client = hub_client
|
||||||
|
wrapped = gateway.tushare()
|
||||||
|
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||||
|
self.assertIsNone(getattr(type(wrapped), "__getattr__", None))
|
||||||
|
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||||
|
self.assertTrue(callable(getattr(type(wrapped), "try_index_quotes", None)))
|
||||||
|
self.assertTrue(callable(getattr(type(wrapped), "record_datahub_legacy", None)))
|
||||||
|
self.assertTrue(callable(getattr(type(wrapped), "dashboard", None)))
|
||||||
|
rows = wrapped.try_market_quotes("20240902")
|
||||||
|
self.assertGreaterEqual(len(rows or []), 200)
|
||||||
|
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
||||||
|
hub_client.response = DatahubResponse(
|
||||||
|
data=[dict(HUB_DAILY)],
|
||||||
|
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
||||||
|
)
|
||||||
|
daily = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
|
self.assertEqual(daily[0]["amount"], 2000.0)
|
||||||
|
self.assertIn("/v1/bars/daily", hub_client.paths)
|
||||||
|
|
||||||
def test_features_do_not_import_datahub_client(self) -> None:
|
def test_features_do_not_import_datahub_client(self) -> None:
|
||||||
violations = []
|
violations = []
|
||||||
for path in (ROOT / "backend" / "features").rglob("*.py"):
|
for path in (ROOT / "backend" / "features").rglob("*.py"):
|
||||||
|
|||||||
@@ -459,6 +459,19 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
|
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
|
||||||
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
|
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
|
||||||
|
|
||||||
|
def test_heaven_loading_timeout_clears_dimmed_state(self):
|
||||||
|
self.assertIn("controller.abort()", self.script)
|
||||||
|
self.assertIn('heavenView?.classList.remove("heaven-data-loading")', self.script)
|
||||||
|
self.assertIn("问天数据仍在准备,页面可继续输入和操作", self.script)
|
||||||
|
self.assertIn("const blocking = !state.heavenSetup;", self.script)
|
||||||
|
self.assertIn("payload?.aborted", self.script)
|
||||||
|
|
||||||
|
def test_stock_detail_does_not_display_missing_metrics_as_zero(self):
|
||||||
|
self.assertIn("function setStockBoardFields(row)", self.script)
|
||||||
|
self.assertIn("function presentMetric(value)", self.script)
|
||||||
|
self.assertIn("payload.available !== false", self.script)
|
||||||
|
self.assertIn('element.textContent = "--"', self.script)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class GlobalSearchTests(unittest.TestCase):
|
|||||||
self.assertIn('event.key.toLowerCase() !== "k"', script)
|
self.assertIn('event.key.toLowerCase() !== "k"', script)
|
||||||
self.assertIn('openStock(item.id, { code: item.code', script)
|
self.assertIn('openStock(item.id, { code: item.code', script)
|
||||||
self.assertNotIn('include_notes', script)
|
self.assertNotIn('include_notes', script)
|
||||||
self.assertIn('const candles = (series || [])', script)
|
self.assertIn('const candles = visibleDailyPrices((series || [])', script)
|
||||||
self.assertIn('renderStockNotes(payload.notes || [])', script)
|
self.assertIn('renderStockNotes(payload.notes || [])', script)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from backend.data.realtime import WebRealtimeAggregator
|
from backend.data.realtime import RealtimeAggregateError, WebRealtimeAggregator
|
||||||
from backend.features.heaven.engine import _market_line_scores, build_manual_market_hexagram
|
from backend.features.heaven.engine import _market_line_scores, build_manual_market_hexagram
|
||||||
from server import DashboardService
|
from server import DashboardService
|
||||||
from backend.data.providers.tushare_client import (
|
from backend.data.providers.tushare_client import (
|
||||||
@@ -377,6 +378,87 @@ class RealtimeAggregatorTests(unittest.TestCase):
|
|||||||
self.assertEqual(rows[0]["quote_time"][:10], "2026-07-20")
|
self.assertEqual(rows[0]["quote_time"][:10], "2026-07-20")
|
||||||
self.assertAlmostEqual(rows[0]["amount_billion"], 12946.52)
|
self.assertAlmostEqual(rows[0]["amount_billion"], 12946.52)
|
||||||
|
|
||||||
|
@patch.object(WebRealtimeAggregator, "_get_json")
|
||||||
|
def test_eastmoney_market_quotes_normalize_and_keep_expected_date(self, get_json: MagicMock):
|
||||||
|
epoch = datetime(2026, 7, 20, 10, 5).timestamp()
|
||||||
|
rows = []
|
||||||
|
for index in range(200):
|
||||||
|
sz = index < 100
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"f12": f"{index:06d}" if sz else f"{600000 + index - 100:06d}",
|
||||||
|
"f13": 0 if sz else 1,
|
||||||
|
"f14": f"股票{index}",
|
||||||
|
"f2": 11.2,
|
||||||
|
"f3": 2.0,
|
||||||
|
"f5": 10,
|
||||||
|
"f6": 50000000,
|
||||||
|
"f15": 11.3,
|
||||||
|
"f16": 11.0,
|
||||||
|
"f17": 11.1,
|
||||||
|
"f18": 11.0,
|
||||||
|
"f124": epoch,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def fake_get_json(_url, params, referer=""):
|
||||||
|
page = int(params.get("pn") or 1)
|
||||||
|
start = (page - 1) * 100
|
||||||
|
return {"rc": 0, "data": {"total": 200, "diff": rows[start:start + 100]}}
|
||||||
|
|
||||||
|
get_json.side_effect = fake_get_json
|
||||||
|
aggregator = WebRealtimeAggregator()
|
||||||
|
aggregator._response_cache.clear()
|
||||||
|
quotes = aggregator.eastmoney_market_quotes("20260720")
|
||||||
|
self.assertEqual(len(quotes), 200)
|
||||||
|
self.assertEqual(quotes[0]["ts_code"], "000000.SZ")
|
||||||
|
self.assertTrue(quotes[100]["ts_code"].endswith(".SH"))
|
||||||
|
self.assertEqual(quotes[0]["vol"], 1000)
|
||||||
|
self.assertEqual(quotes[0]["quote_date"], "20260720")
|
||||||
|
|
||||||
|
@patch.object(WebRealtimeAggregator, "_get_text")
|
||||||
|
def test_tencent_stock_quote_keeps_expected_date(self, get_text: MagicMock):
|
||||||
|
fields = [""] * 38
|
||||||
|
fields[1] = "浦发银行"
|
||||||
|
fields[2] = "600000"
|
||||||
|
fields[3] = "11.20"
|
||||||
|
fields[4] = "11.00"
|
||||||
|
fields[5] = "11.10"
|
||||||
|
fields[6] = "1234"
|
||||||
|
fields[30] = "20260720103000"
|
||||||
|
fields[33] = "11.30"
|
||||||
|
fields[34] = "11.00"
|
||||||
|
fields[37] = "1380"
|
||||||
|
get_text.return_value = (f'v_sh600000="{"~".join(fields)}";', 0)
|
||||||
|
|
||||||
|
quote = WebRealtimeAggregator().tencent_stock_quote("600000", "20260720")
|
||||||
|
|
||||||
|
self.assertEqual(quote["ts_code"], "600000.SH")
|
||||||
|
self.assertEqual(quote["quote_date"], "20260720")
|
||||||
|
self.assertEqual(quote["vol"], 123400)
|
||||||
|
self.assertAlmostEqual(quote["amount"], 13_800_000)
|
||||||
|
|
||||||
|
@patch.object(WebRealtimeAggregator, "_get_json")
|
||||||
|
def test_eastmoney_stock_quote_rejects_stale_date(self, get_json: MagicMock):
|
||||||
|
epoch = datetime(2026, 7, 19, 15, 0).timestamp()
|
||||||
|
get_json.return_value = {
|
||||||
|
"rc": 0,
|
||||||
|
"data": {
|
||||||
|
"f43": 11.2,
|
||||||
|
"f44": 11.3,
|
||||||
|
"f45": 11.0,
|
||||||
|
"f46": 11.1,
|
||||||
|
"f47": 10,
|
||||||
|
"f48": 50000000,
|
||||||
|
"f57": "300750",
|
||||||
|
"f58": "宁德时代",
|
||||||
|
"f60": 11.0,
|
||||||
|
"f86": epoch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with self.assertRaises(RealtimeAggregateError):
|
||||||
|
WebRealtimeAggregator().eastmoney_stock_quote("300750.SZ", "20260720")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user