Compare commits

...

7 Commits

Author SHA1 Message Date
fish
df28200eb0 打分列表增加信号方向快捷筛选
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:01:51 +08:00
fish
7d49aff6c7 移除 Bark 推送通知模块及相关依赖
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:44:59 +08:00
fish
44909f04e2 手动打分页改为按品种选择,日期限定主力合约范围,结果自动滚动到视图
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:40:15 +08:00
fish
fd1c1c7330 合约信息拆分为品种和合约两个展示字段
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:14:41 +08:00
fish
dc22799985 固定六个交易品种并修正 1/5/9 月合约轮换规则
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:09:50 +08:00
fish
a1355d91aa 支持手动指定品种和日期进行打分
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:57:31 +08:00
fish
23776b5e96 同步项目文档:PostgreSQL 迁移、FastAPI 服务、UUID 主键与暗夜模式
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:47:23 +08:00
24 changed files with 563 additions and 123 deletions

View File

@@ -4,13 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述 ## 项目概述
基于 Docker + Python(tushare) 的中国期货行情分析系统,实现日线数据采集三层加权打分模型与 Bark 推送通知。运行方式定位为脚本自动化(宿主机 cron/launchd 定时调用 `docker-compose run`),不规划独立后端服务。详细业务说明见 `使用说明.md` 基于 Docker + Python(tushare) + PostgreSQL 的中国期货行情分析系统,实现日线数据采集三层加权打分模型。运行方式支持两种模式:① 宿主机 cron/launchd 定时调用 `docker-compose run` 执行 CLI;② 通过 FastAPI 服务以 HTTP API 触发。详细业务说明见 `README.md`
## 常用命令 ## 常用命令
```bash ```bash
# 不传参 = 按当月 FG 主力自动选合约(轮换规则见 contracts.py:ROLLOVER_RULES) # === 启动全栈服务(PostgreSQL + tushare API + web) ===
docker-compose run --rm tushare docker-compose up -d
# === tushare CLI(不传参 = 按当月 FG 主力自动选合约,轮换规则见 contracts.py:ROLLOVER_RULES) ===
docker-compose run --rm tushare python -m src.main
# 显式指定合约(注意交易所后缀:.ZCE/.SHF/.DCE,郑商所是 .ZCE 不是 .CZC) # 显式指定合约(注意交易所后缀:.ZCE/.SHF/.DCE,郑商所是 .ZCE 不是 .CZC)
docker-compose run --rm tushare python -m src.main RB2510.SHF docker-compose run --rm tushare python -m src.main RB2510.SHF
@@ -18,28 +21,38 @@ docker-compose run --rm tushare python -m src.main RB2510.SHF
# 用品种代号自动选当月主力(目前只配置了 FG) # 用品种代号自动选当月主力(目前只配置了 FG)
docker-compose run --rm tushare python -m src.main --symbol FG docker-compose run --rm tushare python -m src.main --symbol FG
# 修改 tushare/src/ 下任意 .py 后必须重建镜像 # === tushare API 服务(容器内运行 uvicorn,端口 8000) ===
docker-compose build tushare # 触发单次流水线
curl -X POST http://localhost:8000/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}'
# 查最新打分 # 查询打分列表
sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;" curl "http://localhost:8000/api/v1/scores?limit=5"
# === 修改代码后必须重建镜像 ===
docker-compose build tushare
docker-compose build web
# === 查最新打分(PostgreSQL) ===
docker-compose exec postgres psql -U trade -d futures -c \
"SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;"
``` ```
`tushare/.env` 必须存在且含 `TUSHARE_TOKEN=xxx`(已 gitignored)。可选 `BARK_KEY` 覆盖 `notifier.py` 默认 key。 `tushare/.env` 必须存在且含 `TUSHARE_TOKEN=xxx`(已 gitignored)。
## 关键架构 ## 关键架构
**单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores) → notifier`。无后台任务、无队列,每次 CLI 调用处理一个合约一日。 **单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores)`。无后台任务、无队列,每次 CLI 调用处理一个合约一日。
**FastAPI 服务**(`src.api`):容器默认以 `uvicorn src.api:app` 启动,暴露 `/api/v1/run`(触发流水线)、`/api/v1/scores``/api/v1/scores/{id}``/api/v1/contracts``/api/v1/candles` 等端点。启动时自动 `storage.init_db()` 建表。API 与 CLI 共用同一套 `fetcher/storage/scorer` 逻辑。
**主力轮换规则**(`contracts.py`):每个品种在 `ROLLOVER_RULES` 中维护 `month -> (主力月, 年份偏移)` 表。FG 当前规则:1-3/12 月→05、4-7 月→09、8-11 月→01,其中 8-11 月与 12 月跨年(`year_offset=1`)。新增品种(如 RB、I)只需在该 dict 里加一条,无需改 main 流程。 **主力轮换规则**(`contracts.py`):每个品种在 `ROLLOVER_RULES` 中维护 `month -> (主力月, 年份偏移)` 表。FG 当前规则:1-3/12 月→05、4-7 月→09、8-11 月→01,其中 8-11 月与 12 月跨年(`year_offset=1`)。新增品种(如 RB、I)只需在该 dict 里加一条,无需改 main 流程。
**三层打分模型**(`scorer.py`):综合 = 短期(7日,0.4) + 中期(15日,0.35) + 长期(30日,0.25)。`score_daily()` 要求 DataFrame ≥31 行,`fetcher.fetch_contract` 默认拉一个合约的全历史(实际 100+ 行),按 `trade_date` 升序排列后供打分使用。打分结果通过 `dataclass ScoreResult` + `ScoreDetail` 流转,`storage.save_score` 把 detail 序列化为 `detail_json` 文本列。 **三层打分模型**(`scorer.py`):综合 = 短期(7日,0.4) + 中期(15日,0.35) + 长期(30日,0.25)。`score_daily()` 要求 DataFrame ≥31 行,`fetcher.fetch_contract` 默认拉一个合约的全历史(实际 100+ 行),按 `trade_date` 升序排列后供打分使用。打分结果通过 `dataclass ScoreResult` + `ScoreDetail` 流转,`storage.save_score` 把 detail 序列化为 `detail_json` 文本列。
**SQLite 作为唯一数据**:`storage.py` `candles``scores` 两表都用 `INSERT OR REPLACE`(候选键 `(ts_code, trade_date)`)实现幂等,可反复重跑同一天。`PRAGMA journal_mode=WAL`,提升并发读写。表结构在 `init_db()` 中维护,新增字段需同步该函数 **PostgreSQL 作为业务数据**:docker-compose 编排 `postgres:18.3-alpine3.23`,`tushare``web` 服务均通过 `DATABASE_URL` 连接。`storage.py` 使用 `psycopg3` 驱动,`candles``scores` 表以 `ON CONFLICT (ts_code, trade_date) DO UPDATE` 实现幂等,可反复重跑同一天。`scores` 表主键为 `UUID DEFAULT uuidv7() PRIMARY KEY`(见 `models.py``storage.py`)
**Docker 边界**:`docker-compose.yml` 仅把 `./data` 挂为 `/app/data`(数据持久化);`tushare/src/` 在 Dockerfile 的 `COPY --chown=app:app src ./src` 阶段拷进镜像,**没有源码挂载**——改完 Python 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。 **Docker 边界**:`tushare/src/` `web/backend/``web/frontend/` 在 Dockerfile 的 `COPY` 阶段拷进镜像,**没有源码挂载**——改完 Python/Go/Vue 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。
**Bark 推送**:`notifier.push_bark``requests.get` 走路径形式(`/{key}/{title}/{body}`),所有片段以 `quote(safe='')` URL 编码,失败仅 `print [WARN]` 不抛错。容器内首发请求有时 DNS 慢导致 15s timeout,内置 1 次重试;主机直连通常 <1s。
## 配置/密钥规则 ## 配置/密钥规则
@@ -49,13 +62,14 @@ sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scor
## Web 模块(报告浏览端) ## Web 模块(报告浏览端)
`./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 `data/futures.db`,web 只读访问。docker-compose 上是新增的 `web` 服务,与 `tushare` 共存不互相依赖。 `./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 PostgreSQL,web 只读访问业务数据、读写 `auth.db`。docker-compose 上是 `web` 服务,与 `tushare`/`postgres` 共存不互相依赖。
**架构与边界**: **架构与边界**:
- 后端 Go(`golang:1.25.8-alpine3.23` 构建,`alpine:3.23` 运行),路由 `chi`,SQLite 驱动 `modernc.org/sqlite`(纯 Go 无 CGO,二进制更小、不需要 gcc)。前端 Vue 3 + Vite + Element Plus + ECharts。 - 后端 Go(`golang:1.25.8-alpine3.23` 构建,`alpine:3.23` 运行),路由 `chi`,业务数据库驱动 `github.com/lib/pq`(PostgreSQL),鉴权数据库仍用 `modernc.org/sqlite`(纯 Go 无 CGO)管理 `auth.db`。前端 Vue 3 + Vite + Element Plus + ECharts。
- 单进程同源服务:Vue 产物在 Docker 构建期由 `node` 阶段产出 `dist/`,被 `go:embed all:dist` 嵌入二进制,运行时由 Go 同时服务 `/api/*` 与 SPA 静态文件——不引入 nginx 旁车。 - 单进程同源服务:Vue 产物在 Docker 构建期由 `node` 阶段产出 `dist/`,被 `go:embed all:dist` 嵌入二进制,运行时由 Go 同时服务 `/api/*` 与 SPA 静态文件——不引入 nginx 旁车。
- 双 DB 分离:`futures.db``mode=ro&query_only(true)` 打开,容器挂 `:ro` 双重保险;`auth.db` 由 web 自己 init/写入,落在 `./data/auth.db`(已被 `.gitignore` 覆盖)。 - 双 DB 分离:业务数据 `futures` (PostgreSQL)通过 `DATABASE_URL` 只读访问;`auth.db`(SQLite)由 web 自己 init/写入,落在 `./data/auth.db`(已被 `.gitignore` 覆盖),容器挂载 `./data:/app/auth`
- 鉴权 JWT(HS256, Bearer header),12h 过期,无 sessions 表。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。 - 鉴权 JWT(HS256, Bearer header),12h 过期,无 sessions 表。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb`
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行 ** env 同时存在 `ADMIN_USER`/`ADMIN_PASS`,则用 bcrypt(cost=12) 写一行 admin。一旦 admin 存在,这两个 env 被静默忽略——避免轮换 env 时静默改密。忘记管理员密码的恢复方式:停服 → `sqlite3 data/auth.db "DELETE FROM users WHERE role='admin'"` → 重置 env → 重启。 **禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行 ** env 同时存在 `ADMIN_USER`/`ADMIN_PASS`,则用 bcrypt(cost=12) 写一行 admin。一旦 admin 存在,这两个 env 被静默忽略——避免轮换 env 时静默改密。忘记管理员密码的恢复方式:停服 → `sqlite3 data/auth.db "DELETE FROM users WHERE role='admin'"` → 重置 env → 重启。

View File

@@ -1,12 +1,12 @@
# 期货行情分析系统 — 使用说明 # 期货行情分析系统 — 使用说明
基于 Docker + Python(tushare) 的中国期货行情分析系统。当前阶段已实现数据采集三层加权打分模型,运行方式为脚本自动化(宿主机定时器触发 `docker-compose run`) 基于 Docker + Python(tushare) + PostgreSQL 的中国期货行情分析系统。当前阶段已实现数据采集三层加权打分模型与 Web 报表浏览端。运行方式支持两种模式:① 宿主机定时器触发 `docker-compose run` 执行 CLI;② 通过 FastAPI HTTP API 服务触发
## 环境准备 ## 环境准备
- Docker >= 20.10 - Docker >= 20.10
- Docker Compose >= 2.0 - Docker Compose >= 2.0
- (可选) sqlite3 CLI 用于本地查库 - (可选) psql 或任意 PostgreSQL 客户端用于本地查库
## 快速开始 ## 快速开始
@@ -20,21 +20,45 @@ echo "TUSHARE_TOKEN=你的token" > tushare/.env
该文件已被 gitignore 排除,不会进入版本库。 该文件已被 gitignore 排除,不会进入版本库。
### 2. 启动并跑当月主力 ### 2. 启动全栈服务
```bash ```bash
docker-compose run --rm tushare docker-compose up -d
```
这会同时启动 PostgreSQL、tushare API 服务(端口 8000)与 Web 浏览端(端口 8080)。
### 3. 通过 CLI 跑当月主力
```bash
docker-compose run --rm tushare python -m src.main
``` ```
不传参时,按 `tushare/src/contracts.py``ROLLOVER_RULES` 自动选 FG 玻璃当月主力(例如 2026-05 -> `FG2609.ZCE`),启动后会先打印 `[AUTO] FG 当月主力 -> ...`,然后: 不传参时,按 `tushare/src/contracts.py``ROLLOVER_RULES` 自动选 FG 玻璃当月主力(例如 2026-05 -> `FG2609.ZCE`),启动后会先打印 `[AUTO] FG 当月主力 -> ...`,然后:
1. 从 tushare 拉取合约日线数据 1. 从 tushare 拉取合约日线数据
2. 写入 SQLite `data/futures.db` 2. 写入 PostgreSQL `futures` 数据库
3. 运行三层打分模型 3. 运行三层打分模型
4. 保存打分结果并输出到 stdout 4. 保存打分结果并输出到 stdout
5. 通过 Bark 推送评分摘要
### 3. 跑其他合约或品种 ### 4. 通过 API 触发流水线
```bash
# 触发 FG 打分
curl -X POST http://localhost:8000/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}'
# 查询最新打分
curl "http://localhost:8000/api/v1/scores?limit=5"
# 查询合约列表
curl "http://localhost:8000/api/v1/contracts"
# 查询 K 线数据
curl "http://localhost:8000/api/v1/candles?ts_code=FG2609.ZCE"
```
### 5. 跑其他合约或品种
```bash ```bash
# 显式指定合约 # 显式指定合约
@@ -45,7 +69,7 @@ docker-compose run --rm tushare python -m src.main I2601.DCE
docker-compose run --rm tushare python -m src.main --symbol FG docker-compose run --rm tushare python -m src.main --symbol FG
``` ```
### 4. 玻璃 FG 主力轮换规则 ### 6. 玻璃 FG 主力轮换规则
| 当前自然月 | 主力合约 | | 当前自然月 | 主力合约 |
|----------|---------| |----------|---------|
@@ -112,44 +136,47 @@ docker-compose run --rm tushare python -m src.main --symbol FG
## 数据查询 ## 数据查询
SQLite 数据库位于 `data/futures.db`,可直接用 sqlite3 查询: 业务数据存储在 PostgreSQL 中,可通过以下方式查询:
```bash ```bash
# 查看最新打分 # 查看最新打分
sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;" docker-compose exec postgres psql -U trade -d futures -c \
"SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;"
# 查看合约日线 # 查看合约日线
sqlite3 data/futures.db "SELECT trade_date, open, high, low, close, vol, oi FROM candles WHERE ts_code='FG2609.ZCE' ORDER BY trade_date DESC LIMIT 10;" docker-compose exec postgres psql -U trade -d futures -c \
"SELECT trade_date, open, high, low, close, vol, oi FROM candles WHERE ts_code='FG2609.ZCE' ORDER BY trade_date DESC LIMIT 10;"
# 查看表结构 # 或通过 API 查询
sqlite3 data/futures.db ".schema" curl "http://localhost:8000/api/v1/scores?ts_code=FG2609.ZCE&limit=10"
curl "http://localhost:8000/api/v1/candles?ts_code=FG2609.ZCE"
``` ```
## 项目结构 ## 项目结构
``` ```
trade/ trade/
├── docker-compose.yml # Docker Compose 编排(tushare + web 两个服务) ├── docker-compose.yml # Docker Compose 编排(postgres + tushare + web)
├── 使用说明.md # 本文件 ├── 使用说明.md # 本文件
├── data/ # SQLite 数据库目录(gitignored) ├── CLAUDE.md # Claude Code 项目指引
│ ├── futures.db # tushare 写入,web 只读 ├── data/ # auth.db 目录(gitignored)
│ └── auth.db # web 自己维护的用户表 │ └── auth.db # web 自己维护的用户表(SQLite)
├── .gitignore # Git 忽略配置 ├── .gitignore # Git 忽略配置
├── tushare/ # Python 数据服务 ├── tushare/ # Python 数据服务
│ ├── Dockerfile │ ├── Dockerfile
│ ├── requirements.txt │ ├── requirements.txt
│ ├── .env # TUSHARE_TOKEN(本地,不入库) │ ├── .env # TUSHARE_TOKEN(本地,不入库)
│ └── src/ # 数据采集 + 打分 + Bark 推送 │ └── src/ # 数据采集 + 打分 + FastAPI
│ ├── api.py # FastAPI 服务入口
│ ├── models.py │ ├── models.py
│ ├── fetcher.py │ ├── fetcher.py
│ ├── scorer.py │ ├── scorer.py
│ ├── storage.py │ ├── storage.py # PostgreSQL 读写
│ ├── contracts.py │ ├── contracts.py
── notifier.py ── main.py # CLI 入口
│ └── main.py
└── web/ # Web 浏览端 └── web/ # Web 浏览端
├── .dockerignore ├── .dockerignore
├── backend/ # Go 1.25 后端 (chi + modernc.org/sqlite + JWT) ├── backend/ # Go 1.25 后端 (chi + lib/pq + JWT)
│ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行 │ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
│ ├── go.mod │ ├── go.mod
│ ├── main.go │ ├── main.go
@@ -158,7 +185,7 @@ trade/
│ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖 │ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
│ └── internal/ │ └── internal/
│ ├── config/ # 环境变量加载 │ ├── config/ # 环境变量加载
│ ├── store/ # futures.db 只读 + auth.db 用户表 │ ├── store/ # PostgreSQL 业务查询 + SQLite auth.db
│ ├── auth/ # JWT + bcrypt + 首启 admin 引导 │ ├── auth/ # JWT + bcrypt + 首启 admin 引导
│ ├── middleware/ # RequireUser / RequireAdmin / 日志 │ ├── middleware/ # RequireUser / RequireAdmin / 日志
│ ├── handlers/ # 登录 / 打分 / K线 / 用户管理 │ ├── handlers/ # 登录 / 打分 / K线 / 用户管理
@@ -171,7 +198,7 @@ trade/
└── src/ └── src/
├── main.ts / App.vue ├── main.ts / App.vue
├── router/ # 守卫(未登录/管理员路由) ├── router/ # 守卫(未登录/管理员路由)
├── stores/auth.ts # Pinia,持久化 token ├── stores/ # Pinia: auth.ts(持久化 token) + theme.ts(暗/浅色模式)
├── api/ # axios 封装 + 各端点 ├── api/ # axios 封装 + 各端点
├── views/ # 登录 / 打分列表 / 图表 / 用户管理 ├── views/ # 登录 / 打分列表 / 图表 / 用户管理
└── components/ # 抽屉 + ECharts K 线 └── components/ # 抽屉 + ECharts K 线
@@ -179,10 +206,11 @@ trade/
## 技术栈 ## 技术栈
- **Python 3.13** (alpine) + **tushare** + **pandas** — 数据采集打分 - **Python 3.13** (alpine) + **tushare** + **pandas** + **FastAPI** + **psycopg3** — 数据采集打分与 API 服务
- **Go 1.25.8** (alpine 3.23) + **chi** + **modernc.org/sqlite** + **JWT** — Web 后端 - **Go 1.25.8** (alpine 3.23) + **chi** + **lib/pq** + **JWT** — Web 后端
- **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端 - **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端
- **SQLite** — 本地数据存储(双库:`futures.db` 业务 + `auth.db` 鉴权) - **PostgreSQL 18.3** (alpine 3.23) — 业务数据存储
- **SQLite** — 鉴权数据存储(auth.db)
- **Docker / Docker Compose** — 容器化部署 - **Docker / Docker Compose** — 容器化部署
## 常见问题 ## 常见问题
@@ -197,11 +225,11 @@ A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大
**Q: 如何定时自动跑?** **Q: 如何定时自动跑?**
A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose run --rm tushare ...`打分结束会通过 Bark 推送结果(见 `tushare/src/notifier.py` A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose run --rm tushare ...`也可直接调用 API: `curl -X POST http://localhost:8000/api/v1/run ...`
## Web 报表(浏览端) ## Web 报表(浏览端)
`./web/` 提供一个图形化的浏览端,展示 tushare 流水线写入 `data/futures.db` 的打分与行情数据。后端 Go(`golang:1.25.8-alpine3.23`)读取数据库,前端 Vue 3 + Element Plus + ECharts,通过 docker-compose 一起部署。 `./web/` 提供一个图形化的浏览端,展示 tushare 流水线写入 PostgreSQL 的打分与行情数据。后端 Go(`golang:1.25.8-alpine3.23`)读取数据库,前端 Vue 3 + Element Plus + ECharts,通过 docker-compose 一起部署。
### 1. 配置首启凭据 ### 1. 配置首启凭据
@@ -235,6 +263,8 @@ docker-compose logs -f web
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。 普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
前端支持暗/浅色模式切换,点击顶部导航栏的「暗/亮」开关即可切换。侧边导航在暗色模式使用深色背景,浅色模式使用浅色背景。
### 4. 子账号维护流程 ### 4. 子账号维护流程
1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。 1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
@@ -244,17 +274,18 @@ docker-compose logs -f web
### 5. 数据流向与数据库分离 ### 5. 数据流向与数据库分离
``` ```
tushare(写) → data/futures.db ──(只读挂载 :ro)──> web 服务 ←(读写)→ data/auth.db tushare(写) → PostgreSQL futures 数据库 ←(只读)── web 后端
web 后端 ←(读写)→ data/auth.db (SQLite)
``` ```
`futures.db` 的 schema 与 Python 端一致(`candles` + `scores`)`auth.db` 表为: 业务数据(`candles` + `scores`)统一存储在 PostgreSQL 中,`auth.db` 表为:
```sql ```sql
users(id, username UNIQUE, password_hash, role IN ('admin','user'), users(id, username UNIQUE, password_hash, role IN ('admin','user'),
disabled, created_at, updated_at) disabled, created_at, updated_at)
``` ```
两个 DB 都`./data/` 目录,`.gitignore` 覆盖。 `auth.db` `./data/` 目录,被 `.gitignore` 覆盖。
### 6. 常见问题 ### 6. 常见问题
@@ -277,6 +308,6 @@ docker-compose up -d web
`web/backend/.env` 没设或太短,用 `openssl rand -hex 32` 生成一个 64 字符的十六进制字符串即可。 `web/backend/.env` 没设或太短,用 `openssl rand -hex 32` 生成一个 64 字符的十六进制字符串即可。
**Q: 容器内能不能误写 futures.db?** **Q: 为什么 tushare 容器启动后没有立即退出?**
不能。容器以 `./data:/app/data:ro` 挂载,Go 又用 `mode=ro&query_only(true)` 打开数据库,双层保险。auth.db 走另一个挂载点 `./data:/app/auth`(同物理目录但路径不同,无 `:ro`) 因为默认命令改为 `uvicorn src.api:app` 常驻 API 服务。如需执行单次 CLI,用 `docker-compose run --rm tushare python -m src.main ...`

View File

@@ -1,6 +1,5 @@
tushare>=1.4.0 tushare>=1.4.0
pandas>=2.2.0 pandas>=2.2.0
requests>=2.31.0
fastapi>=0.115.0 fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0
psycopg[binary]>=3.2.0 psycopg[binary]>=3.2.0

View File

@@ -1,9 +1,11 @@
from typing import Optional from typing import Optional
from datetime import date
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from . import contracts, fetcher, notifier, scorer, storage from . import contracts, fetcher, scorer, storage
app = FastAPI(title="期货数据采集与打分服务") app = FastAPI(title="期货数据采集与打分服务")
@@ -11,6 +13,7 @@ app = FastAPI(title="期货数据采集与打分服务")
class RunRequest(BaseModel): class RunRequest(BaseModel):
ts_code: Optional[str] = None ts_code: Optional[str] = None
symbol: str = "FG" symbol: str = "FG"
trade_date: Optional[str] = None
class RunResponse(BaseModel): class RunResponse(BaseModel):
@@ -44,17 +47,9 @@ def run_pipeline(req: RunRequest):
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
storage.save_candles(df) storage.save_candles(df)
result = scorer.score_daily(df) result = scorer.score_daily(df, req.trade_date)
storage.save_score(result) storage.save_score(result)
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
push_body = (
f"综合 {result.composite:.1f}\n"
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n"
f"{result.signal}"
)
notifier.push_bark(push_title, push_body)
return RunResponse( return RunResponse(
ts_code=result.ts_code, ts_code=result.ts_code,
trade_date=result.trade_date, trade_date=result.trade_date,
@@ -132,6 +127,20 @@ def list_contracts():
conn.close() conn.close()
@app.get("/api/v1/contracts/active")
def get_active_contract(symbol: str = Query(...)):
"""返回某品种当前主力合约及可选打分日期范围。"""
if symbol not in contracts.ROLLOVER_RULES:
raise HTTPException(status_code=400, detail=f"未配置 {symbol} 的主力轮换规则")
today = date.today()
return {
"symbol": symbol,
"ts_code": contracts.active_contract(symbol, today),
"min_date": contracts.active_contract_start(symbol, today).isoformat(),
"max_date": today.isoformat(),
}
@app.get("/api/v1/candles") @app.get("/api/v1/candles")
def list_candles( def list_candles(
ts_code: str = Query(...), ts_code: str = Query(...),

View File

@@ -1,11 +1,18 @@
from datetime import date from datetime import date
from typing import Optional from typing import Optional
def _prev_month(year: int, month: int) -> tuple[int, int]:
return (year - 1, 12) if month == 1 else (year, month - 1)
# 品种主力合约轮换规则。 # 品种主力合约轮换规则。
# 每个品种维护: # 每个品种维护:
# exchange: tushare 合约后缀(交易所) # exchange: tushare 合约后缀(交易所)
# active: 当月 -> (主力合约月, 年份偏移) # active: 当月 -> (主力合约月, 年份偏移)
# 例 FG 12 月用次年 5 月,故 12 -> (5, 1) # 例 FG 12 月用次年 5 月,故 12 -> (5, 1)
# 允许前端选择的品种列表
SYMBOLS = ["FG", "SA", "RB", "MA", "CF", "M"]
ROLLOVER_RULES: dict[str, dict] = { ROLLOVER_RULES: dict[str, dict] = {
"FG": { "FG": {
"exchange": "ZCE", "exchange": "ZCE",
@@ -16,6 +23,46 @@ ROLLOVER_RULES: dict[str, dict] = {
12: (5, 1), 12: (5, 1),
}, },
}, },
"SA": {
"exchange": "ZCE",
"active": {
1: (5, 0), 2: (5, 0), 3: (5, 0), 4: (5, 0),
5: (9, 0), 6: (9, 0), 7: (9, 0), 8: (9, 0),
9: (1, 1), 10: (1, 1), 11: (1, 1), 12: (1, 1),
},
},
"MA": {
"exchange": "ZCE",
"active": {
1: (5, 0), 2: (5, 0), 3: (5, 0), 4: (5, 0),
5: (9, 0), 6: (9, 0), 7: (9, 0), 8: (9, 0),
9: (1, 1), 10: (1, 1), 11: (1, 1), 12: (1, 1),
},
},
"CF": {
"exchange": "ZCE",
"active": {
1: (5, 0), 2: (5, 0), 3: (5, 0), 4: (5, 0),
5: (9, 0), 6: (9, 0), 7: (9, 0), 8: (9, 0),
9: (1, 1), 10: (1, 1), 11: (1, 1), 12: (1, 1),
},
},
"RB": {
"exchange": "SHF",
"active": {
1: (10, 0), 2: (10, 0), 3: (10, 0), 4: (10, 0), 5: (10, 0),
6: (1, 1), 7: (1, 1), 8: (1, 1), 9: (1, 1),
10: (5, 1), 11: (5, 1), 12: (5, 1),
},
},
"M": {
"exchange": "DCE",
"active": {
1: (5, 0), 2: (5, 0), 3: (5, 0), 4: (5, 0), 5: (5, 0),
6: (9, 0), 7: (9, 0), 8: (9, 0),
9: (1, 1), 10: (1, 1), 11: (1, 1), 12: (1, 1),
},
},
} }
@@ -29,3 +76,21 @@ def active_contract(symbol: str, today: Optional[date] = None) -> str:
contract_month, year_offset = rule["active"][today.month] contract_month, year_offset = rule["active"][today.month]
year = today.year + year_offset year = today.year + year_offset
return f"{symbol}{year % 100:02d}{contract_month:02d}.{rule['exchange']}" return f"{symbol}{year % 100:02d}{contract_month:02d}.{rule['exchange']}"
def active_contract_start(symbol: str, today: Optional[date] = None) -> date:
"""当前主力合约首次成为主力的日期(月初)。
从今天向前回溯日历月,只要 active_contract 仍指向同一合约就继续往前。
例如今天 2026-05,FG 的 09 合约活跃月份为 4-7 月,则返回 2026-04-01。
"""
today = today or date.today()
target = active_contract(symbol, today)
year, month = today.year, today.month
for _ in range(12):
py, pm = _prev_month(year, month)
if active_contract(symbol, date(py, pm, 1)) != target:
return date(year, month, 1)
year, month = py, pm
return date(year, month, 1)

View File

@@ -1,21 +1,21 @@
import argparse import argparse
import sys import sys
from . import contracts, fetcher, notifier, scorer, storage from . import contracts, fetcher, scorer, storage
def run(ts_code: str) -> int: def run(ts_code: str, trade_date: Optional[str] = None) -> int:
storage.init_db() storage.init_db()
print(f"[1/4] 拉取 {ts_code} 数据...") print(f"[1/4] 拉取 {ts_code} 数据...")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
print(f" 返回 {len(df)}") print(f" 返回 {len(df)}")
print(f"[2/4] 写入/更新 SQLite...") print(f"[2/4] 写入/更新 PostgreSQL...")
storage.save_candles(df) storage.save_candles(df)
print(f"[3/4] 计算打分...") print(f"[3/4] 计算打分...")
result = scorer.score_daily(df) result = scorer.score_daily(df, trade_date)
print(f"[4/4] 保存打分结果...") print(f"[4/4] 保存打分结果...")
storage.save_score(result) storage.save_score(result)
@@ -61,16 +61,8 @@ def run(ts_code: str) -> int:
print(f" 30日前持仓量: {ld['oi_before']:,.0f}") print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%") print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
print(f"\n[OK] 数据已持久化到 SQLite") print(f"\n[OK] 数据已持久化到 PostgreSQL")
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
push_body = (
f"综合 {result.composite:.1f}\n"
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n"
f"{result.signal}"
)
if notifier.push_bark(push_title, push_body):
print("[Bark] 推送成功")
return 0 return 0
@@ -86,12 +78,18 @@ def main() -> int:
default="FG", default="FG",
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG", help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
) )
parser.add_argument(
"--date",
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
)
args = parser.parse_args() args = parser.parse_args()
ts_code = args.ts_code or contracts.active_contract(args.symbol) ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.ts_code: if not args.ts_code:
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
return run(ts_code) if args.date:
print(f"[DATE] 指定打分日期: {args.date}")
return run(ts_code, args.date)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,28 +0,0 @@
import os
from urllib.parse import quote
import requests
DEFAULT_BARK_KEY = "RvdtHq4py2avatt4AFJn9a"
BARK_BASE_URL = "https://api.day.app"
def push_bark(title: str, body: str, key: str | None = None, timeout: float = 15.0, retries: int = 1) -> bool:
bark_key = key or os.environ.get("BARK_KEY") or DEFAULT_BARK_KEY
url = f"{BARK_BASE_URL}/{bark_key}/{quote(title, safe='')}/{quote(body, safe='')}"
last_err: Exception | None = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, timeout=timeout)
except requests.RequestException as e:
last_err = e
continue
if resp.status_code == 200:
return True
print(f"[WARN] Bark 推送返回非 200: {resp.status_code} {resp.text[:120]}")
return False
print(f"[WARN] Bark 推送失败: {last_err}")
return False

View File

@@ -1,3 +1,5 @@
from typing import Optional
import pandas as pd import pandas as pd
from .models import ScoreDetail, ScoreResult from .models import ScoreDetail, ScoreResult
@@ -116,11 +118,21 @@ def _interpret(composite: float) -> str:
return "强烈看空区域 — 资金主动且持续地打压价格" return "强烈看空区域 — 资金主动且持续地打压价格"
def score_daily(df: pd.DataFrame) -> ScoreResult: def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
"""对 DataFrame 中最新一条记录打分。""" """对 DataFrame 中指定日期或最新一条记录打分。"""
if len(df) < 31: if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行") raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
if trade_date:
trade_date_str = str(trade_date)
mask = df["trade_date"].astype(str) == trade_date_str
if not mask.any():
raise ValueError(f"指定日期 {trade_date_str} 不在数据中")
pos = mask.idxmax()
df = df.iloc[:pos + 1].copy()
if len(df) < 31:
raise ValueError(f"指定日期 {trade_date_str} 之前数据不足(仅 {len(df)} 行),需要至少 31 行")
latest = df.iloc[-1] latest = df.iloc[-1]
short, short_details = calc_short_term(df, 7) short, short_details = calc_short_term(df, 7)

5
web/backend/go.sum Normal file
View File

@@ -0,0 +1,5 @@
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA=

View File

@@ -7,21 +7,23 @@ import (
) )
type Config struct { type Config struct {
ListenAddr string ListenAddr string
DatabaseURL string DatabaseURL string
AuthDBPath string AuthDBPath string
JWTSecret []byte JWTSecret []byte
AdminUser string AdminUser string
AdminPass string AdminPass string
TushareAPIURL string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"), AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")), AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
AdminPass: os.Getenv("ADMIN_PASS"), AdminPass: os.Getenv("ADMIN_PASS"),
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")

