Compare commits

...
Author SHA1 Message Date
总管andmultica-agent b950ea4344 fix(HEL-494): keep filtered reference lookups inside datahub
Co-authored-by: multica-agent <github@multica.ai>
2026-09-09 00:50:33 +08:00
总管andmultica-agent 41f8509a98 fix(HEL-494): retry incomplete sector publications
Co-authored-by: multica-agent <github@multica.ai>
2026-09-09 00:46:18 +08:00
总管andmultica-agent c9e2d30780 fix(HEL-494): enforce datahub as sole website market boundary
Co-authored-by: multica-agent <github@multica.ai>
2026-09-09 00:22:27 +08:00
8a7d1f3698 fix(HEL-494): 网站市场客户端改为纯中枢 Facade,并迁移 iFinD 凭据到中枢
生产 gateway 不再读取 Tushare token 或实例化 TushareProvider/TushareClient;问财凭据经带鉴权的中枢接口加密入库,避免发版后 iFinD 未配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 23:37:53 +08:00
100752f43c fix(HEL-494): 切断网站生产装配外源直连,iFinD 与实时观察改走中枢
生产 gateway 不再实例化 iFinD、东财图和免费实时聚合器;问财与竞价快照作为中枢内部数据源。全站阻断外源测试覆盖日K、报价、图表、问财和竞价快照。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 22:41:51 +08:00
0b8419abca fix(HEL-494): 数据中枢独占调度,主网站不再回退旧接口
主网站只向中枢要业务数据;来源选择、切源、补数全部在中枢内部完成,失败不再走东财/腾讯/Tushare 保底。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 21:43:31 +08:00
ef13d6feb5 fix(HEL-494): 盘中双免费源故障切换并禁止问天假0覆盖
主源东财失败后自动改走腾讯行情,成功结果写入缓存;两源都失败时返回最近真实快照并标明延迟,不再显示假0。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 17:38:38 +08:00
b5d65ecb41 fix(HEL-494): 日K默认45根并修复问天行业0/205覆盖
悬浮窗和详情页只画最近45个交易日,中枢仍保留250日历史。盘后缺sw_daily时保留成分日线内核,外显走免费申万;成分行情改为全市场快照+分页,不再截成前60只。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 17:00:01 +08:00
3e828b346c fix(HEL-494): 修复个股缺失指标、问天遮罩、四爻外显并回补250日K
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 16:26:13 +08:00
c8a9376adb fix(HEL-490): 真实装配接通中枢并收编估值晚间复核
把 query/行情钩子绑到内层 TushareClient,图表接受不完整日K窗口;收编现网 HEL-423 未提交的估值复核,避免换版丢掉。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 15:03:34 +08:00
1c2f2ac057 feat(HEL-490): 剩余行情改由数据中枢主线路提供
正式页面以 8766 为主线路,旧接口只作故障备用;compose 钉死全部 DATAHUB_READ_*,避免现网残留 0 造成假完成。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 12:03:30 +08:00
5d3465987d fix(HEL-488): 盘中日K补上今天实时变化的一根
悬浮窗和详情页在 Tushare rt_k / iFinD 不可用时,改用免费实时行情或当日分时生成今日K,收盘后正式日K就绪再无缝替换。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 11:18:21 +08:00
dd89a09643 fix(HEL-487): 盘中当天看板在 rt_k 无权限时降级到免费实时源
rt_k 失败、无权限、超时或空结果时改用东财全市场快照,再失败则用腾讯批量行情;两者都失败仍不退回昨天。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 10:52:10 +08:00
a043bc9eb1 fix(HEL-485): 盘中选择当天不再整页退回昨天
交易时段缺少盘后正式数据时继续展示当天盘中行情,只有开盘前、周末和历史日期才沿用最近收盘结果。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 10:16:52 +08:00
acde4de40d fix(HEL-484): 中枢分时接口空 date 按当天查询
缺少或为空的 date 不再 400,按当天处理;显式历史日期保持原行为。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 10:07:44 +08:00
3d2c1252f1 fix(HEL-482): 开盘前分时回退最近交易日,并接通中枢失败回旧通道
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-08 09:44:00 +08:00
605f97e5df feat(HEL-463): 接入剩余行情数据到 datahub
扩展盘后正式集(涨跌停/人气/龙虎榜/板块日线)与盘中观察 API(报价/指数/分时),网站 bridge 按开关接入并回退旧链路;问天改为按数据依赖跟随开关,不再整栈强制旧路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 17:30:58 +08:00
16ba83ec01 fix(HEL-461): 切换事务失败写入 release-group 审计日志
整组切换中断时除回滚与废弃批次外,同步记录
action=release-group 的失败审计,便于后台追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 16:02:11 +08:00
1c740a9d48 fix(HEL-461): 后台整组切换异常统一为 FAILED_PRECONDITION
管理后台补数在切换事务中断时不再抛出原始异常,
统一映射为 ApiError FAILED_PRECONDITION,并保留旧完整版本。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 15:56:28 +08:00
75c2e33b68 fix(HEL-461): CLI/后台强制重发改为整组边界切换
eod-refresh --force 与管理后台补数不再单数据集发布,
统一走 force_republish_boundary,避免绕过 A/B 完整边界。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 11:26:27 +08:00
32f565ecb9 fix(HEL-461): 整批发布按完整边界重暂存,主档与快照同事务
边界内任有缺失则整组重暂存后统一切换,避免旧新批次混发;
refresh_stocks 失败时主档保持旧值,并补齐回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 08:52:50 +08:00
16841e9ae3 fix(HEL-459): 影子比较按请求字段投影,盘后整批原子发布
比较侧只对网站本次请求字段计业务差异,忽略数据中枢额外列;
盘后 A/B/重发改为先整批暂存与交叉校验,再单事务切换公开版本。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-05 08:39:31 +08:00
multica-agentandmultica-agent bed6450992 feat(HEL-457): 估值字段级质量门、股票主档每日发布和资金流历史回补
- field_gates 按数据集配置关键字段非空率下限/非有限比例/相对上一批次的塌陷保护,
  字段大面积为空的批次拒发并保留上一正式批次,可读失败原因入 batches.error
- 股票主档交易日 20:00/23:10 自动刷新并发布版本化快照(eod_stocks + publications),
  覆盖新上市/简称变化/N前缀摘除;/v1/stocks 携带 batch_id/published_at,无变化跳过
- moneyflow 历史回补(默认 60 交易日,跳过已发布日期);未发布点查返回
  available_from/available_to 与 history_not_backfilled 标记,缺失不再静默
- eod-refresh 新增 --force --dataset 安全重发(仍走全部质量门,上一批次可回滚)
- 保持 HEL-435 盘后重试机制;新增 22 项测试覆盖字段拒发/正常通过/旧批保留/
  主档新增改名/资金流覆盖/重复执行幂等

