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
|
```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 → 重启。
|
||||||
|
|
||||||
|
|||||||
@@ -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 ...`。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(...),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
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
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 {
|
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 环境变量未设置")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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"),
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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
|
ts_code?: string
|
||||||
start?: string
|
start?: string
|
||||||
end?: string
|
end?: string
|
||||||
|
signal?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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 { 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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user