View File

@@ -11,9 +11,10 @@ import (
// Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。 // Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。
type Deps struct { type Deps struct {
Auth *store.AuthStore Auth *store.AuthStore
Futures *store.FuturesStore Futures *store.FuturesStore
JWT *auth.Manager JWT *auth.Manager
TushareURL string
} }
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {

View File

@@ -0,0 +1,64 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type runRequest struct {
TsCode string `json:"ts_code,omitempty"`
Symbol string `json:"symbol,omitempty"`
TradeDate string `json:"trade_date,omitempty"`
}
func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
var req runRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
writeErr(w, http.StatusBadRequest, "symbol is required")
return
}
target := fmt.Sprintf("%s/api/v1/contracts/active?symbol=%s", d.TushareURL, url.QueryEscape(symbol))
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(target)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}

View File

@@ -22,6 +22,7 @@ func (d *Deps) ListScores(w http.ResponseWriter, r *http.Request) {
TsCode: q.Get("ts_code"), TsCode: q.Get("ts_code"),
Start: q.Get("start"), Start: q.Get("start"),
End: q.Get("end"), End: q.Get("end"),
Signal: q.Get("signal"),
Limit: limit, Limit: limit,
}) })
if err != nil { if err != nil {

View File

@@ -29,7 +29,9 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f
r.Get("/scores", d.ListScores) r.Get("/scores", d.ListScores)
r.Get("/scores/{id}", d.GetScore) r.Get("/scores/{id}", d.GetScore)
r.Get("/contracts", d.ListContracts) r.Get("/contracts", d.ListContracts)
r.Get("/contracts/active", d.GetActiveContract)
r.Get("/candles", d.ListCandles) r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)

View File

@@ -48,6 +48,7 @@ type ScoreFilter struct {
TsCode string TsCode string
Start string Start string
End string End string
Signal string
Limit int Limit int
} }
@@ -69,6 +70,10 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
q += " AND trade_date <= " + next() q += " AND trade_date <= " + next()
args = append(args, f.End) args = append(args, f.End)
} }
if f.Signal != "" {
q += " AND signal LIKE " + next()
args = append(args, "%"+f.Signal+"%")
}
q += " ORDER BY trade_date DESC, id DESC" q += " ORDER BY trade_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 500 { if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 200 f.Limit = 200