Co-authored-by: multica-agent <github@multica.ai>
2026-09-04 21:36:20 +08:00
总工andmultica-agent c9892050c3 feat(HEL-435): 盘后未出数时晚间自动重试并提供安全补跑
Co-authored-by: multica-agent <github@multica.ai>
2026-09-03 22:30:15 +08:00
a836cda1b2 feat(HEL-421): 回补历史日历和指数并标记区间不完整
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 22:16:05 +08:00
总工 25ff6bbe06 fix(HEL-417): 配置根日志让影子对比报告落入容器日志 2026-09-02 21:53:19 +08:00
5085cacf0d fix(HEL-412): 刷新降级不再整次失败,并补齐准备中提示
手动刷新与自动补跑共用可用数据判定:日线推算或上一交易日快照记为部分/准备中成功,避免前端误报刷新失败。HTTP JSON 解析错误不再把请求正文写入日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 18:08:40 +08:00
0d13066386 feat(HEL-402): 接通网站首批只读 datahub 并建立双路对比
默认全部读取/影子开关关闭,网站继续走旧 Tushare 链路;开启单项时只替换该类原料并在失败时回旧,问天保持旧路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 17:09:28 +08:00
f5dc0f8076 fix(HEL-396): 禁止空数据批次冒充正式发布
统一发布入口在有效行数为 0 时不再写成 published,也不推进正式批次指针。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 15:56:29 +08:00
031eefab4d fix(HEL-386): 清理任务按 ISO 截止时间删除 job_runs/src_calls
YYYYMMDD 与 ISO 字符串比较会把同年保留期内记录全部误删。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 12:26:09 +08:00
3498dd7a4b feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 12:05:26 +08:00
c2ebc0ab91 docs(HEL-353): 恢复普通 README 并移除多媒体展示
放弃宣传稿与 docs/images 素材,按当前仓库真实结构重写常规说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 21:58:35 +08:00
71a6d68ed7 docs(HEL-277): 用 25 组真实素材替换 README 占位
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-01 21:39:08 +08:00
cda13a787f docs(HEL-272): 用终版项目介绍更新仓库 README
将总工审核通过的项目介绍写入根目录 README,保留本机启动、Docker 与系统配置等必要说明,技术栈保持公开。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 20:58:16 +08:00
总管andmultica-agent 6b688fd473 修复(HEL-235B): 可靠同步受限权限工作目录
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 23:29:11 +08:00
总管andmultica-agent 1e8da5fee2 部署(HEL-235B): 补严分叉与镜像标签校验
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 23:27:18 +08:00
150 changed files with 19551 additions and 588 deletions
+12 -5
View File
@@ -1,13 +1,20 @@
# Generated automatically when omitted. Back it up together with the database.
APP_ENCRYPTION_KEY=
# Initial shared market-data credential. After first launch it is encrypted into
# the system settings; all accounts use the same backend market snapshot.
# Market-source credentials are consumed and encrypted only by xiaobai-datahub.
# compose.yaml masks them from the xiaobai-review website process.
TUSHARE_TOKEN=your_tushare_token_here
# Optional iFinD HTTP credential. The backend exchanges it for a short-lived
# access token and never exposes either token to browsers.
IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
# Official xiaobai-datahub client. Read flags default on in config/datahub.config.json.
# 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_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
# are encrypted into system settings and used only by admins and active members.
+3
View File
@@ -8,6 +8,9 @@ data/*.db
data/*.db-shm
data/*.db-wal
data/backups/
datahub-data/
xiaobai-datahub/data/
xiaobai-datahub/.venv/
data/*.bak
data/*.backup
*.log
+11 -4
View File
@@ -38,10 +38,15 @@ background scheduler
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
- `backend/data/` owns the website-side DataHub client, stable dataset contracts, provenance,
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
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_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
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.
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
`backend/jobs/service.py` is the application-facing owner of scheduler start/stop, manual
refresh submission, and periodic refresh coordination.
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,
streaming rules, and call audit. Feature agents only prepare messages and interpret
feature-specific results.
+8 -5
View File
@@ -22,9 +22,10 @@ xiaobai-review 容器 :8765
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
```
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。
账号、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。外部行情源
Token 只允许保存在 `xiaobai-datahub` 的环境或凭据库,网站进程不读取、不保存,也不向
提供方直接发请求。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与密钥必须成对
备份,任意一个丢失都无法恢复账号内的加密资料。
问天静态知识文件:
@@ -41,7 +42,9 @@ xiaobai-review 容器 :8765
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
首个注册账号自动成为管理员。管理员在“系统管理”中配置全站共享行情、后台刷新、平台会员模型及手动会员;普通用户的“账号设置”用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
首个注册账号自动成为管理员。管理员在网站“系统管理”中查看数据中枢状态并配置后台刷新、
平台会员模型及手动会员;行情源凭据和调度策略在数据中枢后台统一管理。普通用户的“账号设置”
用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
## 2. 服务器要求
@@ -202,7 +205,7 @@ docker compose down
`compose.yaml` 的镜像名与 revision 标签同样做了强校验:直接 `docker compose up -d --build`
会因缺少 `XIAOBAI_GIT_REV` / `XIAOBAI_GIT_SHORT` 变量而拒绝执行,避免再出现构建进
`latest` 的模糊版本。需要用 compose 时先 `export` 这两个变量(值以
`~/xiaobai-git rev-parse HEAD` 为准),或直接用上面的脚本。
`~/xiaobai-build/xiaobai-git rev-parse HEAD` 为准),或直接用上面的脚本。
### 智能体高级入口:Git 归档流式构建
+104 -42
View File
@@ -1,76 +1,138 @@
# 小白复盘 Web
# 小白复盘
一个面向 A 股盘后复盘的本地 Web 工作台。后端使用 Python 访问 Tushare Pro,前端不依赖构建工具
面向 A 股盘后复盘的本地 Web 工作台。收盘后把涨停、炸板、连板梯队、板块轮动、集合竞价、龙虎榜等数据整理成可浏览的复盘界面;不接券商、不代为下单,也不提供个股推荐
本目录是经过保真迁移、结构治理和用户人工验收的唯一正式源码,不依赖父目录旧程序或失败版本
目录职责见[ARCHITECTURE.md](ARCHITECTURE.md),产品与维护文档见[docs/README.md](docs/README.md)。
本目录是唯一正式源码。模块边界见 [ARCHITECTURE.md](ARCHITECTURE.md),产品与维护文档见 [docs/README.md](docs/README.md)
当前包含集合竞价、涨停池、炸板池、跌停板、昨日涨停、涨停表现、市场天梯、板块轮动、题材库、人气热榜、龙虎榜和个人复盘工作区。交易日快照与同步记录保存在本地 SQLite 数据库 `data/review.db`
## 主要功能
集合竞价中心采用盘前生命周期:9:15 前显示预告,9:15–9:25 明确等待最终竞价,9:25–9:30 自动读取并重试最终竞价筛选,9:30 后停止更新并冻结为复盘归档。当前 Tushare 只提供 9:25 最终竞价快照,不将其表述为动态虚拟撮合行情
登录后左侧共 16 个页面,另有一个内嵌页「策略持续跟踪」。交易日快照保存在本地 SQLite 数据库 `data/review.db`
第三阶段加入了机构席位、席位别名、个股复权日 K、资金流、自选股、涨停原因修订、个股笔记、每日复盘和历史数据回补。
- **情绪周期**:0–100 情绪温度与阶段判定(默认首页)
- **涨停池 / 炸板池 / 跌停板 / 昨日涨停 / 涨停表现**:封板结构、炸板、跌停与昨日反馈
- **市场天梯**:按连板高度排列的市场梯队
- **板块轮动**:近若干交易日板块热力与成分下钻
- **集合竞价**:盘前生命周期;9:30 后停止更新并冻结为复盘归档。当前数据源提供 9:25 最终竞价快照,不是动态虚拟撮合行情
- **题材库 / 人气热榜 / 龙虎榜**:题材成分、双榜人气、席位与游资档案
- **智能选股**(会员):六阶段策略、精选策略库、自然语言编译为受控公式后的确定性筛选与滚动回测;候选需手动加入后才进入五交易日跟踪
- **问师**(会员):按选定的游资思维 Skill 单师对话;新增公开角色时在 `游资skills` 下增加含 `SKILL.md` 的目录,并在 `游资skills/mentor_catalog.json` 登记。管理员私有角色放在 `data/private-mentor-skills`(不进 Git / 镜像)
- **问天**(会员):观势 / 观气 / 观心。卦象、干支、节气与气机由本地程序确定性计算,大模型只负责文字解释。此前仅冻结过界面视觉方案,现已解冻;问天可纳入后续数据与功能迁移,本阶段不主动重做视觉。
- **我的复盘**:手工交易日志、每日复盘、提醒中心与复盘助手;不接券商、不自动下单
股票代码在桌面端悬停后会显示分时与日 K 快速预览,默认优先展示日 K;移动端点击代码后从底部打开预览面板。股票详情以及板块、题材、指数详情均可在日 K 与最新分时之间切换。日 K 复用个股详情缓存;分时优先使用 iFinD,东方财富仅作隔离的展示兜底,并使用短时内存缓存。图表数据不写入主行情不参与情绪、选股或问天计算;不可用时明确显示“分时不可用”,不会用日 K 模拟分时走势
全局能力:日间 / 夜间主题、股票代码悬停预览日 K 与分时、`Ctrl + K` 全局搜索。图表数据不写入主行情,也不参与情绪、选股或问天计算。
智能选股包含六阶段盘后候选、29 套精选策略、自定义公式 DSL、自然语言公式编译、候选排名和滚动回测。阶段与精选策略在当日行情更新后由后台确定性计算;自定义选股由用户手动执行,LLM 只负责编译自然语言条件,不参与候选筛选。竞价、估值、财务、资金、人气和席位等字段按已登记的数据可用性进入因子库,缺失时明确显示覆盖问题。
## 技术栈
候选只有经用户手动加入后才进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。
| 层面 | 说明 |
| --- | --- |
| 运行时 | Python 3.12;标准库 `ThreadingHTTPServer`,无独立 Web 框架 |
| 依赖 | `requirements.txt` 仅含 `cryptography`;问天历法使用仓库内 `vendor/lunar_python` |
| 数据库 | SQLiteWAL),默认文件 `data/review.db` |
| 前端 | 原生 HTML / CSS / JavaScript,无打包、无构建步骤 |
| 部署 | Docker / Docker Compose,或本机直接运行 `server.py` |
| 安全 | 账号密码 scrypt 哈希;行情 Token 与模型密钥用 `APP_ENCRYPTION_KEY` 加密后存库 |
问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。
## 环境要求
新增公开问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录,并在 `游资skills/mentor_catalog.json` 中登记素材等级与结构质检。管理员私有角色放在 `data/private-mentor-skills`,该目录不进入 Git 或 Docker 镜像,且只会出现在管理员的问师列表中。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。
- Python 3.12(与 `Dockerfile` 一致)
- 本机启动:能执行 `python` / `pip`
- Docker 部署:Docker Engine 24+Compose v2`docker compose`
- 行情:部署者自行申请并配置 Tushare Pro Token;部分分时优先使用同花顺 iFinD(可选)
- 问师、问天解释、复盘助手、自然语言编译公式:需配置 OpenAI 兼容接口;未配置时市场数据页仍可用
问天模块包含三个相互独立的部分:观势以市场数据生成三才六爻,用于观察“势”,行情缺失或自动取象明显偏差时可显式手动校准六爻,人工结果与自动来源严格区分;观气依据干支、精确节气、五运六气及客主加临关系观察“运”,行业五行仅作传统取象归类;观心先准备1秒,再完成5轮“吸3秒、顿2秒、呼4秒”,随后以六次三枚铜钱起卦、察念和解卦完成一次不输入问题的问心仪式。卦象、干支、节气与气机关系均由本地确定性程序计算,LLM只负责解释,不参与起卦或改动结果。
## 安装与启动
问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`
仓库根目录即为运行目录(`server.py``requirements.txt` 都在根目录)
“我的复盘”包含结构化手工交易日志,可记录方向、价格、数量、仓位、盈亏、逻辑、执行、情绪和标签,不接券商也不自动下单。顶部“复盘助手”以流式方式读取市场统计、策略跟踪、提醒、个人复盘和交易日志;对话按账号保存,只提供分析和条件化计划。
## 启动
```powershell
cd app
```bash
python -m pip install -r requirements.txt
python server.py
```
浏览器打开 `http://127.0.0.1:8765`,首次使用先注册账号。首个账号自动成为管理员,后续账号默认为普通用户。主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时提示等待管理员完成首次同步。
默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。
需要后台启动本地验收端口时,使用`tools/start_local.ps1`。该工具把日志、进程号和Python缓存
统一写入`runtime/`,不在源码根目录产生运行文件:
主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。
可选参数:
```bash
python server.py --host 127.0.0.1 --port 8765
```
Windows 下若需要后台启动,并把日志、进程号和 Python 缓存写入 `runtime/`(不在源码根目录产生运行文件):
```powershell
powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 -Port 8797
powershell -ExecutionPolicy Bypass -File tools/start_local.ps1
```
局域网 Docker 部署使用 `Dockerfile``compose.yaml`,完整的迁移、持久化、
防火墙、备份和恢复步骤见 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)。
该脚本默认端口为 `8797`。统一验收:
账号密码使用 scrypt 哈希;公共 Tushare Token、平台模型密钥以及原始生辰资料均使用 `APP_ENCRYPTION_KEY` 加密后保存在 SQLite。公共数据和平台模型归系统所有,生辰资料仍按账号隔离。普通用户不配置 LLM,只有管理员授权的有效会员可以使用平台模型。请将 `.env` 与数据库一起备份,丢失加密密钥后无法恢复这些资料。
```bash
python tools/verify_baseline.py
```
## 系统与账号配置
涉及运行时或前端时再加 `--e2e`Playwright)。
管理员通过页面右上角“系统管理”保存公共 Tushare Token、平台主/辅助模型、会员每日额度和后台刷新开关。所有用户读取同一份 SQLite 行情快照,不再分别配置行情 Token。已有个人凭据中的 Tushare Token 会在升级时迁移到系统配置并从个人凭据移除。
## Docker 使用
局域网或服务器部署使用仓库根目录的 `Dockerfile``compose.yaml`。容器监听 `8765`,默认以非 root 用户运行,并把宿主机 `./data` 挂到容器内 `/app/data`
1. 复制 `.env.example``.env`,填入 `APP_ENCRYPTION_KEY` 以及行情 / 模型等初始化配置。密钥不会返回到浏览器。
2. `compose.yaml` 构建时要求带上当前 Git 提交号,避免打出无版本标签的镜像:
```bash
export XIAOBAI_GIT_REV="$(git rev-parse HEAD)"
export XIAOBAI_GIT_SHORT="$(git rev-parse --short=7 HEAD)"
docker compose build
docker compose up -d
```
3. 检查健康接口:
```bash
docker compose ps
curl http://127.0.0.1:8765/api/health
```
健康响应类似 `{"ok": true, "storage": "sqlite", "account_required": true}`
完整的迁移、持久化、防火墙、备份、恢复与正式线构建入口见 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)。`.env` 必须与数据库成对备份;丢失 `APP_ENCRYPTION_KEY` 后无法恢复加密资料。
## 目录与配置
```text
TUSHARE_TOKEN=你的Token
server.py 进程入口
backend/ 服务、路由、数据、任务、LLM
frontend/ 无构建前端(shared + pages
config/ 页面 / API / 任务等注册表
data/ SQLite 与私有数据(数据库文件不进 Git)
runtime/ 本地日志、PID、缓存(不进 Git)
tools/ 启动、验收与构建辅助脚本
游资skills/ 公开问师角色
vendor/ 本地第三方库(含 lunar-python
Dockerfile
compose.yaml
.env.example 环境变量模板(复制为 .env 后填写)
```
`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置,密钥不会返回到浏览器。后台刷新只在交易时段更新 SQLite 快照,不会主动刷新或重绘用户页面;用户点击页面“刷新”时读取最新快照。管理员也可点“后台刷新”立即启动一次后台同步,当前页面仍保持不变
管理员通过页面右上角「系统管理」保存公共 Tushare Token、平台主/辅助模型、会员每日额度和后台刷新开关。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
普通用户在账号设置中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员自动使用平台模型;管理员可在“系统管理”中手动开通、续期、停用会员。平台模型受管理员设置的每日调用次数限制,管理员账号始终可用。
普通用户在账号设置中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员可开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
Tushare 各接口有独立积分权限。程序优先使用 `limit_list_d` 获取涨跌停明细;该接口不可用时,会尝试通过日线和每日涨跌停价格推算。
相关文档:
## 隔离实时聚合验证
- [ARCHITECTURE.md](ARCHITECTURE.md) — 模块边界
- [docs/README.md](docs/README.md) — 交接手册入口
- [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md) — Docker 部署、备份与恢复
- [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) — 第三方授权(含问天历法库)
- [AGENTS.md](AGENTS.md) — 维护约束
`backend/data/realtime.py`用于验证东方财富、同花顺和选股宝网页数据源。它不写入 SQLite 主行情快照,也不参与情绪评分或智能选股;当 Tushare 实时指数权限不可用时,观势会使用东方财富三大指数和板块外显,并继续使用 Tushare 的板块成分内核与个股数据。
## 注意事项与免责声明
登录后可调用:
```text
GET /api/realtime-aggregate/health?sector=元器件
```
返回内容包括东方财富三大指数及板块快照、指数时间差、同花顺和选股宝可用性、每个来源的耗时与错误。盘中指数时间差不超过15秒,收盘后不超过120秒。`ready=true` 仅表示本次验证满足聚合层约束,不代表这些网页内部接口具有长期稳定性或商业使用授权
- 本项目是个人研究与复盘工具,全部数据、指标、候选与文字分析均不构成投资建议、证券推荐或买卖要约。
- 不接券商、不代为下单。交易日志只做手工记录与统计,不代表实际成交。
- 情绪温度、阶段判定、连板梯队、策略筛选等均为基于公开数据的统计与规则计算,不预测走势,不保证收益。
- 「问天」属于传统文化视角的观察工具,不具备预测功能,不得作为投资依据。问天不是永久冻结区:此前只冻结过界面视觉方案,现已解冻,后续数据与功能迁移可以纳入。
- 行情来自第三方接口,可能延迟、缺失或口径调整;不可用时页面会明确提示,请以交易所与券商正式披露为准。
- 不要把服务端口直接暴露到公网。不要把 Token、密码、密钥、数据库或 `.env` 提交进 Git。
- 股市有风险,入市需谨慎。投资决策及其后果由使用者本人承担
-17
View File
@@ -60,22 +60,6 @@ from backend.llm.service import LLMServiceMixin
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(
SystemServiceMixin,
AccountApplicationMixin,
@@ -121,7 +105,6 @@ class DashboardService(
self._system_credentials,
MENTOR_SKILLS_DIR,
PRIVATE_MENTOR_SKILLS_DIR,
lambda: self.token,
)
self.data_gateway = self.container.data_gateway
self.ifind = self.container.ifind
+5 -7
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from collections.abc import Callable
from backend.data import DataGateway, build_data_gateway
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.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
from database import ReviewDatabase
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.realtime import WebRealtimeAggregator
from backend.data.datahub.ifind_proxy import HubIfindProxy
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
from backend.features.market.charts import MarketChartClient
@@ -23,13 +22,13 @@ class ApplicationContainer:
database: ReviewDatabase
repositories: RepositoryBundle
data_gateway: DataGateway
ifind: IfindHttpClient
ifind: HubIfindProxy
screener: ScreenerEngine
strategy_tracking: StrategyTrackingService
alert_service: AlertService
trade_journal: TradeJournalService
mentor_skills: MentorSkillRegistry
realtime_aggregator: WebRealtimeAggregator
realtime_aggregator: HubRealtimeProxy
chart_data: MarketChartClient
jobs: InProcessJobRunner
@@ -39,9 +38,8 @@ def build_application_container(
credentials: dict[str, object],
mentor_skills_dir: Path,
private_mentor_skills_dir: Path,
tushare_token_supplier: Callable[[], str] | None = None,
) -> ApplicationContainer:
data_gateway = build_data_gateway(credentials, tushare_token_supplier)
data_gateway = build_data_gateway(credentials)
repositories = build_repository_bundle(database)
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
return ApplicationContainer(
+12
View File
@@ -1,11 +1,23 @@
from __future__ import annotations
import argparse
import logging
from http.server import ThreadingHTTPServer
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:
configure_logging()
if handler_class is None or service is None:
from backend.application import RequestHandler, SERVICE
+2 -3
View File
@@ -10,9 +10,8 @@ from backend.features.accounts.security import SecretVault
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
return {
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
"ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(),
"ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(),
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
"platform_llm_primary_api_key": str(
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
).strip(),
+15
View File
@@ -0,0 +1,15 @@
from backend.data.datahub.bridge import DatahubAwareTushareClient, DatahubBridge
from backend.data.datahub.client import DatahubClient, DatahubResponse
from backend.data.datahub.errors import DatahubError
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
__all__ = [
"DATASETS",
"DatahubAwareTushareClient",
"DatahubBridge",
"DatahubClient",
"DatahubError",
"DatahubResponse",
"DatahubSettings",
"DatasetFlags",
]
+605
View File
@@ -0,0 +1,605 @@
from __future__ import annotations
import logging
import sys
from threading import Lock
from typing import Any, Callable, ClassVar
from backend.data.datahub.client import DatahubClient, DatahubResponse
from backend.data.datahub.compare import compare_rows
from backend.data.datahub.errors import DatahubError
from backend.data.datahub.native import (
API_TO_DATASET,
filter_calendar_rows,
filter_stock_rows,
project_fields,
to_native_rows,
yyyymmdd,
)
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.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")
ShadowSink = Callable[[dict[str, Any]], None]
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:
"""问天调用栈识别(诊断用)。问天按数据集依赖接入,不再整栈强制旧链路。"""
path = filename.replace("\\", "/")
return module_name.startswith("backend.features.heaven") or "/features/heaven/" in path
def caller_is_heaven(depth: int = 24) -> bool:
frame = sys._getframe(1)
for _ in range(depth):
frame = frame.f_back if frame is not None else None
if frame is None:
return False
name = str(frame.f_globals.get("__name__") or "")
filename = str(frame.f_code.co_filename or "")
if looks_like_heaven(name, filename):
return True
return False
class DatahubBridge:
def __init__(
self,
settings: DatahubSettings,
client: DatahubClient,
shadow_sink: ShadowSink | None = None,
heaven_guard: Callable[[], bool] | None = None,
) -> None:
self.settings = settings
self.client = client
self.shadow_sink = shadow_sink
self.heaven_guard = heaven_guard or caller_is_heaven
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
flags = self.settings.flags("status")
if not flags.read and not flags.shadow:
return None
try:
response = self._require_fresh(self.client.dataset_status(yyyymmdd(trade_date)), "status")
rows = list(response.data or [])
if flags.shadow:
self._emit_shadow(compare_rows("status", [], rows, response.meta))
if flags.read:
return rows
return None
except Exception as exc:
self._log_failure("status", exc)
if flags.shadow:
self._emit_shadow(compare_rows("status", [], [], {}, self._error_text(exc)))
return None
def batches(self, trade_date: str, dataset: str = "") -> list[dict[str, Any]] | None:
flags = self.settings.flags("status")
if not flags.read:
return None
try:
response = self._require_fresh(
self.client.batches(yyyymmdd(trade_date), dataset),
"status",
)
return list(response.data or [])
except Exception as exc:
self._log_failure("status", exc)
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(
self,
api_name: str,
params: dict[str, Any] | None = None,
fields: str = "",
) -> 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)
if dataset:
flags = self.settings.flags(dataset)
if flags.read:
try:
response = self._fetch_dataset(dataset, params or {}, api_name=api_name)
hub_canonical = self._extract_rows(dataset, response, params or {})
hub_rows = to_native_rows(dataset, hub_canonical)
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:
self._log_failure(dataset, exc)
try:
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:
self._log_failure(dataset or api_name, exc)
raise TushareError(self._error_text(exc)) from exc
def _fetch_dataset(self, dataset: str, params: dict[str, Any], api_name: str = "") -> DatahubResponse:
date = yyyymmdd(params.get("trade_date") or params.get("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)
code = str(params.get("ts_code") or params.get("code") or "").strip()
if dataset == "calendar":
if not start or not end:
raise DatahubError("INVALID_ARGUMENT", "calendar requires start_date and end_date")
return self.client.calendar(start, end)
if dataset == "stocks":
return self._paginate(self.client.stocks, {})
fetchers = {
"daily": self.client.daily_bars,
"index_daily": self.client.index_bars,
"valuation": self.client.valuation,
"moneyflow": self.client.moneyflow,
"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]
query: dict[str, Any] = {}
if code:
query["code"] = code
if date and not (params.get("start_date") or params.get("end_date")):
query["date"] = date
else:
if start:
query["from"] = start
if end:
query["to"] = end
if dataset == "daily":
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)
def _paginate(self, fetcher: Callable[..., DatahubResponse], params: dict[str, Any]) -> DatahubResponse:
limit = self.settings.page_limit
offset = 0
rows: list[Any] = []
meta: dict[str, Any] = {}
schema_version = 1
while True:
page = fetcher(**{**params, "limit": limit, "offset": offset})
meta = dict(page.meta)
schema_version = page.schema_version
data = page.data or []
if not isinstance(data, list):
raise DatahubError("INTERNAL", "datahub returned a non-list payload")
rows.extend(data)
if len(data) < limit:
break
offset += limit
if offset > 200_000:
break
return DatahubResponse(data=rows, meta=meta, schema_version=schema_version)
def _extract_rows(
self,
dataset: str,
response: DatahubResponse,
params: dict[str, Any],
) -> list[dict[str, Any]]:
rows = [dict(item) for item in (response.data or [])]
if dataset == "calendar":
return filter_calendar_rows(rows, params)
if dataset == "stocks":
return filter_stock_rows(rows, params)
return rows
def _validate_usable(
self,
dataset: str,
rows: list[dict[str, Any]],
response: DatahubResponse,
require_complete: bool = True,
) -> None:
meta = response.meta or {}
stale_seconds = int(meta.get("staleness_seconds") or 0)
if meta.get("stale") or stale_seconds > self.settings.stale_seconds_max:
raise DatahubError("STALE", f"{dataset} data is stale")
if dataset in EMPTY_FAIL_DATASETS and not 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:
self._validate_usable(dataset, list(response.data or []) if isinstance(response.data, list) else [], response)
return response
def _emit_shadow(self, report: dict[str, Any]) -> None:
safe = redact_value(report, secrets=self.settings.secrets())
LOGGER.info("datahub shadow %s", safe)
if self.shadow_sink is not None:
self.shadow_sink(report)
def _log_failure(self, dataset: str, exc: Exception) -> None:
error = redact_text(self._error_text(exc), self.settings.secrets())
LOGGER.warning("datahub unavailable dataset=%s error=%s", dataset, error)
self._record_route(dataset, "datahub", "unavailable", error)
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:
if isinstance(exc, DatahubError):
text = f"{exc.code}: {exc.message}"
else:
text = str(exc)
return redact_text(text, self.settings.secrets())
def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
ts_code = str(row.get("ts_code") or "").strip()
close = _finite(row.get("close") if row.get("close") not in (None, "") else row.get("price"))
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(
self,
api_name: str,
params: dict[str, Any] | None = None,
fields: str = "",
) -> list[dict[str, Any]]:
return self._bridge.query(api_name, params, fields)
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
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)
+249
View File
@@ -0,0 +1,249 @@
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Callable
from backend.data.datahub.errors import DatahubError
from backend.data.datahub.redact import redact_text
from backend.data.datahub.settings import DatahubSettings
LOGGER = logging.getLogger("xiaobai.datahub")
UrlOpen = Callable[..., Any]
@dataclass(frozen=True)
class DatahubResponse:
data: Any
meta: dict[str, Any] = field(default_factory=dict)
schema_version: int = 1
status: int = 200
class DatahubClient:
def __init__(
self,
settings: DatahubSettings,
urlopen: UrlOpen = urllib.request.urlopen,
) -> None:
self.settings = settings
self._urlopen = urlopen
def health(self) -> DatahubResponse:
return self.get("/v1/health")
def calendar(self, start: str, end: str) -> DatahubResponse:
return self.get("/v1/calendar", {"from": start, "to": end})
def stocks(self, updated_since: str = "", limit: int | None = None, offset: int = 0) -> DatahubResponse:
params: dict[str, Any] = {"offset": offset, "limit": limit or self.settings.page_limit}
if updated_since:
params["updated_since"] = updated_since
return self.get("/v1/stocks", params)
def daily_bars(self, **params: Any) -> DatahubResponse:
return self.get("/v1/bars/daily", params)
def index_bars(self, **params: Any) -> DatahubResponse:
return self.get("/v1/indexes/bars", params)
def valuation(self, **params: Any) -> DatahubResponse:
return self.get("/v1/valuation", params)
def moneyflow(self, **params: Any) -> DatahubResponse:
return self.get("/v1/moneyflow", params)
def auction(self, **params: Any) -> DatahubResponse:
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:
return self.get("/v1/datasets/status", {"date": date})
def batches(self, date: str, dataset: str = "") -> DatahubResponse:
params: dict[str, Any] = {"date": date}
if dataset:
params["dataset"] = dataset
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:
if not self.settings.token:
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
query = {
key: value
for key, value in (params or {}).items()
if value is not None and value != ""
}
url = self.settings.base_url + path
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
attempts = 1 + max(0, self.settings.retries)
last_error: DatahubError | None = None
for attempt in range(attempts):
try:
return self._request(url)
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 post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
if not self.settings.token:
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 = {
"Accept": "application/json",
"X-Datahub-Token": self.settings.token,
"User-Agent": "XiaobaiReviewDatahub/1.0",
}
if data is not None:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
url,
data=data,
headers=headers,
method=method,
)
try:
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
status = int(getattr(response, "status", 200) or 200)
raw = response.read().decode("utf-8")
except TimeoutError as exc:
raise DatahubError("TIMEOUT", "datahub request timed out") from exc
except urllib.error.HTTPError as exc:
body = _read_error_body(exc)
raise _http_error(exc.code, body, self.settings.secrets()) from exc
except urllib.error.URLError as exc:
reason = redact_text(str(getattr(exc, "reason", exc)), self.settings.secrets())
if "timed out" in reason.lower():
raise DatahubError("TIMEOUT", "datahub request timed out") from exc
raise DatahubError("UNAVAILABLE", f"datahub unavailable: {reason}") from exc
payload = _parse_json(raw, self.settings.secrets())
return _as_response(payload, status, self.settings.secrets())
def _parse_json(raw: str, secrets: tuple[str, ...]) -> dict[str, Any]:
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise DatahubError("INTERNAL", "datahub returned invalid json") from exc
if not isinstance(payload, dict):
raise DatahubError("INTERNAL", "datahub returned a non-object payload")
return payload
def _as_response(payload: dict[str, Any], status: int, secrets: tuple[str, ...]) -> DatahubResponse:
error = payload.get("error")
if isinstance(error, dict):
raise _mapped_error(str(error.get("code") or "INTERNAL"), str(error.get("message") or "datahub error"), status)
if status >= 400:
raise DatahubError("UNAVAILABLE", f"datahub http {status}", status)
return DatahubResponse(
data=payload.get("data"),
meta=dict(payload.get("meta") or {}),
schema_version=int(payload.get("schema_version") or 1),
status=status,
)
def _http_error(status: int, payload: dict[str, Any], secrets: tuple[str, ...]) -> DatahubError:
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = str((error or {}).get("code") or "")
message = str((error or {}).get("message") or payload.get("message") or f"datahub http {status}")
message = redact_text(message, secrets)
if status == 401 or code == "UNAUTHORIZED":
return DatahubError("UNAUTHORIZED", message, status)
if status == 404 or code == "DATASET_NOT_PUBLISHED":
return DatahubError("DATASET_NOT_PUBLISHED", message, status)
if status == 400 or code == "INVALID_ARGUMENT":
return DatahubError("INVALID_ARGUMENT", message, status)
if status in {429, 503} or code in {"RATE_LIMITED", "SOURCE_UNAVAILABLE"}:
return DatahubError("UNAVAILABLE", message, status)
return DatahubError(code or "INTERNAL", message, status)
def _mapped_error(code: str, message: str, status: int) -> DatahubError:
if code == "STALE_DATA":
return DatahubError("STALE", message, status)
if code in {"UNAUTHORIZED", "DATASET_NOT_PUBLISHED", "INVALID_ARGUMENT"}:
return DatahubError(code, message, status)
if code in {"RATE_LIMITED", "SOURCE_UNAVAILABLE"}:
return DatahubError("UNAVAILABLE", message, status)
return DatahubError(code or "INTERNAL", message, status)
def _read_error_body(exc: urllib.error.HTTPError) -> dict[str, Any]:
try:
raw = exc.read().decode("utf-8")
payload = json.loads(raw)
return payload if isinstance(payload, dict) else {"message": raw}
except Exception:
return {"message": str(exc)}
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from typing import Any
from backend.data.datahub.native import SCALE_FIELDS, row_key, to_canonical_row, yyyymmdd
NUMERIC_TOLERANCE = 1e-4
CANONICAL_ALIASES = {"volume": "vol"}
def compare_rows(
dataset: str,
legacy_rows: list[dict[str, Any]],
hub_rows: list[dict[str, Any]] | None,
hub_meta: dict[str, Any] | None = None,
hub_error: str | None = None,
fields: str = "",
) -> dict[str, Any]:
hub = hub_rows or []
requested = _requested_fields(fields)
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}
missing_hub = sorted(key for key in legacy_map if key not in hub_map)
missing_legacy = sorted(key for key in hub_map if key not in legacy_map)
value_diffs: list[dict[str, Any]] = []
unit_conversion: list[dict[str, Any]] = []
matched = 0
for key, legacy in legacy_map.items():
hub_row = hub_map.get(key)
if hub_row is None:
continue
field_report = _compare_fields(dataset, legacy, hub_row, requested)
if field_report["unit_conversion"]:
unit_conversion.append({"key": list(key), "fields": field_report["unit_conversion"]})
if field_report["value_diff"]:
value_diffs.append({"key": list(key), "fields": field_report["value_diff"]})
if not field_report["unit_conversion"] and not field_report["value_diff"]:
matched += 1
stale_seconds = int((hub_meta or {}).get("staleness_seconds") or 0)
time_skew = bool((hub_meta or {}).get("stale")) or stale_seconds > 0
return {
"dataset": dataset,
"legacy_rows": len(legacy_rows),
"hub_rows": len(hub),
"matched": matched,
"missing_hub": [list(item) for item in missing_hub[:20]],
"missing_legacy": [list(item) for item in missing_legacy[:20]],
"missing_hub_count": len(missing_hub),
"missing_legacy_count": len(missing_legacy),
"value_diff_count": len(value_diffs),
"unit_conversion_count": len(unit_conversion),
"value_diffs": value_diffs[:20],
"unit_conversion": unit_conversion[:20],
"time_skew": time_skew,
"staleness_seconds": stale_seconds,
"published_at": (hub_meta or {}).get("published_at"),
"trade_date": yyyymmdd((hub_meta or {}).get("trade_date")),
"hub_error": hub_error,
"fields_compared": sorted(requested) if requested is not None else None,
"equal": (
not hub_error
and not missing_hub
and not missing_legacy
and not value_diffs
and not unit_conversion
and not time_skew
),
}
def _align_hub_row(row: dict[str, Any]) -> dict[str, Any]:
aligned = dict(row)
if "volume" in aligned and "vol" not in aligned:
aligned["vol"] = aligned.get("volume")
return aligned
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)
hub_canonical = _hub_canonical(dataset, hub)
native_hub = _align_hub_row(hub)
value_diff: list[dict[str, Any]] = []
unit_conversion: list[dict[str, Any]] = []
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 {}
for field in sorted(keys):
left = canonical_legacy.get(field)
right = hub_canonical.get(field)
if _same(left, right):
continue
native_left = legacy.get(field)
hub_raw = native_hub.get(field)
if field in scales and _near(_optional(native_left), _optional(hub_raw)):
unit_conversion.append(
{"field": field, "legacy": native_left, "hub": hub_raw, "reason": "unit_conversion"}
)
continue
value_diff.append({"field": field, "legacy": left, "hub": right, "reason": "value_diff"})
return {"value_diff": value_diff, "unit_conversion": unit_conversion}
def _hub_canonical(dataset: str, row: dict[str, Any]) -> dict[str, Any]:
"""Hub API rows are already canonical; only align field names."""
aligned = dict(row)
if "volume" in aligned and "vol" not in aligned:
aligned["vol"] = aligned.get("volume")
if dataset == "calendar":
is_open = aligned.get("is_open")
aligned["is_open"] = 1 if is_open in (True, 1, "1", "Y", "y") else 0
aligned["cal_date"] = yyyymmdd(aligned.get("cal_date"))
aligned["pretrade_date"] = yyyymmdd(aligned.get("pretrade_date")) or None
aligned["exchange"] = str(aligned.get("exchange") or "SSE")
return aligned
def _same(left: Any, right: Any) -> bool:
if left in (None, "") and right in (None, ""):
return True
if isinstance(left, (int, float)) or isinstance(right, (int, float)):
return _near(_optional(left), _optional(right))
return str(left or "") == str(right or "")
def _near(left: float | None, right: float | None) -> bool:
if left is None and right is None:
return True
if left is None or right is None:
return False
return abs(left - right) <= max(NUMERIC_TOLERANCE, abs(left) * 1e-9, abs(right) * 1e-9)
def _optional(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
class DatahubError(RuntimeError):
def __init__(self, code: str, message: str, status: int | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status = status
def __str__(self) -> str:
return f"{self.code}: {self.message}"
+140
View File
@@ -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 []
+204
View File
@@ -0,0 +1,204 @@
from __future__ import annotations
from typing import Any
from backend.data.numbers import finite_number
AMOUNT_THOUSAND_YUAN = 1000.0
AMOUNT_WAN_YUAN = 10000.0
VOLUME_LOT = 100.0
API_TO_DATASET = {
"trade_cal": "calendar",
"stock_basic": "stocks",
"daily": "daily",
"daily_basic": "valuation",
"index_daily": "index_daily",
"moneyflow": "moneyflow",
"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 = {
"daily": {"vol": VOLUME_LOT, "amount": AMOUNT_THOUSAND_YUAN},
"index_daily": {"vol": VOLUME_LOT, "amount": AMOUNT_THOUSAND_YUAN},
"valuation": {"total_mv": AMOUNT_WAN_YUAN, "circ_mv": AMOUNT_WAN_YUAN},
"moneyflow": {
"buy_sm_amount": AMOUNT_WAN_YUAN,
"sell_sm_amount": AMOUNT_WAN_YUAN,
"buy_md_amount": AMOUNT_WAN_YUAN,
"sell_md_amount": AMOUNT_WAN_YUAN,
"buy_lg_amount": AMOUNT_WAN_YUAN,
"sell_lg_amount": AMOUNT_WAN_YUAN,
"buy_elg_amount": AMOUNT_WAN_YUAN,
"sell_elg_amount": AMOUNT_WAN_YUAN,
"net_mf_amount": 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,
},
}
def yyyymmdd(value: Any) -> str:
return str(value or "").replace("-", "")[:8]
def to_native_rows(dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [to_native_row(dataset, row) for row in rows]
def to_native_row(dataset: str, row: dict[str, Any]) -> dict[str, Any]:
if dataset == "calendar":
is_open = row.get("is_open")
return {
"exchange": str(row.get("exchange") or "SSE"),
"cal_date": yyyymmdd(row.get("cal_date")),
"is_open": 1 if is_open in (True, 1, "1", "Y", "y") else 0,
"pretrade_date": yyyymmdd(row.get("pretrade_date")) or None,
}
converted = dict(row)
converted.pop("batch_id", None)
if "volume" in converted and "vol" not in converted:
converted["vol"] = converted.pop("volume")
elif "volume" in converted:
converted.pop("volume", None)
scales = SCALE_FIELDS.get(dataset) or {}
for field, factor in scales.items():
if field in converted:
converted[field] = _unscale(converted.get(field), factor)
if dataset == "stocks":
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
def to_canonical_row(dataset: str, row: dict[str, Any]) -> dict[str, Any]:
if dataset == "calendar":
is_open = row.get("is_open")
return {
"exchange": str(row.get("exchange") or "SSE"),
"cal_date": yyyymmdd(row.get("cal_date")),
"is_open": 1 if is_open in (True, 1, "1", "Y", "y") else 0,
"pretrade_date": yyyymmdd(row.get("pretrade_date")) or None,
}
converted = dict(row)
if "volume" in converted and "vol" not in converted:
converted["vol"] = converted.pop("volume")
scales = SCALE_FIELDS.get(dataset) or {}
for field, factor in scales.items():
if field in converted:
converted[field] = _scale(converted.get(field), factor)
return converted
def row_key(dataset: str, row: dict[str, Any]) -> tuple[str, ...]:
if dataset == "calendar":
return (yyyymmdd(row.get("cal_date")),)
if dataset == "stocks":
return (str(row.get("ts_code") or "").upper(),)
if dataset == "status":
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")))
def project_fields(rows: list[dict[str, Any]], fields: str) -> list[dict[str, Any]]:
keys = [item.strip() for item in str(fields or "").split(",") if item.strip()]
if not keys:
return rows
return [{key: row.get(key) for key in keys} for row in rows]
def filter_stock_rows(rows: list[dict[str, Any]], params: dict[str, Any] | None) -> list[dict[str, Any]]:
payload = params or {}
ts_code = str(payload.get("ts_code") or "").strip().upper()
status = str(payload.get("list_status") or "").strip()
name = str(payload.get("name") or "").strip()
filtered = rows
if ts_code:
filtered = [row for row in filtered if str(row.get("ts_code") or "").upper() == ts_code]
if status:
filtered = [row for row in filtered if str(row.get("list_status") or status) == status]
if name:
filtered = [row for row in filtered if name.casefold() in str(row.get("name") or "").casefold()]
return filtered
def filter_calendar_rows(rows: list[dict[str, Any]], params: dict[str, Any] | None) -> list[dict[str, Any]]:
payload = params or {}
if payload.get("is_open") in (1, "1", True):
return [row for row in rows if int(row.get("is_open") or 0) == 1]
if payload.get("is_open") in (0, "0", False):
return [row for row in rows if int(row.get("is_open") or 0) == 0]
return rows
def _scale(value: Any, factor: float) -> float | None:
number = _optional_number(value)
if number is None:
return None
return number * factor
def _unscale(value: Any, factor: float) -> float | None:
number = _optional_number(value)
if number is None or factor == 0:
return None
return number / factor
def _optional_number(value: Any) -> float | None:
if value in (None, ""):
return None
number = finite_number(value, default=float("nan"))
if number != number:
return None
return number
+180
View File
@@ -0,0 +1,180 @@
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]
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
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
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any
SECRET_HINTS = (
"token",
"password",
"secret",
"key",
"authorization",
"credential",
"cookie",
)
def redact_value(value: Any, key: str = "", secrets: tuple[str, ...] = ()) -> Any:
lowered = key.lower()
if any(part in lowered for part in SECRET_HINTS):
return "***"
if isinstance(value, dict):
return {
str(item_key): redact_value(item_value, str(item_key), secrets)
for item_key, item_value in value.items()
}
if isinstance(value, list):
return [redact_value(item, key, secrets) for item in value]
text = str(value) if value is not None and not isinstance(value, (int, float, bool)) else value
if isinstance(text, str):
return redact_text(text, secrets)
return value
def redact_text(text: str, secrets: tuple[str, ...] = ()) -> str:
redacted = text
for secret in secrets:
if secret:
redacted = redacted.replace(secret, "***")
return redacted
+57
View File
@@ -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()
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping
from backend.bootstrap.config import APP_DIR
DATASETS = (
"calendar",
"stocks",
"daily",
"index_daily",
"valuation",
"moneyflow",
"auction",
"limit_events",
"popularity",
"dragon_tiger",
"sector_daily",
"quotes",
"index_quotes",
"intraday",
"status",
)
ENV_DATASET = {
"calendar": "CALENDAR",
"stocks": "STOCKS",
"daily": "DAILY",
"index_daily": "INDEX_DAILY",
"valuation": "VALUATION",
"moneyflow": "MONEYFLOW",
"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",
}
DEFAULT_CONFIG_PATH = APP_DIR / "config" / "datahub.config.json"
def _truthy(value: Any) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _int(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class DatasetFlags:
name: str
read: bool = False
shadow: bool = False
@dataclass(frozen=True)
class DatahubSettings:
base_url: str
token: str
timeout_seconds: int = 8
retries: int = 1
page_limit: int = 5000
stale_seconds_max: int = 86400
datasets: dict[str, DatasetFlags] | None = None
def flags(self, dataset: str) -> DatasetFlags:
mapped = self.datasets or {}
return mapped.get(dataset) or DatasetFlags(dataset)
def any_enabled(self) -> bool:
return any(item.read or item.shadow for item in (self.datasets or {}).values())
def secrets(self) -> tuple[str, ...]:
return tuple(item for item in (self.token,) if item)
@classmethod
def load(
cls,
path: Path | None = None,
environ: Mapping[str, str] | None = None,
credentials: Mapping[str, object] | None = None,
) -> "DatahubSettings":
config_path = path or DEFAULT_CONFIG_PATH
payload: dict[str, Any] = {}
if config_path.is_file():
payload = json.loads(config_path.read_text(encoding="utf-8"))
env = dict(os.environ if environ is None else environ)
creds = dict(credentials or {})
dataset_flags: dict[str, DatasetFlags] = {}
raw_datasets = payload.get("datasets") or {}
for name in DATASETS:
item = raw_datasets.get(name) or {}
env_key = ENV_DATASET[name]
read = _truthy(env.get(f"DATAHUB_READ_{env_key}")) if f"DATAHUB_READ_{env_key}" in env else bool(item.get("read"))
shadow = (
_truthy(env.get(f"DATAHUB_SHADOW_{env_key}"))
if f"DATAHUB_SHADOW_{env_key}" in env
else bool(item.get("shadow"))
)
dataset_flags[name] = DatasetFlags(name, read=read, shadow=shadow)
token = str(
env.get("DATAHUB_TOKEN")
or creds.get("datahub_token")
or payload.get("token")
or ""
).strip()
base_url = str(
env.get("DATAHUB_BASE_URL")
or creds.get("datahub_base_url")
or payload.get("base_url")
or "http://127.0.0.1:8766"
).strip().rstrip("/")
return cls(
base_url=base_url,
token=token,
timeout_seconds=_int(env.get("DATAHUB_TIMEOUT") or payload.get("timeout_seconds"), 8),
retries=max(0, _int(env.get("DATAHUB_RETRIES") or payload.get("retries"), 1)),
page_limit=max(1, _int(payload.get("page_limit"), 5000)),
stale_seconds_max=max(0, _int(payload.get("stale_seconds_max"), 86400)),
datasets=dataset_flags,
)
+50 -22
View File
@@ -1,40 +1,71 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from backend.data.contracts import DataUsage
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.providers import IfindProvider, TushareProvider
from backend.data.providers import IfindProvider
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.providers.tushare_client import TushareClient
from backend.data.realtime import WebRealtimeAggregator
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
from backend.features.market.charts import MarketChartClient
@dataclass(frozen=True)
class DataGateway:
policy: DataSourcePolicy
quality: DataQualityGate
tushare_provider: TushareProvider
ifind_provider: IfindProvider
chart_data: MarketChartClient
realtime_observer: WebRealtimeAggregator
realtime_observer: HubRealtimeProxy
datahub: DatahubBridge
@property
def ifind(self) -> IfindHttpClient:
def ifind(self) -> HubIfindProxy:
return self.ifind_provider.client
def tushare(
self,
dataset_id: str = "",
usage: DataUsage = "calculation",
) -> TushareClient:
) -> DatahubAwareTushareClient:
if dataset_id:
self.policy.assert_allowed(dataset_id, "tushare", usage)
return self.tushare_provider.client()
return DatahubAwareTushareClient(self.datahub)
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
return self.datahub.dataset_status(trade_date)
def batches(self, trade_date: str, dataset: str = "") -> list[dict[str, Any]] | None:
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:
self.policy.assert_allowed(dataset_id, provider_id, usage)
@@ -63,21 +94,18 @@ class DataGateway:
def build_data_gateway(
credentials: dict[str, object],
tushare_token_supplier: Callable[[], str] | None = None,
datahub_settings: DatahubSettings | None = None,
) -> 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()
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
datahub_client = DatahubClient(settings)
datahub = DatahubBridge(settings, datahub_client)
ifind = HubIfindProxy(datahub)
return DataGateway(
policy=policy,
quality=DataQualityGate.load(policy),
tushare_provider=TushareProvider(token_supplier),
ifind_provider=IfindProvider(ifind),
chart_data=MarketChartClient(ifind, EastmoneyChartClient()),
realtime_observer=WebRealtimeAggregator(),
chart_data=MarketChartClient(datahub),
realtime_observer=HubRealtimeProxy(datahub),
datahub=datahub,
)
+5 -3
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from backend.data.providers.ifind_client import IfindHttpClient
from typing import Any
class IfindProvider:
def __init__(self, client: IfindHttpClient) -> None:
def __init__(self, client: Any) -> None:
self.client = client
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)
+2
View File
@@ -66,3 +66,5 @@ class TushareClient(
_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()
+76 -4
View File
@@ -3,7 +3,12 @@ from __future__ import annotations
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_helpers import _display_time, _prices_equal
from backend.data.providers.tushare_helpers import (
_display_time,
_optional_number,
_prices_equal,
calendar_is_open,
)
class DailyMarketMixin:
@@ -17,7 +22,11 @@ class DailyMarketMixin:
trade_date = requested
else:
row = requested_rows[0]
trade_date = row["cal_date"] if row.get("is_open") == 1 else row.get("pretrade_date", requested)
trade_date = (
row["cal_date"]
if calendar_is_open(row.get("is_open"))
else row.get("pretrade_date", requested)
)
resolved_rows = self.query(
"trade_cal",
@@ -129,7 +138,66 @@ class DailyMarketMixin:
)
item["capital_trade_date"] = str(capital.get("trade_date") or "")
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
if 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
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
@@ -154,7 +222,11 @@ class DailyMarketMixin:
"turnover_source": row.get("turnover_source") or "provider",
"capital_trade_date": row.get("capital_trade_date") or "",
"amount_billion": round(amount_billion, 2),
"seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0),
"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),
"status": status,
}
+139 -17
View File
@@ -16,6 +16,12 @@ from backend.data.providers.tushare_transport import TushareError
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]:
trade_date, previous_trade_date = self.resolve_trade_context(requested_date)
if self.should_use_realtime(requested_date, trade_date):
@@ -26,11 +32,12 @@ class DashboardMixin:
)
daily = self._load_daily(trade_date)
now = self._now()
if (
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 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(
requested_date,
@@ -98,15 +105,14 @@ class DashboardMixin:
}
return apply_sentiment_to_dashboard(dashboard)
@staticmethod
def should_use_realtime(requested_date: str, trade_date: str) -> bool:
"""Use rt_k for today's open market until end-of-day datasets settle."""
now = datetime.now().astimezone()
def should_use_realtime(self, requested_date: str, trade_date: str) -> bool:
"""Use live quotes for today's open session until official daily settles."""
now = self._now()
today = now.strftime("%Y%m%d")
return (
requested_date == today
and trade_date == today
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(16, 30)
and dt_time(9, 15) <= now.time().replace(tzinfo=None) < dt_time(15, 5)
)
def _realtime_dashboard(
@@ -122,7 +128,7 @@ class DashboardMixin:
)
if not codes:
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:
raise TushareError(f"No realtime data returned for {trade_date}")
@@ -178,14 +184,35 @@ class DashboardMixin:
)
sectors = _build_sectors(limits)
previous_sectors = _build_sectors(previous_limits)
now = datetime.now().astimezone()
now = self._now()
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 = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date),
"source": "tushare",
"source": source_name,
"quote_source": quote_source,
"mode": "realtime",
"realtime": True,
"market_status": market_status,
@@ -193,7 +220,8 @@ class DashboardMixin:
"auto_refresh": False,
"quote_count": len(daily),
"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),
"limits": limits,
@@ -207,6 +235,62 @@ class DashboardMixin:
}
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(
self,
trade_date: str,
@@ -234,7 +318,7 @@ class DashboardMixin:
{"trade_date": previous_trade_date},
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
)
if not basic_rows or not price_limits:
if not basic_rows:
raise TushareError(f"Realtime reference data is incomplete for {trade_date}")
result = {
"basic_rows": basic_rows,
@@ -254,10 +338,9 @@ class DashboardMixin:
ts_code: str,
reference_date: str = "",
) -> dict[str, Any]:
rows = self.query("rt_k", {"ts_code": ts_code})
if not rows:
row = self._realtime_quote_row(ts_code, reference_date)
if not row:
raise TushareError(f"No realtime quote returned for {ts_code}")
row = rows[0]
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
@@ -341,10 +424,24 @@ class DashboardMixin:
"float_share_10k": float_share,
"capital_trade_date": str(capital.get("trade_date") or ""),
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
"data_source": "tushare",
"data_source": str(row.get("source") or "tushare"),
"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(
self,
ts_code: str,
@@ -462,7 +559,7 @@ class DashboardMixin:
for row in reference.get("basic_rows") or []
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 = [
row for row in quotes
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
@@ -608,6 +705,31 @@ def _build_yesterday_performance(
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]]:
result = []
for level in sorted({int(row.get("prior_streak") or 1) for row in rows}, reverse=True):
+60
View File
@@ -6,12 +6,72 @@ from typing import Any
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:
if isinstance(value, (list, tuple, set)):
return "".join(str(item).strip() for item in value if str(item).strip())
return str(value or "").strip()
def _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:
if left is None or right is None:
return False
+71
View File
@@ -59,6 +59,73 @@ class IndexMixin:
}
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)
index_names = {
"000001.SH": "上证指数",
@@ -116,3 +183,7 @@ class IndexMixin:
"average_return_20d": 0,
},
}
def _free_realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
del requested_date
raise TushareError("主网站不再直连免费行情源,请走数据中枢")
+318 -75
View File
@@ -1,11 +1,16 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from backend.data.numbers import finite_number as _number
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:
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
@@ -132,21 +137,63 @@ class ShenwanIndustryMixin:
actual_trade_date = str(daily.get("trade_date") or "")
outer_precise = actual_trade_date == trade_date
outer_error = "" if outer_precise else (
f"No Shenwan daily returned for {sector_code} on {trade_date}"
f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
)
outer_source = "tushare_sw_daily" if outer_precise else "unavailable"
if not outer_precise and allow_realtime_close:
try:
return self._sw_realtime_sector_snapshot(
industry,
members,
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,
previous_trade_date,
finalized=True,
)
except TushareError as exc:
outer_error = f"{outer_error}; realtime close fallback failed: {exc}"
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
else:
try:
snapshot = self._sw_realtime_sector_snapshot(
industry,
members,
trade_date,
previous_trade_date,
finalized=True,
)
snapshot.update({
"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 {
"code": sector_code,
"name": industry.get("l2_name") or daily.get("name") or sector_code,
@@ -173,9 +220,9 @@ class ShenwanIndustryMixin:
"amount_billion": round(amount_billion, 2),
"count": 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",
"outer_source": "tushare_sw_daily" if outer_precise else "unavailable",
"outer_source": outer_source,
"taxonomy": "sw_l2",
"industry": industry,
"trade_date": trade_date,
@@ -189,7 +236,7 @@ class ShenwanIndustryMixin:
"inner_error": inner_error,
"outer_error": outer_error,
"schema_version": 6,
"methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合",
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核优先使用当日成分日线,不调用 rt_sw_k",
}
def _sw_sector_members(
@@ -197,23 +244,100 @@ class ShenwanIndustryMixin:
sector_code: str,
trade_date: str,
) -> list[dict[str, Any]]:
rows = []
for is_new in ("Y", "N"):
rows.extend(
self.query(
"index_member_all",
{"l2_code": sector_code, "is_new": is_new},
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
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"):
rows.extend(
self.query(
"index_member_all",
{"l2_code": sector_code, "is_new": is_new},
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
)
)
except TushareError:
stale = self._read_local_sw_members(sector_code, allow_stale=True) or []
if stale:
return _active_members(stale, trade_date)
raise
reconciled = _reconcile_membership_rows(rows)
self._write_local_sw_members(sector_code, reconciled)
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",
)
deduped: dict[str, dict[str, Any]] = {}
for row in _reconcile_membership_rows(rows):
code = str(row.get("ts_code") or "")
if code and _membership_active_on(row, trade_date):
current = deduped.get(code)
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
deduped[code] = row
return list(deduped.values())
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]]:
"""Return constituents active in a Shenwan L2 industry on the target date."""
@@ -311,37 +435,37 @@ class ShenwanIndustryMixin:
finalized: bool = False,
) -> dict[str, Any]:
sector_code = str(industry.get("l2_code") or "")
sw_rows = self.query(
"rt_sw_k",
{"ts_code": sector_code},
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
sw_row, outer_source, outer_error = self._sw_outer_realtime(
sector_code,
str(industry.get("l2_name") or ""),
trade_date,
finalized=finalized,
)
sw_row = sw_rows[0] if sw_rows else {}
trade_time = str(sw_row.get("trade_time") or "")
quote_date = trade_time[:10].replace("-", "")
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else ""
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
quote_date = str(sw_row.get("quote_date") or trade_time[:10].replace("-", ""))
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else str(sw_row.get("quote_clock") or "")
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
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:
close = _number(sw_row.get("close"))
pre_close = _number(sw_row.get("pre_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") 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
if not outer_precise:
official_change = None
outer_error = ""
if not sw_row:
outer_error = f"No Shenwan realtime index returned for {sector_code}"
elif quote_date != trade_date:
outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}"
elif finalized and (not quote_clock or quote_clock < "15:00:00"):
outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})"
if not sw_row and not outer_error:
outer_error = f"申万行业 {sector_code} 当日外显待盘后正式数据或免费实时源"
elif quote_date and quote_date != trade_date:
outer_error = f"申万实时行业日期是 {quote_date},期望 {trade_date}"
elif finalized and quote_clock and quote_clock < "15:00:00":
outer_error = f"申万行业尚未形成收盘快照({trade_time}"
valid: list[dict[str, Any]] = []
codes: list[str] = []
reference: dict[str, Any] = {}
inner_error = ""
inner_source = "unavailable"
try:
reference = self._load_realtime_reference(trade_date, previous_trade_date)
active_codes = {
@@ -352,20 +476,27 @@ class ShenwanIndustryMixin:
codes = [
str(row.get("ts_code") or "")
for row in members
if str(row.get("ts_code") or "") in active_codes
if str(row.get("ts_code") or "")
]
if codes:
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
for row in quotes:
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
valid.append({**row, "change": (close / previous_close - 1) * 100})
else:
if active_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:
close = _number(row.get("close"))
previous_close = _number(row.get("pre_close"))
if close <= 0 or previous_close <= 0:
continue
valid.append({**row, "change": (close / previous_close - 1) * 100})
if not codes:
inner_error = f"No active Shenwan members returned for {sector_code}"
elif not quotes:
inner_error = f"申万成分实时行情暂不可用:{sector_code}"
except TushareError as 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
valid_codes = {str(item.get("ts_code") or "") for item in valid}
@@ -390,16 +521,18 @@ class ShenwanIndustryMixin:
}
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
market_rows: list[dict[str, Any]] = []
try:
self._ensure_realtime_market_cache(trade_date)
with self._realtime_reference_lock:
market_rows = list(
(self._latest_realtime_market.get(trade_date) or {}).get("rows") or []
)
market_rows = self._ensure_realtime_market_cache(trade_date)
except TushareError as exc:
market_rows = []
inner_precise = False
inner_error = inner_error or str(exc)
message = str(exc)
if "rt_k" in message or "权限" in message:
market_error = "全市场实时行情暂不可用,已避开无权限接口"
else:
market_error = message
if not valid:
inner_precise = False
inner_error = inner_error or market_error
capital_map = {
str(item.get("ts_code") or ""): item
for item in reference.get("capital_rows") or []
@@ -408,20 +541,28 @@ class ShenwanIndustryMixin:
for item in valid:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
volume = _number(item.get("vol"))
if float_share and volume:
# 免费源成交量为股;daily_basic.float_share 为万股。
sector_turnovers.append(volume / float_share / 100)
market_turnovers = []
for item in market_rows:
capital = capital_map.get(str(item.get("ts_code") or ""), {})
float_share = _number(capital.get("float_share"))
if float_share:
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
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
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
relative_turnover = average_turnover / market_turnover if market_turnover else 0
if not relative_turnover:
inner_precise = False
inner_error = inner_error or "Shenwan member relative turnover is unavailable"
delayed = "delayed" in str(inner_source) or any(item.get("delayed") for item in valid)
delay_seconds = max((int(item.get("delay_seconds") or 0) for item in valid), default=0)
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 {
"code": sector_code,
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
@@ -447,9 +588,9 @@ class ShenwanIndustryMixin:
"amount_billion": round(amount_billion, 2),
"count": sum(item["change"] >= 9.5 for item in valid),
"max_streak": 0,
"source": "tushare_rt_sw_k+sw_members_rt_k",
"inner_source": "tushare_sw_members+rt_k",
"outer_source": "tushare_rt_sw_k",
"source": f"{outer_source or 'unavailable'}+{inner_source}",
"inner_source": inner_source,
"outer_source": outer_source or "unavailable",
"taxonomy": "sw_l2",
"industry": industry,
"trade_date": trade_date,
@@ -463,10 +604,96 @@ class ShenwanIndustryMixin:
"precise": inner_precise and outer_precise,
"inner_error": inner_error,
"outer_error": outer_error,
"delayed": delayed,
"delay_seconds": delay_seconds,
"delay_notice": delay_notice,
"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:
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(
members: list[dict[str, Any]],
@@ -568,6 +795,22 @@ def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any
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:
if not rows:
return None
+5 -19
View File
@@ -5,13 +5,14 @@ from typing import Any
from backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_helpers import _moneyflow_payload
class StockMixin:
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
trade_date, _ = self.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
start_date = (end - timedelta(days=400)).strftime("%Y%m%d")
daily = self.query(
"daily",
{"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}
latest_factor = max(factor_map.values(), default=1) or 1
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)
ratio = factor / latest_factor
prices.append(
@@ -56,7 +57,7 @@ class StockMixin:
"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 {}
daily_basic = daily_basics[0] if daily_basics else {}
latest = prices[-1] if prices else {}
@@ -87,22 +88,7 @@ class StockMixin:
"amount_billion": latest.get("amount_billion", 0),
},
"prices": prices,
"moneyflow": {
"net_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_million": round(
(_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount"))
- _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100,
2,
),
"medium_million": round(
(_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100,
2,
),
"small_million": round(
(_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100,
2,
),
},
"moneyflow": _moneyflow_payload(flow),
}
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,
fields: str = "",
) -> 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(
{
"api_name": api_name,
+521 -4
View File
@@ -19,8 +19,22 @@ class RealtimeAggregateError(RuntimeError):
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_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_QUOTE_URL = "https://qt.gtimg.cn/q="
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"
BROWSER_USER_AGENT = (
@@ -134,6 +148,312 @@ class WebRealtimeAggregator:
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
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]]:
raw, cache_age = self._get_text(
TENCENT_INDEX_URL,
@@ -185,11 +505,17 @@ class WebRealtimeAggregator:
if not matched:
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
epoch = int(_number(matched.get("f124")))
quote_time = (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
)
return {
"code": matched.get("f12") or "",
"name": matched.get("f14") or query,
"price": _number(matched.get("f2")),
"close": _number(matched.get("f2")),
"change": _number(matched.get("f3")),
"pct_change": _number(matched.get("f3")),
"change_amount": _number(matched.get("f4")),
"turnover_rate": _number(matched.get("f8")),
"up_count": int(_number(matched.get("f104"))),
@@ -198,10 +524,9 @@ class WebRealtimeAggregator:
"leader_code": matched.get("f140") or "",
"leading_pct": _number(matched.get("f136")),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"quote_time": quote_time,
"trade_time": quote_time,
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
"source": "eastmoney_push2",
"match_query": query,
}
@@ -397,6 +722,198 @@ class WebRealtimeAggregator:
) 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:
text = str(value or "").strip().replace(" ", "")
for suffix in ("板块", "概念", "行业", "", "", "(A股)", "A股)"):
+25 -9
View File
@@ -283,9 +283,9 @@ class HeavenMarketContextMixin:
) -> dict[str, Any] | None:
"""Return the Shenwan L2 sector context for heaven trend.
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照
观势行业层只使用申万二级行业。外显优先使用已发布的 sw_daily
盘中及收盘过渡期使用免费申万实时行情;内核使用数据中枢或免费
实时成分行情。不再调用无权限的 rt_sw_k / rt_k
"""
cache_key = f"{trade_date}:{identifier.strip().lower()}"
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
@@ -299,6 +299,14 @@ class HeavenMarketContextMixin:
and not cached.get("realtime")
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:
return cached
if not self.configured:
@@ -311,8 +319,12 @@ class HeavenMarketContextMixin:
allow_realtime_close=market_mode == "closed",
)
except TushareError as exc:
if cached_valid:
return cached
if cached_lkg:
delayed = dict(cached)
delayed["delayed"] = True
delayed["delay_notice"] = "主备免费行情均暂不可用,显示最近一次真实快照"
delayed["realtime"] = market_mode == "intraday"
return delayed
return {
"name": "",
"code": "",
@@ -323,12 +335,16 @@ class HeavenMarketContextMixin:
"precise": False,
"inner_precise": False,
"outer_precise": False,
"coverage": 0,
"member_count": 0,
"quote_count": 0,
"error": f"申万二级行业数据获取失败:{exc}",
}
if not payload.get("realtime") and payload.get("precise"):
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(
"heaven_sector",
cache_key,
+4 -1
View File
@@ -243,6 +243,7 @@ class HeavenTrendMixin:
"detail": (
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
+ (";延迟快照" if sector.get("delayed") or sector.get("delay_notice") else "")
),
},
{
@@ -341,7 +342,9 @@ class HeavenTrendMixin:
issues.append("行业外显缺少申万官方行情")
if sector and 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("行业内核缺少相对全市场换手活跃度")
stock = stock or {}
+196 -39
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import http.client
import json
import logging
import re
import time
import urllib.error
@@ -13,14 +14,20 @@ from threading import Lock
from typing import Any, ClassVar
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):
pass
DAILY_CHART_LIMIT = 45
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"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -35,55 +42,186 @@ INDEX_SECIDS = {
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:
self.ifind = ifind
self.fallback = fallback
def __init__(self, datahub: Any = None) -> None:
self.datahub = datahub
self.ifind = None
self.fallback = None
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
ifind_code = _stock_market_code(normalized)
try:
return self._ifind_intraday(ifind_code, "stock", normalized)
except (IfindError, ChartDataError):
return self.fallback.stock_intraday(normalized)
hub_chart = self._datahub_intraday(normalized)
if hub_chart is not None:
return hub_chart
raise ChartDataError("分时图数据中枢暂不可用")
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()
if not re.fullmatch(r"\d{6}", normalized):
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()
if normalized not in INDEX_SECIDS:
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]]:
normalized = str(identifier or "").strip().upper()
if not normalized:
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]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
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:
return self._ifind_intraday(normalized, "index", normalized)
except (IfindError, ChartDataError):
return self.fallback.index_intraday(normalized)
chart = self.datahub.try_intraday(code)
except Exception as exc:
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]:
normalized = str(identifier or "").strip().upper()
try:
return self._ifind_intraday(normalized, "board", normalized, name)
except (IfindError, ChartDataError):
return self.fallback.board_intraday(normalized, name)
hub_chart = self._datahub_intraday(normalized)
if hub_chart is not None:
if name:
hub_chart = dict(hub_chart)
hub_chart["name"] = name
return hub_chart
raise ChartDataError("板块分时数据中枢暂不可用")
def _ifind_intraday(
self,
@@ -92,7 +230,7 @@ class MarketChartClient:
identifier: str,
name: str = "",
) -> dict[str, Any]:
if not self.ifind.configured:
if not self.ifind or not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
now = datetime.now().astimezone()
rows: list[dict[str, Any]] = []
@@ -129,7 +267,7 @@ class MarketChartClient:
def _ifind_daily(
self, ifind_code: str, end_date: str, limit: int
) -> list[dict[str, Any]]:
if not self.ifind.configured:
if not self.ifind or not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
compact_end = str(end_date or "").replace("-", "")
if not re.fullmatch(r"\d{8}", compact_end):
@@ -231,9 +369,11 @@ class MarketChartClient:
pass
if not normalized:
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:
if not self.ifind:
return fallback
today = datetime.now().astimezone().date().isoformat()
if trade_date == today:
try:
@@ -305,21 +445,29 @@ class EastmoneyChartClient:
if cached is not None:
return cached
payload = self._request_json(
TRENDS_URL,
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
"ndays": "1",
},
"https://quote.eastmoney.com/",
)
data = payload.get("data") or {}
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
params = {
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
}
last_error: Exception | None = None
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 {}
parsed = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
points = _latest_session(parsed)
if points:
break
if not points:
raise ChartDataError("No intraday chart data returned")
raise ChartDataError("No intraday chart data returned") from last_error
result = {
"entity_type": entity_type,
@@ -433,6 +581,15 @@ class EastmoneyChartClient:
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:
fields = str(raw or "").split(",")
if len(fields) < 8 or " " not in fields[0]:
+388 -49
View File
@@ -14,7 +14,9 @@ from backend.bootstrap.config import (
validate_text,
)
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 (
DEFAULT_RECENT_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_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.sentiment.engine import SENTIMENT_ENGINE_VERSION
@@ -42,6 +44,7 @@ SEARCH_TYPE_LABELS = {
"theme": "题材",
"index": "指数",
}
TODAY_DAILY_UNAVAILABLE_NOTICE = "今日日K暂不可用,仍显示最近收盘K线。"
THS_SEARCH_TYPES = {
"I": ("sector", "行业板块"),
"R": ("sector", "地域板块"),
@@ -58,16 +61,40 @@ class MarketServiceMixin:
self._tushare_client(),
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)
if gateway is not None:
return gateway.tushare()
# Compatibility for isolated legacy unit-test service stubs.
return TushareClient(self.token)
if gateway is None:
raise RuntimeError("数据中枢尚未装配。")
return gateway.tushare()
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]:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
now = self._now()
if (
normalized_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
@@ -79,6 +106,8 @@ class MarketServiceMixin:
if not force:
snapshot = self.database.get_snapshot(normalized_date)
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)
if normalized_date != now.strftime("%Y%m%d"):
snapshot.setdefault("meta", {}).update(
@@ -97,6 +126,8 @@ class MarketServiceMixin:
"dashboard_request_v1", normalized_date
)
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.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date
@@ -138,6 +169,70 @@ class MarketServiceMixin:
def _display_compact_date(compact: str) -> str:
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(
self, snapshot: dict[str, Any], requested_date: str, reason: str
) -> dict[str, Any]:
@@ -152,16 +247,16 @@ class MarketServiceMixin:
"notice": reason,
}
)
return carried
return self._annotate_data_status(carried)
def _realtime_snapshot_due(
self,
normalized_date: str,
snapshot: dict[str, Any],
) -> 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
now = datetime.now().astimezone()
now = self._now()
local_time = now.time().replace(tzinfo=None)
realtime_start = datetime.strptime("09:15", "%H:%M").time()
morning_end = datetime.strptime("11:35", "%H:%M").time()
@@ -197,19 +292,28 @@ class MarketServiceMixin:
if not self.configured:
raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date)
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived":
raise TushareError(
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回")
meta = dashboard.setdefault("meta", {})
quote_source = str(meta.get("quote_source") or "")
meta["source"] = source
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)
record_count = self._record_count(dashboard)
actual_date = normalize_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)
if actual_date != normalized_date:
dashboard.setdefault("meta", {}).update(
@@ -231,10 +335,37 @@ class MarketServiceMixin:
)
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
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)
if fallback:
actual = str((fallback.get("meta") or {}).get("trade_date") or "")
carried = self._carry_dashboard(
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
fallback,
normalized_date,
self._preparing_display_notice(actual, normalized_date),
)
self.database.finish_sync(
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
@@ -544,7 +675,7 @@ class MarketServiceMixin:
"index_daily",
{
"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,
},
"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),
"volume": float(row.get("vol") or 0),
}
for row in rows[-90:]
for row in rows[-DAILY_CHART_LIMIT:]
]
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:
series = chart_series
except (AttributeError, ChartDataError):
@@ -671,7 +802,7 @@ class MarketServiceMixin:
result = copy.deepcopy(payload)
now = datetime.now().astimezone()
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"}
except (AttributeError, ChartDataError):
pass
@@ -683,27 +814,28 @@ class MarketServiceMixin:
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
}
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 = (
requested_date == today
and actual_date <= today
and now.weekday() < 5
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
and not (official_today and after_close)
)
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):
self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today:
client = self._tushare_client()
try:
resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today:
quote = client.realtime_stock_quote(tushare_code(code), 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)
elif actual_date < today:
result["meta"] = {
**(result.get("meta") or {}),
"notice": TODAY_DAILY_UNAVAILABLE_NOTICE,
}
return self._enrich_stock_detail(result, requested_date)
@staticmethod
def _sanitize_stock_detail_prices(
@@ -817,6 +949,138 @@ class MarketServiceMixin:
"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
def _merge_realtime_stock_detail(
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
@@ -838,23 +1102,29 @@ class MarketServiceMixin:
prices[-1] = realtime_bar
else:
prices.append(realtime_bar)
payload["prices"] = prices[-90:]
payload["prices"] = prices[-DAILY_CHART_LIMIT:]
stock = dict(payload.get("stock") or {})
stock.update(
{
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
"turnover_rate": quote["turnover_rate"],
}
)
updates = {
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
}
quote_turnover = _optional_number(quote.get("turnover_rate"))
if quote_turnover:
updates["turnover_rate"] = quote_turnover
stock.update(updates)
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.get("meta") or {}),
"trade_date": display_date,
"realtime": True,
"notice": "",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
@@ -888,7 +1158,7 @@ class MarketServiceMixin:
intraday_status = "unavailable"
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})
realtime = bool(detail_meta.get("realtime"))
return {
@@ -1140,10 +1410,40 @@ class MarketServiceMixin:
return item["name"], item["sector"] or "其他"
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)
stock = dict(payload.get("stock") 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 = {
item["code"]: item
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)
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]:
result = dict(dashboard)
result["meta"] = {
@@ -1160,7 +1499,7 @@ class MarketServiceMixin:
"storage": "sqlite",
"cached": cached,
}
return result
return self._annotate_data_status(result)
@staticmethod
def _record_count(dashboard: dict[str, Any]) -> int:
+42 -31
View File
@@ -4,7 +4,14 @@ import re
import secrets
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:
@@ -18,9 +25,6 @@ class SystemServiceMixin:
first_encrypted = self.database.get_user_credentials(first_user_id)
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
@@ -34,6 +38,10 @@ class SystemServiceMixin:
if key not in current:
current[key] = value
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):
migrated_models: list[dict[str, str]] = []
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
@@ -56,26 +64,27 @@ class SystemServiceMixin:
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
for row in self.database.list_user_credentials():
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
if "tushare_token" in personal:
personal.pop("tushare_token", None)
if any(key in personal for key in MARKET_SOURCE_SECRET_KEYS):
for key in MARKET_SOURCE_SECRET_KEYS:
personal.pop(key, None)
self.database.save_user_credentials(
int(row["user_id"]), self.vault.encrypt_json(personal)
)
return current
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
sanitized = {
key: value
for key, value in credentials.items()
if key not in MARKET_SOURCE_SECRET_KEYS
}
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
self.database.save_system_setting("credentials", self.vault.encrypt_json(sanitized))
self._system_credentials = dict(sanitized)
@property
def configured(self) -> bool:
return bool(self.token)
return bool(self._datahub_status().get("configured"))
def _credentials(self) -> dict[str, str]:
credentials = getattr(self._request_context, "credentials", {})
@@ -99,7 +108,7 @@ class SystemServiceMixin:
@property
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]:
platform = self._platform_llm_profile()
@@ -130,6 +139,7 @@ class SystemServiceMixin:
),
**self.database.status(),
"jobs": self.jobs.repository.recent(12),
"datahub": self._datahub_status(),
},
"llm": {
"primary_configured": self._profile_configured(platform["primary"]),
@@ -145,21 +155,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]:
current = dict(self._system_credentials)
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
@@ -221,8 +234,6 @@ class SystemServiceMixin:
raise ValueError("会员每日额度应为 1 至 1000。") from exc
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
@@ -242,7 +253,7 @@ class SystemServiceMixin:
llm_access = self.llm_access_status()
return {
"configured": self.configured,
"mode": "tushare" if self.configured else "unavailable",
"mode": "datahub" if self.configured else "unavailable",
"llm_configured": self.llm_configured,
"llm_model": self.llm_primary_model if self.llm_configured else "",
"llm_fallback_configured": self.llm_fallback_configured,
+8 -1
View File
@@ -109,7 +109,14 @@ class HttpTransportMixin:
return {}
if length <= 0 or length > 65536:
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:
relative = unquote(request_path).lstrip("/") or "index.html"
+48
View File
@@ -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
View File
@@ -5,16 +5,7 @@ import time
from datetime import date
from backend.bootstrap.config import normalize_date
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
from backend.jobs.refresh import official_catchup_due, verified_dashboard_result
class JobServiceMixin:
@@ -36,7 +27,7 @@ class JobServiceMixin:
started = self.jobs.submit(
"market.refresh",
key,
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)),
lambda: verified_dashboard_result(self.sync_dashboard(normalized)),
{"trade_date": normalized, "trigger": "administrator"},
)
return {"started": started, "job_key": key if started else ""}
@@ -54,7 +45,15 @@ class JobServiceMixin:
self.jobs.submit(
"market.refresh",
f"realtime:{today}:{bucket}",
lambda: self.sync_dashboard(today),
lambda: verified_dashboard_result(self.sync_dashboard(today)),
{"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)
+47
View File
@@ -0,0 +1,47 @@
# Optional overlay. Does not replace the existing xiaobai-review service.
# Start later (总工部署时) with:
# docker compose -f compose.yaml -f compose.datahub.yaml up -d
#
# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, DATAHUB_ADMIN_PASSWORD, TUSHARE_TOKEN
services:
xiaobai-datahub:
build:
context: ./xiaobai-datahub
dockerfile: Dockerfile
image: xiaobai-datahub:local
container_name: xiaobai-datahub
ports:
- "0.0.0.0:8766:8766/tcp"
env_file:
- ./xiaobai-datahub/.env
environment:
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
DATAHUB_DB_PATH: /app/data/datahub.db
DATAHUB_BACKUP_DIR: /app/data/backups
TZ: Asia/Shanghai
PYTHONUTF8: "1"
volumes:
- type: bind
source: ./datahub-data
target: /app/data
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+20
View File
@@ -13,6 +13,26 @@ services:
- ./.env
environment:
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
# 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
PYTHONUTF8: "1"
volumes:
+6
View File
@@ -12,6 +12,12 @@ These registries describe the approved product surface of the standalone applica
providers, model entry points, CSS layers, and remaining code hotspots.
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
known blocked datasets.
- `datahub.config.json`: official read-only client for `xiaobai-datahub`. Each dataset has its
own `read` / `shadow` flag; official reads default on. `compose.yaml` pins every
`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
fail-closed rules for every canonical data product.
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
+109 -91
View File
@@ -204,25 +204,25 @@
"path": "backend/data/providers/tushare_client.py",
"runtime_role": "stable client facade for primary deterministic market data"
},
{
"provider": "datahub",
"path": "backend/data/datahub/client.py",
"runtime_role": "website-only read path; official EOD, live quotes, and licensed iFinD"
},
{
"provider": "ifind",
"path": "backend/data/providers/ifind_client.py",
"runtime_role": "realtime, charts, snapshots, enrichment"
"path": "xiaobai-datahub/datahub/adapters/ifind.py",
"runtime_role": "licensed iFinD source inside the data hub"
},
{
"provider": "eastmoney",
"path": "backend/features/market/charts.py",
"runtime_role": "display chart fallback"
},
{
"provider": "eastmoney",
"path": "backend/data/realtime.py",
"runtime_role": "isolated realtime observation"
"path": "xiaobai-datahub/datahub/adapters/eastmoney.py",
"runtime_role": "free realtime quotes and shenwan inside the data hub"
},
{
"provider": "tencent",
"path": "backend/data/realtime.py",
"runtime_role": "index observation fallback"
"path": "xiaobai-datahub/datahub/adapters/tencent.py",
"runtime_role": "free index and stock quotes inside the data hub"
}
],
"provider_domains": [
@@ -279,16 +279,28 @@
"compatibility_fallback": "backend/features/market/service.py"
},
{
"client": "IfindHttpClient",
"client": "DatahubClient",
"owner": "backend/data/gateway.py"
},
{
"client": "DatahubBridge",
"owner": "backend/data/gateway.py"
},
{
"client": "DatahubAwareTushareClient",
"owner": "backend/data/gateway.py"
},
{
"client": "HubIfindProxy",
"owner": "backend/data/gateway.py"
},
{
"client": "HubRealtimeProxy",
"owner": "backend/data/gateway.py"
},
{
"client": "MarketChartClient",
"owner": "backend/data/gateway.py"
},
{
"client": "WebRealtimeAggregator",
"owner": "backend/data/gateway.py"
}
],
"heaven_service_owners": {
@@ -313,6 +325,7 @@
"system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py",
"job_refresh_status": "backend/jobs/refresh.py",
"feature_routes": "backend/features/*/routes.py"
},
"numeric_normalization": [
@@ -450,13 +463,13 @@
},
{
"path": "frontend/pages/heaven/page.js",
"bytes": 97268,
"lines": 2070
"bytes": 97770,
"lines": 2079
},
{
"path": "frontend/shared/shell.css",
"bytes": 63659,
"lines": 3763
"bytes": 63733,
"lines": 3767
},
{
"path": "backend/features/heaven/engine.py",
@@ -465,8 +478,13 @@
},
{
"path": "frontend/index.html",
"bytes": 48254,
"lines": 664
"bytes": 48403,
"lines": 665
},
{
"path": "backend/data/providers/tushare_industries.py",
"bytes": 37168,
"lines": 859
},
{
"path": "backend/features/screener/catalog.py",
@@ -478,6 +496,11 @@
"bytes": 35247,
"lines": 2416
},
{
"path": "backend/data/providers/tushare_dashboard.py",
"bytes": 33230,
"lines": 770
},
{
"path": "database.py",
"bytes": 32073,
@@ -488,16 +511,6 @@
"bytes": 31756,
"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",
"bytes": 24521,
@@ -515,13 +528,13 @@
},
{
"path": "frontend/pages/market/preview.js",
"bytes": 18178,
"lines": 446
"bytes": 18230,
"lines": 447
},
{
"path": "backend/features/heaven/trend.py",
"bytes": 16772,
"lines": 370
"bytes": 17005,
"lines": 373
},
{
"path": "backend/features/market/insights_auction_scoring.py",
@@ -530,39 +543,39 @@
},
{
"path": "frontend/pages/market/charts.js",
"bytes": 15311,
"lines": 387
"bytes": 15743,
"lines": 401
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 15063,
"lines": 321
},
{
"path": "frontend/pages/pools/page.html",
"bytes": 14942,
"lines": 235
},
{
"path": "frontend/shared/admin.js",
"bytes": 14836,
"lines": 283
},
{
"path": "backend/features/screener/data_sync.py",
"bytes": 14743,
"lines": 342
},
{
"path": "frontend/shared/admin.js",
"bytes": 14410,
"lines": 268
},
{
"path": "backend/features/heaven/market_context.py",
"bytes": 13681,
"lines": 338
"bytes": 14409,
"lines": 354
},
{
"path": "frontend/shared/session.js",
"bytes": 13219,
"lines": 289
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
},
{
"path": "backend/features/market/insights_auction_data.py",
"bytes": 12829,
@@ -570,8 +583,8 @@
},
{
"path": "backend/features/system/service.py",
"bytes": 12392,
"lines": 254
"bytes": 12180,
"lines": 265
},
{
"path": "backend/features/market/insights_auction.py",
@@ -595,9 +608,14 @@
},
{
"path": "frontend/pages/market/entity-detail.js",
"bytes": 9119,
"bytes": 9139,
"lines": 199
},
{
"path": "backend/data/providers/tushare_daily.py",
"bytes": 9076,
"lines": 232
},
{
"path": "backend/data/providers/tushare_dragon_tiger.py",
"bytes": 9059,
@@ -608,6 +626,11 @@
"bytes": 8562,
"lines": 238
},
{
"path": "backend/data/providers/tushare_indices.py",
"bytes": 8447,
"lines": 189
},
{
"path": "frontend/pages/mentor/page.html",
"bytes": 8357,
@@ -618,16 +641,6 @@
"bytes": 6983,
"lines": 146
},
{
"path": "backend/data/providers/tushare_daily.py",
"bytes": 6837,
"lines": 160
},
{
"path": "backend/application.py",
"bytes": 6751,
"lines": 178
},
{
"path": "backend/features/market/insights_popularity.py",
"bytes": 6739,
@@ -644,8 +657,13 @@
"lines": 81
},
{
"path": "backend/data/providers/tushare_stocks.py",
"bytes": 6244,
"path": "backend/application.py",
"bytes": 6399,
"lines": 161
},
{
"path": "frontend/pages/market/stock-detail.js",
"bytes": 6325,
"lines": 137
},
{
@@ -664,14 +682,9 @@
"lines": 85
},
{
"path": "frontend/pages/market/stock-detail.js",
"bytes": 5690,
"lines": 124
},
{
"path": "backend/data/providers/tushare_indices.py",
"bytes": 5451,
"lines": 118
"path": "backend/data/providers/tushare_stocks.py",
"bytes": 5592,
"lines": 123
},
{
"path": "frontend/pages.config.js",
@@ -703,6 +716,11 @@
"bytes": 4712,
"lines": 106
},
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 4406,
"lines": 124
},
{
"path": "backend/features/market/routes.py",
"bytes": 4276,
@@ -768,36 +786,31 @@
"bytes": 2514,
"lines": 63
},
{
"path": "backend/jobs/service.py",
"bytes": 2337,
"lines": 59
},
{
"path": "backend/features/mentor/routes.py",
"bytes": 2299,
"lines": 57
},
{
"path": "backend/jobs/service.py",
"bytes": 2219,
"lines": 60
"path": "backend/data/providers/tushare_client.py",
"bytes": 2263,
"lines": 70
},
{
"path": "backend/features/screener/regime.py",
"bytes": 2202,
"lines": 53
},
{
"path": "backend/data/providers/tushare_client.py",
"bytes": 2166,
"lines": 68
},
{
"path": "frontend/pages/popularity/page.html",
"bytes": 2165,
"lines": 35
},
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 2083,
"lines": 64
},
{
"path": "frontend/pages/market/breadth.js",
"bytes": 2071,
@@ -808,6 +821,11 @@
"bytes": 1919,
"lines": 45
},
{
"path": "backend/jobs/refresh.py",
"bytes": 1808,
"lines": 48
},
{
"path": "backend/features/system/routes.py",
"bytes": 1791,
@@ -823,6 +841,11 @@
"bytes": 1642,
"lines": 53
},
{
"path": "backend/data/providers/tushare_transport.py",
"bytes": 1592,
"lines": 50
},
{
"path": "backend/features/market/insights.py",
"bytes": 1580,
@@ -833,11 +856,6 @@
"bytes": 1535,
"lines": 39
},
{
"path": "backend/data/providers/tushare_transport.py",
"bytes": 1455,
"lines": 48
},
{
"path": "backend/features/themes/routes.py",
"bytes": 1337,
+1
View File
@@ -2,6 +2,7 @@
"schema_version": 1,
"providers": {
"tushare": {"class": "licensed", "calculation_allowed": true},
"datahub": {"class": "licensed", "calculation_allowed": true},
"ifind": {"class": "licensed", "calculation_allowed": true},
"eastmoney": {"class": "public_web", "calculation_allowed": false},
"tencent": {"class": "public_web", "calculation_allowed": false},
+25
View File
@@ -0,0 +1,25 @@
{
"schema_version": 1,
"base_url": "http://127.0.0.1:8766",
"timeout_seconds": 8,
"retries": 1,
"page_limit": 5000,
"stale_seconds_max": 86400,
"datasets": {
"calendar": { "read": true, "shadow": false },
"stocks": { "read": true, "shadow": false },
"daily": { "read": true, "shadow": false },
"index_daily": { "read": true, "shadow": false },
"valuation": { "read": true, "shadow": false },
"moneyflow": { "read": true, "shadow": false },
"auction": { "read": true, "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
View File
@@ -32,4 +32,4 @@
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它
- 「问天」不是永久冻结区:此前只冻结过界面视觉方案,现已解冻。问天可纳入后续数据与功能迁移,不要再写成“永远不碰”
+2 -2
View File
@@ -213,12 +213,12 @@
{
"provider": "eastmoney",
"path": "realtime_aggregator.py",
"runtime_role": "isolated realtime observation"
"runtime_role": "isolated realtime observation and intraday dashboard fallback"
},
{
"provider": "tencent",
"path": "realtime_aggregator.py",
"runtime_role": "index observation fallback"
"runtime_role": "index observation and intraday quote fallback"
}
],
"llm_entrypoints": [
@@ -320,6 +320,11 @@ PC端统一采用以下固定骨架:
### 6.1 数据源职责
运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务
协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、
重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取
已经归档的真实快照,不能绕回旧提供方直连接口。
| 数据源 | 可用于正式计算 | 主要职责 |
|---|:---:|---|
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
+1
View File
@@ -7,6 +7,7 @@
| 任务 | 说明 | 状态 |
|---|---|---|
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
| 行情刷新误报与旧数据提示 | HEL-412:高级接口未到齐不再记整次失败;今日正式数据晚到时提示当前展示日期 | 施工中 |
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
## 已做完
+2 -2
View File
@@ -29,11 +29,11 @@
- **智能工具类(3 个)**:智能选股、问师、问天。
- **个人类(1 个)**:我的复盘。
其中「问天」是冻结区(见下面的硬规矩)
其中「问天」此前只在全站视觉改造阶段冻结过界面方案,现已解冻;问天可以纳入后续数据与功能迁移,但不等于本阶段要重做视觉
## 几条硬规矩(不能破坏的边界)
- 「问天」板块**冻结区**,任何改动都不许碰它
- 「问天」板块**不是永久冻结区**:此前冻结的是界面视觉方案,现已解冻。问天现有功能与界面不要破坏;后续数据与功能迁移可以纳入,不主动重做视觉
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
+4 -3
View File
@@ -607,12 +607,13 @@
<div class="admin-panel" data-admin-panel="market">
<form id="systemMarketForm" class="settings-section">
<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>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
<label class="form-field"><span>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></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>
<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 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>
<section class="settings-section">
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
+42 -14
View File
@@ -770,11 +770,33 @@
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) {
const page = document.querySelector(".m-page");
if (!page) return;
let top = page.querySelector(".m-top");
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 (!top) {
top = document.createElement("div");
@@ -3380,9 +3402,9 @@
const payload = detail && detail.payload ? detail.payload : {};
const meta = payload.meta || {};
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 : "";
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
return "日线 · 近45根 · 至 " + (displayCompactDate(last) || "--");
}
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
return "分时 · " + (d || "--");
@@ -3680,7 +3702,7 @@
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
const pw = W - padL - padR;
const ph = H - padT - padB;
const prices = (payload.prices || []).slice(-48);
const prices = (payload.prices || []).slice(-45);
if (prices.length < 2) return emptyChart("日线数据暂不可用");
@@ -5197,6 +5219,15 @@
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
}
function datahubStatusText(hub) {
const enabled = number(hub.enabled_reads);
const total = number(hub.total_reads) || enabled;
const fallbacks = hub.fallback_labels || [];
if (fallbacks.length) return " 备用 " + fallbacks.join("、");
if (hub.configured) return " 主线路 " + enabled + "/" + total;
return " 未配置";
}
function renderSystemAdmin(key) {
if (key === "system/members") {
renderSystemMembers();
@@ -5211,15 +5242,16 @@
'<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
'<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>行情快照</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(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" +
"</div></div>" +
'<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("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空则保留现有 Token">', false) +
'<button class="m-btn-primary" type="button" data-system-save-market>保存密钥</button></div>' +
formFieldHtml("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', 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>' +
'<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>' +
'<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>' +
@@ -5518,14 +5550,10 @@
function saveSystemMarket() {
const button = document.querySelector("[data-system-save-market]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", {
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
}).then(function () {
showToast("行情密钥已保存");
loadSystem();
loadSystem().then(function () {
showToast("数据中枢状态已刷新");
}).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败");
showToast(error && error.message ? error.message : "数据中枢状态读取失败");
}).then(function () {
if (button) button.disabled = false;
});
+4 -4
View File
@@ -68,10 +68,10 @@
"/pages/sentiment/page.js?v=20260729-1",
"/pages/pools/page.js?v=20260820-1",
"/pages/market/breadth.js?v=20260803-1",
"/pages/market/charts.js?v=20260803-1",
"/pages/market/entity-detail.js?v=20260803-1",
"/pages/market/stock-detail.js?v=20260803-1",
"/pages/market/preview.js?v=20260806-1",
"/pages/market/charts.js?v=20260908-1",
"/pages/market/entity-detail.js?v=20260908-1",
"/pages/market/stock-detail.js?v=20260908-1",
"/pages/market/preview.js?v=20260908-1",
"/pages/market/search.js?v=20260803-1",
"/pages/market/bindings.js?v=20260803-1",
"/pages/ladder/page.js?v=20260820-1",
+13 -4
View File
@@ -113,9 +113,12 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
document.querySelector("#resetHeavenCalibrationButton"),
].filter(Boolean);
cancelHeavenPerformance();
heavenView?.classList.add("heaven-data-loading");
const blocking = !state.heavenSetup;
if (blocking) heavenView?.classList.add("heaven-data-loading");
if (loadButton) loadButton.disabled = true;
calibrationButtons.forEach((button) => { button.disabled = true; });
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 25_000);
try {
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
state.personalField = null;
@@ -126,7 +129,7 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
if (sector) query.set("sector", sector);
if (stockCode) query.set("stock_code", stockCode);
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 (
requestSequence !== state.heavenRequestSequence
|| 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);
} catch (error) {
if (requestSequence !== state.heavenRequestSequence) return;
showHeavenNotice(error.message || "问天数据加载失败");
showToast(error.message || "问天数据加载失败");
const aborted = error?.payload?.aborted || /abort|超时|cancel/i.test(String(error?.message || ""));
const message = aborted
? "问天数据仍在准备,页面可继续输入和操作"
: (error.message || "问天数据加载失败");
showHeavenNotice(message);
if (!aborted) showToast(message);
if (!state.heavenSetup) renderHeavenWorkspace();
} finally {
window.clearTimeout(timeoutId);
if (requestSequence === state.heavenRequestSequence) {
heavenView?.classList.remove("heaven-data-loading");
if (loadButton) loadButton.disabled = false;
+23 -9
View File
@@ -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() {
const style = getComputedStyle(document.documentElement);
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) {
const canvas = elements.priceChart;
if (!prices?.length) {
const visible = visibleDailyPrices(prices);
if (!visible.length) {
clearPriceChart("暂无日 K 数据");
return;
}
@@ -81,15 +95,15 @@ function drawPriceChart(prices) {
const gap = 12;
const priceBottom = height - bottom - volumeHeight - gap;
const plotWidth = width - left - right;
const highs = prices.map((item) => number(item.high));
const lows = prices.map((item) => number(item.low));
const highs = visible.map((item) => number(item.high));
const lows = visible.map((item) => number(item.low));
const maximum = Math.max(...highs);
const minimum = Math.min(...lows);
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 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);
context.strokeStyle = palette.grid;
@@ -105,7 +119,7 @@ function drawPriceChart(prices) {
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 color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
@@ -117,10 +131,10 @@ function drawPriceChart(prices) {
context.textAlign = "center";
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) => {
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) {
const { context, width, height, palette } = prepareStockPreviewCanvas();
const visible = prices.slice(-45);
const visible = visibleDailyPrices(prices);
const visibleStart = prices.length - visible.length;
const left = 45;
const right = 10;
+2 -2
View File
@@ -113,13 +113,13 @@ function renderEntityDetailMetrics(metrics) {
}
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 open = number(item.open) || close;
const high = Math.max(number(item.high) || close, open, close);
const low = Math.min(number(item.low) || close, open, close);
return { ...item, open, high, low, close };
});
}));
if (!candles.length) {
clearEntityDetailChart("暂无日 K 数据", canvas);
return;
+2 -1
View File
@@ -367,7 +367,8 @@ function selectStockPreviewChart(chart) {
}
} else if ((payload.prices || []).length) {
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);
} else {
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
+25 -12
View File
@@ -20,17 +20,9 @@ async function openStock(code, fallback = null) {
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
setText("detailReason", row.reason || "--");
setText("detailSector", row.sector || "其他");
setText("detailFirst", row.first_time || "--");
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)}`);
setStockBoardFields(row);
setText("chartSource", "正在加载行情");
setText("flowNet", "--");
setText("flowLarge", "--");
setText("flowMedium", "--");
setText("flowSmall", "--");
renderMoneyflow({});
document.querySelector("#reasonInput").value = row.reason || "";
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
@@ -48,11 +40,13 @@ async function openStock(code, fallback = null) {
setText("detailName", stock.name || row.name);
setText("detailPrice", formatNumber(stock.price || row.price, 2));
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
setStockBoardFields({ ...row, ...stock });
renderMoneyflow(payload.moneyflow || {});
renderStockNotes(payload.notes || []);
updateWatchButton();
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 || []));
}
} catch (error) {
@@ -69,7 +63,11 @@ async function selectStockDetailChart(mode) {
syncDetailChartButtons("stock", selected);
if (selected === "daily") {
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));
else clearPriceChart("正在加载日 K 数据");
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() {
const code = state.activeStock?.code;
if (!/^\d{6}$/.test(String(code || ""))) return;
+11 -1
View File
@@ -408,8 +408,18 @@ async function saveReasonOverride(event) {
}
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);
if (!element) continue;
if (!available || value === null || value === undefined || value === "") {
element.textContent = "--";
element.className = "";
continue;
}
element.textContent = formatMoneyMillion(value);
element.className = changeClass(value);
}
+23 -8
View File
@@ -41,11 +41,10 @@ async function openAdminSettings(refreshOnly = false) {
const ifind = data.ifind || {};
const llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
renderDatahubRouteStatus(data.datahub || {});
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 || "");
@@ -55,6 +54,26 @@ async function openAdminSettings(refreshOnly = false) {
}
}
function renderDatahubRouteStatus(hub) {
const box = document.querySelector("#datahubRouteStatus");
if (!box) return;
const label = box.querySelector("span");
const enabled = number(hub.enabled_reads);
const total = number(hub.total_reads) || enabled;
const fallbacks = hub.fallback_labels || [];
if (fallbacks.length) {
box.dataset.tone = "warning";
if (label) label.textContent = `数据中枢主线路 ${enabled}/${total} · 备用 ${fallbacks.length} 类:${fallbacks.join("、")}`;
return;
}
box.dataset.tone = hub.configured ? "success" : "idle";
if (label) {
label.textContent = hub.configured
? `数据中枢主线路 ${enabled}/${total},当前无备用`
: "数据中枢未配置,网站只保留已有真实快照";
}
}
function selectAdminPanel(panel) {
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
document.querySelector("#adminSectionSelect").value = selected;
@@ -184,13 +203,9 @@ async function saveMarketSettings(event) {
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
});
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
showToast("行情配置已保存");
showToast("行情刷新设置已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "系统配置保存失败");
+3
View File
@@ -62,6 +62,9 @@
try {
response = await fetch(url, requestOptions(method, body, options.signal));
} catch (error) {
if (error?.name === "AbortError") {
throw new ApiError("请求已取消或超时", 0, { aborted: true });
}
throw new ApiError(readableRequestError(error), 0, null);
}
const payload = await parseJson(response);
+54 -7
View File
@@ -66,11 +66,18 @@ async function startAdminRefresh() {
const requestedCompact = requestedDate.replaceAll("-", "");
const actualCompact = actualDate.replaceAll("-", "");
const updated = formatTimestamp(meta.updated_at);
if (actualCompact !== requestedCompact || meta.carried_forward) {
const reason = meta.notice ? `${meta.notice}` : "";
setAdminRefreshStatus("warning", `刷新已完成,但没有获取 ${requestedDate}最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert");
showToast("刷新完成,但未获取到所选日期的最新行情");
} else if (meta.notice) {
const freshness = dashboardFreshnessMessage(meta);
if (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate}盘中行情,更新时间 ${updated}`, "circle-check");
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
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");
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
} else {
@@ -105,6 +112,37 @@ async function waitForAdminRefresh(jobKey) {
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) {
state.dashboard = payload;
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("#journalDate").value = selectedDate;
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 (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
@@ -180,7 +222,12 @@ function renderDashboard() {
}
}
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();
renderLadderMini(ladders || []);
+4
View File
@@ -921,6 +921,10 @@ body.sidebar-collapsed .app-main {
text-align: right;
}
.status-bar #updatedAt[data-tone="warning"] {
color: var(--warning);
}
.status-bar .risk-note {
display: block;
+436 -7
View File
@@ -1,23 +1,452 @@
from __future__ import annotations
import copy
import threading
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):
def test_carried_snapshot_is_reported_as_failed_job(self):
result = _verified_dashboard_result(
{"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}}
def test_carried_snapshot_is_usable_not_failed_job(self):
result = verified_dashboard_result(
{
"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.assertEqual(result["error"], "官方涨跌停数据尚未返回")
self.assertNotEqual(result.get("status"), "failed")
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):
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__":
+6 -5
View File
@@ -10,7 +10,7 @@ from database import ReviewDatabase
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(
{
"TUSHARE_TOKEN": " tushare ",
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
"LLM_MODEL": "legacy-model",
}
)
self.assertEqual(result["tushare_token"], "tushare")
self.assertEqual(result["ifind_refresh_token"], "refresh")
self.assertNotIn("tushare_token", result)
self.assertNotIn("ifind_refresh_token", result)
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_model"], "legacy-model")
@@ -45,8 +45,9 @@ class BootstrapContainerTests(unittest.TestCase):
self.assertIs(container.strategy_tracking.repository.database, database)
self.assertIs(container.alert_service.repository.database, database)
self.assertIs(container.trade_journal.repository.database, database)
self.assertIs(container.chart_data.ifind, container.ifind)
self.assertTrue(container.ifind.configured)
self.assertIs(container.ifind, container.data_gateway.ifind)
self.assertIs(container.chart_data.datahub, container.data_gateway.datahub)
self.assertIsNone(container.chart_data.ifind)
if __name__ == "__main__":
+34
View File
@@ -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()
+165 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
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
@@ -72,6 +72,170 @@ class ChartDataProviderTests(unittest.TestCase):
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:
@staticmethod
def _payload(code: str, name: str):
+41 -15
View File
@@ -12,6 +12,7 @@ from backend.data import (
QualityEvidence,
build_data_gateway,
)
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.quality import market_timezone
@@ -35,16 +36,30 @@ class DataGatewayTests(unittest.TestCase):
with self.assertRaises(DataPolicyError):
policy.assert_allowed("market.level2", "unresolved", "display")
def test_gateway_uses_live_token_supplier_and_shared_ifind(self) -> None:
token = {"value": "first"}
gateway = build_data_gateway(
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
lambda: token["value"],
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
settings = DatahubSettings(
base_url="http://127.0.0.1:8766",
token="hub-token",
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
)
self.assertEqual(gateway.tushare().token, "first")
token["value"] = "second"
self.assertEqual(gateway.tushare().token, "second")
self.assertIs(gateway.chart_data.ifind, gateway.ifind)
gateway = build_data_gateway(
{},
datahub_settings=settings,
)
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:
source = (
@@ -54,21 +69,29 @@ class DataGatewayTests(unittest.TestCase):
/ "market"
/ "service.py"
).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)
def test_provider_construction_has_unique_declared_owners(self) -> None:
root = Path(__file__).resolve().parents[1]
owners = {
"EastmoneyChartClient": {"backend/data/gateway.py"},
"IfindHttpClient": {"backend/data/gateway.py"},
"IfindProvider": {"backend/data/gateway.py"},
"MarketChartClient": {"backend/data/gateway.py"},
"TushareClient": {"backend/features/market/service.py"},
"TushareProvider": {"backend/data/gateway.py"},
"WebRealtimeAggregator": {"backend/data/gateway.py"},
"TushareClient": set(),
"DatahubClient": {"backend/data/gateway.py"},
"DatahubAwareTushareClient": {"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}
forbidden = {
"IfindHttpClient": set(),
"EastmoneyChartClient": set(),
"WebRealtimeAggregator": set(),
"TushareProvider": set(),
}
found_forbidden = {name: set() for name in forbidden}
for path in (root / "backend").rglob("*.py"):
relative = path.relative_to(root).as_posix()
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
@@ -78,7 +101,10 @@ class DataGatewayTests(unittest.TestCase):
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
if name in found:
found[name].add(relative)
if name in found_forbidden:
found_forbidden[name].add(relative)
self.assertEqual(found, owners)
self.assertEqual(found_forbidden, forbidden)
provider_source = (root / "backend/data/providers/tushare.py").read_text(
encoding="utf-8"
)
+611
View File
@@ -0,0 +1,611 @@
from __future__ import annotations
import ast
import json
import unittest
from pathlib import Path
from typing import Any
from backend.data import build_data_gateway
from backend.data.datahub.bridge import DatahubAwareTushareClient, DatahubBridge, looks_like_heaven
from backend.data.datahub.client import DatahubClient, DatahubResponse
from backend.data.datahub.compare import compare_rows
from backend.data.datahub.errors import DatahubError
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
ROOT = Path(__file__).resolve().parents[1]
TOKEN = "super-secret-datahub-token"
LEGACY_DAILY = {
"ts_code": "600000.SH",
"trade_date": "20240902",
"open": 10.11,
"high": 10.25,
"low": 10.01,
"close": 10.20,
"pct_chg": 1.2345,
"vol": 1000.0,
"amount": 2000.0,
}
HUB_DAILY = {
"ts_code": "600000.SH",
"trade_date": "20240902",
"open": 10.11,
"high": 10.25,
"low": 10.01,
"close": 10.20,
"pct_chg": 1.2345,
"volume": 100000.0,
"amount": 2000000.0,
}
class FakeLegacy:
def __init__(self, rows: list[dict[str, Any]] | Exception | None = None) -> None:
self.token = "legacy-token"
self.timeout = 30
self.rows = [] if rows is None else rows
self.calls: list[tuple[str, dict[str, Any] | None, str]] = []
def query(self, api_name: str, params: dict[str, Any] | None = None, fields: str = "") -> list[dict[str, Any]]:
self.calls.append((api_name, params, fields))
if isinstance(self.rows, Exception):
raise self.rows
return [dict(row) for row in self.rows]
class FakeClient(DatahubClient):
def __init__(self, error: DatahubError | None = None, response: DatahubResponse | None = None) -> None:
super().__init__(DatahubSettings(base_url="http://127.0.0.1:9", token=TOKEN))
self.error = error
self.response = response or DatahubResponse(
data=[dict(HUB_DAILY)],
meta={"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0},
)
self.paths: list[str] = []
self.calls: list[tuple[str, dict[str, Any]]] = []
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.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")
if self.error:
raise self.error
return self.response
def flags(**enabled: tuple[bool, bool]) -> DatahubSettings:
datasets = {name: DatasetFlags(name) for name in DATASETS}
for name, pair in enabled.items():
datasets[name] = DatasetFlags(name, read=pair[0], shadow=pair[1])
return DatahubSettings(base_url="http://127.0.0.1:9", token=TOKEN, datasets=datasets)
class DatahubBridgeTests(unittest.TestCase):
def setUp(self) -> None:
LEDGER.clear()
def test_default_config_enables_official_reads(self) -> None:
settings = DatahubSettings.load(environ={}, credentials={})
self.assertTrue(settings.any_enabled())
self.assertTrue(all(settings.flags(name).read and not settings.flags(name).shadow for name in DATASETS))
client = FakeClient()
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, client))
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,vol,amount")
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(client.paths, ["/v1/bars/daily"])
self.assertEqual(legacy.calls, [])
self.assertEqual(LEDGER.snapshot()[0]["route"], "datahub")
def test_each_dataset_has_independent_read_flag(self) -> None:
settings = flags(daily=(True, False), auction=(False, False))
self.assertTrue(settings.flags("daily").read)
self.assertFalse(settings.flags("auction").read)
self.assertFalse(any(settings.flags(name).read for name in DATASETS if name != "daily"))
source = (ROOT / "config" / "datahub.config.json").read_text(encoding="utf-8")
self.assertNotIn("master", 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:
shadows: list[dict[str, Any]] = []
client = FakeClient()
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(
legacy,
DatahubBridge(flags(daily=(True, False)), client, shadow_sink=shadows.append),
)
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,vol,amount")
self.assertEqual(rows[0]["vol"], 1000.0)
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(legacy.calls, [])
self.assertEqual(client.paths, ["/v1/bars/daily"])
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
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_legacy,
DatahubBridge(flags(daily=(True, False)), calendar_client),
)
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
self.assertEqual(calendar[0]["is_open"], 1)
self.assertEqual(calendar_legacy.calls, [])
self.assertEqual(calendar_client.paths, ["/v1/query"])
def test_hub_failure_does_not_call_website_legacy(self) -> None:
cases = [
DatahubError("UNAVAILABLE", "down"),
DatahubError("UNAUTHORIZED", "401"),
DatahubError("TIMEOUT", "late"),
DatahubError("EMPTY", "no rows"),
DatahubError("DATASET_NOT_PUBLISHED", "not ready"),
DatahubError("STALE", "old"),
DatahubError("INCOMPLETE", "truncated"),
]
for error in cases:
with self.subTest(error=error.code):
client = FakeClient(error=error)
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
with self.assertRaises(TushareError):
wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
self.assertEqual(legacy.calls, [])
def test_shadow_mode_no_longer_calls_website_tushare(self) -> None:
reports: list[dict[str, Any]] = []
client = FakeClient(
response=DatahubResponse(
data=[dict(LEGACY_DAILY)],
meta={"source": "tushare", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
)
)
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(
legacy,
DatahubBridge(flags(daily=(False, True)), client, shadow_sink=reports.append),
)
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(legacy.calls, [])
self.assertEqual(client.paths, ["/v1/query"])
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
fail_legacy = FakeLegacy([LEGACY_DAILY])
fail_wrapped = DatahubAwareTushareClient(
fail_legacy,
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=reports.append),
)
with self.assertRaises(TushareError):
fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
self.assertEqual(fail_legacy.calls, [])
self.assertNotIn(TOKEN, str(failed.calls))
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})
self.assertTrue(equal["equal"])
unit = compare_rows("daily", [LEGACY_DAILY], [{**HUB_DAILY, "amount": 2000.0, "volume": 1000.0}])
self.assertGreater(unit["unit_conversion_count"], 0)
missing = compare_rows("daily", [LEGACY_DAILY], [])
self.assertEqual(missing["missing_hub_count"], 1)
value = compare_rows("daily", [LEGACY_DAILY], [{**HUB_DAILY, "close": 99.0}])
self.assertEqual(value["value_diff_count"], 1)
skew = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 12})
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:
native = to_native_row("daily", HUB_DAILY)
self.assertEqual(native["vol"], 1000.0)
self.assertEqual(native["amount"], 2000.0)
canonical = to_canonical_row("daily", native)
self.assertEqual(canonical["vol"], 100000.0)
self.assertEqual(canonical["amount"], 2000000.0)
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.assertFalse(looks_like_heaven("backend.features.market.service", "backend/features/market/service.py"))
client = FakeClient()
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(
legacy,
DatahubBridge(flags(daily=(True, False)), client, heaven_guard=lambda: True),
)
rows = wrapped.query("daily", {"trade_date": "20240902"}, "amount")
self.assertEqual(rows[0]["amount"], 2000.0)
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:
off = DatahubBridge(flags(), FakeClient(error=DatahubError("UNAVAILABLE", "down")))
self.assertIsNone(off.dataset_status("20240902"))
reports: list[dict[str, Any]] = []
failed = DatahubBridge(
flags(status=(True, True)),
FakeClient(error=DatahubError("UNAUTHORIZED", "nope")),
shadow_sink=reports.append,
)
self.assertIsNone(failed.dataset_status("20240902"))
self.assertTrue(reports[0]["hub_error"])
ok = DatahubBridge(
flags(status=(True, False)),
FakeClient(response=DatahubResponse(data=[{"dataset": "daily", "state": "published"}], meta={"stale": False, "staleness_seconds": 0})),
)
self.assertEqual(ok.dataset_status("20240902")[0]["state"], "published")
def test_default_gateway_wraps_tushare_without_calling_datahub(self) -> None:
gateway = build_data_gateway({}, datahub_settings=flags())
client = gateway.tushare()
self.assertIsInstance(client, DatahubAwareTushareClient)
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:
violations = []
for path in (ROOT / "backend" / "features").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
names = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
names = [node.module]
for name in names:
if "datahub" in name.split("."):
violations.append(f"{path.relative_to(ROOT)} -> {name}")
self.assertEqual(violations, [])
if __name__ == "__main__":
unittest.main()
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import io
import json
import logging
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from backend.data.datahub.client import DatahubClient
from backend.data.datahub.errors import DatahubError
from backend.data.datahub.redact import redact_text
from backend.data.datahub.settings import DatahubSettings
TOKEN = "super-secret-datahub-token"
class FakeHubState:
def __init__(self) -> None:
self.mode = "ok"
self.hits = 0
self.paths: list[str] = []
STATE = FakeHubState()
class FakeHubHandler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None:
return
def do_GET(self) -> None: # noqa: N802
STATE.hits += 1
parsed = urlparse(self.path)
STATE.paths.append(parsed.path)
token = self.headers.get("X-Datahub-Token", "")
if STATE.mode == "timeout":
raise TimeoutError("simulated timeout")
if token != TOKEN:
self._json(401, {"error": {"code": "UNAUTHORIZED", "message": "missing or invalid X-Datahub-Token"}})
return
if STATE.mode == "unpublished":
self._json(404, {"error": {"code": "DATASET_NOT_PUBLISHED", "message": "daily 19990101 尚未发布", "expected_at": "15:05+08:00"}})
return
if STATE.mode == "empty":
self._json(200, {"schema_version": 1, "data": [], "meta": {"tier": "official", "stale": False, "staleness_seconds": 0}})
return
if STATE.mode == "stale":
self._json(200, {"schema_version": 1, "data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "volume": 100000, "amount": 2000000}], "meta": {"tier": "official", "stale": True, "staleness_seconds": 999999}})
return
if STATE.mode == "invalid":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b"not-json")
return
if parsed.path == "/v1/health":
self._json(200, {"schema_version": 1, "data": {"status": "ok"}, "meta": {"tier": "official", "source": "datahub", "stale": False, "staleness_seconds": 0}})
return
if parsed.path == "/v1/calendar":
self._json(200, {"schema_version": 1, "data": [{"cal_date": "20240902", "is_open": True, "pretrade_date": "20240830"}], "meta": {"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0}})
return
if parsed.path == "/v1/bars/daily":
query = {key: values[-1] for key, values in parse_qs(parsed.query).items()}
self._json(200, {
"schema_version": 1,
"data": [{
"ts_code": "600000.SH",
"trade_date": query.get("date") or "20240902",
"open": 10.11, "high": 10.25, "low": 10.01, "close": 10.20,
"pct_chg": 1.2345, "volume": 100000.0, "amount": 2000000.0, "adj_factor": 1.1,
}],
"meta": {"tier": "official", "trade_date": "20240902", "stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
})
return
if parsed.path == "/v1/datasets/status":
self._json(200, {"schema_version": 1, "data": [{"dataset": "daily", "state": "published", "trade_date": "20240902"}], "meta": {"tier": "official", "stale": False, "staleness_seconds": 0}})
return
self._json(400, {"error": {"code": "INVALID_ARGUMENT", "message": f"unknown endpoint: {parsed.path}"}})
def _json(self, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class DatahubClientTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeHubHandler)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
@classmethod
def tearDownClass(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
def setUp(self) -> None:
STATE.mode = "ok"
STATE.hits = 0
STATE.paths = []
self.client = DatahubClient(DatahubSettings(base_url=self.base, token=TOKEN, retries=1, timeout_seconds=2))
def test_health_envelope(self) -> None:
response = self.client.health()
self.assertEqual(response.schema_version, 1)
self.assertEqual(response.data["status"], "ok")
self.assertIn("stale", response.meta)
def test_missing_and_bad_token_401(self) -> None:
missing = DatahubClient(DatahubSettings(base_url=self.base, token=""))
with self.assertRaises(DatahubError) as raised:
missing.health()
self.assertEqual(raised.exception.code, "NOT_CONFIGURED")
bad = DatahubClient(DatahubSettings(base_url=self.base, token="wrong"))
with self.assertRaises(DatahubError) as raised:
bad.health()
self.assertEqual(raised.exception.code, "UNAUTHORIZED")
self.assertNotIn(TOKEN, str(raised.exception))
def test_unpublished_and_empty_and_stale_codes(self) -> None:
STATE.mode = "unpublished"
with self.assertRaises(DatahubError) as raised:
self.client.daily_bars(date="19990101")
self.assertEqual(raised.exception.code, "DATASET_NOT_PUBLISHED")
STATE.mode = "empty"
response = self.client.daily_bars(date="20240902")
self.assertEqual(response.data, [])
STATE.mode = "stale"
stale = self.client.daily_bars(date="20240902")
self.assertTrue(stale.meta["stale"])
def test_invalid_json_maps_to_internal(self) -> None:
STATE.mode = "invalid"
with self.assertRaises(DatahubError) as raised:
self.client.health()
self.assertEqual(raised.exception.code, "INTERNAL")
def test_timeout_maps_and_retries(self) -> None:
hits = {"n": 0}
def boom(_request, timeout=None):
hits["n"] += 1
raise TimeoutError("late")
client = DatahubClient(
DatahubSettings(base_url=self.base, token=TOKEN, retries=1, timeout_seconds=1),
urlopen=boom,
)
with self.assertRaises(DatahubError) as raised:
client.health()
self.assertEqual(raised.exception.code, "TIMEOUT")
self.assertEqual(hits["n"], 2)
def test_token_never_appears_in_error_text_or_logs(self) -> None:
stream = io.StringIO()
logger = logging.getLogger("xiaobai.datahub")
handler = logging.StreamHandler(stream)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
try:
with self.assertRaises(DatahubError):
DatahubClient(DatahubSettings(base_url=self.base, token="wrong")).health()
blob = stream.getvalue() + redact_text("header " + TOKEN, (TOKEN,))
self.assertNotIn(TOKEN, blob)
self.assertIn("***", redact_text(TOKEN, (TOKEN,)))
finally:
logger.removeHandler(handler)
def test_calendar_and_status_contract(self) -> None:
calendar = self.client.calendar("20240901", "20240902")
self.assertEqual(calendar.data[0]["cal_date"], "20240902")
status = self.client.dataset_status("20240902")
self.assertEqual(status.data[0]["dataset"], "daily")
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -459,6 +459,19 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn('payload.question_preset = state.heartQuestionPreset;', 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__":
unittest.main()
+1 -1
View File
@@ -94,7 +94,7 @@ class GlobalSearchTests(unittest.TestCase):
self.assertIn('event.key.toLowerCase() !== "k"', script)
self.assertIn('openStock(item.id, { code: item.code', 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)
+83 -1
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
import http.client
import json
import unittest
from datetime import datetime
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 server import DashboardService
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.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__":
unittest.main()
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.data.providers.tushare_helpers import _moneyflow_payload
from backend.data.realtime import (
WebRealtimeAggregator,
_normalize_eastmoney_limit_row,
_normalize_eastmoney_stock_quote,
)
from backend.data.providers.tushare_daily import DailyMarketMixin
class MoneyflowPayloadTests(unittest.TestCase):
def test_missing_row_is_not_zero(self) -> None:
payload = _moneyflow_payload(None)
self.assertFalse(payload["available"])
self.assertIsNone(payload["net_million"])
self.assertIsNone(payload["large_million"])
def test_empty_row_is_not_zero(self) -> None:
payload = _moneyflow_payload({})
self.assertFalse(payload["available"])
self.assertIsNone(payload["net_million"])
def test_real_zero_net_is_kept_when_source_exists(self) -> None:
payload = _moneyflow_payload(
{
"net_mf_amount": 0,
"buy_lg_amount": 1,
"sell_lg_amount": 1,
"buy_elg_amount": 0,
"sell_elg_amount": 0,
"buy_md_amount": 0,
"sell_md_amount": 0,
"buy_sm_amount": 0,
"sell_sm_amount": 0,
}
)
self.assertTrue(payload["available"])
self.assertEqual(payload["net_million"], 0)
class LimitOverlayTests(unittest.TestCase):
def test_normalize_limit_keeps_missing_seal_as_none(self) -> None:
row = DailyMarketMixin._normalize_limit(
{
"ts_code": "000737.SZ",
"name": "北方铜业",
"close": 12.3,
"pct_chg": 10,
"amount": 1e8,
"amount_unit": "yuan",
},
"涨停",
)
self.assertIsNone(row["seal_amount_million"])
self.assertEqual(row["first_time"], "--")
def test_overlay_fills_board_times_from_official_list(self) -> None:
mixin = DailyMarketMixin()
mixin._load_limit_lists = lambda trade_date: [
{
"ts_code": "000737.SZ",
"first_time": "09:31:02",
"last_time": "10:18:11",
"fd_amount": 82000000,
"open_times": 1,
"turnover_ratio": 18.4,
}
]
mixin.realtime_aggregator = None
rows = mixin._overlay_board_fields(
[{"ts_code": "000737.SZ", "close": 12.3, "limit_type": "U"}],
"20260908",
)
self.assertEqual(rows[0]["first_time"], "09:31:02")
self.assertEqual(rows[0]["fd_amount"], 82000000)
self.assertEqual(rows[0]["turnover_ratio"], 18.4)
class ShenwanRealtimeSourceTests(unittest.TestCase):
def test_transport_refuses_rt_sw_k(self) -> None:
client = TushareClient(token="demo")
with self.assertRaisesRegex(TushareError, "rt_sw_k is disabled"):
client.query("rt_sw_k", {"ts_code": "801074.SI"})
def test_outer_realtime_uses_hub_sector_quote_not_rt_sw_k(self) -> None:
client = TushareClient(token="demo")
client.query = MagicMock(side_effect=AssertionError("should not call tushare"))
client.try_sector_quote = MagicMock(return_value={
"code": "801074.SI",
"name": "工业金属",
"close": 1234.5,
"pre_close": 1200,
"change": 2.88,
"pct_change": 2.88,
"quote_date": "20260908",
"quote_time": "2026-09-08T14:50:00+08:00",
"source": "eastmoney_sw",
})
row, source, error = client._sw_outer_realtime("801074.SI", "工业金属", "20260908")
self.assertEqual(source, "eastmoney_sw")
self.assertEqual(error, "")
self.assertEqual(row["change"], 2.88)
client.query.assert_not_called()
def test_outer_waiting_state_has_no_permission_error(self) -> None:
client = TushareClient(token="demo")
client.realtime_aggregator = None
row, source, error = client._sw_outer_realtime(
"801074.SI", "工业金属", "20260908", finalized=True
)
self.assertEqual(row, {})
self.assertIn("尚未入库", error)
self.assertNotIn("权限", error)
self.assertNotIn("rt_sw_k", error)
class EastmoneyHelperTests(unittest.TestCase):
def test_limit_pool_row_keeps_board_clock(self) -> None:
parsed = _normalize_eastmoney_limit_row(
{
"c": "000737",
"m": 0,
"n": "北方铜业",
"fbt": 93102,
"lbt": 101811,
"zbc": 1,
"lbc": 2,
"hs": 18.4,
"fund": 82000000,
},
"U",
)
self.assertEqual(parsed["ts_code"], "000737.SZ")
self.assertEqual(parsed["first_time"], "09:31:02")
self.assertEqual(parsed["last_time"], "10:18:11")
self.assertEqual(parsed["fd_amount"], 82000000)
def test_stock_quote_keeps_moneyflow_when_present(self) -> None:
quote = _normalize_eastmoney_stock_quote(
{
"f43": 12.3,
"f60": 11.18,
"f46": 11.2,
"f44": 12.3,
"f45": 11.1,
"f47": 1000,
"f48": 150000000,
"f58": "北方铜业",
"f86": 0,
"f168": 8.5,
"f62": 25000000,
"f78": 3000000,
"f84": -1000000,
},
"000737.SZ",
)
self.assertEqual(quote["net_mf_amount"], 2500)
payload = _moneyflow_payload(quote)
self.assertTrue(payload["available"])
self.assertEqual(payload["net_million"], 25)
@patch.object(WebRealtimeAggregator, "_get_json")
def test_shenwan_quote_uses_eastmoney_90_prefix(self, get_json: MagicMock) -> None:
get_json.return_value = {
"rc": 0,
"data": {
"diff": [
{
"f12": "801074",
"f14": "工业金属",
"f2": 1234.5,
"f3": 2.88,
"f18": 1200,
"f17": 1205,
"f15": 1240,
"f16": 1198,
"f6": 1,
"f124": 1757319000,
}
]
},
}
quote = WebRealtimeAggregator().eastmoney_shenwan_quote("801074.SI")
self.assertEqual(quote["source"], "eastmoney_sw")
self.assertAlmostEqual(quote["change"], 2.88)
params = get_json.call_args.args[1]
self.assertEqual(params["secids"], "90.801074")
class ChartWindowTests(unittest.TestCase):
def test_display_window_is_45_not_250(self) -> None:
from backend.features.market.charts import DAILY_CHART_LIMIT
self.assertEqual(DAILY_CHART_LIMIT, 45)
class MemberQuoteCoverageTests(unittest.TestCase):
def test_prefers_full_hub_market_over_truncated_named_quotes(self) -> None:
client = TushareClient(token="demo")
wanted = [f"{index:06d}.SZ" for index in range(205)]
market = [
{"ts_code": code, "close": 10.0, "pre_close": 9.0}
for code in wanted
]
client.try_market_quotes = MagicMock(return_value=market)
client.try_quotes = MagicMock(return_value=market[:60])
client.realtime_aggregator = MagicMock()
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
self.assertEqual(len(rows), 205)
self.assertEqual(source, "datahub")
client.try_quotes.assert_not_called()
def test_hub_named_quotes_cover_members_when_market_missing(self) -> None:
client = TushareClient(token="demo")
wanted = ["000737.SZ", "000630.SZ"]
client.try_market_quotes = MagicMock(return_value=None)
client.try_quotes = MagicMock(return_value=[
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
{"ts_code": "000630.SZ", "close": 4.5, "pre_close": 4.4},
])
client.realtime_aggregator = MagicMock()
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
self.assertEqual(len(rows), 2)
self.assertEqual(source, "datahub")
client.try_quotes.assert_called()
client.realtime_aggregator.eastmoney_stock_quotes.assert_not_called()
client.realtime_aggregator.tencent_stock_quotes.assert_not_called()
def test_delayed_hub_quotes_are_kept_not_zeroed(self) -> None:
client = TushareClient(token="demo")
delayed = [
{
"ts_code": "000737.SZ",
"close": 12.3,
"pre_close": 11.2,
"delayed": True,
"delay_seconds": 90,
"delay_notice": "主备免费行情均暂不可用,显示 90 秒前的真实快照",
}
]
client.try_market_quotes = MagicMock(return_value=delayed)
client.try_quotes = MagicMock()
client.realtime_aggregator = MagicMock()
rows, source = client._load_member_realtime_quotes(["000737.SZ"], "20260908")
self.assertEqual(source, "datahub_delayed")
self.assertEqual(rows[0]["close"], 12.3)
client.try_quotes.assert_not_called()
def test_ignores_non_member_quotes_from_market_snapshot(self) -> None:
client = TushareClient(token="demo")
client.try_market_quotes = MagicMock(
return_value=[
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
{"ts_code": "600000.SH", "close": 10.0, "pre_close": 9.9},
]
)
client.try_quotes = MagicMock(return_value=[])
client._free_realtime_quotes = MagicMock(return_value=([], "empty"))
rows, _source = client._load_member_realtime_quotes(
["000737.SZ", "000630.SZ"], "20260908"
)
self.assertEqual([row["ts_code"] for row in rows], ["000737.SZ"])
def test_local_sw_members_survive_tushare_outage(self) -> None:
import tempfile
from pathlib import Path
from backend.data.providers import tushare_industries as module
client = TushareClient(token="demo")
stored = [
{
"ts_code": "000737.SZ",
"name": "北方铜业",
"l2_code": "801074.SI",
"in_date": "20200101",
"out_date": "",
}
]
with tempfile.TemporaryDirectory() as tmp:
original = module._SW_MEMBER_DIR
module._SW_MEMBER_DIR = Path(tmp)
try:
client._sw_member_cache.clear()
client._write_local_sw_members("801074.SI", stored)
client.query = MagicMock(side_effect=TushareError("index_member_all down"))
members = client._sw_sector_members("801074.SI", "20260908")
finally:
module._SW_MEMBER_DIR = original
client._sw_member_cache.clear()
self.assertEqual([item["ts_code"] for item in members], ["000737.SZ"])
client.query.assert_not_called()
def test_closed_keeps_daily_inner_when_sw_daily_missing(self) -> None:
client = TushareClient(token="demo")
client.resolve_trade_context = lambda _date: ("20260908", "20260907")
client.sw_stock_industry = MagicMock(
return_value={"l2_code": "801074.SI", "l2_name": "工业金属"}
)
client._sw_sector_members = MagicMock(
return_value=[
{"ts_code": "000737.SZ", "name": "北方铜业"},
{"ts_code": "000630.SZ", "name": "铜陵有色"},
]
)
client._stock_listing_reference = MagicMock(return_value={})
client._load_daily = MagicMock(
return_value=[
{"ts_code": "000737.SZ", "name": "北方铜业", "pct_chg": 2, "amount": 1e8},
{"ts_code": "000630.SZ", "name": "铜陵有色", "pct_chg": 1, "amount": 1e8},
]
)
client._confirmed_suspended_members = MagicMock(return_value=[])
client.query = MagicMock(return_value=[])
client._sw_realtime_sector_snapshot = MagicMock(
side_effect=AssertionError("daily inner should be kept")
)
client.try_sector_quote = MagicMock(return_value={
"code": "801074.SI",
"name": "工业金属",
"change": 1.5,
"pct_change": 1.5,
"quote_date": "20260908",
"quote_time": "2026-09-08T15:00:00+08:00",
"source": "eastmoney_sw",
})
snapshot = client.sw_sector_snapshot(
"000737.SZ", "20260908", allow_realtime_close=True
)
self.assertEqual(snapshot["quote_count"], 2)
self.assertEqual(snapshot["member_count"], 2)
self.assertTrue(snapshot["inner_precise"])
self.assertTrue(snapshot["outer_precise"])
self.assertEqual(snapshot["inner_source"], "tushare_member_daily")
self.assertEqual(snapshot["change"], 1.5)
self.assertNotIn("权限", snapshot.get("outer_error") or "")
self.assertNotIn("rt_sw_k", snapshot.get("outer_error") or "")
+1 -1
View File
@@ -138,7 +138,7 @@ class HttpDispatchContractTests(unittest.TestCase):
self.assertTrue(claimed.isdisjoint(methods))
claimed.update(methods)
self.assertLessEqual(len(path.read_text(encoding="utf-8").splitlines()), line_limit)
self.assertEqual(len(claimed), 27)
self.assertEqual(len(claimed), 28)
if __name__ == "__main__":
+434
View File
@@ -0,0 +1,434 @@
from __future__ import annotations
import ast
import json
import re
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
from backend.data import build_data_gateway
from backend.data.datahub.bridge import DatahubAwareTushareClient, DatahubBridge
from backend.data.datahub.client import DatahubClient
from backend.data.datahub.ifind_proxy import HubIfindProxy
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.providers.tushare_transport import TushareError
from backend.features.market.charts import MarketChartClient
from backend.features.system.service import SystemServiceMixin
from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags
ROOT = Path(__file__).resolve().parents[1]
BLOCKED_HOSTS = (
"api.tushare.pro",
"push2.eastmoney.com",
"push2delay.eastmoney.com",
"push2his.eastmoney.com",
"push2ex.eastmoney.com",
"qt.gtimg.cn",
"hq.sinajs.cn",
"10jqka.com.cn",
"xuangubao.cn",
"quantapi.51ifind.com",
"51ifind.com",
)
LEFTOVER_WEBSITE_FILES = {
"backend/data/providers/ifind_client.py",
"backend/data/realtime.py",
"backend/features/market/charts.py",
"backend/data/providers/tushare_transport.py",
}
HUB_BASE = "http://127.0.0.1:8766"
def _enabled_settings() -> DatahubSettings:
return DatahubSettings(
base_url=HUB_BASE,
token="hub-token",
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
)
class _Resp:
def __init__(self, payload: dict) -> None:
self.status = 200
self._raw = json.dumps(payload).encode("utf-8")
def read(self):
return self._raw
def __enter__(self):
return self
def __exit__(self, *args):
return False
def hub_payload(request) -> dict:
url = str(getattr(request, "full_url", None) or request)
if any(host in url for host in BLOCKED_HOSTS):
raise AssertionError(f"website opened blocked host: {url}")
if HUB_BASE not in url:
raise AssertionError(f"unexpected url: {url}")
path = url.split(HUB_BASE, 1)[1].split("?", 1)[0]
if path == "/v1/bars/daily":
return {
"schema_version": 1,
"data": [
{
"ts_code": "600000.SH",
"trade_date": "20240902",
"open": 10.0,
"high": 10.4,
"low": 9.9,
"close": 10.2,
"volume": 100000,
"amount": 2000000,
}
],
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
}
if path == "/v1/quotes/latest":
return {
"schema_version": 1,
"data": [
{
"ts_code": "600000.SH",
"code": "600000",
"name": "浦发银行",
"close": 10.2,
"price": 10.2,
"pre_close": 10.0,
"open": 10.1,
"high": 10.3,
"low": 9.9,
"vol": 1000,
"amount": 2000000,
"quote_date": "20240902",
"source": "datahub",
}
],
"meta": {"stale": False, "staleness_seconds": 0, "source": "eastmoney"},
}
if path == "/v1/indexes/quotes":
return {
"schema_version": 1,
"data": [
{
"ts_code": "000001.SH",
"code": "000001",
"name": "上证指数",
"price": 3000,
"pct_chg": 1.2,
"quote_time_epoch": 1725249600,
"source": "datahub",
},
{
"ts_code": "399001.SZ",
"code": "399001",
"name": "深证成指",
"price": 9000,
"pct_chg": 0.8,
"quote_time_epoch": 1725249600,
"source": "datahub",
},
{
"ts_code": "399006.SZ",
"code": "399006",
"name": "创业板指",
"price": 1800,
"pct_chg": 0.5,
"quote_time_epoch": 1725249600,
"source": "datahub",
},
],
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"},
}
if path == "/v1/auction":
return {
"schema_version": 1,
"data": [
{
"ts_code": "600000.SH",
"trade_date": "20240902",
"close": 10.2,
"vol": 1000.0,
"amount": 2000.0,
}
],
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
}
if path == "/v1/credentials/ifind":
return {
"schema_version": 1,
"data": {"configured": True, "access_ready": True, "access_expires_at": ""},
"meta": {"source": "ifind"},
}
if path == "/v1/intraday/points":
return {
"schema_version": 1,
"data": {
"entity_type": "stock",
"identifier": "600000",
"code": "600000",
"trade_date": "2024-09-02",
"previous_close": 10.0,
"points": [
{"date": "2024-09-02", "time": "09:30", "close": 10.2, "average": 10.1, "open": 10.1, "high": 10.2, "low": 10.0, "volume": 100, "amount": 1000}
],
},
"meta": {"stale": False, "source": "datahub"},
}
if path == "/v1/query":
body = json.loads(request.data.decode("utf-8") if request.data else "{}")
api_name = body.get("api_name")
if api_name == "ifind_status":
return {"schema_version": 1, "data": [{"configured": True, "access_ready": True, "access_expires_at": ""}], "meta": {"source": "ifind"}}
if api_name == "ifind_wencai":
return {
"schema_version": 1,
"data": [{"股票代码": "000001.SZ", "涨停原因": "重组", "首次涨停时间": "09:42:00"}],
"meta": {"source": "ifind"},
}
if api_name == "ifind_snapshots":
return {
"schema_version": 1,
"data": [
{
"time": "2024-09-02 09:21:00",
"thscode": "000001.SZ",
"latest": 10.5,
"preClose": 10,
"volume": 2000,
"amount": 21000,
}
],
"meta": {"source": "ifind"},
}
if api_name in {"daily", "rt_k", "stk_auction"}:
return {
"schema_version": 1,
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}],
"meta": {"source": "datahub", "stale": False, "row_shape": "tushare"},
}
raise AssertionError(f"unexpected query api: {api_name}")
raise AssertionError(f"unexpected path: {path}")
def blocked_urlopen(request, timeout=None):
return _Resp(hub_payload(request))
class HubExclusiveWebsiteTests(unittest.TestCase):
def test_website_availability_depends_on_hub_not_provider_credentials(self) -> None:
service = SystemServiceMixin()
service._system_credentials = {}
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
self.assertTrue(service.configured)
self.assertEqual(service.token, "datahub")
def test_website_vault_scrubs_historical_provider_credentials(self) -> None:
class Vault:
@staticmethod
def decrypt_json(payload):
if isinstance(payload, str):
return json.loads(payload)
return dict(payload)
@staticmethod
def encrypt_json(payload):
return dict(payload)
class Database:
system = {
"tushare_token": "old-tushare",
"ifind_refresh_token": "old-refresh",
"ifind_access_token": "old-access",
}
personal = {"tushare_token": "old-personal", "llm_primary_model": "model"}
def get_system_setting(self, _name):
return dict(self.system)
@staticmethod
def first_user_id():
return 1
def get_user_credentials(self, _user_id):
return dict(self.personal)
@staticmethod
def list_user_credentials():
return [{"user_id": 1, "encrypted_payload": '{"tushare_token":"old-personal"}'}]
def save_system_setting(self, _name, payload):
self.system = dict(payload)
def save_user_credentials(self, _user_id, payload):
self.personal = dict(payload)
service = SystemServiceMixin()
service.database = Database()
service.vault = Vault()
service.system_lock = threading.Lock()
loaded = service._load_system_credentials({})
for key in ("tushare_token", "ifind_refresh_token", "ifind_access_token"):
self.assertNotIn(key, loaded)
self.assertNotIn(key, service.database.system)
self.assertNotIn(key, service.database.personal)
def test_query_never_calls_website_tushare_transport(self) -> None:
client = FakeClient()
legacy = FakeLegacy(TushareError("website tushare must stay dark"))
wrapped = DatahubAwareTushareClient(
legacy,
DatahubBridge(flags(daily=(True, False)), client),
)
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(legacy.calls, [])
def test_blocked_external_hosts_still_read_hub(self) -> None:
settings = _enabled_settings()
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
legacy = FakeLegacy(TushareError("blocked"))
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, hub_client))
with patch("urllib.request.urlopen", blocked_urlopen):
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,amount")
self.assertEqual(rows[0]["close"], 10.2)
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(legacy.calls, [])
def test_site_features_only_reach_hub_when_external_hosts_blocked(self) -> None:
settings = _enabled_settings()
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
bridge = DatahubBridge(settings, hub_client)
with patch("urllib.request.urlopen", blocked_urlopen):
quotes = bridge.try_quotes(["600000.SH"])
indices = bridge.try_index_quotes()
chart = MarketChartClient(bridge).stock_daily("600000", "20240902")
intraday = MarketChartClient(bridge).stock_intraday("600000")
ifind = HubIfindProxy(bridge)
wencai = ifind.wencai("2024年9月2日涨停股票")
snapshots = ifind.snapshots(["000001.SZ"], ["latest"], "2024-09-02 09:15:00", "2024-09-02 09:22:00")
realtime = HubRealtimeProxy(bridge)
index_rows = realtime.tencent_indices()
stock = realtime.tencent_stock_quote("600000.SH", "20240902")
health = realtime.health_snapshot()
self.assertEqual(quotes[0]["close"], 10.2)
self.assertEqual(len(indices), 3)
self.assertEqual(chart[-1]["close"], 10.2)
self.assertEqual(intraday["source"], "datahub")
self.assertEqual(wencai[0]["股票代码"], "000001.SZ")
self.assertEqual(snapshots[0]["latest"], 10.5)
self.assertEqual([row["code"] for row in index_rows], ["000001", "399001", "399006"])
self.assertEqual(stock["close"], 10.2)
self.assertTrue(health["ready"])
self.assertTrue(ifind.configured)
def test_production_gateway_does_not_construct_external_clients(self) -> None:
source = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
self.assertNotIn("IfindHttpClient", source)
self.assertNotIn("EastmoneyChartClient", source)
self.assertNotIn("WebRealtimeAggregator", source)
self.assertNotIn("TushareProvider", source)
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", source))
self.assertIn("HubIfindProxy", source)
self.assertIn("HubRealtimeProxy", source)
self.assertIn("DatahubAwareTushareClient", source)
facade = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
tree = ast.parse(facade)
cls = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "DatahubAwareTushareClient"
)
methods = {item.name for item in cls.body if isinstance(item, ast.FunctionDef)}
self.assertNotIn("__getattr__", methods)
self.assertIn("query", methods)
self.assertTrue(any(base.id == "DashboardMixin" for base in cls.bases if isinstance(base, ast.Name)))
def test_production_python_does_not_embed_blocked_hosts(self) -> None:
violations = []
for path in (ROOT / "backend").rglob("*.py"):
relative = path.relative_to(ROOT).as_posix()
if relative in LEFTOVER_WEBSITE_FILES:
continue
text = path.read_text(encoding="utf-8")
for host in BLOCKED_HOSTS:
if host in text:
violations.append(f"{relative} -> {host}")
self.assertEqual(violations, [])
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
self.assertNotIn("TushareProvider", gateway_src)
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", gateway_src))
self.assertIn("DatahubAwareTushareClient", gateway_src)
def test_bridge_query_has_no_legacy_call(self) -> None:
source = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
tree = ast.parse(source)
query_fn = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "DatahubBridge"
for item in node.body
if isinstance(item, ast.FunctionDef) and item.name == "query"
)
called = [
ast.unparse(item.func) if hasattr(ast, "unparse") else ""
for item in ast.walk(query_fn)
if isinstance(item, ast.Call)
]
self.assertTrue(any("query_api" in text for text in called))
self.assertFalse(any("legacy_query" in text for text in called))
def test_build_gateway_uses_hub_proxies_without_opening_external_hosts(self) -> None:
settings = _enabled_settings()
with patch("urllib.request.urlopen", blocked_urlopen):
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
gateway.datahub.client = hub_client
rows = gateway.ifind.wencai("涨停")
quotes = gateway.realtime_observer.tencent_indices()
chart = gateway.chart_data.stock_daily("600000", "20240902")
market = gateway.tushare()
market_quotes = market.try_quotes(["600000.SH"])
auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
self.assertEqual(rows[0]["涨停原因"], "重组")
self.assertEqual(len(quotes), 3)
self.assertEqual(chart[-1]["close"], 10.2)
self.assertEqual(market_quotes[0]["close"], 10.2)
self.assertEqual(auction[0]["close"], 10.2)
self.assertIsNone(market.realtime_aggregator)
self.assertEqual(market.token, "datahub")
def test_website_does_not_accept_or_forward_provider_credentials(self) -> None:
proxy = HubIfindProxy(DatahubBridge(_enabled_settings(), FakeClient()))
self.assertFalse(hasattr(proxy, "set_credentials"))
client_source = (ROOT / "backend" / "data" / "datahub" / "client.py").read_text(encoding="utf-8")
application_source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
self.assertNotIn("/v1/credentials", client_source)
self.assertNotIn("ifind_refresh_token", application_source)
def test_site_configuration_depends_on_hub_not_provider_credentials(self) -> None:
service = SystemServiceMixin()
service._system_credentials = {}
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
self.assertTrue(service.configured)
self.assertEqual(service.token, "datahub")
def test_compose_passes_ifind_env_to_hub(self) -> None:
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
standalone = (ROOT / "xiaobai-datahub" / "compose.yaml").read_text(encoding="utf-8")
for text in (overlay, standalone):
self.assertIn('IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"', text)
self.assertIn('IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"', text)
if __name__ == "__main__":
unittest.main()
+54 -3
View File
@@ -6,12 +6,26 @@ from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
from backend.features.market.charts import MarketChartClient
from database import ReviewDatabase
from backend.features.market.insights import MarketInsightsService
from server import DashboardService
class _FakeDailyHub:
def __init__(self, rows: list) -> None:
self.rows = rows
def try_daily_chart(self, code, end_date, limit, dataset="daily"):
return list(self.rows)
def try_quotes(self, codes):
return None
def try_index_quotes(self):
return None
class FakeIfind:
configured = True
@@ -128,13 +142,50 @@ class IfindFeatureTests(unittest.TestCase):
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
def test_ifind_daily_chart_normalizes_change(self):
client = MarketChartClient(FakeIfind(), EastmoneyChartClient())
hub = _FakeDailyHub(
[
{
"trade_date": "2026-07-27",
"open": 10,
"high": 10.5,
"low": 9.8,
"close": 10.2,
"volume": 100,
"amount_billion": 0.01,
"change": 0,
},
{
"trade_date": "2026-07-28",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"close": 10.5,
"volume": 120,
"amount_billion": 0.012,
"change": 2.9412,
},
]
)
client = MarketChartClient(hub)
rows = client.stock_daily("000001", "20260728")
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient())
hub = _FakeDailyHub(
[
{
"trade_date": "2026-07-28",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"close": 10.5,
"volume": 120,
"amount_billion": 0.012,
}
]
)
client = MarketChartClient(hub)
with patch("backend.features.market.charts.datetime", FixedPreopenDatetime):
rows = client.stock_daily("000001", "20260729")
+1 -1
View File
@@ -58,7 +58,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"data-system-edit-model",
"data-system-open-member",
"管理员专区",
"保存密钥",
"刷新状态",
"保存分工",
'location.assign("/login/")',
):
+323
View File
@@ -1,8 +1,16 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from backend.data.providers.tushare_client import TushareClient
from backend.data.providers.tushare_helpers import calendar_is_open
from backend.data.providers.tushare_transport import TushareError
from backend.data.realtime import (
RealtimeAggregateError,
_normalize_eastmoney_quote,
_parse_tencent_stock_quote,
)
class FakeRealtimeClient(TushareClient):
@@ -81,6 +89,72 @@ class FakeRealtimeClient(TushareClient):
raise AssertionError(f"Unexpected API call: {api_name} {params}")
FREE_QUOTES = [
{
"ts_code": "000001.SZ", "name": "", "pre_close": 10.0,
"open": 10.1, "high": 11.0, "low": 10.0, "close": 11.0,
"vol": 1000, "amount": 100000000, "num": 10,
"quote_date": "20260720",
},
{
"ts_code": "000002.SZ", "name": "", "pre_close": 20.0,
"open": 19.5, "high": 20.0, "low": 18.0, "close": 18.0,
"vol": 2000, "amount": 200000000, "num": 20,
"quote_date": "20260720",
},
{
"ts_code": "000003.SZ", "name": "", "pre_close": 30.0,
"open": 31.0, "high": 33.0, "low": 30.0, "close": 32.0,
"vol": 3000, "amount": 300000000, "num": 30,
"quote_date": "20260720",
},
]
class FakeFreeAggregator:
def __init__(self, quotes=None, fail=False):
self.quotes = list(quotes if quotes is not None else FREE_QUOTES)
self.fail = fail
self.calls = 0
def eastmoney_market_quotes(self, expected_date=""):
self.calls += 1
if self.fail:
raise RealtimeAggregateError("eastmoney down")
if expected_date and self.quotes:
dated = [
row for row in self.quotes
if str(row.get("quote_date") or "") == str(expected_date).replace("-", "")
]
if dated:
return dated
return list(self.quotes)
def tencent_market_quotes(self, codes, expected_date=""):
return self.eastmoney_market_quotes(expected_date)
def tencent_stock_quotes(self, codes, expected_date="", minimum=None):
wanted = {str(code).strip() for code in (codes or []) if str(code).strip()}
rows = self.tencent_market_quotes(codes, expected_date)
if not wanted:
return rows
return [row for row in rows if str(row.get("ts_code") or "") in wanted]
def eastmoney_indices(self):
return [
{
"code": "000001",
"name": "上证指数",
"price": 3800.12,
"change": 0.85,
"previous_close": 3768.0,
"amount_billion": 4200.5,
"quote_time": "2026-07-20T10:05:00+08:00",
"source": "eastmoney_push2",
}
]
class RealtimeDashboardTests(unittest.TestCase):
def setUp(self):
TushareClient._realtime_reference_cache.clear()
@@ -130,6 +204,255 @@ class RealtimeDashboardTests(unittest.TestCase):
self.assertEqual(dashboard["meta"]["limit_data_source"], "derived")
self.assertIn("日线数据推算", dashboard["meta"]["notice"])
def test_calendar_open_flag_accepts_string_and_bool(self):
self.assertTrue(calendar_is_open(1))
self.assertTrue(calendar_is_open("1"))
self.assertTrue(calendar_is_open(True))
self.assertFalse(calendar_is_open(0))
self.assertFalse(calendar_is_open("0"))
self.assertFalse(calendar_is_open(False))
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "trade_cal":
return [
{
"cal_date": params.get("start_date"),
"is_open": "1",
"pretrade_date": "20260907",
}
]
return original_query(api_name, params, fields)
self.client.query = query
trade_date, previous = self.client.resolve_trade_context("20260908")
self.assertEqual(trade_date, "20260908")
self.assertEqual(previous, "20260907")
def test_session_clock_uses_realtime_until_official_window(self):
today = "20260908"
self.client.clock = lambda: datetime(
2026, 9, 8, 10, 5, tzinfo=timezone(timedelta(hours=8))
)
self.assertTrue(self.client.should_use_realtime(today, today))
self.client.clock = lambda: datetime(
2026, 9, 8, 16, 10, tzinfo=timezone(timedelta(hours=8))
)
self.assertFalse(self.client.should_use_realtime(today, today))
def test_realtime_dashboard_survives_missing_limit_table(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "stk_limit":
return []
return original_query(api_name, params, fields)
self.client.query = query
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertTrue(dashboard["meta"]["realtime"])
self.assertEqual(dashboard["meta"]["quote_count"], 3)
self.assertEqual(dashboard["overview"]["limit_up_count"], 0)
def test_hub_quotes_used_when_rt_k_denied(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "rt_k":
raise TushareError("没有接口访问权限")
return original_query(api_name, params, fields)
self.client.query = query
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertTrue(dashboard["meta"]["realtime"])
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertEqual(dashboard["meta"]["trade_date"], "2026-07-20")
self.assertEqual(dashboard["meta"]["quote_count"], 3)
self.assertEqual(dashboard["overview"]["limit_up_count"], 1)
self.assertEqual(dashboard["overview"]["limit_down_count"], 1)
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
self.assertIn("数据中枢", dashboard["meta"]["notice"])
def test_hub_quotes_used_when_rt_k_empty(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "rt_k":
return []
return original_query(api_name, params, fields)
self.client.query = query
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
def test_hub_failure_keeps_today_error(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "rt_k":
raise TushareError("数据中枢行情暂不可用")
return original_query(api_name, params, fields)
self.client.query = query
TushareClient._realtime_reference_cache.clear()
with self.assertRaises(TushareError) as ctx:
self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertIn("当天盘中实时行情不可用", str(ctx.exception))
def test_hub_failover_is_invisible_to_website(self):
original_query = self.client.query
def query(api_name, params=None, fields=""):
if api_name == "rt_k":
raise TushareError("没有接口访问权限")
return original_query(api_name, params, fields)
self.client.query = query
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
self.assertIn("数据中枢", dashboard["meta"]["notice"])
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
def test_normalize_eastmoney_quote_maps_units_and_exchange(self):
quote = _normalize_eastmoney_quote(
{
"f12": "600000",
"f13": 1,
"f14": "浦发银行",
"f2": 10.5,
"f5": 12.0,
"f6": 200000000,
"f15": 10.8,
"f16": 10.2,
"f17": 10.3,
"f18": 10.0,
"f124": 1752986700,
}
)
self.assertEqual(quote["ts_code"], "600000.SH")
self.assertEqual(quote["vol"], 1200)
self.assertEqual(quote["close"], 10.5)
self.assertEqual(quote["pre_close"], 10.0)
self.assertEqual(quote["source"], "eastmoney_clist")
def test_parse_tencent_stock_quote_keeps_today_and_units(self):
line = (
'v_sz000001="51~平安银行~000001~11.73~11.70~11.66~346232~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~'
'~20260720100500~0.03~0.26~11.79~11.65~11.73/346232/406045563~346232~40605~0.18~5.24~~11.79~11.65~1.20~'
'2276.29~2276.31~0.49~12.87~10.53~0.95~-3076~11.73~4.43~5.34~~~0.18~40604.5563~0.0000~0~";'
)
quote = _parse_tencent_stock_quote(line)
self.assertEqual(quote["ts_code"], "000001.SZ")
self.assertEqual(quote["quote_date"], "20260720")
self.assertEqual(quote["close"], 11.73)
self.assertEqual(quote["pre_close"], 11.70)
self.assertEqual(quote["vol"], 34623200)
self.assertEqual(quote["amount"], 406050000)
self.assertEqual(quote["source"], "tencent_qt")
def test_datahub_market_quotes_used_before_legacy(self):
calls = []
def try_market_quotes(trade_date):
calls.append(trade_date)
return list(FREE_QUOTES)
self.client.try_market_quotes = try_market_quotes
self.client.realtime_aggregator = FakeFreeAggregator(fail=True)
TushareClient._realtime_reference_cache.clear()
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
self.assertEqual(calls, ["20260720"])
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertEqual(dashboard["meta"]["source"], "datahub")
self.assertEqual(dashboard["meta"]["quote_count"], 3)
self.assertIn("数据中枢", dashboard["meta"]["notice"])
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
from backend.data import build_data_gateway
from backend.data.datahub.bridge import DatahubAwareTushareClient
from backend.data.datahub.client import DatahubResponse
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
quotes = [
{
"ts_code": item["ts_code"],
"name": item["name"],
"pre_close": item["pre_close"],
"open": item["open"],
"high": item["high"],
"low": item["low"],
"close": item["close"],
"vol": item["vol"],
"amount": item["amount"],
"quote_date": "20260720",
}
for item in FREE_QUOTES
]
extras = [
{
"ts_code": f"{index:06d}.SZ",
"name": f"X{index}",
"pre_close": 10.0,
"open": 10.0,
"high": 10.2,
"low": 9.8,
"close": 10.1,
"vol": 100.0,
"amount": 1000.0,
"quote_date": "20260720",
}
for index in range(10, 230)
]
class QuoteHub:
def __init__(self):
self.calls = []
def query_api(self, api_name, params=None, fields=""):
rows = FakeRealtimeClient("tok").query(api_name, params or {}, fields)
return DatahubResponse(
data=rows,
meta={"source": "datahub", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
)
def quotes_latest(self, **params):
return self.get("/v1/quotes/latest", params)
def get(self, path, params=None):
self.calls.append(path)
if path == "/v1/quotes/latest":
return DatahubResponse(
data=quotes + extras,
meta={"stale": False, "staleness_seconds": 0, "source": "eastmoney_clist"},
)
raise AssertionError(path)
datasets = {name: DatasetFlags(name) for name in DATASETS}
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
gateway.datahub.client = QuoteHub()
wrapped = gateway.tushare()
wrapped.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
wrapped.realtime_aggregator = FakeFreeAggregator(fail=True)
DatahubAwareTushareClient._realtime_reference_cache.clear()
dashboard = wrapped.dashboard("20260720")
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
self.assertFalse(hasattr(wrapped, "_legacy"))
if __name__ == "__main__":
unittest.main()
+318 -9
View File
@@ -5,6 +5,10 @@ import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
from backend.data.providers.tushare_client import TushareError
from backend.data.realtime import RealtimeAggregateError
from backend.features.market.charts import ChartDataError
from backend.features.market.service import TODAY_DAILY_UNAVAILABLE_NOTICE
from server import DashboardService
@@ -17,6 +21,10 @@ class DetailDatabaseStub:
def list_notes(user_id, code=""):
return []
@staticmethod
def get_snapshot(trade_date):
return {}
class RealtimeClientStub:
quote_calls = 0
@@ -45,6 +53,18 @@ class RealtimeClientStub:
}
class DataGatewayStub:
def __init__(self, client):
self.client = client
def tushare(self):
return self.client
@staticmethod
def datahub_status():
return {"configured": True}
class FixedMarketDatetime(datetime):
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
@@ -61,6 +81,122 @@ class FixedPreopenDatetime(datetime):
return cls.fixed_now
class FixedLunchDatetime(datetime):
fixed_now = datetime(2026, 7, 31, 11, 45).astimezone()
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class FixedAfterCloseDatetime(datetime):
fixed_now = datetime(2026, 7, 31, 15, 30).astimezone()
@classmethod
def now(cls, tz=None):
return cls.fixed_now
class DeniedRealtimeClientStub:
quote_calls = 0
def __init__(self, token):
self.token = token
@staticmethod
def resolve_trade_context(requested_date):
return requested_date, requested_date
@classmethod
def realtime_stock_quote(cls, ts_code, reference_date=""):
cls.quote_calls += 1
raise TushareError("没有接口访问权限")
class FreeQuoteAggregator:
def __init__(self, quote=None, fail=False):
self.quote = quote
self.fail = fail
self.tencent_calls = 0
self.eastmoney_calls = 0
def tencent_stock_quote(self, code, expected_date=""):
self.tencent_calls += 1
if self.fail:
raise RealtimeAggregateError("tencent down")
if self.quote and self.quote.get("source") == "eastmoney_stock":
raise RealtimeAggregateError("tencent empty")
if self.quote:
return self.quote
raise RealtimeAggregateError("tencent empty")
def eastmoney_stock_quote(self, code, expected_date=""):
self.eastmoney_calls += 1
if self.fail:
raise RealtimeAggregateError("eastmoney down")
if self.quote and self.quote.get("source") == "eastmoney_stock":
return self.quote
raise RealtimeAggregateError("eastmoney empty")
class IntradayChartStub:
def __init__(self, points, previous_close=10.0, trade_date="2026-07-31"):
self.points = points
self.previous_close = previous_close
self.trade_date = trade_date
def stock_daily(self, code, end_date, limit=90):
raise ChartDataError("iFinD daily unavailable")
def stock_intraday(self, code):
return {
"trade_date": self.trade_date,
"previous_close": self.previous_close,
"points": self.points,
}
def _history_payload(code="002141"):
yesterday = (FixedMarketDatetime.fixed_now - timedelta(days=1)).strftime("%Y-%m-%d")
return {
"meta": {"trade_date": yesterday, "source": "tushare"},
"stock": {"code": code, "name": "旧名称", "price": 10, "change": 7.1},
"prices": [
{
"trade_date": yesterday,
"open": 9.5,
"high": 10.1,
"low": 9.4,
"close": 10,
"change": 7.1,
"volume": 100,
"amount_billion": 1.1,
}
],
"moneyflow": {},
}
def _free_quote(source="tencent_qt", **overrides):
quote = {
"ts_code": "002141.SZ",
"name": "贤程科技",
"pre_close": 10.0,
"open": 10.2,
"high": 10.8,
"low": 10.1,
"close": 10.6,
"vol": 250000,
"amount": 26_500_000,
"quote_date": "20260731",
"quote_time_epoch": int(datetime(2026, 7, 31, 10, 31).timestamp()),
"source": source,
"turnover_rate": 2.5,
}
quote.update(overrides)
return quote
class StockDetailRealtimeTests(unittest.TestCase):
def setUp(self):
self.service = DashboardService.__new__(DashboardService)
@@ -68,7 +204,13 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.service.database = DetailDatabaseStub()
self.service._request_context = threading.local()
self.service._request_context.user_id = 1
self.service.ifind = None
self.service.realtime_aggregator = None
self.service.chart_data = None
self.service._market_client_override = RealtimeClientStub("datahub")
self.service.data_gateway = DataGatewayStub(self.service._market_client_override)
RealtimeClientStub.quote_calls = 0
DeniedRealtimeClientStub.quote_calls = 0
def test_today_detail_merges_rt_quote_without_mutating_daily_cache(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
@@ -90,9 +232,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"moneyflow": {},
}
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(cached, "002141", today)
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
@@ -112,9 +252,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"stock": {"code": "002141", "price": 10, "change": 1.2},
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
}
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2)
@@ -151,9 +289,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
},
],
}
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedPreopenDatetime):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(result["meta"]["trade_date"], yesterday)
@@ -162,6 +298,179 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.assertEqual(result["stock"]["change"], 1.2)
self.assertEqual(RealtimeClientStub.quote_calls, 0)
def test_today_detail_falls_back_to_tencent_quote_when_rt_k_denied(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
aggregator = FreeQuoteAggregator(_free_quote())
self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
bar = result["prices"][-1]
self.assertEqual(bar["trade_date"], "2026-07-31")
self.assertTrue(bar["realtime"])
self.assertEqual(bar["open"], 10.2)
self.assertEqual(bar["high"], 10.8)
self.assertEqual(bar["low"], 10.1)
self.assertEqual(bar["close"], 10.6)
self.assertAlmostEqual(bar["change"], 6.0, places=4)
self.assertEqual(bar["volume"], 2500)
self.assertAlmostEqual(bar["amount_billion"], 0.265)
self.assertEqual(len(result["prices"]), 2)
self.assertEqual(result["meta"]["notice"], "")
self.assertEqual(aggregator.tencent_calls, 1)
self.assertEqual(DeniedRealtimeClientStub.quote_calls, 1)
def test_today_detail_falls_back_to_eastmoney_then_intraday(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
aggregator = FreeQuoteAggregator(
_free_quote(
"eastmoney_stock",
ts_code="600000.SH",
name="浦发银行",
net_mf_amount=12,
),
)
self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
self.assertEqual(result["prices"][-1]["close"], 10.6)
self.assertEqual(aggregator.tencent_calls, 1)
self.assertEqual(aggregator.eastmoney_calls, 1)
aggregator = FreeQuoteAggregator(fail=True)
self.service.realtime_aggregator = aggregator
self.service.chart_data = IntradayChartStub(
[
{
"date": "2026-07-31",
"time": "09:30",
"open": 10.1,
"high": 10.2,
"low": 10.0,
"close": 10.15,
"volume": 120,
"amount": 121800,
},
{
"date": "2026-07-31",
"time": "10:05",
"open": 10.15,
"high": 10.5,
"low": 9.9,
"close": 10.4,
"volume": 80,
"amount": 83200,
},
]
)
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
bar = result["prices"][-1]
self.assertEqual(bar["trade_date"], "2026-07-31")
self.assertEqual(bar["open"], 10.1)
self.assertEqual(bar["high"], 10.5)
self.assertEqual(bar["low"], 9.9)
self.assertEqual(bar["close"], 10.4)
self.assertAlmostEqual(bar["change"], 4.0, places=4)
self.assertEqual(bar["volume"], 200)
self.assertTrue(bar["realtime"])
def test_today_detail_keeps_history_when_free_sources_fail(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
self.assertFalse(result["meta"].get("realtime", False))
self.assertEqual(result["meta"]["notice"], TODAY_DAILY_UNAVAILABLE_NOTICE)
self.assertEqual(len(result["prices"]), 1)
def test_lunch_keeps_morning_realtime_bar(self):
today = FixedLunchDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
)
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedLunchDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
self.assertTrue(result["meta"]["realtime"])
def test_after_close_keeps_forming_bar_until_official_ready(self):
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
self.assertTrue(forming["prices"][-1]["realtime"])
official = _history_payload()
official["prices"].append(
{
"trade_date": "2026-07-31",
"open": 10.15,
"high": 10.9,
"low": 10.05,
"close": 10.7,
"change": 7.0,
"volume": 1800,
"amount_billion": 0.3,
}
)
RealtimeClientStub.quote_calls = 0
self.service._market_client_override = RealtimeClientStub("datahub")
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
replaced = self.service._prepare_stock_detail(official, "002141", today)
self.assertEqual(replaced["prices"][-1]["close"], 10.7)
self.assertFalse(replaced["prices"][-1].get("realtime", False))
self.assertEqual(len(replaced["prices"]), 2)
self.assertEqual(RealtimeClientStub.quote_calls, 0)
def test_same_date_bar_is_replaced_not_duplicated(self):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
payload = _history_payload()
payload["prices"].append(
{
"trade_date": "2026-07-31",
"open": 10.0,
"high": 10.1,
"low": 9.9,
"close": 10.05,
"change": 0.5,
"volume": 10,
"amount_billion": 0.01,
"realtime": True,
}
)
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(len(result["prices"]), 2)
self.assertEqual(result["prices"][-1]["close"], 10.6)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
if __name__ == "__main__":
unittest.main()
+11 -6
View File
@@ -97,6 +97,7 @@ def code_hotspots() -> list[dict[str, Any]]:
"backend/features/system/service.py",
"backend/features/accounts/application.py",
"backend/jobs/service.py",
"backend/jobs/refresh.py",
"database.py",
"backend/features/screener/engine.py",
"backend/features/screener/catalog.py",
@@ -217,10 +218,10 @@ def build() -> dict[str, Any]:
),
"external_data_adapters": [
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "stable client facade for primary deterministic market data"},
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
{"provider": "eastmoney", "path": "backend/features/market/charts.py", "runtime_role": "display chart fallback"},
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"},
{"provider": "tencent", "path": "backend/data/realtime.py", "runtime_role": "index observation fallback"},
{"provider": "datahub", "path": "backend/data/datahub/client.py", "runtime_role": "website-only read path; official EOD, live quotes, and licensed iFinD"},
{"provider": "ifind", "path": "xiaobai-datahub/datahub/adapters/ifind.py", "runtime_role": "licensed iFinD source inside the data hub"},
{"provider": "eastmoney", "path": "xiaobai-datahub/datahub/adapters/eastmoney.py", "runtime_role": "free realtime quotes and shenwan inside the data hub"},
{"provider": "tencent", "path": "xiaobai-datahub/datahub/adapters/tencent.py", "runtime_role": "free index and stock quotes inside the data hub"},
],
"provider_domains": [
{"provider": "tushare", "path": "backend/data/providers/tushare_transport.py", "responsibility": "HTTP transport and provider errors"},
@@ -235,9 +236,12 @@ def build() -> dict[str, Any]:
],
"provider_construction": [
{"client": "TushareClient", "owner": "backend/data/providers/tushare.py", "compatibility_fallback": "backend/features/market/service.py"},
{"client": "IfindHttpClient", "owner": "backend/data/gateway.py"},
{"client": "DatahubClient", "owner": "backend/data/gateway.py"},
{"client": "DatahubBridge", "owner": "backend/data/gateway.py"},
{"client": "DatahubAwareTushareClient", "owner": "backend/data/gateway.py"},
{"client": "HubIfindProxy", "owner": "backend/data/gateway.py"},
{"client": "HubRealtimeProxy", "owner": "backend/data/gateway.py"},
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
{"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"},
],
"heaven_service_owners": {
"facade": "backend/features/heaven/service.py",
@@ -261,6 +265,7 @@ def build() -> dict[str, Any]:
"system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py",
"job_refresh_status": "backend/jobs/refresh.py",
"feature_routes": "backend/features/*/routes.py",
},
"numeric_normalization": [
+31 -8
View File
@@ -33,16 +33,37 @@ $DIRTY"
LOCAL_HEAD="$(g rev-parse HEAD)"
REMOTE_HEAD="$(g rev-parse origin/main)"
if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
TOP_AD="$(g diff --name-status HEAD origin/main | grep -E "^[AD][[:space:]]+[^/]+$" || true)"
g merge-base --is-ancestor "$LOCAL_HEAD" "$REMOTE_HEAD" \
|| refuse "本地 main 与 origin/main 历史分叉,停止(未改写工作目录)"
CHANGES="$(g diff --no-renames --name-status HEAD origin/main)"
TOP_AD="$(printf '%s\n' "$CHANGES" | grep -E "^[AD][[:space:]]+[^/]+$" || true)"
[ -z "$TOP_AD" ] || refuse "main 相比本地新增/删除了顶层文件,目录顶层归 root,需管理员执行:
$TOP_AD"
TOP_MOD="$(g diff --name-status HEAD origin/main | grep -E "^M[[:space:]]+[^/]+$" | awk '{print $2}' || true)"
if [ -n "$TOP_MOD" ]; then
echo "==> 预写入顶层改动文件(顶层目录无删除权限,改为截断写入)"
while IFS= read -r f; do g show "origin/main:$f" > "$WORK_TREE/$f"; done <<< "$TOP_MOD"
fi
echo "==> 快进合并到 origin/main"
g merge --ff-only origin/main >/dev/null 2>&1 || refuse "无法快进合并 origin/main(历史分叉),停止"
echo "==> 同步 origin/main 文件(顶层文件保留原 inode,避免目录权限限制)"
while IFS=$'\t' read -r status path; do
[ -n "$path" ] || continue
case "$status" in
D)
case "$path" in
*/*) rm -f -- "$WORK_TREE/$path" ;;
*) refuse "main 删除了顶层文件 $path,需管理员处理" ;;
esac
;;
A|M)
MODE_BITS="$(g ls-tree origin/main -- "$path" | awk '{print $1}')"
case "$MODE_BITS" in
100644|100755) ;;
*) refuse "文件 ${path} 的 Git 类型 ${MODE_BITS} 不支持自动同步,需管理员处理" ;;
esac
mkdir -p -- "$(dirname "$WORK_TREE/$path")"
g show "origin/main:$path" > "$WORK_TREE/$path"
[ "$MODE_BITS" = "100755" ] && chmod 755 "$WORK_TREE/$path" || chmod 644 "$WORK_TREE/$path"
;;
*) refuse "遇到未支持的 Git 变更类型 ${status}${path}" ;;
esac
done <<< "$CHANGES"
g read-tree origin/main
g update-ref refs/heads/main "$REMOTE_HEAD" "$LOCAL_HEAD"
DIRTY="$(g status --porcelain)"
[ -z "$DIRTY" ] || refuse "快进后工作区仍不一致,停止:
$DIRTY"
@@ -54,6 +75,8 @@ echo "==> 校验通过:本地 HEAD = origin/main = ${LOCAL_HEAD}${SHORT}
if [ "$MODE" = "verify-tag" ]; then
TAG="${2:?用法: update-from-main.sh verify-tag <tag>}"
[ "$TAG" = "main-${SHORT}" ] \
|| refuse "镜像标签必须是当前 main 对应的 main-${SHORT},收到:${TAG}"
LABEL="$(docker image inspect "${IMAGE_REPO}:${TAG}" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null)" \
|| refuse "镜像 ${IMAGE_REPO}:${TAG} 不存在"
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
*.log
data/
tests/
+17
View File
@@ -0,0 +1,17 @@
# Fernet key. Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
DATAHUB_ENCRYPTION_KEY=
# Consumer API token for /v1 (32+ random bytes, shown once). Never log this value.
DATAHUB_TOKEN=
# Initial admin password for /admin. Forced change on first login.
DATAHUB_ADMIN_PASSWORD=
# Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages.
TUSHARE_TOKEN=
# Optional licensed iFinD tokens. Used only inside the hub; the website never dials 51ifind.com.
IFIND_REFRESH_TOKEN=
IFIND_ACCESS_TOKEN=
TZ=Asia/Shanghai
+36
View File
@@ -0,0 +1,36 @@
FROM python:3.12-slim-bookworm
ARG APP_UID=10002
ARG APP_GID=10002
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONUTF8=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Asia/Shanghai
WORKDIR /app
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
&& groupadd --gid "${APP_GID}" datahub \
&& useradd --uid "${APP_UID}" --gid "${APP_GID}" --create-home --shell /usr/sbin/nologin datahub \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt
COPY --chown=datahub:datahub . .
RUN mkdir -p /app/data /app/data/backups && chown -R datahub:datahub /app/data
USER datahub
EXPOSE 8766
STOPSIGNAL SIGINT
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8766/livez', timeout=4).read()"]
CMD ["python", "-u", "server.py", "--host", "0.0.0.0", "--port", "8766"]
+152
View File
@@ -0,0 +1,152 @@
# xiaobai-datahub
独立行情数据中枢(HEL-382 / P0)。与 `xiaobai-review` 同仓库、不同容器、不共享数据库文件。
本阶段不部署现网;只提供可本地运行、可自测的底座和盘后正式数据链路。
## 做什么
- SQLite WAL `datahub.db`,容器名 `xiaobai-datahub`,端口 `8766`
- Tushare 盘后正式数据:交易日历、股票主档、daily、daily_basic、adj_factor、index_daily、moneyflow、stk_auction、limit_list_d、ths_hot/dc_hot、hm_detail、ths_daily/dc_index/sw_daily
- 盘中观察(provisional):东财/腾讯指数报价、个股最新价、全市场快照、分时点(`/v1/quotes/latest` 不传 codes 即全市场,`/v1/indexes/quotes` `/v1/intraday/points`);永不写入 eod_* 正式表
- 暂存 → 校验 → 整批原子发布 → 可回滚
- `/v1` 稳定接口(`X-Datahub-Token`
- `/admin/` 最小管理后台(总览 / 数据源 / 调度 / 发布 / 数据集 / 审计)
- 同花顺/选股宝/AKShare/iFinD 适配器位仍预留;东财/腾讯已接入盘中观察
## 单位口径(相对现站)
现站 `xiaobai-review` 按 Tushare 原始单位入库、展示时再换算。中枢在归一化层一次换算:
| 字段 | Tushare / 现站 | 中枢 canonical |
|---|---|---|
| `daily.amount` / `index_daily.amount` | 千元 | 元(×1000 |
| `daily.vol` / `index_daily.vol` | 手 | 股(×100 |
| `moneyflow.*_amount` | 万元 | 元(×1e4 |
| `daily_basic.total_mv` / `circ_mv` | 万元 | 元(×1e4 |
| `stk_auction.amount` | 元 | 元 |
差异为口径升级,golden 测试按上表对照,不为 0 的字段都有说明。
## 本地启动(不走 Docker
```bash
cd xiaobai-datahub
python -m venv .venv && .venv/bin/pip install -r requirements.txt
cp .env.example .env
# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / DATAHUB_ADMIN_PASSWORD / TUSHARE_TOKEN
# 生成 Fernet 密钥:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
.venv/bin/python server.py --host 127.0.0.1 --port 8766
```
- 管理后台:http://127.0.0.1:8766/admin/
- 存活检查:http://127.0.0.1:8766/livez (无需 token
- `/v1/*` 必须带请求头 `X-Datahub-Token`
## Docker(独立 compose,不改现网 review 服务)
```bash
cd xiaobai-datahub
cp .env.example .env # 填密钥
mkdir -p data
docker compose build
docker compose up -d
```
仓库根目录另有 `compose.datahub.yaml`,供总工以后与现有 `compose.yaml` 叠加部署,本卡不执行现网 `up`
## 自测
```bash
cd xiaobai-datahub
python -m unittest discover -s tests -v
```
不调用真实 Tushare;用内存/临时库和假适配器。
## 历史回补
交易日历默认从 `20160101` 拉到今天后 30 天;盘前 `precheck` 与手动回补都走同一 UPSERT,可重复执行。
网站实际使用的指数(上证、深成、创业板、沪深300)按交易日增量发布,默认覆盖 260 个交易日(大于现有 90 天窗口,并覆盖智能选股基准回看)。已发布日期默认跳过。
```bash
cd xiaobai-datahub
python -m datahub history-backfill
# 可选:--calendar-start 20160101 --index-days 260 --force
```
管理后台也可手动跑 `history_backfill` 任务,或 `POST /admin/api/backfill``dataset=history`、确认词 `history:full`
区间接口在 `meta.coverage` / `meta.incomplete` 标明覆盖是否完整;网站只读接入把不完整区间视为不可用并回旧链路。个股日 K 的 90 天区间查询依赖已核实,本阶段不回补全市场历史。
## 估值字段级质量门
`hub-quality.config.json``field_gates` 按数据集配置关键字段:非空率下限(支持按字段覆盖,如 `dv_ttm` 合法高空值)、非有限值比例上限、以及相对上一已发布批次的非空率塌陷保护。字段大面积为空的批次会被拒绝发布、保留上一份正常正式数据,失败原因逐字段写入 `batches.error` / `quality_json`。被拒后数据集仍视为缺失,盘后自动重试(HEL-435 机制)会继续尝试直到成功或截止。配置对任意数据集生效,不写死单日或单字段。
## 整批原子发布(release group
盘后发布/重发(eod_a、eod_retry、`eod-refresh`、跨数据集重发)不再逐数据集各自切换,而是走整批原子可见机制:
- 一致性边界:日 K、估值、资金流、竞价同属 A 组整批;指数日 K 为 B 组;当日股票主档快照随 A 组一同切换(主档 `stock_master` 的 UPSERT 与快照发布同一事务,不会出现主档先行/滞后)。
- 流程:组内全部成员先在暂存表完成拉取、字段质量门、覆盖检查和跨数据集交叉校验(`cross_gates` 配置 ts_code 覆盖重叠率下限),全部达标后才在**一个 SQLite 事务**里复制正式表并翻转全部 `publications` 指针。
- 任一成员失败(拉取失败、质量门拒绝、交叉校验不过、切换事务中断)→ 整批不切换,对外继续提供上一份完整正式版本,失败原因写入 `batches.error``audit_log``action=release-group`),等待晚间自动重试。
- 读取侧任何时刻只会看到"旧完整版本"或"新完整版本":发布指针在单事务内统一翻转,容器重启/事务中断自动回滚,不暴露字段残缺或跨数据集混合版本。
- 幂等:仅当一致性边界内全部成员都已发布时才整组跳过;边界内任有缺失则整组重暂存后统一切换,避免旧批次与新批次混在同一次重发中。重复执行、并发重试不会在完整边界已就绪时生成重复批次(调度器另有 EOD 互斥锁)。
## 股票主档每日刷新与发布
交易日 20:00 与 23:10`stocks_refresh_times` 可配)自动刷新股票主档并发布版本化快照(`eod_stocks` + `publications.dataset='stocks'`),覆盖当日新上市、证券简称变化和上市首日 N/C 前缀摘除;无变化则跳过,重复执行幂等。`/v1/stocks` 从最新已发布快照提供数据并带 `batch_id` / `published_at``/v1/datasets/status` 同步展示 stocks 状态。
```bash
cd xiaobai-datahub
python -m datahub stocks-refresh # 手动触发;--force 无变化也重发
```
## 资金流历史回补
网站会沿真实调用链查最近若干交易日的 moneyflow(个股详情任意日期点查 + 智能选股最近 5 个交易日),默认回补最近 60 个交易日(`moneyflow_history_trading_days` 可配,已发布日期自动跳过)。点查未覆盖的历史日期返回 `DATASET_NOT_PUBLISHED` 并附 `available_from` / `available_to`(低于下界时 `reason=history_not_backfilled`),网站据此明确回退旧链路,不会静默拿到半截数据。
```bash
cd xiaobai-datahub
python -m datahub moneyflow-backfill # --trading-days 60 --end-date --force 可选
```
## 盘后补跑与强制重发
```bash
cd xiaobai-datahub
python -m datahub eod-refresh --trade-date 20260904 # 补不完整的 A/B 边界
python -m datahub eod-refresh --trade-date 20260904 --force --dataset valuation
# --force 按一致性边界整组重发:valuation/daily/moneyflow/auction/stocks → A 组;
# index_daily → B 组。不可再单独切换某一个正式数据集。
```
管理后台「补数」对盘后正式数据集同样走 `force_republish_boundary`,不会绕过 A/B 整批边界。
## 估值发布后复核与自动追补
Tushare `daily_basic` 会在盘后继续改当日字段。HEL-423 在 2026-09-07 观察到:中枢 17:10 发布 `003021.SZ turnover_rate=1.3565`21:05 上游/旧链路已是 `1.3572`;其余 7 类观察对象当日一致。日 K、资金流、竞价、指数没有同类晚间修订证据,股票主档已有 20:00/23:10 刷新,因此默认只复核估值,不盲目全量重拉。
窗口(可配):交易日 **20:0023:20**,每 30 分钟一次轻量比对(对齐网站 21:00 / 23:30 观察)。只拉取 `daily_basic`,按网站真实请求字段精确比较,无误差豁免。
- 无变化:不产生新批次,状态「已追平」。
- 发现修订:重新走字段质量门、覆盖检查和 A 组整批原子发布;读者全程只能看到上一完整版本或新完整版本。
- 上游空 / 接口失败 / 不完整 / 质量门拒绝:保留上一完整版本,状态「复核失败」。
- 23:20 截止后停止当晚复核;下一自然日盘前对上一交易日再做一次安全追赶。
- 与 `eod_a` / `eod_retry` 共用互斥锁;容器重启会在窗口内立即补一次。
## 备份
每日 00:40 任务把 `datahub.db` 备份到 `data/backups/`(保留 14 份)。也可手动:
```bash
python -c "from pathlib import Path; from datahub.db import HubDB; HubDB(Path('data/datahub.db')).backup_to(Path('data/backups/manual.db'))"
```
## 安全
- 密钥只以 `configured / 末4位 / 更新时间` 出现在后台,不进日志、不进 `/v1`
- HTTP 解析失败只记录“请求不是合法 JSON”,不把请求正文、密码或 Token 写入容器日志
- 回滚、补数需重新输入密码 + 确认词
- 容器非 rootuid 10002)、read_only、cap_drop ALL
+299
View File
@@ -0,0 +1,299 @@
const state = { csrf: "", page: "overview" };
function $(id) { return document.getElementById(id); }
async function api(path, options = {}) {
const headers = Object.assign({ "Content-Type": "application/json" }, options.headers || {});
if (state.csrf && (options.method || "GET") !== "GET") headers["X-CSRF-Token"] = state.csrf;
const res = await fetch(path, Object.assign({}, options, { headers, credentials: "same-origin" }));
const body = await res.json();
if (!res.ok) {
const msg = (body.error && body.error.message) || body.error || res.statusText;
throw new Error(msg);
}
return body;
}
function show(id) {
["login-view", "change-view", "shell"].forEach((key) => { $(key).hidden = key !== id; });
}
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[ch]));
}
function table(headers, rows) {
const thead = headers.map((h) => `<th>${esc(h)}</th>`).join("");
const body = rows.length
? rows.map((cols) => `<tr>${cols.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")
: `<tr><td colspan="${headers.length}">暂无数据</td></tr>`;
return `<table><thead><tr>${thead}</tr></thead><tbody>${body}</tbody></table>`;
}
async function boot() {
try {
const session = await api("/admin/api/session");
state.csrf = session.csrf;
$("who").textContent = session.username;
if (session.must_change) { show("change-view"); return; }
show("shell");
await render();
} catch {
show("login-view");
}
}
$("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
$("login-error").hidden = true;
try {
const result = await api("/admin/api/login", {
method: "POST",
body: JSON.stringify({ username: form.get("username"), password: form.get("password") }),
});
state.csrf = result.csrf;
if (result.must_change) show("change-view");
else { show("shell"); await render(); }
} catch (err) {
$("login-error").hidden = false;
$("login-error").textContent = err.message;
}
});
$("change-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.target);
try {
await api("/admin/api/change-password", {
method: "POST",
body: JSON.stringify({ current: form.get("current"), new_password: form.get("new_password") }),
});
show("shell");
await render();
} catch (err) {
$("change-error").hidden = false;
$("change-error").textContent = err.message;
}
});
$("logout-btn").addEventListener("click", async () => {
await api("/admin/api/logout", { method: "POST", body: "{}" });
show("login-view");
});
$("theme-btn").addEventListener("click", () => {
const root = document.documentElement;
const next = root.getAttribute("data-theme") === "night" ? "" : "night";
if (next) root.setAttribute("data-theme", next);
else root.removeAttribute("data-theme");
$("theme-btn").textContent = next ? "日间" : "夜间";
});
document.querySelectorAll("nav button").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll("nav button").forEach((item) => item.classList.remove("active"));
btn.classList.add("active");
state.page = btn.dataset.page;
render();
});
});
async function render() {
const page = $("page");
if (state.page === "overview") {
const data = await api("/admin/api/overview");
$("phase").textContent = data.session_phase;
const eod = data.eod_status || {};
const rev = data.revision_status || {};
const eodLabels = {
pending_first_attempt: "等待首次尝试",
waiting_upstream: "等待上游",
done: "已成功",
cutoff_failed: "已截止失败",
closed_day: "休市",
};
const revLabels = {
waiting_review: "等待复核",
review_failed: "复核失败",
aligned: "已追平",
cutoff: "已截止",
pending_publish: "待发布",
closed_day: "休市",
};
const eodExtra = [];
if (eod.state === "waiting_upstream") {
eodExtra.push(`已试 ${eod.attempts}`);
if (eod.next_retry_at) eodExtra.push(`下次重试 ${esc(String(eod.next_retry_at).replace("T", " ").slice(11, 16))}`);
if (eod.missing_datasets && eod.missing_datasets.length) eodExtra.push(`${esc(eod.missing_datasets.join(","))}`);
}
if (eod.state === "cutoff_failed" && eod.missing_datasets) {
eodExtra.push(`${esc(eod.missing_datasets.join(","))}`);
}
const revExtra = [];
if (rev.detail) revExtra.push(esc(String(rev.detail)));
if (rev.window) revExtra.push(esc(String(rev.window)));
page.innerHTML = `
<div class="cards">
<div class="card"><div class="muted">交易日</div><strong>${esc(data.trade_date)}</strong></div>
<div class="card"><div class="muted">阶段</div><strong>${esc(data.session_phase)}</strong></div>
<div class="card"><div class="muted">今日发布</div><strong>${data.publications.length}</strong></div>
<div class="card"><div class="muted">盘后补跑</div><strong>${esc(eodLabels[eod.state] || eod.state || "-")}</strong><div class="muted">${eodExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">估值复核</div><strong>${esc(revLabels[rev.state] || rev.state || "-")}</strong><div class="muted">${revExtra.join(" · ")}</div></div>
<div class="card"><div class="muted">异常批次</div><strong class="${data.anomalies.length ? "fail" : "ok"}">${data.anomalies.length}</strong></div>
</div>
<h2>最近调用</h2>
${table(["时间", "源", "端点", "结果", "耗时"], data.recent_calls.map((row) => [
esc(row.created_at), esc(row.provider), esc(row.endpoint),
row.ok ? '<span class="ok">成功</span>' : `<span class="fail">${esc(row.error)}</span>`,
`${row.latency_ms ?? "-"} ms`,
]))}
`;
return;
}
if (state.page === "sources") {
const data = await api("/admin/api/sources");
page.innerHTML = `<h2>数据源</h2>` + table(
["源", "角色", "状态", "凭据", "操作"],
data.items.map((item) => {
const cred = item.credential || {};
const credText = cred.configured ? `已配置 · ${esc(cred.last4 || "****")}` : "未配置";
return [
esc(item.provider),
esc(item.role),
esc((item.health && (item.health.state || item.health.status)) || "-"),
credText,
`<button data-probe="${esc(item.provider)}">探测一次</button>`,
];
}),
);
page.querySelectorAll("[data-probe]").forEach((btn) => {
btn.addEventListener("click", async () => {
const result = await api(`/admin/api/sources/${btn.dataset.probe}/probe`, { method: "POST", body: "{}" });
alert(JSON.stringify(result));
render();
});
});
return;
}
if (state.page === "jobs") {
const data = await api("/admin/api/jobs");
page.innerHTML = `
<h2>调度任务</h2>
${table(["任务", "时刻", "操作"], data.jobs.map((job) => [
`${esc(job.id)} · ${esc(job.title)}`, esc(job.at),
`<button data-run="${esc(job.id)}">手动触发</button>`,
]))}
<h3>最近运行</h3>
${table(["ID", "任务", "状态", "开始", "结束", "错误"], data.runs.map((row) => [
row.id, esc(row.job_id), esc(row.state), esc(row.started_at), esc(row.finished_at), esc(row.error),
]))}
`;
page.querySelectorAll("[data-run]").forEach((btn) => {
btn.addEventListener("click", async () => {
const date = prompt("交易日 YYYYMMDD(可留空=今天)", "") || "";
await api(`/admin/api/jobs/${btn.dataset.run}/run`, { method: "POST", body: JSON.stringify({ trade_date: date }) });
render();
});
});
return;
}
if (state.page === "release") {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const data = await api(`/admin/api/batches?date=${date}`);
page.innerHTML = `
<h2>盘后发布 ${esc(data.trade_date)}</h2>
<div class="toolbar">
<label>日期 <input id="rel-date" value="${esc(data.trade_date)}" /></label>
<button type="button" id="rel-load">查看</button>
<button type="button" id="rel-backfill">补数</button>
</div>
<h3>当前映射</h3>
${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
row.prev_batch ? `<button class="danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
]))}
<h3>批次</h3>
${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [
esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error),
]))}
`;
$bindRelease(page);
return;
}
if (state.page === "datasets") {
const data = await api("/admin/api/datasets?date=");
page.innerHTML = `
<h2>数据集 / 质量 ${esc(data.trade_date)}</h2>
${table(["数据集", "批次", "状态", "发布时间"], data.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.state), esc(row.published_at),
]))}
<h3>源间差异</h3>
${table(["指标", "左", "右", "偏差", "样本"], data.diff_reports.map((row) => [
esc(row.metric), esc(row.left_value), esc(row.right_value), esc(row.deviation), row.sample_count ?? "",
]))}
`;
return;
}
if (state.page === "audit") {
const data = await api("/admin/api/audit");
page.innerHTML = `<h2>审计</h2>` + table(
["时间", "操作者", "动作", "对象", "详情"],
data.items.map((row) => [esc(row.created_at), esc(row.actor), esc(row.action), esc(row.target), esc(row.detail)]),
);
}
}
function $bindRelease(page) {
page.querySelector("#rel-load").addEventListener("click", async () => {
const date = page.querySelector("#rel-date").value;
const data = await api(`/admin/api/batches?date=${encodeURIComponent(date)}`);
state.page = "release";
// re-render with fetched date by writing location hash
history.replaceState(null, "", `#release-${date}`);
$("page").innerHTML = renderRelease(data);
$bindRelease($("page"));
});
page.querySelector("#rel-backfill").addEventListener("click", () => dangerous("backfill"));
page.querySelectorAll("[data-rollback]").forEach((btn) => {
btn.addEventListener("click", () => dangerous("rollback", btn.dataset.rollback));
});
}
function renderRelease(data) {
return `
<h2>盘后发布 ${esc(data.trade_date)}</h2>
<div class="toolbar">
<label>日期 <input id="rel-date" value="${esc(data.trade_date)}" /></label>
<button type="button" id="rel-load">查看</button>
<button type="button" id="rel-backfill">补数</button>
</div>
<h3>当前映射</h3>
${table(["数据集", "活跃批次", "上一批次", "状态", "发布时间", "操作"], data.publications.map((row) => [
esc(row.dataset), esc(row.active_batch), esc(row.prev_batch), esc(row.state), esc(row.published_at),
row.prev_batch ? `<button class="danger" data-rollback="${esc(row.dataset)}">回滚</button>` : "-",
]))}
<h3>批次</h3>
${table(["batch_id", "数据集", "状态", "行数", "错误"], data.batches.map((row) => [
esc(row.batch_id), esc(row.dataset), esc(row.state), row.rows_out ?? "", esc(row.error),
]))}
`;
}
async function dangerous(kind, dataset) {
const date = ($("rel-date") && $("rel-date").value) || "";
const ds = dataset || prompt("数据集(daily/valuation/moneyflow/auction/stocks→A组整批;index_daily→B组;或 reference", "daily");
if (!ds) return;
const password = prompt("二次确认:输入管理密码");
if (!password) return;
const confirmWord = `${ds}:${date}`;
const typed = prompt(`请输入确认词:${confirmWord}`);
const path = kind === "rollback" ? "/admin/api/rollback" : "/admin/api/backfill";
await api(path, {
method: "POST",
body: JSON.stringify({ dataset: ds, trade_date: date, password, confirm: typed }),
});
render();
}
boot();
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>xiaobai-datahub 管理后台</title>
<link rel="stylesheet" href="/admin/styles.css" />
</head>
<body>
<div id="app">
<section id="login-view" class="panel auth-panel">
<h1>数据中枢</h1>
<p class="muted">内网管理后台,用于查看源状态、调度和盘后发布批次。</p>
<form id="login-form">
<label>账号 <input name="username" value="hub_admin" autocomplete="username" /></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" /></label>
<button type="submit">登录</button>
<p id="login-error" class="error" hidden></p>
</form>
</section>
<section id="change-view" class="panel auth-panel" hidden>
<h1>修改初始密码</h1>
<form id="change-form">
<label>当前密码 <input name="current" type="password" /></label>
<label>新密码(至少 8 位) <input name="new_password" type="password" /></label>
<button type="submit">保存并继续</button>
<p id="change-error" class="error" hidden></p>
</form>
</section>
<section id="shell" hidden>
<header class="top">
<strong>xiaobai-datahub</strong>
<span id="phase" class="pill"></span>
<span id="who" class="muted"></span>
<button type="button" id="theme-btn" class="ghost">夜间</button>
<button type="button" id="logout-btn" class="ghost">退出</button>
</header>
<nav>
<button data-page="overview" class="active">总览</button>
<button data-page="sources">数据源</button>
<button data-page="jobs">调度任务</button>
<button data-page="release">盘后发布</button>
<button data-page="datasets">数据集</button>
<button data-page="audit">审计</button>
</nav>
<main id="page"></main>
</section>
</div>
<script src="/admin/app.js"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
:root {
color-scheme: light;
--bg: #f4f5f7;
--surface: #ffffff;
--text: #1f2329;
--muted: #646a73;
--line: #dee0e3;
--action: #3370ff;
--danger: #e04536;
--ok: #16a34a;
--warn: #b45309;
--radius: 8px;
--pad: 16px;
font-family: "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
}
:root[data-theme="night"] {
color-scheme: dark;
--bg: #111318;
--surface: #1b1e24;
--text: #e8eaed;
--muted: #9aa0a6;
--line: #2a2f38;
--action: #5b8cff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); }
.panel, header.top, nav, main { background: var(--surface); }
.auth-panel { max-width: 420px; margin: 12vh auto; padding: 28px; border-radius: var(--radius); border: 1px solid var(--line); }
label { display: block; margin: 12px 0; }
input, select { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--text); }
button { background: var(--action); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; cursor: pointer; }
button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }
button.danger { background: var(--danger); }
.muted { color: var(--muted); }
.error { color: var(--danger); }
.top { display: flex; gap: 12px; align-items: center; padding: 10px var(--pad); border-bottom: 1px solid var(--line); }
nav { display: flex; gap: 4px; padding: 8px var(--pad); border-bottom: 1px solid var(--line); }
nav button { background: transparent; color: var(--muted); }
nav button.active { color: var(--action); background: transparent; font-weight: 600; }
main { padding: var(--pad); min-height: calc(100vh - 96px); }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card { border: 1px solid var(--line); border-radius: var(--radius); padding: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
.pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.fail { color: var(--danger); }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: end; }
.toolbar label { margin: 0; }
dialog { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 20px; }
+41
View File
@@ -0,0 +1,41 @@
services:
xiaobai-datahub:
build:
context: .
dockerfile: Dockerfile
image: xiaobai-datahub:local
container_name: xiaobai-datahub
ports:
- "0.0.0.0:8766:8766/tcp"
env_file:
- ./.env
environment:
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
DATAHUB_DB_PATH: /app/data/datahub.db
DATAHUB_BACKUP_DIR: /app/data/backups
TZ: Asia/Shanghai
PYTHONUTF8: "1"
volumes:
- type: bind
source: ./data
target: /app/data
restart: unless-stopped
init: true
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
@@ -0,0 +1,63 @@
{
"daily_row_ratio": 0.98,
"null_rate_max": 0.01,
"cross_check_price_deviation": 0.03,
"cross_check_outlier_ratio": 0.05,
"index_price_deviation": 0.005,
"max_publish_attempts": 5,
"staging_retain_days": 14,
"job_run_retain_days": 90,
"backup_retain": 14,
"publication_generations": 3,
"tushare_rate_per_minute": 300,
"list_limit_default": 5000,
"list_limit_max": 5000,
"calendar_start": "20160101",
"index_history_trading_days": 260,
"daily_history_trading_days": 250,
"eod_retry_start": "15:15",
"eod_retry_interval_minutes": 30,
"eod_retry_cutoff": "23:30",
"revision_review_datasets": ["valuation"],
"revision_review_start": "20:00",
"revision_review_interval_minutes": 30,
"revision_review_cutoff": "23:20",
"moneyflow_history_trading_days": 60,
"stocks_refresh_times": [
"20:00",
"23:10"
],
"cross_gates": [
{
"left": "daily",
"right": "valuation",
"min_key_overlap": 0.98
},
{
"left": "daily",
"right": "moneyflow",
"min_key_overlap": 0.98
}
],
"field_gates": {
"valuation": {
"fields": [
"turnover_rate",
"volume_ratio",
"total_mv",
"circ_mv",
"pe_ttm",
"pb",
"ps_ttm",
"dv_ttm"
],
"min_nonnull_rate": 0.9,
"min_nonnull_rate_by_field": {
"pe_ttm": 0.5,
"dv_ttm": 0.3
},
"max_nonnull_drop_vs_prev": 0.15,
"max_nonfinite_rate": 0.01
}
}
}
+4
View File
@@ -0,0 +1,4 @@
"""xiaobai-datahub: independent market-data service for xiaobai-review."""
__version__ = "0.1.0"
SCHEMA_VERSION = 1
+4
View File
@@ -0,0 +1,4 @@
from datahub.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,15 @@
from datahub.adapters.akshare import ADAPTER as akshare
from datahub.adapters.eastmoney import EastmoneyAdapter
from datahub.adapters.ifind import ADAPTER as ifind
from datahub.adapters.tencent import TencentAdapter
from datahub.adapters.ths import ADAPTER as ths
from datahub.adapters.xgb import ADAPTER as xgb
RESERVED = {
"eastmoney": EastmoneyAdapter(),
"tencent": TencentAdapter(),
"ths": ths,
"xgb": xgb,
"akshare": akshare,
"ifind": ifind,
}
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("akshare")
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class AdapterError(RuntimeError):
pass
class MarketAdapter(ABC):
"""Uniform adapter: probe / fetch / normalize. Realtime adapters may be stubs in P0."""
name: str = "base"
@abstractmethod
def probe(self) -> dict[str, Any]:
"""Liveness check. Must not leak credentials."""
@abstractmethod
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
"""Return provider-native rows (pre-canonical)."""
@abstractmethod
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Map provider-native rows onto hub canonical fields."""
class ReservedAdapter(MarketAdapter):
"""Placeholder for a later free/licensed source. Does not pull data in P0."""
def __init__(self, name: str) -> None:
self.name = name
def probe(self) -> dict[str, Any]:
return {
"provider": self.name,
"configured": False,
"state": "reserved",
"message": "适配器位已预留,本阶段不接入",
}
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
raise AdapterError(f"{self.name} 适配器本阶段未接入")
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return []
@@ -0,0 +1,501 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from typing import Any
from datahub.adapters.base import AdapterError, MarketAdapter
from datahub.numbers import finite_number, round4
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
EASTMONEY_CLIST_URL = "https://push2.eastmoney.com/api/qt/clist/get"
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
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
HIS_TRENDS_URL = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
)
INDEX_SECIDS = {
"000001.SH": "1.000001",
"399001.SZ": "0.399001",
"399006.SZ": "0.399006",
}
class EastmoneyAdapter(MarketAdapter):
name = "eastmoney"
def __init__(self, timeout: int = 8) -> None:
self.timeout = timeout
def probe(self) -> dict[str, Any]:
started = time.perf_counter()
try:
rows = self.fetch_indices()
state = "ok" if len(rows) == 3 else "empty"
except AdapterError as exc:
return {
"provider": self.name,
"configured": True,
"state": "error",
"message": str(exc),
"latency_ms": round((time.perf_counter() - started) * 1000),
}
return {
"provider": self.name,
"configured": True,
"state": state,
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
if dataset in {"indexes_quotes", "index_quotes"}:
return self.fetch_indices()
if dataset in {"quotes", "quotes_latest"}:
codes = params.get("codes") or []
if isinstance(codes, str):
codes = [item.strip() for item in codes.split(",") if item.strip()]
if codes:
return self.fetch_quotes(list(codes))
return self.fetch_market_quotes()
if dataset in {"quotes_market", "market_quotes"}:
return self.fetch_market_quotes()
raise AdapterError(f"{self.name} unsupported dataset: {dataset}")
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return list(rows)
def fetch_indices(self) -> list[dict[str, Any]]:
payload = self._get_json(
EASTMONEY_INDEX_URL,
{
"secids": "1.000001,0.399001,0.399006",
"fltt": "2",
"invt": "2",
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
},
referer="https://quote.eastmoney.com/",
)
rows = list((payload.get("data") or {}).get("diff") or [])
result = []
for row in rows:
code = str(row.get("f12") or "")
if code not in {"000001", "399001", "399006"}:
continue
epoch = int(finite_number(row.get("f124")) or 0)
ts_code = f"{code}.SH" if code.startswith("0") and code == "000001" else f"{code}.SZ"
if code == "000001":
ts_code = "000001.SH"
result.append(
{
"ts_code": ts_code,
"code": code,
"name": row.get("f14") or code,
"price": round4(finite_number(row.get("f2"))),
"pct_chg": round4(finite_number(row.get("f3"))),
"change_amount": round4(finite_number(row.get("f4"))),
"open": round4(finite_number(row.get("f17"))),
"high": round4(finite_number(row.get("f15"))),
"low": round4(finite_number(row.get("f16"))),
"previous_close": round4(finite_number(row.get("f18"))),
"amount": round4(finite_number(row.get("f6"))),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch
else ""
),
"source": "eastmoney_push2",
}
)
if len(result) != 3:
raise AdapterError(f"Eastmoney returned {len(result)}/3 indices")
return result
def fetch_quotes(self, codes: list[str]) -> list[dict[str, Any]]:
# Eastmoney ulist.np accepts ~60 secids per request; page remaining codes.
secids = []
for code in codes:
ts = str(code or "").upper()
symbol = ts.split(".")[0]
if ts.endswith(".SH") or symbol.startswith(("5", "6", "9")):
secids.append(f"1.{symbol}")
else:
secids.append(f"0.{symbol}")
if not secids:
return []
result: 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": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f5,f6,f8,f124",
},
referer="https://quote.eastmoney.com/",
)
rows = list((payload.get("data") or {}).get("diff") or [])
for row in rows:
symbol = str(row.get("f12") or "")
if not symbol:
continue
ts_code = f"{symbol}.SH" if symbol.startswith(("5", "6", "9")) else f"{symbol}.SZ"
epoch = int(finite_number(row.get("f124")) or 0)
close = round4(finite_number(row.get("f2")))
previous = round4(finite_number(row.get("f18")))
quote_date = (
datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d")
if epoch
else ""
)
result.append(
{
"ts_code": ts_code,
"name": row.get("f14") or symbol,
"price": close,
"close": close,
"pct_chg": round4(finite_number(row.get("f3"))),
"change_amount": round4(finite_number(row.get("f4"))),
"open": round4(finite_number(row.get("f17"))),
"high": round4(finite_number(row.get("f15"))),
"low": round4(finite_number(row.get("f16"))),
"pre_close": previous,
"previous_close": previous,
"volume": round4(finite_number(row.get("f5"))),
"vol": round4(finite_number(row.get("f5")) * 100),
"amount": round4(finite_number(row.get("f6"))),
"turnover_rate": round4(finite_number(row.get("f8"))),
"quote_date": quote_date,
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch
else ""
),
"source": "eastmoney_push2",
}
)
return result
def fetch_market_quotes(self) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
board_errors: list[str] = []
for board in EASTMONEY_A_SHARE_BOARDS:
try:
rows.extend(self._board_quotes(board))
except Exception as exc:
board_errors.append(f"{board}:{exc}")
quotes: list[dict[str, Any]] = []
seen: set[str] = set()
for row in rows:
quote = _normalize_market_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 AdapterError(f"Eastmoney market snapshot too small: {len(quotes)}{detail}")
return quotes
def _board_quotes(self, board: str) -> list[dict[str, Any]]:
first = self._market_page(board, 1)
data = first.get("data") or {}
rows = list(data.get("diff") or [])
total = int(finite_number(data.get("total")) or 0)
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._market_page(board, page)
rows.extend(list((payload.get("data") or {}).get("diff") or []))
return rows
def _market_page(self, board: str, page: int) -> dict[str, Any]:
return self._get_json(
EASTMONEY_CLIST_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 fetch_intraday(self, ts_code: str, date: str = "") -> dict[str, Any]:
code = str(ts_code or "").upper()
if code in INDEX_SECIDS:
secid = INDEX_SECIDS[code]
entity = "index"
identifier = code
elif code.startswith("BK") or code.endswith((".TI", ".SI")):
symbol = code.split(".")[0]
secid = f"90.{symbol}"
entity = "board"
identifier = symbol
else:
symbol = code.split(".")[0]
market = "1" if symbol.startswith(("5", "6", "9")) else "0"
secid = f"{market}.{symbol}"
entity = "stock"
identifier = symbol
params = {
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
}
data: dict[str, Any] = {}
points: list[dict[str, Any]] = []
last_error: Exception | None = None
for url, ndays in ((TRENDS_URL, "1"), (TRENDS_URL, "5"), (HIS_TRENDS_URL, "5")):
try:
payload = self._get_json(
url,
{**params, "ndays": ndays},
referer="https://quote.eastmoney.com/",
)
except AdapterError as exc:
last_error = exc
continue
data = payload.get("data") or {}
parsed = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
points = _preferred_session(parsed, date)
if points:
break
if not points:
raise AdapterError("No intraday chart data returned") from last_error
return {
"entity_type": entity,
"identifier": identifier,
"ts_code": code if "." in code else f"{identifier}.{'SH' if identifier.startswith(('5','6','9')) else 'SZ'}",
"name": str(data.get("name") or ""),
"code": str(data.get("code") or identifier),
"trade_date": points[-1]["date"],
"previous_close": round4(finite_number(data.get("preClose"))),
"points": points,
"source": "eastmoney_trends2",
}
def fetch_shenwan_quote(self, ts_code: str) -> dict[str, Any]:
code = str(ts_code or "").split(".")[0]
if not code:
raise AdapterError("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/",
)
rows = list((payload.get("data") or {}).get("diff") or [])
row = next((item for item in rows if item), None)
if not row:
raise AdapterError(f"Eastmoney Shenwan quote missing for {code}")
epoch = int(finite_number(row.get("f124")) or 0)
close = round4(finite_number(row.get("f2")))
previous = round4(finite_number(row.get("f18")))
if close <= 0 or previous <= 0:
raise AdapterError(f"Eastmoney Shenwan quote empty for {code}")
quote_time = (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch
else ""
)
return {
"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": round4(finite_number(row.get("f17"))),
"high": round4(finite_number(row.get("f15"))),
"low": round4(finite_number(row.get("f16"))),
"change": round4(finite_number(row.get("f3"))),
"pct_change": round4(finite_number(row.get("f3"))),
"pct_chg": round4(finite_number(row.get("f3"))),
"amount": round4(finite_number(row.get("f6"))),
"leader": row.get("f128") or "--",
"leader_code": row.get("f140") or "",
"leading_pct": round4(finite_number(row.get("f136"))),
"up_count": int(finite_number(row.get("f104")) or 0),
"down_count": int(finite_number(row.get("f105")) or 0),
"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",
}
def fetch_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 (
("https://push2ex.eastmoney.com/getTopicZTPool", "U"),
("https://push2ex.eastmoney.com/getTopicZBPool", "Z"),
):
params = {
"ut": "7eea3edcaed734bea9cbfc24409ed989",
"dpt": "wz.ztzt",
"PageIndex": "0",
"PageSize": "200",
"sort": "fbt:asc",
"stat": "1",
}
if day:
params["date"] = day
try:
payload = self._get_json(url, params, referer="https://quote.eastmoney.com/")
except AdapterError:
continue
pool = ((payload.get("data") or {}).get("pool") or []) if isinstance(payload.get("data"), dict) else []
for item in pool:
code = str(item.get("c") or item.get("code") or "")
if not code:
continue
market = str(item.get("m") or item.get("market") or "")
suffix = "SH" if market in {"1", "SH"} or code.startswith(("5", "6", "9")) else "SZ"
first = str(item.get("fbt") or item.get("first_time") or "")
last = str(item.get("lbt") or item.get("last_time") or "")
rows.append(
{
"ts_code": f"{code}.{suffix}",
"limit_type": limit_type,
"first_time": first,
"last_time": last,
"fd_amount": item.get("fund") or item.get("fd_amount"),
"open_times": item.get("zbc") or item.get("open_times"),
"limit_times": item.get("lbc") or item.get("limit_times"),
"turnover_ratio": item.get("hs") or item.get("turnover_ratio"),
"source": "eastmoney_zt_pool",
}
)
if not rows:
raise AdapterError("Eastmoney limit pool empty")
return rows
def _get_json(self, url: str, params: dict[str, str], referer: str) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"User-Agent": BROWSER_UA,
"Referer": referer,
},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise AdapterError(f"eastmoney request failed: {exc}") from exc
def _preferred_session(points: list[dict[str, Any]], preferred_date: str = "") -> list[dict[str, Any]]:
if not points:
return []
want = ""
digits = str(preferred_date or "").replace("-", "")[:8]
if len(digits) == 8 and digits.isdigit():
want = f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}"
if want:
matched = [point for point in points if str(point.get("date") or "") == want]
if matched:
return matched
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 _normalize_market_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 = round4(finite_number(row.get("f2")))
previous_close = round4(finite_number(row.get("f18")))
if close <= 0 or previous_close <= 0:
return None
market = int(finite_number(row.get("f13")) or 0)
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(finite_number(row.get("f124")) or 0)
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,
"previous_close": previous_close,
"open": round4(finite_number(row.get("f17"))),
"high": round4(finite_number(row.get("f15"))),
"low": round4(finite_number(row.get("f16"))),
"close": close,
"price": close,
"pct_chg": round4(finite_number(row.get("f3"))),
"vol": round4(finite_number(row.get("f5")) * 100),
"volume": round4(finite_number(row.get("f5")) * 100),
"amount": round4(finite_number(row.get("f6"))),
"quote_date": quote_date,
"quote_time_epoch": epoch,
"source": "eastmoney_clist",
}
def _parse_trend(raw: Any) -> dict[str, Any] | None:
text = str(raw or "")
parts = text.split(",")
if len(parts) < 8:
return None
stamp = parts[0]
try:
when = datetime.strptime(stamp, "%Y-%m-%d %H:%M")
except ValueError:
return None
close = round4(finite_number(parts[2]))
if close <= 0:
return None
return {
"time": when.strftime("%H:%M"),
"date": when.strftime("%Y-%m-%d"),
"open": round4(finite_number(parts[1])),
"close": close,
"high": round4(finite_number(parts[3])),
"low": round4(finite_number(parts[4])),
"avg_price": round4(finite_number(parts[7] if len(parts) > 7 else parts[2])),
"volume": round4(finite_number(parts[5])),
"amount": round4(finite_number(parts[6])),
}
+432
View File
@@ -0,0 +1,432 @@
from __future__ import annotations
import copy
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from typing import Any, Callable
from datahub.adapters.base import AdapterError, MarketAdapter
UrlOpen = Callable[..., Any]
class IfindAdapter(MarketAdapter):
"""Licensed iFinD source used only inside the data hub."""
name = "ifind"
BASE_URL = "https://quantapi.51ifind.com/api/v1"
AUTH_ENDPOINT = "get_access_token"
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str = "",
access_token: str = "",
timeout: int = 15,
urlopen: UrlOpen = urllib.request.urlopen,
) -> None:
self.timeout = max(3, int(timeout))
self._urlopen = urlopen
self._refresh_token = str(refresh_token or "").strip()
self._access_token = str(access_token or "").strip()
self._access_expires_at: datetime | None = None
self._token_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, dict[str, Any]] = {}
@property
def configured(self) -> bool:
return bool(self._refresh_token or self._access_token)
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
refresh_token = str(refresh_token or "").strip()
access_token = str(access_token or "").strip()
with self._token_lock:
refresh_changed = refresh_token != self._refresh_token
self._refresh_token = refresh_token
if access_token or refresh_changed:
self._access_token = access_token
self._access_expires_at = None
if refresh_changed:
with self._cache_lock:
self._cache.clear()
def status(self) -> dict[str, Any]:
return {
"configured": self.configured,
"access_ready": bool(self._access_token),
"access_expires_at": (
self._access_expires_at.isoformat(timespec="seconds")
if self._access_expires_at
else ""
),
}
def probe(self) -> dict[str, Any]:
started = time.perf_counter()
if not self.configured:
return {
"provider": self.name,
"configured": False,
"state": "unconfigured",
"message": "iFinD token 未配置",
"latency_ms": round((time.perf_counter() - started) * 1000),
}
try:
rows = self.real_time("000001.SH", ["latest"], cache_ttl=0)
state = "ok" if rows else "empty"
return {
"provider": self.name,
"configured": True,
"state": state,
"latency_ms": round((time.perf_counter() - started) * 1000),
}
except AdapterError as exc:
return {
"provider": self.name,
"configured": True,
"state": "error",
"message": str(exc),
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
if dataset == "wencai":
return self.wencai(
str(params.get("query") or params.get("searchstring") or ""),
str(params.get("search_type") or params.get("searchtype") or "stock"),
int(params.get("cache_ttl") or 300),
)
if dataset == "snapshots":
return self.snapshots(
params.get("codes") or "",
_indicators(params.get("indicators")),
str(params.get("start_time") or ""),
str(params.get("end_time") or ""),
int(params.get("cache_ttl") or 8),
)
if dataset == "history":
return self.history(
params.get("codes") or "",
_indicators(params.get("indicators") or ["close", "volume", "amount"]),
str(params.get("start_date") or ""),
str(params.get("end_date") or ""),
int(params.get("cache_ttl") or 300),
)
if dataset == "realtime":
return self.real_time(
params.get("codes") or "",
_indicators(params.get("indicators") or ["latest"]),
int(params.get("cache_ttl") or 10),
)
if dataset == "intraday":
return self.intraday(
str(params.get("code") or params.get("codes") or ""),
str(params.get("start_time") or ""),
str(params.get("end_time") or ""),
int(params.get("cache_ttl") or 20),
)
raise AdapterError(f"{self.name} unsupported dataset: {dataset}")
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return list(rows)
def real_time(
self,
codes: str | list[str],
indicators: list[str],
cache_ttl: int = 10,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"real_time_quotation",
{"codes": code_text, "indicators": ",".join(indicators)},
cache_key=f"rq:{code_text}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def history(
self,
codes: str | list[str],
indicators: list[str],
start_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"cmd_history_quotation",
{
"codes": code_text,
"indicators": ",".join(indicators),
"startdate": self._display_date(start_date),
"enddate": self._display_date(end_date),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def intraday(
self,
code: str,
start_time: str,
end_time: str,
cache_ttl: int = 20,
) -> list[dict[str, Any]]:
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
payload = self._request(
"high_frequency",
{
"codes": self._codes(code),
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
cache_key=f"hf:{code}:{start_time}:{end_time}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def snapshots(
self,
codes: str | list[str],
indicators: list[str],
start_time: str,
end_time: str,
cache_ttl: int = 8,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"snap_shot",
{
"codes": code_text,
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
},
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
normalized = " ".join(str(query or "").split())
if not normalized:
raise AdapterError("问财查询不能为空。")
payload = self._request(
"smart_stock_picking",
{"searchstring": normalized, "searchtype": search_type},
cache_key=f"wc:{search_type}:{normalized}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def _request(
self,
endpoint: str,
body: dict[str, Any],
cache_key: str = "",
cache_ttl: int = 0,
) -> dict[str, Any]:
if not self.configured:
raise AdapterError("iFinD 尚未配置。")
if cache_key and cache_ttl > 0:
cached = self._cached(cache_key, cache_ttl)
if cached is not None:
return cached
payload = self._post(endpoint, body, self._ensure_access_token())
if self._is_auth_error(payload) and self._refresh_token:
self._invalidate_access_token()
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
self._validate_payload(payload)
if cache_key and cache_ttl > 0:
with self._cache_lock:
self._cache[cache_key] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
def _ensure_access_token(self, force: bool = False) -> str:
with self._token_lock:
now = datetime.now().astimezone().replace(tzinfo=None)
token_valid = bool(self._access_token) and (
self._access_expires_at is None
or self._access_expires_at > now + timedelta(minutes=2)
)
if token_valid and not force:
return self._access_token
if not self._refresh_token:
if self._access_token:
return self._access_token
raise AdapterError("iFinD Refresh Token 尚未配置。")
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
self._validate_payload(payload)
data = payload.get("data") or {}
token = str(data.get("access_token") or "").strip()
if not token:
raise AdapterError("iFinD 未返回 Access Token。")
expires_at = self._parse_datetime(data.get("expired_time"))
self._access_token = token
self._access_expires_at = expires_at
return token
def _post(
self,
endpoint: str,
body: dict[str, Any],
access_token: str,
refresh_token: str = "",
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiDatahub/1.0",
"ifindlang": "cn",
}
if access_token:
headers["access_token"] = access_token
if refresh_token:
headers["refresh_token"] = refresh_token
request = urllib.request.Request(
f"{self.BASE_URL}/{endpoint}",
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with self._urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
except (json.JSONDecodeError, OSError):
pass
raise AdapterError(f"iFinD HTTP {exc.code}{f'{detail[:160]}' if detail else ''}") from exc
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise AdapterError("iFinD 数据请求失败。") from exc
if not isinstance(payload, dict):
raise AdapterError("iFinD 返回格式不正确。")
return payload
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > ttl:
self._cache.pop(key, None)
return None
return copy.deepcopy(cached["payload"])
def _invalidate_access_token(self) -> None:
with self._token_lock:
self._access_token = ""
self._access_expires_at = None
@classmethod
def _validate_payload(cls, payload: dict[str, Any]) -> None:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = -1
if error_code != 0:
message = str(payload.get("errmsg") or "未知错误")
raise AdapterError(f"iFinD 返回错误:{message[:200]}")
@classmethod
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = 0
message = str(payload.get("errmsg") or "").casefold()
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
@staticmethod
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
tables = payload.get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables if isinstance(tables, list) else []:
if not isinstance(block, dict):
continue
table = block.get("table") or {}
if not isinstance(table, dict):
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
lengths = [len(value) for value in table.values() if isinstance(value, list)]
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
for index in range(row_count):
row: dict[str, Any] = {}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
for field, values in table.items():
if isinstance(values, list):
row[field] = values[index] if index < len(values) else None
elif index == 0:
row[field] = values
rows.append(row)
return rows
@staticmethod
def _codes(codes: str | list[str]) -> str:
if isinstance(codes, list):
values = [str(code or "").strip().upper() for code in codes]
else:
values = [part.strip().upper() for part in str(codes or "").split(",")]
values = [value for value in values if value]
if not values:
raise AdapterError("iFinD 证券代码不能为空。")
if len(values) > 100:
raise AdapterError("iFinD 单次证券代码过多。")
return ",".join(values)
@staticmethod
def _display_date(value: str) -> str:
compact = str(value or "").replace("-", "")
if len(compact) != 8 or not compact.isdigit():
raise AdapterError("iFinD 日期格式不正确。")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
return None
def _indicators(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return [part.strip() for part in str(value or "").split(",") if part.strip()]
ADAPTER = IfindAdapter()
+218
View File
@@ -0,0 +1,218 @@
from __future__ import annotations
import time
import urllib.request
from datetime import datetime
from typing import Any
from datahub.adapters.base import AdapterError, MarketAdapter
from datahub.numbers import finite_number, round4
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
TENCENT_QUOTE_URL = "https://qt.gtimg.cn/q="
TENCENT_QUOTE_BATCH = 80
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
)
class TencentAdapter(MarketAdapter):
name = "tencent"
def __init__(self, timeout: int = 8) -> None:
self.timeout = timeout
def probe(self) -> dict[str, Any]:
started = time.perf_counter()
try:
rows = self.fetch_indices()
state = "ok" if len(rows) == 3 else "empty"
except AdapterError as exc:
return {
"provider": self.name,
"configured": True,
"state": "error",
"message": str(exc),
"latency_ms": round((time.perf_counter() - started) * 1000),
}
return {
"provider": self.name,
"configured": True,
"state": state,
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
if dataset in {"indexes_quotes", "index_quotes"}:
return self.fetch_indices()
if dataset in {"quotes", "quotes_latest"}:
codes = params.get("codes") or []
if isinstance(codes, str):
codes = [item.strip() for item in codes.split(",") if item.strip()]
return self.fetch_quotes(list(codes))
if dataset in {"quotes_market", "market_quotes"}:
codes = params.get("codes") or []
if isinstance(codes, str):
codes = [item.strip() for item in codes.split(",") if item.strip()]
return self.fetch_quotes(list(codes))
raise AdapterError(f"{self.name} unsupported dataset: {dataset}")
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return list(rows)
def fetch_indices(self) -> list[dict[str, Any]]:
request = urllib.request.Request(
TENCENT_INDEX_URL,
headers={"User-Agent": BROWSER_UA, "Referer": "https://gu.qq.com/"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read().decode("gb18030", errors="ignore")
except Exception as exc:
raise AdapterError(f"tencent request failed: {exc}") from exc
result = []
for line in raw.splitlines():
if '="' not in line:
continue
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
if len(fields) < 38:
continue
code = fields[2]
if code not in {"000001", "399001", "399006"}:
continue
try:
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
except ValueError as exc:
raise AdapterError(f"Tencent invalid quote time for {code}") from exc
ts_code = "000001.SH" if code == "000001" else f"{code}.SZ"
result.append(
{
"ts_code": ts_code,
"code": code,
"name": fields[1] or code,
"price": round4(finite_number(fields[3])),
"pct_chg": round4(finite_number(fields[32])),
"change_amount": round4(finite_number(fields[31])),
"open": round4(finite_number(fields[5])),
"high": round4(finite_number(fields[33])),
"low": round4(finite_number(fields[34])),
"previous_close": round4(finite_number(fields[4])),
"amount": round4(finite_number(fields[37]) * 10000),
"quote_time_epoch": int(quote_time.timestamp()),
"quote_time": quote_time.isoformat(timespec="seconds"),
"source": "tencent_qt",
}
)
if len(result) != 3:
raise AdapterError(f"Tencent returned {len(result)}/3 indices")
return result
def fetch_quotes(self, codes: list[str]) -> list[dict[str, Any]]:
symbols: list[str] = []
seen: set[str] = set()
for raw in codes:
symbol = _tencent_symbol(str(raw or ""))
if not symbol or symbol in seen:
continue
seen.add(symbol)
symbols.append(symbol)
if not symbols:
return []
result: list[dict[str, Any]] = []
errors: list[str] = []
for index in range(0, len(symbols), TENCENT_QUOTE_BATCH):
batch = symbols[index:index + TENCENT_QUOTE_BATCH]
try:
raw = self._get_text(f"{TENCENT_QUOTE_URL}{','.join(batch)}")
except AdapterError as exc:
errors.append(str(exc))
continue
for line in raw.splitlines():
quote = _parse_tencent_stock_quote(line)
if quote:
result.append(quote)
if not result:
detail = f"{'; '.join(errors[:3])}" if errors else ""
raise AdapterError(f"Tencent quotes empty{detail}")
return result
def _get_text(self, url: str) -> str:
request = urllib.request.Request(
url,
headers={"User-Agent": BROWSER_UA, "Referer": "https://gu.qq.com/"},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return response.read().decode("gb18030", errors="ignore")
except Exception as exc:
raise AdapterError(f"tencent request failed: {exc}") from exc
def _tencent_symbol(code: str) -> str:
raw = str(code or "").strip().upper()
if not raw:
return ""
symbol = raw.split(".")[0]
if not symbol.isdigit() or len(symbol) != 6:
return ""
if raw.endswith(".SH") or symbol.startswith(("5", "6", "9")):
return f"sh{symbol}"
if raw.endswith(".BJ") or symbol.startswith(("4", "8")):
return f"bj{symbol}"
return f"sz{symbol}"
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 = str(fields[2] or "")
if not symbol.isdigit() or len(symbol) != 6:
return None
close = round4(finite_number(fields[3]))
previous = round4(finite_number(fields[4]))
if not close or not previous or close <= 0 or previous <= 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"
quote_stamp = ""
quote_date = ""
epoch = 0
try:
parsed = datetime.strptime(fields[30], "%Y%m%d%H%M%S")
quote_date = parsed.strftime("%Y%m%d")
epoch = int(parsed.timestamp())
quote_stamp = parsed.astimezone().isoformat(timespec="seconds")
except ValueError:
pass
return {
"ts_code": ts_code,
"name": fields[1] or symbol,
"price": close,
"close": close,
"pct_chg": round4(finite_number(fields[32])),
"change_amount": round4(finite_number(fields[31])),
"open": round4(finite_number(fields[5])),
"high": round4(finite_number(fields[33])),
"low": round4(finite_number(fields[34])),
"pre_close": previous,
"previous_close": previous,
"volume": round4(finite_number(fields[6]) * 100),
"vol": round4(finite_number(fields[6]) * 100),
"amount": round4(finite_number(fields[37]) * 10000),
"quote_date": quote_date,
"quote_time_epoch": epoch,
"quote_time": quote_stamp,
"source": "tencent_qt",
}
+3
View File
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("ths")

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