Compare commits

...
Author SHA1 Message Date
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
总工andmultica-agent 51f410d942 部署(HEL-235B): 服务器本地目录纳入 Git 管理,固化 main 校验构建流程
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 23:22:59 +08:00
总工 a8732f51be 部署集成(HEL-252): 合入登录页动态小人 4a63ccd 2026-08-29 21:59:09 +08:00
4a63ccd10f feat(HEL-251): 按确认稿实现登录页动态小K线人物
在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 21:46:05 +08:00
总工 865d8b0516 部署集成(HEL-244): 合入切换账号返回修复 d2a165a 2026-08-29 21:01:12 +08:00
d2a165ada8 fix(HEL-243): 当前账号可点击返回,避免切换死路
当前账号行原先只显示对号、不可点击,单账号时只能关网页。现改为整行继续使用且不重新签发会话,并带回切换前的业务页。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 20:44:18 +08:00
总工 e29d5115fa 部署集成(HEL-241): 合入桌面登录门户返工 aef8a05(含 28px 底部缺口修复) 2026-08-29 20:09:41 +08:00
aef8a059f2 fix(HEL-240): 去掉登录页底部状态栏缺口
登录页覆盖全局 body 底边距,让左栏品牌面板铺满视口高度。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 20:04:22 +08:00
f905b44675 feat(HEL-240): 按确认样图还原桌面登录页左右骨架
把登录门户改回确认稿的品牌行、中下部主标题、底部行情标签和 K 线背景,并让超宽屏按比例展开左栏。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 19:21:13 +08:00
541fb48c1c feat(HEL-238): 按确认样图重做手机端系统管理,并加入部署基线门禁
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 17:34:50 +08:00
111 changed files with 10547 additions and 472 deletions
+5
View File
@@ -5,6 +5,11 @@ APP_ENCRYPTION_KEY=
# the system settings; all accounts use the same backend market snapshot. # the system settings; all accounts use the same backend market snapshot.
TUSHARE_TOKEN=your_tushare_token_here TUSHARE_TOKEN=your_tushare_token_here
# Optional xiaobai-datahub client. All DATAHUB_READ_* / DATAHUB_SHADOW_* flags
# default off in config/datahub.config.json, so the website keeps using Tushare.
DATAHUB_BASE_URL=http://127.0.0.1:8766
DATAHUB_TOKEN=
# Optional iFinD HTTP credential. The backend exchanges it for a short-lived # Optional iFinD HTTP credential. The backend exchanges it for a short-lived
# access token and never exposes either token to browsers. # access token and never exposes either token to browsers.
IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
+3
View File
@@ -8,6 +8,9 @@ data/*.db
data/*.db-shm data/*.db-shm
data/*.db-wal data/*.db-wal
data/backups/ data/backups/
datahub-data/
xiaobai-datahub/data/
xiaobai-datahub/.venv/
data/*.bak data/*.bak
data/*.backup data/*.backup
*.log *.log
+3 -1
View File
@@ -54,7 +54,9 @@ background scheduler
feature repository mixins; do not add feature queries to it. feature repository mixins; do not add feature queries to it.
- `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state. - `backend/jobs/` owns job definitions, locks, retries, idempotency, and persisted run state.
`backend/jobs/service.py` is the application-facing owner of scheduler start/stop, manual `backend/jobs/service.py` is the application-facing owner of scheduler start/stop, manual
refresh submission, and periodic refresh coordination. refresh submission, and periodic refresh coordination. `backend/jobs/refresh.py` owns
whether a dashboard payload is a usable refresh result versus a failed job, and whether
after-hours official catch-up is due.
- `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport, - `backend/llm/` owns model selection, membership/quota checks, fallback, provider transport,
streaming rules, and call audit. Feature agents only prepare messages and interpret streaming rules, and call audit. Feature agents only prepare messages and interpret
feature-specific results. feature-specific results.
+36 -65
View File
@@ -165,87 +165,58 @@ docker compose restart xiaobai-review
docker compose down docker compose down
``` ```
### 镜像构建的唯一安全入口(2026-08 HEL-235 起 ### 服务器本地目录更新与构建(日常推荐
生产机 `192.168.200.11` `/opt/1panel/docker/compose/xiaobaifupan` 只是历史文件树: 生产机 `192.168.200.11``/opt/1panel/docker/compose/xiaobaifupan` 自 2026-08-29HEL-235B
不是 Git 仓库、内容停在旧提交、与线上镜像不一致,且其 `compose.yaml` 会把构建结果打进 起已是受 Git 管理的工作目录,只跟踪 Gitea `main`(仓库
`xiaobai-review:latest`。**禁止在该目录(或任何服务器工作树)里 `docker build` / `http://192.168.200.36:3200/leefer/xiaobai-review.git`)。由于目录顶层归 root
`docker compose build`**,否则会把已上线功能悄悄打回旧版。 `.git` 存放在部署账号家目录(外部 Git 目录方案):
唯一安全构建方式是在有仓库检出、能免密 SSH 到部署机的机器上运行:
```bash
tools/build_image.sh <提交号> <镜像tag>
# 示例:tools/build_image.sh cefc86917d89 verify-hel235-cefc869
```
该脚本的行为约束:
-`git fetch`,再把提交号解析为完整 SHA,解析失败立即中止,绝不使用本地脏状态或服务器旧目录;
- 镜像 tag 必须以 `-<提交短号7位>` 结尾(如 `hel234-cefc869`),禁止 `latest``rollback-*`
- 通过 `git archive <提交> | ssh 部署机 docker build -` 流式构建,服务器上不存在构建用工作树;
- 构建后回读镜像 label 里的 `org.opencontainers.image.revision`,与预期提交不一致则删除镜像并中止;
- 每次构建在部署机 `~/xiaobai-build/BUILD_LOG.tsv` 留痕,可追溯每个镜像的来源提交。
构建只产出镜像,不启动、不替换任何容器;换版用新 tag 起新容器,回滚用既有镜像 tag 重跑。
### 使用 Gitea 更新程序(旧方式,生产机禁用)
代码仓库为:
```text ```text
http://192.168.200.36:3200/leefer/xiaobaifupan.git ~/xiaobai-build/repos/xiaobai-review.git Git 元数据(分支/历史/索引)
/opt/1panel/docker/compose/xiaobaifupan 工作目录(程序文件本体)
~/xiaobai-build/update-from-main.sh 一键更新+构建入口
~/xiaobai-git 便捷查看(status/log/diff
``` ```
首次在服务器部署代码时,可以直接克隆到目标目录 日常更新只需要在服务器上执行一条命令
```bash ```bash
sudo mkdir -p /opt/xiaobai-review ~/xiaobai-build/update-from-main.sh # 更新到 main 并构建 main-<短号> 镜像
sudo chown "$USER":"$USER" /opt/xiaobai-review ~/xiaobai-build/update-from-main.sh verify-tag main-a8732f5 # 部署前复核镜像与 main 一致
git clone http://192.168.200.36:3200/leefer/xiaobaifupan.git /opt/xiaobai-review
cd /opt/xiaobai-review
``` ```
私有仓库会提示输入 Gitea 用户名和密码或访问令牌。不要把密码写入仓库 URL、 脚本在构建前强制完成五道校验,任一不符立即停止、不产出镜像:
`compose.yaml` 或脚本。然后把原 `.env``data/` 放回该目录;这两项已被 Git
忽略,后续拉取代码不会覆盖数据库与密钥。
如需部署管理员私有问师,通过 NAS 文件管理器将本地 1. `git fetch` 成功(连不上 Gitea 即停);
`data/private-mentor-skills/` 复制到服务器项目的同名 `data` 目录,并保持目录仅由 2. 必须在 `main` 分支(智能体不得用功能分支直接当正式线);
部署账号和容器运行用户读取。该内容不会通过 Gitea 同步。 3. 工作区无未提交改动、无多余文件;
4. 只允许快进合并到 `origin/main`(分叉即停);main 新增/删除顶层文件时会给出
需管理员执行的精确清单(目录顶层归 root);
5. 构建后回读镜像 `org.opencontainers.image.revision`,与 `main` 提交不一致则删除镜像。
每次更新前先创建 SQLite 一致性备份,再拉取并重建容器(注意:`docker compose up -d --build` 镜像 tag 固定为 `main-<提交短号7位>`(不带提交号的模糊 tag 一律禁止);每次构建在
从服务器本地工作树构建,仅适用于来源可信的全新环境;生产机 `192.168.200.11` 禁用, `~/xiaobai-build/BUILD_LOG.tsv` 留痕。构建只产出镜像,不启动、不替换容器;换版与
请用 `tools/build_image.sh` 构建后换容器): 回滚步骤见 `~/xiaobai-build/README.md`
```bash `compose.yaml` 的镜像名与 revision 标签同样做了强校验:直接 `docker compose up -d --build`
cd /opt/xiaobai-review 会因缺少 `XIAOBAI_GIT_REV` / `XIAOBAI_GIT_SHORT` 变量而拒绝执行,避免再出现构建进
docker compose exec -T xiaobai-review python -c "import sqlite3; s=sqlite3.connect('/app/data/review.db'); d=sqlite3.connect('/app/data/review-before-update.db'); s.backup(d); d.close(); s.close()" `latest` 的模糊版本。需要用 compose 时先 `export` 这两个变量(值以
git pull --ff-only origin main `~/xiaobai-build/xiaobai-git rev-parse HEAD` 为准),或直接用上面的脚本。
docker compose up -d --build
docker compose ps
curl --fail http://127.0.0.1:8765/api/health
```
`docker compose up -d --build` 会原地替换应用容器,不删除宿主机的 `data` 目录。 ### 智能体高级入口:Git 归档流式构建
数据库迁移会在新容器启动时自动执行。若 `git pull --ff-only` 提示本地代码有修改,
先用 `git status` 查明原因,不要用强制重置覆盖 `.env``data`
### 不使用 Git 时更新(生产机禁用) 有仓库检出、能免密 SSH 到部署机的智能体可以用 `tools/build_image.sh <提交号> <镜像tag>`
从任意明确提交流式构建(`git archive | ssh docker build`),tag 同样必须以
`-<提交短号7位>` 结尾,构建后回读 revision 校验并留痕。用于在服务器不便拉取时的
应急构建;日常正式线仍应走 `main`
`docker compose build` 会从服务器本地目录构建,来源提交不可追溯。生产机 ### 历史方式(已废弃)
`192.168.200.11` 上禁止使用本节方式,一律改用上一节的 `tools/build_image.sh`
重新上传代码后执行: 早期文档建议在服务器重新 `git clone` 一份或手工上传代码后 `docker compose up --build`
这两条路径已废弃:服务器上**只允许存在一个受管工作目录**(上述
```bash `/opt/1panel/docker/compose/xiaobaifupan`),任何脱离 Git 校验的本地构建都会把
docker compose down 来源提交变成不可追溯状态,禁止使用。
docker compose build --pull
docker compose up -d
```
`docker compose down` 不会删除宿主机的 `data` 目录。不要使用带有手工删除
`data` 目录的清理命令。
## 7. 备份与恢复 ## 7. 备份与恢复
+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` 都在根目录)
“我的复盘”包含结构化手工交易日志,可记录方向、价格、数量、仓位、盈亏、逻辑、执行、情绪和标签,不接券商也不自动下单。顶部“复盘助手”以流式方式读取市场统计、策略跟踪、提醒、个人复盘和交易日志;对话按账号保存,只提供分析和条件化计划。 ```bash
## 启动
```powershell
cd app
python -m pip install -r requirements.txt python -m pip install -r requirements.txt
python server.py 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
powershell -ExecutionPolicy Bypass -File tools/start_local.ps1 -Port 8797 powershell -ExecutionPolicy Bypass -File tools/start_local.ps1
``` ```
局域网 Docker 部署使用 `Dockerfile``compose.yaml`,完整的迁移、持久化、 该脚本默认端口为 `8797`。统一验收:
防火墙、备份和恢复步骤见 [DOCKER_DEPLOY.md](DOCKER_DEPLOY.md)。
账号密码使用 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 ```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=元器件 - 「问天」属于传统文化视角的观察工具,不具备预测功能,不得作为投资依据。问天不是永久冻结区:此前只冻结过界面视觉方案,现已解冻,后续数据与功能迁移可以纳入。
``` - 行情来自第三方接口,可能延迟、缺失或口径调整;不可用时页面会明确提示,请以交易所与券商正式披露为准。
- 不要把服务端口直接暴露到公网。不要把 Token、密码、密钥、数据库或 `.env` 提交进 Git。
返回内容包括东方财富三大指数及板块快照、指数时间差、同花顺和选股宝可用性、每个来源的耗时与错误。盘中指数时间差不超过15秒,收盘后不超过120秒。`ready=true` 仅表示本次验证满足聚合层约束,不代表这些网页内部接口具有长期稳定性或商业使用授权 - 股市有风险,入市需谨慎。投资决策及其后果由使用者本人承担
+12
View File
@@ -1,11 +1,23 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
from typing import Any from typing import Any
def configure_logging() -> None:
"""让 INFO 级结构化日志(含 datahub 影子对比报告)落到容器日志。"""
if logging.getLogger().handlers:
return
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None: def main(handler_class: type[Any] | None = None, service: Any | None = None) -> None:
configure_logging()
if handler_class is None or service is None: if handler_class is None or service is None:
from backend.application import RequestHandler, SERVICE from backend.application import RequestHandler, SERVICE
+2
View File
@@ -11,6 +11,8 @@ from backend.features.accounts.security import SecretVault
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]: def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
return { return {
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(), "tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
"ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(), "ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(),
"ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(), "ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(),
"platform_llm_primary_api_key": str( "platform_llm_primary_api_key": str(
+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",
]
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
import logging
import sys
from typing import Any, Callable
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.settings import DatahubSettings
from backend.data.providers.tushare_client import TushareClient
LOGGER = logging.getLogger("xiaobai.datahub")
ShadowSink = Callable[[dict[str, Any]], None]
EMPTY_FAIL_DATASETS = {"stocks", "daily", "index_daily", "valuation", "moneyflow", "auction"}
def looks_like_heaven(module_name: str, filename: str = "") -> bool:
"""问天调用栈识别。问天未永久冻结,只是本阶段仍走旧 Tushare 链路。"""
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 query(
self,
api_name: str,
params: dict[str, Any] | None,
fields: str,
legacy_query: Callable[..., list[dict[str, Any]]],
) -> list[dict[str, Any]]:
dataset = API_TO_DATASET.get(api_name)
# 问天允许后续纳入 datahub;首批只读接入仍保持旧链路,避免误切。
if not dataset or self.heaven_guard():
return legacy_query(api_name, params, fields)
flags = self.settings.flags(dataset)
if not flags.read and not flags.shadow:
return legacy_query(api_name, params, fields)
hub_rows: list[dict[str, Any]] | None = None
hub_meta: dict[str, Any] = {}
hub_error: str | None = None
hub_canonical: list[dict[str, Any]] = []
try:
response = self._fetch_dataset(dataset, params or {})
hub_canonical = self._extract_rows(dataset, response, params or {})
hub_rows = to_native_rows(dataset, hub_canonical)
hub_meta = dict(response.meta)
self._validate_usable(dataset, hub_rows, response)
except Exception as exc:
hub_error = self._error_text(exc)
self._log_failure(dataset, exc)
if flags.shadow:
try:
legacy_rows = legacy_query(api_name, params, fields)
except Exception as exc:
if flags.read and hub_rows is not None and hub_error is None:
self._emit_shadow(compare_rows(dataset, [], hub_canonical, hub_meta, self._error_text(exc)))
return project_fields(hub_rows, fields)
raise
self._emit_shadow(compare_rows(dataset, legacy_rows, hub_canonical, hub_meta, hub_error))
if flags.read and hub_rows is not None and hub_error is None:
return project_fields(hub_rows, fields)
return legacy_rows
if flags.read and hub_rows is not None and hub_error is None:
return project_fields(hub_rows, fields)
return legacy_query(api_name, params, fields)
def _fetch_dataset(self, dataset: str, params: dict[str, Any]) -> DatahubResponse:
date = yyyymmdd(params.get("trade_date") or params.get("date"))
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,
}
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"
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) -> 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 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:
LOGGER.warning(
"datahub fallback dataset=%s error=%s",
dataset,
redact_text(self._error_text(exc), 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())
class DatahubAwareTushareClient:
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None:
self._legacy = legacy
self._bridge = bridge
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, self._legacy.query)
def __getattr__(self, name: str) -> Any:
return getattr(self._legacy, name)
+184
View File
@@ -0,0 +1,184 @@
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 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 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 _request(self, url: str) -> DatahubResponse:
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"X-Datahub-Token": self.settings.token,
"User-Agent": "XiaobaiReviewDatahub/1.0",
},
method="GET",
)
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)}
+134
View File
@@ -0,0 +1,134 @@
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
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,
) -> dict[str, Any]:
hub = hub_rows or []
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)
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,
"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 _compare_fields(dataset: str, legacy: dict[str, Any], hub: dict[str, Any]) -> 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"}
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}"
+153
View File
@@ -0,0 +1,153 @@
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",
}
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},
}
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)
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")))
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
+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
+120
View File
@@ -0,0 +1,120 @@
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",
"status",
)
ENV_DATASET = {
"calendar": "CALENDAR",
"stocks": "STOCKS",
"daily": "DAILY",
"index_daily": "INDEX_DAILY",
"valuation": "VALUATION",
"moneyflow": "MONEYFLOW",
"auction": "AUCTION",
"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,
)
+14 -1
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Any
from backend.data.contracts import DataUsage from backend.data.contracts import DataUsage
from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, DatahubClient, DatahubSettings
from backend.data.policy import DataSourcePolicy from backend.data.policy import DataSourcePolicy
from backend.data.providers import IfindProvider, TushareProvider from backend.data.providers import IfindProvider, TushareProvider
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
@@ -22,6 +24,7 @@ class DataGateway:
ifind_provider: IfindProvider ifind_provider: IfindProvider
chart_data: MarketChartClient chart_data: MarketChartClient
realtime_observer: WebRealtimeAggregator realtime_observer: WebRealtimeAggregator
datahub: DatahubBridge
@property @property
def ifind(self) -> IfindHttpClient: def ifind(self) -> IfindHttpClient:
@@ -34,7 +37,13 @@ class DataGateway:
) -> TushareClient: ) -> TushareClient:
if dataset_id: if dataset_id:
self.policy.assert_allowed(dataset_id, "tushare", usage) self.policy.assert_allowed(dataset_id, "tushare", usage)
return self.tushare_provider.client() return DatahubAwareTushareClient(self.tushare_provider.client(), 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 assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None: def assert_source(self, dataset_id: str, provider_id: str, usage: DataUsage) -> None:
self.policy.assert_allowed(dataset_id, provider_id, usage) self.policy.assert_allowed(dataset_id, provider_id, usage)
@@ -64,6 +73,7 @@ class DataGateway:
def build_data_gateway( def build_data_gateway(
credentials: dict[str, object], credentials: dict[str, object],
tushare_token_supplier: Callable[[], str] | None = None, tushare_token_supplier: Callable[[], str] | None = None,
datahub_settings: DatahubSettings | None = None,
) -> DataGateway: ) -> DataGateway:
ifind = IfindHttpClient( ifind = IfindHttpClient(
str(credentials.get("ifind_refresh_token") or ""), str(credentials.get("ifind_refresh_token") or ""),
@@ -73,6 +83,8 @@ def build_data_gateway(
lambda: str(credentials.get("tushare_token") or "") lambda: str(credentials.get("tushare_token") or "")
) )
policy = DataSourcePolicy.load() policy = DataSourcePolicy.load()
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
datahub_client = DatahubClient(settings)
return DataGateway( return DataGateway(
policy=policy, policy=policy,
quality=DataQualityGate.load(policy), quality=DataQualityGate.load(policy),
@@ -80,4 +92,5 @@ def build_data_gateway(
ifind_provider=IfindProvider(ifind), ifind_provider=IfindProvider(ifind),
chart_data=MarketChartClient(ifind, EastmoneyChartClient()), chart_data=MarketChartClient(ifind, EastmoneyChartClient()),
realtime_observer=WebRealtimeAggregator(), realtime_observer=WebRealtimeAggregator(),
datahub=DatahubBridge(settings, datahub_client),
) )
+79 -10
View File
@@ -79,6 +79,8 @@ class MarketServiceMixin:
if not force: if not force:
snapshot = self.database.get_snapshot(normalized_date) snapshot = self.database.get_snapshot(normalized_date)
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo": if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
if self._should_retry_incomplete_snapshot(snapshot, normalized_date):
return self.sync_dashboard(normalized_date)
snapshot = copy.deepcopy(snapshot) snapshot = copy.deepcopy(snapshot)
if normalized_date != now.strftime("%Y%m%d"): if normalized_date != now.strftime("%Y%m%d"):
snapshot.setdefault("meta", {}).update( snapshot.setdefault("meta", {}).update(
@@ -97,6 +99,8 @@ class MarketServiceMixin:
"dashboard_request_v1", normalized_date "dashboard_request_v1", normalized_date
) )
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo": if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
if self._should_retry_incomplete_snapshot(resolved, normalized_date):
return self.sync_dashboard(normalized_date)
resolved = copy.deepcopy(resolved) resolved = copy.deepcopy(resolved)
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date( resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date normalized_date
@@ -138,6 +142,68 @@ class MarketServiceMixin:
def _display_compact_date(compact: str) -> str: def _display_compact_date(compact: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}" return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
@staticmethod
def _chinese_month_day(value: str) -> str:
compact = str(value or "").replace("-", "").replace("/", "")
if len(compact) < 8 or not compact[:8].isdigit():
return "最近可用交易日"
return f"{int(compact[4:6])}{int(compact[6:8])}"
@classmethod
def _preparing_display_notice(cls, actual_date: str, requested_date: str) -> str:
shown = cls._chinese_month_day(actual_date)
requested = str(requested_date or "").replace("-", "")
if requested == date.today().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 != date.today().strftime("%Y%m%d"):
return False
meta = snapshot.get("meta") or {}
incomplete = (
meta.get("limit_data_source") == "derived"
or bool(meta.get("carried_forward"))
or str(meta.get("trade_date") or "").replace("-", "") != requested_date
)
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)
else:
meta["data_status"] = "official"
meta.setdefault("display_notice", "")
return dashboard
def _carry_dashboard( def _carry_dashboard(
self, snapshot: dict[str, Any], requested_date: str, reason: str self, snapshot: dict[str, Any], requested_date: str, reason: str
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -152,7 +218,7 @@ class MarketServiceMixin:
"notice": reason, "notice": reason,
} }
) )
return carried return self._annotate_data_status(carried)
def _realtime_snapshot_due( def _realtime_snapshot_due(
self, self,
@@ -197,14 +263,14 @@ class MarketServiceMixin:
if not self.configured: if not self.configured:
raise TushareError("公共行情尚未配置") raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date) dashboard = self._tushare_client().dashboard(normalized_date)
meta = dashboard.setdefault("meta", {})
if (dashboard.get("meta") or {}).get("limit_data_source") == "derived": meta["source"] = source
raise TushareError( meta["requested_date"] = self._display_compact_date(normalized_date)
str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回") if meta.get("limit_data_source") == "derived":
meta.setdefault(
"notice",
"涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
) )
dashboard["meta"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
record_count = self._record_count(dashboard) record_count = self._record_count(dashboard)
actual_date = normalize_date( actual_date = normalize_date(
@@ -233,8 +299,11 @@ class MarketServiceMixin:
except TushareError as exc: except TushareError as exc:
fallback = self.database.get_latest_real_snapshot(normalized_date) fallback = self.database.get_latest_real_snapshot(normalized_date)
if fallback: if fallback:
actual = str((fallback.get("meta") or {}).get("trade_date") or "")
carried = self._carry_dashboard( carried = self._carry_dashboard(
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}" fallback,
normalized_date,
self._preparing_display_notice(actual, normalized_date),
) )
self.database.finish_sync( self.database.finish_sync(
sync_id, "fallback", self._record_count(carried), str(exc), "tushare" sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
@@ -1160,7 +1229,7 @@ class MarketServiceMixin:
"storage": "sqlite", "storage": "sqlite",
"cached": cached, "cached": cached,
} }
return result return self._annotate_data_status(result)
@staticmethod @staticmethod
def _record_count(dashboard: dict[str, Any]) -> int: def _record_count(dashboard: dict[str, Any]) -> int:
+8 -1
View File
@@ -109,7 +109,14 @@ class HttpTransportMixin:
return {} return {}
if length <= 0 or length > 65536: if length <= 0 or length > 65536:
raise ValueError("请求内容为空或过大。") raise ValueError("请求内容为空或过大。")
return json.loads(self.rfile.read(length).decode("utf-8")) raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise ValueError("请求不是合法 JSON。") from None
if not isinstance(payload, dict):
raise ValueError("请求不是合法 JSON。")
return payload
def serve_static(self, request_path: str) -> None: def serve_static(self, request_path: str) -> None:
relative = unquote(request_path).lstrip("/") or "index.html" relative = unquote(request_path).lstrip("/") or "index.html"
+46
View File
@@ -0,0 +1,46 @@
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")
):
return False
return True
+11 -12
View File
@@ -5,16 +5,7 @@ import time
from datetime import date from datetime import date
from backend.bootstrap.config import normalize_date from backend.bootstrap.config import normalize_date
from backend.jobs.refresh import official_catchup_due, verified_dashboard_result
def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]:
meta = dashboard.get("meta") or {}
if isinstance(meta, dict) and meta.get("carried_forward"):
return {
"status": "failed",
"error": str(meta.get("notice") or "未获取到所选日期的最新行情"),
}
return dashboard
class JobServiceMixin: class JobServiceMixin:
@@ -36,7 +27,7 @@ class JobServiceMixin:
started = self.jobs.submit( started = self.jobs.submit(
"market.refresh", "market.refresh",
key, key,
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)), lambda: verified_dashboard_result(self.sync_dashboard(normalized)),
{"trade_date": normalized, "trigger": "administrator"}, {"trade_date": normalized, "trigger": "administrator"},
) )
return {"started": started, "job_key": key if started else ""} return {"started": started, "job_key": key if started else ""}
@@ -54,7 +45,15 @@ class JobServiceMixin:
self.jobs.submit( self.jobs.submit(
"market.refresh", "market.refresh",
f"realtime:{today}:{bucket}", f"realtime:{today}:{bucket}",
lambda: self.sync_dashboard(today), lambda: verified_dashboard_result(self.sync_dashboard(today)),
{"trade_date": today, "trigger": "realtime-poll"}, {"trade_date": today, "trigger": "realtime-poll"},
) )
elif official_catchup_due(today, snapshot):
bucket = int(time.time() // 300)
self.jobs.submit(
"market.refresh",
f"catchup:{today}:{bucket}",
lambda: verified_dashboard_result(self.sync_dashboard(today)),
{"trade_date": today, "trigger": "official-catchup"},
)
self._schedule_automatic_screeners(today, snapshot) self._schedule_automatic_screeners(today, snapshot)
+45
View File
@@ -0,0 +1,45 @@
# 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:-}"
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"
+3 -1
View File
@@ -3,7 +3,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: xiaobai-review:latest labels:
org.opencontainers.image.revision: "${XIAOBAI_GIT_REV:?必须先设置 XIAOBAI_GIT_REV=当前 main 完整提交号(或改用 tools/update_from_main.sh}"
image: xiaobai-review:main-${XIAOBAI_GIT_SHORT:?必须先设置 XIAOBAI_GIT_SHORT=7位提交短号(或改用 tools/update_from_main.sh}
container_name: xiaobai-review container_name: xiaobai-review
ports: ports:
- "0.0.0.0:8765:8765/tcp" - "0.0.0.0:8765:8765/tcp"
+3
View File
@@ -12,6 +12,9 @@ These registries describe the approved product surface of the standalone applica
providers, model entry points, CSS layers, and remaining code hotspots. providers, model entry points, CSS layers, and remaining code hotspots.
- `data-fields.config.json`: canonical data products, provider eligibility, intended use, and - `data-fields.config.json`: canonical data products, provider eligibility, intended use, and
known blocked datasets. known blocked datasets.
- `datahub.config.json`: optional read-only client for `xiaobai-datahub`. Each dataset has its
own `read` / `shadow` flag, all default off. Environment variables `DATAHUB_READ_*` and
`DATAHUB_SHADOW_*` can override a single dataset without a master switch.
- `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and - `data-quality.config.json`: freshness, coverage, units, adjustment, point-in-time, and
fail-closed rules for every canonical data product. fail-closed rules for every canonical data product.
- `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts, - `jobs.config.json`: background schedules, dependencies, lock keys, retry policy, timeouts,
+45 -22
View File
@@ -204,6 +204,11 @@
"path": "backend/data/providers/tushare_client.py", "path": "backend/data/providers/tushare_client.py",
"runtime_role": "stable client facade for primary deterministic market data" "runtime_role": "stable client facade for primary deterministic market data"
}, },
{
"provider": "datahub",
"path": "backend/data/datahub/client.py",
"runtime_role": "optional official EOD read path behind per-dataset flags"
},
{ {
"provider": "ifind", "provider": "ifind",
"path": "backend/data/providers/ifind_client.py", "path": "backend/data/providers/ifind_client.py",
@@ -278,6 +283,18 @@
"owner": "backend/data/providers/tushare.py", "owner": "backend/data/providers/tushare.py",
"compatibility_fallback": "backend/features/market/service.py" "compatibility_fallback": "backend/features/market/service.py"
}, },
{
"client": "DatahubClient",
"owner": "backend/data/gateway.py"
},
{
"client": "DatahubBridge",
"owner": "backend/data/gateway.py"
},
{
"client": "DatahubAwareTushareClient",
"owner": "backend/data/gateway.py"
},
{ {
"client": "IfindHttpClient", "client": "IfindHttpClient",
"owner": "backend/data/gateway.py" "owner": "backend/data/gateway.py"
@@ -313,6 +330,7 @@
"system_service": "backend/features/system/service.py", "system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py", "account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py", "job_lifecycle": "backend/jobs/service.py",
"job_refresh_status": "backend/jobs/refresh.py",
"feature_routes": "backend/features/*/routes.py" "feature_routes": "backend/features/*/routes.py"
}, },
"numeric_normalization": [ "numeric_normalization": [
@@ -374,10 +392,10 @@
} }
], ],
"css_layers": [ "css_layers": [
"/shared/tokens.css?v=20260829-1", "/shared/tokens.css?v=20260829-hel240",
"/shared/base.css?v=20260806-1", "/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260829-hel237", "/shared/shell.css?v=20260829-hel237",
"/shared/auth.css?v=20260829-hel237", "/shared/auth.css?v=20260829-hel240b",
"/shared/components/controls.css?v=20260829-hel237", "/shared/components/controls.css?v=20260829-hel237",
"/shared/components/navigation.css?v=20260820-1", "/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1",
@@ -455,8 +473,8 @@
}, },
{ {
"path": "frontend/shared/shell.css", "path": "frontend/shared/shell.css",
"bytes": 63659, "bytes": 63733,
"lines": 3763 "lines": 3767
}, },
{ {
"path": "backend/features/heaven/engine.py", "path": "backend/features/heaven/engine.py",
@@ -465,7 +483,7 @@
}, },
{ {
"path": "frontend/index.html", "path": "frontend/index.html",
"bytes": 48248, "bytes": 48254,
"lines": 664 "lines": 664
}, },
{ {
@@ -543,6 +561,11 @@
"bytes": 14743, "bytes": 14743,
"lines": 342 "lines": 342
}, },
{
"path": "frontend/shared/dashboard.js",
"bytes": 14740,
"lines": 316
},
{ {
"path": "frontend/shared/admin.js", "path": "frontend/shared/admin.js",
"bytes": 14410, "bytes": 14410,
@@ -553,15 +576,10 @@
"bytes": 13681, "bytes": 13681,
"lines": 338 "lines": 338
}, },
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
},
{ {
"path": "frontend/shared/session.js", "path": "frontend/shared/session.js",
"bytes": 12848, "bytes": 13219,
"lines": 283 "lines": 289
}, },
{ {
"path": "backend/features/market/insights_auction_data.py", "path": "backend/features/market/insights_auction_data.py",
@@ -673,16 +691,16 @@
"bytes": 5451, "bytes": 5451,
"lines": 118 "lines": 118
}, },
{
"path": "frontend/pages.config.js",
"bytes": 5385,
"lines": 130
},
{ {
"path": "frontend/pages/market/search.js", "path": "frontend/pages/market/search.js",
"bytes": 5384, "bytes": 5384,
"lines": 131 "lines": 131
}, },
{
"path": "frontend/pages.config.js",
"bytes": 5380,
"lines": 130
},
{ {
"path": "frontend/pages/auction/page.html", "path": "frontend/pages/auction/page.html",
"bytes": 5350, "bytes": 5350,
@@ -768,16 +786,16 @@
"bytes": 2514, "bytes": 2514,
"lines": 63 "lines": 63
}, },
{
"path": "backend/jobs/service.py",
"bytes": 2337,
"lines": 59
},
{ {
"path": "backend/features/mentor/routes.py", "path": "backend/features/mentor/routes.py",
"bytes": 2299, "bytes": 2299,
"lines": 57 "lines": 57
}, },
{
"path": "backend/jobs/service.py",
"bytes": 2219,
"lines": 60
},
{ {
"path": "backend/features/screener/regime.py", "path": "backend/features/screener/regime.py",
"bytes": 2202, "bytes": 2202,
@@ -813,6 +831,11 @@
"bytes": 1791, "bytes": 1791,
"lines": 46 "lines": 46
}, },
{
"path": "backend/jobs/refresh.py",
"bytes": 1728,
"lines": 46
},
{ {
"path": "backend/features/alerts/routes.py", "path": "backend/features/alerts/routes.py",
"bytes": 1687, "bytes": 1687,
+1
View File
@@ -2,6 +2,7 @@
"schema_version": 1, "schema_version": 1,
"providers": { "providers": {
"tushare": {"class": "licensed", "calculation_allowed": true}, "tushare": {"class": "licensed", "calculation_allowed": true},
"datahub": {"class": "licensed", "calculation_allowed": true},
"ifind": {"class": "licensed", "calculation_allowed": true}, "ifind": {"class": "licensed", "calculation_allowed": true},
"eastmoney": {"class": "public_web", "calculation_allowed": false}, "eastmoney": {"class": "public_web", "calculation_allowed": false},
"tencent": {"class": "public_web", "calculation_allowed": false}, "tencent": {"class": "public_web", "calculation_allowed": false},
+18
View File
@@ -0,0 +1,18 @@
{
"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": false, "shadow": false },
"stocks": { "read": false, "shadow": false },
"daily": { "read": false, "shadow": false },
"index_daily": { "read": false, "shadow": false },
"valuation": { "read": false, "shadow": false },
"moneyflow": { "read": false, "shadow": false },
"auction": { "read": false, "shadow": false },
"status": { "read": false, "shadow": false }
}
}
+1 -1
View File
@@ -32,4 +32,4 @@
- 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。 - 旧文档不能删:被替代的旧文档开头要加一行「⚠️ 本文档已过时,仅留档备查,请勿删除」,再写新版。
- 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。 - 用中文大白话写,专业词要带通俗解释,让不懂代码的人也能看懂。
- 「问天」板块是冻结区,任何改动都不许碰;写文档时别误导后来人去改它 - 「问天」不是永久冻结区:此前只冻结过界面视觉方案,现已解冻。问天可纳入后续数据与功能迁移,不要再写成“永远不碰”
+1
View File
@@ -7,6 +7,7 @@
| 任务 | 说明 | 状态 | | 任务 | 说明 | 状态 |
|---|---|---| |---|---|---|
| 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 | | 全站视觉统一改造收尾 | 主线。17 个阶段已完成,正在最终验收、代码合并 | 收尾中 |
| 行情刷新误报与旧数据提示 | HEL-412:高级接口未到齐不再记整次失败;今日正式数据晚到时提示当前展示日期 | 施工中 |
| 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 | | 手机端独立重新设计 | 先出视觉/交互规范和技术架构方案,等老板确认后再施工 | 方案送审中 |
## 已做完 ## 已做完
+2 -2
View File
@@ -29,11 +29,11 @@
- **智能工具类(3 个)**:智能选股、问师、问天。 - **智能工具类(3 个)**:智能选股、问师、问天。
- **个人类(1 个)**:我的复盘。 - **个人类(1 个)**:我的复盘。
其中「问天」是冻结区(见下面的硬规矩) 其中「问天」此前只在全站视觉改造阶段冻结过界面方案,现已解冻;问天可以纳入后续数据与功能迁移,但不等于本阶段要重做视觉
## 几条硬规矩(不能破坏的边界) ## 几条硬规矩(不能破坏的边界)
- 「问天」板块**冻结区**,任何改动都不许碰它 - 「问天」板块**不是永久冻结区**:此前冻结的是界面视觉方案,现已解冻。问天现有功能与界面不要破坏;后续数据与功能迁移可以纳入,不主动重做视觉
- **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。 - **不用假数据冒充真行情**;数据缺失就明说“没有/不可用”,不能编。
- **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。 - **每个用户自己的数据互相隔离**(自选、复盘、对话、问天历史等),看不到别人的。
- **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。 - **计算由程序确定性完成**(情绪周期、智能选股、问天排盘等),AI 大模型(LLM,就是会聊天的那个 AI)只负责解释或编译自然语言条件,不能改计算结果。
+2 -2
View File
@@ -34,10 +34,10 @@
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
})(); })();
</script> </script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1"> <link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel240">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1"> <link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260829-hel237"> <link rel="stylesheet" href="/shared/shell.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-hel237"> <link rel="stylesheet" href="/shared/auth.css?v=20260829-hel240b">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260829-hel237"> <link rel="stylesheet" href="/shared/components/controls.css?v=20260829-hel237">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1"> <link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1"> <link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
+110 -7
View File
@@ -19,18 +19,118 @@
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
})(); })();
</script> </script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1"> <link rel="stylesheet" href="/shared/tokens.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1"> <link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-1"> <link rel="stylesheet" href="/shared/auth.css?v=20260829-hel251">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2"> <link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
</head> </head>
<body class="login-portal"> <body class="login-portal">
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
<aside class="login-brand" aria-hidden="true"> <aside class="login-brand" aria-hidden="true">
<div class="login-brand-header">
<div class="login-brand-mark"><span class="login-brand-glyph"></span></div> <div class="login-brand-mark"><span class="login-brand-glyph"></span></div>
<div class="login-brand-identity">
<p class="login-brand-name">小白复盘</p>
<p class="login-brand-subtitle">A股个人复盘工作台</p>
</div>
</div>
<div class="login-mascots" id="loginMascots" aria-hidden="true" data-mood="idle">
<div class="login-mascots-stage">
<div class="login-mascot is-red">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<svg class="login-mascot-figure" viewBox="0 0 96 210" focusable="false">
<ellipse class="login-mascot-shadow" cx="48" cy="200" rx="26" ry="5.5"></ellipse>
<line class="login-mascot-wick" x1="48" y1="10" x2="48" y2="50"></line>
<rect class="login-mascot-body" x="16" y="50" width="64" height="126" rx="14"></rect>
<line class="login-mascot-wick" x1="48" y1="176" x2="48" y2="190"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="36" cy="94" r="10"></circle>
<circle class="login-mascot-eye" cx="60" cy="94" r="10"></circle>
<circle class="login-mascot-pupil" cx="36" cy="94" r="4.2"></circle>
<circle class="login-mascot-pupil" cx="60" cy="94" r="4.2"></circle>
<rect class="login-mascot-lid" x="24" y="82" width="48" height="24" rx="10"></rect>
<path class="login-mascot-happy" d="M28 96 Q36 88 44 96"></path>
<path class="login-mascot-happy" d="M52 96 Q60 88 68 96"></path>
<circle class="login-mascot-sad" cx="36" cy="96" r="2.2"></circle>
<circle class="login-mascot-sad" cx="60" cy="96" r="2.2"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="16" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="80" cy="128" rx="8" ry="5.5"></ellipse>
<ellipse class="login-mascot-hand is-left" cx="16" cy="128" rx="9" ry="7"></ellipse>
<ellipse class="login-mascot-hand is-right" cx="80" cy="128" rx="9" ry="7"></ellipse>
</svg>
</div>
</div>
</div>
<div class="login-mascot is-green">
<div class="login-mascot-breathe">
<div class="login-mascot-lean">
<div class="login-mascot-figure">
<svg class="login-mascot-front" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<g class="login-mascot-face">
<circle class="login-mascot-eye" cx="32" cy="76" r="8.5"></circle>
<circle class="login-mascot-eye" cx="52" cy="76" r="8.5"></circle>
<circle class="login-mascot-pupil" cx="32" cy="76" r="3.6"></circle>
<circle class="login-mascot-pupil" cx="52" cy="76" r="3.6"></circle>
<rect class="login-mascot-lid" x="22" y="66" width="40" height="20" rx="8"></rect>
<path class="login-mascot-happy" d="M25 78 Q32 71 39 78"></path>
<path class="login-mascot-happy" d="M45 78 Q52 71 59 78"></path>
<circle class="login-mascot-sad" cx="32" cy="78" r="1.9"></circle>
<circle class="login-mascot-sad" cx="52" cy="78" r="1.9"></circle>
</g>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
<svg class="login-mascot-back" viewBox="0 0 84 175" focusable="false">
<ellipse class="login-mascot-shadow" cx="42" cy="166" rx="22" ry="4.5"></ellipse>
<line class="login-mascot-wick" x1="42" y1="8" x2="42" y2="42"></line>
<rect class="login-mascot-body" x="15" y="42" width="54" height="104" rx="12"></rect>
<line class="login-mascot-wick" x1="42" y1="146" x2="42" y2="158"></line>
<rect class="login-mascot-slit" x="40.5" y="58" width="3" height="72" rx="1.5"></rect>
<ellipse class="login-mascot-arm is-left" cx="15" cy="108" rx="7" ry="4.5"></ellipse>
<ellipse class="login-mascot-arm is-right" cx="69" cy="108" rx="7" ry="4.5"></ellipse>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="login-brand-copy">
<p class="login-brand-kicker">收盘之后 · 复盘开始</p> <p class="login-brand-kicker">收盘之后 · 复盘开始</p>
<h1 class="login-brand-title">小白复盘</h1> <h1 class="login-brand-title">看懂情绪周期,把复盘变成下一次的先手。</h1>
<p class="login-brand-lead">看懂情绪周期,把复盘变成下一次的先手</p> <p class="login-brand-lead">情绪周期、涨停梯队、主题轮动、竞价、龙虎榜、人气榜、交易复盘,集中在一个安静的复盘空间</p>
</div>
<div class="login-brand-market">
<svg class="login-brand-chart" viewBox="0 0 480 168" focusable="false">
<defs>
<linearGradient id="loginChartFade" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#d7e4ff" stop-opacity="0.18"></stop>
<stop offset="100%" stop-color="#d7e4ff" stop-opacity="0"></stop>
</linearGradient>
</defs>
<path class="login-chart-area" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84 L 472 168 L 8 168 Z"></path>
<g class="login-candles">
<g class="is-up" transform="translate(36 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="72" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(68 0)"><line x1="8" y1="64" x2="8" y2="132"></line><rect x="2" y="86" width="12" height="28"></rect></g>
<g class="is-up" transform="translate(100 0)"><line x1="8" y1="48" x2="8" y2="118"></line><rect x="2" y="60" width="12" height="44"></rect></g>
<g class="is-down" transform="translate(132 0)"><line x1="8" y1="70" x2="8" y2="136"></line><rect x="2" y="92" width="12" height="26"></rect></g>
<g class="is-up" transform="translate(164 0)"><line x1="8" y1="42" x2="8" y2="110"></line><rect x="2" y="54" width="12" height="38"></rect></g>
<g class="is-up" transform="translate(196 0)"><line x1="8" y1="36" x2="8" y2="98"></line><rect x="2" y="48" width="12" height="32"></rect></g>
<g class="is-down" transform="translate(228 0)"><line x1="8" y1="58" x2="8" y2="128"></line><rect x="2" y="78" width="12" height="36"></rect></g>
<g class="is-up" transform="translate(260 0)"><line x1="8" y1="40" x2="8" y2="104"></line><rect x="2" y="52" width="12" height="36"></rect></g>
<g class="is-down" transform="translate(292 0)"><line x1="8" y1="66" x2="8" y2="134"></line><rect x="2" y="88" width="12" height="30"></rect></g>
<g class="is-up" transform="translate(324 0)"><line x1="8" y1="44" x2="8" y2="112"></line><rect x="2" y="58" width="12" height="40"></rect></g>
<g class="is-down" transform="translate(356 0)"><line x1="8" y1="72" x2="8" y2="138"></line><rect x="2" y="96" width="12" height="24"></rect></g>
<g class="is-up" transform="translate(388 0)"><line x1="8" y1="38" x2="8" y2="108"></line><rect x="2" y="50" width="12" height="42"></rect></g>
<g class="is-up" transform="translate(420 0)"><line x1="8" y1="32" x2="8" y2="96"></line><rect x="2" y="44" width="12" height="34"></rect></g>
</g>
<path class="login-chart-line" d="M8 118 C 52 108, 78 96, 112 102 S 168 128, 204 112 S 268 78, 312 86 S 372 118, 428 92 L 472 84"></path>
</svg>
<dl class="login-brand-stats"> <dl class="login-brand-stats">
<div class="login-stat"> <div class="login-stat">
<dt>市场情绪</dt> <dt>市场情绪</dt>
@@ -44,16 +144,19 @@
<dt>跌停</dt> <dt>跌停</dt>
<dd>4</dd> <dd>4</dd>
</div> </div>
<div class="login-stat"> <div class="login-stat login-stat-wide">
<dt>两市成交</dt> <dt>两市成交</dt>
<dd>1.02万亿</dd> <dd>1.02万亿</dd>
</div> </div>
</dl> </dl>
</div>
<p class="login-brand-disclaimer">股市有风险,投资需谨慎 · 本工具仅供个人复盘学习使用</p>
</aside> </aside>
<main class="login-stage"> <main class="login-stage">
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
<section class="login-card" id="loginCard" aria-live="polite"></section> <section class="login-card" id="loginCard" aria-live="polite"></section>
</main> </main>
<script src="/shared/api.js?v=20260803-2"></script> <script src="/shared/api.js?v=20260803-2"></script>
<script src="/login/page.js?v=20260829-1"></script> <script src="/login/page.js?v=20260829-hel251"></script>
</body> </body>
</html> </html>
+360 -23
View File
@@ -13,6 +13,9 @@
loading: false, loading: false,
confirmingId: null, confirmingId: null,
error: "", error: "",
username: "",
password: "",
passwordVisible: false,
}; };
function escapeHtml(value) { function escapeHtml(value) {
@@ -52,48 +55,90 @@
state.error = message || ""; state.error = message || "";
} }
function membershipLabel(account) { function formatLastUsed(value) {
if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员"; if (!value) return "";
return account.membership?.subscribed ? "会员" : "普通用户"; const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
const now = new Date();
const hh = String(parsed.getHours()).padStart(2, "0");
const mm = String(parsed.getMinutes()).padStart(2, "0");
if (parsed.toDateString() === now.toDateString()) return `今天 ${hh}:${mm}`;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (parsed.toDateString() === yesterday.toDateString()) return `昨天 ${hh}:${mm}`;
return `${parsed.getMonth() + 1}${parsed.getDate()}`;
}
function chipsFor(account, current) {
const chips = [];
if (current) chips.push('<span class="login-chip login-chip-current">当前</span>');
if (account.role === "admin") chips.push('<span class="login-chip">管理员</span>');
if (account.membership?.subscribed) chips.push('<span class="login-chip login-chip-member">会员</span>');
else if (account.role !== "admin") chips.push('<span class="login-chip">普通用户</span>');
return chips.join("");
}
function returnPath() {
const raw = new URLSearchParams(global.location.search).get("next") || "";
if (!raw) return "/";
try {
const url = new URL(raw, global.location.origin);
if (url.origin !== global.location.origin) return "/";
const path = url.pathname || "/";
if (path === "/login" || path.startsWith("/login/")) return "/";
return `${path}${url.search}${url.hash}` || "/";
} catch (_error) {
return "/";
}
} }
function enterApp() { function enterApp() {
const next = new URLSearchParams(global.location.search).get("next"); global.location.replace(returnPath());
global.location.replace(next && next.startsWith("/") ? next : "/");
} }
function formMarkup(options) { function formMarkup(options) {
const registering = state.mode === "register"; const registering = state.mode === "register";
const submitLabel = options.submitLabel const submitLabel = options.submitLabel
|| (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录"); || (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
const lead = options.lead;
const hint = options.hint;
const invalid = state.error ? " is-invalid" : "";
const passwordType = state.passwordVisible ? "text" : "password";
const passwordToggle = state.passwordVisible ? "隐藏" : "显示";
return [ return [
options.back options.back
? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>' ? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>'
: "", : "",
`<h2 class="login-card-title">${escapeHtml(options.title)}</h2>`, `<h2 class="login-card-title">${escapeHtml(options.title)}</h2>`,
`<p class="login-card-lead">${escapeHtml(options.lead)}</p>`, `<p class="login-card-lead">${escapeHtml(lead)}</p>`,
'<div class="login-tabs" role="tablist">', '<div class="login-tabs" role="tablist">',
`<button class="login-tab${state.mode === "login" ? " is-active" : ""}" type="button" data-auth-mode="login">登录</button>`, `<button class="login-tab${state.mode === "login" ? " is-active" : ""}" type="button" data-auth-mode="login">登录</button>`,
`<button class="login-tab${state.mode === "register" ? " is-active" : ""}" type="button" data-auth-mode="register">注册</button>`, `<button class="login-tab${state.mode === "register" ? " is-active" : ""}" type="button" data-auth-mode="register">注册</button>`,
"</div>", "</div>",
'<form class="login-form" id="loginForm">', '<form class="login-form" id="loginForm">',
'<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>', `<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
`<label class="form-field"><span>密码</span><input id="loginPassword" type="password" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" required></label>`, `<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`, `<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>', state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`, `<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "", state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
`<span>${escapeHtml(submitLabel)}</span></button>`, `<span>${escapeHtml(submitLabel)}</span></button>`,
"</form>", "</form>",
'<p class="login-hint">密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。</p>', `<p class="login-hint">${escapeHtml(hint)}</p>`,
].join(""); ].join("");
} }
function accountRow(account) { function accountRow(account) {
const current = Number(account.user_id) === Number(state.currentUserId); const current = Number(account.user_id) === Number(state.currentUserId);
const confirming = Number(state.confirmingId) === Number(account.user_id); const confirming = Number(state.confirmingId) === Number(account.user_id);
const classes = `login-account-row${current ? " is-current" : ""}${confirming ? " is-confirming" : ""}`; const managing = state.view === "manage";
if (state.view === "manage" && confirming) { const classes = [
"login-account-row",
current ? "is-current" : "",
confirming ? "is-confirming" : "",
!managing ? "is-switchable" : "",
].filter(Boolean).join(" ");
if (managing && confirming) {
return [ return [
`<div class="${classes}" data-user-id="${account.user_id}">`, `<div class="${classes}" data-user-id="${account.user_id}">`,
`<p class="login-confirm-copy">移除「${escapeHtml(account.username)}」的本机记录?</p>`, `<p class="login-confirm-copy">移除「${escapeHtml(account.username)}」的本机记录?</p>`,
@@ -103,16 +148,25 @@
"</div></div>", "</div></div>",
].join(""); ].join("");
} }
const action = state.view === "manage" const glyph = escapeHtml(String(account.username || "账").slice(0, 1));
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}">移除</button>` const tone = Number(account.user_id || 0) % 4;
const used = formatLastUsed(account.last_used_at);
const action = managing
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}" aria-label="移除 ${escapeHtml(account.username)}"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M6 2h4l.5 1H14v1H2V3h3.5L6 2zm1 4v6H6V6h1zm3 0v6H9V6h1zM3.5 5H13l-.7 8.2A1.5 1.5 0 0 1 10.81 14H5.19a1.5 1.5 0 0 1-1.49-1.8L3.5 5z"></path></svg></button>`
: current : current
? '<span class="login-account-check" aria-hidden="true">✓</span>' ? '<span class="login-account-action"><span class="login-account-check" aria-hidden="true">✓</span>继续使用</span>'
: `<button class="login-account-enter" type="button" data-switch-id="${account.user_id}">进入</button>`; : "";
const switchAttr = !managing && !current ? ` data-switch-id="${account.user_id}"` : "";
const resumeAttr = !managing && current ? ` data-resume-id="${account.user_id}"` : "";
return [ return [
`<div class="${classes}" data-user-id="${account.user_id}">`, `<div class="${classes}" data-user-id="${account.user_id}"${switchAttr}${resumeAttr}>`,
`<span class="login-avatar tone-${tone}" aria-hidden="true">${glyph}</span>`,
'<div class="login-account-meta">', '<div class="login-account-meta">',
'<div class="login-account-name">',
`<strong>${escapeHtml(account.username)}</strong>`, `<strong>${escapeHtml(account.username)}</strong>`,
`<span>${escapeHtml(membershipLabel(account))}${current ? " · 当前" : ""}</span>`, chipsFor(account, current),
"</div>",
used ? `<span class="login-account-used">上次登录 ${escapeHtml(used)}</span>` : "",
"</div>", "</div>",
action, action,
"</div>", "</div>",
@@ -123,10 +177,15 @@
const count = state.accounts.length; const count = state.accounts.length;
const managing = state.view === "manage"; const managing = state.view === "manage";
return [ return [
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
`<p class="login-card-lead">这台电脑已记录 ${count} 个账号,可直接进入,无需再次输入密码。</p>`,
managing managing
? '<button class="login-manage" type="button" data-login-action="picker">完成</button>' ? ""
: '<button class="login-back" type="button" data-login-action="resume">返回复盘</button>',
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
`<p class="login-card-lead">${managing
? "移除只删除这台电脑上的登录记录,不会注销账号"
: `这台电脑已记录 ${count} 个账号,点选即可进入,无需再次输入密码。`}</p>`,
managing
? '<div class="login-manage-toolbar"><p class="login-manage-hint">点击右侧图标移除对应记录</p><button class="login-manage-done" type="button" data-login-action="picker">完成</button></div>'
: "", : "",
`<div class="login-account-list">${state.accounts.map(accountRow).join("")}</div>`, `<div class="login-account-list">${state.accounts.map(accountRow).join("")}</div>`,
managing managing
@@ -136,7 +195,9 @@
? "" ? ""
: '<button class="login-manage" type="button" data-login-action="manage">管理已记录的账号</button>', : '<button class="login-manage" type="button" data-login-action="manage">管理已记录的账号</button>',
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : "", state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : "",
'<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>账号记录仅保存在这台电脑的浏览器中</p>', managing
? '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>移除后再次登录该账号需重新输入密码</p>'
: '<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>账号记录仅保存在这台电脑的浏览器中</p>',
].join(""); ].join("");
} }
@@ -145,7 +206,10 @@
if (state.view === "first" || state.view === "add") { if (state.view === "first" || state.view === "add") {
card.innerHTML = formMarkup({ card.innerHTML = formMarkup({
title: state.view === "add" ? "添加账号" : "欢迎回来", title: state.view === "add" ? "添加账号" : "欢迎回来",
lead: "登录后进入你的复盘空间", lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间",
hint: state.view === "add"
? "添加后账号会保存在这台电脑,方便随时切换。"
: "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。",
add: state.view === "add", add: state.view === "add",
back: state.view === "add", back: state.view === "add",
}); });
@@ -153,6 +217,7 @@
card.innerHTML = pickerMarkup(); card.innerHTML = pickerMarkup();
} }
bindCard(); bindCard();
if (mascots) mascots.sync();
} }
function bindCard() { function bindCard() {
@@ -166,6 +231,14 @@
card.querySelectorAll("[data-login-action]").forEach((button) => { card.querySelectorAll("[data-login-action]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const action = button.dataset.loginAction; const action = button.dataset.loginAction;
if (action === "toggle-password") {
togglePasswordVisible();
return;
}
if (action === "resume") {
resumeCurrentAccount();
return;
}
if (action === "picker") { if (action === "picker") {
state.view = state.accounts.length ? "picker" : "first"; state.view = state.accounts.length ? "picker" : "first";
state.confirmingId = null; state.confirmingId = null;
@@ -184,8 +257,12 @@
card.querySelectorAll("[data-switch-id]").forEach((button) => { card.querySelectorAll("[data-switch-id]").forEach((button) => {
button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId))); button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId)));
}); });
card.querySelectorAll("[data-resume-id]").forEach((button) => {
button.addEventListener("click", () => resumeCurrentAccount());
});
card.querySelectorAll("[data-confirm-id]").forEach((button) => { card.querySelectorAll("[data-confirm-id]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", (event) => {
event.stopPropagation();
state.confirmingId = Number(button.dataset.confirmId); state.confirmingId = Number(button.dataset.confirmId);
render(); render();
}); });
@@ -212,6 +289,8 @@
event.preventDefault(); event.preventDefault();
const username = document.querySelector("#loginUsername").value.trim(); const username = document.querySelector("#loginUsername").value.trim();
const password = document.querySelector("#loginPassword").value; const password = document.querySelector("#loginPassword").value;
state.username = username;
state.password = password;
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) { if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
setError("两次输入的密码不一致。"); setError("两次输入的密码不一致。");
render(); render();
@@ -222,11 +301,13 @@
render(); render();
try { try {
await api.request(`/api/auth/${state.mode}`, "POST", { username, password }); await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
await celebrateLogin();
enterApp(); enterApp();
} catch (error) { } catch (error) {
state.loading = false; state.loading = false;
setError(error.message || "账号操作失败"); setError(error.message || "账号操作失败");
render(); render();
mascots.fail();
} }
} }
@@ -244,6 +325,27 @@
} }
} }
async function resumeCurrentAccount() {
state.loading = true;
setError("");
render();
try {
const session = await api.request("/api/auth/me");
const sessionUserId = session.user?.id;
const matches = Boolean(session.authenticated) && (
!state.currentUserId || Number(sessionUserId) === Number(state.currentUserId)
);
if (!matches) {
throw new Error("当前会话已失效,请重新登录");
}
enterApp();
} catch (error) {
state.loading = false;
setError(error.message || "当前会话已失效,请重新登录");
render();
}
}
async function forgetAccount(userId) { async function forgetAccount(userId) {
try { try {
await api.request("/api/auth/forget", "POST", { user_id: userId }); await api.request("/api/auth/forget", "POST", { user_id: userId });
@@ -258,6 +360,241 @@
} }
} }
function togglePasswordVisible() {
state.passwordVisible = !state.passwordVisible;
const input = document.querySelector("#loginPassword");
const button = document.querySelector(".login-password-toggle");
if (input) {
input.type = state.passwordVisible ? "text" : "password";
input.focus();
}
if (button) {
button.textContent = state.passwordVisible ? "隐藏" : "显示";
button.setAttribute("aria-pressed", state.passwordVisible ? "true" : "false");
button.setAttribute("aria-label", state.passwordVisible ? "隐藏密码" : "显示密码");
}
mascots.sync();
}
function reducedMotion() {
return Boolean(global.matchMedia && global.matchMedia("(prefers-reduced-motion: reduce)").matches);
}
function finePointer() {
return Boolean(global.matchMedia && global.matchMedia("(pointer: fine)").matches);
}
function wait(ms) {
return new Promise((resolve) => global.setTimeout(resolve, ms));
}
async function celebrateLogin() {
mascots.succeed();
if (!reducedMotion()) await wait(720);
}
const mascots = (() => {
const root = document.querySelector("#loginMascots");
const red = root && root.querySelector(".login-mascot.is-red");
const green = root && root.querySelector(".login-mascot.is-green");
const motion = {
pupilX: 0,
pupilY: 0,
targetX: 0,
targetY: 0,
leanRed: 0,
leanGreen: 0,
targetLeanRed: 0,
targetLeanGreen: 0,
};
let mood = "idle";
let locked = "";
let blinkTimer = 0;
let failTimer = 0;
let raf = 0;
function setVars() {
if (!red || !green) return;
const pupilX = `${motion.pupilX.toFixed(2)}px`;
const pupilY = `${motion.pupilY.toFixed(2)}px`;
red.style.setProperty("--pupil-x", pupilX);
red.style.setProperty("--pupil-y", pupilY);
green.style.setProperty("--pupil-x", pupilX);
green.style.setProperty("--pupil-y", pupilY);
red.style.setProperty("--lean", `${motion.leanRed.toFixed(2)}deg`);
green.style.setProperty("--lean", `${motion.leanGreen.toFixed(2)}deg`);
}
function poseFor(next) {
if (next === "account" || next === "busy") {
motion.targetX = 3.2;
motion.targetY = 0.8;
motion.targetLeanRed = 7;
motion.targetLeanGreen = 5.6;
return;
}
if (next === "password") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (next === "fail") {
motion.targetX = 0;
motion.targetY = 2.8;
motion.targetLeanRed = 8;
motion.targetLeanGreen = 8;
return;
}
if (next === "success") {
motion.targetX = 0;
motion.targetY = 0;
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
return;
}
if (!finePointer()) {
motion.targetX = 2.4;
motion.targetY = 0.4;
motion.targetLeanRed = 4;
motion.targetLeanGreen = 3.2;
return;
}
motion.targetLeanRed = 0;
motion.targetLeanGreen = 0;
}
function applyMood(next) {
if (!root) return;
const changed = next !== mood;
if (changed) {
mood = next;
root.dataset.mood = next;
poseFor(next);
} else if (next !== "idle") {
poseFor(next);
}
if (reducedMotion()) {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
}
function focusedControl() {
const active = document.activeElement;
if (!active || !card.contains(active)) return "";
if (active.classList.contains("login-password-toggle")) return "loginPassword";
return active.id || "";
}
function desiredMood() {
if (locked === "success") return "success";
if (state.loading) return "busy";
const focused = focusedControl();
if (focused === "loginPassword" || focused === "loginPasswordConfirm") return "password";
if (focused === "loginUsername") return "account";
if (locked === "fail") return "fail";
return "idle";
}
function sync() {
applyMood(desiredMood());
}
function succeed() {
locked = "success";
applyMood("success");
}
function fail() {
locked = "fail";
applyMood("fail");
global.clearTimeout(failTimer);
failTimer = global.setTimeout(() => {
if (locked === "fail") locked = "";
sync();
}, 900);
}
function blink() {
if (!root || reducedMotion()) return;
if (mood !== "idle" && mood !== "account" && mood !== "busy") return;
root.classList.remove("is-blinking");
void root.offsetWidth;
root.classList.add("is-blinking");
global.setTimeout(() => root.classList.remove("is-blinking"), 160);
}
function scheduleBlink() {
global.clearTimeout(blinkTimer);
if (reducedMotion()) return;
const waitMs = 4000 + Math.random() * 2000;
blinkTimer = global.setTimeout(() => {
blink();
scheduleBlink();
}, waitMs);
}
function onMouseMove(event) {
if (reducedMotion() || !finePointer()) return;
if (desiredMood() !== "idle") return;
const rect = root.getBoundingClientRect();
const cx = rect.left + rect.width * 0.42;
const cy = rect.top + rect.height * 0.42;
const dx = event.clientX - cx;
const dy = event.clientY - cy;
const dist = Math.hypot(dx, dy) || 1;
const cap = 3.5;
motion.targetX = (dx / dist) * Math.min(cap, Math.abs(dx) / 90);
motion.targetY = (dy / dist) * Math.min(cap, Math.abs(dy) / 90);
const tilt = Math.max(-5, Math.min(5, (dx / Math.max(global.innerWidth, 1)) * 10));
motion.targetLeanRed = tilt;
motion.targetLeanGreen = tilt * 0.8;
}
function tick() {
if (!root) return;
if (!reducedMotion()) {
motion.pupilX += (motion.targetX - motion.pupilX) * 0.18;
motion.pupilY += (motion.targetY - motion.pupilY) * 0.18;
motion.leanRed += (motion.targetLeanRed - motion.leanRed) * 0.18;
motion.leanGreen += (motion.targetLeanGreen - motion.leanGreen) * 0.18;
setVars();
} else {
motion.pupilX = motion.targetX;
motion.pupilY = motion.targetY;
motion.leanRed = motion.targetLeanRed;
motion.leanGreen = motion.targetLeanGreen;
setVars();
}
raf = global.requestAnimationFrame(tick);
}
function onFocusChange() {
global.requestAnimationFrame(sync);
}
if (root) {
document.addEventListener("focusin", onFocusChange);
document.addEventListener("focusout", onFocusChange);
global.addEventListener("mousemove", onMouseMove, { passive: true });
if (!reducedMotion()) {
raf = global.requestAnimationFrame(tick);
scheduleBlink();
} else {
poseFor("idle");
setVars();
}
sync();
}
return { sync, succeed, fail };
})();
themeButton.addEventListener("click", () => { themeButton.addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true); applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
}); });
+415 -8
View File
@@ -3192,8 +3192,7 @@
.m-sys-grid div { .m-sys-grid div {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
background: var(--surface); background: var(--surface-muted);
box-shadow: var(--elevation-card);
} }
.m-sys-grid span { .m-sys-grid span {
@@ -3209,16 +3208,424 @@
color: var(--text-primary); color: var(--text-primary);
} }
.m-sys-account-actions { .m-sys-home {
display: grid; padding: 0 0 12px;
display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 16px;
} }
.m-page[data-page^="system/"] .m-btn-primary { .m-sys-body {
margin-bottom: 8px; padding: 12px 12px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.m-sys-home .m-card,
.m-sys-body .m-card {
margin-left: 0;
margin-right: 0;
}
.m-sys-profile-card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px;
}
.m-sys-avatar {
flex: 0 0 auto;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
font-size: var(--font-size-page-title);
font-weight: 600;
}
.m-sys-profile-card strong {
display: block;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-profile-meta {
margin: 4px 0 8px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.m-sys-badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: var(--font-size-aux);
font-weight: 600;
background: var(--surface-muted);
color: var(--text-secondary);
}
.m-sys-badge--admin {
background: var(--action-soft);
color: var(--action);
}
.m-sys-badge--ok {
background: var(--market-down-soft);
color: var(--market-down);
}
.m-sys-group-title {
margin: 4px 0 0;
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--text-tertiary);
}
.m-sys-list {
padding: 0;
overflow: hidden;
}
.m-sys-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-sys-row + .m-sys-row {
border-top: 1px solid var(--border);
}
.m-sys-row:active {
background: var(--surface-hover);
}
.m-sys-row-icon {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--action-soft);
color: var(--action);
}
.m-sys-row--danger .m-sys-row-icon {
background: var(--market-up-soft);
color: var(--market-up);
}
.m-sys-row-body {
flex: 1;
min-width: 0;
}
.m-sys-row-body strong {
display: block;
font-size: var(--font-size-body);
font-weight: 600;
color: var(--text-primary);
}
.m-sys-row-body small {
display: block;
margin-top: 2px;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-row-chevron {
flex: 0 0 auto;
color: var(--text-tertiary);
}
.m-sys-foot {
margin: 8px 0 0;
text-align: center;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
}
.m-sys-notice {
padding: 12px;
border-radius: 12px;
background: var(--surface-muted);
}
.m-sys-notice p {
margin: 0;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.55;
}
.m-sys-notice .m-sys-badges {
margin-top: 8px;
}
.m-sys-section {
padding: 12px;
}
.m-sys-section > strong,
.m-sys-section-title {
display: block;
margin-bottom: 10px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
}
.m-sys-hint {
margin: 8px 0 0;
font-size: var(--font-size-caption);
color: var(--text-tertiary);
line-height: 1.45;
}
.m-sys-status-list {
display: grid;
gap: 8px;
}
.m-sys-status-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: var(--font-size-body);
color: var(--text-primary);
}
.m-sys-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-tertiary);
}
.m-sys-dot--ok {
background: var(--market-down);
}
.m-sys-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.m-sys-switch-row strong {
display: block;
font-size: var(--font-size-body);
margin-bottom: 0;
}
.m-btn-outline,
.m-btn-outline-danger {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
background: var(--surface);
font-size: var(--font-size-body);
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.m-btn-outline {
border: 1px solid var(--action);
color: var(--action);
}
.m-btn-outline:active {
background: var(--action-soft);
}
.m-btn-outline-danger {
border: 1px solid var(--market-up);
color: var(--market-up);
}
.m-btn-outline-danger:active {
background: var(--market-up-soft);
}
.m-btn-outline:disabled,
.m-btn-outline-danger:disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.m-sys-model-card .m-sys-badges,
.m-sys-user-row .m-sys-badges {
margin-top: 6px;
}
.m-sys-model-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 12px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.m-sys-model-card + .m-sys-model-card {
border-top: 1px solid var(--border);
}
.m-sys-model-card:active {
background: var(--surface-hover);
}
.m-sys-user-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
}
.m-sys-user-row + .m-sys-user-row {
border-top: 1px solid var(--border);
}
.m-sys-user-row .m-btn-outline {
width: auto;
height: 32px;
padding: 0 12px;
flex: 0 0 auto;
}
.m-sys-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-sheet-root.is-dialog .m-sheet {
display: none;
}
.m-dialog {
position: absolute;
left: 50%;
top: 42%;
width: calc(100% - 48px);
max-width: 320px;
transform: translate(-50%, -46%) scale(0.96);
border-radius: 12px;
background: var(--surface);
box-shadow: var(--elevation-float);
padding: 18px 16px 14px;
z-index: 3;
opacity: 0;
transition: opacity var(--motion-enter) var(--ease-enter),
transform var(--motion-enter) var(--ease-enter);
}
.m-sheet-root.is-open .m-dialog {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.m-dialog h2 {
margin: 0 0 8px;
font-size: var(--font-size-card-title);
color: var(--text-primary);
text-align: center;
}
.m-dialog p {
margin: 0 0 16px;
font-size: var(--font-size-label);
color: var(--text-secondary);
line-height: 1.5;
text-align: center;
}
.m-dialog-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.m-dialog-actions .m-btn-outline,
.m-dialog-actions .m-btn-outline-danger,
.m-dialog-actions .m-btn-primary {
height: 40px;
}
.m-sys-sheet-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 12px;
}
.m-sys-test-row {
display: flex;
align-items: center;
gap: 10px;
margin: 8px 0 12px;
}
.m-sys-test-row .m-btn-outline {
width: auto;
flex: 0 0 auto;
height: 36px;
padding: 0 12px;
}
.m-sys-test-row [data-model-test-status] {
flex: 1;
font-size: var(--font-size-caption);
color: var(--text-secondary);
}
.m-sys-hero {
padding: 14px;
}
.m-sys-hero strong {
display: block;
font-size: var(--font-size-page-title);
margin-bottom: 6px;
} }
.m-page[data-page^="system/"] .m-card { .m-page[data-page^="system/"] .m-card {
margin-bottom: 12px; margin-bottom: 0;
} }
+497 -130
View File
@@ -91,6 +91,14 @@
"sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>', "sticky-note": '<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z"/><path d="M15 3v4a2 2 0 0 0 2 2h4"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>', bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
gem: '<path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
"sliders-horizontal": '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
"arrow-left-right": '<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>',
"log-out": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
}; };
function icon(name, size) { function icon(name, size) {
@@ -191,6 +199,10 @@
account: null, account: null,
admin: null, admin: null,
adminTab: "market", adminTab: "market",
models: [],
accounts: [],
editingModelId: "",
editingUserId: "",
}, },
}; };
@@ -758,11 +770,33 @@
scroll.classList.add("m-motion-fade-in"); scroll.classList.add("m-motion-fade-in");
} }
function dashboardFreshnessNotice() {
const meta = (state.dashboard && state.dashboard.meta) || {};
if (meta.display_notice) return String(meta.display_notice);
const requested = String(meta.requested_date || "").replace(/-/g, "");
const actual = String(meta.trade_date || "").replace(/-/g, "");
const compact = actual;
const shown = /^\d{8}$/.test(compact)
? (Number(compact.slice(4, 6)) + " 月 " + Number(compact.slice(6, 8)) + " 日")
: "";
if (meta.data_status === "preparing" || (meta.carried_forward && actual && requested && actual !== requested)) {
return shown ? ("今日数据正在准备,当前展示 " + shown) : "今日数据正在准备,当前展示最近可用数据";
}
if (meta.data_status === "partial" || meta.limit_data_source === "derived") {
return meta.notice || "部分正式数据尚未到齐,当前展示日线推算结果";
}
return "";
}
function renderTopArea(key) { function renderTopArea(key) {
const page = document.querySelector(".m-page"); const page = document.querySelector(".m-page");
if (!page) return; if (!page) return;
let top = page.querySelector(".m-top"); let top = page.querySelector(".m-top");
let html = buildStrip(); let html = buildStrip();
const freshness = dashboardFreshnessNotice();
if (freshness) {
html = '<div class="m-phase-notice"><strong>' + escapeHtml(freshness) + "</strong></div>" + html;
}
if (key === "market/performance") html += performanceConclusion(); if (key === "market/performance") html += performanceConclusion();
if (!top) { if (!top) {
top = document.createElement("div"); top = document.createElement("div");
@@ -1594,11 +1628,43 @@
/* ---------------------------------------------------------------- helpers shared by new pages */ /* ---------------------------------------------------------------- helpers shared by new pages */
function bindConfirmAction(onConfirm) {
const ok = document.querySelector("[data-confirm-ok]");
if (ok && typeof onConfirm === "function") {
ok.addEventListener("click", function () {
closeSheet();
onConfirm();
});
}
}
function openCenteredDialog(content) {
const root = ensureSheetRoot();
sheetToken += 1;
root.classList.add("is-dialog");
root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-dialog" role="dialog" aria-modal="true">' + content + "</div>";
global.requestAnimationFrame(function () { root.classList.add("is-open"); });
}
function openConfirmSheet(title, body, options) { function openConfirmSheet(title, body, options) {
const opts = options || {}; const opts = options || {};
const confirmLabel = opts.confirmLabel || "确定"; const confirmLabel = opts.confirmLabel || "确定";
const cancelLabel = opts.cancelLabel || "取消"; const cancelLabel = opts.cancelLabel || "取消";
const danger = Boolean(opts.danger); const danger = Boolean(opts.danger);
if (opts.centered) {
openCenteredDialog(
"<h2>" + escapeHtml(title) + "</h2>" +
(body ? "<p>" + escapeHtml(body) + "</p>" : "") +
'<div class="m-dialog-actions">' +
'<button class="m-btn-outline" type="button" data-sheet-close>' + escapeHtml(cancelLabel) + "</button>" +
'<button class="' + (danger ? "m-btn-outline-danger" : "m-btn-primary") + '" type="button" data-confirm-ok>' + escapeHtml(confirmLabel) + "</button>" +
"</div>"
);
bindConfirmAction(opts.onConfirm);
return;
}
openSheet( openSheet(
'<div class="m-sheet-head"><h2>' + escapeHtml(title) + '</h2>' + '<div class="m-sheet-head"><h2>' + escapeHtml(title) + '</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' + '<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + '</button></div>' +
@@ -1610,13 +1676,7 @@
'</div></div>', '</div></div>',
{ detail: false } { detail: false }
); );
const ok = document.querySelector('[data-confirm-ok]'); bindConfirmAction(opts.onConfirm);
if (ok && typeof opts.onConfirm === 'function') {
ok.addEventListener('click', function () {
closeSheet();
opts.onConfirm();
});
}
} }
function nextSeq() { function nextSeq() {
@@ -3123,6 +3183,7 @@
function openSheet(content, opts) { function openSheet(content, opts) {
const root = ensureSheetRoot(); const root = ensureSheetRoot();
sheetToken += 1; sheetToken += 1;
root.classList.remove("is-dialog");
root.innerHTML = root.innerHTML =
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' + '<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' + '<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
@@ -3138,12 +3199,16 @@
if (!root) return; if (!root) return;
const token = sheetToken; const token = sheetToken;
root.classList.remove("is-open"); root.classList.remove("is-open");
root.classList.remove("is-dialog");
global.setTimeout(function () { global.setTimeout(function () {
if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = ""; if (sheetToken === token && !root.classList.contains("is-open")) {
root.innerHTML = "";
}
}, 340); }, 340);
} }
function bindSheetDrag(root) { function bindSheetDrag(root) {
if (root.classList.contains("is-dialog")) return;
const sheet = root.querySelector(".m-sheet"); const sheet = root.querySelector(".m-sheet");
const backdrop = root.querySelector(".m-sheet-backdrop"); const backdrop = root.querySelector(".m-sheet-backdrop");
const handle = root.querySelector(".m-sheet-handle"); const handle = root.querySelector(".m-sheet-handle");
@@ -4863,7 +4928,7 @@
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>"; return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
} }
/* ---------------------------------------------------------------- 系统管理(恢复桌面端已有能力,禁止再走占位页 */ /* ---------------------------------------------------------------- 系统管理(HEL-238 按确认样图重排 */
function isSystemPage(key) { function isSystemPage(key) {
return String(key || state.key || "").indexOf("system/") === 0; return String(key || state.key || "").indexOf("system/") === 0;
@@ -4889,13 +4954,27 @@
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed); return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(parsed);
} }
function currentUser() {
return global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null;
}
function currentUsername() {
const user = currentUser();
return user && user.username ? user.username : "当前账号";
}
function currentMembership() {
const user = currentUser();
return (user && user.membership) || {};
}
function setupSystemPage(key) { function setupSystemPage(key) {
state.key = key; state.key = key;
state.requestedDate = todayString(); state.requestedDate = todayString();
state.sort = { key: "", dir: null }; state.sort = { key: "", dir: null };
state.sortTable = { cols: null, reapply: null }; state.sortTable = { cols: null, reapply: null };
state.detail = null; state.detail = null;
if (key === "system/admin") state.system.adminTab = "market"; if (key === "system/admin") state.system.adminTab = state.system.adminTab || "market";
document.getElementById("m-view").classList.add("m-view-feature"); document.getElementById("m-view").classList.add("m-view-feature");
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" }); global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6))); document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
@@ -4917,6 +4996,9 @@
if (seq !== state.seq || state.key !== key) return; if (seq !== state.seq || state.key !== key) return;
if (key === "system/admin" || key === "system/members") { if (key === "system/admin" || key === "system/members") {
state.system.admin = payload || {}; state.system.admin = payload || {};
state.system.models = ((payload.llm && payload.llm.models) || []).map(function (item) {
return Object.assign({}, item);
});
renderSystemAdmin(key); renderSystemAdmin(key);
} else { } else {
state.system.account = payload || {}; state.system.account = payload || {};
@@ -4929,9 +5011,115 @@
}); });
} }
function currentUsername() { function formatLastUsed(value) {
const user = global.MobileSession && global.MobileSession.state ? global.MobileSession.state.user : null; if (!value) return "";
return user && user.username ? user.username : "当前账号"; const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "";
const now = new Date();
const hh = String(parsed.getHours()).padStart(2, "0");
const mm = String(parsed.getMinutes()).padStart(2, "0");
const sameDay = parsed.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (sameDay) return "今天 " + hh + ":" + mm;
if (parsed.toDateString() === yesterday.toDateString()) return "昨天 " + hh + ":" + mm;
return membershipDateLabel(value) + " " + hh + ":" + mm;
}
function systemRowHtml(item) {
return '<button class="m-sys-row" type="button" data-route="#/feature/' + item.key + '">' +
'<span class="m-sys-row-icon">' + icon(item.icon, 18) + "</span>" +
'<span class="m-sys-row-body"><strong>' + escapeHtml(item.label) + "</strong><small>" + escapeHtml(item.hint) + "</small></span>" +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span>" +
"</button>";
}
function systemThemeRowHtml() {
const dark = document.getElementById("m-app") && document.getElementById("m-app").dataset.theme === "dark";
return '<div class="m-sys-row" data-theme-row>' +
'<span class="m-sys-row-icon" data-theme-row-icon>' + icon(dark ? "moon" : "sun", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>外观主题</strong><small data-theme-row-label>' + (dark ? "当前:夜间模式" : "当前:日间模式") + "</small></span>" +
'<button class="m-theme-switch" type="button" data-theme-toggle role="switch" aria-checked="' + (dark ? "true" : "false") + '" aria-label="切换日间/夜间模式"><span class="m-theme-switch-thumb"></span></button>' +
"</div>";
}
function renderSystemHome() {
state.key = "system";
document.getElementById("m-view").classList.remove("m-view-feature");
global.MobileRouter.updateHeader({ title: "系统管理", back: false, actions: "" });
const view = document.getElementById("m-view");
view.innerHTML = '<div class="m-sys-home" data-system-page="home">' + skeletonHtml(4) + "</div>";
const seq = nextSeq();
Promise.all([
global.MobileAPI.request("/api/account/status").catch(function () { return {}; }),
global.MobileSession.listAccounts().catch(function () { return { accounts: [] }; })
]).then(function (results) {
if (seq !== state.seq || state.key !== "system") return;
state.system.account = results[0] || {};
state.system.accounts = (results[1] && results[1].accounts) || [];
paintSystemHome();
});
}
function paintSystemHome() {
const user = currentUser() || {};
const membership = currentMembership();
const account = state.system.account || {};
const access = account.llm_access || {};
const status = access.membership || membership;
const username = user.username || currentUsername();
const avatar = String(username).slice(0, 1);
const remembered = (state.system.accounts || []).some(function (item) {
return String(item.user_id) === String(user.id);
});
const currentGrant = (state.system.accounts || []).find(function (item) {
return String(item.user_id) === String(user.id);
});
const lastUsed = formatLastUsed(currentGrant && currentGrant.last_used_at);
const metaParts = [];
if (lastUsed) metaParts.push("上次登录:" + lastUsed);
if (remembered) metaParts.push("本机已记住");
const badges = [];
if (global.MobileSession.isAdmin()) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
if (status.subscribed) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员有效</span>');
else if (!global.MobileSession.isAdmin()) badges.push('<span class="m-sys-badge">普通用户</span>');
const accountRows = [
{ key: "system/profile", icon: "user", label: "账号资料", hint: "出生信息 · 加密保存" },
{ key: "system/password", icon: "lock", label: "修改密码", hint: "建议定期更换" },
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
];
const adminRows = [
{ key: "system/admin", icon: "sliders-horizontal", label: "系统设置", hint: "行情数据 · 模型池" },
{ key: "system/members", icon: "users", label: "会员管理", hint: "开通 · 续期 · 额度" }
];
const html =
'<div class="m-sys-home" data-system-page="home">' +
'<div class="m-card m-sys-profile-card"><span class="m-sys-avatar">' + escapeHtml(avatar) + "</span><div>" +
"<strong>" + escapeHtml(username) + "</strong>" +
(metaParts.length ? '<p class="m-sys-profile-meta">' + escapeHtml(metaParts.join(" · ")) + "</p>" : "") +
(badges.length ? '<div class="m-sys-badges">' + badges.join("") + "</div>" : "") +
"</div></div>" +
'<h3 class="m-sys-group-title">账号</h3>' +
'<div class="m-card m-sys-list">' + accountRows.map(systemRowHtml).join("") + "</div>" +
'<h3 class="m-sys-group-title">偏好</h3>' +
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
(global.MobileSession.isAdmin()
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + "</div>"
: "") +
'<h3 class="m-sys-group-title">其他</h3>' +
'<div class="m-card m-sys-list">' +
'<button class="m-sys-row" type="button" data-system-switch>' +
'<span class="m-sys-row-icon">' + icon("arrow-left-right", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>切换账号</strong><small>本机免密进入其他账号</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
'<button class="m-sys-row m-sys-row--danger" type="button" data-system-logout>' +
'<span class="m-sys-row-icon">' + icon("log-out", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
"</div>" +
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "</p>" +
"</div>";
document.getElementById("m-view").innerHTML = html;
} }
function renderSystemProfile() { function renderSystemProfile() {
@@ -4947,9 +5135,10 @@
const gender = birth.gender || "unspecified"; const gender = birth.gender || "unspecified";
const configured = Boolean(payload.birth_profile_configured); const configured = Boolean(payload.birth_profile_configured);
const html = const html =
'<div class="m-form-body" data-system-page="profile">' + '<div class="m-sys-body" data-system-page="profile">' +
'<div class="m-card"><strong>' + escapeHtml(currentUsername()) + "</strong><p class=\"m-sys-lead\">出生信息仅对当前账号可见并加密保存。</p></div>" + '<div class="m-sys-notice"><p>出生信息仅对当前账号可见并加密保存。原始信息不会在观气页回显,智能解读只使用排盘后的派生结果。</p>' +
'<p class="m-sys-lead">原始信息加密保存且不在观气页回显;智能解读只使用排盘后的派生结果。</p>' + '<div class="m-sys-badges"><span class="m-sys-badge ' + (configured ? "m-sys-badge--ok" : "") + '">' + (configured ? "已加密保存" : "尚未设置") + "</span></div></div>" +
'<div class="m-card m-sys-section"><strong>命理资料</strong>' +
formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) + formFieldHtml("出生日期", dateInputHtml("m-sys-birth-date", birthDate), true) +
formFieldHtml("出生时间", '<input id="m-sys-birth-time" type="time" value="' + escapeHtml(birthTime) + '">', true) + formFieldHtml("出生时间", '<input id="m-sys-birth-time" type="time" value="' + escapeHtml(birthTime) + '">', true) +
formFieldHtml("性别", '<select id="m-sys-birth-gender">' + formFieldHtml("性别", '<select id="m-sys-birth-gender">' +
@@ -4957,12 +5146,10 @@
'<option value="male"' + (gender === "male" ? " selected" : "") + ">男</option>" + '<option value="male"' + (gender === "male" ? " selected" : "") + ">男</option>" +
'<option value="female"' + (gender === "female" ? " selected" : "") + ">女</option>" + '<option value="female"' + (gender === "female" ? " selected" : "") + ">女</option>" +
"</select>", false) + "</select>", false) +
'<p class="m-sys-lead">资料状态:' + (configured ? "已加密保存" : "尚未设置") + "</p>" + "</div>" +
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除资料</button>" + '<button class="m-btn-outline-danger" type="button" data-system-delete-birth' + (configured ? "" : " disabled") + ">删除命理资料</button>" +
'<div class="m-card m-sys-account-actions">' + '<p class="m-sys-hint">删除后智能解读将无法使用出生信息,执行前会再次确认。</p>' +
'<button class="m-btn-primary" type="button" data-system-switch>切换账号</button>' + "</div>";
'<button class="m-btn-primary m-btn-danger" type="button" data-system-logout>退出当前账号</button>' +
"</div></div>";
const page = document.querySelector(".m-page"); const page = document.querySelector(".m-page");
if (page) { if (page) {
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" + page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
@@ -4972,17 +5159,16 @@
function renderSystemPassword() { function renderSystemPassword() {
const html = const html =
'<div class="m-form-body" data-system-page="password">' + '<div class="m-sys-body" data-system-page="password">' +
'<p class="m-sys-lead">仅修改当前账号密码,不会保存在这台设备上。</p>' + '<div class="m-sys-notice"><p>仅修改当前账号的登录密码,密码不会保存在这台设备上。修改成功后下次登录需使用新密码。</p></div>' +
formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password">', true) + '<div class="m-card m-sys-section"><strong>设置新密码</strong>' +
formFieldHtml("密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) + formFieldHtml("当前密码", '<input id="m-sys-password-current" type="password" autocomplete="current-password" placeholder="输入现在的密码">', true, '<p class="m-field-error" hidden data-field-error="current"></p>') +
formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password">', true) + formFieldHtml("新密码", '<input id="m-sys-password-new" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="8-128 位">', true, '<p class="m-sys-hint">建议字母与数字混合,不要与其他网站重复。</p><p class="m-field-error" hidden data-field-error="new"></p>') +
"</div>"; formFieldHtml("确认新密码", '<input id="m-sys-password-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password" placeholder="再输入一次新密码">', true, '<p class="m-field-error" hidden data-field-error="confirm"></p>') +
'<button class="m-btn-primary" type="button" data-system-save-password>更新密码</button>' +
"</div></div>";
const page = document.querySelector(".m-page"); const page = document.querySelector(".m-page");
if (page) { if (page) page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>";
page.innerHTML = '<div class="m-scroll" id="m-scroll">' + html + "</div>" +
'<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-password>更新密码</button></div>';
}
} }
function renderSystemMembership() { function renderSystemMembership() {
@@ -4995,25 +5181,28 @@
? number(membership.remaining_days) + " 天" ? number(membership.remaining_days) + " 天"
: (membership.is_admin || membership.subscribed ? "长期有效" : "--"); : (membership.is_admin || membership.subscribed ? "长期有效" : "--");
const detail = membership.subscribed const detail = membership.subscribed
? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效")) ? ((membership.plan || "会员") + (membership.expires_at ? " · 有效至 " + membershipDateLabel(membership.expires_at) : " · 长期有效") + (membership.remaining_days != null ? ",剩余 " + number(membership.remaining_days) + " 天。" : "。"))
: membership.is_admin : membership.is_admin
? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。" ? "管理员拥有智能功能管理权限,但不会因此显示为已开通会员。"
: "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。"; : "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。";
const quota = "会员默认每日智能分析额度 " + number(access.daily_limit) + " 次,由管理员统一设置。"; const usedToday = membership.active ? (number(access.used_today) + " 次") : "--";
const usage = membership.active ? ("今日已用 " + number(access.used_today) + " 次") : "今日智能分析:--";
const usageSummary = membership.active ? (number(access.used_today) + " / " + number(access.daily_limit)) : "--";
const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--"; const remainingCalls = membership.is_admin ? "不限" : membership.active ? (number(access.remaining_calls) + " 次") : "--";
const html = const html =
'<div class="m-form-body" data-system-page="membership">' + '<div class="m-sys-body" data-system-page="membership">' +
'<div class="m-card"><strong>' + escapeHtml(badge) + "</strong><p class=\"m-sys-lead\">" + escapeHtml(detail) + "</p><p class=\"m-sys-lead\">" + escapeHtml(quota) + "</p></div>" + '<div class="m-card m-sys-hero"><strong>' + escapeHtml(badge) + '</strong>' +
(membership.subscribed ? '<div class="m-sys-badges"><span class="m-sys-badge m-sys-badge--ok">已开通</span></div>' : "") +
'<p class="m-sys-lead">' + escapeHtml(detail) + "</p></div>" +
'<div class="m-card m-sys-section"><strong>今日智能分析</strong>' +
'<div class="m-sys-grid">' + '<div class="m-sys-grid">' +
"<div><span>开通状态</span><strong>" + escapeHtml(stateLabel) + "</strong></div>" + "<div><span>开通状态</span><strong>" + escapeHtml(stateLabel) + "</strong></div>" +
"<div><span>剩余时长</span><strong>" + escapeHtml(remaining) + "</strong></div>" + "<div><span>剩余时长</span><strong>" + escapeHtml(remaining) + "</strong></div>" +
"<div><span>今日智能分析</span><strong>" + escapeHtml(usageSummary) + "</strong></div>" + "<div><span>今日已用</span><strong>" + escapeHtml(usedToday) + "</strong></div>" +
"<div><span>剩余智能分析</span><strong>" + escapeHtml(remainingCalls) + "</strong></div>" + "<div><span>今日剩余</span><strong>" + escapeHtml(remainingCalls) + "</strong></div>" +
"</div>" + "</div>" +
'<div class="m-card"><p class="m-sys-lead">' + escapeHtml(usage) + "</p>" + '<p class="m-sys-hint">会员每日智能分析额度 ' + number(access.daily_limit) + " 次,每日 0 点自动重置,由管理员统一设置。</p></div>" +
'<p class="m-sys-lead">行情、搜索、自选与复盘:普通用户可用。智能选股、问师、问天、复盘助手:仅会员可用。</p></div>' + '<div class="m-card m-sys-section"><strong>权益说明</strong>' +
'<p class="m-sys-lead">全部用户可用:行情、搜索、自选股、交易日志与复盘。</p>' +
'<p class="m-sys-lead">会员专属:智能选股、问师、问天、复盘助手等智能功能。</p></div>' +
"</div>"; "</div>";
systemFill(html); systemFill(html);
} }
@@ -5026,6 +5215,10 @@
"</div>"; "</div>";
} }
function statusDot(ok) {
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
}
function renderSystemAdmin(key) { function renderSystemAdmin(key) {
if (key === "system/members") { if (key === "system/members") {
renderSystemMembers(); renderSystemMembers();
@@ -5035,74 +5228,86 @@
const data = payload.data || {}; const data = payload.data || {};
const ifind = data.ifind || {}; const ifind = data.ifind || {};
const llm = payload.llm || {}; const llm = payload.llm || {};
const status = "Tushare " + (data.configured ? "已配置" : "未配置") +
" · iFinD " + (ifind.configured ? "已配置" : "未配置") +
" · " + number(data.snapshot_dates) + " 个交易日";
const refreshLabel = data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停";
const tab = state.system.adminTab === "models" ? "models" : "market"; const tab = state.system.adminTab === "models" ? "models" : "market";
const marketHtml = const marketHtml =
'<div class="m-form-body" data-system-admin-panel="market">' + '<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card"><strong>公共行情</strong><p class="m-sys-lead">' + escapeHtml(status) + "</p><p class=\"m-sys-lead\">" + escapeHtml(refreshLabel) + "</p></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) + '<div class="m-sys-status-list">' +
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token">', false) + '<div class="m-sys-status-item"><span>Tushare</span><span>' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "</span></div>" +
formFieldHtml("交易时段后台刷新", '<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ">", false) + '<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<p class="m-sys-lead">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>' + '<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
'<button class="m-btn-primary" type="button" data-system-refresh>立即后台刷新</button>' + '<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
'<div class="m-card"><strong>历史数据回补</strong></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>' +
'<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>' +
'<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ' hidden>' +
'<button class="m-btn-outline" type="button" data-system-refresh>立即刷新一次</button>' +
'<p class="m-sys-hint">所有用户读取同一份快照,刷新不影响当前页面内容。</p></div>' +
'<div class="m-card m-sys-section"><strong>历史数据回补</strong>' +
formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) + formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) + formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
'<button class="m-btn-primary" type="button" data-system-backfill>开始回补</button>' + '<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
"</div>"; "</div>";
const models = state.system.models || [];
const modelsHtml = const modelsHtml =
'<div class="m-form-body" data-system-admin-panel="models">' + '<div class="m-sys-body" data-system-admin-panel="models">' +
'<div class="m-card m-sys-section"><strong>模型分工</strong>' +
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) + formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) + formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
'<div id="m-sys-model-list">' + renderModelPoolHtml(llm.models || []) + "</div>" + '<p class="m-sys-hint">主模型不可用时自动改用辅助模型</p>' +
'<button class="m-btn-primary" type="button" data-system-add-model>添加模型</button>' + '<button class="m-btn-primary" type="button" data-system-save-models>保存分工</button></div>' +
'<div class="m-card m-sys-section"><strong>模型池 · ' + models.length + " 个</strong>" +
'<div id="m-sys-model-list">' + renderModelPoolHtml(models) + "</div>" +
'<p class="m-sys-hint">点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除</p>' +
'<button class="m-btn-primary" type="button" data-system-add-model>+ 添加模型</button></div>' +
"</div>"; "</div>";
const page = document.querySelector(".m-page"); const page = document.querySelector(".m-page");
if (!page) return; if (!page) return;
const bar = tab === "models" page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>";
? '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-models>保存模型池</button></div>'
: '<div class="m-form-bar"><button class="m-btn-primary" type="button" data-system-save-market>保存行情配置</button></div>';
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>" + bar;
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || ""); if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
} }
function hostOfUrl(url) {
try {
return new URL(url).host;
} catch (error) {
return String(url || "").replace(/^https?:\/\//, "").split("/")[0] || "--";
}
}
function renderModelPoolHtml(models) { function renderModelPoolHtml(models) {
if (!models.length) { if (!models.length) {
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>'; return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
} }
return models.map(function (item, index) { const llm = (state.system.admin && state.system.admin.llm) || {};
return '<article class="m-card" data-model-id="' + escapeHtml(item.id) + '">' + return models.map(function (item) {
"<strong>" + escapeHtml(item.name || ("模型 " + (index + 1))) + "</strong>" + const badges = [];
'<p class="m-sys-lead">' + (item.configured ? "已保存密钥" : "待配置") + "</p>" + if (item.id === llm.primary_model_id) badges.push('<span class="m-sys-badge m-sys-badge--admin">主模型</span>');
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) + if (item.id === llm.fallback_model_id) badges.push('<span class="m-sys-badge">辅助</span>');
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) + badges.push('<span class="m-sys-badge' + (item.configured ? " m-sys-badge--ok" : "") + '">' + (item.configured ? "已配置" : "待配置") + "</span>");
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) + return '<button class="m-sys-model-card" type="button" data-model-id="' + escapeHtml(item.id) + '" data-system-edit-model>' +
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空保留已保存的 Key" : "输入 API Key") + '">', !item.configured) + "<div><strong>" + escapeHtml(item.name || "未命名模型") + "</strong>" +
'<button class="m-btn-primary" type="button" data-system-test-model>测试连接</button>' + '<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-lead" data-model-test-status>未测试</p>' + '<p class="m-sys-hint">' + escapeHtml(hostOfUrl(item.base_url) + " · " + (item.model || "未填写标识")) + "</p></div>" +
'<button class="m-btn-primary m-btn-danger" type="button" data-system-delete-model>删除模型</button>' + '<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
"</article>";
}).join(""); }).join("");
} }
function collectSystemModelPool() { function collectSystemModelPool() {
const models = (state.system.admin && state.system.admin.llm && state.system.admin.llm.models) || []; return (state.system.models || []).map(function (item) {
const saved = new Map(models.map(function (item) { return [item.id, item]; }));
return Array.prototype.map.call(document.querySelectorAll("#m-sys-model-list [data-model-id]"), function (row) {
function fieldValue(name) {
const input = row.querySelector("[data-model-field='" + name + "']");
return String(input && input.value != null ? input.value : "").trim();
}
return { return {
id: row.dataset.modelId, id: item.id,
name: fieldValue("name"), name: item.name || "",
base_url: fieldValue("base_url"), base_url: item.base_url || "",
model: fieldValue("model"), model: item.model || "",
api_key: fieldValue("api_key"), api_key: item.api_key || "",
configured: Boolean(saved.get(row.dataset.modelId) && saved.get(row.dataset.modelId).configured), configured: Boolean(item.configured)
}; };
}); });
} }
@@ -5122,6 +5327,40 @@
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : ""; fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
} }
function openModelEditSheet(modelId) {
const models = state.system.models || [];
const item = models.find(function (row) { return row.id === modelId; }) || {};
state.system.editingModelId = modelId;
openSheet(
'<div class="m-sheet-head"><h2>编辑模型</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-model-id="' + escapeHtml(modelId) + '">' +
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空则保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
'<div class="m-sys-test-row"><button class="m-btn-outline" type="button" data-system-test-model>测试连接</button>' +
'<span data-model-test-status>未测试</span></div>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline-danger" type="button" data-system-delete-model>删除模型</button>' +
'<button class="m-btn-primary" type="button" data-system-save-model>保存</button>' +
"</div></div>"
);
}
function readModelSheetFields(root) {
function fieldValue(name) {
const input = root.querySelector("[data-model-field='" + name + "']");
return String(input && input.value != null ? input.value : "").trim();
}
return {
name: fieldValue("name"),
base_url: fieldValue("base_url"),
model: fieldValue("model"),
api_key: fieldValue("api_key")
};
}
function renderSystemMembers() { function renderSystemMembers() {
const payload = state.system.admin || {}; const payload = state.system.admin || {};
const membership = payload.membership || {}; const membership = payload.membership || {};
@@ -5129,17 +5368,49 @@
const userHtml = users.map(function (user) { const userHtml = users.map(function (user) {
const admin = user.role === "admin"; const admin = user.role === "admin";
const member = Boolean(user.membership_subscribed); const member = Boolean(user.membership_subscribed);
const identity = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · "); const badges = [];
if (admin) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
if (member) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员</span>');
else badges.push('<span class="m-sys-badge">普通用户</span>');
const expiry = member const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效") ? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended" : user.membership_status === "suspended"
? "会员已停用" ? "会员已停用"
: "尚未开通"; : "尚未开通";
return '<article class="m-card" data-admin-user="' + number(user.id) + '">' + return '<div class="m-sys-user-row" data-admin-user="' + number(user.id) + '">' +
"<strong>" + escapeHtml(user.username) + "</strong>" + '<div class="m-sys-row-body"><strong>' + escapeHtml(user.username) + "</strong>" +
'<p class="m-sys-lead">' + escapeHtml(identity) + " · 今日调用 " + number(user.used_today) + "</p>" + '<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-lead">' + escapeHtml(expiry) + "</p>" + '<p class="m-sys-hint">' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p></div>" +
formFieldHtml("状态", '<select data-member-status>' + '<button class="m-btn-outline" type="button" data-system-open-member>管理</button></div>';
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
const html =
'<div class="m-sys-body" data-system-page="members">' +
'<div class="m-card m-sys-section"><strong>全局额度</strong>' +
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
'<p class="m-sys-hint">对所有会员生效,每日 0 点自动重置。</p>' +
'<button class="m-btn-primary" type="button" data-system-save-limit>保存额度</button></div>' +
'<div class="m-card m-sys-section"><strong>会员账号 · ' + users.length + " 个</strong>" +
userHtml +
'<p class="m-sys-hint">点「管理」为对应账号开通、续期或停用会员。</p></div>' +
"</div>";
systemFill(html);
}
function openMemberManageSheet(userId) {
const users = (state.system.admin && state.system.admin.users) || [];
const user = users.find(function (item) { return String(item.id) === String(userId); });
if (!user) return;
state.system.editingUserId = String(userId);
const member = Boolean(user.membership_subscribed);
const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended" ? "会员已停用" : "尚未开通";
openSheet(
'<div class="m-sheet-head"><h2>管理会员 · ' + escapeHtml(user.username) + "</h2>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-admin-user="' + number(user.id) + '">' +
'<p class="m-sys-lead">当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p>" +
formFieldHtml("会员状态", '<select data-member-status>' +
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" + '<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" + '<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" + '<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
@@ -5148,18 +5419,24 @@
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' + '<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
'<option value="12_months">12个月</option><option value="3_years">3年</option>' + '<option value="12_months">12个月</option><option value="3_years">3年</option>' +
'<option value="permanent">永久</option></select>', false) + '<option value="permanent">永久</option></select>', false) +
'<p class="m-sys-hint">从当前时间开始顺延;已有会员则叠加续期。</p>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline" type="button" data-sheet-close>取消</button>' +
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' + '<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
"</article>"; "</div></div>"
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>'; );
const html = }
'<div class="m-form-body" data-system-page="members">' +
'<div class="m-card"><strong>会员调用额度</strong><p class="m-sys-lead">每日自动重置</p></div>' + function setFieldError(inputId, message) {
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) + const input = document.getElementById(inputId);
'<button class="m-btn-primary" type="button" data-system-save-limit>保存调用额度</button>' + const field = input && input.closest(".m-form-field");
'<div class="m-card"><strong>会员账号</strong><p class="m-sys-lead">手动开通与续期</p></div>' + if (!field) return;
userHtml + field.classList.toggle("is-invalid", Boolean(message));
"</div>"; const error = field.querySelector("[data-field-error]");
systemFill(html); if (error) {
error.hidden = !message;
error.textContent = message || "";
}
} }
function saveSystemBirth() { function saveSystemBirth() {
@@ -5176,7 +5453,7 @@
gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified", gender: (document.getElementById("m-sys-birth-gender") || {}).value || "unspecified",
trade_date: todayString(), trade_date: todayString(),
}).then(function () { }).then(function () {
showToast("个人命理资料已保存到当前账号"); showToast("个人命理资料已保存");
loadSystem(); loadSystem();
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "个人命理资料保存失败"); showToast(error && error.message ? error.message : "个人命理资料保存失败");
@@ -5186,12 +5463,12 @@
} }
function deleteSystemBirth() { function deleteSystemBirth() {
openConfirmSheet("删除资料", "确定删除当前账号保存的个人命理资料吗?", { openConfirmSheet("删除命理资料", "删除后智能解读将无法使用你的出生信息,此操作不可恢复。", {
danger: true, danger: true,
centered: true,
confirmLabel: "删除", confirmLabel: "删除",
onConfirm: function () { onConfirm: function () {
global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () { global.MobileAPI.request("/api/account/birth-profile", "DELETE").then(function () {
closeSheet();
showToast("个人命理资料已删除"); showToast("个人命理资料已删除");
loadSystem(); loadSystem();
}).catch(function (error) { }).catch(function (error) {
@@ -5205,6 +5482,23 @@
const current = (document.getElementById("m-sys-password-current") || {}).value || ""; const current = (document.getElementById("m-sys-password-current") || {}).value || "";
const next = (document.getElementById("m-sys-password-new") || {}).value || ""; const next = (document.getElementById("m-sys-password-new") || {}).value || "";
const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || ""; const confirm = (document.getElementById("m-sys-password-confirm") || {}).value || "";
setFieldError("m-sys-password-current", "");
setFieldError("m-sys-password-new", "");
setFieldError("m-sys-password-confirm", "");
let invalid = false;
if (!current) {
setFieldError("m-sys-password-current", "请输入当前密码");
invalid = true;
}
if (next.length < 8 || next.length > 128) {
setFieldError("m-sys-password-new", "新密码长度应为 8 至 128 位");
invalid = true;
}
if (next !== confirm) {
setFieldError("m-sys-password-confirm", "两次输入的密码不一致,请重新输入。");
invalid = true;
}
if (invalid) return;
const button = document.querySelector("[data-system-save-password]"); const button = document.querySelector("[data-system-save-password]");
if (button) button.disabled = true; if (button) button.disabled = true;
global.MobileAPI.request("/api/account/password", "POST", { global.MobileAPI.request("/api/account/password", "POST", {
@@ -5212,14 +5506,13 @@
new_password: next, new_password: next,
confirm_password: confirm, confirm_password: confirm,
}).then(function () { }).then(function () {
const formIds = ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"]; ["m-sys-password-current", "m-sys-password-new", "m-sys-password-confirm"].forEach(function (id) {
formIds.forEach(function (id) {
const input = document.getElementById(id); const input = document.getElementById(id);
if (input) input.value = ""; if (input) input.value = "";
}); });
showToast("密码已更新"); showToast("密码已更新");
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "密码更新失败"); showToast("更新失败:" + (error && error.message ? error.message : "密码更新失败"));
}).then(function () { }).then(function () {
if (button) button.disabled = false; if (button) button.disabled = false;
}); });
@@ -5232,10 +5525,10 @@
function logoutSystemAccount() { function logoutSystemAccount() {
openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", { openConfirmSheet("退出当前账号", "退出后需要重新登录。本机已记录的其他账号仍可直接切换。", {
danger: true, danger: true,
centered: true,
confirmLabel: "退出", confirmLabel: "退出",
onConfirm: function () { onConfirm: function () {
global.MobileSession.logout().then(function () { global.MobileSession.logout().then(function () {
closeSheet();
global.MobileRouter.replace("#/auth"); global.MobileRouter.replace("#/auth");
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "退出失败"); showToast(error && error.message ? error.message : "退出失败");
@@ -5250,9 +5543,8 @@
global.MobileAPI.request("/api/admin/settings", "POST", { global.MobileAPI.request("/api/admin/settings", "POST", {
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(), tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(), ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
background_refresh_enabled: Boolean((document.getElementById("m-sys-bg-refresh") || {}).checked),
}).then(function () { }).then(function () {
showToast("行情配置已保存"); showToast("行情密钥已保存");
loadSystem(); loadSystem();
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败"); showToast(error && error.message ? error.message : "系统配置保存失败");
@@ -5261,30 +5553,65 @@
}); });
} }
function toggleSystemRefresh() {
const enabled = !Boolean((state.system.admin && state.system.admin.data && state.system.admin.data.background_refresh_enabled));
global.MobileAPI.request("/api/admin/settings", "POST", {
background_refresh_enabled: enabled,
}).then(function () {
showToast(enabled ? "后台刷新已启用" : "后台刷新已暂停");
loadSystem();
}).catch(function (error) {
showToast(error && error.message ? error.message : "后台刷新设置失败");
});
}
function saveSystemModels() { function saveSystemModels() {
const button = document.querySelector("[data-system-save-models]"); const button = document.querySelector("[data-system-save-models]");
if (button) button.disabled = true; if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", { global.MobileAPI.request("/api/admin/settings", "POST", {
models: collectSystemModelPool(),
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "", primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "", fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
}).then(function () { }).then(function () {
showToast("模型已保存"); showToast("模型分工已保存");
loadSystem(); loadSystem();
}).catch(function (error) { }).catch(function (error) {
showToast(error && error.message ? error.message : "模型保存失败"); showToast(error && error.message ? error.message : "模型分工保存失败");
}).then(function () { }).then(function () {
if (button) button.disabled = false; if (button) button.disabled = false;
}); });
} }
function persistSystemModels(models, message) {
return global.MobileAPI.request("/api/admin/settings", "POST", { models: models }).then(function () {
showToast(message || "模型池已保存");
closeSheet();
loadSystem();
});
}
function saveEditedSystemModel() {
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
if (!sheet) return;
const id = sheet.dataset.modelId;
const fields = readModelSheetFields(sheet);
const models = collectSystemModelPool().map(function (item) {
if (item.id !== id) return item;
return Object.assign({}, item, fields);
});
persistSystemModels(models, "模型已保存").catch(function (error) {
showToast(error && error.message ? error.message : "模型保存失败");
});
}
function addSystemModel() { function addSystemModel() {
const models = collectSystemModelPool(); const models = collectSystemModelPool();
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000); const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
models.push({ id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false }); const created = { id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false };
state.system.models = models.concat([created]);
const list = document.getElementById("m-sys-model-list"); const list = document.getElementById("m-sys-model-list");
if (list) list.innerHTML = renderModelPoolHtml(models); if (list) list.innerHTML = renderModelPoolHtml(state.system.models);
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || ""); updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
openModelEditSheet(id);
} }
function deleteSystemModel(row) { function deleteSystemModel(row) {
@@ -5292,25 +5619,36 @@
const id = row.dataset.modelId; const id = row.dataset.modelId;
const primary = (document.getElementById("m-sys-primary-model") || {}).value; const primary = (document.getElementById("m-sys-primary-model") || {}).value;
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value; const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
if (id === primary || id === fallback) { const llm = (state.system.admin && state.system.admin.llm) || {};
if (id === primary || id === fallback || id === llm.primary_model_id || id === llm.fallback_model_id) {
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型"); showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
return; return;
} }
openConfirmSheet("删除模型?", "删除后该模型将从模型池移除,此操作不可恢复。", {
danger: true,
centered: true,
confirmLabel: "删除",
onConfirm: function () {
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; }); const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
const list = document.getElementById("m-sys-model-list"); persistSystemModels(models, "模型已删除").catch(function (error) {
if (list) list.innerHTML = renderModelPoolHtml(models); showToast(error && error.message ? error.message : "删除失败");
updateSystemModelRoleOptions(primary, fallback); });
}
});
} }
function testSystemModel(row) { function testSystemModel(row) {
if (!row) return; if (!row) return;
const status = row.querySelector("[data-model-test-status]"); const status = document.querySelector("[data-model-test-status]");
const button = row.querySelector("[data-system-test-model]"); const button = document.querySelector("[data-system-test-model]");
const profile = collectSystemModelPool().find(function (item) { return item.id === row.dataset.modelId; }) || {}; const sheet = document.querySelector(".m-sheet-body[data-model-id]");
const id = (sheet && sheet.dataset.modelId) || row.dataset.modelId;
const fields = sheet ? readModelSheetFields(sheet) : {};
const profile = Object.assign({}, collectSystemModelPool().find(function (item) { return item.id === id; }) || {}, fields, { id: id });
if (button) button.disabled = true; if (button) button.disabled = true;
if (status) status.textContent = "连接中"; if (status) status.textContent = "连接中";
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile: profile }).then(function (payload) { global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: id, profile: profile }).then(function (payload) {
if (status) status.textContent = "已连通 · " + number(payload.result && payload.result.latency_ms) + " ms"; if (status) status.textContent = "上次测试:成功 · " + number(payload.result && payload.result.latency_ms) + "ms";
}).catch(function (error) { }).catch(function (error) {
if (status) status.textContent = error && error.message ? error.message : "测试失败"; if (status) status.textContent = error && error.message ? error.message : "测试失败";
}).then(function () { }).then(function () {
@@ -5331,11 +5669,17 @@
} }
function startSystemBackfill() { function startSystemBackfill() {
const startDate = (document.getElementById("m-sys-backfill-start") || {}).value;
const endDate = (document.getElementById("m-sys-backfill-end") || {}).value;
openConfirmSheet("开始历史回补?", "将按选定日期补齐缺失行情,回补期间页面仍可使用。", {
confirmLabel: "开始回补",
centered: true,
onConfirm: function () {
const button = document.querySelector("[data-system-backfill]"); const button = document.querySelector("[data-system-backfill]");
if (button) button.disabled = true; if (button) button.disabled = true;
global.MobileAPI.request("/api/backfill", "POST", { global.MobileAPI.request("/api/backfill", "POST", {
start_date: (document.getElementById("m-sys-backfill-start") || {}).value, start_date: startDate,
end_date: (document.getElementById("m-sys-backfill-end") || {}).value, end_date: endDate,
}).then(function (payload) { }).then(function (payload) {
const count = payload && payload.results ? payload.results.length : 0; const count = payload && payload.results ? payload.results.length : 0;
showToast("历史回补完成,共处理 " + count + " 个工作日"); showToast("历史回补完成,共处理 " + count + " 个工作日");
@@ -5346,6 +5690,8 @@
if (button) button.disabled = false; if (button) button.disabled = false;
}); });
} }
});
}
function saveSystemMemberLimit() { function saveSystemMemberLimit() {
const button = document.querySelector("[data-system-save-limit]"); const button = document.querySelector("[data-system-save-limit]");
@@ -5364,14 +5710,17 @@
function saveSystemMember(card) { function saveSystemMember(card) {
if (!card) return; if (!card) return;
const status = (card.querySelector("[data-member-status]") || {}).value;
const apply = function () {
const button = card.querySelector("[data-system-save-member]"); const button = card.querySelector("[data-system-save-member]");
if (button) button.disabled = true; if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/membership", "POST", { global.MobileAPI.request("/api/admin/membership", "POST", {
user_id: card.dataset.adminUser, user_id: card.dataset.adminUser,
status: (card.querySelector("[data-member-status]") || {}).value, status: status,
duration: (card.querySelector("[data-member-duration]") || {}).value, duration: (card.querySelector("[data-member-duration]") || {}).value,
}).then(function (payload) { }).then(function (payload) {
if (state.system.admin) state.system.admin.users = payload.users || []; if (state.system.admin) state.system.admin.users = payload.users || [];
closeSheet();
showToast("会员状态已更新"); showToast("会员状态已更新");
renderSystemMembers(); renderSystemMembers();
}).catch(function (error) { }).catch(function (error) {
@@ -5379,6 +5728,17 @@
}).then(function () { }).then(function () {
if (button) button.disabled = false; if (button) button.disabled = false;
}); });
};
if (status === "suspended") {
openConfirmSheet("停用该会员?", "停用后该账号将无法使用会员智能功能,可稍后重新开通。", {
danger: true,
centered: true,
confirmLabel: "停用",
onConfirm: apply
});
return;
}
apply();
} }
/* ---------------------------------------------------------------- events */ /* ---------------------------------------------------------------- events */
@@ -5522,8 +5882,12 @@
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; } if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; } if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; } if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; } if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
if (event.target.closest("[data-system-save-model]")) { saveEditedSystemModel(); return; }
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; } if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
const editModel = event.target.closest("[data-system-edit-model]");
if (editModel) { openModelEditSheet(editModel.dataset.modelId); return; }
const deleteModel = event.target.closest("[data-system-delete-model]"); const deleteModel = event.target.closest("[data-system-delete-model]");
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; } if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
const testModel = event.target.closest("[data-system-test-model]"); const testModel = event.target.closest("[data-system-test-model]");
@@ -5531,6 +5895,8 @@
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; } if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; } if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; } if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
const openMember = event.target.closest("[data-system-open-member]");
if (openMember) { openMemberManageSheet(openMember.closest("[data-admin-user]").dataset.adminUser); return; }
const saveMember = event.target.closest("[data-system-save-member]"); const saveMember = event.target.closest("[data-system-save-member]");
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; } if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
@@ -5691,5 +6057,6 @@
global.MobilePages = { global.MobilePages = {
render: renderPage, render: renderPage,
has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); }, has: function (key) { return Boolean(pageConfig(key) || isComplexPage(key)); },
renderSystemHome: renderSystemHome,
}; };
})(window); })(window);
+6 -2
View File
@@ -187,9 +187,9 @@
const dark = document.getElementById("m-app").dataset.theme === "dark"; const dark = document.getElementById("m-app").dataset.theme === "dark";
const toggle = document.querySelector("[data-theme-toggle]"); const toggle = document.querySelector("[data-theme-toggle]");
if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false"); if (toggle) toggle.setAttribute("aria-checked", dark ? "true" : "false");
const rowIcon = document.querySelector(".m-theme-row-icon"); const rowIcon = document.querySelector(".m-theme-row-icon, [data-theme-row-icon]");
if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun"); if (rowIcon) rowIcon.innerHTML = icon(dark ? "moon" : "sun");
const rowBodySmall = document.querySelector(".m-theme-row-body small"); const rowBodySmall = document.querySelector(".m-theme-row-body small, [data-theme-row-label]");
if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式"; if (rowBodySmall) rowBodySmall.textContent = dark ? "当前:夜间模式" : "当前:日间模式";
} }
@@ -206,6 +206,10 @@
replace(DEFAULT_HASH); replace(DEFAULT_HASH);
return; return;
} }
if (key === "system" && global.MobilePages && typeof global.MobilePages.renderSystemHome === "function") {
global.MobilePages.renderSystemHome();
return;
}
const items = visibleHubItems(hub); const items = visibleHubItems(hub);
updateHeader({ title: hub.title, back: false }); updateHeader({ title: hub.title, back: false });
const section = key === "system" ? themeToggleSection() : ""; const section = key === "system" ? themeToggleSection() : "";
+1 -1
View File
@@ -95,7 +95,7 @@
"/shared/table.js?v=20260803-1", "/shared/table.js?v=20260803-1",
"/shared/theme.js?v=20260803-1", "/shared/theme.js?v=20260803-1",
"/shared/dashboard.js?v=20260820-1", "/shared/dashboard.js?v=20260820-1",
"/shared/session.js?v=20260803-1", "/shared/session.js?v=20260829-hel243",
"/shared/admin.js?v=20260803-1", "/shared/admin.js?v=20260803-1",
"/app.js?v=20260803-2", "/app.js?v=20260803-2",
]; ];
+786 -67
View File
File diff suppressed because it is too large Load Diff
+49 -7
View File
@@ -66,11 +66,13 @@ async function startAdminRefresh() {
const requestedCompact = requestedDate.replaceAll("-", ""); const requestedCompact = requestedDate.replaceAll("-", "");
const actualCompact = actualDate.replaceAll("-", ""); const actualCompact = actualDate.replaceAll("-", "");
const updated = formatTimestamp(meta.updated_at); const updated = formatTimestamp(meta.updated_at);
if (actualCompact !== requestedCompact || meta.carried_forward) { const freshness = dashboardFreshnessMessage(meta);
const reason = meta.notice ? `${meta.notice}` : ""; if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert"); setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert");
showToast("刷新完成,但未获取到所选日期的最新行情"); setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
} else if (meta.notice) { return;
}
if (meta.notice) {
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}${updated}),但数据源提示:${meta.notice}`, "triangle-alert"); setAdminRefreshStatus("warning", `已刷新到 ${actualDate}${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
showToast(`已刷新到 ${actualDate},请留意数据源提示`); showToast(`已刷新到 ${actualDate},请留意数据源提示`);
} else { } else {
@@ -105,6 +107,37 @@ async function waitForAdminRefresh(jobKey) {
throw new Error("刷新等待超时,请稍后重试"); throw new Error("刷新等待超时,请稍后重试");
} }
let dashboardCatchupTimer = 0;
function chineseMonthDay(value) {
const compact = String(value || "").replaceAll("-", "").replaceAll("/", "");
if (!/^\d{8}/.test(compact)) return "";
return `${Number(compact.slice(4, 6))}${Number(compact.slice(6, 8))}`;
}
function dashboardFreshnessMessage(meta = {}) {
if (meta.display_notice) return String(meta.display_notice);
const requested = String(meta.requested_date || "").replaceAll("-", "");
const actual = String(meta.trade_date || "").replaceAll("-", "");
const shown = chineseMonthDay(actual);
if (meta.data_status === "preparing" || (meta.carried_forward && actual && requested && actual !== requested)) {
return shown ? `今日数据正在准备,当前展示 ${shown}` : "今日数据正在准备,当前展示最近可用数据";
}
if (meta.data_status === "partial" || meta.limit_data_source === "derived") {
return meta.notice || "部分正式数据尚未到齐,当前展示日线推算结果";
}
return "";
}
function scheduleDashboardCatchup(meta = {}) {
window.clearTimeout(dashboardCatchupTimer);
const status = String(meta.data_status || "");
if (status !== "preparing" && status !== "partial") return;
dashboardCatchupTimer = window.setTimeout(() => {
loadDashboard(false, true, false);
}, 60000);
}
function applyDashboard(payload, background = false) { function applyDashboard(payload, background = false) {
state.dashboard = payload; state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date; const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
@@ -112,7 +145,11 @@ function applyDashboard(payload, background = false) {
document.querySelector("#qiObservationDate").value = selectedDate; document.querySelector("#qiObservationDate").value = selectedDate;
document.querySelector("#journalDate").value = selectedDate; document.querySelector("#journalDate").value = selectedDate;
renderDashboard(); renderDashboard();
setStatus(`${dashboardSourceLabel(payload.meta)} · 数据已更新`); const freshness = dashboardFreshnessMessage(payload.meta || {});
setStatus(freshness || `${dashboardSourceLabel(payload.meta)} · 数据已更新`);
const updatedAt = document.querySelector("#updatedAt");
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
scheduleDashboardCatchup(payload.meta || {});
if (!background) { if (!background) {
if (state.activeView === "dragonView") loadDragonTiger(); if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup(); if (state.activeView === "screenerView") loadScreenerSetup();
@@ -180,7 +217,12 @@ function renderDashboard() {
} }
} }
updateSentimentGauge(overview.sentiment_score); updateSentimentGauge(overview.sentiment_score);
setText("updatedAt", `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`); const freshness = dashboardFreshnessMessage(meta);
setText("updatedAt", freshness
? freshness
: `${dashboardSourceLabel(meta)} · 更新 ${formatTimestamp(meta.updated_at)}`);
const updatedAt = document.querySelector("#updatedAt");
if (updatedAt) updatedAt.dataset.tone = freshness ? "warning" : "ok";
renderLimitTable(); renderLimitTable();
renderLadderMini(ladders || []); renderLadderMini(ladders || []);
+7 -1
View File
@@ -196,7 +196,13 @@ async function changeAccountPassword(event) {
async function switchAccount() { async function switchAccount() {
toggleAccountDropdown(false); toggleAccountDropdown(false);
window.location.assign("/login/"); const params = new URLSearchParams();
const next = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next.startsWith("/") && !next.startsWith("//") && next !== "/login" && !next.startsWith("/login/") && !next.startsWith("/login?")) {
params.set("next", next);
}
const query = params.toString();
window.location.assign("/login/" + (query ? `?${query}` : ""));
} }
+4
View File
@@ -921,6 +921,10 @@ body.sidebar-collapsed .app-main {
text-align: right; text-align: right;
} }
.status-bar #updatedAt[data-tone="warning"] {
color: var(--warning);
}
.status-bar .risk-note { .status-bar .risk-note {
display: block; display: block;
+29
View File
@@ -147,6 +147,31 @@
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-normal: 220ms; --duration-normal: 220ms;
--login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc); --login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
--login-brand-share: 34%;
--login-brand-min: 420px;
--login-brand-cap: 560px;
--login-brand-wide-share: 29.2%;
--login-brand-pad: 36px 40px 24px;
--login-brand-mark-size: 44px;
--login-brand-name-size: 16px;
--login-hero-size: 28px;
--login-card-width: 408px;
--login-card-pad: 32px;
--login-card-title-size: 22px;
--login-account-row-min: 72px;
--login-account-avatar: 40px;
--login-submit-height: 40px;
--login-stat-chip-bg: rgba(8, 12, 24, 0.48);
--login-candle-up: #e07078;
--login-candle-down: #3db88a;
--login-trend-line: rgba(244, 247, 255, 0.88);
--login-mascot-red: #e8605a;
--login-mascot-red-shade: #c74a45;
--login-mascot-green: #46be93;
--login-mascot-green-shade: #37997a;
--login-mascot-eye: #f7f9fc;
--login-mascot-pupil: #1a2440;
--login-mascot-height: clamp(150px, 15vw, 260px);
--font-size-aux: 11.5px; --font-size-aux: 11.5px;
--font-size-caption: 12.5px; --font-size-caption: 12.5px;
@@ -516,6 +541,10 @@
--warning-line-strong: #66502d; --warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3); --control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a); --login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a);
--login-stat-chip-bg: rgba(6, 8, 16, 0.58);
--login-candle-up: #f06d73;
--login-candle-down: #43bc8a;
--login-trend-line: rgba(232, 236, 244, 0.9);
--dialog-backdrop: var(--backdrop); --dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426; --ladder-level-1: #2d2426;
--ladder-level-2: #2b2822; --ladder-level-2: #2b2822;
+407 -2
View File
@@ -1,4 +1,9 @@
const { test, expect } = require("@playwright/test"); const { test, expect } = require("@playwright/test");
const fs = require("fs");
const path = require("path");
const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots");
fs.mkdirSync(SHOT_DIR, { recursive: true });
function loginPayload(user) { function loginPayload(user) {
return { return {
@@ -54,6 +59,15 @@ async function mockLoginPortal(page, options = {}) {
return; return;
} }
if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") { if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") {
if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay));
if (options.loginFails) {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }),
});
return;
}
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -67,14 +81,58 @@ async function mockLoginPortal(page, options = {}) {
return; return;
} }
if (url.pathname === "/api/auth/me") { if (url.pathname === "/api/auth/me") {
const current = accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null;
const authenticated = Boolean(current) && !options.sessionExpired;
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
ok: true, ok: true,
authenticated: Boolean(currentUserId), authenticated,
csrf_token: "portal-csrf", csrf_token: "portal-csrf",
user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null, user: authenticated ? {
id: current.user_id,
username: current.username,
role: current.role,
membership: current.membership,
} : null,
}),
});
return;
}
if (url.pathname === "/api/dashboard") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
ok: true,
meta: {
trade_date: "2026-07-22",
requested_date: "2026-07-22",
source: "tushare",
realtime: false,
cached: true,
market_status: "closed",
updated_at: "2026-07-22T15:00:00+08:00",
},
overview: {
up_count: 2100,
down_count: 2800,
limit_up_count: 42,
limit_down_count: 8,
broken_count: 17,
seal_rate: 71.2,
amount_billion: 12600,
sentiment_score: 48,
},
limits: [],
broken: [],
down_limits: [],
yesterday_limits: [],
limit_performance: [],
ladders: [],
sectors: [],
sector_rotation: [],
}), }),
}); });
return; return;
@@ -149,3 +207,350 @@ test("managing accounts removes a local record after inline confirmation", async
await expect(page.locator(".login-account-row")).toHaveCount(1); await expect(page.locator(".login-account-row")).toHaveCount(1);
await expect(page.locator(".login-account-row")).toContainText("alpha_user"); await expect(page.locator(".login-account-row")).toContainText("alpha_user");
}); });
async function assertConfirmedSkeleton(page, { width, height }) {
await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。");
await expect(page.locator(".login-brand-header")).toBeVisible();
await expect(page.locator(".login-brand-chart")).toBeVisible();
await expect(page.locator(".login-brand-stats")).toBeVisible();
await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始");
const brand = await page.locator(".login-brand").boundingBox();
const header = await page.locator(".login-brand-header").boundingBox();
const mark = await page.locator(".login-brand-mark").boundingBox();
const name = await page.locator(".login-brand-name").boundingBox();
const title = await page.locator(".login-brand-title").boundingBox();
const stats = await page.locator(".login-brand-stats").boundingBox();
const chart = await page.locator(".login-brand-chart").boundingBox();
const card = await page.locator(".login-card").boundingBox();
expect(brand).toBeTruthy();
expect(header.y - brand.y).toBeLessThan(48);
expect(Math.abs(mark.y - name.y)).toBeLessThan(16);
expect(title.y).toBeGreaterThan(height * 0.28);
expect(title.y).toBeLessThan(height * 0.72);
expect(stats.y).toBeGreaterThan(height * 0.55);
expect(chart.height).toBeGreaterThan(80);
await expect(page.locator("#loginMascots")).toBeVisible();
expect(card.width).toBeGreaterThan(380);
expect(card.width).toBeLessThan(450);
if (width === 1440) {
expect(brand.width).toBeGreaterThan(470);
expect(brand.width).toBeLessThan(520);
expect(brand.height).toBe(height);
const mascots = await page.locator("#loginMascots").boundingBox();
const kicker = await page.locator(".login-brand-kicker").boundingBox();
expect(mascots).toBeTruthy();
expect(kicker).toBeTruthy();
expect(mascots.y).toBeGreaterThan(header.y + header.height - 4);
expect(mascots.y + mascots.height).toBeLessThan(kicker.y + 8);
const gapTop = header.y + header.height;
const gapBottom = kicker.y;
const mid = (gapTop + gapBottom) / 2;
const mascotMid = mascots.y + mascots.height / 2;
expect(Math.abs(mascotMid - mid)).toBeLessThan(48);
expect(title.y).toBeGreaterThan(470);
expect(title.y).toBeLessThan(580);
} else if (width === 1920) {
expect(brand.width).toBeGreaterThan(540);
expect(brand.width).toBeLessThan(580);
} else {
expect(brand.width).toBeGreaterThan(560);
}
}
async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) {
await page.addInitScript((nextTheme) => {
localStorage.setItem("xiaobaiTheme", nextTheme);
}, theme);
await page.setViewportSize({ width, height });
await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay });
await page.goto("/login/");
}
for (const theme of ["light", "dark"]) {
for (const [width, height] of [[1440, 900], [1920, 1080]]) {
test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => {
await openPortal(page, { theme, width, height, accounts: [] });
await assertConfirmedSkeleton(page, { width, height });
await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间");
await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true });
});
}
}
test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => {
await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] });
await assertConfirmedSkeleton(page, { width: 2560, height: 1080 });
});
test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => {
const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item }));
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts,
currentUserId: 1,
});
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await expect(page.locator(".login-avatar")).toHaveCount(2);
await expect(page.locator(".login-add")).toBeVisible();
await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="add"]').click();
await expect(page.locator(".login-card-title")).toHaveText("添加账号");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true });
await page.locator('[data-login-action="picker"]').click();
await page.locator('[data-login-action="manage"]').click();
await expect(page.locator(".login-card-title")).toHaveText("管理账号记录");
await page.locator('[data-confirm-id="2"]').click();
await expect(page.locator(".login-confirm-copy")).toContainText("beta_user");
await page.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true });
});
test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "light",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
await page.locator(".login-submit").click();
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/);
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true });
});
test("loading button appears on the confirmed first-login skeleton", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginDelay: 2500,
});
await page.evaluate(() => {
window.location.replace = () => {};
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("password12");
const submit = page.locator(".login-submit").click();
await expect(page.locator(".login-submit")).toContainText("正在登录...");
await expect(page.locator(".login-spinner")).toBeVisible();
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true });
await submit;
});
async function openPicker(page, options = {}) {
const accounts = options.accounts || SAVED_ACCOUNTS.map((item) => ({ ...item }));
const currentUserId = options.currentUserId ?? 1;
const next = options.next || "/index.html?view=sentimentCycleView";
await page.unroute("**/api/**").catch(() => {});
await mockLoginPortal(page, {
accounts,
currentUserId,
sessionExpired: options.sessionExpired,
switchFails: options.switchFails,
});
await page.goto(`/login/?next=${encodeURIComponent(next)}`);
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
}
test("clicking the current account from two workspace pages returns without switching", async ({ page }) => {
const views = ["sentimentCycleView", "ladderView"];
for (const viewId of views) {
const next = `/index.html?view=${viewId}`;
const switchCalls = [];
const onRequest = (request) => {
if (request.url().includes("/api/auth/switch") && request.method() === "POST") {
switchCalls.push(request);
}
};
page.on("request", onRequest);
await openPicker(page, { next });
await expect(page.locator('[data-resume-id="1"]')).toContainText("继续使用");
await expect(page.locator('[data-resume-id="1"]')).toContainText("当前");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(new RegExp(`[?&]view=${viewId}\\b`));
expect(switchCalls).toEqual([]);
page.off("request", onRequest);
}
});
test("a lone current account can return from the picker instead of dead-ending", async ({ page }) => {
await openPicker(page, {
accounts: [SAVED_ACCOUNTS[0]],
currentUserId: 1,
next: "/index.html?view=reviewWorkspaceView",
});
await expect(page.locator(".login-account-row")).toHaveCount(1);
await expect(page.locator('[data-switch-id]')).toHaveCount(0);
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=reviewWorkspaceView/);
});
test("the return control also restores the originating workspace page", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=ladderView" });
await page.locator('[data-login-action="resume"]').click();
await expect(page).toHaveURL(/view=ladderView/);
});
test("refreshing the picker still returns to the originating page", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=sentimentCycleView" });
await page.reload();
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=sentimentCycleView/);
});
test("an expired current session asks for login instead of pretending to return", async ({ page }) => {
await openPicker(page, {
next: "/index.html?view=sentimentCycleView",
sessionExpired: true,
});
await page.locator('[data-resume-id="1"]').click();
await expect(page.locator(".login-error")).toHaveText("当前会话已失效,请重新登录");
await expect(page).toHaveURL(/\/login\/?/);
});
test("other saved accounts still switch while the current row only resumes", async ({ page }) => {
await openPicker(page, { next: "/index.html?view=auctionView" });
const switched = page.waitForRequest((request) => (
request.url().includes("/api/auth/switch") && request.method() === "POST"
));
await page.locator('[data-switch-id="2"]').click();
const request = await switched;
expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 });
});
test("workspace switch-account menu carries the current page back to the picker", async ({ page }) => {
await mockLoginPortal(page, {
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
currentUserId: 1,
});
await page.goto("/index.html?view=sentimentCycleView");
await expect(page.locator("#accountButton")).toBeVisible();
await page.locator("#accountButton").click();
await page.locator("#switchAccountMenuButton").click();
await expect(page).toHaveURL(/\/login\/\?next=/);
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=sentimentCycleView/);
});
test("workspace switch-account from a second page also returns to that page", async ({ page }) => {
await mockLoginPortal(page, {
accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })),
currentUserId: 1,
});
await page.goto("/index.html?view=ladderView");
await expect(page.locator("#accountButton")).toBeVisible();
await page.locator("#accountButton").click();
await page.locator("#switchAccountMenuButton").click();
await expect(page.locator(".login-card-title")).toHaveText("选择账号");
await page.locator('[data-resume-id="1"]').click();
await expect(page).toHaveURL(/view=ladderView/);
});
test("desktop mascots react to account focus, password, toggle, loading, success and failure", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toHaveAttribute("data-mood", "idle");
await page.locator("#loginUsername").focus();
await expect(mascots).toHaveAttribute("data-mood", "account");
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await expect.poll(async () => (
page.locator(".login-mascot.is-red .login-mascot-hand.is-left").evaluate((node) => getComputedStyle(node).opacity)
)).toBe("1");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "text");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.locator(".login-password-toggle").click();
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "password");
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-password-light-1440.png"), fullPage: true });
});
test("login loading and failure drive short mascot feedback", async ({ page }) => {
await openPortal(page, {
theme: "dark",
width: 1440,
height: 900,
accounts: [],
loginFails: true,
loginDelay: 800,
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("wrong-password");
const submit = page.locator(".login-submit").click();
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "busy");
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "fail");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-fail-dark-1440.png"), fullPage: true });
await submit;
});
test("login success plays a hop before leaving the portal", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
await page.evaluate(() => {
window.location.replace = () => {};
});
await page.locator("#loginUsername").fill("baiqizhi");
await page.locator("#loginPassword").fill("password12");
const submit = page.locator(".login-submit").click();
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", /busy|success/);
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "success", { timeout: 4000 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-success-light-1440.png"), fullPage: true });
await submit;
});
test("reduced motion keeps static mascots without mouse tracking", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await expect(mascots).toBeVisible();
await expect(mascots).toHaveAttribute("data-mood", "idle");
const before = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1200, 120);
await page.waitForTimeout(120);
const after = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(after).toBe(before);
await page.locator("#loginPassword").focus();
await expect(mascots).toHaveAttribute("data-mood", "password");
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-reduced-dark-1440.png"), fullPage: true });
});
test("mouse follow updates mascot pupils on a fine pointer", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
const mascots = page.locator("#loginMascots");
await page.mouse.move(80, 160);
await page.waitForTimeout(180);
const left = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
await page.mouse.move(1280, 200);
await page.waitForTimeout(180);
const right = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
expect(Number.parseFloat(right)).toBeGreaterThan(Number.parseFloat(left));
});
test("narrow screens hide mascots without moving the login card", async ({ page }) => {
await openPortal(page, { theme: "light", width: 800, height: 900, accounts: [] });
await expect(page.locator("#loginMascots")).toBeHidden();
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
await expect(page.locator(".login-brand-title")).toBeHidden();
});
test("theme toggle keeps mascots in the brand gap", async ({ page }) => {
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
await page.locator("#loginThemeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-idle-dark-1440.png"), fullPage: true });
});
+73 -6
View File
@@ -138,6 +138,11 @@ async function mockMobileApi(page, options = {}) {
payload = { items: [] }; payload = { items: [] };
} else if (path === "/api/search") { } else if (path === "/api/search") {
payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } }; payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } };
} else if (path === "/api/auth/accounts") {
payload = {
accounts: [{ user_id: auth.user.id, username: auth.user.username, role: auth.user.role, last_used_at: "2026-07-22T09:12:00+08:00" }],
current_user_id: auth.user.id,
};
} else if (path === "/api/account/status") { } else if (path === "/api/account/status") {
payload = { payload = {
birth_profile_configured: true, birth_profile_configured: true,
@@ -284,12 +289,16 @@ test("mobile login renders before authentication", async ({ page }) => {
test("four hub pages render their icon grids", async ({ page }) => { test("four hub pages render their icon grids", async ({ page }) => {
await mockMobileApi(page); await mockMobileApi(page);
await openMobile(page); await openMobile(page);
for (const hub of ["market", "tools", "review", "system"]) { for (const hub of ["market", "tools", "review"]) {
await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub); await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub);
await expect(page.locator(".m-hub-grid")).toBeVisible(); await expect(page.locator(".m-hub-grid")).toBeVisible();
await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible(); await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1); expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
} }
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator(".m-sys-row").first()).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
}); });
for (const theme of ["day", "night"]) { for (const theme of ["day", "night"]) {
@@ -344,7 +353,7 @@ test("system management pages render real content instead of placeholders", asyn
} }
await navigateToFeature(page, "system/profile"); await navigateToFeature(page, "system/profile");
await expect(page.locator("#m-sys-birth-date")).toBeVisible(); await expect(page.locator("#m-sys-birth-date")).toBeVisible();
await expect(page.locator("[data-system-switch]")).toBeVisible(); await expect(page.locator("[data-system-save-birth]")).toBeVisible();
await navigateToFeature(page, "system/password"); await navigateToFeature(page, "system/password");
await expect(page.locator("#m-sys-password-current")).toBeVisible(); await expect(page.locator("#m-sys-password-current")).toBeVisible();
await navigateToFeature(page, "system/membership"); await navigateToFeature(page, "system/membership");
@@ -355,7 +364,64 @@ test("system management pages render real content instead of placeholders", asyn
await expect(page.locator("#m-sys-member-limit")).toBeVisible(); await expect(page.locator("#m-sys-member-limit")).toBeVisible();
}); });
test("empty profile save click shows a toast instead of a dead button", async ({ page }) => { test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator("#m-view")).toContainText("账号");
await expect(page.locator("#m-view")).toContainText("偏好");
await expect(page.locator("#m-view")).toContainText("管理员专区");
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
await expect(page.locator("[data-system-switch]")).toBeVisible();
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
});
test("system settings tabs, model editor, delete confirm and theme toggle work", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/admin");
await expect(page.locator("[data-system-admin-panel='market']")).toBeVisible();
await page.locator("[data-system-admin-tab='models']").click();
await expect(page.locator("[data-system-admin-panel='models']")).toBeVisible();
await page.locator("[data-system-edit-model]").first().click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toHaveText("编辑模型");
await page.locator("[data-sheet-close]").click();
await page.locator("[data-system-admin-tab='market']").click();
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/profile");
await page.locator("[data-system-delete-birth]").click();
await expect(page.locator(".m-dialog")).toBeVisible();
await expect(page.locator(".m-dialog")).toContainText("删除命理资料");
await page.locator("[data-sheet-close]").click();
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-theme-toggle]")).toBeVisible();
const before = await page.locator("#m-app").getAttribute("data-theme");
await page.locator("[data-theme-toggle]").click();
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
await navigateToFeature(page, "system/members");
await page.locator("[data-system-open-member]").click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
});
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
await mockMobileApi(page);
await openMobile(page);
await navigateToFeature(page, "system/password");
await page.locator("#m-sys-password-current").fill("OldPass12");
await page.locator("#m-sys-password-new").fill("NewPass123");
await page.locator("#m-sys-password-confirm").fill("OtherPass123");
await page.locator("[data-system-save-password]").click();
await expect(page.locator("[data-field-error='confirm']")).toBeVisible();
await expect(page.locator("[data-field-error='confirm']")).toContainText("两次输入的密码不一致");
});
test("empty birth profile save shows a validation toast", async ({ page }) => {
await mockMobileApi(page); await mockMobileApi(page);
await openMobile(page); await openMobile(page);
await navigateToFeature(page, "system/profile"); await navigateToFeature(page, "system/profile");
@@ -371,9 +437,10 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
await mockMobileApi(page, { auth: authSession("user", true) }); await mockMobileApi(page, { auth: authSession("user", true) });
await openMobile(page); await openMobile(page);
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator(".m-hub-grid")).toBeVisible(); await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator('.m-grid-item[data-route="#/feature/system/admin"]')).toHaveCount(0); await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
await expect(page.locator('.m-grid-item[data-route="#/feature/system/members"]')).toHaveCount(0); await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
await expect(page.locator("[data-system-switch]")).toBeVisible();
await navigateToFeature(page, "system/admin"); await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY); await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible(); await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
+207 -7
View File
@@ -1,23 +1,223 @@
from __future__ import annotations from __future__ import annotations
import copy
import threading
import unittest import unittest
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from backend.jobs.service import _verified_dashboard_result from backend.features.market.service import MarketServiceMixin
from backend.jobs.refresh import (
dashboard_has_usable_data,
official_catchup_due,
verified_dashboard_result,
)
from backend.data.providers.tushare_transport import TushareError
class AdminRefreshStatusTests(unittest.TestCase): class AdminRefreshStatusTests(unittest.TestCase):
def test_carried_snapshot_is_reported_as_failed_job(self): def test_carried_snapshot_is_usable_not_failed_job(self):
result = _verified_dashboard_result( result = verified_dashboard_result(
{"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}} {
"meta": {
"trade_date": "2026-09-01",
"requested_date": "2026-09-02",
"carried_forward": True,
"notice": "今日数据正在准备,当前展示 9 月 1 日",
"data_status": "preparing",
},
"overview": {"limit_up_count": 12},
}
) )
self.assertEqual(result["status"], "failed") self.assertNotEqual(result.get("status"), "failed")
self.assertEqual(result["error"], "官方涨跌停数据尚未返回") self.assertEqual(result["meta"]["data_status"], "preparing")
self.assertTrue(dashboard_has_usable_data(result))
def test_derived_limit_snapshot_is_usable_not_failed_job(self):
dashboard = {
"meta": {
"trade_date": "2026-09-02",
"limit_data_source": "derived",
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
"data_status": "partial",
},
"overview": {"limit_up_count": 8},
}
self.assertIs(verified_dashboard_result(dashboard), dashboard)
def test_current_snapshot_is_reported_as_successful_job(self): def test_current_snapshot_is_reported_as_successful_job(self):
dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}} dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}}
self.assertIs(_verified_dashboard_result(dashboard), dashboard) self.assertIs(verified_dashboard_result(dashboard), dashboard)
def test_empty_payload_is_still_failed(self):
result = verified_dashboard_result({"meta": {}, "overview": {}})
self.assertEqual(result["status"], "failed")
class FakeSyncDatabase:
def __init__(self, latest=None):
self.latest = latest
self.saved = []
self.finished = []
def start_sync(self, *_args, **_kwargs):
return 1
def save_snapshot(self, trade_date, source, payload):
self.saved.append((trade_date, source, copy.deepcopy(payload)))
def save_data_snapshot(self, *_args, **_kwargs):
return None
def finish_sync(self, *args, **kwargs):
self.finished.append((args, kwargs))
def get_latest_real_snapshot(self, *_args, **_kwargs):
return copy.deepcopy(self.latest)
def get_snapshot(self, *_args, **_kwargs):
return None
def get_data_snapshot(self, *_args, **_kwargs):
return None
def reason_overrides(self, *_args, **_kwargs):
return {}
class FakeDerivedClient:
def dashboard(self, trade_date: str):
return {
"meta": {
"trade_date": f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}",
"limit_data_source": "derived",
"notice": "涨跌停高级接口当日数据尚未更新,已使用日线数据推算。",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"overview": {"limit_up_count": 3},
"limits": [{"code": "000001"}],
"broken": [],
"down_limits": [],
"yesterday_limits": [],
}
class FakeMissingDailyClient:
def dashboard(self, trade_date: str):
raise TushareError(f"No daily data returned for {trade_date}")
class SyncHarness(MarketServiceMixin):
def __init__(self, client, latest=None):
self.configured = True
self.sync_lock = threading.Lock()
self.database = FakeSyncDatabase(latest)
self._client = client
self.current_user_id = 1
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_missing_official_data_keeps_previous_day_with_preparing_notice(self):
today = date.today()
previous = (today - timedelta(days=1)).strftime("%Y-%m-%d")
latest = {
"meta": {"trade_date": previous, "source": "tushare"},
"overview": {"limit_up_count": 20},
}
harness = SyncHarness(FakeMissingDailyClient(), latest)
payload = harness.sync_dashboard(today.strftime("%Y%m%d"))
meta = payload["meta"]
self.assertTrue(meta["carried_forward"])
self.assertEqual(meta["data_status"], "preparing")
self.assertIn("今日数据正在准备,当前展示", meta["display_notice"])
self.assertIn("", meta["display_notice"])
self.assertNotIn("No daily data", meta["display_notice"])
self.assertNotEqual(verified_dashboard_result(payload).get("status"), "failed")
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 datetime.strptime("15:05", "%H:%M").time() <= now < datetime.strptime("22:00", "%H:%M").time():
self.assertFalse(due)
self.assertTrue(derived_due)
else:
self.assertFalse(due)
self.assertFalse(derived_due)
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('job.status === "failed"', script)
failed_block = script.split("if (job.status === \"failed\")", 1)[1].split("const query", 1)[0]
self.assertIn("后台刷新失败", failed_block)
success_block = script.split("const freshness = dashboardFreshnessMessage(meta);", 1)[1]
self.assertNotIn("后台刷新失败", success_block.split("} else {", 1)[0])
if __name__ == "__main__": if __name__ == "__main__":
+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()
+3
View File
@@ -67,6 +67,9 @@ class DataGatewayTests(unittest.TestCase):
"TushareClient": {"backend/features/market/service.py"}, "TushareClient": {"backend/features/market/service.py"},
"TushareProvider": {"backend/data/gateway.py"}, "TushareProvider": {"backend/data/gateway.py"},
"WebRealtimeAggregator": {"backend/data/gateway.py"}, "WebRealtimeAggregator": {"backend/data/gateway.py"},
"DatahubClient": {"backend/data/gateway.py"},
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
"DatahubBridge": {"backend/data/gateway.py"},
} }
found = {name: set() for name in owners} found = {name: set() for name in owners}
for path in (root / "backend").rglob("*.py"): for path in (root / "backend").rglob("*.py"):
+287
View File
@@ -0,0 +1,287 @@
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.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] = []
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
self.paths.append(path)
if TOKEN in json.dumps(params or {}) 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 test_default_config_keeps_legacy_and_does_not_call_datahub(self) -> None:
settings = DatahubSettings.load(environ={}, credentials={})
self.assertFalse(settings.any_enabled())
self.assertTrue(all(not settings.flags(name).read and not settings.flags(name).shadow for name in DATASETS))
client = FakeClient(error=DatahubError("INTERNAL", "should not be called"))
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, [])
self.assertEqual(len(legacy.calls), 1)
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)
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(error=DatahubError("INTERNAL", "nope"))
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_client.paths, [])
def test_fallback_on_down_401_timeout_empty_unpublished_stale_and_incomplete(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):
if error.code == "EMPTY":
client = FakeClient(response=DatahubResponse(data=[], meta={"stale": False, "staleness_seconds": 0}))
elif error.code == "STALE":
client = FakeClient(response=DatahubResponse(
data=[dict(HUB_DAILY)],
meta={"stale": True, "staleness_seconds": 999999},
))
elif error.code == "INCOMPLETE":
client = FakeClient(response=DatahubResponse(
data=[dict(HUB_DAILY)],
meta={
"stale": False,
"staleness_seconds": 0,
"incomplete": True,
"coverage": {"complete": False, "missing_count": 80},
},
))
else:
client = FakeClient(error=error)
legacy = FakeLegacy([LEGACY_DAILY])
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(len(legacy.calls), 1)
def test_shadow_compares_without_replacing_and_survives_hub_failure(self) -> None:
reports: list[dict[str, Any]] = []
client = FakeClient()
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(len(legacy.calls), 1)
self.assertEqual(reports[0]["equal"], True)
self.assertEqual(reports[0]["matched"], 1)
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
fail_reports: list[dict[str, Any]] = []
fail_legacy = FakeLegacy([LEGACY_DAILY])
fail_wrapped = DatahubAwareTushareClient(
fail_legacy,
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=fail_reports.append),
)
again = fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
self.assertEqual(again[0]["amount"], 2000.0)
self.assertTrue(fail_reports[0]["hub_error"])
self.assertNotIn(TOKEN, json.dumps(fail_reports[0]))
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_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_keeps_legacy_on_first_batch_even_when_read_flag_is_on(self) -> None:
"""问天未永久冻结;首批只读接入仍走旧链路,后续迁移可以纳入。"""
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, [])
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(
response=DatahubResponse(
data=[dict(HUB_DAILY)],
meta={"stale": False, "staleness_seconds": 0, "incomplete": True, "coverage": {"complete": False, "missing_count": 89}},
)
)
legacy = FakeLegacy([LEGACY_DAILY])
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
rows = wrapped.query(
"daily",
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
"ts_code,amount",
)
self.assertEqual(rows[0]["amount"], 2000.0)
self.assertEqual(len(legacy.calls), 1)
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()
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import os
import stat
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CHECK = ROOT / "tools" / "check_deploy_baseline.sh"
BUILD = ROOT / "tools" / "build_image.sh"
def run_check(repo: Path, candidate: str, live: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["GIT_DIR"] = str(repo / ".git")
env["GIT_WORK_TREE"] = str(repo)
return subprocess.run(
["bash", str(CHECK), candidate, "--live-revision", live],
cwd=repo,
capture_output=True,
text=True,
env=env,
check=False,
)
def git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=repo,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
class DeployBaselineGateTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.tmpdir = tempfile.TemporaryDirectory()
cls.repo = Path(cls.tmpdir.name) / "repo"
cls.repo.mkdir()
git(cls.repo, "init")
git(cls.repo, "config", "user.email", "gate@example.com")
git(cls.repo, "config", "user.name", "Gate")
(cls.repo / "README").write_text("base\n", encoding="utf-8")
git(cls.repo, "add", "README")
git(cls.repo, "commit", "-m", "base")
cls.base = git(cls.repo, "rev-parse", "HEAD")
(cls.repo / "online.txt").write_text("live\n", encoding="utf-8")
git(cls.repo, "add", "online.txt")
git(cls.repo, "commit", "-m", "online")
cls.live = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-b", "successor")
(cls.repo / "next.txt").write_text("next\n", encoding="utf-8")
git(cls.repo, "add", "next.txt")
git(cls.repo, "commit", "-m", "successor of live")
cls.successor = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "lagging-main", cls.base)
(cls.repo / "stale.txt").write_text("stale main\n", encoding="utf-8")
git(cls.repo, "add", "stale.txt")
git(cls.repo, "commit", "-m", "lagging main")
cls.lagging = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "side", cls.base)
(cls.repo / "side.txt").write_text("side branch\n", encoding="utf-8")
git(cls.repo, "add", "side.txt")
git(cls.repo, "commit", "-m", "unrelated side branch")
cls.side = git(cls.repo, "rev-parse", "HEAD")
git(cls.repo, "checkout", "-B", "successor", cls.successor)
@classmethod
def tearDownClass(cls) -> None:
cls.tmpdir.cleanup()
def test_check_script_is_executable(self) -> None:
self.assertTrue(CHECK.exists())
self.assertTrue(stat.S_IXUSR & CHECK.stat().st_mode)
def test_successor_of_live_passes(self) -> None:
result = run_check(self.repo, self.successor, self.live)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(self.live, result.stdout)
self.assertIn(self.successor, result.stdout)
self.assertIn("next.txt", result.stdout)
self.assertIn("祖先关系通过", result.stdout)
def test_lagging_main_is_blocked(self) -> None:
result = run_check(self.repo, self.lagging, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_side_branch_is_blocked(self) -> None:
result = run_check(self.repo, self.side, self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("拒绝", result.stderr)
def test_unknown_commit_is_blocked(self) -> None:
result = run_check(self.repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", self.live)
self.assertNotEqual(result.returncode, 0)
self.assertIn("无法解析", result.stderr)
def test_build_image_calls_the_gate_and_rejects_latest(self) -> None:
source = BUILD.read_text(encoding="utf-8")
self.assertIn("check_deploy_baseline.sh", source)
self.assertIn("禁止构建 latest", source)
self.assertIn("org.opencontainers.image.revision", source)
gate = CHECK.read_text(encoding="utf-8")
self.assertIn("org.opencontainers.image.revision", gate)
self.assertIn("merge-base --is-ancestor", gate)
self.assertIn("候选将丢失的提交", gate)
self.assertIn("禁止人工填写", gate)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOGIN = (ROOT / "frontend" / "login" / "index.html").read_text(encoding="utf-8")
LOGIN_JS = (ROOT / "frontend" / "login" / "page.js").read_text(encoding="utf-8")
AUTH = (ROOT / "frontend" / "shared" / "auth.css").read_text(encoding="utf-8")
TOKENS = (ROOT / "frontend" / "shared" / "tokens.css").read_text(encoding="utf-8")
class LoginPortalContractTests(unittest.TestCase):
def test_confirmed_brand_skeleton_is_in_markup(self) -> None:
for needle in (
'class="login-brand-header"',
'class="login-brand-name"',
"A股个人复盘工作台",
'class="login-brand-kicker"',
"看懂情绪周期,把复盘变成下一次的先手。",
'class="login-brand-chart"',
'class="login-brand-stats"',
"股市有风险,投资需谨慎",
'id="loginThemeToggle"',
'id="loginCard"',
):
self.assertIn(needle, LOGIN)
def test_login_tokens_own_the_confirmed_layout_metrics(self) -> None:
for needle in (
"--login-brand-share: 34%;",
"--login-brand-cap: 560px;",
"--login-brand-wide-share: 29.2%;",
"--login-card-width: 408px;",
"--login-hero-size: 28px;",
"--login-account-row-min: 72px;",
):
self.assertIn(needle, TOKENS)
self.assertIn("width: var(--login-brand-share);", AUTH)
self.assertIn("width: var(--login-card-width);", AUTH)
self.assertIn("justify-content: flex-end;", AUTH)
self.assertIn("body.login-portal {", AUTH)
self.assertIn("padding-bottom: 0;", AUTH)
self.assertNotIn("padding: 0 0 var(--statusbar-height);", AUTH)
def test_confirmed_mascots_fill_the_brand_gap(self) -> None:
for needle in (
'id="loginMascots"',
'aria-hidden="true"',
'class="login-mascot is-red"',
'class="login-mascot is-green"',
'class="login-mascot-back"',
'class="login-mascot-slit"',
):
self.assertIn(needle, LOGIN)
for needle in (
"--login-mascot-red: #e8605a;",
"--login-mascot-green: #46be93;",
"--login-mascot-height: clamp(150px, 15vw, 260px);",
):
self.assertIn(needle, TOKENS)
self.assertIn("flex: 1 1 auto;", AUTH)
self.assertIn(".login-mascots {", AUTH)
self.assertIn("prefers-reduced-motion: reduce", AUTH)
self.assertIn('next === "password"', LOGIN_JS)
self.assertIn("toggle-password", LOGIN_JS)
self.assertIn("celebrateLogin", LOGIN_JS)
def test_portal_keeps_account_switch_and_theme_hooks(self) -> None:
self.assertIn("data-switch-id", LOGIN_JS)
self.assertIn("data-resume-id", LOGIN_JS)
self.assertIn('data-login-action="manage"', LOGIN_JS)
self.assertIn('data-login-action="add"', LOGIN_JS)
self.assertIn('data-login-action="resume"', LOGIN_JS)
self.assertIn("继续使用", LOGIN_JS)
self.assertIn("返回复盘", LOGIN_JS)
self.assertIn("/api/auth/me", LOGIN_JS)
self.assertIn("xiaobaiTheme", LOGIN_JS)
self.assertNotIn("内网个人版", LOGIN)
self.assertNotIn("192.168.200.11", LOGIN)
self.assertNotIn("/api/heaven", LOGIN_JS)
def test_current_account_row_stays_clickable_without_reswitching(self) -> None:
self.assertIn("resumeCurrentAccount", LOGIN_JS)
self.assertIn("当前会话已失效,请重新登录", LOGIN_JS)
self.assertNotIn("!managing && !current ? \"is-switchable\"", LOGIN_JS)
session = (ROOT / "frontend" / "shared" / "session.js").read_text(encoding="utf-8")
self.assertIn('params.set("next", next)', session)
self.assertIn(".login-account-action", AUTH)
+8
View File
@@ -43,6 +43,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
def test_system_pages_render_real_controls_not_stubs(self) -> None: def test_system_pages_render_real_controls_not_stubs(self) -> None:
pages = PAGES.read_text(encoding="utf-8") pages = PAGES.read_text(encoding="utf-8")
for marker in ( for marker in (
'data-system-page="home"',
'data-system-page="profile"', 'data-system-page="profile"',
'data-system-page="password"', 'data-system-page="password"',
'data-system-page="membership"', 'data-system-page="membership"',
@@ -54,6 +55,11 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"m-sys-token", "m-sys-token",
"m-sys-member-limit", "m-sys-member-limit",
"data-system-switch", "data-system-switch",
"data-system-edit-model",
"data-system-open-member",
"管理员专区",
"保存密钥",
"保存分工",
'location.assign("/login/")', 'location.assign("/login/")',
): ):
self.assertIn(marker, pages) self.assertIn(marker, pages)
@@ -73,6 +79,8 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"data-system-save-models", "data-system-save-models",
"data-system-save-market", "data-system-save-market",
"data-system-refresh", "data-system-refresh",
"data-system-toggle-refresh",
"data-system-save-model",
): ):
self.assertIn(name, pages) self.assertIn(name, pages)
self.assertNotIn(name + '">', pages) self.assertNotIn(name + '">', pages)
+18 -5
View File
@@ -20,12 +20,25 @@ registry, and verification tools.
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`: - `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
auditable recent trading-day dashboard snapshot backfill. See auditable recent trading-day dashboard snapshot backfill. See
`docs/maintenance/行情历史补档.md`. `docs/maintenance/行情历史补档.md`.
- `bash tools/build_image.sh <commit> <tag>`: the only sanctioned way to build the - `tools/update_from_main.sh` (deployed to the server as
production Docker image. Streams `git archive <commit>` to the deploy host over SSH `~/xiaobai-build/update-from-main.sh`): the server-side update-and-build entry for
(default `moxiaobai@192.168.200.11`), refuses tags that do not end with the commit the managed local worktree at `/opt/1panel/docker/compose/xiaobaifupan`. Fetches
Gitea `main`, enforces branch/clean/fast-forward checks, builds a
`main-<shortsha>` tagged image with the revision label, and verifies the label
after the build. `tools/xiaobai-git` is the matching git wrapper for that
worktree (`status`/`log`/`diff`).
- `bash tools/build_image.sh <commit> <tag>`: agent-grade entry that streams
`git archive <commit>` to the deploy host over SSH (default
`moxiaobai@192.168.200.11`), refuses tags that do not end with the commit
short SHA, verifies the revision label after the build, and appends a record to short SHA, verifies the revision label after the build, and appends a record to
`~/xiaobai-build/BUILD_LOG.tsv` on the host. Building from any server-side working `~/xiaobai-build/BUILD_LOG.tsv` on the host. Before building, it runs
tree is forbidden; see `DOCKER_DEPLOY.md`. `tools/check_deploy_baseline.sh` so the candidate commit must contain the currently
running container's Git revision as an ancestor.
- `bash tools/check_deploy_baseline.sh <commit> [--live-revision <sha>]`: deployment
ancestor gate. Reads the live `org.opencontainers.image.revision` from the running
`xiaobai-review` container (or `--live-revision` in tests), prints the live SHA,
candidate SHA, file diff, and commits the candidate would drop, then exits if the
live revision is not an ancestor of the candidate.
`verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside `verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside
this application. Historical comparison scripts were retired after final standalone acceptance; this application. Historical comparison scripts were retired after final standalone acceptance;
+6
View File
@@ -97,6 +97,7 @@ def code_hotspots() -> list[dict[str, Any]]:
"backend/features/system/service.py", "backend/features/system/service.py",
"backend/features/accounts/application.py", "backend/features/accounts/application.py",
"backend/jobs/service.py", "backend/jobs/service.py",
"backend/jobs/refresh.py",
"database.py", "database.py",
"backend/features/screener/engine.py", "backend/features/screener/engine.py",
"backend/features/screener/catalog.py", "backend/features/screener/catalog.py",
@@ -217,6 +218,7 @@ def build() -> dict[str, Any]:
), ),
"external_data_adapters": [ "external_data_adapters": [
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "stable client facade for primary deterministic market data"}, {"provider": "tushare", "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": "optional official EOD read path behind per-dataset flags"},
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"}, {"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/features/market/charts.py", "runtime_role": "display chart fallback"},
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"}, {"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation"},
@@ -235,6 +237,9 @@ def build() -> dict[str, Any]:
], ],
"provider_construction": [ "provider_construction": [
{"client": "TushareClient", "owner": "backend/data/providers/tushare.py", "compatibility_fallback": "backend/features/market/service.py"}, {"client": "TushareClient", "owner": "backend/data/providers/tushare.py", "compatibility_fallback": "backend/features/market/service.py"},
{"client": "DatahubClient", "owner": "backend/data/gateway.py"},
{"client": "DatahubBridge", "owner": "backend/data/gateway.py"},
{"client": "DatahubAwareTushareClient", "owner": "backend/data/gateway.py"},
{"client": "IfindHttpClient", "owner": "backend/data/gateway.py"}, {"client": "IfindHttpClient", "owner": "backend/data/gateway.py"},
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"}, {"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
{"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"}, {"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"},
@@ -261,6 +266,7 @@ def build() -> dict[str, Any]:
"system_service": "backend/features/system/service.py", "system_service": "backend/features/system/service.py",
"account_bridge": "backend/features/accounts/application.py", "account_bridge": "backend/features/accounts/application.py",
"job_lifecycle": "backend/jobs/service.py", "job_lifecycle": "backend/jobs/service.py",
"job_refresh_status": "backend/jobs/refresh.py",
"feature_routes": "backend/features/*/routes.py", "feature_routes": "backend/features/*/routes.py",
}, },
"numeric_normalization": [ "numeric_normalization": [
+3
View File
@@ -59,6 +59,9 @@ if [[ "$TAG" != *-"$SHORT" ]]; then
exit 1 exit 1
fi fi
echo "==> 部署基线门禁(线上提交必须是候选祖先)"
bash "$(git rev-parse --show-toplevel)/tools/check_deploy_baseline.sh" "$FULL_SHA"
echo "==> 构建计划" echo "==> 构建计划"
echo " 提交: ${FULL_SHA} ${SUBJECT}" echo " 提交: ${FULL_SHA} ${SUBJECT}"
echo " 镜像: ${REPO_NAME}:${TAG} @ ${HOST}" echo " 镜像: ${REPO_NAME}:${TAG} @ ${HOST}"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# 部署基线门禁(HEL-238):候选提交必须包含当前线上提交的全部历史。
# 线上提交号从运行中的容器镜像 label 读取,禁止人工填写“看起来正确”的基线。
set -euo pipefail
HOST_DEFAULT="moxiaobai@192.168.200.11"
CONTAINER_DEFAULT="xiaobai-review"
usage() {
cat <<'EOF'
用法: tools/check_deploy_baseline.sh <candidate_commit> [--live-revision <sha>]
<candidate_commit> 准备构建/部署的提交(完整或前缀)
--live-revision <sha> 仅测试用:直接指定线上提交,跳过 SSH 读取
环境变量:
XB_BUILD_HOST 部署机 SSH(默认 moxiaobai@192.168.200.11
XB_LIVE_CONTAINER 运行中容器名(默认 xiaobai-review
XB_LIVE_REVISION 若已设置则视为线上提交,不再 SSH
EOF
exit 2
}
[ $# -ge 1 ] || usage
CANDIDATE="$1"
shift
LIVE_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--live-revision)
[ $# -ge 2 ] || usage
LIVE_OVERRIDE="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo "拒绝:未知参数 $1" >&2
usage
;;
esac
done
HOST="${XB_BUILD_HOST:-$HOST_DEFAULT}"
CONTAINER="${XB_LIVE_CONTAINER:-$CONTAINER_DEFAULT}"
case "$HOST" in
*192.168.200.36*)
echo "拒绝:192.168.200.36 已永久废弃,严禁在其上构建或部署。" >&2
exit 1
;;
esac
cd "$(git rev-parse --show-toplevel)"
read_live_revision() {
if [ -n "${LIVE_OVERRIDE}" ]; then
printf '%s\n' "${LIVE_OVERRIDE}"
return
fi
if [ -n "${XB_LIVE_REVISION:-}" ]; then
printf '%s\n' "${XB_LIVE_REVISION}"
return
fi
ssh -o BatchMode=yes "$HOST" bash -s -- "$CONTAINER" <<'REMOTE'
set -euo pipefail
container="$1"
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)"
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)"
if [ -n "$image" ]; then
revision="$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image" 2>/dev/null || true)"
fi
fi
if [ -z "$revision" ] || [ "$revision" = "<no value>" ]; then
echo "拒绝:无法从线上容器 ${container} 读取 org.opencontainers.image.revision。" >&2
exit 1
fi
printf '%s\n' "$revision"
REMOTE
}
LIVE_RAW="$(read_live_revision)"
LIVE_RAW="$(printf '%s' "$LIVE_RAW" | tr -d '[:space:]')"
if [ -z "$LIVE_RAW" ]; then
echo "拒绝:线上提交号为空,禁止继续构建或部署。" >&2
exit 1
fi
CANDIDATE_SHA="$(git rev-parse --verify --quiet "${CANDIDATE}^{commit}" || true)"
if [ -z "$CANDIDATE_SHA" ]; then
echo "拒绝:候选提交 ${CANDIDATE} 无法解析。" >&2
exit 1
fi
LIVE_SHA="$(git rev-parse --verify --quiet "${LIVE_RAW}^{commit}" || true)"
if [ -z "$LIVE_SHA" ]; then
echo "拒绝:线上提交 ${LIVE_RAW} 在本地仓库无法解析;请先 git fetch,禁止手工填写替代基线。" >&2
exit 1
fi
echo "==> 部署基线对照"
echo " 线上提交: ${LIVE_SHA}"
echo " 候选提交: ${CANDIDATE_SHA}"
echo "==> 候选相对线上的文件差异"
DIFF_FILES="$(git diff --name-only "$LIVE_SHA" "$CANDIDATE_SHA" || true)"
if [ -z "$DIFF_FILES" ]; then
echo " (无文件差异)"
else
printf '%s\n' "$DIFF_FILES" | sed 's/^/ /'
fi
echo "==> 候选将丢失的提交(线上有、候选没有)"
LOST="$(git log --oneline "$CANDIDATE_SHA".."$LIVE_SHA" || true)"
if [ -z "$LOST" ]; then
echo " (无)"
else
printf '%s\n' "$LOST" | sed 's/^/ /'
fi
if ! git merge-base --is-ancestor "$LIVE_SHA" "$CANDIDATE_SHA"; then
echo "拒绝:候选提交不是当前线上提交的后继,部署会丢失线上已有提交。禁止构建或部署。" >&2
exit 1
fi
echo "==> 祖先关系通过:线上 ${LIVE_SHA:0:7} 是候选 ${CANDIDATE_SHA:0:7} 的祖先"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# 小白复盘服务器本地目录安全更新/构建入口(HEL-235B 固化)
# 作用:把 /opt/1panel/docker/compose/xiaobaifupan 的 Git 工作目录安全快进到 Gitea main,
# 校验“本地 HEAD = origin/main = 镜像 revision”后,从本地目录构建带提交号的镜像。
# 禁止:不从 main 构建;不使用不带提交短号的 tag;本地有改动/落后/分叉时一律停止。
# 说明:目录顶层归 root,本脚本用“截断写入”绕开 git 对顶层文件 unlink+重建的权限要求;
# 但 main 新增/删除顶层文件时无法自动处理,会列出需管理员执行的精确清单。
set -euo pipefail
GIT_DIR_PATH="$HOME/xiaobai-build/repos/xiaobai-review.git"
WORK_TREE="/opt/1panel/docker/compose/xiaobaifupan"
IMAGE_REPO="xiaobai-review"
LOG_FILE="$HOME/xiaobai-build/BUILD_LOG.tsv"
MODE="${1:-build}"
g() { git --git-dir="$GIT_DIR_PATH" --work-tree="$WORK_TREE" "$@"; }
refuse() { printf '拒绝:%s\n' "$*" >&2; exit 1; }
[ "$MODE" = "build" ] || [ "$MODE" = "verify-tag" ] || refuse "未知子命令「${MODE}」(可用:build / verify-tag <tag>"
[ -d "$GIT_DIR_PATH" ] || refuse "Git 目录不存在:$GIT_DIR_PATH"
echo "==> 拉取 Gitea origin/main"
g fetch --quiet origin main || refuse "无法连接 Gitea 拉取 origin/main"
echo "==> 检查分支与工作区"
BRANCH="$(g symbolic-ref --short HEAD 2>/dev/null || true)"
[ "$BRANCH" = "main" ] || refuse "当前不在 main 分支(${BRANCH:-detached}),停止"
DIRTY="$(g status --porcelain)"
[ -z "$DIRTY" ] || refuse "本地目录有未提交改动或多余文件,先处理再构建:
$DIRTY"
LOCAL_HEAD="$(g rev-parse HEAD)"
REMOTE_HEAD="$(g rev-parse origin/main)"
if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then
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"
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"
LOCAL_HEAD="$(g rev-parse HEAD)"
fi
[ "$LOCAL_HEAD" = "$REMOTE_HEAD" ] || refuse "本地 HEAD 与 origin/main 不一致,停止"
SHORT="${LOCAL_HEAD:0:7}"
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} 不存在"
[ "$LABEL" = "$LOCAL_HEAD" ] || refuse "镜像 revision${LABEL})与当前 main${LOCAL_HEAD})不一致,禁止部署"
echo "==> 通过:${IMAGE_REPO}:${TAG} 的 revision 与 main 一致,可以部署"
exit 0
fi
TAG="main-${SHORT}"
echo "==> 从本地目录构建 ${IMAGE_REPO}:${TAG}"
docker build --rm -t "${IMAGE_REPO}:${TAG}" \
--label "org.opencontainers.image.revision=${LOCAL_HEAD}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$WORK_TREE" 2>&1 | tail -5
echo "==> 回读校验镜像 revision"
GOT="$(docker image inspect "${IMAGE_REPO}:${TAG}" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
if [ "$GOT" != "$LOCAL_HEAD" ]; then
docker rmi "${IMAGE_REPO}:${TAG}" >/dev/null 2>&1 || true
refuse "镜像 revision${GOT})与 main${LOCAL_HEAD})不一致,已删除镜像"
fi
IMAGE_ID="$(docker image inspect "${IMAGE_REPO}:${TAG}" --format '{{.Id}}' | cut -c8-19)"
mkdir -p "$(dirname "$LOG_FILE")"
printf '%s\t%s\t%s\t%s\tlocal-worktree\n' \
"$(date '+%F %T')" "${IMAGE_REPO}:${TAG}" "${LOCAL_HEAD}" "${IMAGE_ID}" >> "$LOG_FILE"
cat <<EOF
==> 完成:${IMAGE_REPO}:${TAG}revision=${LOCAL_HEAD}
部署需人工确认,参考 ~/xiaobai-build/README.md 的换版与回滚步骤。
EOF
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# 查看服务器本地目录 Git 状态的便捷入口:xiaobai-git status / log / diff 等
exec git \
--git-dir="$HOME/xiaobai-build/repos/xiaobai-review.git" \
--work-tree="/opt/1panel/docker/compose/xiaobaifupan" \
"$@"
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
.env
.env.*
!.env.example
__pycache__/
*.py[cod]
*.log
data/
tests/
+13
View File
@@ -0,0 +1,13 @@
# 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=
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"]
+95
View File
@@ -0,0 +1,95 @@
# 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
- 暂存 → 校验 → 整批原子发布 → 可回滚
- `/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 天区间查询依赖已核实,本阶段不回补全市场历史。
## 备份
每日 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
+268
View File
@@ -0,0 +1,268 @@
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;
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 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 / index_daily / 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; }
+39
View File
@@ -0,0 +1,39 @@
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:-}"
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,17 @@
{
"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
}
+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 ADAPTER as eastmoney
from datahub.adapters.ifind import ADAPTER as ifind
from datahub.adapters.tencent import ADAPTER as tencent
from datahub.adapters.ths import ADAPTER as ths
from datahub.adapters.xgb import ADAPTER as xgb
RESERVED = {
"eastmoney": eastmoney,
"tencent": tencent,
"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,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("eastmoney")
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("ifind")
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("tencent")
+3
View File
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("ths")
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any, Callable
from datahub.adapters.base import AdapterError, MarketAdapter
from datahub.normalize import (
normalize_auction,
normalize_calendar,
normalize_daily,
normalize_index_daily,
normalize_moneyflow,
normalize_stock,
normalize_valuation,
)
TUSHARE_URL = "http://api.tushare.pro"
TUSHARE_FIELDS = {
"trade_cal": "exchange,cal_date,is_open,pretrade_date",
"stock_basic": "ts_code,symbol,name,area,industry,market,list_status,list_date",
"daily": "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
"daily_basic": "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,pe_ttm,pb,ps_ttm,dv_ttm",
"adj_factor": "ts_code,trade_date,adj_factor",
"index_daily": "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
"moneyflow": (
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount"
),
"stk_auction": "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
}
DATASET_API = {
"calendar": "trade_cal",
"stocks": "stock_basic",
"daily": "daily",
"valuation": "daily_basic",
"adj_factor": "adj_factor",
"index_daily": "index_daily",
"moneyflow": "moneyflow",
"auction": "stk_auction",
}
# Website actual index usage: market cards / 90-day charts (SH/SZ/CYB) plus
# screener 沪深300 benchmark (lookback up to 260 trading days).
WEBSITE_INDEX_CODES = ("000001.SH", "399001.SZ", "399006.SZ", "000300.SH")
DEFAULT_INDEX_CODES = WEBSITE_INDEX_CODES
class TushareAdapter(MarketAdapter):
name = "tushare"
def __init__(
self,
token: str,
timeout: int = 30,
transport: Callable[[str, dict[str, Any], str], list[dict[str, Any]]] | None = None,
) -> None:
self.token = token
self.timeout = timeout
self._transport = transport
def probe(self) -> dict[str, Any]:
if not self.token:
return {"provider": self.name, "configured": False, "state": "unconfigured"}
started = time.perf_counter()
try:
rows = self.fetch("calendar", {"exchange": "SSE", "start_date": "20200102", "end_date": "20200102"})
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": "ok" if rows else "empty",
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
api_name = DATASET_API.get(dataset, dataset)
fields = TUSHARE_FIELDS.get(api_name, "")
query_params = dict(params)
if api_name == "stock_basic" and "list_status" not in query_params:
query_params["list_status"] = "L"
if api_name == "trade_cal" and "exchange" not in query_params:
query_params["exchange"] = "SSE"
if api_name == "index_daily" and "ts_code" not in query_params:
# Caller typically loops codes; a missing code would pull nothing useful.
query_params.setdefault("ts_code", DEFAULT_INDEX_CODES[0])
return self._query(api_name, query_params, fields)
def fetch_index_daily(self, trade_date: str, codes: tuple[str, ...] = DEFAULT_INDEX_CODES) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for ts_code in codes:
rows.extend(self.fetch("index_daily", {"ts_code": ts_code, "trade_date": trade_date}))
return rows
def normalize(self, dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
mapping = {
"calendar": normalize_calendar,
"trade_cal": normalize_calendar,
"stocks": normalize_stock,
"stock_basic": normalize_stock,
"daily": normalize_daily,
"valuation": normalize_valuation,
"daily_basic": normalize_valuation,
"moneyflow": normalize_moneyflow,
"auction": normalize_auction,
"stk_auction": normalize_auction,
"index_daily": normalize_index_daily,
}
fn = mapping.get(dataset)
if fn is None:
if dataset == "adj_factor":
return [
{
"ts_code": str(row.get("ts_code") or "").upper(),
"trade_date": str(row.get("trade_date") or ""),
"adj_factor": row.get("adj_factor"),
}
for row in rows
]
raise AdapterError(f"unsupported dataset: {dataset}")
return [fn(row) for row in rows]
def _query(self, api_name: str, params: dict[str, Any], fields: str) -> list[dict[str, Any]]:
if self._transport is not None:
return self._transport(api_name, params, fields)
if not self.token:
raise AdapterError("Tushare token 未配置")
payload = json.dumps(
{"api_name": api_name, "token": self.token, "params": params, "fields": fields}
).encode("utf-8")
request = urllib.request.Request(
TUSHARE_URL,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "XiaobaiDatahub/0.1"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
result = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError:
raise AdapterError("Tushare returned invalid json") from None
except (urllib.error.URLError, TimeoutError) as exc:
raise AdapterError(f"Tushare request failed: {exc}") from exc
if result.get("code") != 0:
raise AdapterError(result.get("msg") or "Tushare returned an unknown error")
data = result.get("data") or {}
columns = data.get("fields") or []
return [dict(zip(columns, item)) for item in data.get("items") or []]
+3
View File
@@ -0,0 +1,3 @@
from datahub.adapters.base import ReservedAdapter
ADAPTER = ReservedAdapter("xgb")
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import json
from typing import Any
from datahub.adapters import RESERVED
from datahub.auth import AuthService
from datahub.db import HubDB
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import ApiError
from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
class AdminAPI:
def __init__(self, db: HubDB, pipeline: Pipeline, scheduler: Scheduler, auth: AuthService) -> None:
self.db = db
self.pipeline = pipeline
self.scheduler = scheduler
self.auth = auth
def overview(self) -> dict[str, Any]:
today = yyyymmdd(now_shanghai())
cal = self.db.fetchone(
"SELECT is_open FROM trade_calendar WHERE exchange = 'SSE' AND cal_date = ?",
(today,),
)
is_open = bool(cal and int(cal["is_open"]) == 1)
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (today,))
failed = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? AND state IN ('failed','staged')",
(today,),
)
calls = self.db.fetchall(
"SELECT * FROM src_calls ORDER BY id DESC LIMIT 20",
)
return {
"trade_date": today,
"session_phase": session_phase(now_shanghai(), is_open),
"is_open_day": is_open,
"publications": pubs,
"anomalies": failed,
"recent_calls": _public_calls(calls),
"source_count": len(self.db.fetchall("SELECT provider FROM src_health")),
}
def sources(self) -> dict[str, Any]:
health = {f"{row['provider']}:{row['endpoint_class']}": row for row in self.db.fetchall("SELECT * FROM src_health")}
items = [
{
"provider": "tushare",
"role": "official",
"health": health.get("tushare:pro") or {"state": "unknown"},
"credential": self.auth.credential_status("tushare_token") or {"configured": bool(self.pipeline.adapter.token)},
}
]
for name, adapter in RESERVED.items():
items.append(
{
"provider": name,
"role": "reserved",
"health": adapter.probe(),
"credential": {"configured": False, "last4": "", "updated_at": ""},
}
)
# Prefer encrypted last4 if stored
cred = self.auth.credential_status("tushare_token")
if cred.get("configured"):
items[0]["credential"] = cred
elif self.pipeline.adapter.token:
from datahub.crypto import mask_secret
items[0]["credential"] = {"configured": True, "last4": mask_secret(self.pipeline.adapter.token), "updated_at": ""}
return {"items": items}
def probe(self, provider: str) -> dict[str, Any]:
if provider == "tushare":
return self.pipeline.adapter.probe()
adapter = RESERVED.get(provider)
if adapter is None:
raise ApiError("INVALID_ARGUMENT", f"unknown provider: {provider}")
return adapter.probe()
def jobs(self) -> dict[str, Any]:
runs = self.db.fetchall("SELECT * FROM job_runs ORDER BY id DESC LIMIT 100")
return {
"jobs": [
{"id": "precheck", "at": "08:45", "title": "盘前预检"},
{"id": "eod_a", "at": "15:05", "title": "盘后批 A daily/valuation/moneyflow/auction"},
{"id": "eod_b", "at": "15:10", "title": "盘后批 B index_daily"},
{"id": "history_backfill", "at": "manual", "title": "回补历史日历与指数日 K"},
{"id": "cleanup", "at": "00:30", "title": "清理 staging / 日志"},
{"id": "backup", "at": "00:40", "title": "SQLite 备份"},
],
"runs": runs,
}
def run_job(self, job_id: str, trade_date: str) -> dict[str, Any]:
return self.scheduler.run_job(job_id, yyyymmdd(trade_date or now_shanghai()))
def batches(self, date: str, dataset: str = "") -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
if dataset:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? AND dataset = ? ORDER BY started_at",
(trade_date, dataset),
)
else:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? ORDER BY started_at",
(trade_date,),
)
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (trade_date,))
return {"trade_date": trade_date, "batches": rows, "publications": pubs}
def datasets(self, date: str) -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
pubs = self.db.fetchall("SELECT * FROM publications WHERE trade_date = ?", (trade_date,))
diffs = self.db.fetchall(
"SELECT * FROM diff_reports WHERE trade_date = ? ORDER BY id",
(trade_date,),
)
return {"trade_date": trade_date, "publications": pubs, "diff_reports": diffs}
def audit(self) -> dict[str, Any]:
return {"items": self.db.fetchall("SELECT * FROM audit_log ORDER BY id DESC LIMIT 200")}
def rollback(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}")
result = self.pipeline.rollback(dataset, trade_date, actor=actor)
return result
def backfill(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
day = yyyymmdd(trade_date or now_shanghai())
if dataset == "history":
self._dangerous(password, confirm, "history:full")
result = self.pipeline.backfill_history(day)
else:
self._dangerous(password, confirm, f"{dataset}:{day}")
if dataset == "reference":
result = self.pipeline.ingest_reference(day)
else:
result = self.pipeline.run_dataset(dataset, day)
self.pipeline.audit(actor, "backfill", f"{dataset}:{day}", json.dumps({"ok": True}))
return result
def _dangerous(self, password: str, confirm: str, expected: str) -> None:
if not self.auth.confirm_password(password):
raise ApiError("UNAUTHORIZED", "二次确认密码错误")
if confirm.strip() != expected:
raise ApiError("INVALID_ARGUMENT", f"确认词必须为 {expected}")
def _public_calls(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
for row in rows:
out.append(
{
"id": row["id"],
"provider": row["provider"],
"endpoint": row["endpoint"],
"ok": bool(row["ok"]),
"latency_ms": row["latency_ms"],
"error": row["error"],
"created_at": row["created_at"],
}
)
return out
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import secrets
from datetime import timedelta
from typing import Any
from datahub.crypto import SecretVault, mask_secret
from datahub.db import HubDB
from datahub.timeutil import isoformat, now_shanghai
PBKDF2_ROUNDS = 200_000
SESSION_HOURS = 12
LOGIN_FAIL_LIMIT = 5
LOCK_MINUTES = 10
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
raw_salt = salt or os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), raw_salt, PBKDF2_ROUNDS, dklen=32)
return (
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
try:
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
_, actual = hash_password(password, salt)
except (ValueError, TypeError):
return False
return hmac.compare_digest(actual, expected_hash)
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
class AuthService:
def __init__(self, db: HubDB, vault: SecretVault, api_token: str, admin_password: str) -> None:
self.db = db
self.vault = vault
self._bootstrap(api_token, admin_password)
def _bootstrap(self, api_token: str, admin_password: str) -> None:
if api_token:
existing = self.db.fetchone("SELECT token_hash FROM api_tokens WHERE name = ?", ("review",))
hashed = token_hash(api_token)
last4 = mask_secret(api_token)
if existing is None:
self.db.execute(
"INSERT INTO api_tokens(token_hash, name, last4, created_at) VALUES (?,?,?,?)",
(hashed, "review", last4, isoformat()),
)
elif existing["token_hash"] != hashed:
self.db.execute(
"UPDATE api_tokens SET token_hash = ?, last4 = ? WHERE name = ?",
(hashed, last4, "review"),
)
admin = self.db.fetchone("SELECT id FROM hub_admin WHERE username = ?", ("hub_admin",))
if admin is None and admin_password:
salt, hashed = hash_password(admin_password)
now = isoformat()
self.db.execute(
"""
INSERT INTO hub_admin(username, password_salt, password_hash, password_must_change, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
""",
("hub_admin", salt, hashed, now, now),
)
def check_api_token(self, supplied: str) -> bool:
if not supplied:
return False
row = self.db.fetchone(
"SELECT token_hash FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL",
(token_hash(supplied),),
)
return row is not None
def login(self, username: str, password: str) -> dict[str, Any]:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", (username,))
if not user:
raise PermissionError("账号或密码错误")
now = now_shanghai()
locked_until = user.get("locked_until")
if locked_until:
try:
from datetime import datetime
if datetime.fromisoformat(str(locked_until)) > now:
raise PermissionError("账号已锁定,请稍后再试")
except ValueError:
pass
if not verify_password(password, str(user["password_salt"]), str(user["password_hash"])):
fails = int(user["failed_attempts"] or 0) + 1
lock = isoformat(now + timedelta(minutes=LOCK_MINUTES)) if fails >= LOGIN_FAIL_LIMIT else None
self.db.execute(
"UPDATE hub_admin SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(fails, lock, user["id"]),
)
raise PermissionError("账号或密码错误")
self.db.execute(
"UPDATE hub_admin SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
(user["id"],),
)
session = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
expires = isoformat(now + timedelta(hours=SESSION_HOURS))
self.db.execute(
"INSERT INTO hub_sessions(token_hash, csrf_token, expires_at, created_at) VALUES (?,?,?,?)",
(token_hash(session), csrf, expires, isoformat(now)),
)
return {
"session": session,
"csrf": csrf,
"must_change": bool(user["password_must_change"]),
"expires_at": expires,
}
def session_user(self, raw_token: str) -> dict[str, Any] | None:
if not raw_token:
return None
row = self.db.fetchone(
"SELECT * FROM hub_sessions WHERE token_hash = ?",
(token_hash(raw_token),),
)
if not row:
return None
if str(row["expires_at"]) < isoformat():
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (row["token_hash"],))
return None
admin = self.db.fetchone("SELECT username, password_must_change FROM hub_admin WHERE username = ?", ("hub_admin",))
return {
"username": (admin or {}).get("username") or "hub_admin",
"csrf_token": row["csrf_token"],
"must_change": bool((admin or {}).get("password_must_change")),
"token_hash": row["token_hash"],
}
def logout(self, raw_token: str) -> None:
if raw_token:
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (token_hash(raw_token),))
def change_password(self, current: str, new_password: str) -> None:
if len(new_password) < 8:
raise ValueError("新密码至少 8 位")
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user or not verify_password(current, str(user["password_salt"]), str(user["password_hash"])):
raise PermissionError("当前密码错误")
salt, hashed = hash_password(new_password)
self.db.execute(
"UPDATE hub_admin SET password_salt=?, password_hash=?, password_must_change=0, updated_at=? WHERE id=?",
(salt, hashed, isoformat(), user["id"]),
)
def confirm_password(self, password: str) -> bool:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user:
return False
return verify_password(password, str(user["password_salt"]), str(user["password_hash"]))
def credential_status(self, name: str) -> dict[str, Any]:
row = self.db.fetchone("SELECT last4, updated_at FROM credentials WHERE name = ?", (name,))
if not row:
return {"configured": False, "last4": "", "updated_at": ""}
return {"configured": True, "last4": row["last4"], "updated_at": row["updated_at"]}
def store_credential(self, name: str, secret: str) -> None:
payload = self.vault.encrypt_json({name: secret})
self.db.execute(
"""
INSERT INTO credentials(name, encrypted_payload, last4, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
encrypted_payload=excluded.encrypted_payload, last4=excluded.last4, updated_at=excluded.updated_at
""",
(name, payload, mask_secret(secret), isoformat()),
)
def load_credential(self, name: str) -> str:
row = self.db.fetchone("SELECT encrypted_payload FROM credentials WHERE name = ?", (name,))
if not row:
return ""
data = self.vault.decrypt_json(str(row["encrypted_payload"]))
return str(data.get(name) or "")
+38
View File
@@ -0,0 +1,38 @@
"""Command-line entry for one-shot datahub operations."""
from __future__ import annotations
import argparse
import json
import sys
from datahub.hub import build_hub
from datahub.settings import load_settings
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="xiaobai-datahub CLI")
sub = parser.add_subparsers(dest="command", required=True)
history = sub.add_parser("history-backfill", help="回补 2016 年起交易日历和网站所用指数日 K")
history.add_argument("--calendar-start", default=None, help="日历起点,默认配置 calendar_start")
history.add_argument("--index-days", type=int, default=None, help="指数回补交易日数量,默认 260")
history.add_argument("--force", action="store_true", help="覆盖已发布的指数日期")
args = parser.parse_args(argv)
settings = load_settings()
hub = build_hub(settings)
if args.command == "history-backfill":
result = hub.pipeline.backfill_history(
calendar_start=args.calendar_start,
index_days=args.index_days,
force=args.force,
)
json.dump(result, sys.stdout, ensure_ascii=False, indent=2, default=str)
sys.stdout.write("\n")
return 0 if result.get("ok") else 1
parser.error(f"unknown command: {args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from datahub.db import HubDB
def resolve_code(db: HubDB, raw: str) -> str | None:
text = str(raw or "").strip().upper()
if not text:
return None
if "." in text:
row = db.fetchone("SELECT ts_code FROM stock_master WHERE ts_code = ?", (text,))
if row:
return row["ts_code"]
# indices are not always in stock_master
return text
matches = db.fetchall(
"SELECT ts_code FROM stock_master WHERE symbol = ? OR ts_code LIKE ?",
(text, f"{text}.%"),
)
if len(matches) == 1:
return matches[0]["ts_code"]
if len(matches) > 1:
return None
# unique exchange guess for 6-digit codes
suffix = "SH" if text.startswith("6") or text.startswith("9") else "SZ" if text.startswith(("0", "3")) else "BJ"
return f"{text}.{suffix}"
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
from typing import Any, Iterable
from datahub.db import HubDB
from datahub.timeutil import iter_yyyymmdd, yyyymmdd
MISSING_SAMPLE_LIMIT = 10
def coverage_payload(
*,
kind: str,
start: str,
end: str,
expected: Iterable[str],
available: Iterable[str],
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
start = yyyymmdd(start)
end = yyyymmdd(end)
expected_list = sorted({yyyymmdd(item) for item in expected if item})
available_set = {yyyymmdd(item) for item in available if item}
missing = [item for item in expected_list if item not in available_set]
payload: dict[str, Any] = {
"kind": kind,
"complete": not missing,
"requested_from": start,
"requested_to": end,
"available_from": min(available_set) if available_set else None,
"available_to": max(available_set) if available_set else None,
"expected_count": len(expected_list),
"available_count": len(available_set),
"missing_count": len(missing),
"missing_sample": missing[:MISSING_SAMPLE_LIMIT],
}
if extra:
payload.update(extra)
return payload
def calendar_coverage(db: HubDB, start: str, end: str, exchange: str = "SSE") -> dict[str, Any]:
start = yyyymmdd(start)
end = yyyymmdd(end)
expected = list(iter_yyyymmdd(start, end))
rows = db.fetchall(
"SELECT cal_date FROM trade_calendar WHERE exchange = ? AND cal_date >= ? AND cal_date <= ?",
(exchange, start, end),
)
return coverage_payload(
kind="calendar",
start=start,
end=end,
expected=expected,
available=(row["cal_date"] for row in rows),
extra={"exchange": exchange},
)
def published_range_coverage(
db: HubDB,
dataset: str,
start: str,
end: str,
ts_code: str = "",
table: str = "",
) -> dict[str, Any]:
start = yyyymmdd(start)
end = yyyymmdd(end)
calendar = calendar_coverage(db, start, end)
open_rows = db.fetchall(
"""
SELECT cal_date FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1 AND cal_date >= ? AND cal_date <= ?
ORDER BY cal_date
""",
(start, end),
)
expected_open = [row["cal_date"] for row in open_rows]
pubs = db.fetchall(
"""
SELECT trade_date, active_batch FROM publications
WHERE dataset = ? AND trade_date >= ? AND trade_date <= ?
ORDER BY trade_date
""",
(dataset, start, end),
)
published_dates = [row["trade_date"] for row in pubs]
available = list(published_dates)
extra: dict[str, Any] = {
"dataset": dataset,
"calendar_complete": calendar["complete"],
"calendar_missing_count": calendar["missing_count"],
}
if ts_code and table and pubs:
present_code: list[str] = []
for pub in pubs:
hit = db.fetchone(
f"SELECT 1 AS ok FROM {table} WHERE trade_date = ? AND batch_id = ? AND ts_code = ? LIMIT 1",
(pub["trade_date"], pub["active_batch"], ts_code),
)
if hit:
present_code.append(pub["trade_date"])
available = present_code
extra["code"] = ts_code
payload = coverage_payload(
kind="published_range",
start=start,
end=end,
expected=expected_open,
available=available,
extra=extra,
)
if not calendar["complete"]:
payload["complete"] = False
payload["calendar_missing_sample"] = calendar["missing_sample"]
return payload
def point_coverage(trade_date: str, dataset: str = "") -> dict[str, Any]:
day = yyyymmdd(trade_date)
payload = coverage_payload(
kind="point",
start=day,
end=day,
expected=[day],
available=[day],
extra={"dataset": dataset} if dataset else None,
)
return payload
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import json
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
class SecretVault:
def __init__(self, key: str) -> None:
try:
self._fernet = Fernet(key.encode("ascii"))
except (ValueError, TypeError) as exc:
raise ValueError("DATAHUB_ENCRYPTION_KEY 格式无效。") from exc
@staticmethod
def generate_key() -> str:
return Fernet.generate_key().decode("ascii")
def encrypt_json(self, payload: dict[str, Any]) -> str:
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return self._fernet.encrypt(raw).decode("ascii")
def decrypt_json(self, token: str) -> dict[str, Any]:
if not token:
return {}
try:
payload = json.loads(self._fernet.decrypt(token.encode("ascii")).decode("utf-8"))
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("凭据无法解密,请检查 DATAHUB_ENCRYPTION_KEY。") from exc
if not isinstance(payload, dict):
raise ValueError("凭据格式无效。")
return payload
def mask_secret(value: str, last_n: int = 4) -> str:
text = str(value or "")
if not text:
return ""
if len(text) <= last_n:
return "*" * len(text)
return ("*" * max(4, len(text) - last_n)) + text[-last_n:]
+340
View File
@@ -0,0 +1,340 @@
from __future__ import annotations
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from datahub.timeutil import isoformat
SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS credentials (
name TEXT PRIMARY KEY,
encrypted_payload TEXT NOT NULL,
last4 TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_admin (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_must_change INTEGER NOT NULL DEFAULT 1,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
last4 TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS trade_calendar (
exchange TEXT NOT NULL,
cal_date TEXT NOT NULL,
is_open INTEGER NOT NULL,
pretrade_date TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (exchange, cal_date)
);
CREATE TABLE IF NOT EXISTS stock_master (
ts_code TEXT PRIMARY KEY,
symbol TEXT,
name TEXT,
area TEXT,
industry TEXT,
market TEXT,
list_status TEXT,
list_date TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS eod_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL, adj_factor REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_valuation (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
turnover_rate REAL, volume_ratio REAL,
total_mv REAL, circ_mv REAL,
pe_ttm REAL, pb REAL, ps_ttm REAL, dv_ttm REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_moneyflow (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
buy_sm_amount REAL, sell_sm_amount REAL,
buy_md_amount REAL, sell_md_amount REAL,
buy_lg_amount REAL, sell_lg_amount REAL,
buy_elg_amount REAL, sell_elg_amount REAL,
net_mf_amount REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_auction (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
volume REAL, price REAL, amount REAL, pre_close REAL,
turnover_rate REAL, volume_ratio REAL, float_share REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_index_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS staging_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL, adj_factor REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_valuation (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
turnover_rate REAL, volume_ratio REAL,
total_mv REAL, circ_mv REAL, pe_ttm REAL, pb REAL, ps_ttm REAL, dv_ttm REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_moneyflow (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
buy_sm_amount REAL, sell_sm_amount REAL, buy_md_amount REAL, sell_md_amount REAL,
buy_lg_amount REAL, sell_lg_amount REAL, buy_elg_amount REAL, sell_elg_amount REAL,
net_mf_amount REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_auction (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
volume REAL, price REAL, amount REAL, pre_close REAL,
turnover_rate REAL, volume_ratio REAL, float_share REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_index_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS publications (
dataset TEXT NOT NULL, trade_date TEXT NOT NULL,
active_batch TEXT NOT NULL, prev_batch TEXT,
state TEXT NOT NULL,
published_at TEXT NOT NULL,
PRIMARY KEY (dataset, trade_date)
);
CREATE TABLE IF NOT EXISTS publication_history (
dataset TEXT NOT NULL, trade_date TEXT NOT NULL,
batch_id TEXT NOT NULL, published_at TEXT NOT NULL,
generation INTEGER NOT NULL,
PRIMARY KEY (dataset, trade_date, batch_id)
);
CREATE TABLE IF NOT EXISTS batches (
batch_id TEXT PRIMARY KEY,
dataset TEXT NOT NULL,
trade_date TEXT NOT NULL,
state TEXT NOT NULL,
attempt INTEGER DEFAULT 0,
rows_in INTEGER,
rows_out INTEGER,
quality_json TEXT,
started_at TEXT,
finished_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS src_health (
provider TEXT NOT NULL, endpoint_class TEXT NOT NULL,
state TEXT NOT NULL,
last_ok_at TEXT, last_error TEXT,
consec_failures INTEGER DEFAULT 0,
opened_at TEXT,
cooldown_until TEXT,
PRIMARY KEY (provider, endpoint_class)
);
CREATE TABLE IF NOT EXISTS src_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
endpoint TEXT NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER,
error TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
state TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
rows_in INTEGER,
rows_out INTEGER,
error TEXT,
attempt INTEGER DEFAULT 1,
detail TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rt_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
source TEXT NOT NULL,
stored_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS last_known_good (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
source TEXT NOT NULL,
stored_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS diff_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
metric TEXT NOT NULL,
left_source TEXT,
right_source TEXT,
left_value REAL,
right_value REAL,
deviation REAL,
sample_count INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_batches_date ON batches(trade_date, dataset);
CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at);
CREATE INDEX IF NOT EXISTS idx_src_calls_created ON src_calls(created_at);
CREATE INDEX IF NOT EXISTS idx_eod_bars_date ON eod_bars(trade_date, batch_id);
CREATE INDEX IF NOT EXISTS idx_calendar_open ON trade_calendar(is_open, cal_date);
"""
DATASET_TABLES = {
"daily": ("eod_bars", "staging_bars"),
"valuation": ("eod_valuation", "staging_valuation"),
"moneyflow": ("eod_moneyflow", "staging_moneyflow"),
"auction": ("eod_auction", "staging_auction"),
"index_daily": ("eod_index_bars", "staging_index_bars"),
}
class ManagedConnection(sqlite3.Connection):
def __exit__(self, exc_type, exc_value, traceback):
try:
return super().__exit__(exc_type, exc_value, traceback)
finally:
self.close()
class HubDB:
def __init__(self, path: Path, timeout_seconds: float = 20) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.timeout_seconds = timeout_seconds
self._write_lock = threading.RLock()
self.initialize()
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self.path,
timeout=self.timeout_seconds,
factory=ManagedConnection,
)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=20000")
connection.execute("PRAGMA synchronous=NORMAL")
return connection
def initialize(self) -> None:
with self.connect() as connection:
connection.executescript(SCHEMA)
row = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
).fetchone()
if row is None:
connection.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (1, ?)",
(isoformat(),),
)
@contextmanager
def write(self) -> Iterator[sqlite3.Connection]:
with self._write_lock:
with self.connect() as connection:
yield connection
def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(sql, params).fetchall()
return [dict(row) for row in rows]
def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(sql, params).fetchone()
return dict(row) if row else None
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> None:
with self.write() as connection:
connection.execute(sql, params)
def executemany(self, sql: str, rows: list[tuple[Any, ...]]) -> None:
with self.write() as connection:
connection.executemany(sql, rows)
def backup_to(self, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
with self.connect() as source, sqlite3.connect(dest) as target:
source.backup(target)
def vacuum(self) -> None:
with self.connect() as connection:
connection.execute("VACUUM")
@@ -0,0 +1,13 @@
from datahub.governance.circuit import CircuitBreaker, CircuitState
from datahub.governance.lkg import LastKnownGood
from datahub.governance.ratelimit import TokenBucket
from datahub.governance.retry import RetryError, retry_call
__all__ = [
"CircuitBreaker",
"CircuitState",
"LastKnownGood",
"RetryError",
"TokenBucket",
"retry_call",
]
@@ -0,0 +1,107 @@
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
@dataclass
class CircuitState:
state: str = "closed" # closed | open | half_open
consec_failures: int = 0
opened_at: float | None = None
cooldown_until: float = 0.0
last_error: str = ""
last_ok_at: float | None = None
class CircuitBreaker:
"""Sliding-window breaker: 5 consecutive failures or >50% of 60s window → open."""
def __init__(
self,
failure_threshold: int = 5,
window_seconds: float = 60.0,
open_seconds: float = 120.0,
max_open_seconds: float = 600.0,
clock=time.monotonic,
) -> None:
self.failure_threshold = failure_threshold
self.window_seconds = window_seconds
self.open_seconds = open_seconds
self.max_open_seconds = max_open_seconds
self._clock = clock
self._lock = threading.Lock()
self._events: deque[tuple[float, bool]] = deque()
self.status = CircuitState()
self._open_stretch = open_seconds
def allow(self) -> bool:
with self._lock:
self._refresh_locked()
if self.status.state == "open":
return False
if self.status.state == "half_open":
# single probe in flight: caller must record success/failure
return True
return True
def record_success(self) -> CircuitState:
with self._lock:
now = self._clock()
self._events.append((now, True))
self.status.last_ok_at = now
self.status.consec_failures = 0
self.status.last_error = ""
self._open_stretch = self.open_seconds
self.status.state = "closed"
self.status.opened_at = None
self.status.cooldown_until = 0.0
return self._copy()
def record_failure(self, error: str = "") -> CircuitState:
with self._lock:
now = self._clock()
self._events.append((now, False))
self.status.consec_failures += 1
self.status.last_error = error
self._prune_locked(now)
failures = sum(1 for _, ok in self._events if not ok)
total = len(self._events)
rate = (failures / total) if total else 0.0
trip = self.status.consec_failures >= self.failure_threshold or (
total >= self.failure_threshold and rate > 0.5
)
if trip:
self.status.state = "open"
self.status.opened_at = now
self.status.cooldown_until = now + self._open_stretch
self._open_stretch = min(self.max_open_seconds, self._open_stretch * 2)
return self._copy()
def snapshot(self) -> CircuitState:
with self._lock:
self._refresh_locked()
return self._copy()
def _refresh_locked(self) -> None:
now = self._clock()
self._prune_locked(now)
if self.status.state == "open" and now >= self.status.cooldown_until:
self.status.state = "half_open"
def _prune_locked(self, now: float) -> None:
cutoff = now - self.window_seconds
while self._events and self._events[0][0] < cutoff:
self._events.popleft()
def _copy(self) -> CircuitState:
return CircuitState(
state=self.status.state,
consec_failures=self.status.consec_failures,
opened_at=self.status.opened_at,
cooldown_until=self.status.cooldown_until,
last_error=self.status.last_error,
last_ok_at=self.status.last_ok_at,
)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from typing import Any
from datahub.db import HubDB
from datahub.timeutil import isoformat, now_shanghai
class LastKnownGood:
def __init__(self, db: HubDB) -> None:
self.db = db
def store(self, cache_key: str, payload: Any, source: str) -> None:
self.db.execute(
"""
INSERT INTO last_known_good(cache_key, payload, source, stored_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
payload=excluded.payload, source=excluded.source, stored_at=excluded.stored_at
""",
(cache_key, json.dumps(payload, ensure_ascii=False), source, isoformat()),
)
def load(self, cache_key: str) -> dict[str, Any] | None:
row = self.db.fetchone("SELECT * FROM last_known_good WHERE cache_key = ?", (cache_key,))
if not row:
return None
return {
"payload": json.loads(row["payload"]),
"source": row["source"],
"stored_at": row["stored_at"],
}
def put_rt(self, cache_key: str, payload: Any, source: str, ttl_seconds: int) -> None:
now = now_shanghai()
expires = isoformat(now.replace(microsecond=0))
# expires_at stored as iso; compute by adding ttl via timestamp
from datetime import timedelta
self.db.execute(
"""
INSERT INTO rt_cache(cache_key, payload, source, stored_at, expires_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
payload=excluded.payload, source=excluded.source,
stored_at=excluded.stored_at, expires_at=excluded.expires_at
""",
(
cache_key,
json.dumps(payload, ensure_ascii=False),
source,
isoformat(now),
isoformat(now + timedelta(seconds=ttl_seconds)),
),
)
self.store(cache_key, payload, source)
def get_rt(self, cache_key: str, max_stale_seconds: int | None = None) -> dict[str, Any] | None:
row = self.db.fetchone("SELECT * FROM rt_cache WHERE cache_key = ?", (cache_key,))
if not row:
lkg = self.load(cache_key)
if not lkg:
return None
return {**lkg, "stale": True}
stored_at = row["stored_at"]
expired = row["expires_at"] < isoformat()
result = {
"payload": json.loads(row["payload"]),
"source": row["source"],
"stored_at": stored_at,
"stale": expired,
}
if expired and max_stale_seconds is not None:
from datetime import datetime
try:
stored = datetime.fromisoformat(stored_at)
age = (now_shanghai() - stored).total_seconds()
except ValueError:
age = max_stale_seconds + 1
if age > max_stale_seconds:
return None
return result
@@ -0,0 +1,36 @@
from __future__ import annotations
import threading
import time
class TokenBucket:
def __init__(self, rate_per_minute: float, capacity: float | None = None, clock=time.monotonic) -> None:
self.rate_per_second = max(0.001, rate_per_minute / 60.0)
self.capacity = float(capacity if capacity is not None else rate_per_minute)
self._tokens = self.capacity
self._updated = clock()
self._clock = clock
self._lock = threading.Lock()
def acquire(self, tokens: float = 1.0, block: bool = True) -> bool:
while True:
with self._lock:
now = self._clock()
elapsed = max(0.0, now - self._updated)
self._tokens = min(self.capacity, self._tokens + elapsed * self.rate_per_second)
self._updated = now
if self._tokens >= tokens:
self._tokens -= tokens
return True
wait = (tokens - self._tokens) / self.rate_per_second
if not block:
return False
time.sleep(min(wait, 0.05))
@property
def remaining(self) -> float:
with self._lock:
now = self._clock()
elapsed = max(0.0, now - self._updated)
return min(self.capacity, self._tokens + elapsed * self.rate_per_second)
@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
class RetryError(RuntimeError):
def __init__(self, message: str, attempts: int, last_error: BaseException | None = None) -> None:
super().__init__(message)
self.attempts = attempts
self.last_error = last_error
def retry_call(
fn: Callable[[], T],
attempts: int = 5,
base_delay: float = 0.2,
max_delay: float = 8.0,
sleeper: Callable[[float], None] = time.sleep,
retry_on: tuple[type[BaseException], ...] = (Exception,),
) -> T:
last: BaseException | None = None
for attempt in range(1, max(1, attempts) + 1):
try:
return fn()
except retry_on as exc:
last = exc
if attempt >= attempts:
break
delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
sleeper(delay)
raise RetryError(f"retry exhausted after {attempts} attempts: {last}", attempts, last)
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
import json
import mimetypes
import secrets
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import unquote, urlparse
from datahub.hub import Hub
from datahub.logutil import configure_logging, get_logger
from datahub.serving import ApiError, parse_query
LOGGER = get_logger()
SESSION_COOKIE = "datahub_session"
class HubRequestHandler(BaseHTTPRequestHandler):
hub: Hub
def log_message(self, format: str, *args: Any) -> None:
LOGGER.info(format % args)
def do_GET(self) -> None: # noqa: N802
self._dispatch("GET")
def do_POST(self) -> None: # noqa: N802
self._dispatch("POST")
def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(HTTPStatus.NO_CONTENT)
self.send_header("Allow", "GET, POST, OPTIONS")
self.end_headers()
def _dispatch(self, method: str) -> None:
parsed = urlparse(self.path)
path = unquote(parsed.path)
try:
if path in {"/livez", "/healthz"}:
self._json({"status": "ok"}, HTTPStatus.OK)
return
if path.startswith("/v1/"):
self._v1(path, parsed.query)
return
if path.startswith("/admin/api/"):
self._admin_api(method, path)
return
if path.startswith("/admin"):
self._admin_static(path)
return
if path == "/":
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", "/admin/")
self.end_headers()
return
self._json({"error": {"code": "INVALID_ARGUMENT", "message": "Not found"}}, HTTPStatus.NOT_FOUND)
except ApiError as exc:
self._json(exc.payload(), exc.status)
except PermissionError as exc:
self._json({"error": {"code": "UNAUTHORIZED", "message": str(exc)}}, HTTPStatus.UNAUTHORIZED)
except ValueError as exc:
self._json({"error": {"code": "INVALID_ARGUMENT", "message": str(exc)}}, HTTPStatus.BAD_REQUEST)
except Exception:
LOGGER.exception("internal error")
self._json({"error": {"code": "INTERNAL", "message": "internal error"}}, HTTPStatus.INTERNAL_SERVER_ERROR)
def _v1(self, path: str, query: str) -> None:
token = self.headers.get("X-Datahub-Token", "")
if not self.hub.auth.check_api_token(token):
self.hub.pipeline.audit("anonymous", "unauthorized", path, "")
raise ApiError("UNAUTHORIZED", "missing or invalid X-Datahub-Token")
payload = self.hub.api.handle(path, parse_query(query))
self._json(payload, HTTPStatus.OK)
def _admin_api(self, method: str, path: str) -> None:
if path == "/admin/api/login" and method == "POST":
body = self._read_json()
result = self.hub.auth.login(str(body.get("username") or "hub_admin"), str(body.get("password") or ""))
self._json(
{"ok": True, "must_change": result["must_change"], "csrf": result["csrf"]},
HTTPStatus.OK,
extra_headers=[self._cookie(result["session"])],
)
return
user = self.hub.auth.session_user(self._cookie_value(SESSION_COOKIE))
if not user:
raise ApiError("UNAUTHORIZED", "请先登录")
if method == "POST" and path != "/admin/api/login":
csrf = self.headers.get("X-CSRF-Token", "")
if not csrf or not secrets.compare_digest(csrf, str(user["csrf_token"])):
raise ApiError("UNAUTHORIZED", "CSRF 校验失败")
if path == "/admin/api/logout" and method == "POST":
self.hub.auth.logout(self._cookie_value(SESSION_COOKIE))
self._json({"ok": True}, HTTPStatus.OK, extra_headers=[self._cookie("", clear=True)])
return
if path == "/admin/api/session" and method == "GET":
self._json({"username": user["username"], "must_change": user["must_change"], "csrf": user["csrf_token"]}, HTTPStatus.OK)
return
if path == "/admin/api/change-password" and method == "POST":
body = self._read_json()
self.hub.auth.change_password(str(body.get("current") or ""), str(body.get("new_password") or ""))
self.hub.pipeline.audit(user["username"], "change_password", "hub_admin", "")
self._json({"ok": True}, HTTPStatus.OK)
return
if user["must_change"] and path not in {"/admin/api/change-password", "/admin/api/session"}:
raise ApiError("UNAUTHORIZED", "请先修改初始密码")
if path == "/admin/api/overview" and method == "GET":
self._json(self.hub.admin.overview(), HTTPStatus.OK)
return
if path == "/admin/api/sources" and method == "GET":
self._json(self.hub.admin.sources(), HTTPStatus.OK)
return
if path.startswith("/admin/api/sources/") and path.endswith("/probe") and method == "POST":
provider = path.split("/")[4]
self._json(self.hub.admin.probe(provider), HTTPStatus.OK)
return
if path == "/admin/api/jobs" and method == "GET":
self._json(self.hub.admin.jobs(), HTTPStatus.OK)
return
if path.startswith("/admin/api/jobs/") and path.endswith("/run") and method == "POST":
job_id = path.split("/")[4]
body = self._read_json(allow_empty=True)
self._json(self.hub.admin.run_job(job_id, str(body.get("trade_date") or "")), HTTPStatus.OK)
return
if path == "/admin/api/batches" and method == "GET":
query = parse_query(urlparse(self.path).query)
date = (query.get("date") or [""])[0]
dataset = (query.get("dataset") or [""])[0]
self._json(self.hub.admin.batches(date, dataset), HTTPStatus.OK)
return
if path == "/admin/api/datasets" and method == "GET":
query = parse_query(urlparse(self.path).query)
self._json(self.hub.admin.datasets((query.get("date") or [""])[0]), HTTPStatus.OK)
return
if path == "/admin/api/audit" and method == "GET":
self._json(self.hub.admin.audit(), HTTPStatus.OK)
return
if path == "/admin/api/rollback" and method == "POST":
body = self._read_json()
result = self.hub.admin.rollback(
str(body.get("dataset") or ""),
str(body.get("trade_date") or ""),
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
)
self._json(result, HTTPStatus.OK)
return
if path == "/admin/api/backfill" and method == "POST":
body = self._read_json()
result = self.hub.admin.backfill(
str(body.get("dataset") or ""),
str(body.get("trade_date") or ""),
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
)
self._json(result, HTTPStatus.OK)
return
raise ApiError("INVALID_ARGUMENT", f"unknown admin endpoint: {path}")
def _admin_static(self, path: str) -> None:
relative = path[len("/admin"):].lstrip("/") or "index.html"
candidate = (self.hub.static_dir / relative).resolve()
try:
candidate.relative_to(self.hub.static_dir.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if candidate.is_dir():
candidate = candidate / "index.html"
if not candidate.is_file():
candidate = self.hub.static_dir / "index.html"
content = candidate.read_bytes()
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
content_type += "; charset=utf-8"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(content)
def _read_json(self, allow_empty: bool = False) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0") or 0)
if length == 0 and allow_empty:
return {}
if length <= 0 or length > 65536:
raise ValueError("请求内容为空或过大")
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
LOGGER.warning("invalid json request body")
raise ValueError("请求不是合法 JSON") from None
if not isinstance(payload, dict):
raise ValueError("请求不是合法 JSON")
return payload
def _cookie_value(self, name: str) -> str:
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
return ""
morsel = cookie.get(name)
return morsel.value if morsel else ""
def _cookie(self, value: str, clear: bool = False) -> str:
max_age = 0 if clear else 12 * 3600
return f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}"
def _json(self, payload: dict[str, Any], status: HTTPStatus, extra_headers: list[str] | None = None) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
for header in extra_headers or []:
self.send_header("Set-Cookie", header)
self.end_headers()
self.wfile.write(raw)
def make_handler(hub: Hub) -> type[HubRequestHandler]:
class BoundHandler(HubRequestHandler):
pass
BoundHandler.hub = hub
BoundHandler.protocol_version = "HTTP/1.1"
return BoundHandler
def serve(hub: Hub, host: str, port: int) -> None:
configure_logging(hub.settings.log_level)
handler = make_handler(hub)
server = ThreadingHTTPServer((host, port), handler)
hub.start()
LOGGER.info("xiaobai-datahub listening", extra={"hub": {"host": host, "port": port}})
print(f"xiaobai-datahub is running at http://{host}:{port}/admin/")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
hub.stop()
server.server_close()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from pathlib import Path
from datahub.adapters.tushare import TushareAdapter
from datahub.admin_api import AdminAPI
from datahub.auth import AuthService
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.governance.circuit import CircuitBreaker
from datahub.governance.lkg import LastKnownGood
from datahub.governance.ratelimit import TokenBucket
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import V1API
from datahub.settings import Settings, load_settings
class Hub:
def __init__(self, settings: Settings, adapter: TushareAdapter | None = None) -> None:
if not settings.encryption_key:
raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置")
self.settings = settings
self.db = HubDB(settings.db_path)
self.vault = SecretVault(settings.encryption_key)
self.auth = AuthService(self.db, self.vault, settings.api_token, settings.admin_password)
token = settings.tushare_token or self.auth.load_credential("tushare_token")
if settings.tushare_token:
self.auth.store_credential("tushare_token", settings.tushare_token)
token = settings.tushare_token
self.adapter = adapter or TushareAdapter(token)
self.pipeline = Pipeline(
self.db,
self.adapter,
settings,
bucket=TokenBucket(settings.tushare_rate_per_minute),
breaker=CircuitBreaker(),
)
self.lkg = LastKnownGood(self.db)
self.scheduler = Scheduler(self.db, self.pipeline)
self.api = V1API(self.db, self.pipeline, settings)
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth)
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
def start(self) -> None:
if self.settings.scheduler_enabled:
self.scheduler.start()
def stop(self) -> None:
self.scheduler.stop()
def build_hub(settings: Settings | None = None) -> Hub:
return Hub(settings or load_settings())
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import json
import logging
import re
import sys
import traceback
from typing import Any
from datahub.timeutil import isoformat
_SECRET_KEYS = (
"token", "password", "secret", "key", "authorization", "credential",
"tushare_token", "datahub_token", "encryption_key", "cookie",
)
_SECRET_JSON = re.compile(
r'(?i)("(?:' + "|".join(re.escape(key) for key in _SECRET_KEYS) + r')"\s*:\s*")([^"\\]*(?:\\.[^"\\]*)*)(")'
)
def redact_log_text(text: str) -> str:
return _SECRET_JSON.sub(r"\1***\3", str(text))
def _redact(value: Any, key: str = "") -> Any:
lowered = key.lower()
if any(part in lowered for part in _SECRET_KEYS):
return "***"
if isinstance(value, dict):
return {str(item_key): _redact(item_value, str(item_key)) for item_key, item_value in value.items()}
if isinstance(value, list):
return [_redact(item) for item in value]
if isinstance(value, str):
return redact_log_text(value)
return value
def _safe_exc_text(exc_info: tuple[Any, Any, Any]) -> str:
exc = exc_info[1]
if isinstance(exc, json.JSONDecodeError):
return f"JSONDecodeError: invalid json at position {exc.pos}"
cause = getattr(exc, "__cause__", None)
if isinstance(cause, json.JSONDecodeError):
return f"{type(exc).__name__}: invalid json in request"
text = "".join(traceback.format_exception(*exc_info))
if isinstance(cause, json.JSONDecodeError) and cause.doc:
text = text.replace(cause.doc, "")
if isinstance(exc, json.JSONDecodeError) and exc.doc:
text = text.replace(exc.doc, "")
return redact_log_text(text)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"ts": isoformat(),
"level": record.levelname,
"logger": record.name,
"message": redact_log_text(record.getMessage()),
}
extra = getattr(record, "hub", None)
if isinstance(extra, dict):
payload.update(_redact(extra))
if record.exc_info:
payload["exc"] = _safe_exc_text(record.exc_info)
return json.dumps(payload, ensure_ascii=False, default=str)
def configure_logging(level: str = "INFO") -> logging.Logger:
logger = logging.getLogger("datahub")
if logger.handlers:
return logger
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
logger.propagate = False
return logger
def get_logger() -> logging.Logger:
return logging.getLogger("datahub")
+201
View File
@@ -0,0 +1,201 @@
"""Canonical field normalization for Tushare-native rows.
Units (architecture §7.1):
- price: 4 decimal REAL
- pct_chg: percent, 4 decimal REAL
- volume: shares (Tushare daily/index vol is 手 → ×100)
- amount: yuan (Tushare daily/index amount is 千元 → ×1000)
- moneyflow amounts: yuan (Tushare is 万元 → ×1e4)
- daily_basic total_mv / circ_mv: yuan (Tushare is 万元 → ×1e4)
- stk_auction.amount is already yuan in Tushare; volume 手 → ×100
Existing xiaobai-review stores Tushare native units and converts at display time.
Hub converts once at ingest. Golden tests compare hub output against applying
these same factors to review-native rows.
"""
from __future__ import annotations
from typing import Any
from datahub.numbers import finite_number, round4
AMOUNT_THOUSAND_YUAN = 1000.0
AMOUNT_WAN_YUAN = 10000.0
VOLUME_LOT = 100.0
DAILY_FIELDS = ("ts_code", "trade_date", "open", "high", "low", "close", "pct_chg", "vol", "amount")
VALUATION_FIELDS = (
"ts_code", "trade_date", "turnover_rate", "volume_ratio",
"total_mv", "circ_mv", "pe_ttm", "pb", "ps_ttm", "dv_ttm",
)
MONEYFLOW_FIELDS = (
"ts_code", "trade_date",
"buy_sm_amount", "sell_sm_amount", "buy_md_amount", "sell_md_amount",
"buy_lg_amount", "sell_lg_amount", "buy_elg_amount", "sell_elg_amount",
"net_mf_amount",
)
AUCTION_FIELDS = (
"ts_code", "trade_date", "vol", "price", "amount", "pre_close",
"turnover_rate", "volume_ratio", "float_share",
)
INDEX_FIELDS = ("ts_code", "trade_date", "open", "high", "low", "close", "pct_chg", "vol", "amount")
CALENDAR_FIELDS = ("exchange", "cal_date", "is_open", "pretrade_date")
STOCK_FIELDS = ("ts_code", "symbol", "name", "area", "industry", "market", "list_status", "list_date")
def _code(value: Any) -> str:
return str(value or "").strip().upper()
def _date(value: Any) -> str:
return str(value or "").replace("-", "")[:8]
def review_daily_to_canonical(row: dict[str, Any]) -> dict[str, Any]:
"""Convert a review-stored daily row (Tushare native units) to hub canonical."""
return normalize_daily(row)
def normalize_daily(row: dict[str, Any], adj_factor: float | None = None) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"open": round4(finite_number(row.get("open"))),
"high": round4(finite_number(row.get("high"))),
"low": round4(finite_number(row.get("low"))),
"close": round4(finite_number(row.get("close"))),
"pct_chg": round4(finite_number(row.get("pct_chg"))),
"volume": round4(_scale(row.get("vol"), VOLUME_LOT)),
"amount": round4(_scale(row.get("amount"), AMOUNT_THOUSAND_YUAN)),
"adj_factor": round4(finite_number(adj_factor if adj_factor is not None else row.get("adj_factor"))),
}
def normalize_valuation(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"turnover_rate": round4(finite_number(row.get("turnover_rate"))),
"volume_ratio": round4(finite_number(row.get("volume_ratio"))),
"total_mv": round4(_scale(row.get("total_mv"), AMOUNT_WAN_YUAN)),
"circ_mv": round4(_scale(row.get("circ_mv"), AMOUNT_WAN_YUAN)),
"pe_ttm": round4(finite_number(row.get("pe_ttm"))),
"pb": round4(finite_number(row.get("pb"))),
"ps_ttm": round4(finite_number(row.get("ps_ttm"))),
"dv_ttm": round4(finite_number(row.get("dv_ttm"))),
}
def normalize_moneyflow(row: dict[str, Any]) -> dict[str, Any]:
converted = {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
}
for field in MONEYFLOW_FIELDS[2:]:
converted[field] = round4(_scale(row.get(field), AMOUNT_WAN_YUAN))
return converted
def normalize_auction(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"volume": round4(_scale(row.get("vol") if row.get("vol") is not None else row.get("volume"), VOLUME_LOT)),
"price": round4(finite_number(row.get("price"))),
"amount": round4(finite_number(row.get("amount"))),
"pre_close": round4(finite_number(row.get("pre_close"))),
"turnover_rate": round4(finite_number(row.get("turnover_rate"))),
"volume_ratio": round4(finite_number(row.get("volume_ratio"))),
"float_share": round4(_scale(row.get("float_share"), AMOUNT_WAN_YUAN) if row.get("float_share") is not None else None),
}
def normalize_index_daily(row: dict[str, Any]) -> dict[str, Any]:
return {
"ts_code": _code(row.get("ts_code")),
"trade_date": _date(row.get("trade_date")),
"open": round4(finite_number(row.get("open"))),
"high": round4(finite_number(row.get("high"))),
"low": round4(finite_number(row.get("low"))),
"close": round4(finite_number(row.get("close"))),
"pct_chg": round4(finite_number(row.get("pct_chg"))),
"volume": round4(_scale(row.get("vol"), VOLUME_LOT)),
"amount": round4(_scale(row.get("amount"), AMOUNT_THOUSAND_YUAN)),
}
def normalize_calendar(row: dict[str, Any]) -> dict[str, Any]:
is_open = row.get("is_open")
if is_open in (True, "1", 1, "Y", "y"):
open_flag = 1
elif is_open in (False, "0", 0, "N", "n", None, ""):
open_flag = 0
else:
open_flag = int(is_open)
return {
"exchange": str(row.get("exchange") or "SSE"),
"cal_date": _date(row.get("cal_date") or row.get("calDate")),
"is_open": open_flag,
"pretrade_date": _date(row.get("pretrade_date")) or None,
}
def normalize_stock(row: dict[str, Any]) -> dict[str, Any]:
ts_code = _code(row.get("ts_code"))
symbol = str(row.get("symbol") or "").strip() or (ts_code.split(".")[0] if ts_code else "")
return {
"ts_code": ts_code,
"symbol": symbol,
"name": str(row.get("name") or "").strip(),
"area": str(row.get("area") or "").strip() or None,
"industry": str(row.get("industry") or "").strip() or None,
"market": str(row.get("market") or "").strip() or None,
"list_status": str(row.get("list_status") or "L").strip() or "L",
"list_date": _date(row.get("list_date")) or None,
}
def apply_qfq(price: float | None, factor: float | None, latest_factor: float | None) -> float | None:
if price is None:
return None
current = factor if factor not in (None, 0) else 1.0
latest = latest_factor if latest_factor not in (None, 0) else current
return round4(price * current / latest)
def qfq_bar(row: dict[str, Any], latest_factor: float | None) -> dict[str, Any]:
factor = finite_number(row.get("adj_factor"), 1.0) or 1.0
out = dict(row)
for field in ("open", "high", "low", "close"):
out[field] = apply_qfq(finite_number(row.get(field)), factor, latest_factor)
return out
NORMALIZERS = {
"daily": normalize_daily,
"valuation": normalize_valuation,
"daily_basic": normalize_valuation,
"moneyflow": normalize_moneyflow,
"auction": normalize_auction,
"stk_auction": normalize_auction,
"index_daily": normalize_index_daily,
"trade_cal": normalize_calendar,
"calendar": normalize_calendar,
"stock_basic": normalize_stock,
"stocks": normalize_stock,
}
def normalize_rows(dataset: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
fn = NORMALIZERS.get(dataset)
if fn is None:
raise ValueError(f"unknown dataset: {dataset}")
return [fn(row) for row in rows]
def _scale(value: Any, factor: float) -> float | None:
number = finite_number(value)
if number is None:
return None
return number * factor
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import math
from typing import Any
def finite_number(value: Any, default: float | None = None) -> float | None:
"""Return a finite float, or default (None means JSON null)."""
if value is None or value == "":
return default
try:
number = float(value)
except (TypeError, ValueError):
return default
if not math.isfinite(number):
return default
return number
def round4(value: float | None) -> float | None:
if value is None:
return None
return round(float(value), 4)
+701
View File
@@ -0,0 +1,701 @@
from __future__ import annotations
import json
import time
from collections.abc import Callable
from datetime import timedelta
from typing import Any
from datahub.adapters.base import AdapterError
from datahub.adapters.tushare import DEFAULT_INDEX_CODES, WEBSITE_INDEX_CODES, TushareAdapter
from datahub.db import DATASET_TABLES, HubDB
from datahub.governance.circuit import CircuitBreaker
from datahub.governance.ratelimit import TokenBucket
from datahub.governance.retry import RetryError, retry_call
from datahub.logutil import get_logger
from datahub.normalize import finite_number, normalize_daily
from datahub.settings import Settings
from datahub.timeutil import add_days, isoformat, now_shanghai, yyyymmdd
LOGGER = get_logger()
HARD_DATASETS = {"daily", "valuation", "index_daily"}
SOFT_DATASETS = {"moneyflow", "auction"}
OFFICIAL_DATASETS = HARD_DATASETS | SOFT_DATASETS
EMPTY_BATCH_ERROR = "empty official batch: 0 valid rows"
STAGING_INSERT = {
"daily": (
"INSERT INTO staging_bars(ts_code,trade_date,batch_id,open,high,low,close,pct_chg,volume,amount,adj_factor) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b, r.get("open"), r.get("high"), r.get("low"),
r.get("close"), r.get("pct_chg"), r.get("volume"), r.get("amount"), r.get("adj_factor"),
),
),
"valuation": (
"INSERT INTO staging_valuation(ts_code,trade_date,batch_id,turnover_rate,volume_ratio,total_mv,circ_mv,pe_ttm,pb,ps_ttm,dv_ttm) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b, r.get("turnover_rate"), r.get("volume_ratio"),
r.get("total_mv"), r.get("circ_mv"), r.get("pe_ttm"), r.get("pb"), r.get("ps_ttm"), r.get("dv_ttm"),
),
),
"moneyflow": (
"INSERT INTO staging_moneyflow(ts_code,trade_date,batch_id,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b,
r.get("buy_sm_amount"), r.get("sell_sm_amount"), r.get("buy_md_amount"), r.get("sell_md_amount"),
r.get("buy_lg_amount"), r.get("sell_lg_amount"), r.get("buy_elg_amount"), r.get("sell_elg_amount"),
r.get("net_mf_amount"),
),
),
"auction": (
"INSERT INTO staging_auction(ts_code,trade_date,batch_id,volume,price,amount,pre_close,turnover_rate,volume_ratio,float_share) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b, r.get("volume"), r.get("price"), r.get("amount"),
r.get("pre_close"), r.get("turnover_rate"), r.get("volume_ratio"), r.get("float_share"),
),
),
"index_daily": (
"INSERT INTO staging_index_bars(ts_code,trade_date,batch_id,open,high,low,close,pct_chg,volume,amount) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
lambda r, b: (
r["ts_code"], r["trade_date"], b, r.get("open"), r.get("high"), r.get("low"),
r.get("close"), r.get("pct_chg"), r.get("volume"), r.get("amount"),
),
),
}
EOD_COPY = {
"daily": (
"INSERT OR REPLACE INTO eod_bars "
"SELECT ts_code,trade_date,open,high,low,close,pct_chg,volume,amount,adj_factor,batch_id "
"FROM staging_bars WHERE batch_id = ?"
),
"valuation": (
"INSERT OR REPLACE INTO eod_valuation "
"SELECT ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,pe_ttm,pb,ps_ttm,dv_ttm,batch_id "
"FROM staging_valuation WHERE batch_id = ?"
),
"moneyflow": (
"INSERT OR REPLACE INTO eod_moneyflow "
"SELECT ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount,batch_id "
"FROM staging_moneyflow WHERE batch_id = ?"
),
"auction": (
"INSERT OR REPLACE INTO eod_auction "
"SELECT ts_code,trade_date,volume,price,amount,pre_close,turnover_rate,volume_ratio,float_share,batch_id "
"FROM staging_auction WHERE batch_id = ?"
),
"index_daily": (
"INSERT OR REPLACE INTO eod_index_bars "
"SELECT ts_code,trade_date,open,high,low,close,pct_chg,volume,amount,batch_id "
"FROM staging_index_bars WHERE batch_id = ?"
),
}
def _staging_row_count(connection: Any, dataset: str, batch_id: str) -> int:
table = DATASET_TABLES[dataset][1]
row = connection.execute(
f"SELECT COUNT(*) AS n FROM {table} WHERE batch_id = ?",
(batch_id,),
).fetchone()
return int(row["n"] if row is not None else 0)
class QualityError(RuntimeError):
def __init__(self, message: str, report: dict[str, Any]) -> None:
super().__init__(message)
self.report = report
class Pipeline:
def __init__(
self,
db: HubDB,
adapter: TushareAdapter,
settings: Settings,
bucket: TokenBucket | None = None,
breaker: CircuitBreaker | None = None,
before_commit: Callable[[], None] | None = None,
clock=None,
) -> None:
self.db = db
self.adapter = adapter
self.settings = settings
self.bucket = bucket or TokenBucket(settings.tushare_rate_per_minute)
self.breaker = breaker or CircuitBreaker()
self.before_commit = before_commit
self.clock = clock or now_shanghai
def next_batch_id(self, dataset: str, trade_date: str) -> str:
row = self.db.fetchone(
"SELECT COUNT(*) AS n FROM batches WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
)
seq = int((row or {}).get("n") or 0) + 1
return f"{trade_date}-{dataset}-{seq:03d}"
def ingest_reference(
self,
trade_date: str | None = None,
start: str | None = None,
end: str | None = None,
) -> dict[str, Any]:
"""Refresh trade calendar and stock master. Not versioned by batch.
Calendar defaults to 2016-01-01 through today+30 so a 5-year website
query is not silently truncated. UPSERT makes repeats safe.
"""
day = yyyymmdd(trade_date or self.clock())
start = yyyymmdd(start or self.settings.calendar_start)
end = yyyymmdd(end or add_days(day, 30))
if start > end:
start, end = end, start
calendar = self.adapter.normalize(
"calendar",
self._guarded_fetch("calendar", {"exchange": "SSE", "start_date": start, "end_date": end}),
)
stocks = self.adapter.normalize("stocks", self._guarded_fetch("stocks", {"list_status": "L"}))
fetched_at = isoformat(self.clock())
with self.db.write() as connection:
for row in calendar:
connection.execute(
"""
INSERT INTO trade_calendar(exchange, cal_date, is_open, pretrade_date, fetched_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(exchange, cal_date) DO UPDATE SET
is_open=excluded.is_open, pretrade_date=excluded.pretrade_date, fetched_at=excluded.fetched_at
""",
(row["exchange"], row["cal_date"], row["is_open"], row.get("pretrade_date"), fetched_at),
)
for row in stocks:
connection.execute(
"""
INSERT INTO stock_master(ts_code,symbol,name,area,industry,market,list_status,list_date,updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(ts_code) DO UPDATE SET
symbol=excluded.symbol, name=excluded.name, area=excluded.area,
industry=excluded.industry, market=excluded.market,
list_status=excluded.list_status, list_date=excluded.list_date,
updated_at=excluded.updated_at
""",
(
row["ts_code"], row.get("symbol"), row.get("name"), row.get("area"),
row.get("industry"), row.get("market"), row.get("list_status"),
row.get("list_date"), fetched_at,
),
)
return {
"calendar": len(calendar),
"stocks": len(stocks),
"trade_date": day,
"calendar_from": start,
"calendar_to": end,
}
def open_trade_dates(self, end: str, limit: int) -> list[str]:
end = yyyymmdd(end)
rows = self.db.fetchall(
"""
SELECT cal_date FROM trade_calendar
WHERE exchange = 'SSE' AND is_open = 1 AND cal_date <= ?
ORDER BY cal_date DESC
LIMIT ?
""",
(end, max(1, int(limit))),
)
return sorted(str(row["cal_date"]) for row in rows)
def backfill_history(
self,
trade_date: str | None = None,
calendar_start: str | None = None,
index_days: int | None = None,
codes: tuple[str, ...] | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Idempotent calendar + website-index history backfill."""
day = yyyymmdd(trade_date or self.clock())
calendar = self.ingest_reference(day, start=calendar_start)
index = self.backfill_index_history(
end_date=day,
trading_days=index_days,
codes=codes,
force=force,
)
return {"calendar": calendar, "index_daily": index, "ok": bool(index.get("ok"))}
def backfill_index_history(
self,
end_date: str | None = None,
trading_days: int | None = None,
codes: tuple[str, ...] | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Incrementally publish official index bars for website index codes.
One range fetch per code, then per-day publish. Already published dates
are skipped unless ``force``. Failures are recorded and do not roll back
successful days.
"""
end = yyyymmdd(end_date or self.clock())
limit = int(trading_days or self.settings.index_history_trading_days)
codes = tuple(codes or WEBSITE_INDEX_CODES)
open_dates = self.open_trade_dates(end, limit)
if not open_dates:
return {
"start": None,
"end": end,
"codes": list(codes),
"requested_days": 0,
"published": [],
"skipped": [],
"failed": [{"error": "calendar has no open dates on or before end"}],
"ok": False,
}
start = open_dates[0]
complete_dates = set() if force else self._index_dates_with_all_codes(start, end, codes)
targets = [day for day in open_dates if day not in complete_dates]
skipped = [day for day in open_dates if day in complete_dates]
by_date: dict[str, list[dict[str, Any]]] = {day: [] for day in targets}
failed: list[dict[str, Any]] = []
for ts_code in codes:
try:
raw = retry_call(
lambda code=ts_code: self._guarded_fetch(
"index_daily",
{"ts_code": code, "start_date": start, "end_date": end},
),
attempts=self.settings.max_publish_attempts,
base_delay=0.05,
sleeper=lambda _d: time.sleep(_d),
)
for row in self.adapter.normalize("index_daily", raw):
day = str(row.get("trade_date") or "")
if day in by_date:
by_date[day].append(row)
except Exception as exc:
failed.append({"ts_code": ts_code, "error": str(exc)})
published: list[dict[str, Any]] = []
for day in targets:
rows = by_date.get(day) or []
try:
result = self.run_dataset("index_daily", day, prepared_rows=rows)
published.append(
{
"trade_date": day,
"batch_id": result["batch_id"],
"rows": result["rows"],
"state": result["state"],
}
)
except Exception as exc:
failed.append({"trade_date": day, "error": str(exc), "rows": len(rows)})
return {
"start": start,
"end": end,
"codes": list(codes),
"requested_days": len(open_dates),
"published": published,
"skipped": skipped,
"failed": failed,
"ok": not failed,
}
def _index_dates_with_all_codes(self, start: str, end: str, codes: tuple[str, ...]) -> set[str]:
pubs = self.db.fetchall(
"""
SELECT trade_date, active_batch FROM publications
WHERE dataset = 'index_daily' AND trade_date >= ? AND trade_date <= ?
""",
(start, end),
)
needed = set(codes)
complete: set[str] = set()
for pub in pubs:
rows = self.db.fetchall(
"SELECT DISTINCT ts_code FROM eod_index_bars WHERE trade_date = ? AND batch_id = ?",
(pub["trade_date"], pub["active_batch"]),
)
have = {str(row["ts_code"]) for row in rows}
if needed <= have:
complete.add(str(pub["trade_date"]))
return complete
def run_dataset(
self,
dataset: str,
trade_date: str,
attempts: int | None = None,
prepared_rows: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
trade_date = yyyymmdd(trade_date)
batch_id = self.next_batch_id(dataset, trade_date)
max_attempts = attempts or self.settings.max_publish_attempts
self._set_batch(batch_id, dataset, trade_date, "scheduled", 0)
rows: list[dict[str, Any]] = []
try:
self._set_batch(batch_id, dataset, trade_date, "fetching", 1)
if prepared_rows is None:
rows = retry_call(
lambda: self._fetch_dataset(dataset, trade_date),
attempts=max_attempts,
base_delay=0.05,
sleeper=lambda _d: None if attempts == 1 else time.sleep(_d),
)
else:
rows = list(prepared_rows)
self._stage(dataset, batch_id, rows)
self._set_batch(batch_id, dataset, trade_date, "staged", 1, rows_in=len(rows), rows_out=len(rows))
self._set_batch(batch_id, dataset, trade_date, "validating", 1)
report = self.validate(dataset, batch_id, trade_date, rows)
if report["hard_fail"]:
self._reject_batch(batch_id, dataset, trade_date, rows, report)
raise QualityError("integrity gate failed", report)
self._set_batch(batch_id, dataset, trade_date, "deriving", 1, rows_in=len(rows), rows_out=len(rows), quality=report)
self._set_batch(batch_id, dataset, trade_date, "publishing", 1, rows_in=len(rows), rows_out=len(rows), quality=report)
state = "degraded" if report["soft_fail"] else "published"
self.publish(dataset, trade_date, batch_id, state=state)
self._set_batch(
batch_id, dataset, trade_date, "published", 1,
rows_in=len(rows), rows_out=len(rows), quality=report, finished=True,
)
return {"batch_id": batch_id, "dataset": dataset, "trade_date": trade_date, "rows": len(rows), "state": state, "quality": report}
except RetryError as exc:
self._set_batch(batch_id, dataset, trade_date, "failed", max_attempts, error=str(exc), finished=True)
raise
except QualityError as exc:
current = self.db.fetchone("SELECT state FROM batches WHERE batch_id = ?", (batch_id,))
if current and current["state"] not in {"staged", "failed"}:
self._reject_batch(batch_id, dataset, trade_date, rows, exc.report)
raise
except Exception as exc:
self._set_batch(batch_id, dataset, trade_date, "failed", 1, error=str(exc), finished=True)
raise
def run_eod_batch_a(self, trade_date: str) -> dict[str, Any]:
results = {}
for dataset in ("daily", "valuation", "moneyflow", "auction"):
results[dataset] = self.run_dataset(dataset, trade_date)
return results
def run_eod_batch_b(self, trade_date: str) -> dict[str, Any]:
return {"index_daily": self.run_dataset("index_daily", trade_date)}
def validate(self, dataset: str, batch_id: str, trade_date: str, rows: list[dict[str, Any]]) -> dict[str, Any]:
quality = self.settings.quality
errors: list[str] = []
warnings: list[str] = []
listed = self.db.fetchone(
"SELECT COUNT(*) AS n FROM stock_master WHERE list_status = 'L'",
)
listed_n = int((listed or {}).get("n") or 0)
row_n = len(rows)
keys = [(row.get("ts_code"), row.get("trade_date")) for row in rows]
dup = row_n - len(set(keys))
if dup:
errors.append(f"duplicate keys: {dup}")
bad_date = sum(1 for row in rows if str(row.get("trade_date")) != trade_date)
if bad_date:
errors.append(f"date mismatch rows: {bad_date}")
ratio = (row_n / listed_n) if listed_n else 1.0
if dataset == "daily" and listed_n and ratio < float(quality.get("daily_row_ratio") or 0.98):
errors.append(f"row ratio {ratio:.4f} < {quality.get('daily_row_ratio')}")
null_fields = ("open", "high", "low", "close", "amount") if dataset in {"daily", "index_daily"} else ()
if null_fields and rows:
nulls = sum(1 for row in rows if any(row.get(field) is None for field in null_fields))
null_rate = nulls / row_n
if null_rate >= float(quality.get("null_rate_max") or 0.01):
errors.append(f"null rate {null_rate:.4f}")
empty = row_n == 0
if empty and dataset in OFFICIAL_DATASETS:
errors.append(EMPTY_BATCH_ERROR)
if dataset in SOFT_DATASETS:
hard_fail = bool(dup or bad_date or empty)
else:
hard_fail = bool(errors) and dataset in HARD_DATASETS
return {
"rows": row_n,
"listed": listed_n,
"ratio": round(ratio, 4),
"errors": errors,
"warnings": warnings,
"hard_fail": hard_fail,
"soft_fail": bool(warnings) and not hard_fail,
"batch_id": batch_id,
}
def publish(self, dataset: str, trade_date: str, batch_id: str, state: str = "published") -> None:
copy_sql = EOD_COPY[dataset]
published_at = isoformat(self.clock())
with self.db.write() as connection:
rows_out = _staging_row_count(connection, dataset, batch_id)
if rows_out <= 0:
report = {
"rows": 0,
"errors": [EMPTY_BATCH_ERROR],
"warnings": [],
"hard_fail": True,
"soft_fail": False,
"batch_id": batch_id,
"dataset": dataset,
"trade_date": trade_date,
}
LOGGER.warning(
"skip official publish for empty batch",
extra={
"hub": {
"dataset": dataset,
"trade_date": trade_date,
"batch_id": batch_id,
"rows_out": rows_out,
"reason": "upstream_empty",
}
},
)
raise QualityError("empty batch cannot be officially published", report)
current = connection.execute(
"SELECT active_batch FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
).fetchone()
prev = str(current["active_batch"]) if current else None
connection.execute(copy_sql, (batch_id,))
if self.before_commit:
self.before_commit()
connection.execute(
"""
INSERT INTO publications(dataset, trade_date, active_batch, prev_batch, state, published_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(dataset, trade_date) DO UPDATE SET
prev_batch=excluded.prev_batch,
active_batch=excluded.active_batch,
state=excluded.state,
published_at=excluded.published_at
""",
(dataset, trade_date, batch_id, prev, state, published_at),
)
max_gen = connection.execute(
"SELECT COALESCE(MAX(generation), 0) AS g FROM publication_history WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
).fetchone()
generation = int(max_gen["g"]) + 1
connection.execute(
"INSERT OR REPLACE INTO publication_history(dataset, trade_date, batch_id, published_at, generation) VALUES (?,?,?,?,?)",
(dataset, trade_date, batch_id, published_at, generation),
)
keep = int(self.settings.quality.get("publication_generations") or 3)
stale = connection.execute(
"""
SELECT batch_id FROM publication_history
WHERE dataset = ? AND trade_date = ?
ORDER BY generation DESC
""",
(dataset, trade_date),
).fetchall()
for row in stale[keep:]:
connection.execute(
"DELETE FROM publication_history WHERE dataset = ? AND trade_date = ? AND batch_id = ?",
(dataset, trade_date, row["batch_id"]),
)
def rollback(self, dataset: str, trade_date: str, actor: str = "admin") -> dict[str, Any]:
trade_date = yyyymmdd(trade_date)
pub = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
)
if not pub or not pub.get("prev_batch"):
raise ValueError("没有可回滚的上一批次")
target = pub["prev_batch"]
published_at = isoformat(self.clock())
with self.db.write() as connection:
connection.execute(
"""
UPDATE publications
SET prev_batch = active_batch, active_batch = ?, published_at = ?, state = 'published'
WHERE dataset = ? AND trade_date = ?
""",
(target, published_at, dataset, trade_date),
)
self.audit(actor, "rollback", f"{dataset}:{trade_date}", json.dumps({"to": target, "from": pub["active_batch"]}))
return {"dataset": dataset, "trade_date": trade_date, "active_batch": target, "prev_batch": pub["active_batch"]}
def active_batch(self, dataset: str, trade_date: str) -> str | None:
row = self.db.fetchone(
"SELECT active_batch FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
)
return str(row["active_batch"]) if row else None
def cleanup(self) -> dict[str, int]:
staging_days = int(self.settings.quality.get("staging_retain_days") or 14)
job_days = int(self.settings.quality.get("job_run_retain_days") or 90)
now = now_shanghai(self.clock())
cutoff_staging = add_days(yyyymmdd(now), -staging_days)
cutoff_jobs = isoformat(now - timedelta(days=job_days))
deleted = 0
with self.db.write() as connection:
for dataset, (_eod, staging) in DATASET_TABLES.items():
cur = connection.execute(
f"DELETE FROM {staging} WHERE trade_date < ?",
(cutoff_staging,),
)
deleted += cur.rowcount
connection.execute("DELETE FROM job_runs WHERE started_at < ?", (cutoff_jobs,))
connection.execute("DELETE FROM src_calls WHERE created_at < ?", (cutoff_jobs,))
return {"staging_deleted": deleted}
def audit(self, actor: str, action: str, target: str = "", detail: str = "") -> None:
self.db.execute(
"INSERT INTO audit_log(actor, action, target, detail, created_at) VALUES (?,?,?,?,?)",
(actor, action, target, detail, isoformat(self.clock())),
)
def _reject_batch(
self,
batch_id: str,
dataset: str,
trade_date: str,
rows: list[dict[str, Any]],
report: dict[str, Any],
) -> None:
errors = report.get("errors") or []
LOGGER.warning(
"official batch rejected",
extra={
"hub": {
"dataset": dataset,
"trade_date": trade_date,
"batch_id": batch_id,
"rows_out": len(rows),
"errors": errors,
"reason": "upstream_empty" if EMPTY_BATCH_ERROR in errors else "integrity_gate",
}
},
)
self._set_batch(
batch_id, dataset, trade_date, "staged", 1,
rows_in=len(rows), rows_out=len(rows),
quality=report, error="; ".join(str(item) for item in errors),
)
def _fetch_dataset(self, dataset: str, trade_date: str) -> list[dict[str, Any]]:
if dataset == "daily":
raw = self._guarded_fetch("daily", {"trade_date": trade_date})
factors = {
(row["ts_code"], row["trade_date"]): finite_number(row.get("adj_factor"))
for row in self._guarded_fetch("adj_factor", {"trade_date": trade_date})
}
return [
normalize_daily(row, adj_factor=factors.get((str(row.get("ts_code") or "").upper(), str(row.get("trade_date") or ""))))
for row in raw
]
if dataset == "index_daily":
rows: list[dict[str, Any]] = []
for ts_code in DEFAULT_INDEX_CODES:
raw = self._guarded_fetch("index_daily", {"ts_code": ts_code, "trade_date": trade_date})
rows.extend(self.adapter.normalize("index_daily", raw))
return rows
api_dataset = dataset
raw = self._guarded_fetch(api_dataset, {"trade_date": trade_date})
return self.adapter.normalize(api_dataset, raw)
def _guarded_fetch(self, dataset: str, params: dict[str, Any]) -> list[dict[str, Any]]:
if not self.breaker.allow():
raise AdapterError("Tushare circuit open")
self.bucket.acquire()
started = time.perf_counter()
try:
# For daily we want RAW tushare rows so adj_factor can be merged later.
rows = self.adapter.fetch(dataset, params)
latency = round((time.perf_counter() - started) * 1000)
self.breaker.record_success()
self._log_call(dataset, True, latency, "")
self._persist_health("ok")
return rows
except Exception as exc:
latency = round((time.perf_counter() - started) * 1000)
self.breaker.record_failure(str(exc))
self._log_call(dataset, False, latency, str(exc))
self._persist_health("error", str(exc))
raise
def _stage(self, dataset: str, batch_id: str, rows: list[dict[str, Any]]) -> None:
sql, mapper = STAGING_INSERT[dataset]
with self.db.write() as connection:
connection.execute(
f"DELETE FROM {DATASET_TABLES[dataset][1]} WHERE batch_id = ?",
(batch_id,),
)
connection.executemany(sql, [mapper(row, batch_id) for row in rows])
def _set_batch(
self,
batch_id: str,
dataset: str,
trade_date: str,
state: str,
attempt: int,
rows_in: int | None = None,
rows_out: int | None = None,
quality: dict[str, Any] | None = None,
error: str | None = None,
finished: bool = False,
) -> None:
now = isoformat(self.clock())
existing = self.db.fetchone("SELECT batch_id FROM batches WHERE batch_id = ?", (batch_id,))
payload = json.dumps(quality, ensure_ascii=False) if quality else None
with self.db.write() as connection:
if existing is None:
connection.execute(
"""
INSERT INTO batches(batch_id, dataset, trade_date, state, attempt, rows_in, rows_out, quality_json, started_at, finished_at, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(batch_id, dataset, trade_date, state, attempt, rows_in, rows_out, payload, now, now if finished else None, error),
)
else:
connection.execute(
"""
UPDATE batches SET state=?, attempt=?,
rows_in=COALESCE(?, rows_in), rows_out=COALESCE(?, rows_out),
quality_json=COALESCE(?, quality_json),
finished_at=CASE WHEN ? THEN ? ELSE finished_at END,
error=COALESCE(?, error)
WHERE batch_id = ?
""",
(state, attempt, rows_in, rows_out, payload, 1 if finished else 0, now, error, batch_id),
)
def _log_call(self, endpoint: str, ok: bool, latency_ms: int, error: str) -> None:
self.db.execute(
"INSERT INTO src_calls(provider, endpoint, ok, latency_ms, error, created_at) VALUES (?,?,?,?,?,?)",
("tushare", endpoint, 1 if ok else 0, latency_ms, error, isoformat(self.clock())),
)
def _persist_health(self, state: str, error: str = "") -> None:
snap = self.breaker.snapshot()
self.db.execute(
"""
INSERT INTO src_health(provider, endpoint_class, state, last_ok_at, last_error, consec_failures, opened_at, cooldown_until)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, endpoint_class) DO UPDATE SET
state=excluded.state, last_ok_at=excluded.last_ok_at, last_error=excluded.last_error,
consec_failures=excluded.consec_failures, opened_at=excluded.opened_at, cooldown_until=excluded.cooldown_until
""",
(
"tushare", "pro",
snap.state,
isoformat(self.clock()) if state == "ok" else None,
error or snap.last_error,
snap.consec_failures,
isoformat(self.clock()) if snap.state == "open" else None,
None,
),
)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import threading
from collections.abc import Callable
from datetime import datetime, time
from typing import Any
from datahub.db import HubDB
from datahub.logutil import get_logger
from datahub.pipeline import Pipeline
from datahub.timeutil import isoformat, now_shanghai, yyyymmdd
LOGGER = get_logger()
JobFn = Callable[[str], Any]
def is_open_day(db: HubDB, day: str) -> bool:
row = db.fetchone(
"SELECT is_open FROM trade_calendar WHERE exchange = 'SSE' AND cal_date = ?",
(day,),
)
if row is None:
return True # unknown calendar: do not skip reference refresh
return int(row["is_open"]) == 1
class Scheduler:
"""Calendar-driven in-process scheduler. Non-trading days skip EOD fetches."""
def __init__(self, db: HubDB, pipeline: Pipeline, jobs: dict[str, JobFn] | None = None) -> None:
self.db = db
self.pipeline = pipeline
self.jobs = jobs or {
"precheck": self._precheck,
"eod_a": self._eod_a,
"eod_b": self._eod_b,
"cleanup": self._cleanup,
"backup": self._backup,
"history_backfill": self._history_backfill,
}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._fired: set[tuple[str, str, str]] = set()
def start(self, interval_seconds: float = 30.0) -> None:
if self._thread and self._thread.is_alive():
return
def loop() -> None:
while not self._stop.wait(interval_seconds):
try:
self.tick()
except Exception:
LOGGER.exception("scheduler tick failed")
self._thread = threading.Thread(target=loop, name="datahub-scheduler", daemon=True)
self._thread.start()
def stop(self, timeout: float = 5.0) -> None:
self._stop.set()
if self._thread and self._thread is not threading.current_thread():
self._thread.join(timeout)
def tick(self, clock: datetime | None = None) -> list[str]:
now = clock or now_shanghai()
day = yyyymmdd(now)
current = now.timetz() if False else now.time()
ran: list[str] = []
plan = [
("precheck", time(8, 45)),
("eod_a", time(15, 5)),
("eod_b", time(15, 10)),
("cleanup", time(0, 30)),
("backup", time(0, 40)),
]
open_day = is_open_day(self.db, day)
for job_id, at in plan:
if current < at:
continue
key = (job_id, day, at.strftime("%H%M"))
if key in self._fired:
continue
if job_id in {"eod_a", "eod_b"} and not open_day:
self._fired.add(key)
continue
self._fired.add(key)
self.run_job(job_id, day)
ran.append(job_id)
return ran
def run_job(self, job_id: str, trade_date: str) -> dict[str, Any]:
fn = self.jobs.get(job_id)
if fn is None:
raise KeyError(job_id)
started = isoformat()
run_id = None
with self.db.write() as connection:
cur = connection.execute(
"INSERT INTO job_runs(job_id, state, started_at, attempt) VALUES (?,?,?,1)",
(job_id, "running", started),
)
run_id = cur.lastrowid
try:
result = fn(trade_date) or {}
with self.db.write() as connection:
connection.execute(
"UPDATE job_runs SET state=?, finished_at=?, rows_out=?, detail=? WHERE id=?",
("ok", isoformat(), result.get("rows") if isinstance(result, dict) else None, str(result)[:2000], run_id),
)
return {"job_id": job_id, "result": result, "state": "ok"}
except Exception as exc:
with self.db.write() as connection:
connection.execute(
"UPDATE job_runs SET state=?, finished_at=?, error=? WHERE id=?",
("failed", isoformat(), str(exc), run_id),
)
raise
def _precheck(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.ingest_reference(trade_date)
def _eod_a(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.run_eod_batch_a(trade_date)
def _eod_b(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.run_eod_batch_b(trade_date)
def _history_backfill(self, trade_date: str) -> dict[str, Any]:
return self.pipeline.backfill_history(trade_date)
def _cleanup(self, trade_date: str) -> dict[str, Any]:
result = self.pipeline.cleanup()
if now_shanghai().weekday() == 6:
self.pipeline.db.vacuum()
result["vacuum"] = True
return result
def _backup(self, trade_date: str) -> dict[str, Any]:
from pathlib import Path
dest_dir = Path(self.pipeline.settings.backup_dir)
dest = dest_dir / f"datahub-{trade_date}.db"
self.pipeline.db.backup_to(dest)
keep = int(self.pipeline.settings.quality.get("backup_retain") or 14)
backups = sorted(dest_dir.glob("datahub-*.db"))
for old in backups[:-keep]:
old.unlink(missing_ok=True)
return {"path": str(dest.name), "kept": min(len(backups), keep)}
+396
View File
@@ -0,0 +1,396 @@
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from urllib.parse import parse_qs
from datahub import SCHEMA_VERSION
from datahub.codes import resolve_code
from datahub.coverage import calendar_coverage, point_coverage, published_range_coverage
from datahub.db import HubDB
from datahub.normalize import qfq_bar
from datahub.numbers import finite_number
from datahub.pipeline import Pipeline
from datahub.settings import Settings
from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
ERROR_STATUS = {
"UNAUTHORIZED": HTTPStatus.UNAUTHORIZED,
"INVALID_ARGUMENT": HTTPStatus.BAD_REQUEST,
"RATE_LIMITED": HTTPStatus.TOO_MANY_REQUESTS,
"SOURCE_UNAVAILABLE": HTTPStatus.SERVICE_UNAVAILABLE,
"DATASET_NOT_PUBLISHED": HTTPStatus.NOT_FOUND,
"STALE_DATA": HTTPStatus.OK,
"INTERNAL": HTTPStatus.INTERNAL_SERVER_ERROR,
}
class ApiError(Exception):
def __init__(self, code: str, message: str, retry_after: int | None = None, extra: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.retry_after = retry_after
self.extra = extra or {}
def payload(self) -> dict[str, Any]:
body: dict[str, Any] = {"code": self.code, "message": self.message}
if self.retry_after is not None:
body["retry_after"] = self.retry_after
body.update(self.extra)
return {"error": body}
@property
def status(self) -> HTTPStatus:
return ERROR_STATUS.get(self.code, HTTPStatus.INTERNAL_SERVER_ERROR)
def envelope(data: Any, meta: dict[str, Any]) -> dict[str, Any]:
return {"schema_version": SCHEMA_VERSION, "data": data, "meta": meta}
class V1API:
def __init__(self, db: HubDB, pipeline: Pipeline, settings: Settings) -> None:
self.db = db
self.pipeline = pipeline
self.settings = settings
def handle(self, path: str, query: dict[str, list[str]]) -> dict[str, Any]:
q = {key: values[-1] if values else "" for key, values in query.items()}
if path == "/v1/health":
return self.health()
if path == "/v1/calendar":
return self.calendar(q.get("from") or "", q.get("to") or "")
if path == "/v1/stocks":
return self.stocks(q.get("updated_since") or "", q)
if path == "/v1/bars/daily":
return self.daily_bars(q)
if path == "/v1/indexes/bars":
return self.index_bars(q)
if path == "/v1/valuation":
return self.valuation(q)
if path == "/v1/moneyflow":
return self.moneyflow(q)
if path == "/v1/auction":
return self.auction(q)
if path == "/v1/datasets/status":
return self.dataset_status(q.get("date") or "")
if path == "/v1/batches":
return self.batches(q.get("date") or "", q.get("dataset") or "")
raise ApiError("INVALID_ARGUMENT", f"unknown endpoint: {path}")
def health(self) -> dict[str, Any]:
today = yyyymmdd(now_shanghai())
cal = self.db.fetchone(
"SELECT is_open FROM trade_calendar WHERE exchange = 'SSE' AND cal_date = ?",
(today,),
)
is_open = bool(cal and cal["is_open"] == 1)
sources = self.db.fetchall("SELECT * FROM src_health")
return envelope(
{
"status": "ok",
"session_phase": session_phase(now_shanghai(), is_open),
"trade_date": today,
"is_open_day": is_open,
"sources": [
{
"provider": row["provider"],
"endpoint_class": row["endpoint_class"],
"state": row["state"],
"last_ok_at": row["last_ok_at"],
"consec_failures": row["consec_failures"],
}
for row in sources
],
},
{"tier": "official", "trade_date": today, "source": "datahub", "stale": False, "staleness_seconds": 0},
)
def calendar(self, start: str, end: str) -> dict[str, Any]:
start = yyyymmdd(start or add_default(-30))
end = yyyymmdd(end or add_default(5))
rows = self.db.fetchall(
"""
SELECT cal_date, is_open, pretrade_date,
(SELECT MAX(cal_date) FROM trade_calendar t2
WHERE t2.exchange = 'SSE' AND t2.is_open = 1 AND t2.cal_date < t1.cal_date) AS prev_open
FROM trade_calendar t1
WHERE exchange = 'SSE' AND cal_date >= ? AND cal_date <= ?
ORDER BY cal_date
""",
(start, end),
)
items = [
{
"cal_date": row["cal_date"],
"is_open": bool(row["is_open"]),
"pretrade_date": row["pretrade_date"],
"prev_open": row["prev_open"],
}
for row in rows
]
meta = self._official_meta("calendar", end if items else start, source="tushare:trade_cal")
return envelope(items, attach_coverage(meta, calendar_coverage(self.db, start, end)))
def stocks(self, updated_since: str, q: dict[str, str]) -> dict[str, Any]:
limit, offset = self._page(q)
if updated_since:
rows = self.db.fetchall(
"SELECT * FROM stock_master WHERE updated_at >= ? ORDER BY ts_code LIMIT ? OFFSET ?",
(updated_since, limit, offset),
)
else:
rows = self.db.fetchall(
"SELECT * FROM stock_master ORDER BY ts_code LIMIT ? OFFSET ?",
(limit, offset),
)
return envelope(rows, self._official_meta("stocks", yyyymmdd(), source="tushare:stock_basic"))
def daily_bars(self, q: dict[str, str]) -> dict[str, Any]:
return self._published_rows(
dataset="daily",
table="eod_bars",
q=q,
source="tushare:daily",
adjust=q.get("adjust") or "none",
)
def index_bars(self, q: dict[str, str]) -> dict[str, Any]:
return self._published_rows(
dataset="index_daily",
table="eod_index_bars",
q=q,
source="tushare:index_daily",
default_code="000001.SH",
)
def valuation(self, q: dict[str, str]) -> dict[str, Any]:
return self._published_rows(dataset="valuation", table="eod_valuation", q=q, source="tushare:daily_basic")
def moneyflow(self, q: dict[str, str]) -> dict[str, Any]:
return self._published_rows(dataset="moneyflow", table="eod_moneyflow", q=q, source="tushare:moneyflow")
def auction(self, q: dict[str, str]) -> dict[str, Any]:
return self._published_rows(dataset="auction", table="eod_auction", q=q, source="tushare:stk_auction")
def dataset_status(self, date: str) -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
datasets = ("daily", "valuation", "moneyflow", "auction", "index_daily")
items = []
for dataset in datasets:
pub = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
)
batch = None
if pub:
batch = self.db.fetchone("SELECT * FROM batches WHERE batch_id = ?", (pub["active_batch"],))
items.append(
{
"dataset": dataset,
"trade_date": trade_date,
"state": (pub or {}).get("state") or "unpublished",
"batch_id": (pub or {}).get("active_batch"),
"published_at": (pub or {}).get("published_at"),
"rows_out": (batch or {}).get("rows_out"),
"quality": _parse_json((batch or {}).get("quality_json")),
}
)
return envelope(items, self._official_meta("status", trade_date, source="datahub"))
def batches(self, date: str, dataset: str) -> dict[str, Any]:
trade_date = yyyymmdd(date or now_shanghai())
if dataset:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? AND dataset = ? ORDER BY started_at",
(trade_date, dataset),
)
else:
rows = self.db.fetchall(
"SELECT * FROM batches WHERE trade_date = ? ORDER BY started_at",
(trade_date,),
)
return envelope(rows, self._official_meta("batches", trade_date, source="datahub"))
def _published_rows(
self,
dataset: str,
table: str,
q: dict[str, str],
source: str,
adjust: str = "none",
default_code: str = "",
) -> dict[str, Any]:
trade_date = q.get("date") or q.get("trade_date") or ""
code = q.get("code") or default_code
start = q.get("from") or ""
end = q.get("to") or ""
if trade_date:
trade_date = yyyymmdd(trade_date)
start = end = trade_date
if not start or not end:
if not trade_date:
raise ApiError("INVALID_ARGUMENT", "date or from/to is required")
else:
start = yyyymmdd(start)
end = yyyymmdd(end)
ts_code = ""
if code:
resolved = resolve_code(self.db, code)
if resolved is None:
raise ApiError("INVALID_ARGUMENT", f"ambiguous code: {code}")
ts_code = resolved
# For a range, use per-date published batch. Single-date is the common path.
if start == end:
pub = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, start),
)
if not pub:
raise ApiError(
"DATASET_NOT_PUBLISHED",
f"{dataset} {start} 尚未发布",
extra={"expected_at": "15:05+08:00"},
)
limit, offset = self._page(q)
sql = f"SELECT * FROM {table} WHERE trade_date = ? AND batch_id = ?"
params: list[Any] = [start, pub["active_batch"]]
if ts_code:
sql += " AND ts_code = ?"
params.append(ts_code)
sql += " ORDER BY ts_code LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = [dict(row) for row in self.db.fetchall(sql, tuple(params))]
if adjust == "qfq" and dataset == "daily":
rows = self._apply_qfq(rows)
meta = {
"tier": "official",
"trade_date": start,
"published_at": pub["published_at"],
"source": source,
"batch_id": pub["active_batch"],
"stale": False,
"staleness_seconds": 0,
"state": pub["state"],
}
return envelope(rows, attach_coverage(meta, point_coverage(start, dataset)))
# multi-day: walk published dates
pubs = self.db.fetchall(
"SELECT * FROM publications WHERE dataset = ? AND trade_date >= ? AND trade_date <= ? ORDER BY trade_date",
(dataset, start, end),
)
if not pubs:
raise ApiError("DATASET_NOT_PUBLISHED", f"{dataset} {start}-{end} 尚未发布")
rows: list[dict[str, Any]] = []
limit, offset = self._page(q)
for pub in pubs:
sql = f"SELECT * FROM {table} WHERE trade_date = ? AND batch_id = ?"
params = [pub["trade_date"], pub["active_batch"]]
if ts_code:
sql += " AND ts_code = ?"
params.append(ts_code)
sql += " ORDER BY ts_code"
rows.extend(self.db.fetchall(sql, tuple(params)))
sliced = rows[offset: offset + limit]
if adjust == "qfq" and dataset == "daily":
sliced = self._apply_qfq(sliced)
last = pubs[-1]
coverage = published_range_coverage(
self.db,
dataset,
start,
end,
ts_code=ts_code,
table=table,
)
return envelope(
sliced,
attach_coverage(
{
"tier": "official",
"trade_date": last["trade_date"],
"published_at": last["published_at"],
"source": source,
"batch_id": last["active_batch"],
"stale": False,
"staleness_seconds": 0,
},
coverage,
),
)
def _apply_qfq(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
by_code: dict[str, list[dict[str, Any]]] = {}
for row in rows:
by_code.setdefault(str(row["ts_code"]), []).append(row)
out: list[dict[str, Any]] = []
for code, group in by_code.items():
latest = None
factors = [finite_number(item.get("adj_factor")) for item in group]
factors = [item for item in factors if item]
if factors:
latest = max(factors)
else:
extra = self.db.fetchone(
"SELECT MAX(adj_factor) AS f FROM eod_bars WHERE ts_code = ?",
(code,),
)
latest = finite_number((extra or {}).get("f"), 1.0)
out.extend(qfq_bar(item, latest) for item in group)
return out
def _page(self, q: dict[str, str]) -> tuple[int, int]:
try:
limit = int(q.get("limit") or self.settings.list_limit_default)
offset = int(q.get("offset") or 0)
except ValueError as exc:
raise ApiError("INVALID_ARGUMENT", "limit/offset must be integers") from exc
limit = max(1, min(limit, self.settings.list_limit_max))
offset = max(0, offset)
return limit, offset
def _official_meta(self, dataset: str, trade_date: str, source: str) -> dict[str, Any]:
pub = self.db.fetchone(
"SELECT * FROM publications WHERE dataset = ? AND trade_date = ?",
(dataset, trade_date),
)
return {
"tier": "official",
"trade_date": trade_date,
"published_at": (pub or {}).get("published_at"),
"source": source,
"batch_id": (pub or {}).get("active_batch"),
"stale": False,
"staleness_seconds": 0,
}
def add_default(days: int) -> str:
from datetime import timedelta
return (now_shanghai() + timedelta(days=days)).strftime("%Y%m%d")
def attach_coverage(meta: dict[str, Any], coverage: dict[str, Any]) -> dict[str, Any]:
merged = dict(meta)
merged["coverage"] = coverage
merged["incomplete"] = not bool(coverage.get("complete"))
return merged
def parse_query(raw: str) -> dict[str, list[str]]:
return parse_qs(raw, keep_blank_values=True)
def _parse_json(raw: Any) -> Any:
if not raw:
return None
if isinstance(raw, dict):
return raw
import json
try:
return json.loads(str(raw))
except json.JSONDecodeError:
return None
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db"))
DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups"))
DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json"
def _load_quality(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8"))
@dataclass
class Settings:
host: str = "127.0.0.1"
port: int = 8766
encryption_key: str = ""
api_token: str = ""
admin_password: str = ""
tushare_token: str = ""
db_path: Path = DEFAULT_DB_PATH
backup_dir: Path = DEFAULT_BACKUP_DIR
quality: dict[str, Any] = field(default_factory=dict)
log_level: str = "INFO"
scheduler_enabled: bool = True
@property
def tushare_rate_per_minute(self) -> int:
return int(self.quality.get("tushare_rate_per_minute") or 300)
@property
def max_publish_attempts(self) -> int:
return int(self.quality.get("max_publish_attempts") or 5)
@property
def list_limit_default(self) -> int:
return int(self.quality.get("list_limit_default") or 5000)
@property
def list_limit_max(self) -> int:
return int(self.quality.get("list_limit_max") or 5000)
@property
def calendar_start(self) -> str:
return str(self.quality.get("calendar_start") or "20160101")
@property
def index_history_trading_days(self) -> int:
return int(self.quality.get("index_history_trading_days") or 260)
def load_settings(
env: dict[str, str] | None = None,
config_path: Path | None = None,
) -> Settings:
environ = env if env is not None else dict(os.environ)
quality_path = config_path or DEFAULT_CONFIG_PATH
db_path = Path(environ.get("DATAHUB_DB_PATH") or DEFAULT_DB_PATH)
backup_dir = Path(environ.get("DATAHUB_BACKUP_DIR") or DEFAULT_BACKUP_DIR)
return Settings(
host=environ.get("DATAHUB_HOST") or "127.0.0.1",
port=int(environ.get("DATAHUB_PORT") or 8766),
encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(),
api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(),
admin_password=str(environ.get("DATAHUB_ADMIN_PASSWORD") or "").strip(),
tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(),
db_path=db_path,
backup_dir=backup_dir,
quality=_load_quality(quality_path),
log_level=environ.get("DATAHUB_LOG_LEVEL") or "INFO",
scheduler_enabled=str(environ.get("DATAHUB_SCHEDULER") or "1") not in {"0", "false", "False"},
)
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
SHANGHAI = ZoneInfo("Asia/Shanghai")
def now_shanghai(clock: datetime | None = None) -> datetime:
if clock is not None:
if clock.tzinfo is None:
return clock.replace(tzinfo=SHANGHAI)
return clock.astimezone(SHANGHAI)
return datetime.now(SHANGHAI)
def isoformat(value: datetime | None = None) -> str:
current = now_shanghai(value)
return current.isoformat(timespec="seconds")
def yyyymmdd(value: date | datetime | str | None = None) -> str:
if value is None:
return now_shanghai().strftime("%Y%m%d")
if isinstance(value, str):
digits = value.replace("-", "")[:8]
if len(digits) != 8 or not digits.isdigit():
raise ValueError(f"invalid trade_date: {value}")
return digits
if isinstance(value, datetime):
return value.astimezone(SHANGHAI).strftime("%Y%m%d")
return value.strftime("%Y%m%d")
def parse_trade_date(value: str) -> date:
text = yyyymmdd(value)
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
def session_phase(clock: datetime | None, is_open_day: bool) -> str:
"""pre | intradaily | lunch | eod | closed"""
if not is_open_day:
return "closed"
current = now_shanghai(clock).time()
if current < time(9, 15):
return "pre"
if current < time(11, 30) or (time(13, 0) <= current <= time(15, 5)):
return "intraday"
if current < time(13, 0):
return "lunch"
if current <= time(23, 40):
return "eod"
return "closed"
def add_days(trade_date: str, days: int) -> str:
return (parse_trade_date(trade_date) + timedelta(days=days)).strftime("%Y%m%d")
def iter_yyyymmdd(start: str, end: str):
cursor = parse_trade_date(start)
last = parse_trade_date(end)
if cursor > last:
return
while cursor <= last:
yield cursor.strftime("%Y%m%d")
cursor += timedelta(days=1)
def utc_timestamp(value: Any) -> str:
if isinstance(value, datetime):
return isoformat(value)
return isoformat()
+1
View File
@@ -0,0 +1 @@
cryptography==49.0.0

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