View File

@@ -41,7 +41,7 @@ func main() {
} }
mgr := auth.NewManager(cfg.JWTSecret) mgr := auth.NewManager(cfg.JWTSecret)
deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr} deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr, TushareURL: cfg.TushareAPIURL}
dist, err := fs.Sub(distFS, "dist") dist, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {

View File

@@ -36,6 +36,7 @@ function logout() {
> >
<el-menu-item index="/scores">打分列表</el-menu-item> <el-menu-item index="/scores">打分列表</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item> <el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item> <el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>

View File

@@ -0,0 +1,37 @@
import client from './client'
export interface RunRequest {
ts_code?: string
symbol?: string
trade_date?: string
}
export interface RunResponse {
ts_code: string
trade_date: string
close: number
oi: number
oi_chg: number
short_term: number
medium_term: number
long_term: number
composite: number
signal: string
}
export interface ActiveContract {
symbol: string
ts_code: string
min_date: string
max_date: string
}
export function runPipeline(req: RunRequest) {
return client.post<RunResponse>('/run', req).then((r) => r.data)
}
export function getActiveContract(symbol: string) {
return client
.get<ActiveContract>('/contracts/active', { params: { symbol } })
.then((r) => r.data)
}

View File

@@ -49,6 +49,7 @@ export interface ScoreListParams {
ts_code?: string ts_code?: string
start?: string start?: string
end?: string end?: string
signal?: string
limit?: number limit?: number
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { getScore, type Score } from '@/api/scores' import { getScore, type Score } from '@/api/scores'
import { parseTsCode } from '@/utils/contract'
const props = defineProps<{ scoreId: number | null }>() const props = defineProps<{ scoreId: number | null }>()
const emit = defineEmits<{ (e: 'close'): void }>() const emit = defineEmits<{ (e: 'close'): void }>()
@@ -36,7 +37,8 @@ watch(
<el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close> <el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close>
<div v-loading="loading" v-if="score"> <div v-loading="loading" v-if="score">
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="合约">{{ score.ts_code }}</el-descriptions-item> <el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
<el-descriptions-item label="合约">{{ parseTsCode(score.ts_code).contract }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item> <el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ score.close }}</el-descriptions-item> <el-descriptions-item label="收盘">{{ score.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ score.oi }}</el-descriptions-item> <el-descriptions-item label="持仓">{{ score.oi }}</el-descriptions-item>

View File

@@ -19,6 +19,11 @@ const routes: RouteRecordRaw[] = [
name: 'chart', name: 'chart',
component: () => import('@/views/ChartView.vue'), component: () => import('@/views/ChartView.vue'),
}, },
{
path: '/run',
name: 'run',
component: () => import('@/views/RunView.vue'),
},
{ {
path: '/admin/users', path: '/admin/users',
name: 'admin-users', name: 'admin-users',

View File

@@ -0,0 +1,7 @@
export function parseTsCode(tsCode: string): { symbol: string; contract: string } {
const m = tsCode.match(/^([A-Za-z]+)(\d{4})\.[A-Z]+$/)
if (!m) {
return { symbol: tsCode, contract: '' }
}
return { symbol: m[1], contract: m[2] }
}

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
runPipeline,
getActiveContract,
type ActiveContract,
type RunResponse,
} from '@/api/run'
import { parseTsCode } from '@/utils/contract'
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
const form = reactive<{
symbol: string
trade_date: string
}>({
symbol: 'FG',
trade_date: '',
})
const active = ref<ActiveContract | null>(null)
const activeLoading = ref(false)
const loading = ref(false)
const result = ref<RunResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null)
async function loadActive() {
activeLoading.value = true
try {
active.value = await getActiveContract(form.symbol)
// 切换品种后,如果原日期落在新合约的可选范围之外,清空它
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
form.trade_date = ''
}
} catch (err: any) {
active.value = null
ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
} finally {
activeLoading.value = false
}
}
function toDate(s: string) {
// s 形如 'YYYY-MM-DD'
const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d)
}
function isDateAllowed(d: Date): boolean {
if (!active.value) return true
const min = toDate(active.value.min_date).getTime()
const max = toDate(active.value.max_date).getTime()
const t = d.getTime()
return t >= min && t <= max
}
function disabledDate(d: Date) {
return !isDateAllowed(d)
}
async function submit() {
if (!form.symbol) {
ElMessage.warning('请选择品种')
return
}
loading.value = true
result.value = null
try {
const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
const resp = await runPipeline(req)
result.value = resp
ElMessage.success('打分完成')
await nextTick()
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
loading.value = false
}
}
function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return ''
if (s.includes('偏空')) return 'warning'
if (s.includes('强烈看空')) return 'danger'
return 'info'
}
watch(() => form.symbol, loadActive)
onMounted(loadActive)
</script>
<template>
<div class="page">
<el-card shadow="never">
<template #header>
<span>手动打分</span>
</template>
<el-form :model="form" label-width="100px" style="max-width: 480px">
<el-form-item label="品种">
<el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%">
<el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="主力合约">
<span v-if="active">
{{ parseTsCode(active.ts_code).contract }}
<el-text type="info" size="small" style="margin-left: 8px">
({{ active.ts_code }})
</el-text>
</span>
<el-text v-else type="info">加载中</el-text>
</el-form-item>
<el-form-item label="打分日期">
<el-date-picker
v-model="form.trade_date"
type="date"
:placeholder="active ? `${active.min_date} ~ ${active.max_date},留空则取最新` : '加载中…'"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
:disabled="!active"
style="width: 100%"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
执行打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<div v-if="result" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header>
<span>打分结果</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="品种">{{ parseTsCode(result.ts_code).symbol }}</el-descriptions-item>
<el-descriptions-item label="合约">{{ parseTsCode(result.ts_code).contract }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ result.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ result.oi }}</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ result.short_term }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ result.medium_term }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)">{{ result.long_term }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ result.composite }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号" :span="2">
<el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>

