Compare commits
7 Commits
c64def9031
...
df28200eb0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df28200eb0 | ||
|
|
7d49aff6c7 | ||
|
|
44909f04e2 | ||
|
|
fd1c1c7330 | ||
|
|
dc22799985 | ||
|
|
a1355d91aa | ||
|
|
23776b5e96 |
46
CLAUDE.md
46
CLAUDE.md
@@ -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
|
||||
# 不传参 = 按当月 FG 主力自动选合约(轮换规则见 contracts.py:ROLLOVER_RULES)
|
||||
docker-compose run --rm tushare
|
||||
# === 启动全栈服务(PostgreSQL + tushare API + web) ===
|
||||
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)
|
||||
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)
|
||||
docker-compose run --rm tushare python -m src.main --symbol FG
|
||||
|
||||
# 修改 tushare/src/ 下任意 .py 后必须重建镜像
|
||||
docker-compose build tushare
|
||||
# === tushare API 服务(容器内运行 uvicorn,端口 8000) ===
|
||||
# 触发单次流水线
|
||||
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 流程。
|
||||
|
||||
**三层打分模型**(`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 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。
|
||||
|
||||
**Bark 推送**:`notifier.push_bark` 用 `requests.get` 走路径形式(`/{key}/{title}/{body}`),所有片段以 `quote(safe='')` URL 编码,失败仅 `print [WARN]` 不抛错。容器内首发请求有时 DNS 慢导致 15s timeout,内置 1 次重试;主机直连通常 <1s。
|
||||
**Docker 边界**:`tushare/src/` 与 `web/backend/`、`web/frontend/` 均在 Dockerfile 的 `COPY` 阶段拷进镜像,**没有源码挂载**——改完 Python/Go/Vue 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。
|
||||
|
||||
## 配置/密钥规则
|
||||
|
||||
@@ -49,13 +62,14 @@ sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scor
|
||||
|
||||
## 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 旁车。
|
||||
- 双 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 自动跳登录。
|
||||
- 前端支持暗/浅色模式切换(`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 → 重启。
|
||||
|
||||
|
||||
@@ -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 Compose >= 2.0
|
||||
- (可选) sqlite3 CLI 用于本地查库
|
||||
- (可选) psql 或任意 PostgreSQL 客户端用于本地查库
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -20,21 +20,45 @@ echo "TUSHARE_TOKEN=你的token" > tushare/.env
|
||||
|
||||
该文件已被 gitignore 排除,不会进入版本库。
|
||||
|
||||
### 2. 启动并跑当月主力
|
||||
### 2. 启动全栈服务
|
||||
|
||||
```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 当月主力 -> ...`,然后:
|
||||
|
||||
1. 从 tushare 拉取合约日线数据
|
||||
2. 写入 SQLite `data/futures.db`
|
||||
2. 写入 PostgreSQL `futures` 数据库
|
||||
3. 运行三层打分模型
|
||||
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
|
||||
# 显式指定合约
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 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
|
||||
# 查看最新打分
|
||||
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;"
|
||||
|
||||
# 查看表结构
|
||||
sqlite3 data/futures.db ".schema"
|
||||
# 或通过 API 查询
|
||||
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/
|
||||
├── docker-compose.yml # Docker Compose 编排(tushare + web 两个服务)
|
||||
├── docker-compose.yml # Docker Compose 编排(postgres + tushare + web)
|
||||
├── 使用说明.md # 本文件
|
||||
├── data/ # SQLite 数据库目录(gitignored)
|
||||
│ ├── futures.db # tushare 写入,web 只读
|
||||
│ └── auth.db # web 自己维护的用户表
|
||||
├── CLAUDE.md # Claude Code 项目指引
|
||||
├── data/ # auth.db 目录(gitignored)
|
||||
│ └── auth.db # web 自己维护的用户表(SQLite)
|
||||
├── .gitignore # Git 忽略配置
|
||||
├── tushare/ # Python 数据服务
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── .env # TUSHARE_TOKEN(本地,不入库)
|
||||
│ └── src/ # 数据采集 + 打分 + Bark 推送
|
||||
│ └── src/ # 数据采集 + 打分 + FastAPI
|
||||
│ ├── api.py # FastAPI 服务入口
|
||||
│ ├── models.py
|
||||
│ ├── fetcher.py
|
||||
│ ├── scorer.py
|
||||
│ ├── storage.py
|
||||
│ ├── storage.py # PostgreSQL 读写
|
||||
│ ├── contracts.py
|
||||
│ ├── notifier.py
|
||||
│ └── main.py
|
||||
│ └── main.py # CLI 入口
|
||||
└── web/ # Web 浏览端
|
||||
├── .dockerignore
|
||||
├── backend/ # Go 1.25 后端 (chi + modernc.org/sqlite + JWT)
|
||||
├── backend/ # Go 1.25 后端 (chi + lib/pq + JWT)
|
||||
│ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
|
||||
│ ├── go.mod
|
||||
│ ├── main.go
|
||||
@@ -158,7 +185,7 @@ trade/
|
||||
│ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
|
||||
│ └── internal/
|
||||
│ ├── config/ # 环境变量加载
|
||||
│ ├── store/ # futures.db 只读 + auth.db 用户表
|
||||
│ ├── store/ # PostgreSQL 业务查询 + SQLite auth.db
|
||||
│ ├── auth/ # JWT + bcrypt + 首启 admin 引导
|
||||
│ ├── middleware/ # RequireUser / RequireAdmin / 日志
|
||||
│ ├── handlers/ # 登录 / 打分 / K线 / 用户管理
|
||||
@@ -171,7 +198,7 @@ trade/
|
||||
└── src/
|
||||
├── main.ts / App.vue
|
||||
├── router/ # 守卫(未登录/管理员路由)
|
||||
├── stores/auth.ts # Pinia,持久化 token
|
||||
├── stores/ # Pinia: auth.ts(持久化 token) + theme.ts(暗/浅色模式)
|
||||
├── api/ # axios 封装 + 各端点
|
||||
├── views/ # 登录 / 打分列表 / 图表 / 用户管理
|
||||
└── components/ # 抽屉 + ECharts K 线
|
||||
@@ -179,10 +206,11 @@ trade/
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Python 3.13** (alpine) + **tushare** + **pandas** — 数据采集与打分
|
||||
- **Go 1.25.8** (alpine 3.23) + **chi** + **modernc.org/sqlite** + **JWT** — Web 后端
|
||||
- **Python 3.13** (alpine) + **tushare** + **pandas** + **FastAPI** + **psycopg3** — 数据采集、打分与 API 服务
|
||||
- **Go 1.25.8** (alpine 3.23) + **chi** + **lib/pq** + **JWT** — 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** — 容器化部署
|
||||
|
||||
## 常见问题
|
||||
@@ -197,11 +225,11 @@ A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大
|
||||
|
||||
**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/` 提供一个图形化的浏览端,展示 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. 配置首启凭据
|
||||
|
||||
@@ -235,6 +263,8 @@ docker-compose logs -f web
|
||||
|
||||
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
|
||||
|
||||
前端支持暗/浅色模式切换,点击顶部导航栏的「暗/亮」开关即可切换。侧边导航在暗色模式使用深色背景,浅色模式使用浅色背景。
|
||||
|
||||
### 4. 子账号维护流程
|
||||
|
||||
1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
|
||||
@@ -244,17 +274,18 @@ docker-compose logs -f web
|
||||
### 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
|
||||
users(id, username UNIQUE, password_hash, role IN ('admin','user'),
|
||||
disabled, created_at, updated_at)
|
||||
```
|
||||
|
||||
两个 DB 都在 `./data/` 目录,均被 `.gitignore` 覆盖。
|
||||
`auth.db` 在 `./data/` 目录,被 `.gitignore` 覆盖。
|
||||
|
||||
### 6. 常见问题
|
||||
|
||||
@@ -277,6 +308,6 @@ docker-compose up -d web
|
||||
|
||||
`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 ...`。
|
||||
@@ -1,6 +1,5 @@
|
||||
tushare>=1.4.0
|
||||
pandas>=2.2.0
|
||||
requests>=2.31.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
psycopg[binary]>=3.2.0
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from typing import Optional
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import contracts, fetcher, notifier, scorer, storage
|
||||
from . import contracts, fetcher, scorer, storage
|
||||
|
||||
app = FastAPI(title="期货数据采集与打分服务")
|
||||
|
||||
@@ -11,6 +13,7 @@ app = FastAPI(title="期货数据采集与打分服务")
|
||||
class RunRequest(BaseModel):
|
||||
ts_code: Optional[str] = None
|
||||
symbol: str = "FG"
|
||||
trade_date: Optional[str] = None
|
||||
|
||||
|
||||
class RunResponse(BaseModel):
|
||||
@@ -44,17 +47,9 @@ def run_pipeline(req: RunRequest):
|
||||
|
||||
df = fetcher.fetch_contract(ts_code)
|
||||
storage.save_candles(df)
|
||||
result = scorer.score_daily(df)
|
||||
result = scorer.score_daily(df, req.trade_date)
|
||||
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(
|
||||
ts_code=result.ts_code,
|
||||
trade_date=result.trade_date,
|
||||
@@ -132,6 +127,20 @@ def list_contracts():
|
||||
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")
|
||||
def list_candles(
|
||||
ts_code: str = Query(...),
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
from datetime import date
|
||||
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 合约后缀(交易所)
|
||||
# active: 当月 -> (主力合约月, 年份偏移)
|
||||
# 例 FG 12 月用次年 5 月,故 12 -> (5, 1)
|
||||
# 允许前端选择的品种列表
|
||||
SYMBOLS = ["FG", "SA", "RB", "MA", "CF", "M"]
|
||||
|
||||
ROLLOVER_RULES: dict[str, dict] = {
|
||||
"FG": {
|
||||
"exchange": "ZCE",
|
||||
@@ -16,6 +23,46 @@ ROLLOVER_RULES: dict[str, dict] = {
|
||||
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]
|
||||
year = today.year + year_offset
|
||||
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)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import argparse
|
||||
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()
|
||||
|
||||
print(f"[1/4] 拉取 {ts_code} 数据...")
|
||||
df = fetcher.fetch_contract(ts_code)
|
||||
print(f" 返回 {len(df)} 行")
|
||||
|
||||
print(f"[2/4] 写入/更新 SQLite...")
|
||||
print(f"[2/4] 写入/更新 PostgreSQL...")
|
||||
storage.save_candles(df)
|
||||
|
||||
print(f"[3/4] 计算打分...")
|
||||
result = scorer.score_daily(df)
|
||||
result = scorer.score_daily(df, trade_date)
|
||||
|
||||
print(f"[4/4] 保存打分结果...")
|
||||
storage.save_score(result)
|
||||
@@ -61,16 +61,8 @@ def run(ts_code: str) -> int:
|
||||
print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
|
||||
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
|
||||
|
||||
|
||||
@@ -86,12 +78,18 @@ def main() -> int:
|
||||
default="FG",
|
||||
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
ts_code = args.ts_code or contracts.active_contract(args.symbol)
|
||||
if not args.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__":
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .models import ScoreDetail, ScoreResult
|
||||
@@ -116,11 +118,21 @@ def _interpret(composite: float) -> str:
|
||||
return "强烈看空区域 — 资金主动且持续地打压价格"
|
||||
|
||||
|
||||
def score_daily(df: pd.DataFrame) -> ScoreResult:
|
||||
"""对 DataFrame 中最新一条记录打分。"""
|
||||
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
|
||||
"""对 DataFrame 中指定日期或最新一条记录打分。"""
|
||||
if 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]
|
||||
|
||||
short, short_details = calc_short_term(df, 7)
|
||||
|
||||
5
web/backend/go.sum
Normal file
5
web/backend/go.sum
Normal 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=
|
||||
@@ -7,21 +7,23 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
DatabaseURL string
|
||||
AuthDBPath string
|
||||
JWTSecret []byte
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
ListenAddr string
|
||||
DatabaseURL string
|
||||
AuthDBPath string
|
||||
JWTSecret []byte
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
TushareAPIURL string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
|
||||
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
|
||||
AdminPass: os.Getenv("ADMIN_PASS"),
|
||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
|
||||
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
|
||||
AdminPass: os.Getenv("ADMIN_PASS"),
|
||||
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
||||
|
||||
@@ -11,9 +11,10 @@ import (
|
||||
|
||||
// Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。
|
||||
type Deps struct {
|
||||
Auth *store.AuthStore
|
||||
Futures *store.FuturesStore
|
||||
JWT *auth.Manager
|
||||
Auth *store.AuthStore
|
||||
Futures *store.FuturesStore
|
||||
JWT *auth.Manager
|
||||
TushareURL string
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
|
||||
64
web/backend/internal/handlers/run.go
Normal file
64
web/backend/internal/handlers/run.go
Normal 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)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func (d *Deps) ListScores(w http.ResponseWriter, r *http.Request) {
|
||||
TsCode: q.Get("ts_code"),
|
||||
Start: q.Get("start"),
|
||||
End: q.Get("end"),
|
||||
Signal: q.Get("signal"),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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/{id}", d.GetScore)
|
||||
r.Get("/contracts", d.ListContracts)
|
||||
r.Get("/contracts/active", d.GetActiveContract)
|
||||
r.Get("/candles", d.ListCandles)
|
||||
r.Post("/run", d.RunPipeline)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAdmin)
|
||||
|
||||
@@ -48,6 +48,7 @@ type ScoreFilter struct {
|
||||
TsCode string
|
||||
Start string
|
||||
End string
|
||||
Signal string
|
||||
Limit int
|
||||
}
|
||||
|
||||
@@ -69,6 +70,10 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
|
||||
q += " AND trade_date <= " + next()
|
||||
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"
|
||||
if f.Limit <= 0 || f.Limit > 500 {
|
||||
f.Limit = 200
|
||||
|
||||
@@ -41,7 +41,7 @@ func main() {
|
||||
}
|
||||
|
||||
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")
|
||||
if err != nil {
|
||||
|
||||
@@ -36,6 +36,7 @@ function logout() {
|
||||
>
|
||||
<el-menu-item index="/scores">打分列表</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>
|
||||
</el-aside>
|
||||
|
||||
37
web/frontend/src/api/run.ts
Normal file
37
web/frontend/src/api/run.ts
Normal 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)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export interface ScoreListParams {
|
||||
ts_code?: string
|
||||
start?: string
|
||||
end?: string
|
||||
signal?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getScore, type Score } from '@/api/scores'
|
||||
import { parseTsCode } from '@/utils/contract'
|
||||
|
||||
const props = defineProps<{ scoreId: number | null }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
@@ -36,7 +37,8 @@ watch(
|
||||
<el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close>
|
||||
<div v-loading="loading" v-if="score">
|
||||
<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.close }}</el-descriptions-item>
|
||||
<el-descriptions-item label="持仓">{{ score.oi }}</el-descriptions-item>
|
||||
|
||||
@@ -19,6 +19,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'chart',
|
||||
component: () => import('@/views/ChartView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/run',
|
||||
name: 'run',
|
||||
component: () => import('@/views/RunView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'admin-users',
|
||||
|
||||
7
web/frontend/src/utils/contract.ts
Normal file
7
web/frontend/src/utils/contract.ts
Normal 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] }
|
||||
}
|
||||
169
web/frontend/src/views/RunView.vue
Normal file
169
web/frontend/src/views/RunView.vue
Normal 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>
|
||||
@@ -2,10 +2,17 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { listContracts, listScores, type Score } from '@/api/scores'
|
||||
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,
|
||||
range: [],
|
||||
signal: undefined,
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
@@ -22,6 +29,7 @@ async function reload() {
|
||||
ts_code: filter.ts_code,
|
||||
start: start || undefined,
|
||||
end: end || undefined,
|
||||
signal: filter.signal,
|
||||
limit: filter.limit,
|
||||
})
|
||||
} finally {
|
||||
@@ -29,6 +37,11 @@ async function reload() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSignal(s: string) {
|
||||
filter.signal = filter.signal === s ? undefined : s
|
||||
reload()
|
||||
}
|
||||
|
||||
function signalTagType(s: string) {
|
||||
if (s.includes('强烈看多')) return 'success'
|
||||
if (s.includes('偏多')) return ''
|
||||
@@ -74,12 +87,37 @@ onMounted(async () => {
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="reload">查询</el-button>
|
||||
</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-card>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe class="score-table">
|
||||
<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="oi" label="持仓" width="100" />
|
||||
<el-table-column prop="oi_chg" label="持仓变化" width="100" />
|
||||
|
||||
Reference in New Issue
Block a user