View File

@@ -2,10 +2,17 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { listContracts, listScores, type Score } from '@/api/scores' import { listContracts, listScores, type Score } from '@/api/scores'
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue' import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
import { parseTsCode } from '@/utils/contract'
const filter = reactive<{ ts_code?: string; range: [string, string] | []; limit: number }>({ const filter = reactive<{
ts_code?: string
range: [string, string] | []
signal?: string
limit: number
}>({
ts_code: undefined, ts_code: undefined,
range: [], range: [],
signal: undefined,
limit: 200, limit: 200,
}) })
@@ -22,6 +29,7 @@ async function reload() {
ts_code: filter.ts_code, ts_code: filter.ts_code,
start: start || undefined, start: start || undefined,
end: end || undefined, end: end || undefined,
signal: filter.signal,
limit: filter.limit, limit: filter.limit,
}) })
} finally { } finally {
@@ -29,6 +37,11 @@ async function reload() {
} }
} }
function toggleSignal(s: string) {
filter.signal = filter.signal === s ? undefined : s
reload()
}
function signalTagType(s: string) { function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success' if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return '' if (s.includes('偏多')) return ''
@@ -74,12 +87,37 @@ onMounted(async () => {
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="reload">查询</el-button> <el-button type="primary" :loading="loading" @click="reload">查询</el-button>
</el-form-item> </el-form-item>
<el-form-item label="快捷">
<el-button-group>
<el-button
:type="filter.signal === '强烈看多' ? 'success' : ''"
@click="toggleSignal('强烈看多')"
>
强烈看多
</el-button>
<el-button
:type="filter.signal === '强烈看空' ? 'danger' : ''"
@click="toggleSignal('强烈看空')"
>
强烈看空
</el-button>
</el-button-group>
</el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-table :data="rows" v-loading="loading" stripe class="score-table"> <el-table :data="rows" v-loading="loading" stripe class="score-table">
<el-table-column prop="trade_date" label="日期" width="100" /> <el-table-column prop="trade_date" label="日期" width="100" />
<el-table-column prop="ts_code" label="合约" width="140" /> <el-table-column label="品种" width="80">
<template #default="{ row }">
{{ parseTsCode(row.ts_code).symbol }}
</template>
</el-table-column>
<el-table-column label="合约" width="80">
<template #default="{ row }">
{{ parseTsCode(row.ts_code).contract }}
</template>
</el-table-column>
<el-table-column prop="close" label="收盘" width="90" /> <el-table-column prop="close" label="收盘" width="90" />
<el-table-column prop="oi" label="持仓" width="100" /> <el-table-column prop="oi" label="持仓" width="100" />
<el-table-column prop="oi_chg" label="持仓变化" width="100" /> <el-table-column prop="oi_chg" label="持仓变化" width="100" />