Compare commits

...

48 Commits

Author SHA1 Message Date
fish
947e227d5f 支撑位和阻力位列内容居中展示 2026-05-11 22:42:28 +08:00
fish
5dad7a6a02 表格支撑位和阻力位拆为四列:支撑位一/二、阻力位一/二 2026-05-11 22:21:21 +08:00
fish
b91bffdb4c 支撑位和阻力位改为逐个标注:表格用S1/R1标签,抽屉分开展示每层价位 2026-05-11 22:07:36 +08:00
fish
e756c3f300 列表页移除分析逻辑和风险提示列,改为查看按钮引导至抽屉明细 2026-05-11 21:44:46 +08:00
fish
bd48887b88 日内方向分析详情改用抽屉面板展示,点击表格行查看完整逻辑与风险提示 2026-05-11 21:39:21 +08:00
fish
5c30bfa472 日内方向分析按钮自动先执行批量打分再调用AI分析 2026-05-11 21:23:53 +08:00
fish
f5615d9580 新增日内方向分析功能:基于三层打分数据由 AI 批量生成下一个交易日方向判断 2026-05-11 21:18:29 +08:00
fish
6ab310cfb3 AI报告第4点改为支撑与阻力专节,喂入30日K线数据,Go构建加goproxy镜像 2026-05-10 18:03:10 +08:00
fish
c47735f3b6 AI分析Markdown样式优化:紧凑段落间距、标题上下留白、npm淘宝镜像 2026-05-10 17:45:58 +08:00
fish
1d1a6d6cdf AI分析Markdown渲染间距优化,去除pre-wrap双重间距;Dockerfile npm换国内镜像
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 17:32:49 +08:00
fish
04636c53d8 AI分析内容支持Markdown渲染,标题、列表、加粗正常显示 2026-05-10 17:21:52 +08:00
fish
4cdc542291 修复AI分析SSE流断开:中间件透传Flusher、DB Key生效、错误提示不覆盖 2026-05-10 17:11:51 +08:00
fish
1094e82e88 品种打分结果卡增加AI分析入口,打完分可直接问AI 2026-05-10 16:31:26 +08:00
fish
99c2a5bcbf AI分析功能:LLM Key 改为数据库管理,支持管理员后台配置 2026-05-10 16:21:15 +08:00
fish
ad9edf7ad4 K线图参数标题汉化:Y轴标签、Tooltip OHLC 字段改为中文 2026-05-10 15:59:36 +08:00
fish
819b327cdb 打分算法全面改进:修复方向性bug,引入自适应权重与分数动量 2026-05-10 15:52:03 +08:00
fish
f2e4bf7041 打分请求超时时间延长至 60 秒
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:19:37 +08:00
fish
e6351750cf 打分列表改为品种打分,移除同步数据功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:19:00 +08:00
fish
465feaa833 修复 NaN 值导致 JSON 序列化失败、K 线数据无法展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:02:39 +08:00
fish
f7b60659ab 修复 Tushare 返回的 ts_code 缺少交易所后缀导致查不到数据
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:56:19 +08:00
fish
fef806f796 合约全景刷新时自动拉取打分,并同步回写完整合约代码
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:49:33 +08:00
fish
512d43121c 合约全景功能独立为单独菜单,与原 K 线页面分离
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:44:46 +08:00
fish
01309dd8ff 支持短合约代码自动补全交易所后缀
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:31:45 +08:00
fish
ee3acd1c4d 新增合约全历史拉取与 K 线打分叠加功能
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:29:00 +08:00
fish
9d2997a3cb 手动打分页面移除主力合约展示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:58:33 +08:00
fish
cdf793608d 区间打分根据用户选择日期自动判断主力合约,放宽前端日期限制支持历史区间
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:48:20 +08:00
fish
c54ba5a470 修复区间打分 422 错误:Go 后端新增 runRangeRequest 结构体正确透传 start_date/end_date
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:41:51 +08:00
fish
01edda923a 新增自定义时间段批量打分功能:支持设置日期区间,对区间内每天自动拉取数据并打分
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:37:20 +08:00
fish
7aa74dc9bc 同步数据功能改为仅管理员可见
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 15:07:57 +08:00
fish
b1f824b06d 批量打分接口超时时间从15s增加到180s,避免6品种同步时超时
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:29:48 +08:00
fish
d217628ccf 文档同步:补充同步数据、数据重置功能说明
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:27:45 +08:00
fish
cff4319321 新增手动同步数据功能:点击侧边栏"同步数据"调用批量打分接口,完成后跳转打分列表并刷新
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:26:04 +08:00
fish
a7e1c9f416 重置成功后自动跳转打分列表并刷新页面
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:22:33 +08:00
fish
fa5fa07ef6 新增数据重置功能:管理员可一键清空所有行情数据,需输入确认文字防误操作
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:20:07 +08:00
fish
7b6732488a 同步文档:更新三层打分模型描述,反映短期幅度因子、中期连续化、长期价格维度、波动率调整等新逻辑
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:08:20 +08:00
fish
76ddc495c1 优化三层打分模型:短期引入幅度因子与量能确认,中期资金信号连续化,长期加入价格维度,新增波动率调整
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 22:57:45 +08:00
fish
23a1149c5f 打分列表信号列增加趋势图标 2026-05-04 21:42:56 +08:00
fish
b6bacbfae9 适配移动端展示 2026-05-04 21:36:48 +08:00
fish
c852b1d871 同步批量打分接口文档 2026-05-03 22:31:19 +08:00
fish
6a1541ad9c 同步文档与实际代码状态 2026-05-03 22:16:57 +08:00
fish
944fa90e0d 新增批量打分接口 2026-05-03 22:09:46 +08:00
fish
e37a51cac6 快捷切换不触发查询按钮 loading 2026-05-03 22:03:36 +08:00
fish
066e139910 固定 compose 项目名称为 trade 2026-05-03 21:39:29 +08:00
fish
ac684ae6ff 重命名 docker-compose 文件 2026-05-03 21:27:59 +08:00
fish
ff4c6b2258 移除冗余环境变量 2026-05-03 21:17:18 +08:00
fish
ad66aec880 固定查询按钮宽度 2026-05-03 21:14:31 +08:00
fish
526903bf4a 消除快捷按钮点击闪动 2026-05-03 21:10:08 +08:00
fish
5d0299c6e0 关闭登录页背景滑动 2026-05-03 21:07:07 +08:00
43 changed files with 3780 additions and 453 deletions

View File

@@ -10,31 +10,34 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash ```bash
# === 启动全栈服务(PostgreSQL + tushare API + web) === # === 启动全栈服务(PostgreSQL + tushare API + web) ===
docker-compose up -d docker-compose -f docker-compose.trade.yml up -d
# === tushare CLI(不传参 = 按当月 FG 主力自动选合约,轮换规则见 contracts.py:ROLLOVER_RULES) === # === tushare CLI(不传参 = 按当月 FG 主力自动选合约,轮换规则见 contracts.py:ROLLOVER_RULES) ===
docker-compose run --rm tushare python -m src.main docker-compose -f docker-compose.trade.yml 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 -f docker-compose.trade.yml run --rm tushare python -m src.main RB2510.SHF
# 用品种代号自动选当月主力(目前只配置了 FG) # 用品种代号自动选当月主力(目前只配置了 FG)
docker-compose run --rm tushare python -m src.main --symbol FG docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main --symbol FG
# === tushare API 服务(容器内运行 uvicorn,端口 8000) === # === tushare API 服务(容器内运行 uvicorn,端口 8000) ===
# 触发单次流水线 # 触发单次流水线
curl -X POST http://localhost:8000/api/v1/run -H "Content-Type: application/json" \ curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}' -d '{"symbol":"FG"}'
# 批量触发所有固定品种今日打分
curl -X POST http://localhost:4001/api/v1/run/batch
# 查询打分列表 # 查询打分列表
curl "http://localhost:8000/api/v1/scores?limit=5" curl "http://localhost:4001/api/v1/scores?limit=5"
# === 修改代码后必须重建镜像 === # === 修改代码后必须重建镜像 ===
docker-compose build tushare docker-compose -f docker-compose.trade.yml build tushare
docker-compose build web docker-compose -f docker-compose.trade.yml build web
# === 查最新打分(PostgreSQL) === # === 查最新打分(PostgreSQL) ===
docker-compose exec postgres psql -U trade -d futures -c \ docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d futures -c \
"SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;" "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;"
``` ```
@@ -42,11 +45,11 @@ docker-compose exec postgres psql -U trade -d futures -c \
**单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores)`。无后台任务、无队列,每次 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` 逻辑。 **FastAPI 服务**(`src.api`):容器默认以 `uvicorn src.api:app` 启动,暴露 `/api/v1/run`(触发流水线)、`/api/v1/run/batch`(批量打分)、`/api/v1/scores``/api/v1/scores/{id}``/api/v1/contracts``/api/v1/candles``/api/v1/admin/reset-data`(清空行情数据) 等端点。启动时自动 `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)) × 波动率惩罚系数。短期引入幅度因子(价格/OI变化率)和量能确认,按象限(增仓涨/跌、减仓涨/跌、持平)给基础分再叠加加成;中期资金意愿连续化(50 + (增仓涨-增仓跌)/15×50);长期加入30日价格趋势分与OI趋势分 4:6 合成;高波动品种综合分打 85-100 折`score_daily()` 要求 DataFrame ≥31 行,`fetcher.fetch_contract` 默认拉一个合约的全历史(实际 100+ 行),按 `trade_date` 升序排列后供打分使用。打分结果通过 `dataclass ScoreResult` + `ScoreDetail` 流转,`storage.save_score` 把 detail 序列化为 `detail_json` 文本列。
**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`)。 **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`)。
@@ -60,29 +63,32 @@ docker-compose exec postgres psql -U trade -d futures -c \
## Web 模块(报告浏览端) ## Web 模块(报告浏览端)
`./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 PostgreSQL,web 只读访问业务数据、读写 `auth.db`。docker-compose 上是 `web` 服务,与 `tushare`/`postgres` 共存不互相依赖。 `./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 PostgreSQL,web 读写 PostgreSQL(业务数据 + 用户表)。docker-compose 上是 `web` 服务,与 `tushare`/`postgres` 共存不互相依赖。
**架构与边界**: **架构与边界**:
- 后端 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。 - 后端 Go(`golang:1.25.8-alpine3.23` 构建,`alpine:3.23` 运行),路由 `chi`,数据库驱动 `github.com/lib/pq`(PostgreSQL),业务数据与用户鉴权统一由 PostgreSQL 管理。前端 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` (PostgreSQL)通过 `DATABASE_URL` 只读访问;`auth.db`(SQLite)由 web 自己 init/写入,落在 `./data/auth.db`(已被 `.gitignore` 覆盖),容器挂载 `./data:/app/auth` - 统一 DB:业务数据与用户鉴权数据均存储在 PostgreSQL `futures` 数据库中,通过 `DATABASE_URL` 访问`auth.db`(SQLite)已废弃,`users` 表现在由 `AuthStore` 直接管理在 PostgreSQL 中
- 鉴权 JWT(HS256, Bearer header),12h 过期,无 sessions 表。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。 - 鉴权已简化:登录接口返回固定 token,`middleware.RequireUser` 直接注入默认管理员上下文,所有请求放行。后端仍保留密码校验与角色检查(`RequireAdmin`)。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb` - 前端支持暗/浅色模式切换(`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 行,则自动创建默认管理员 `admin` / `admin`,并标记强制首次登录后改密。忘记管理员密码的恢复方式:停服 → 清理 PostgreSQL 中的 admin 记录 → 重启。
**修改即重建**:沿用 `tushare` 服务的约定,`web/backend/``web/frontend/` 都通过镜像 COPY 进容器,**没有源码挂载**。改完 Go/Vue 代码不重建镜像就跑等于跑旧代码。重建命令 `docker-compose build web` **鉴权已简化**:当前 `middleware.RequireUser` 直接注入默认管理员用户到上下文,所有请求放行;`handlers.Login` 返回固定 token (`"noop"`),不再使用 JWT。后端仍保留登录校验密码和角色检查(`RequireAdmin`),但 token 本身已不做签名验证。前端保持原有登录流程和 localStorage token 持久化不变
**修改即重建**:沿用 `tushare` 服务的约定,`web/backend/``web/frontend/` 都通过镜像 COPY 进容器,**没有源码挂载**。改完 Go/Vue 代码不重建镜像就跑等于跑旧代码。重建命令 `docker-compose -f docker-compose.trade.yml build web`
**常用命令**: **常用命令**:
```bash ```bash
# 首启需先在 web/backend/.env 写 ADMIN_USER/ADMIN_PASS/JWT_SECRET (gitignored) # 启动 web 服务
docker-compose up -d web docker-compose -f docker-compose.trade.yml up -d web
docker-compose logs -f web # 看 [bootstrap] 日志确认 admin 是否被创建 docker-compose -f docker-compose.trade.yml logs -f web # 看 [bootstrap] 日志确认 admin 是否被创建
# 仅重建 web,不影响 tushare # 仅重建 web,不影响 tushare
docker-compose build web && docker-compose up -d web docker-compose -f docker-compose.trade.yml build web && docker-compose -f docker-compose.trade.yml up -d web
# 本地开发 (后端 + 前端分别起,api 走代理) # 本地开发 (后端 + 前端分别起,api 走代理)
cd web/backend && go run ./ # 需要本地 Go 1.25.8;dist/ 目录的占位会被 embed cd web/backend && go run ./ # 需要本地 Go 1.25.8;dist/ 目录的占位会被 embed
cd web/frontend && npm install && npm run dev # 默认 5173 端口,/api 代理到 8080 cd web/frontend && npm install && npm run dev # 默认 5173 端口,/api 代理到 4000
``` ```

174
README.md
View File

@@ -13,7 +13,7 @@
### 1. 启动全栈服务 ### 1. 启动全栈服务
```bash ```bash
docker-compose up -d docker-compose -f docker-compose.trade.yml up -d
``` ```
这会同时启动 PostgreSQL、tushare API 服务(端口 8000)与 Web 浏览端(端口 8080)。 这会同时启动 PostgreSQL、tushare API 服务(端口 8000)与 Web 浏览端(端口 8080)。
@@ -21,7 +21,7 @@ docker-compose up -d
### 3. 通过 CLI 跑当月主力 ### 3. 通过 CLI 跑当月主力
```bash ```bash
docker-compose run --rm tushare python -m src.main docker-compose -f docker-compose.trade.yml 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 当月主力 -> ...`,然后:
@@ -35,28 +35,34 @@ docker-compose run --rm tushare python -m src.main
```bash ```bash
# 触发 FG 打分 # 触发 FG 打分
curl -X POST http://localhost:8000/api/v1/run -H "Content-Type: application/json" \ curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}' -d '{"symbol":"FG"}'
# 批量触发所有固定品种今日打分
curl -X POST http://localhost:4001/api/v1/run/batch
# 查询最新打分 # 查询最新打分
curl "http://localhost:8000/api/v1/scores?limit=5" curl "http://localhost:4001/api/v1/scores?limit=5"
# 查询合约列表 # 查询合约列表
curl "http://localhost:8000/api/v1/contracts" curl "http://localhost:4001/api/v1/contracts"
# 查询 K 线数据 # 查询 K 线数据
curl "http://localhost:8000/api/v1/candles?ts_code=FG2609.ZCE" curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
# 清空所有行情数据(谨慎操作)
curl -X POST http://localhost:4001/api/v1/admin/reset-data
``` ```
### 5. 跑其他合约或品种 ### 5. 跑其他合约或品种
```bash ```bash
# 显式指定合约 # 显式指定合约
docker-compose run --rm tushare python -m src.main RB2510.SHF docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main RB2510.SHF
docker-compose run --rm tushare python -m src.main I2601.DCE docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main I2601.DCE
# 按品种代号自动选当月主力(目前只配置了 FG) # 按品种代号自动选当月主力(目前只配置了 FG)
docker-compose run --rm tushare python -m src.main --symbol FG docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main --symbol FG
``` ```
### 6. 玻璃 FG 主力轮换规则 ### 6. 玻璃 FG 主力轮换规则
@@ -73,32 +79,39 @@ docker-compose run --rm tushare python -m src.main --symbol FG
### 综合分数公式 ### 综合分数公式
``` ```
综合分数 = (短期动力 × 0.4) + (中期趋势 × 0.35) + (长期结构 × 0.25) 综合分数 = (短期动力 × 0.4 + 中期趋势 × 0.35 + 长期结构 × 0.25) × 波动率惩罚系数
``` ```
### 1. 短期动力7 日窗口,权重 0.4 ### 1. 短期动力7 日窗口,权重 0.4
逐日打分后取均值 逐日打分后取均值。每日评分 = (象限基础分 + 幅度加成) × 量能确认,产出 0-100 连续值。
| 持仓变化 | 价格方向 | 得分 | **象限基础分**(持仓与价格方向):
|---------|---------|------|
| 增仓 | 上涨 | 100多头主动进攻 | | 象限 | 持仓变化 | 价格方向 | 基础分 |
| 增仓 | 下跌 | 0空头主动进攻 | |------|---------|---------|--------|
| 仓 | 上涨 | 70空头撤退 | | accumulation增仓上涨 | 增仓 | 上涨 | 75 |
| 仓 | 下跌 | 30多头撤退 | | distribution增仓下跌 | 增仓 | 下跌 | 25 |
| 持平(\|变化\|<1% | 上涨 | 60 | | covering减仓上涨 | 减仓 | 上涨 | 65 |
| 持平(\|变化\|<1% | 下跌 | 40 | | liquidation减仓下跌 | 减仓 | 下跌 | 20 |
| flat持平 | \|变化\|<1% | 上涨 | 60 |
| flat持平 | \|变化\|<1% | 下跌 | 40 |
**幅度加成**(根据 OI 变化率和涨跌幅放大有利方向得分):
- OI 变化率封顶 5%,价格涨跌幅封顶 3%
- 增仓上涨 / 减仓下跌(有利方向):加成 = (OI 幅度 + 价格幅度) / 2 × 20
- 持仓持平:加成 = 价格幅度 × 10
- 增仓下跌 / 减仓上涨(不利方向):无加成
**量能确认**`量比 = 当日成交量 / 7 日均量`,系数范围 [0.9, 1.2],量比 1.5 以上封顶
### 2. 中期趋势15 日窗口,权重 0.35 ### 2. 中期趋势15 日窗口,权重 0.35
``` ```
价格信号 = (今收 - 15日前收) / 15日前收 价格收益率 = (今收 - 15日前收) / 15日前收
价格信号分 = clamp(50 + 收益率×500, 0, 100) 价格信号分 = clamp(50 + 收益率 × 500, 0, 100)
资金意愿: 资金意愿 = 50 + (增仓上涨天数 - 增仓下跌天数) / 15 × 50 (连续值 0-100
增仓上涨天数 > 增仓下跌天数 → 80
两者相当 → 50
增仓下跌天数 > 增仓上涨天数 → 20
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4 模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
``` ```
@@ -106,15 +119,23 @@ docker-compose run --rm tushare python -m src.main --symbol FG
### 3. 长期结构30 日窗口,权重 0.25 ### 3. 长期结构30 日窗口,权重 0.25
``` ```
持仓变化幅度 = (30日日均持仓 - 30日前持仓) / 30日前持仓 OI 趋势分 = clamp(50 + OI变化幅度 × 250, 0, 100) (权重 60%
价格趋势分 = clamp(50 + 30日价格收益率 × 200, 0, 100) (权重 40%
> 10% → 90显著增仓 模块得分 = OI 趋势分 × 0.6 + 价格趋势分 × 0.4
5%~10% → 70温和增仓
-5%~5% → 50基本持平
-10%~-5% → 30温和减仓
< -10% → 10显著减仓
``` ```
### 4. 波动率调整
基于近 30 日日收益率标准差和 ATR%(平均真实波幅/均价):
```
日波动率 ≤ 1.5% → 惩罚系数 = 1.0(无惩罚)
日波动率 > 1.5% → 惩罚系数 = max(0.85, 1.0 - (日波动率 - 1.5%) × 10)
```
高波动品种的综合分会被适当打折,最低打 85 折。
### 信号解读 ### 信号解读
| 综合分数 | 信号 | | 综合分数 | 信号 |
@@ -130,27 +151,27 @@ docker-compose run --rm tushare python -m src.main --symbol FG
```bash ```bash
# 查看最新打分 # 查看最新打分
docker-compose exec postgres psql -U trade -d futures -c \ docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d futures -c \
"SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;" "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 \ docker-compose -f docker-compose.trade.yml 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;" "SELECT trade_date, open, high, low, close, vol, oi FROM candles WHERE ts_code='FG2609.ZCE' ORDER BY trade_date DESC LIMIT 10;"
# 或通过 API 查询 # 或通过 API 查询
curl "http://localhost:8000/api/v1/scores?ts_code=FG2609.ZCE&limit=10" curl "http://localhost:4001/api/v1/scores?ts_code=FG2609.ZCE&limit=10"
curl "http://localhost:8000/api/v1/candles?ts_code=FG2609.ZCE" curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
``` ```
## 项目结构 ## 项目结构
``` ```
trade/ trade/
├── docker-compose.yml # Docker Compose 编排(postgres + tushare + web) ├── docker-compose.trade.yml # Docker Compose 编排(postgres + tushare + web)
├── 使用说明.md # 本文件 ├── README.md # 本文件
├── CLAUDE.md # Claude Code 项目指引 ├── CLAUDE.md # Claude Code 项目指引
├── data/ # auth.db 目录(gitignored) ├── data/ # 数据目录(gitignored)
│ └── auth.db # web 自己维护的用户表(SQLite) │ └── (运行时生成)
├── .gitignore # Git 忽略配置 ├── .gitignore # Git 忽略配置
├── tushare/ # Python 数据服务 ├── tushare/ # Python 数据服务
│ ├── Dockerfile │ ├── Dockerfile
@@ -165,17 +186,17 @@ trade/
│ └── main.py # CLI 入口 │ └── main.py # CLI 入口
└── web/ # Web 浏览端 └── web/ # Web 浏览端
├── .dockerignore ├── .dockerignore
├── backend/ # Go 1.25 后端 (chi + lib/pq + JWT) ├── backend/ # Go 1.25 后端 (chi + lib/pq)
│ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行 │ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
│ ├── go.mod │ ├── go.mod
│ ├── main.go │ ├── main.go
│ ├── embed.go # //go:embed all:dist │ ├── embed.go # //go:embed all:dist
│ ├── .env.example # ADMIN_USER/ADMIN_PASS/JWT_SECRET 示例 │ ├── go.sum
│ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖 │ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
│ └── internal/ │ └── internal/
│ ├── config/ # 环境变量加载 │ ├── config/ # 环境变量加载
│ ├── store/ # PostgreSQL 业务查询 + SQLite auth.db │ ├── store/ # PostgreSQL 业务查询 + 用户管理
│ ├── auth/ # JWT + bcrypt + 首启 admin 引导 │ ├── auth/ # bcrypt + 首启 admin 引导
│ ├── middleware/ # RequireUser / RequireAdmin / 日志 │ ├── middleware/ # RequireUser / RequireAdmin / 日志
│ ├── handlers/ # 登录 / 打分 / K线 / 用户管理 │ ├── handlers/ # 登录 / 打分 / K线 / 用户管理
│ └── router/ # chi 路由装配 │ └── router/ # chi 路由装配
@@ -196,10 +217,10 @@ trade/
## 技术栈 ## 技术栈
- **Python 3.13** (alpine) + **tushare** + **pandas** + **FastAPI** + **psycopg3** — 数据采集、打分与 API 服务 - **Python 3.13** (alpine) + **tushare** + **pandas** + **FastAPI** + **psycopg3** — 数据采集、打分与 API 服务
- **Go 1.25.8** (alpine 3.23) + **chi** + **lib/pq** + **JWT** — Web 后端 - **Go 1.25.8** (alpine 3.23) + **chi** + **lib/pq** — Web 后端
- **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端 - **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端
- **PostgreSQL 18.3** (alpine 3.23) — 业务数据存储 - **PostgreSQL 18.3** (alpine 3.23) — 业务数据存储
- **SQLite** — 鉴权数据存储(auth.db) - **PostgreSQL** — 业务数据与用户鉴权数据统一存储
- **Docker / Docker Compose** — 容器化部署 - **Docker / Docker Compose** — 容器化部署
## 常见问题 ## 常见问题
@@ -214,40 +235,31 @@ A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大
**Q: 如何定时自动跑?** **Q: 如何定时自动跑?**
A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose run --rm tushare ...`。也可直接调用 API: `curl -X POST http://localhost:8000/api/v1/run ...` A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose -f docker-compose.trade.yml run --rm tushare ...`。也可直接调用 API: `curl -X POST http://localhost:4001/api/v1/run ...` 或批量接口 `curl -X POST http://localhost:4001/api/v1/run/batch`
## Web 报表(浏览端) ## Web 报表(浏览端)
`./web/` 提供一个图形化的浏览端,展示 tushare 流水线写入 PostgreSQL 的打分与行情数据。后端 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. 启动
`web/backend/.env` 写入(`.env` 已 gitignored,可参考 `web/backend/.env.example`):
```bash
ADMIN_USER=admin
ADMIN_PASS=请改成强密码
JWT_SECRET=$(openssl rand -hex 32)
```
`ADMIN_USER`/`ADMIN_PASS` 仅在 `auth.db` 中没有任何 admin 时生效,首次启动会以这一对凭据建立管理员;之后即使改这两个变量也不会改密。`JWT_SECRET` 必须 ≥16 字符。
### 2. 启动
```bash ```bash
# 构建并启动 web 服务,不影响现有 tushare # 构建并启动 web 服务,不影响现有 tushare
docker-compose up -d --build web docker-compose -f docker-compose.trade.yml up -d --build web
# 查看启动日志:首启会出现 [bootstrap] admin 'xxx' created # 查看启动日志:首启会出现 [bootstrap] admin created
docker-compose logs -f web docker-compose -f docker-compose.trade.yml logs -f web
``` ```
浏览器访问 `http://localhost:8080`,用上一步的管理员账号登录 浏览器访问 `http://localhost:4000`。首次启动时系统会自动创建默认管理员账号 `admin` / `admin`,首次登录后系统会强制要求修改密码
### 3. 页面说明 ### 3. 页面说明
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分、中期(15d)价格收益与资金意愿、长期(30d)持仓变化 - **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分(涨跌幅/OI变化%/量比/象限)、中期(15d)价格收益与资金意愿、长期(30d)OI/价格趋势分与波动率调整
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。 - **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
- **同步数据**(侧边栏):点击调用批量打分接口,对所有固定品种执行当日打分,完成后自动跳转并刷新打分列表。
- **手动打分** `/run`:选品种 + 日期,对单个合约执行数据拉取与打分。
- **数据重置**(侧边栏,仅管理员):输入确认文字后清空所有行情数据candles + scores用户表不受影响。
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。 - **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。 普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
@@ -258,45 +270,39 @@ docker-compose logs -f web
1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。 1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
2. 把账号发给同事即可登录;无注册入口。 2. 把账号发给同事即可登录;无注册入口。
3. 离职 / 风险事件:用「禁用」临时停用(token 立即失效,前端不能再请求),或「删除」彻底清除。 3. 离职 / 风险事件:用「禁用」临时停用(被禁用的账号将无法登录),或「删除」彻底清除。
### 5. 数据流向与数据库分离 ### 5. 数据流向与数据库
``` ```
tushare(写) → PostgreSQL futures 数据库 ←(读)── web 后端 tushare(写) → PostgreSQL futures 数据库 ←(读)── web 后端
web 后端 ←(读写)→ data/auth.db (SQLite)
``` ```
业务数据(`candles` + `scores`)统一存储在 PostgreSQL 中,`auth.db`: 业务数据(`candles` + `scores`)与用户鉴权数据(`users`)统一存储在 PostgreSQL `futures` 数据库中。`users`结构:
```sql ```sql
users(id, username UNIQUE, password_hash, role IN ('admin','user'), users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, password_hash TEXT,
disabled, created_at, updated_at) role TEXT CHECK(role IN ('admin','user')), disabled BOOLEAN,
force_password_change BOOLEAN, created_at TEXT, updated_at TEXT)
``` ```
`auth.db``./data/` 目录,被 `.gitignore` 覆盖。
### 6. 常见问题 ### 6. 常见问题
**Q: 忘记管理员密码怎么办?** **Q: 忘记管理员密码怎么办?**
```bash ```bash
docker-compose stop web docker-compose -f docker-compose.trade.yml stop web
sqlite3 data/auth.db "DELETE FROM users WHERE role='admin';" docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d futures -c \
# 修改 web/backend/.env 里的 ADMIN_USER/ADMIN_PASS "DELETE FROM users WHERE role='admin';"
docker-compose up -d web docker-compose -f docker-compose.trade.yml up -d web
``` ```
启动时会重新触发 bootstrap 写入新的 admin 启动时会重新触发 bootstrap 写入新的默认管理员 `admin` / `admin`
**Q: 改了 Go / Vue 代码但页面没变?** **Q: 改了 Go / Vue 代码但页面没变?**
源码不挂载,镜像内是 COPY 进去的。重建:`docker-compose build web && docker-compose up -d web` 源码不挂载,镜像内是 COPY 进去的。重建:`docker-compose -f docker-compose.trade.yml build web && docker-compose -f docker-compose.trade.yml up -d web`
**Q: 登录提示 "JWT_SECRET 必须至少 16 个字符"?**
`web/backend/.env` 没设或太短,用 `openssl rand -hex 32` 生成一个 64 字符的十六进制字符串即可。
**Q: 为什么 tushare 容器启动后没有立即退出?** **Q: 为什么 tushare 容器启动后没有立即退出?**
因为默认命令改为 `uvicorn src.api:app` 常驻 API 服务。如需执行单次 CLI,用 `docker-compose run --rm tushare python -m src.main ...` 因为默认命令改为 `uvicorn src.api:app` 常驻 API 服务。如需执行单次 CLI,用 `docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main ...`

View File

@@ -1,3 +1,5 @@
name: trade
services: services:
postgres: postgres:
image: postgres:18.3-alpine3.23 image: postgres:18.3-alpine3.23
@@ -34,7 +36,6 @@ services:
container_name: trade-web container_name: trade-web
# .env 已移除,环境变量直接写在此处 # .env 已移除,环境变量直接写在此处
environment: environment:
- LISTEN_ADDR=:8080
- DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable - DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable
depends_on: depends_on:
- postgres - postgres

View File

@@ -1,4 +1,5 @@
tushare>=1.4.0 tushare>=1.4.0
numpy>=2.0.0
pandas>=2.2.0 pandas>=2.2.0
fastapi>=0.115.0 fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0

View File

@@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from datetime import date from datetime import date, datetime, timedelta
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
@@ -16,6 +16,16 @@ class RunRequest(BaseModel):
trade_date: Optional[str] = None trade_date: Optional[str] = None
class RunRangeRequest(BaseModel):
symbol: str = "FG"
start_date: str
end_date: str
class RunFullRequest(BaseModel):
ts_code: str
class RunResponse(BaseModel): class RunResponse(BaseModel):
ts_code: str ts_code: str
trade_date: str trade_date: str
@@ -27,6 +37,9 @@ class RunResponse(BaseModel):
long_term: float long_term: float
composite: float composite: float
signal: str signal: str
vol_penalty: float = 1.0
composite_delta: Optional[float] = None
composite_delta_5d: Optional[float] = None
@app.on_event("startup") @app.on_event("startup")
@@ -41,8 +54,11 @@ def health():
@app.post("/api/v1/run", response_model=RunResponse) @app.post("/api/v1/run", response_model=RunResponse)
def run_pipeline(req: RunRequest): def run_pipeline(req: RunRequest):
ts_code = req.ts_code or contracts.active_contract(req.symbol) ref_date = datetime.strptime(req.trade_date, "%Y%m%d").date() if req.trade_date else None
if not req.ts_code: ts_code = req.ts_code or contracts.active_contract(req.symbol, ref_date)
if req.ts_code:
ts_code = contracts.normalize_ts_code(ts_code)
else:
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
@@ -61,9 +77,104 @@ def run_pipeline(req: RunRequest):
long_term=result.long_term, long_term=result.long_term,
composite=result.composite, composite=result.composite,
signal=result.signal, signal=result.signal,
vol_penalty=result.vol_penalty,
composite_delta=result.composite_delta,
composite_delta_5d=result.composite_delta_5d,
) )
@app.post("/api/v1/run/batch")
def run_batch():
"""对所有固定品种执行今日打分。"""
results = []
errors = []
for symbol in contracts.SYMBOLS:
try:
ts_code = contracts.active_contract(symbol)
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
result = scorer.score_daily(df)
storage.save_score(result)
results.append({
"symbol": symbol,
"ts_code": result.ts_code,
"trade_date": result.trade_date,
"signal": result.signal,
"composite": result.composite,
})
except Exception as e:
errors.append({"symbol": symbol, "error": str(e)})
return {"results": results, "errors": errors}
@app.post("/api/v1/run/range")
def run_range(req: RunRangeRequest):
"""对指定日期区间内的每一天分别打分。"""
start_dt = datetime.strptime(req.start_date, "%Y%m%d").date()
ts_code = contracts.active_contract(req.symbol, start_dt)
# 为确保区间开始日有足够前置数据,拉取时 start_date 前推 60 天
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=req.end_date)
storage.save_candles(df)
results, warnings = scorer.score_range(df, req.start_date, req.end_date)
for r in results:
storage.save_score(r)
return {
"ts_code": ts_code,
"start_date": req.start_date,
"end_date": req.end_date,
"scored": len(results),
"skipped": len(warnings),
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.post("/api/v1/run/full")
def run_full(req: RunFullRequest):
"""拉取指定合约全部历史数据,保存 candles对所有可打分日期逐日打分并保存。"""
ts_code = contracts.normalize_ts_code(req.ts_code)
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
results, warnings, total_days, scored_count = scorer.score_all(df)
for r in results:
storage.save_score(r)
skipped_count = total_days - scored_count
return {
"ts_code": ts_code,
"total_days": total_days,
"scored_count": scored_count,
"skipped_count": skipped_count,
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.get("/api/v1/scores") @app.get("/api/v1/scores")
def list_scores( def list_scores(
ts_code: Optional[str] = Query(None), ts_code: Optional[str] = Query(None),
@@ -169,3 +280,10 @@ def list_candles(
return [dict(zip(cols, row)) for row in cur.fetchall()] return [dict(zip(cols, row)) for row in cur.fetchall()]
finally: finally:
conn.close() conn.close()
@app.post("/api/v1/admin/reset-data")
def reset_data():
"""清空所有行情数据(仅限管理员调用)。"""
storage.truncate_all()
return {"status": "ok", "message": "已清空所有行情数据"}

View File

@@ -66,6 +66,18 @@ ROLLOVER_RULES: dict[str, dict] = {
} }
def normalize_ts_code(ts_code: str) -> str:
"""把用户输入的短代码(如 FG2509)补全为带交易所后缀的完整代码(如 FG2509.ZCE)。"""
if "." in ts_code:
return ts_code
# 提取品种代码: 前两个字母
symbol = ts_code[:2]
if symbol not in ROLLOVER_RULES:
raise ValueError(f"未配置 {symbol} 的品种规则,无法自动补全交易所后缀")
exchange = ROLLOVER_RULES[symbol]["exchange"]
return f"{ts_code}.{exchange}"
def active_contract(symbol: str, today: Optional[date] = None) -> str: def active_contract(symbol: str, today: Optional[date] = None) -> str:
"""按主力轮换规则,返回当日 ts_code(含交易所后缀)。""" """按主力轮换规则,返回当日 ts_code(含交易所后缀)。"""
if symbol not in ROLLOVER_RULES: if symbol not in ROLLOVER_RULES:

View File

@@ -11,10 +11,19 @@ def _init_api():
return ts.pro_api() return ts.pro_api()
def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame: def fetch_contract(
ts_code: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> pd.DataFrame:
"""拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。""" """拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。"""
pro = _init_api() pro = _init_api()
df = pro.fut_daily(ts_code=ts_code) kwargs: dict = {"ts_code": ts_code}
if start_date:
kwargs["start_date"] = start_date
if end_date:
kwargs["end_date"] = end_date
df = pro.fut_daily(**kwargs)
if df.empty: if df.empty:
raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足") raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足")
@@ -25,6 +34,10 @@ def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame:
] ]
df = df[[c for c in cols if c in df.columns]].copy() df = df[[c for c in cols if c in df.columns]].copy()
# 确保 ts_code 列与查询时传入的完整代码一致Tushare 返回的可能不带交易所后缀)
if "ts_code" in df.columns:
df["ts_code"] = ts_code
numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"] numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"]
for c in numeric: for c in numeric:
if c in df.columns: if c in df.columns:

View File

@@ -1,5 +1,6 @@
import argparse import argparse
import sys import sys
from typing import Optional
from . import contracts, fetcher, scorer, storage from . import contracts, fetcher, scorer, storage
@@ -37,15 +38,19 @@ def run(ts_code: str, trade_date: Optional[str] = None) -> int:
print(f"\n信号: {result.signal}") print(f"\n信号: {result.signal}")
print("=" * 65) print("=" * 65)
quadrant_names = {
"accumulation": "增仓上涨", "distribution": "增仓下跌",
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
}
print("\n[短期动力] 近7日逐日打分:") print("\n[短期动力] 近7日逐日打分:")
print("-" * 65) print("-" * 80)
for d in result.detail.short_details: for d in result.detail.short_details:
tag = "增仓" if d["oi_chg"] > 0 else "减仓" q = quadrant_names.get(d["quadrant"], d["quadrant"])
if abs(d["oi_chg"] / d["oi"]) < 0.01: print(f" {d['trade_date']} {q} "
tag = "持平" f"涨跌{d['price_chg_pct']*100:>+.2f}% "
price_dir = "" if d["close"] >= d["pre_close"] else "" f"OI变化{d['oi_chg_pct']*100:>+.2f}% "
print(f" {d['trade_date']} {tag:>4} + {price_dir} " f"量比{d['vol_ratio']:.2f} "
f"持仓{d['oi_chg']:>+8,.0f} 得分: {d['score']:>3}") f"得分: {d['score']:>5.1f}")
md = result.detail.medium_detail md = result.detail.medium_detail
print(f"\n[中期趋势] 明细:") print(f"\n[中期趋势] 明细:")
@@ -53,19 +58,93 @@ def run(ts_code: str, trade_date: Optional[str] = None) -> int:
print(f" 价格信号得分: {md['price_signal']:.1f}") print(f" 价格信号得分: {md['price_signal']:.1f}")
print(f" 增仓上涨天数: {md['long_up_days']}") print(f" 增仓上涨天数: {md['long_up_days']}")
print(f" 增仓下跌天数: {md['long_down_days']}") print(f" 增仓下跌天数: {md['long_down_days']}")
print(f" 资金意愿得分: {md['fund_signal']}") print(f" 资金意愿得分: {md['fund_signal']:.1f}")
ld = result.detail.long_detail ld = result.detail.long_detail
print(f"\n[长期结构] 明细:") print(f"\n[长期结构] 明细:")
print(f" OI趋势得分: {ld['oi_score']:.1f} (权重 60%)")
print(f" 价格趋势得分: {ld['price_score']:.1f} (权重 40%)")
print(f" 30日价格收益: {ld['price_return_30d_pct']:+.2f}%")
print(f" 30日前收盘价: {ld['price_before_30d']:.2f}")
print(f" 近30日日均持仓: {ld['avg_oi']:,.0f}") print(f" 近30日日均持仓: {ld['avg_oi']:,.0f}")
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}%")
vd = result.detail.volatility
print(f"\n[波动率调整]")
print(f" 日波动率(30d std): {vd['daily_vol_pct']*100:.2f}%")
print(f" ATR%: {vd['atr_pct']*100:.2f}%")
vol_risk = vd.get("vol_risk", vd["daily_vol_pct"])
print(f" 综合波动风险: {vol_risk*100:.2f}%")
print(f" 惩罚系数: {vd['vol_penalty']:.3f}")
aw = result.detail.adaptive_weights
if aw:
print(f"\n[自适应权重]")
print(f" 趋势强度: {aw['trend_strength']:.2f}")
print(f" 短期权重: {aw['w_short']:.2%} (基准 40%)")
print(f" 中期权重: {aw['w_medium']:.2%} (基准 35%)")
print(f" 长期权重: {aw['w_long']:.2%} (基准 25%)")
if result.composite_delta is not None:
print(f"\n[分数动量]")
print(f" 日变化 (Δ1d): {result.composite_delta:+.1f}")
if result.composite_delta_5d is not None:
print(f" 周变化 (Δ5d): {result.composite_delta_5d:+.1f}")
print(f"\n[OK] 数据已持久化到 PostgreSQL") print(f"\n[OK] 数据已持久化到 PostgreSQL")
return 0 return 0
def run_range(ts_code: str, start_date: str, end_date: str) -> int:
from datetime import datetime, timedelta
storage.init_db()
print(f"[1/4] 拉取 {ts_code} 数据 ({start_date} ~ {end_date})...")
start_dt = datetime.strptime(start_date, "%Y%m%d")
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=end_date)
print(f" 返回 {len(df)}")
print(f"[2/4] 写入/更新 PostgreSQL...")
storage.save_candles(df)
print(f"[3/4] 批量计算打分...")
results, warnings = scorer.score_range(df, start_date, end_date)
print(f"[4/4] 保存打分结果...")
for r in results:
storage.save_score(r)
print("\n" + "=" * 65)
print(f"合约: {ts_code}")
print(f"区间: {start_date} ~ {end_date}")
print(f"成功打分: {len(results)}")
if warnings:
print(f"跳过: {len(warnings)}")
for w in warnings[:5]:
print(f" - {w}")
if len(warnings) > 5:
print(f" ... 还有 {len(warnings) - 5}")
print("=" * 65)
quadrant_names = {
"accumulation": "增仓上涨", "distribution": "增仓下跌",
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
}
print(f"\n{'日期':<12} {'收盘':>10} {'综合':>8} {'Δ1d':>6} {'Δ5d':>6} {'信号':<20}")
print("-" * 70)
for r in results:
d1 = f"{r.composite_delta:+.1f}" if r.composite_delta is not None else " -"
d5 = f"{r.composite_delta_5d:+.1f}" if r.composite_delta_5d is not None else " -"
print(f"{r.trade_date:<12} {r.close:>10.2f} {r.composite:>8.1f} {d1:>6} {d5:>6} {r.signal:<20}")
print(f"\n[OK] {len(results)} 条打分已持久化到 PostgreSQL")
return 0
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="期货合约三层打分模型") parser = argparse.ArgumentParser(description="期货合约三层打分模型")
parser.add_argument( parser.add_argument(
@@ -82,11 +161,26 @@ def main() -> int:
"--date", "--date",
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分", help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
) )
parser.add_argument(
"--start-date",
dest="start_date",
help="区间打分开始日期,格式 YYYYMMDD(与 --end-date 同时使用时生效)",
)
parser.add_argument(
"--end-date",
dest="end_date",
help="区间打分结束日期,格式 YYYYMMDD(与 --start-date 同时使用时生效)",
)
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}")
if args.start_date and args.end_date:
print(f"[RANGE] 区间打分: {args.start_date} ~ {args.end_date}")
return run_range(ts_code, args.start_date, args.end_date)
if args.date: if args.date:
print(f"[DATE] 指定打分日期: {args.date}") print(f"[DATE] 指定打分日期: {args.date}")
return run(ts_code, args.date) return run(ts_code, args.date)

View File

@@ -22,6 +22,8 @@ class ScoreDetail:
short_details: list = field(default_factory=list) short_details: list = field(default_factory=list)
medium_detail: dict = field(default_factory=dict) medium_detail: dict = field(default_factory=dict)
long_detail: dict = field(default_factory=dict) long_detail: dict = field(default_factory=dict)
volatility: dict = field(default_factory=dict)
adaptive_weights: dict = field(default_factory=dict)
@dataclass @dataclass
@@ -37,3 +39,6 @@ class ScoreResult:
composite: float composite: float
signal: str signal: str
detail: ScoreDetail detail: ScoreDetail
vol_penalty: float = 1.0
composite_delta: Optional[float] = None # 与前一日综合分差值
composite_delta_5d: Optional[float] = None # 与5日前综合分差值

View File

@@ -1,50 +1,110 @@
import math
from typing import Optional from typing import Optional
import numpy as np
import pandas as pd import pandas as pd
from .models import ScoreDetail, ScoreResult from .models import ScoreDetail, ScoreResult
def _daily_short_score(row: pd.Series) -> int: # ---------------------------------------------------------------------------
"""单日短期动力打分。""" # 短期动力 — 单日打分
# ---------------------------------------------------------------------------
def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict:
"""单日短期动力打分(连续值 + 方向性幅度加成 + 量能确认)。"""
oi = float(row["oi"]) oi = float(row["oi"])
oi_chg = float(row["oi_chg"]) oi_chg = float(row["oi_chg"])
close = float(row["close"]) close = float(row["close"])
pre_close = float(row["pre_close"]) pre_close = float(row["pre_close"])
vol = float(row.get("vol", 0))
oi_change_pct = abs(oi_chg / oi) if oi != 0 else 0 oi_chg_pct = oi_chg / oi if oi != 0 else 0.0
price_chg_pct = (close - pre_close) / pre_close if pre_close != 0 else 0.0
price_up = close >= pre_close price_up = close >= pre_close
if oi_change_pct < 0.01:
return 60 if price_up else 40
oi_increasing = oi_chg > 0 oi_increasing = oi_chg > 0
if oi_increasing and price_up:
return 100
if oi_increasing and not price_up:
return 0
if not oi_increasing and price_up:
return 70
return 30
# ── 象限基础分 ──
if abs(oi_chg_pct) < 0.01:
base = 60.0 if price_up else 40.0
quadrant = "flat"
elif oi_increasing and price_up:
base = 75.0
quadrant = "accumulation" # 增仓上涨
elif oi_increasing and not price_up:
base = 25.0
quadrant = "distribution" # 增仓下跌
elif not oi_increasing and price_up:
base = 65.0
quadrant = "covering" # 减仓上涨
else:
base = 20.0
quadrant = "liquidation" # 减仓下跌
# ── 幅度加成(方向性)──
# OI 变化率封顶 5%,价格涨跌幅封顶 3%
oi_mag = min(1.0, abs(oi_chg_pct) / 0.05)
price_mag = min(1.0, abs(price_chg_pct) / 0.03)
if quadrant == "accumulation":
boost = (oi_mag + price_mag) / 2.0 * 20.0 # 看多,加成推高分数
elif quadrant == "liquidation":
boost = -(oi_mag + price_mag) / 2.0 * 20.0 # 看空,扣分强化信号
elif quadrant == "flat":
boost = price_mag * 10.0 if price_up else -(price_mag * 10.0)
elif quadrant == "distribution":
boost = -price_mag * 10.0 # 增仓下跌:价格幅度扣分
else: # covering
boost = price_mag * 10.0 # 减仓上涨:价格幅度加分
# ── 量能确认ratio=1.0 时为中性因子 1.0)──
vol_ratio = vol / avg_vol_7d if avg_vol_7d > 0 else 1.0
vol_factor = 0.8 + 0.2 * min(vol_ratio, 2.0) # 范围 [0.8, 1.2],中性 = 1.0
score = max(0.0, min(100.0, (base + boost) * vol_factor))
return {
"trade_date": str(row["trade_date"]),
"close": close,
"pre_close": pre_close,
"oi": oi,
"oi_chg": oi_chg,
"oi_chg_pct": round(float(oi_chg_pct), 4),
"price_chg_pct": round(float(price_chg_pct), 4),
"vol": float(vol),
"vol_ratio": round(float(vol_ratio), 2),
"quadrant": quadrant,
"score": round(float(score), 1),
}
# ---------------------------------------------------------------------------
# 短期动力 — 7 日窗口,指数加权平均
# ---------------------------------------------------------------------------
def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]: def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]:
"""近 window 日逐日打分,指数加权平均(越近权重越高)。"""
recent = df.iloc[-window:].copy() recent = df.iloc[-window:].copy()
avg_vol_7d = float(recent["vol"].mean()) if "vol" in recent.columns else 0.0
scores = [] scores = []
details = [] details = []
for _, row in recent.iterrows(): for _, row in recent.iterrows():
score = _daily_short_score(row) detail = _daily_short_score(row, avg_vol_7d)
scores.append(score) scores.append(detail["score"])
details.append({ details.append(detail)
"trade_date": str(row["trade_date"]),
"close": float(row["close"]),
"pre_close": float(row["pre_close"]),
"oi": float(row["oi"]),
"oi_chg": float(row["oi_chg"]),
"score": score,
})
return sum(scores) / len(scores), details
# 指数加权:权重从 exp(0)=1 递增到 exp(1)≈2.718
weights = np.exp(np.linspace(0, 1, window))
weights = weights / weights.sum()
weighted_avg = float(np.average(scores, weights=weights))
return weighted_avg, details
# ---------------------------------------------------------------------------
# 中期趋势 — 15 日窗口,四象限资金意愿
# ---------------------------------------------------------------------------
def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]: def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
if len(df) < window + 1: if len(df) < window + 1:
@@ -54,60 +114,93 @@ def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
close_now = float(df.iloc[-1]["close"]) close_now = float(df.iloc[-1]["close"])
close_before = float(df.iloc[-window - 1]["close"]) close_before = float(df.iloc[-window - 1]["close"])
# ── 价格信号 ──
price_return = (close_now - close_before) / close_before if close_before != 0 else 0 price_return = (close_now - close_before) / close_before if close_before != 0 else 0
price_score = max(0.0, min(100.0, 50.0 + price_return * 500)) price_score = max(0.0, min(100.0, 50.0 + price_return * 500))
long_up = 0 # ── 资金意愿(四象限全计入)──
long_down = 0 accumulation = 0 # 增仓上涨
for _, row in recent.iterrows(): distribution = 0 # 增仓下跌
if row["oi_chg"] > 0: covering = 0 # 减仓上涨
if row["close"] >= row["pre_close"]: liquidation = 0 # 减仓下跌
long_up += 1
else: for _, row in recent.iterrows():
long_down += 1 oi_inc = row["oi_chg"] > 0
price_up = row["close"] >= row["pre_close"]
if oi_inc and price_up:
accumulation += 1
elif oi_inc and not price_up:
distribution += 1
elif not oi_inc and price_up:
covering += 1
else:
liquidation += 1
# 增仓驱动权重 1.0,减仓驱动权重 0.5(持续性较差)
fund_score = 50.0 + (
accumulation * 1.0 - distribution * 1.0
+ covering * 0.5 - liquidation * 0.5
) / window * 50.0
fund_score = max(0.0, min(100.0, fund_score))
fund_score = 80 if long_up > long_down else (20 if long_up < long_down else 50)
score = price_score * 0.6 + fund_score * 0.4 score = price_score * 0.6 + fund_score * 0.4
detail = { detail = {
"price_return_pct": round(price_return * 100, 2), "price_return_pct": round(price_return * 100, 2),
"price_signal": round(price_score, 1), "price_signal": round(price_score, 1),
"long_up_days": long_up, "accumulation_days": accumulation,
"long_down_days": long_down, "distribution_days": distribution,
"fund_signal": fund_score, "covering_days": covering,
"liquidation_days": liquidation,
"fund_signal": round(fund_score, 1),
"window": window,
} }
return score, detail return score, detail
# ---------------------------------------------------------------------------
# 长期结构 — 30 日窗口,端点 OI 比较
# ---------------------------------------------------------------------------
def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]: def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
if len(df) < window + 1: if len(df) < window + 1:
raise ValueError(f"数据不足,需要至少 {window + 1}") raise ValueError(f"数据不足,需要至少 {window + 1}")
recent_oi = df.iloc[-window:]["oi"] # ── OI 趋势分(端点比较,消除均值滞后)──
avg_oi = recent_oi.mean() oi_now = float(df.iloc[-1]["oi"])
oi_before = float(df.iloc[-window - 1]["oi"]) oi_before = float(df.iloc[-window - 1]["oi"])
oi_change_pct = (oi_now - oi_before) / oi_before if oi_before != 0 else 0.0
oi_score = max(0.0, min(100.0, 50.0 + oi_change_pct * 250))
change_pct = (avg_oi - oi_before) / oi_before if oi_before != 0 else 0 # ── 价格趋势分 ──
close_now = float(df.iloc[-1]["close"])
price_before = float(df.iloc[-window - 1]["close"])
price_return_30d = (close_now - price_before) / price_before if price_before != 0 else 0.0
price_score = max(0.0, min(100.0, 50.0 + price_return_30d * 200))
if change_pct > 0.10: score = oi_score * 0.6 + price_score * 0.4
score = 90
elif change_pct > 0.05: # 额外统计近 30 日 OI 均值供参考
score = 70 recent_oi = df.iloc[-window:]["oi"]
elif change_pct > -0.05:
score = 50
elif change_pct > -0.10:
score = 30
else:
score = 10
detail = { detail = {
"avg_oi": round(float(avg_oi), 0), "oi_now": round(oi_now, 0),
"oi_before": round(oi_before, 0), "oi_before": round(oi_before, 0),
"change_pct": round(change_pct * 100, 2), "oi_change_pct": round(oi_change_pct * 100, 2),
"oi_score": round(oi_score, 1),
"price_score": round(price_score, 1),
"price_return_30d_pct": round(price_return_30d * 100, 2),
"price_before_30d": round(price_before, 2),
"avg_oi_30d": round(float(recent_oi.mean()), 0),
"window": window,
} }
return score, detail return score, detail
# ---------------------------------------------------------------------------
# 信号解读
# ---------------------------------------------------------------------------
def _interpret(composite: float) -> str: def _interpret(composite: float) -> str:
if composite >= 80: if composite >= 80:
return "强烈看多区域 — 价格与资金共振,趋势多头的温床" return "强烈看多区域 — 价格与资金共振,趋势多头的温床"
@@ -118,6 +211,10 @@ def _interpret(composite: float) -> str:
return "强烈看空区域 — 资金主动且持续地打压价格" return "强烈看空区域 — 资金主动且持续地打压价格"
# ---------------------------------------------------------------------------
# 主打分函数
# ---------------------------------------------------------------------------
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult: def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
"""对 DataFrame 中指定日期或最新一条记录打分。""" """对 DataFrame 中指定日期或最新一条记录打分。"""
if len(df) < 31: if len(df) < 31:
@@ -135,11 +232,59 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
latest = df.iloc[-1] latest = df.iloc[-1]
# ── 三层原始分 ──
short, short_details = calc_short_term(df, 7) short, short_details = calc_short_term(df, 7)
medium, medium_detail = calc_medium_term(df, 15) medium, medium_detail = calc_medium_term(df, 15)
long_, long_detail = calc_long_term(df, 30) long_, long_detail = calc_long_term(df, 30)
composite = short * 0.4 + medium * 0.35 + long_ * 0.25 # ── 波动率(日收益率标准差 + ATR%)──
recent_30 = df.iloc[-30:].copy()
recent_30["ret"] = recent_30["close"].pct_change()
daily_vol = float(recent_30["ret"].std())
recent_30["tr"] = recent_30.apply(
lambda r: max(
r["high"] - r["low"],
abs(r["high"] - r["pre_close"]),
abs(r["low"] - r["pre_close"]),
),
axis=1,
)
atr = float(recent_30["tr"].mean())
avg_close_30 = float(recent_30["close"].mean())
atr_pct = (atr / avg_close_30) if avg_close_30 else 0.0
# 综合波动率风险度量:日收益标准差 70% + ATR% 30%
vol_risk = daily_vol * 0.7 + atr_pct * 0.3
vol_ref = 0.015
if vol_risk <= vol_ref:
vol_penalty = 1.0
else:
vol_penalty = max(0.85, 1.0 - (vol_risk - vol_ref) * 10)
# ── 自适应权重(趋势越强,长期权重越高)──
price_return_30d = (
(float(df.iloc[-1]["close"]) - float(df.iloc[-31]["close"]))
/ float(df.iloc[-31]["close"])
if df.iloc[-31]["close"] != 0
else 0.0
)
# 趋势效率 = |30日收益率| / 日波动率,比率高 = 方向明确
trend_strength = abs(price_return_30d) / max(daily_vol, 0.005)
trend_factor = min(trend_strength / 3.0, 1.0) # 归一化到 [0, 1]
w_short_base = 0.40
w_medium_base = 0.35
w_long_base = 0.25
shift = trend_factor * 0.10 # 最多转移 10% 权重
w_short = w_short_base - shift
w_medium = w_medium_base
w_long = w_long_base + shift
# ── 综合分数 ──
composite_raw = short * w_short + medium * w_medium + long_ * w_long
composite = round(composite_raw * vol_penalty, 1)
signal = _interpret(composite) signal = _interpret(composite)
return ScoreResult( return ScoreResult(
@@ -153,9 +298,94 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
long_term=round(long_, 1), long_term=round(long_, 1),
composite=round(composite, 1), composite=round(composite, 1),
signal=signal, signal=signal,
vol_penalty=round(float(vol_penalty), 4),
detail=ScoreDetail( detail=ScoreDetail(
short_details=short_details, short_details=short_details,
medium_detail=medium_detail, medium_detail=medium_detail,
long_detail=long_detail, long_detail=long_detail,
volatility={
"daily_vol_pct": round(float(daily_vol), 4),
"atr_pct": round(float(atr_pct), 4),
"vol_risk": round(float(vol_risk), 4),
"vol_penalty": round(float(vol_penalty), 4),
},
adaptive_weights={
"trend_strength": round(float(trend_strength), 2),
"trend_factor": round(float(trend_factor), 2),
"w_short": round(float(w_short), 4),
"w_medium": round(float(w_medium), 4),
"w_long": round(float(w_long), 4),
},
), ),
) )
# ---------------------------------------------------------------------------
# 区间 / 全量打分
# ---------------------------------------------------------------------------
def score_range(
df: pd.DataFrame, start_date: str, end_date: str
) -> tuple[list[ScoreResult], list[str]]:
"""对日期区间内的每一天分别打分,返回 (结果列表, 警告列表)。"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
target_dates = df[
(df["trade_date"].astype(str) >= str(start_date))
& (df["trade_date"].astype(str) <= str(end_date))
]["trade_date"].astype(str).tolist()
for td in target_dates:
try:
result = score_daily(df, td)
results.append(result)
except ValueError as e:
warnings.append(str(e))
_fill_deltas(results)
return results, warnings
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
Returns:
(results, warnings, total_days, scored_count)
"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
for idx in range(30, len(df)):
subset = df.iloc[: idx + 1].copy()
trade_date = str(df.iloc[idx]["trade_date"])
try:
result = score_daily(subset)
results.append(result)
except ValueError as e:
warnings.append(f"{trade_date}: {e}")
_fill_deltas(results)
total_days = len(df) - 30
scored_count = len(results)
return results, warnings, total_days, scored_count
# ---------------------------------------------------------------------------
# 辅助:填充分数动量
# ---------------------------------------------------------------------------
def _fill_deltas(results: list[ScoreResult]):
"""为结果列表中的每个 ScoreResult 填充 composite_delta 和 composite_delta_5d。"""
for i, r in enumerate(results):
if i >= 1 and results[i - 1].composite is not None:
r.composite_delta = round(r.composite - results[i - 1].composite, 1)
if i >= 5 and results[i - 5].composite is not None:
r.composite_delta_5d = round(r.composite - results[i - 5].composite, 1)

View File

@@ -68,6 +68,11 @@ def save_candles(df: pd.DataFrame, db_url: str = DEFAULT_DB_URL):
df = df.copy() df = df.copy()
df = df.where(pd.notna(df), None) df = df.where(pd.notna(df), None)
records = df.to_dict(orient="records") records = df.to_dict(orient="records")
# pandas float 列的 None 会被转为 NaN需手动清理后再存入数据库
for record in records:
for key, val in record.items():
if isinstance(val, float) and val != val: # NaN != NaN
record[key] = None
with conn.cursor() as cur: with conn.cursor() as cur:
cur.executemany( cur.executemany(
""" """
@@ -98,6 +103,19 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
conn = _get_conn(db_url) conn = _get_conn(db_url)
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
detail_payload = {
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
"volatility": score.detail.volatility,
"adaptive_weights": score.detail.adaptive_weights,
"vol_penalty": score.vol_penalty,
}
if score.composite_delta is not None:
detail_payload["composite_delta"] = score.composite_delta
if score.composite_delta_5d is not None:
detail_payload["composite_delta_5d"] = score.composite_delta_5d
cur.execute( cur.execute(
""" """
INSERT INTO scores INSERT INTO scores
@@ -127,11 +145,7 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
score.long_term, score.long_term,
score.composite, score.composite,
score.signal, score.signal,
json.dumps({ json.dumps(detail_payload, ensure_ascii=False, default=str),
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
}, ensure_ascii=False, default=str),
), ),
) )
conn.commit() conn.commit()
@@ -152,3 +166,14 @@ def get_latest_score(ts_code: str, db_url: str = DEFAULT_DB_URL) -> Optional[dic
return dict(row) if row else None return dict(row) if row else None
finally: finally:
conn.close() conn.close()
def truncate_all(db_url: str = DEFAULT_DB_URL):
"""清空所有行情数据candles + scores保留用户表。"""
conn = _get_conn(db_url)
try:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE candles, scores")
conn.commit()
finally:
conn.close()

View File

@@ -5,7 +5,8 @@ WORKDIR /ui
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install # 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi RUN npm config set registry https://registry.npmmirror.com && \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./ COPY frontend ./
RUN npm run build RUN npm run build
@@ -21,7 +22,7 @@ WORKDIR /src
COPY backend ./ COPY backend ./
COPY --from=ui /ui/dist ./dist COPY --from=ui /ui/dist ./dist
ENV CGO_ENABLED=0 GOOS=linux ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
RUN go mod tidy && \ RUN go mod tidy && \
go build -trimpath -ldflags="-s -w" -o /out/web ./ go build -trimpath -ldflags="-s -w" -o /out/web ./

View File

@@ -6,6 +6,11 @@ import (
"trade/web/internal/store" "trade/web/internal/store"
) )
// BootstrapLLMConfig 初始化 llm_config 表。
func BootstrapLLMConfig(s *store.FuturesStore) error {
return s.EnsureLLMConfigTable()
}
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin; // Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
// 并强制首次登录后改密码。已存在 admin 时静默跳过。 // 并强制首次登录后改密码。已存在 admin 时静默跳过。
func Bootstrap(s *store.AuthStore) error { func Bootstrap(s *store.AuthStore) error {

View File

@@ -9,6 +9,9 @@ type Config struct {
ListenAddr string ListenAddr string
DatabaseURL string DatabaseURL string
TushareAPIURL string TushareAPIURL string
LLMBaseURL string
LLMAPIKey string
LLMModel string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -16,6 +19,9 @@ func Load() (*Config, error) {
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"), TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
LLMBaseURL: getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMModel: getenv("LLM_MODEL", "deepseek-chat"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")

View File

@@ -0,0 +1,307 @@
package handlers
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"trade/web/internal/store"
)
// AIConfig LLM 调用配置。
type AIConfig struct {
BaseURL string
APIKey string
Model string
}
// resolveLLMConfig 返回实际使用的 LLM 配置DB 优先,环境变量作 fallback。
func (d *Deps) resolveLLMConfig() *AIConfig {
cfg := &AIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
}
// 尝试从 DB 读取
if dbCfg, err := d.Futures.GetLLMConfig(); err == nil && dbCfg != nil && dbCfg.APIKey != "" {
cfg.APIKey = dbCfg.APIKey
if dbCfg.BaseURL != "" {
cfg.BaseURL = dbCfg.BaseURL
}
if dbCfg.Model != "" {
cfg.Model = dbCfg.Model
}
return cfg
}
// fallback 到环境变量
if d.AIConfig != nil {
if d.AIConfig.APIKey != "" {
cfg.APIKey = d.AIConfig.APIKey
}
if d.AIConfig.BaseURL != "" {
cfg.BaseURL = d.AIConfig.BaseURL
}
if d.AIConfig.Model != "" {
cfg.Model = d.AIConfig.Model
}
}
return cfg
}
// Analyze 接收 ts_code + trade_date查库拼 prompt调 LLM 并以 SSE 流式返回。
func (d *Deps) Analyze(w http.ResponseWriter, r *http.Request) {
llmCfg := d.resolveLLMConfig()
if llmCfg.APIKey == "" {
writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置")
return
}
q := r.URL.Query()
tsCode := q.Get("ts_code")
tradeDate := q.Get("trade_date")
if tsCode == "" || tradeDate == "" {
writeErr(w, http.StatusBadRequest, "缺少 ts_code 或 trade_date")
return
}
ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
prompt := buildPrompt(ctx)
// SSE
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "不支持 SSE")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
if err := streamLLM(llmCfg, prompt, w, flusher); err != nil {
log.Printf("[ai] stream error: %v", err)
sendSSE(w, flusher, "error", err.Error())
}
sendSSE(w, flusher, "done", "")
}
func sendSSE(w io.Writer, flusher http.Flusher, event, data string) {
if event != "" {
fmt.Fprintf(w, "event: %s\n", event)
}
for _, line := range strings.Split(data, "\n") {
fmt.Fprintf(w, "data: %s\n", line)
}
fmt.Fprint(w, "\n")
flusher.Flush()
}
// streamLLM 调用 LLM chat/completions逐 token 推送 SSE。
func streamLLM(cfg *AIConfig, prompt []map[string]string, w io.Writer, flusher http.Flusher) error {
body := map[string]any{
"model": cfg.Model,
"messages": prompt,
"stream": true,
}
payload, _ := json.Marshal(body)
req, err := http.NewRequest("POST", strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b))
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
return nil
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // 忽略解析失败的行
}
for _, c := range chunk.Choices {
if c.Delta.Content != "" {
sendSSE(w, flusher, "token", c.Delta.Content)
}
}
}
return scanner.Err()
}
// buildPrompt 用 AnalysisContext 构建 system + user 消息。
func buildPrompt(ctx *store.AnalysisContext) []map[string]string {
s := ctx.Score
// 解析 detail_json 中的关键字段
var detail struct {
ShortDetails []map[string]any `json:"short_details"`
MediumDetail map[string]any `json:"medium_detail"`
LongDetail map[string]any `json:"long_detail"`
Volatility map[string]any `json:"volatility"`
AdaptiveW map[string]any `json:"adaptive_weights"`
VolPenalty float64 `json:"vol_penalty"`
Delta1D *float64 `json:"composite_delta"`
Delta5D *float64 `json:"composite_delta_5d"`
}
if s.Detail != nil {
_ = json.Unmarshal(s.Detail, &detail)
}
var sb strings.Builder
sb.WriteString("你是一位资深期货技术分析师,擅长从量化打分系统中解读市场信号。\n")
// 综合
sb.WriteString(fmt.Sprintf("\n## 合约 %s %s\n", s.TsCode, s.TradeDate))
sb.WriteString(fmt.Sprintf("- 收盘 %.2f 持仓 %.0f (日变动 %+.0f)\n", s.Close, s.OI, s.OIChg))
sb.WriteString(fmt.Sprintf("- 综合分 **%.1f** / 100\n", s.Composite))
sb.WriteString(fmt.Sprintf("- 分层: 短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm))
sb.WriteString(fmt.Sprintf("- 信号: %s\n", s.Signal))
// 波动率
if v, ok := detail.Volatility["vol_penalty"]; ok {
dp := 0.0
if vv, ok := v.(float64); ok {
dp = vv
}
risk := 0.0
if vr, ok := detail.Volatility["vol_risk"]; ok {
if vv, ok := vr.(float64); ok {
risk = vv
}
}
sb.WriteString(fmt.Sprintf("- 波动率惩罚系数: %.3f (综合风险 %.2f%%)\n", dp, risk*100))
}
// 自适应权重
if aw, ok := detail.AdaptiveW["trend_strength"]; ok {
sb.WriteString(fmt.Sprintf("- 趋势强度: %.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n",
aw, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long")))
}
// 分数动量
if detail.Delta1D != nil {
sb.WriteString(fmt.Sprintf("- 分数动量: Δ1d %+.1f", *detail.Delta1D))
}
if detail.Delta5D != nil {
sb.WriteString(fmt.Sprintf(" Δ5d %+.1f", *detail.Delta5D))
}
if detail.Delta1D != nil || detail.Delta5D != nil {
sb.WriteString("\n")
}
// 短期细节
sb.WriteString("\n## 短期动力 (近7日逐日)\n")
sb.WriteString("| 日期 | 象限 | 涨跌% | OI变化% | 得分 |\n")
sb.WriteString("|------|------|-------|---------|------|\n")
for _, d := range detail.ShortDetails {
q := fmt.Sprint(d["quadrant"])
qcn := map[string]string{
"accumulation": "增仓涨", "distribution": "增仓跌",
"covering": "减仓涨", "liquidation": "减仓跌", "flat": "持平",
}[q]
if qcn == "" {
qcn = q
}
sb.WriteString(fmt.Sprintf("| %s | %s | %+.2f%% | %+.2f%% | %.1f |\n",
d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "score")))
}
// 中期细节
md := detail.MediumDetail
sb.WriteString("\n## 中期趋势 (15日)\n")
sb.WriteString(fmt.Sprintf("- 价格信号: %.1f (收益率 %+.2f%%)\n", floatVal(md, "price_signal"), floatVal(md, "price_return_pct")))
sb.WriteString(fmt.Sprintf("- 资金意愿: %.1f\n", floatVal(md, "fund_signal")))
a, d_, c, l := intVal(md, "accumulation_days"), intVal(md, "distribution_days"), intVal(md, "covering_days"), intVal(md, "liquidation_days")
sb.WriteString(fmt.Sprintf("- 象限分布: 增仓涨 %d天 / 增仓跌 %d天 / 减仓涨 %d天 / 减仓跌 %d天\n", a, d_, c, l))
// 长期细节
ld := detail.LongDetail
sb.WriteString("\n## 长期结构 (30日)\n")
sb.WriteString(fmt.Sprintf("- OI 趋势分: %.1f (端点变化 %+.2f%%)\n", floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct")))
sb.WriteString(fmt.Sprintf("- 价格趋势分: %.1f (30日收益 %+.2f%%)\n", floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct")))
// 近 5 日分数趋势
if len(ctx.RecentScores) > 0 {
sb.WriteString("\n## 近5日分数趋势\n")
for i, rs := range ctx.RecentScores {
if i >= 5 {
break
}
sb.WriteString(fmt.Sprintf("- %s 综合 %.1f\n", rs.TradeDate, rs.Composite))
}
}
// 近30日K线数据供支撑阻力分析
if len(ctx.Candles) > 0 {
start := 0
if len(ctx.Candles) > 30 {
start = len(ctx.Candles) - 30
}
sb.WriteString("\n## 近30日K线开/高/低/收)\n")
sb.WriteString("| 日期 | 开盘 | 最高 | 最低 | 收盘 |\n")
sb.WriteString("|------|------|------|------|------|\n")
for _, c := range ctx.Candles[start:] {
sb.WriteString(fmt.Sprintf("| %s | %.1f | %.1f | %.1f | %.1f |\n",
c.TradeDate, c.Open, c.High, c.Low, c.Close))
}
}
sb.WriteString("\n请从以下4个角度简要分析使用中文\n")
sb.WriteString("1. 当前多空格局2-3句话\n")
sb.WriteString("2. 资金行为特征2-3句话\n")
sb.WriteString("3. 关键风险点2-3句话\n")
sb.WriteString("4. 支撑与阻力明确指出最近的关键支撑位和阻力位基于近30日高低点和均线位置给出具体价位和依据\n")
return []map[string]string{
{"role": "user", "content": sb.String()},
}
}
func floatVal(m map[string]any, key string) float64 {
v, _ := m[key].(float64)
return v
}
func intVal(m map[string]any, key string) int {
v, _ := m[key].(float64)
return int(v)
}
func valPct(m map[string]any, key string) float64 {
v, _ := m[key].(float64)
return v * 100
}

View File

@@ -0,0 +1,409 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"trade/web/internal/store"
)
// 所有已配置品种(与 tushare/src/contracts.py 保持一致)。
var allSymbols = []string{"FG", "SA", "RB", "MA", "CF", "M"}
type ddRunRequest struct {
TradeDate string `json:"trade_date,omitempty"` // YYYYMMDD, 默认今天
Symbols []string `json:"symbols,omitempty"` // 默认全部
}
type ddRunResponse struct {
TradeDate string `json:"trade_date"`
Results []ddSymbolResult `json:"results"`
Errors []ddSymbolResult `json:"errors,omitempty"`
}
type ddSymbolResult struct {
Symbol string `json:"symbol"`
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Error string `json:"error,omitempty"`
}
// DailyDirectionRun 对多个品种批量执行 AI 方向分析。
func (d *Deps) DailyDirectionRun(w http.ResponseWriter, r *http.Request) {
var req ddRunRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.TradeDate == "" {
req.TradeDate = time.Now().Format("20060102")
}
if len(req.Symbols) == 0 {
req.Symbols = allSymbols
}
llmCfg := d.resolveLLMConfig()
if llmCfg.APIKey == "" {
writeErr(w, http.StatusServiceUnavailable, "LLM API Key 未配置,请在管理后台设置")
return
}
resp := ddRunResponse{TradeDate: req.TradeDate}
for _, sym := range req.Symbols {
result, err := d.analyzeOneDirection(llmCfg, sym, req.TradeDate)
if err != nil {
resp.Errors = append(resp.Errors, ddSymbolResult{Symbol: sym, Error: err.Error()})
continue
}
resp.Results = append(resp.Results, *result)
}
writeJSON(w, http.StatusOK, resp)
}
// analyzeOneDirection 对单个品种做方向分析并持久化。
func (d *Deps) analyzeOneDirection(llmCfg *AIConfig, symbol, tradeDate string) (*ddSymbolResult, error) {
// 1. 找到活跃合约
tsCode, err := d.Futures.GetActiveTsCode(symbol, tradeDate)
if err != nil {
return nil, fmt.Errorf("查找活跃合约: %w", err)
}
// 2. 拉取分析上下文
ctx, err := d.Futures.GetAnalysisContext(tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("获取分析数据: %w", err)
}
// 3. 构建方向分析 prompt
prompt := buildDirectionPrompt(ctx, symbol)
promptSnapshot := prompt[len(prompt)-1]["content"]
// 4. 调 LLM非流式
rawJSON, err := callLLM(llmCfg, prompt)
if err != nil {
return nil, fmt.Errorf("LLM 调用失败: %w", err)
}
// 5. 解析 JSON
parsed, err := parseDirectionJSON(rawJSON)
if err != nil {
log.Printf("[daily-direction] %s JSON parse failed, raw: %s", symbol, rawJSON)
return nil, fmt.Errorf("AI 返回格式异常: %w", err)
}
// 6. 计算 target_date简化+1天周五 +3天
targetDate := nextTradeDate(tradeDate)
// 7. 持久化
supportJSON, _ := json.Marshal(parsed.Support)
resistJSON, _ := json.Marshal(parsed.Resistance)
dd := &store.DailyDirection{
Symbol: symbol,
TradeDate: tradeDate,
TargetDate: targetDate,
Direction: parsed.Direction,
Confidence: parsed.Confidence,
Support: string(supportJSON),
Resistance: string(resistJSON),
Reasoning: parsed.Reasoning,
RiskNote: parsed.RiskNote,
PromptSnapshot: promptSnapshot,
}
if err := d.Futures.SaveDailyDirection(dd); err != nil {
return nil, fmt.Errorf("保存失败: %w", err)
}
return &ddSymbolResult{
Symbol: symbol,
Direction: parsed.Direction,
Confidence: parsed.Confidence,
}, nil
}
// directionJSON LLM 返回的期望结构。
type directionJSON struct {
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Support []float64 `json:"support"`
Resistance []float64 `json:"resistance"`
Reasoning string `json:"reasoning"`
RiskNote string `json:"risk_note"`
}
// parseDirectionJSON 从 LLM 原始响应中提取结构化结果。
func parseDirectionJSON(raw string) (*directionJSON, error) {
raw = strings.TrimSpace(raw)
// 尝试直接解析
var d directionJSON
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
// 尝试提取 markdown code block 中的 JSON
if idx := strings.Index(raw, "```json"); idx >= 0 {
rest := raw[idx+7:]
if end := strings.Index(rest, "```"); end > 0 {
raw = strings.TrimSpace(rest[:end])
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
}
}
if idx := strings.Index(raw, "```"); idx >= 0 {
rest := raw[idx+3:]
if end := strings.Index(rest, "```"); end > 0 {
raw = strings.TrimSpace(rest[:end])
if err := json.Unmarshal([]byte(raw), &d); err == nil {
return &d, d.validate()
}
}
}
return nil, fmt.Errorf("无法解析 AI 返回的 JSON")
}
func (d *directionJSON) validate() error {
d.Direction = strings.TrimSpace(strings.ToLower(d.Direction))
switch d.Direction {
case "bullish", "bearish", "neutral":
default:
return fmt.Errorf("无效的 direction 值: %s", d.Direction)
}
if d.Confidence < 0 || d.Confidence > 100 {
d.Confidence = max(0, min(100, d.Confidence))
}
return nil
}
// callLLM 非流式调用 LLM返回 content 文本。
func callLLM(cfg *AIConfig, prompt []map[string]string) (string, error) {
body := map[string]any{
"model": cfg.Model,
"messages": prompt,
"stream": false,
}
payload, err := json.Marshal(body)
if err != nil {
return "", err
}
client := &http.Client{Timeout: 120 * time.Second}
req, err := http.NewRequest("POST", strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, string(b))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode llm response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("llm returned empty choices")
}
return result.Choices[0].Message.Content, nil
}
// buildDirectionPrompt 构建方向分析专用 prompt。
func buildDirectionPrompt(ctx *store.AnalysisContext, symbol string) []map[string]string {
s := ctx.Score
var detail struct {
ShortDetails []map[string]any `json:"short_details"`
MediumDetail map[string]any `json:"medium_detail"`
LongDetail map[string]any `json:"long_detail"`
Volatility map[string]any `json:"volatility"`
AdaptiveW map[string]any `json:"adaptive_weights"`
VolPenalty float64 `json:"vol_penalty"`
Delta1D *float64 `json:"composite_delta"`
Delta5D *float64 `json:"composite_delta_5d"`
}
if s.Detail != nil {
_ = json.Unmarshal(s.Detail, &detail)
}
symbolNames := map[string]string{
"FG": "玻璃", "SA": "纯碱", "RB": "螺纹钢", "MA": "甲醇", "CF": "棉花", "M": "豆粕",
}
symbolName := symbolNames[symbol]
if symbolName == "" {
symbolName = symbol
}
var sb strings.Builder
// ── 综合概况 ──
sb.WriteString(fmt.Sprintf("品种:%s(%s)\n", symbolName, symbol))
sb.WriteString(fmt.Sprintf("合约:%s 日期:%s\n", s.TsCode, s.TradeDate))
sb.WriteString(fmt.Sprintf("收盘:%.2f 持仓:%.0f (日变动 %+.0f)\n", s.Close, s.OI, s.OIChg))
sb.WriteString(fmt.Sprintf("综合分:%.1f/100 信号:%s\n", s.Composite, s.Signal))
sb.WriteString(fmt.Sprintf("分层:短期 %.1f 中期 %.1f 长期 %.1f\n", s.ShortTerm, s.MediumTerm, s.LongTerm))
// 波动率
if v, ok := detail.Volatility["vol_penalty"]; ok {
risk := 0.0
if vr, ok := detail.Volatility["vol_risk"]; ok {
if vv, ok := vr.(float64); ok {
risk = vv
}
}
sb.WriteString(fmt.Sprintf("波动率惩罚:%.3f (风险 %.2f%%)\n", v, risk*100))
}
// 自适应权重
if ts, ok := detail.AdaptiveW["trend_strength"]; ok {
sb.WriteString(fmt.Sprintf("趋势强度:%.2f → 权重 短期%.0f%%/中期%.0f%%/长期%.0f%%\n",
ts, valPct(detail.AdaptiveW, "w_short"), valPct(detail.AdaptiveW, "w_medium"), valPct(detail.AdaptiveW, "w_long")))
}
// 分数动量
if detail.Delta1D != nil {
sb.WriteString(fmt.Sprintf("分数日变化:%+.1f", *detail.Delta1D))
}
if detail.Delta5D != nil {
sb.WriteString(fmt.Sprintf(" 周变化:%+.1f", *detail.Delta5D))
}
if detail.Delta1D != nil || detail.Delta5D != nil {
sb.WriteString("\n")
}
// ── 短期动力 7 日 ──
sb.WriteString("\n近7日逐日打分\n")
for _, d := range detail.ShortDetails {
q := fmt.Sprint(d["quadrant"])
qcn := map[string]string{
"accumulation": "增仓涨", "distribution": "增仓跌",
"covering": "减仓涨", "liquidation": "减仓跌", "flat": "持平",
}[q]
if qcn == "" {
qcn = q
}
sb.WriteString(fmt.Sprintf(" %s %s 涨跌%+.2f%% OI%+.2f%% 量比%.2f 得分%.1f\n",
d["trade_date"], qcn, floatVal(d, "price_chg_pct")*100, floatVal(d, "oi_chg_pct")*100, floatVal(d, "vol_ratio"), floatVal(d, "score")))
}
// ── 中期趋势 15 日 ──
md := detail.MediumDetail
sb.WriteString(fmt.Sprintf("\n中期趋势(15日):价格信号 %.1f (收益率 %+.2f%%) 资金意愿 %.1f\n",
floatVal(md, "price_signal"), floatVal(md, "price_return_pct"), floatVal(md, "fund_signal")))
a, d_, c, l := intVal(md, "accumulation_days"), intVal(md, "distribution_days"), intVal(md, "covering_days"), intVal(md, "liquidation_days")
sb.WriteString(fmt.Sprintf("象限分布:增仓涨 %d天 增仓跌 %d天 减仓涨 %d天 减仓跌 %d天\n", a, d_, c, l))
// ── 长期结构 30 日 ──
ld := detail.LongDetail
sb.WriteString(fmt.Sprintf("\n长期结构(30日)OI趋势分 %.1f (变化 %+.2f%%) 价格趋势分 %.1f (收益 %+.2f%%)\n",
floatVal(ld, "oi_score"), floatVal(ld, "oi_change_pct"), floatVal(ld, "price_score"), floatVal(ld, "price_return_30d_pct")))
// ── 近 5 日分数趋势 ──
if len(ctx.RecentScores) > 0 {
sb.WriteString("\n近5日综合分趋势")
for i, rs := range ctx.RecentScores {
if i >= 5 {
break
}
sb.WriteString(fmt.Sprintf("%s=%.1f ", rs.TradeDate, rs.Composite))
}
sb.WriteString("\n")
}
// ── 近 30 日 K 线(支撑阻力依据)──
if len(ctx.Candles) > 0 {
start := 0
if len(ctx.Candles) > 30 {
start = len(ctx.Candles) - 30
}
sb.WriteString("\n近30日K线开/高/低/收):\n")
for _, c := range ctx.Candles[start:] {
sb.WriteString(fmt.Sprintf(" %s O%.1f H%.1f L%.1f C%.1f\n", c.TradeDate, c.Open, c.High, c.Low, c.Close))
}
}
systemPrompt := strings.Join([]string{
"你是一位期货日内交易分析师。基于量化打分数据,给出下一个交易日的方向判断。",
"",
"你必须输出严格 JSON不要有任何额外文字",
"{",
` "direction": "bullish" | "bearish" | "neutral",`,
` "confidence": <0-100 的整数,表示对方向的确定程度>`,
` "support": [<关键支撑位1>, <关键支撑位2>]`,
` "resistance": [<关键阻力位1>, <关键阻力位2>]`,
` "reasoning": "<3-5句话说明核心逻辑三层信号是共振还是背离资金在干什么>",`,
` "risk_note": "<1-2句话最可能打破当前判断的风险>"`,
"}",
"",
"分析要点:",
"1. 短期(7日)+中期(15日)+长期(30日) ≥2 层指向同一方向 = 有效方向信号",
"2. 关注持仓变化:增仓方向才是真方向,减仓方向可能是离场",
"3. 分数动量Δ1d 和 Δ5d 连续同向 = 趋势加速,反向 = 可能转折",
"4. 支撑阻力从 K 线数据中找:近期高低点、密集成交区",
"5. 波动率惩罚系数 < 0.95 表示行情不稳定,降低 confidence",
}, "\n")
return []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": sb.String()},
}
}
// nextTradeDate 计算下一个交易日(简化规则:周六→周一,周日→周一,周五→周一,其余+1天
func nextTradeDate(dateStr string) string {
t, err := time.Parse("20060102", dateStr)
if err != nil {
return dateStr
}
next := t.AddDate(0, 0, 1)
switch next.Weekday() {
case time.Saturday:
next = next.AddDate(0, 0, 2)
case time.Sunday:
next = next.AddDate(0, 0, 1)
}
return next.Format("20060102")
}
// ListDailyDirections 查询方向分析列表。
func (d *Deps) ListDailyDirections(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
symbol := q.Get("symbol")
start := q.Get("start")
end := q.Get("end")
limit := 50
if l, err := strconv.Atoi(q.Get("limit")); err == nil {
limit = l
}
items, err := d.Futures.ListDailyDirections(symbol, start, end, limit)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}

View File

@@ -13,6 +13,7 @@ type Deps struct {
Auth *store.AuthStore Auth *store.AuthStore
Futures *store.FuturesStore Futures *store.FuturesStore
TushareURL string TushareURL string
AIConfig *AIConfig
} }
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {

View File

@@ -0,0 +1,86 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"trade/web/internal/store"
)
type llmConfigReq struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
type llmConfigResp struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"` // 脱敏:仅显示首尾
Model string `json:"model"`
HasAPIKey bool `json:"has_api_key"` // 前端据此判断是否已配置
}
// maskKey 将 sk-abc123xyz 脱敏为 sk-a...xyz。
func maskKey(key string) string {
if len(key) <= 8 {
return strings.Repeat("*", len(key))
}
return key[:4] + "..." + key[len(key)-4:]
}
// GetLLMConfig 返回当前 LLM 配置API Key 脱敏)。
func (d *Deps) GetLLMConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := d.Futures.GetLLMConfig()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
resp := llmConfigResp{
BaseURL: cfg.BaseURL,
Model: cfg.Model,
HasAPIKey: cfg.APIKey != "",
}
if cfg.APIKey != "" {
resp.APIKey = maskKey(cfg.APIKey)
}
writeJSON(w, http.StatusOK, resp)
}
// SaveLLMConfig 保存 LLM 配置。api_key 为脱敏占位时保留原值。
func (d *Deps) SaveLLMConfig(w http.ResponseWriter, r *http.Request) {
var req llmConfigReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
req.BaseURL = strings.TrimSpace(req.BaseURL)
req.Model = strings.TrimSpace(req.Model)
if req.BaseURL == "" {
req.BaseURL = "https://api.deepseek.com/v1"
}
if req.Model == "" {
req.Model = "deepseek-chat"
}
// 如果前端传的是脱敏值(含 ...),说明未修改 Key保留旧值
if strings.Contains(req.APIKey, "...") {
old, err := d.Futures.GetLLMConfig()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
req.APIKey = old.APIKey
}
cfg := &store.LLMConfig{
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Model: req.Model,
}
if err := d.Futures.SaveLLMConfig(cfg); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,22 @@
package handlers
import (
"fmt"
"io"
"net/http"
"time"
)
func (d *Deps) AdminResetData(w http.ResponseWriter, r *http.Request) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/admin/reset-data", "application/json", nil)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}

View File

@@ -42,6 +42,86 @@ func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
func (d *Deps) RunBatch(w http.ResponseWriter, r *http.Request) {
client := &http.Client{Timeout: 180 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/batch", "application/json", nil)
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)
}
type runRangeRequest struct {
Symbol string `json:"symbol,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
var req runRangeRequest
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: 180 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/range", "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)
}
type runFullRequest struct {
TsCode string `json:"ts_code"`
}
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
var req runFullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.TsCode == "" {
writeErr(w, http.StatusBadRequest, "ts_code is required")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 300 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "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) { func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol") symbol := r.URL.Query().Get("symbol")
if symbol == "" { if symbol == "" {

View File

@@ -39,6 +39,13 @@ func (r *statusRecorder) WriteHeader(code int) {
r.ResponseWriter.WriteHeader(code) r.ResponseWriter.WriteHeader(code)
} }
// Flush 透传 http.Flusher,避免 SSE 流式响应被中间件阻断。
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -31,6 +31,12 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
r.Get("/contracts/active", d.GetActiveContract) r.Get("/contracts/active", d.GetActiveContract)
r.Get("/candles", d.ListCandles) r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline) r.Post("/run", d.RunPipeline)
r.Post("/run/batch", d.RunBatch)
r.Post("/run/range", d.RunRange)
r.Post("/run/full", d.RunFull)
r.Get("/ai/analyze", d.Analyze)
r.Post("/ai/daily-direction", d.DailyDirectionRun)
r.Get("/ai/daily-direction", d.ListDailyDirections)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
@@ -38,6 +44,9 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
r.Post("/admin/users", d.AdminCreateUser) r.Post("/admin/users", d.AdminCreateUser)
r.Patch("/admin/users/{id}", d.AdminPatchUser) r.Patch("/admin/users/{id}", d.AdminPatchUser)
r.Delete("/admin/users/{id}", d.AdminDeleteUser) r.Delete("/admin/users/{id}", d.AdminDeleteUser)
r.Post("/admin/reset-data", d.AdminResetData)
r.Get("/admin/llm-config", d.GetLLMConfig)
r.Put("/admin/llm-config", d.SaveLLMConfig)
}) })
}) })
}) })

View File

@@ -0,0 +1,134 @@
package store
import (
"database/sql"
"encoding/json"
"fmt"
)
// AnalysisContext 汇总一次 AI 分析所需的全部数据。
type AnalysisContext struct {
Score Score `json:"score"`
Candles []Candle `json:"candles"`
RecentScores []Score `json:"recent_scores"`
}
// GetAnalysisContext 拉取指定合约某日的 score + 近 60 日 K 线 + 近 10 日 scores。
func (s *FuturesStore) GetAnalysisContext(tsCode, tradeDate string) (*AnalysisContext, error) {
ctx := &AnalysisContext{}
// 1) 目标日 score含 detail_json
row := s.db.QueryRow(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, detail_json, created_at FROM scores
WHERE ts_code = $1 AND trade_date = $2`, tsCode, tradeDate)
var detail sql.NullString
if err := row.Scan(&ctx.Score.ID, &ctx.Score.TsCode, &ctx.Score.TradeDate,
&ctx.Score.Close, &ctx.Score.OI, &ctx.Score.OIChg,
&ctx.Score.ShortTerm, &ctx.Score.MediumTerm, &ctx.Score.LongTerm,
&ctx.Score.Composite, &ctx.Score.Signal, &detail, &ctx.Score.CreatedAt); err != nil {
return nil, fmt.Errorf("score not found: %w", err)
}
if detail.Valid {
ctx.Score.Detail = json.RawMessage(detail.String)
}
// 2) 近 60 日 K 线
rows, err := s.db.Query(`SELECT ts_code, trade_date,
COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0),
COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0),
COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0),
COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0),
COALESCE(NULLIF(pre_close, 'NaN'::real), 0)
FROM candles WHERE ts_code = $1 AND trade_date <= $2
ORDER BY trade_date DESC LIMIT 60`, tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("candles: %w", err)
}
defer rows.Close()
for rows.Next() {
var c Candle
if err := rows.Scan(&c.TsCode, &c.TradeDate, &c.Open, &c.High, &c.Low, &c.Close,
&c.Vol, &c.Amount, &c.OI, &c.OIChg, &c.PreClose); err != nil {
return nil, err
}
ctx.Candles = append(ctx.Candles, c)
}
// 反转回升序
for i, j := 0, len(ctx.Candles)-1; i < j; i, j = i+1, j-1 {
ctx.Candles[i], ctx.Candles[j] = ctx.Candles[j], ctx.Candles[i]
}
// 3) 近 10 日 scores不含 detail_json 以减体积)
scoreRows, err := s.db.Query(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, created_at FROM scores
WHERE ts_code = $1 AND trade_date <= $2
ORDER BY trade_date DESC LIMIT 10`, tsCode, tradeDate)
if err != nil {
return nil, fmt.Errorf("recent scores: %w", err)
}
defer scoreRows.Close()
for scoreRows.Next() {
var sc Score
if err := scoreRows.Scan(&sc.ID, &sc.TsCode, &sc.TradeDate, &sc.Close, &sc.OI, &sc.OIChg,
&sc.ShortTerm, &sc.MediumTerm, &sc.LongTerm, &sc.Composite, &sc.Signal, &sc.CreatedAt); err != nil {
return nil, err
}
ctx.RecentScores = append(ctx.RecentScores, sc)
}
// 反转回升序
for i, j := 0, len(ctx.RecentScores)-1; i < j; i, j = i+1, j-1 {
ctx.RecentScores[i], ctx.RecentScores[j] = ctx.RecentScores[j], ctx.RecentScores[i]
}
return ctx, nil
}
// LLMConfig 数据库中的 LLM 配置单例。
type LLMConfig struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
}
// EnsureLLMConfigTable 建 llm_config 表(幂等)。
func (s *FuturesStore) EnsureLLMConfigTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK(id = 1),
base_url TEXT NOT NULL DEFAULT 'https://api.deepseek.com/v1',
api_key TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT 'deepseek-chat',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO llm_config (id) VALUES (1) ON CONFLICT DO NOTHING;
`)
return err
}
// GetLLMConfig 读取 LLM 配置,无记录返回零值。
func (s *FuturesStore) GetLLMConfig() (*LLMConfig, error) {
cfg := &LLMConfig{}
err := s.db.QueryRow(`SELECT base_url, api_key, model FROM llm_config WHERE id = 1`).
Scan(&cfg.BaseURL, &cfg.APIKey, &cfg.Model)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return cfg, nil
}
return nil, err
}
return cfg, nil
}
// SaveLLMConfig 写入 LLM 配置upsert
func (s *FuturesStore) SaveLLMConfig(cfg *LLMConfig) error {
_, err := s.db.Exec(`
INSERT INTO llm_config (id, base_url, api_key, model, updated_at)
VALUES (1, $1, $2, $3, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE SET
base_url = EXCLUDED.base_url,
api_key = EXCLUDED.api_key,
model = EXCLUDED.model,
updated_at = CURRENT_TIMESTAMP
`, cfg.BaseURL, cfg.APIKey, cfg.Model)
return err
}

View File

@@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
args = append(args, "%"+f.Signal+"%") 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 > 1000 {
f.Limit = 200 f.Limit = 200
} }
q += " LIMIT " + next() q += " LIMIT " + next()
@@ -144,14 +144,136 @@ type Candle struct {
PreClose float64 `json:"pre_close"` PreClose float64 `json:"pre_close"`
} }
// ── Daily Direction ────────────────────────────────────────────────
// DailyDirection 日内方向分析结果。
type DailyDirection struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
TradeDate string `json:"trade_date"`
TargetDate string `json:"target_date"`
Direction string `json:"direction"`
Confidence float64 `json:"confidence"`
Support string `json:"support"` // JSONB → string
Resistance string `json:"resistance"` // JSONB → string
Reasoning string `json:"reasoning"`
RiskNote string `json:"risk_note"`
PromptSnapshot string `json:"prompt_snapshot,omitempty"`
CreatedAt string `json:"created_at"`
}
// EnsureDailyDirectionTable 建 daily_direction 表(幂等)。
func (s *FuturesStore) EnsureDailyDirectionTable() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS daily_direction (
id UUID DEFAULT uuidv7() PRIMARY KEY,
symbol TEXT NOT NULL,
trade_date TEXT NOT NULL,
target_date TEXT NOT NULL,
direction TEXT NOT NULL,
confidence REAL,
support JSONB,
resistance JSONB,
reasoning TEXT,
risk_note TEXT,
prompt_snapshot TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (symbol, trade_date)
)
`)
return err
}
// SaveDailyDirection 写入upsert一条方向分析。
func (s *FuturesStore) SaveDailyDirection(dd *DailyDirection) error {
_, err := s.db.Exec(`
INSERT INTO daily_direction
(symbol, trade_date, target_date, direction, confidence, support, resistance, reasoning, risk_note, prompt_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (symbol, trade_date) DO UPDATE SET
target_date = EXCLUDED.target_date,
direction = EXCLUDED.direction,
confidence = EXCLUDED.confidence,
support = EXCLUDED.support,
resistance = EXCLUDED.resistance,
reasoning = EXCLUDED.reasoning,
risk_note = EXCLUDED.risk_note,
prompt_snapshot = EXCLUDED.prompt_snapshot,
created_at = CURRENT_TIMESTAMP
`, dd.Symbol, dd.TradeDate, dd.TargetDate, dd.Direction, dd.Confidence,
dd.Support, dd.Resistance, dd.Reasoning, dd.RiskNote, dd.PromptSnapshot)
return err
}
// ListDailyDirections 查询方向分析列表。
func (s *FuturesStore) ListDailyDirections(symbol, start, end string, limit int) ([]DailyDirection, error) {
if limit <= 0 || limit > 500 {
limit = 50
}
q := `SELECT id, symbol, trade_date, target_date, direction, confidence,
COALESCE(support::text, '[]'), COALESCE(resistance::text, '[]'),
reasoning, risk_note, COALESCE(prompt_snapshot, ''),
COALESCE(created_at::text, '')
FROM daily_direction WHERE 1=1`
args := []any{}
n := 0
next := func() string { n++; return fmt.Sprintf("$%d", n) }
if symbol != "" {
q += " AND symbol = " + next()
args = append(args, symbol)
}
if start != "" {
q += " AND trade_date >= " + next()
args = append(args, start)
}
if end != "" {
q += " AND trade_date <= " + next()
args = append(args, end)
}
q += " ORDER BY trade_date DESC, symbol ASC LIMIT " + next()
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DailyDirection{}
for rows.Next() {
var dd DailyDirection
if err := rows.Scan(&dd.ID, &dd.Symbol, &dd.TradeDate, &dd.TargetDate,
&dd.Direction, &dd.Confidence, &dd.Support, &dd.Resistance,
&dd.Reasoning, &dd.RiskNote, &dd.PromptSnapshot, &dd.CreatedAt); err != nil {
return nil, err
}
out = append(out, dd)
}
return out, rows.Err()
}
// GetActiveTsCode 通过 scores 表查找某品种在指定日期的活跃合约代码。
func (s *FuturesStore) GetActiveTsCode(symbol, tradeDate string) (string, error) {
var tsCode string
err := s.db.QueryRow(
`SELECT ts_code FROM scores WHERE trade_date = $1 AND ts_code LIKE $2 || '%' ORDER BY ts_code DESC LIMIT 1`,
tradeDate, symbol,
).Scan(&tsCode)
if err != nil {
return "", fmt.Errorf("no active contract for %s on %s: %w", symbol, tradeDate, err)
}
return tsCode, nil
}
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) { func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
if tsCode == "" { if tsCode == "" {
return nil, ErrMissingTsCode return nil, ErrMissingTsCode
} }
q := `SELECT ts_code, trade_date, q := `SELECT ts_code, trade_date,
COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0), COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0),
COALESCE(vol, 0), COALESCE(amount, 0), COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0),
COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0) COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0),
COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0),
COALESCE(NULLIF(pre_close, 'NaN'::real), 0)
FROM candles WHERE ts_code = $1` FROM candles WHERE ts_code = $1`
args := []any{tsCode} args := []any{tsCode}
n := 1 n := 1

View File

@@ -40,7 +40,23 @@ func main() {
log.Fatalf("bootstrap: %v", err) log.Fatalf("bootstrap: %v", err)
} }
deps := &handlers.Deps{Auth: authDB, Futures: futures, TushareURL: cfg.TushareAPIURL} if err := auth.BootstrapLLMConfig(futures); err != nil {
log.Fatalf("bootstrap llm config: %v", err)
}
if err := futures.EnsureDailyDirectionTable(); err != nil {
log.Fatalf("bootstrap daily_direction: %v", err)
}
deps := &handlers.Deps{
Auth: authDB,
Futures: futures,
TushareURL: cfg.TushareAPIURL,
AIConfig: &handlers.AIConfig{
BaseURL: cfg.LLMBaseURL,
APIKey: cfg.LLMAPIKey,
Model: cfg.LLMModel,
},
}
dist, err := fs.Sub(distFS, "dist") dist, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"marked": "^15.0.0",
"element-plus": "^2.8.4", "element-plus": "^2.8.4",
"pinia": "^2.2.4", "pinia": "^2.2.4",
"vue": "^3.5.10", "vue": "^3.5.10",

View File

@@ -1,13 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
import { resetAllData } from '@/api/admin'
const auth = useAuthStore() const auth = useAuthStore()
const theme = useThemeStore() const theme = useThemeStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { isMobile } = useMobile()
const drawerOpen = ref(false)
const resetting = ref(false)
const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token) const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token)
@@ -21,11 +28,42 @@ function logout() {
auth.logout() auth.logout()
router.replace('/login') router.replace('/login')
} }
function closeDrawer() {
drawerOpen.value = false
}
async function handleReset() {
closeDrawer()
try {
await ElMessageBox.prompt('此操作将清空所有行情数据candles + scores不可恢复。请输入"确认重置"后继续:', '数据重置', {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
inputPattern: /^确认重置$/,
inputErrorMessage: '请输入"确认重置"',
type: 'warning',
})
resetting.value = true
await resetAllData()
ElMessage.success('已清空所有行情数据')
if (route.path === '/scores') {
router.go(0)
} else {
router.push('/scores')
}
} catch {
// user cancelled
} finally {
resetting.value = false
}
}
</script> </script>
<template> <template>
<el-container v-if="showLayout" class="app"> <el-container v-if="showLayout" class="app">
<el-aside width="220px" class="aside" :class="{ 'aside-light': !theme.isDark }"> <!-- desktop sidebar -->
<el-aside v-if="!isMobile" width="220px" class="aside" :class="{ 'aside-light': !theme.isDark }">
<div class="brand">期货报告</div> <div class="brand">期货报告</div>
<el-menu <el-menu
:default-active="route.path" :default-active="route.path"
@@ -34,19 +72,57 @@ function logout() {
:text-color="menuColors.text" :text-color="menuColors.text"
:active-text-color="menuColors.active" :active-text-color="menuColors.active"
> >
<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="/contract-full">合约全景</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item> <el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</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-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
数据重置
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<!-- mobile drawer overlay -->
<template v-else>
<div v-if="drawerOpen" class="drawer-mask" @click="closeDrawer"></div>
<el-aside
:width="drawerOpen ? '220px' : '0px'"
class="aside mobile-aside"
:class="{ 'aside-light': !theme.isDark, open: drawerOpen }"
>
<div class="brand">期货报告</div>
<el-menu
:default-active="route.path"
router
:background-color="menuColors.bg"
:text-color="menuColors.text"
:active-text-color="menuColors.active"
@select="closeDrawer"
>
<el-menu-item index="/scores">品种打分</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item index="/contract-full">合约全景</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu>
</el-aside>
</template>
<el-container> <el-container>
<el-header class="header"> <el-header class="header">
<div class="user"> <div class="left">
<span>{{ auth.user?.username }}</span> <el-button v-if="isMobile" text class="hamburger" @click="drawerOpen = !drawerOpen">
<el-tag size="small" :type="auth.isAdmin ? 'danger' : 'info'"> <span style="font-size: 20px; line-height: 1"></span>
{{ auth.isAdmin ? '管理员' : '普通用户' }} </el-button>
</el-tag> <div class="user">
<span>{{ auth.user?.username }}</span>
<el-tag size="small" :type="auth.isAdmin ? 'danger' : 'info'">
{{ auth.isAdmin ? '管理员' : '普通用户' }}
</el-tag>
</div>
</div> </div>
<div class="right"> <div class="right">
<el-switch <el-switch
@@ -78,6 +154,7 @@ body,
body { body {
background-color: var(--el-bg-color-page); background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
overflow: hidden;
} }
.app { .app {
height: 100%; height: 100%;
@@ -85,12 +162,31 @@ body {
.aside { .aside {
background: #282828; background: #282828;
color: #cfd8e3; color: #cfd8e3;
transition: width 0.3s ease;
overflow: hidden;
} }
.aside-light { .aside-light {
background: #f9fafb; background: #f9fafb;
color: #1f2937; color: #1f2937;
border-right: 1px solid var(--el-border-color-light); border-right: 1px solid var(--el-border-color-light);
} }
.mobile-aside {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 2001;
width: 0;
}
.mobile-aside.open {
width: 220px;
}
.drawer-mask {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.45);
}
.brand { .brand {
height: 60px; height: 60px;
display: flex; display: flex;
@@ -99,6 +195,7 @@ body {
font-size: 18px; font-size: 18px;
letter-spacing: 2px; letter-spacing: 2px;
border-bottom: 1px solid #3a3a3a; border-bottom: 1px solid #3a3a3a;
white-space: nowrap;
} }
.aside-light .brand { .aside-light .brand {
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
@@ -110,6 +207,15 @@ body {
background: var(--el-bg-color); background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light); border-bottom: 1px solid var(--el-border-color-light);
} }
.left {
display: flex;
align-items: center;
gap: 12px;
}
.hamburger {
padding: 4px !important;
height: auto !important;
}
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -123,4 +229,20 @@ body {
.el-menu { .el-menu {
border-right: none !important; border-right: none !important;
} }
/* mobile overrides */
@media (max-width: 768px) {
.header {
padding: 0 12px !important;
}
.right {
gap: 8px;
}
.user span {
display: none;
}
.el-main {
padding: 8px !important;
}
}
</style> </style>

View File

@@ -0,0 +1,20 @@
import client from './client'
export function resetAllData() {
return client.post('/admin/reset-data').then((r) => r.data)
}
export interface LLMConfig {
base_url: string
api_key: string
model: string
has_api_key: boolean
}
export function getLLMConfig() {
return client.get<LLMConfig>('/admin/llm-config').then((r) => r.data)
}
export function saveLLMConfig(cfg: { base_url: string; api_key: string; model: string }) {
return client.put('/admin/llm-config', cfg).then((r) => r.data)
}

View File

@@ -0,0 +1,46 @@
import client from './client'
export interface DailyDirection {
id: string
symbol: string
trade_date: string
target_date: string
direction: string
confidence: number
support: string // JSON string of number[]
resistance: string // JSON string of number[]
reasoning: string
risk_note: string
created_at: string
}
export interface DailyDirectionRunRequest {
trade_date?: string
symbols?: string[]
}
export interface DailyDirectionRunResult {
symbol: string
direction: string
confidence: number
error?: string
}
export interface DailyDirectionRunResponse {
trade_date: string
results: DailyDirectionRunResult[]
errors?: DailyDirectionRunResult[]
}
export function runDailyDirection(req?: DailyDirectionRunRequest) {
return client.post<DailyDirectionRunResponse>('/ai/daily-direction', req ?? {}, { timeout: 300_000 }).then((r) => r.data)
}
export function listDailyDirections(params?: {
symbol?: string
start?: string
end?: string
limit?: number
}) {
return client.get<DailyDirection[]>('/ai/daily-direction', { params }).then((r) => r.data)
}

View File

@@ -27,7 +27,62 @@ export interface ActiveContract {
} }
export function runPipeline(req: RunRequest) { export function runPipeline(req: RunRequest) {
return client.post<RunResponse>('/run', req).then((r) => r.data) return client.post<RunResponse>('/run', req, { timeout: 60_000 }).then((r) => r.data)
}
export interface RunRangeRequest {
symbol: string
start_date: string
end_date: string
}
export interface RunRangeResult {
trade_date: string
close: number
composite: number
signal: string
}
export interface RunRangeResponse {
ts_code: string
start_date: string
end_date: string
scored: number
skipped: number
warnings: string[]
results: RunRangeResult[]
}
export interface RunFullRequest {
ts_code: string
}
export interface RunFullResult {
trade_date: string
close: number
composite: number
signal: string
}
export interface RunFullResponse {
ts_code: string
total_days: number
scored_count: number
skipped_count: number
warnings: string[]
results: RunFullResult[]
}
export function runRange(req: RunRangeRequest) {
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
}
export function runFull(req: RunFullRequest) {
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
}
export function runBatch() {
return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data)
} }
export function getActiveContract(symbol: string) { export function getActiveContract(symbol: string) {

View File

@@ -7,26 +7,60 @@ export interface ShortDetail {
oi: number oi: number
oi_chg: number oi_chg: number
score: number score: number
oi_chg_pct: number
price_chg_pct: number
vol: number
vol_ratio: number
quadrant: string
} }
export interface MediumDetail { export interface MediumDetail {
price_return_pct: number price_return_pct: number
price_signal: number price_signal: number
long_up_days: number accumulation_days: number
long_down_days: number distribution_days: number
covering_days: number
liquidation_days: number
fund_signal: number fund_signal: number
window: number
} }
export interface LongDetail { export interface LongDetail {
avg_oi: number oi_now: number
oi_before: number oi_before: number
change_pct: number oi_change_pct: number
oi_score: number
price_score: number
price_return_30d_pct: number
price_before_30d: number
avg_oi_30d: number
window: number
}
export interface VolatilityDetail {
daily_vol_pct: number
atr_pct: number
vol_risk: number
vol_penalty: number
}
export interface AdaptiveWeights {
trend_strength: number
trend_factor: number
w_short: number
w_medium: number
w_long: number
} }
export interface ScoreDetail { export interface ScoreDetail {
short_details?: ShortDetail[] short_details?: ShortDetail[]
medium_detail?: MediumDetail medium_detail?: MediumDetail
long_detail?: LongDetail long_detail?: LongDetail
volatility?: VolatilityDetail
adaptive_weights?: AdaptiveWeights
vol_penalty?: number
composite_delta?: number | null
composite_delta_5d?: number | null
} }
export interface Score { export interface Score {

View File

@@ -3,9 +3,11 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { Candle } from '@/api/candles' import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
const props = defineProps<{ data: Candle[] }>() const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
const theme = useThemeStore() const theme = useThemeStore()
const { isMobile } = useMobile()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null let chart: echarts.ECharts | null = null
@@ -26,51 +28,109 @@ function render() {
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high]) const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi) const oi = props.data.map((c) => c.oi)
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
const hasScores = props.scores && props.scores.length > 0
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
const grids: any[] = [
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
]
const xAxes: any[] = [
{ type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
]
const yAxes: any[] = [
{ scale: true, splitArea: { show: true }, name: '价格', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
{ gridIndex: 1, scale: true, splitNumber: 3, name: '持仓', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
]
const series: any[] = [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
]
if (hasScores) {
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2, name: '综合分', nameLocation: 'end', nameTextStyle: { fontSize: 11 } })
series.push({
name: '综合分',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: compositeData,
barWidth: '60%',
itemStyle: {
color: (params: any) => {
const val = params.value as number | null
if (val == null) return 'transparent'
if (val >= 80) return '#ec3a3a'
if (val >= 50) return '#f89898'
if (val >= 40) return '#89d6c7'
return '#26a69a'
},
},
})
}
chart.setOption( chart.setOption(
{ {
backgroundColor: 'transparent', backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, tooltip: {
legend: { data: ['K 线', '持仓量'], top: 0 }, trigger: 'axis',
grid: [ axisPointer: { type: 'cross' },
{ left: 60, right: 40, top: 40, height: '60%' }, formatter: (params: any) => {
{ left: 60, right: 40, top: '78%', height: '18%' }, if (!Array.isArray(params)) return ''
], const date = params[0]?.axisValue || ''
xAxis: [ let html = `<strong>${date}</strong><br/>`
{ type: 'category', data: dates, scale: true, boundaryGap: false }, for (const p of params) {
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } }, if (p.seriesName === 'K 线') {
], const ohlc = p.data as number[]
yAxis: [ const labels = ['开盘', '收盘', '最低', '最高']
{ scale: true, splitArea: { show: true } }, html += labels.map((n, i) => `${p.marker} ${n}: ${ohlc[i] ?? '-'}`).join('<br/>') + '<br/>'
{ gridIndex: 1, scale: true, splitNumber: 3 }, continue
], }
let name = p.seriesName
let val: string
if (name === '持仓量') val = (p.data as number)?.toLocaleString() ?? '-'
else if (name === '综合分') val = (p.data as number)?.toFixed(1) ?? '-'
else val = p.data ?? '-'
html += `${p.marker} ${name}: ${val}<br/>`
}
return html
},
},
legend: { data: legendData, top: 0 },
grid: grids,
xAxis: xAxes,
yAxis: yAxes,
dataZoom: [ dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1] }, { type: 'inside', xAxisIndex: xAxisIndices },
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 }, { type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
],
series: [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
], ],
series,
}, },
true, true,
) )
@@ -100,6 +160,10 @@ watch(
render() render()
}, },
) )
watch(isMobile, () => {
ensureChart()
render()
})
</script> </script>
<template> <template>
@@ -111,4 +175,9 @@ watch(
width: 100%; width: 100%;
height: 560px; height: 560px;
} }
@media (max-width: 768px) {
.chart {
height: 420px;
}
}
</style> </style>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { getScore, type Score } from '@/api/scores' import { getScore, type Score } from '@/api/scores'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps<{ scoreId: number | null }>() const props = defineProps<{ scoreId: number | null }>()
const emit = defineEmits<{ (e: 'close'): void }>() const emit = defineEmits<{ (e: 'close'): void }>()
@@ -9,6 +13,43 @@ const emit = defineEmits<{ (e: 'close'): void }>()
const score = ref<Score | null>(null) const score = ref<Score | null>(null)
const loading = ref(false) const loading = ref(false)
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let es: EventSource | null = null
function closeAI() {
es?.close()
es = null
aiLoading.value = false
}
async function askAI() {
if (!score.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const url = `/api/ai/analyze?ts_code=${encodeURIComponent(score.value.ts_code)}&trade_date=${encodeURIComponent(score.value.trade_date)}`
es = new EventSource(url)
es.addEventListener('token', (e) => {
aiContent.value += e.data
})
es.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
es.addEventListener('done', () => {
closeAI()
})
es.onerror = () => {
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
closeAI()
}
}
const visible = computed({ const visible = computed({
get: () => props.scoreId !== null, get: () => props.scoreId !== null,
set: (v) => { set: (v) => {
@@ -19,6 +60,9 @@ const visible = computed({
watch( watch(
() => props.scoreId, () => props.scoreId,
async (id) => { async (id) => {
closeAI()
aiContent.value = ''
aiError.value = ''
if (id === null) { if (id === null) {
score.value = null score.value = null
return return
@@ -31,12 +75,34 @@ watch(
} }
}, },
) )
const quadrantTag = (q: string) => {
const map: Record<string, string> = {
accumulation: 'success',
distribution: 'warning',
covering: 'info',
liquidation: 'danger',
flat: '',
}
return map[q] ?? ''
}
const quadrantLabel = (q: string) => {
const map: Record<string, string> = {
accumulation: '增仓涨',
distribution: '增仓跌',
covering: '减仓涨',
liquidation: '减仓跌',
flat: '持平',
}
return map[q] ?? q
}
</script> </script>
<template> <template>
<el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close> <el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" 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="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</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="合约">{{ 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>
@@ -48,54 +114,174 @@ watch(
<el-descriptions-item label="信号"> <el-descriptions-item label="信号">
<el-tag>{{ score.signal }}</el-tag> <el-tag>{{ score.signal }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="短期(7d × 0.4)">{{ score.short_term.toFixed(2) }}</el-descriptions-item> <el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="中期(15d × 0.35)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item> <el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="长期(30d × 0.25)" :span="2"> <el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
{{ score.long_term.toFixed(2) }} {{ score.long_term.toFixed(2) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta != null" label="Δ1d">
<span :style="{ color: score.detail.composite_delta >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta >= 0 ? '+' : '' }}{{ score.detail.composite_delta.toFixed(1) }}
</span>
</el-descriptions-item>
<el-descriptions-item v-if="score.detail?.composite_delta_5d != null" label="Δ5d">
<span :style="{ color: score.detail.composite_delta_5d >= 0 ? '#e4393c' : '#1ca11c' }">
{{ score.detail.composite_delta_5d >= 0 ? '+' : '' }}{{ score.detail.composite_delta_5d.toFixed(1) }}
</span>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4 class="section">短期 7 日逐日打分</h4> <h4 class="section">短期 7 日逐日打分</h4>
<el-table :data="score.detail?.short_details ?? []" size="small" border> <div class="table-wrapper">
<el-table-column prop="trade_date" label="日期" width="100" /> <el-table
<el-table-column prop="close" label="收盘" /> :data="score.detail?.short_details ?? []"
<el-table-column prop="pre_close" label="昨收" /> size="small"
<el-table-column prop="oi" label="持仓" /> border
<el-table-column prop="oi_chg" label="持仓变化" /> class="detail-table"
<el-table-column prop="score" label="单日得分" /> max-height="400"
</el-table> >
<el-table-column prop="trade_date" label="日期" width="100" />
<el-table-column prop="close" label="收盘" width="70" />
<el-table-column label="涨跌幅" width="80">
<template #default="{ row }">
<span :style="{ color: row.price_chg_pct >= 0 ? '#e4393c' : '#1ca11c' }">
{{ ((row.price_chg_pct ?? 0) * 100).toFixed(2) }}%
</span>
</template>
</el-table-column>
<el-table-column label="OI变化%" width="90">
<template #default="{ row }">
{{ ((row.oi_chg_pct ?? 0) * 100).toFixed(2) }}%
</template>
</el-table-column>
<el-table-column label="量比" width="65">
<template #default="{ row }">
{{ row.vol_ratio ?? '-' }}
</template>
</el-table-column>
<el-table-column label="象限" width="85">
<template #default="{ row }">
<el-tag :type="quadrantTag(row.quadrant)" size="small">
{{ quadrantLabel(row.quadrant) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="score" label="得分" width="65" />
</el-table>
</div>
<h4 class="section">中期(15d)细节</h4> <h4 class="section">中期(15d)资金意愿</h4>
<el-descriptions :column="2" border v-if="score.detail?.medium_detail"> <el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
<el-descriptions-item label="价格收益率"> <el-descriptions-item label="价格收益率">
{{ (score.detail.medium_detail.price_return_pct * 100).toFixed(2) }}% {{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="价格信号分"> <el-descriptions-item label="价格信号分">
{{ score.detail.medium_detail.price_signal.toFixed(2) }} {{ score.detail.medium_detail.price_signal.toFixed(2) }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="增仓上涨"> <el-descriptions-item label="增仓上涨">
{{ score.detail.medium_detail.long_up_days }} {{ score.detail.medium_detail.accumulation_days }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="增仓下跌"> <el-descriptions-item label="增仓下跌">
{{ score.detail.medium_detail.long_down_days }} {{ score.detail.medium_detail.distribution_days }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="资金意愿分" :span="2"> <el-descriptions-item label="减仓上涨">
{{ score.detail.medium_detail.fund_signal }} {{ score.detail.medium_detail.covering_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓下跌">
{{ score.detail.medium_detail.liquidation_days }}
</el-descriptions-item>
<el-descriptions-item label="资金意愿分" :span="isMobile ? 1 : 2">
{{ score.detail.medium_detail.fund_signal.toFixed(1) }}
<span class="formula-hint">(四象限加权合成)</span>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4 class="section">长期(30d)细节</h4> <h4 class="section">长期(30d)结构</h4>
<el-descriptions :column="2" border v-if="score.detail?.long_detail"> <el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
<el-descriptions-item label="30 日均持仓"> <el-descriptions-item label="OI 趋势分">
{{ score.detail.long_detail.avg_oi.toFixed(0) }} {{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
<span class="formula-hint">(权重 60%)</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="30 日前持仓"> <el-descriptions-item label="价格趋势分">
{{ score.detail.long_detail.oi_before.toFixed(0) }} {{ score.detail.long_detail.price_score?.toFixed(1) ?? '-' }}
<span class="formula-hint">(权重 40%)</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="变化幅度" :span="2"> <el-descriptions-item label="30 日价格收益">
{{ (score.detail.long_detail.change_pct * 100).toFixed(2) }}% {{ score.detail.long_detail.price_return_30d_pct?.toFixed(2) ?? '-' }}%
</el-descriptions-item>
<el-descriptions-item label="30 日前收盘">
{{ score.detail.long_detail.price_before_30d?.toFixed(2) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="当前 OI">
{{ score.detail.long_detail.oi_now?.toFixed(0) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="30 日前 OI">
{{ score.detail.long_detail.oi_before?.toFixed(0) ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="OI 变化幅度" :span="isMobile ? 1 : 2">
{{ score.detail.long_detail.oi_change_pct?.toFixed(2) ?? '-' }}%
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4 class="section">波动率调整</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.volatility">
<el-descriptions-item label="日波动率(30d std)">
{{ (score.detail.volatility.daily_vol_pct * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="ATR%">
{{ (score.detail.volatility.atr_pct * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="综合波动风险" v-if="score.detail.volatility.vol_risk !== undefined">
{{ (score.detail.volatility.vol_risk * 100).toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="惩罚系数">
{{ score.detail.volatility.vol_penalty.toFixed(3) }}
</el-descriptions-item>
</el-descriptions>
<template v-if="score.detail?.adaptive_weights">
<h4 class="section">自适应权重</h4>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="趋势强度">
{{ score.detail.adaptive_weights.trend_strength.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="趋势因子">
{{ (score.detail.adaptive_weights.trend_factor * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="短期权重">
{{ (score.detail.adaptive_weights.w_short * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="中期权重">
{{ (score.detail.adaptive_weights.w_medium * 100).toFixed(0) }}%
</el-descriptions-item>
<el-descriptions-item label="长期权重" :span="isMobile ? 1 : 2">
{{ (score.detail.adaptive_weights.w_long * 100).toFixed(0) }}%
</el-descriptions-item>
</el-descriptions>
</template>
<el-divider />
<div class="ai-section">
<div v-if="!aiLoading && !aiContent && !aiError">
<el-button type="primary" :loading="aiLoading" @click="askAI">
🤖 AI 分析当前打分
</el-button>
</div>
<div v-if="aiLoading || aiContent || aiError" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="closeAI" v-if="aiLoading">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="marked(aiContent)"></div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading">
<el-icon class="is-loading"><span></span></el-icon> 正在分析...
</div>
</div>
</div>
</div>
</div> </div>
</el-drawer> </el-drawer>
</template> </template>
@@ -104,4 +290,70 @@ watch(
.section { .section {
margin: 18px 0 8px; margin: 18px 0 8px;
} }
.table-wrapper {
overflow-x: auto;
}
.detail-table {
min-width: 620px;
}
.formula-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: 6px;
}
.ai-section {
margin-top: 4px;
}
.ai-card {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-top: 8px;
overflow: hidden;
}
.ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-weight: 600;
}
.ai-body {
padding: 12px;
}
.ai-text {
line-height: 1.7;
}
.ai-text :deep(p) {
margin: 8px 0;
}
.ai-text :deep(h1),
.ai-text :deep(h2),
.ai-text :deep(h3),
.ai-text :deep(h4) {
margin: 16px 0 8px;
}
.ai-text :deep(ul),
.ai-text :deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
.ai-text :deep(li) {
margin: 4px 0;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
</style>
<style>
/* AI Markdown 输出段落间距(非 scoped确保 v-html 生效) */
.ai-text p { margin: 3px 0; }
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
.ai-text li { margin: 1px 0; }
.ai-text strong { color: var(--el-color-primary, #409eff); }
</style> </style>

View File

@@ -0,0 +1,26 @@
import { ref, onMounted, onUnmounted } from 'vue'
const isMobile = ref(false)
function check() {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
let listeners = 0
export function useMobile() {
onMounted(() => {
if (listeners === 0) {
check()
window.addEventListener('resize', check)
}
listeners++
})
onUnmounted(() => {
listeners--
if (listeners === 0) {
window.removeEventListener('resize', check)
}
})
return { isMobile }
}

View File

@@ -25,11 +25,21 @@ const routes: RouteRecordRaw[] = [
name: 'chart', name: 'chart',
component: () => import('@/views/ChartView.vue'), component: () => import('@/views/ChartView.vue'),
}, },
{
path: '/contract-full',
name: 'contract-full',
component: () => import('@/views/ContractFullView.vue'),
},
{ {
path: '/run', path: '/run',
name: 'run', name: 'run',
component: () => import('@/views/RunView.vue'), component: () => import('@/views/RunView.vue'),
}, },
{
path: '/daily-direction',
name: 'daily-direction',
component: () => import('@/views/DailyDirectionView.vue'),
},
{ {
path: '/admin/users', path: '/admin/users',
name: 'admin-users', name: 'admin-users',

View File

@@ -8,6 +8,7 @@ import {
updateUser, updateUser,
type AdminUser, type AdminUser,
} from '@/api/users' } from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -27,6 +28,52 @@ const resetDialog = reactive({
password: '', password: '',
}) })
// LLM 配置
const llmCfg = reactive({
base_url: 'https://api.deepseek.com/v1',
api_key: '',
model: 'deepseek-chat',
has_api_key: false,
saving: false,
loading: false,
})
async function loadLLMConfig() {
llmCfg.loading = true
try {
const cfg = await getLLMConfig()
llmCfg.base_url = cfg.base_url
llmCfg.api_key = cfg.api_key || ''
llmCfg.model = cfg.model
llmCfg.has_api_key = cfg.has_api_key
} catch {
// ignore
} finally {
llmCfg.loading = false
}
}
async function submitLLMConfig() {
if (!llmCfg.base_url.trim()) {
ElMessage.warning('API 地址不能为空')
return
}
llmCfg.saving = true
try {
await saveLLMConfig({
base_url: llmCfg.base_url.trim(),
api_key: llmCfg.api_key.trim(),
model: llmCfg.model.trim(),
})
ElMessage.success('LLM 配置已保存')
await loadLLMConfig()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '保存失败')
} finally {
llmCfg.saving = false
}
}
async function reload() { async function reload() {
loading.value = true loading.value = true
try { try {
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
await reload() await reload()
} }
onMounted(reload) onMounted(() => {
reload()
loadLLMConfig()
})
</script> </script>
<template> <template>
@@ -103,45 +153,79 @@ onMounted(reload)
</div> </div>
</el-card> </el-card>
<el-table :data="users" v-loading="loading" stripe> <div class="table-wrapper" v-loading="loading">
<el-table-column prop="id" label="ID" width="60" /> <el-table :data="users" stripe class="user-table">
<el-table-column prop="username" label="用户名" /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="role" label="角色" width="100"> <el-table-column prop="username" label="用户名" />
<template #default="{ row }"> <el-table-column prop="role" label="角色" width="100">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">{{ row.role }}</el-tag> <template #default="{ row }">
</template> <el-tag :type="row.role === 'admin' ? 'danger' : 'info'">{{ row.role }}</el-tag>
</el-table-column> </template>
<el-table-column label="状态" width="100"> </el-table-column>
<template #default="{ row }"> <el-table-column label="状态" width="100">
<el-tag :type="row.disabled ? 'warning' : 'success'"> <template #default="{ row }">
{{ row.disabled ? '已禁用' : '正常' }} <el-tag :type="row.disabled ? 'warning' : 'success'">
{{ row.disabled ? '已禁用' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建于" width="180" />
<el-table-column prop="updated_at" label="更新于" width="180" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openReset(row)">重置密码</el-button>
<el-button
link
:type="row.disabled ? 'success' : 'warning'"
:disabled="row.id === auth.user?.id"
@click="toggleDisabled(row)"
>
{{ row.disabled ? '启用' : '禁用' }}
</el-button>
<el-button
link
type="danger"
:disabled="row.id === auth.user?.id"
@click="remove(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>🤖 LLM 配置</span>
<el-tag :type="llmCfg.has_api_key ? 'success' : 'warning'" size="small">
{{ llmCfg.has_api_key ? '已配置' : '未配置' }}
</el-tag> </el-tag>
</template> </div>
</el-table-column> </template>
<el-table-column prop="created_at" label="创建于" width="180" /> <el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
<el-table-column prop="updated_at" label="更新于" width="180" /> <el-form-item label="API 地址">
<el-table-column label="操作" width="280" fixed="right"> <el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
<template #default="{ row }"> </el-form-item>
<el-button link type="primary" @click="openReset(row)">重置密码</el-button> <el-form-item label="API Key">
<el-button <el-input
link v-model="llmCfg.api_key"
:type="row.disabled ? 'success' : 'warning'" type="password"
:disabled="row.id === auth.user?.id" show-password
@click="toggleDisabled(row)" placeholder="sk-..."
> />
{{ row.disabled ? '启用' : '禁用' }} </el-form-item>
<el-form-item label="模型">
<el-input v-model="llmCfg.model" placeholder="deepseek-chat" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="llmCfg.saving" @click="submitLLMConfig">
保存配置
</el-button> </el-button>
<el-button </el-form-item>
link </el-form>
type="danger" </el-card>
:disabled="row.id === auth.user?.id"
@click="remove(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px"> <el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px"> <el-form label-width="80px">
@@ -195,4 +279,24 @@ onMounted(reload)
align-items: center; align-items: center;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
} }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper {
background: var(--el-bg-color);
border-radius: 4px;
overflow-x: auto;
}
.user-table {
min-width: 960px;
}
@media (max-width: 768px) {
.head {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style> </style>

View File

@@ -87,9 +87,12 @@ async function submit() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%); background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
overflow: hidden;
padding: 16px;
} }
.card { .card {
width: 360px; width: 360px;
max-width: 100%;
padding: 36px 32px; padding: 36px 32px;
background: var(--el-bg-color); background: var(--el-bg-color);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
@@ -106,4 +109,9 @@ async function submit() {
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
} }
@media (max-width: 768px) {
.card {
padding: 28px 20px;
}
}
</style> </style>

View File

@@ -4,6 +4,9 @@ import { ElMessage } from 'element-plus'
import { listContracts } from '@/api/scores' import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles' import { listCandles, type Candle } from '@/api/candles'
import KLineChart from '@/components/KLineChart.vue' import KLineChart from '@/components/KLineChart.vue'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({ const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
ts_code: '', ts_code: '',
@@ -40,13 +43,13 @@ onMounted(async () => {
<template> <template>
<div class="page"> <div class="page">
<el-card shadow="never" class="filter-card"> <el-card shadow="never" class="filter-card">
<el-form :inline="true"> <el-form :inline="!isMobile">
<el-form-item label="合约"> <el-form-item label="合约">
<el-select <el-select
v-model="filter.ts_code" v-model="filter.ts_code"
placeholder="选择合约" placeholder="选择合约"
filterable filterable
style="width: 200px" :style="{ width: isMobile ? '100%' : '200px' }"
> >
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" /> <el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select> </el-select>
@@ -59,6 +62,7 @@ onMounted(async () => {
range-separator="" range-separator=""
start-placeholder="" start-placeholder=""
end-placeholder="" end-placeholder=""
:style="{ width: isMobile ? '100%' : 'auto' }"
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles'
import { listScores, type Score } from '@/api/scores'
import { runFull, type RunFullResponse } from '@/api/run'
import KLineChart from '@/components/KLineChart.vue'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
ts_code: '',
range: [],
})
const contracts = ref<string[]>([])
const candles = ref<Candle[]>([])
const scores = ref<Score[]>([])
const loading = ref(false)
const fullLoading = ref(false)
const fullResult = ref<RunFullResponse | null>(null)
async function reload() {
if (!filter.ts_code) {
ElMessage.warning('请输入或选择合约')
return
}
loading.value = true
try {
const [start, end] = filter.range || []
const [candleData, scoreData] = await Promise.all([
listCandles(filter.ts_code, start, end),
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
])
candles.value = candleData
scores.value = scoreData
// 如果数据库里没有该合约数据,自动拉取并打分
if (candleData.length === 0) {
await doFetchAndScore(true)
}
} finally {
loading.value = false
}
}
async function doFetchAndScore(skipConfirm: boolean) {
if (!filter.ts_code) {
ElMessage.warning('请输入或选择合约')
return
}
if (!skipConfirm) {
try {
await ElMessageBox.confirm(
`即将拉取 ${filter.ts_code} 的全部历史数据并逐日打分,这可能需要一些时间。`,
'拉取并打分',
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
}
fullLoading.value = true
fullResult.value = null
try {
const resp = await runFull({ ts_code: filter.ts_code })
fullResult.value = resp
// 用后端返回的完整代码(含交易所后缀)更新输入框
filter.ts_code = resp.ts_code
ElMessage.success(`完成: ${resp.scored_count} 天已打分, ${resp.skipped_count} 天跳过`)
await loadData()
} catch (err: any) {
const msg = err?.response?.data?.error || err.message || '请求失败'
ElMessage.error(msg)
} finally {
fullLoading.value = false
}
}
async function handleFetchAndScore() {
await doFetchAndScore(false)
}
async function loadData() {
if (!filter.ts_code) return
const [start, end] = filter.range || []
const [candleData, scoreData] = await Promise.all([
listCandles(filter.ts_code, start, end),
listScores({ ts_code: filter.ts_code, start, end, limit: 1000 }),
])
candles.value = candleData
scores.value = scoreData
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
if (contracts.value.length > 0) {
filter.ts_code = contracts.value[0]
await reload()
}
})
</script>
<template>
<div class="page">
<el-card shadow="never" class="filter-card">
<el-form :inline="!isMobile">
<el-form-item label="合约">
<div style="display: flex; gap: 8px;">
<el-input
v-model="filter.ts_code"
placeholder="输入合约代码如 FG2509"
clearable
:style="{ width: isMobile ? '100%' : '200px' }"
/>
<el-select
v-model="filter.ts_code"
placeholder="已存合约"
clearable
style="width: 120px"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</div>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="filter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
:style="{ width: isMobile ? '100%' : 'auto' }"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
<el-button type="warning" :loading="fullLoading" @click="handleFetchAndScore">
拉取并打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="fullResult" shadow="never" class="result-card">
<el-descriptions :column="isMobile ? 2 : 4" border>
<el-descriptions-item label="合约">{{ fullResult.ts_code }}</el-descriptions-item>
<el-descriptions-item label="总天数">{{ fullResult.total_days }}</el-descriptions-item>
<el-descriptions-item label="已打分">{{ fullResult.scored_count }}</el-descriptions-item>
<el-descriptions-item label="跳过">{{ fullResult.skipped_count }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="chart-card" v-loading="loading">
<KLineChart
:data="candles"
:scores="scores.map((s) => ({ trade_date: s.trade_date, composite: s.composite }))"
/>
</el-card>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.result-card :deep(.el-card__body) {
padding: 12px 16px;
}
.chart-card :deep(.el-card__body) {
padding: 8px;
}
</style>

View File

@@ -0,0 +1,304 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
listDailyDirections,
runDailyDirection,
type DailyDirection,
type DailyDirectionRunResponse,
} from '@/api/daily'
import { runBatch } from '@/api/run'
const items = ref<DailyDirection[]>([])
const loading = ref(false)
const running = ref(false)
const runStep = ref('')
const runResult = ref<DailyDirectionRunResponse | null>(null)
const selectedRow = ref<DailyDirection | null>(null)
const drawerOpen = ref(false)
const symbolNames: Record<string, string> = {
FG: '玻璃', SA: '纯碱', RB: '螺纹钢', MA: '甲醇', CF: '棉花', M: '豆粕',
}
const directionLabel = (d: string) => {
switch (d) {
case 'bullish': return '看多'
case 'bearish': return '看空'
case 'neutral': return '震荡'
default: return d
}
}
const directionType = (d: string): '' | 'success' | 'danger' | 'warning' => {
switch (d) {
case 'bullish': return 'success'
case 'bearish': return 'danger'
case 'neutral': return 'warning'
default: return ''
}
}
const runSummary = computed(() => {
if (!runResult.value) return ''
const parts: string[] = []
for (const r of runResult.value.results ?? []) {
parts.push(`${symbolNames[r.symbol] ?? r.symbol}: ${directionLabel(r.direction)}`)
}
return parts.join(' | ')
})
const drawerTitle = computed(() => {
if (!selectedRow.value) return ''
const name = symbolNames[selectedRow.value.symbol] ?? selectedRow.value.symbol
return `${name}(${selectedRow.value.symbol}) · ${selectedRow.value.trade_date}`
})
async function fetchList() {
loading.value = true
try {
items.value = await listDailyDirections({ limit: 50 })
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
async function handleRun() {
running.value = true
runResult.value = null
try {
runStep.value = '正在拉取数据…'
await runBatch()
runStep.value = '正在 AI 分析…'
runResult.value = await runDailyDirection()
const ok = runResult.value?.results?.length ?? 0
const fail = runResult.value?.errors?.length ?? 0
if (fail > 0) {
ElMessage.warning(`分析完成:成功 ${ok} 个,失败 ${fail}`)
} else {
ElMessage.success(`已完成 ${ok} 个品种的方向分析`)
}
await fetchList()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '分析失败')
} finally {
running.value = false
}
}
function parseLevels(json: string): number[] {
try {
return JSON.parse(json) as number[]
} catch {
return []
}
}
function levelAt(json: string, i: number): string {
const arr = parseLevels(json)
return arr[i] != null ? String(arr[i]) : '-'
}
function openDrawer(row: DailyDirection) {
selectedRow.value = row
drawerOpen.value = true
}
onMounted(fetchList)
</script>
<template>
<div class="daily-direction">
<div class="toolbar">
<h2>日内方向分析</h2>
<el-button type="primary" :loading="running" @click="handleRun">
{{ running ? runStep : '执行分析' }}
</el-button>
</div>
<el-alert
v-if="runResult"
:title="`${runResult.trade_date} 分析结果`"
:description="runSummary"
type="info"
show-icon
closable
style="margin-bottom: 16px"
/>
<el-table :data="items" stripe v-loading="loading" empty-text="暂无数据,请先执行分析" highlight-current-row @row-click="openDrawer">
<el-table-column prop="symbol" label="品种" width="80">
<template #default="{ row }">
{{ symbolNames[row.symbol] ?? row.symbol }}
</template>
</el-table-column>
<el-table-column prop="trade_date" label="分析日" width="110" />
<el-table-column prop="target_date" label="目标日" width="110" />
<el-table-column prop="direction" label="方向" width="80">
<template #default="{ row }">
<el-tag :type="directionType(row.direction)" size="small">
{{ directionLabel(row.direction) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.confidence >= 70 ? '#67c23a' : row.confidence >= 50 ? '#e6a23c' : '#f56c6c', fontWeight: 'bold' }">
{{ row.confidence }}%
</span>
</template>
</el-table-column>
<el-table-column label="支撑位一" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.support, 0) }}</template>
</el-table-column>
<el-table-column label="支撑位二" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.support, 1) }}</template>
</el-table-column>
<el-table-column label="阻力位一" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.resistance, 0) }}</template>
</el-table-column>
<el-table-column label="阻力位二" width="100" align="center">
<template #default="{ row }">{{ levelAt(row.resistance, 1) }}</template>
</el-table-column>
<el-table-column label="明细" width="80" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click.stop="openDrawer(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-drawer v-model="drawerOpen" :title="drawerTitle" size="480px" direction="rtl">
<template v-if="selectedRow">
<div class="drawer-section">
<div class="drawer-label">方向判断</div>
<el-tag :type="directionType(selectedRow.direction)" size="default">
{{ directionLabel(selectedRow.direction) }}
</el-tag>
<span class="drawer-confidence" :style="{ color: selectedRow.confidence >= 70 ? '#67c23a' : selectedRow.confidence >= 50 ? '#e6a23c' : '#f56c6c' }">
置信度 {{ selectedRow.confidence }}%
</span>
</div>
<div class="drawer-section">
<div class="drawer-label">支撑位</div>
<div class="level-rows">
<div v-for="(v, i) in parseLevels(selectedRow.support)" :key="'ds'+i" class="level-row sup">
<span class="level-tag">支撑{{ i + 1 }}</span>
<span class="level-val">{{ v }}</span>
</div>
<div v-if="!parseLevels(selectedRow.support).length" class="level-row">-</div>
</div>
</div>
<div class="drawer-section">
<div class="drawer-label">阻力位</div>
<div class="level-rows">
<div v-for="(v, i) in parseLevels(selectedRow.resistance)" :key="'dr'+i" class="level-row res">
<span class="level-tag">阻力{{ i + 1 }}</span>
<span class="level-val">{{ v }}</span>
</div>
<div v-if="!parseLevels(selectedRow.resistance).length" class="level-row">-</div>
</div>
</div>
<div class="drawer-section">
<div class="drawer-label">分析逻辑</div>
<div class="drawer-text">{{ selectedRow.reasoning || '-' }}</div>
</div>
<div class="drawer-section">
<div class="drawer-label">风险提示</div>
<div class="drawer-text risk">{{ selectedRow.risk_note || '-' }}</div>
</div>
<div class="drawer-section meta">
<span>分析日期{{ selectedRow.trade_date }}</span>
<span>目标交易日{{ selectedRow.target_date }}</span>
</div>
</template>
</el-drawer>
</div>
</template>
<style scoped>
.daily-direction {
max-width: 1400px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.toolbar h2 {
margin: 0;
font-size: 18px;
}
.drawer-section {
margin-bottom: 20px;
}
.drawer-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.drawer-confidence {
margin-left: 12px;
font-size: 16px;
font-weight: bold;
}
.drawer-text {
font-size: 15px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--el-text-color-primary);
}
.drawer-text.risk {
color: var(--el-color-warning);
}
.level-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.level-row {
display: flex;
align-items: center;
gap: 12px;
}
.level-tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 4px;
font-weight: 600;
min-width: 54px;
text-align: center;
}
.level-row.sup .level-tag {
background: #ecf5ff;
color: #409eff;
}
.level-row.res .level-tag {
background: #fef0f0;
color: #f56c6c;
}
.level-val {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.drawer-section.meta {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-light);
display: flex;
gap: 24px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -70,9 +70,11 @@ async function submit() {
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%); background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
overflow: hidden; overflow: hidden;
padding: 16px;
} }
.card { .card {
width: 360px; width: 360px;
max-width: 100%;
padding: 36px 32px; padding: 36px 32px;
background: var(--el-bg-color); background: var(--el-bg-color);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
@@ -89,4 +91,9 @@ async function submit() {
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
} }
@media (max-width: 768px) {
.card {
padding: 28px 20px;
}
}
</style> </style>

View File

@@ -3,14 +3,21 @@ import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
runPipeline, runPipeline,
runRange,
getActiveContract, getActiveContract,
type ActiveContract, type ActiveContract,
type RunResponse, type RunResponse,
type RunRangeResponse,
} from '@/api/run' } from '@/api/run'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M'] const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
const mode = ref<'single' | 'range'>('single')
const form = reactive<{ const form = reactive<{
symbol: string symbol: string
trade_date: string trade_date: string
@@ -19,20 +26,32 @@ const form = reactive<{
trade_date: '', trade_date: '',
}) })
const range = reactive<{
dates: [string, string] | []
}>({
dates: [],
})
const active = ref<ActiveContract | null>(null) const active = ref<ActiveContract | null>(null)
const activeLoading = ref(false) const activeLoading = ref(false)
const loading = ref(false) const loading = ref(false)
const result = ref<RunResponse | null>(null) const result = ref<RunResponse | null>(null)
const rangeResult = ref<RunRangeResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null) const resultRef = ref<HTMLElement | null>(null)
async function loadActive() { async function loadActive() {
activeLoading.value = true activeLoading.value = true
try { try {
active.value = await getActiveContract(form.symbol) active.value = await getActiveContract(form.symbol)
// 切换品种后,如果原日期落在新合约的可选范围之外,清空它
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) { if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
form.trade_date = '' form.trade_date = ''
} }
if (Array.isArray(range.dates) && range.dates.length === 2) {
const [s, e] = range.dates
if (!isDateAllowed(toDate(s)) || !isDateAllowed(toDate(e))) {
range.dates = []
}
}
} catch (err: any) { } catch (err: any) {
active.value = null active.value = null
ElMessage.error(err?.response?.data?.error || '加载主力合约失败') ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
@@ -42,17 +61,15 @@ async function loadActive() {
} }
function toDate(s: string) { function toDate(s: string) {
// s 形如 'YYYY-MM-DD'
const [y, m, d] = s.split('-').map(Number) const [y, m, d] = s.split('-').map(Number)
return new Date(y, m - 1, d) return new Date(y, m - 1, d)
} }
function isDateAllowed(d: Date): boolean { function isDateAllowed(d: Date): boolean {
if (!active.value) return true if (!active.value) return true
const min = toDate(active.value.min_date).getTime()
const max = toDate(active.value.max_date).getTime() const max = toDate(active.value.max_date).getTime()
const t = d.getTime() const t = d.getTime()
return t >= min && t <= max return t <= max
} }
function disabledDate(d: Date) { function disabledDate(d: Date) {
@@ -66,12 +83,29 @@ async function submit() {
} }
loading.value = true loading.value = true
result.value = null result.value = null
rangeResult.value = null
try { try {
const req: { symbol: string; trade_date?: string } = { symbol: form.symbol } if (mode.value === 'single') {
if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '') const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
const resp = await runPipeline(req) if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
result.value = resp const resp = await runPipeline(req)
ElMessage.success('打分完成') result.value = resp
ElMessage.success('打分完成')
} else {
if (!Array.isArray(range.dates) || range.dates.length !== 2) {
ElMessage.warning('请选择日期区间')
loading.value = false
return
}
const [start, end] = range.dates
const resp = await runRange({
symbol: form.symbol,
start_date: start.replace(/-/g, ''),
end_date: end.replace(/-/g, ''),
})
rangeResult.value = resp
ElMessage.success(`区间打分完成,成功 ${resp.scored}`)
}
await nextTick() await nextTick()
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} catch (err: any) { } catch (err: any) {
@@ -101,21 +135,18 @@ onMounted(loadActive)
<span>手动打分</span> <span>手动打分</span>
</template> </template>
<el-form :model="form" label-width="100px" style="max-width: 480px"> <el-form :model="form" label-width="100px" style="max-width: 480px">
<el-form-item label="模式">
<el-radio-group v-model="mode">
<el-radio-button label="single">单日打分</el-radio-button>
<el-radio-button label="range">区间打分</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="品种"> <el-form-item label="品种">
<el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%"> <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-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="主力合约"> <el-form-item v-if="mode === 'single'" 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 <el-date-picker
v-model="form.trade_date" v-model="form.trade_date"
type="date" type="date"
@@ -126,9 +157,23 @@ onMounted(loadActive)
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item v-else label="日期区间">
<el-date-picker
v-model="range.dates"
type="daterange"
:placeholder="active ? `${active.min_date} ~ ${active.max_date}` : '加载中…'"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
:disabled="!active"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 100%"
/>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" :disabled="!active" @click="submit"> <el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
执行打分 {{ mode === 'single' ? '执行打分' : '批量打分' }}
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -139,7 +184,7 @@ onMounted(loadActive)
<template #header> <template #header>
<span>打分结果</span> <span>打分结果</span>
</template> </template>
<el-descriptions :column="2" border> <el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="品种">{{ parseTsCode(result.ts_code).symbol }}</el-descriptions-item> <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="合约">{{ parseTsCode(result.ts_code).contract }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item> <el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item>
@@ -151,12 +196,47 @@ onMounted(loadActive)
<el-descriptions-item label="综合"> <el-descriptions-item label="综合">
<strong>{{ result.composite }}</strong> <strong>{{ result.composite }}</strong>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="信号" :span="2"> <el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
<el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag> <el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </el-card>
</div> </div>
<div v-if="rangeResult" ref="resultRef">
<el-card shadow="never" class="result-card">
<template #header>
<span>区间打分结果</span>
</template>
<el-descriptions :column="isMobile ? 1 : 2" border>
<el-descriptions-item label="合约">{{ parseTsCode(rangeResult.ts_code).symbol }}</el-descriptions-item>
<el-descriptions-item label="区间">{{ rangeResult.start_date }} ~ {{ rangeResult.end_date }}</el-descriptions-item>
<el-descriptions-item label="成功">{{ rangeResult.scored }} </el-descriptions-item>
<el-descriptions-item label="跳过">{{ rangeResult.skipped }} </el-descriptions-item>
</el-descriptions>
<el-alert
v-if="rangeResult.warnings.length > 0"
:title="`警告 (${rangeResult.warnings.length} 条)`"
type="warning"
:closable="false"
style="margin-top: 12px"
>
<div style="max-height: 120px; overflow-y: auto">
<div v-for="(w, i) in rangeResult.warnings" :key="i" style="font-size: 12px">{{ w }}</div>
</div>
</el-alert>
<el-table :data="rangeResult.results" stripe style="margin-top: 16px" max-height="400">
<el-table-column prop="trade_date" label="日期" width="110" />
<el-table-column prop="close" label="收盘" width="90" />
<el-table-column prop="composite" label="综合" width="80" />
<el-table-column label="信号">
<template #default="{ row }">
<el-tag :type="signalTagType(row.signal)" size="small">{{ row.signal }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div> </div>
</template> </template>
@@ -166,4 +246,7 @@ onMounted(loadActive)
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.result-card {
margin-top: 8px;
}
</style> </style>

View File

@@ -1,45 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { nextTick, onMounted, reactive, ref, watch, computed } from 'vue'
import { listContracts, listScores, type Score } from '@/api/scores' import { marked } from 'marked'
import { ElMessage } from 'element-plus'
import { listScores, listContracts, type Score } from '@/api/scores'
import { runPipeline, type RunResponse } from '@/api/run'
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue' import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
import { parseTsCode } from '@/utils/contract' import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const filter = reactive<{ const { isMobile } = useMobile()
ts_code?: string
range: [string, string] | []
signal?: string
limit: number
}>({
ts_code: undefined,
range: [],
signal: undefined,
limit: 200,
})
const contracts = ref<string[]>([]) const EXCHANGES = [
const rows = ref<Score[]>([]) { code: 'ZCE', name: '郑商所' },
const loading = ref(false) { code: 'SHF', name: '上期所' },
const drawerScoreId = ref<number | null>(null) { code: 'DCE', name: '大商所' },
]
async function reload() { const SYMBOLS_BY_EXCHANGE: Record<string, string[]> = {
loading.value = true ZCE: ['FG', 'SA', 'MA', 'CF'],
try { SHF: ['RB'],
const [start, end] = filter.range || [] DCE: ['M'],
rows.value = await listScores({
ts_code: filter.ts_code,
start: start || undefined,
end: end || undefined,
signal: filter.signal,
limit: filter.limit,
})
} finally {
loading.value = false
}
} }
function toggleSignal(s: string) { // 品种打分
filter.signal = filter.signal === s ? undefined : s const selectedExchange = ref('')
reload() const selectedSymbol = ref('')
const scoring = ref(false)
const scoreResult = ref<RunResponse | null>(null)
const resultRef = ref<HTMLElement | null>(null)
const availableSymbols = computed(() => {
if (!selectedExchange.value) return []
return SYMBOLS_BY_EXCHANGE[selectedExchange.value] || []
})
watch(selectedExchange, () => {
selectedSymbol.value = ''
})
async function handleScore() {
if (!selectedSymbol.value) {
ElMessage.warning('请选择品种')
return
}
scoring.value = true
scoreResult.value = null
try {
const resp = await runPipeline({ symbol: selectedSymbol.value })
scoreResult.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 {
scoring.value = false
}
} }
function signalTagType(s: string) { function signalTagType(s: string) {
@@ -50,113 +67,309 @@ function signalTagType(s: string) {
return 'info' return 'info'
} }
function signalIcon(s: string) {
if (s.includes('强烈看多')) return '📈📈'
if (s.includes('偏多')) return '📈'
if (s.includes('偏空')) return '📉'
if (s.includes('强烈看空')) return '📉📉'
return ''
}
// AI 分析
const aiLoading = ref(false)
const aiContent = ref('')
const aiError = ref('')
let aiES: EventSource | null = null
function closeAI() {
aiES?.close()
aiES = null
aiLoading.value = false
}
async function askAI() {
if (!scoreResult.value) return
closeAI()
aiLoading.value = true
aiContent.value = ''
aiError.value = ''
const ts = encodeURIComponent(scoreResult.value.ts_code)
const td = encodeURIComponent(scoreResult.value.trade_date)
aiES = new EventSource(`/api/ai/analyze?ts_code=${ts}&trade_date=${td}`)
aiES.addEventListener('token', (e) => { aiContent.value += e.data })
aiES.addEventListener('error', (e) => {
aiError.value = (e as any)?.data || '请求失败'
closeAI()
})
aiES.addEventListener('done', () => closeAI())
aiES.onerror = () => {
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
closeAI()
}
}
// 历史查询(折叠)
const showHistory = ref(false)
const historyFilter = reactive<{
ts_code?: string
range: [string, string] | []
signal?: string
limit: number
}>({
ts_code: undefined,
range: [],
signal: undefined,
limit: 50,
})
const contracts = ref<string[]>([])
const historyRows = ref<Score[]>([])
const historyLoading = ref(false)
const drawerScoreId = ref<number | null>(null)
async function reloadHistory(silent = false) {
if (!silent) historyLoading.value = true
try {
const [start, end] = historyFilter.range || []
historyRows.value = await listScores({
ts_code: historyFilter.ts_code,
start: start || undefined,
end: end || undefined,
signal: historyFilter.signal,
limit: historyFilter.limit,
})
} finally {
if (!silent) historyLoading.value = false
}
}
function toggleSignal(s: string) {
historyFilter.signal = historyFilter.signal === s ? undefined : s
reloadHistory(true)
}
onMounted(async () => { onMounted(async () => {
contracts.value = await listContracts().catch(() => []) contracts.value = await listContracts().catch(() => [])
await reload()
}) })
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<el-card shadow="never" class="filter-card"> <!-- 品种打分 -->
<el-form :inline="true"> <el-card shadow="never">
<el-form-item label="合约"> <template #header>
<span>品种打分</span>
</template>
<el-form :inline="!isMobile">
<el-form-item label="交易所">
<el-select <el-select
v-model="filter.ts_code" v-model="selectedExchange"
placeholder="全部合约" placeholder="选择交易所"
clearable clearable
filterable :style="{ width: isMobile ? '100%' : '160px' }"
style="width: 200px"
> >
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" /> <el-option
v-for="ex in EXCHANGES"
:key="ex.code"
:label="ex.name"
:value="ex.code"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="日期"> <el-form-item label="品种">
<el-date-picker <el-select
v-model="filter.range" v-model="selectedSymbol"
type="daterange" placeholder="选择品种"
value-format="YYYYMMDD" :disabled="!selectedExchange"
range-separator="" :style="{ width: isMobile ? '100%' : '120px' }"
start-placeholder="" >
end-placeholder="" <el-option
/> v-for="s in availableSymbols"
</el-form-item> :key="s"
<el-form-item label="条数"> :label="s"
<el-input-number v-model="filter.limit" :min="10" :max="500" :step="50" /> :value="s"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="loading" @click="reload">查询</el-button> <el-button
</el-form-item> type="primary"
<el-form-item label="快捷"> :loading="scoring"
<el-button-group> :disabled="!selectedSymbol"
<el-button @click="handleScore"
:type="filter.signal === '强烈看多' ? 'success' : ''" >
@click="toggleSignal('强烈看多')" 打分
> </el-button>
强烈看多
</el-button>
<el-button
:type="filter.signal === '偏多' ? 'primary' : ''"
@click="toggleSignal('偏多')"
>
偏多
</el-button>
<el-button
:type="filter.signal === '偏空' ? 'warning' : ''"
@click="toggleSignal('偏空')"
>
偏空
</el-button>
<el-button
:type="filter.signal === '强烈看空' ? 'danger' : ''"
@click="toggleSignal('强烈看空')"
>
强烈看空
</el-button>
</el-button-group>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-table :data="rows" v-loading="loading" stripe class="score-table"> <!-- 打分结果 -->
<el-table-column prop="trade_date" label="日期" width="100" /> <div v-if="scoreResult" ref="resultRef">
<el-table-column label="品种" width="80"> <el-card shadow="never" class="result-card">
<template #default="{ row }"> <template #header>
{{ parseTsCode(row.ts_code).symbol }} <span>打分结果</span>
</template> </template>
</el-table-column> <el-descriptions :column="isMobile ? 1 : 2" border>
<el-table-column label="合约" width="80"> <el-descriptions-item label="品种">
<template #default="{ row }"> {{ parseTsCode(scoreResult.ts_code).symbol }}
{{ parseTsCode(row.ts_code).contract }} </el-descriptions-item>
</template> <el-descriptions-item label="合约">
</el-table-column> {{ parseTsCode(scoreResult.ts_code).contract }}
<el-table-column prop="close" label="收盘" width="90" /> </el-descriptions-item>
<el-table-column prop="oi" label="持仓" width="100" /> <el-descriptions-item label="日期">{{ scoreResult.trade_date }}</el-descriptions-item>
<el-table-column prop="oi_chg" label="持仓变化" width="100" /> <el-descriptions-item label="收盘">{{ scoreResult.close }}</el-descriptions-item>
<el-table-column prop="short_term" label="短期(7d)" width="90" /> <el-descriptions-item label="持仓">{{ scoreResult.oi }}</el-descriptions-item>
<el-table-column prop="medium_term" label="期(15d)" width="90" /> <el-descriptions-item label="期(7d)">{{ scoreResult.short_term }}</el-descriptions-item>
<el-table-column prop="long_term" label="期(30d)" width="90" /> <el-descriptions-item label="期(15d)">{{ scoreResult.medium_term }}</el-descriptions-item>
<el-table-column prop="composite" label="综合" width="80"> <el-descriptions-item label="长期(30d)">{{ scoreResult.long_term }}</el-descriptions-item>
<template #default="{ row }"> <el-descriptions-item label="综合">
<strong>{{ row.composite.toFixed(2) }}</strong> <strong>{{ scoreResult.composite }}</strong>
</template> </el-descriptions-item>
</el-table-column> <el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
<el-table-column prop="signal" label="信号" min-width="160"> <el-tag :type="signalTagType(scoreResult.signal)">
<template #default="{ row }"> {{ signalIcon(scoreResult.signal) }} {{ scoreResult.signal }}
<el-tag :type="signalTagType(row.signal)">{{ row.signal }}</el-tag> </el-tag>
</template> </el-descriptions-item>
</el-table-column> </el-descriptions>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="drawerScoreId = row.id">明细</el-button>
</template>
</el-table-column>
</el-table>
<ScoreDetailDrawer <div class="ai-section">
:score-id="drawerScoreId" <el-button
@close="drawerScoreId = null" v-if="!aiLoading && !aiContent && !aiError"
/> type="primary"
:loading="aiLoading"
@click="askAI"
>
🤖 AI 分析当前打分
</el-button>
<div v-if="aiLoading || aiContent || aiError" class="ai-card">
<div class="ai-header">
<span>🤖 AI 分析</span>
<el-button v-if="aiLoading" text size="small" @click="closeAI">取消</el-button>
</div>
<div class="ai-body">
<div v-if="aiContent" class="ai-text" v-html="marked(aiContent)" />
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
<div v-if="aiLoading && !aiContent" class="ai-loading"> 正在分析...</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 历史查询折叠 -->
<el-card shadow="never">
<template #header>
<div class="history-header" @click="showHistory = !showHistory">
<span>历史打分查询</span>
<span>{{ showHistory ? '▲' : '▼' }}</span>
</div>
</template>
<div v-if="showHistory">
<el-form :inline="!isMobile">
<el-form-item label="合约">
<el-select
v-model="historyFilter.ts_code"
placeholder="全部合约"
clearable
filterable
:style="{ width: isMobile ? '100%' : '200px' }"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="historyFilter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
:style="{ width: isMobile ? '100%' : 'auto' }"
/>
</el-form-item>
<el-form-item label="条数">
<el-input-number v-model="historyFilter.limit" :min="10" :max="500" :step="50" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="historyLoading" style="width: 88px" @click="reloadHistory">
查询
</el-button>
</el-form-item>
<el-form-item label="快捷" class="signal-item">
<el-button-group class="signal-group" :size="isMobile ? 'small' : 'default'">
<el-button
:type="historyFilter.signal === '强烈看多' ? 'success' : ''"
@click="toggleSignal('强烈看多')"
>
强烈看多
</el-button>
<el-button
:type="historyFilter.signal === '偏多' ? 'primary' : ''"
@click="toggleSignal('偏多')"
>
偏多
</el-button>
<el-button
:type="historyFilter.signal === '偏空' ? 'warning' : ''"
@click="toggleSignal('偏空')"
>
偏空
</el-button>
<el-button
:type="historyFilter.signal === '强烈看空' ? 'danger' : ''"
@click="toggleSignal('强烈看空')"
>
强烈看空
</el-button>
</el-button-group>
</el-form-item>
</el-form>
<div class="table-wrapper" v-loading="historyLoading">
<el-table :data="historyRows" stripe class="score-table">
<el-table-column prop="trade_date" label="日期" width="100" />
<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" />
<el-table-column prop="short_term" label="短期(7d)" width="90" />
<el-table-column prop="medium_term" label="中期(15d)" width="90" />
<el-table-column prop="long_term" label="长期(30d)" width="90" />
<el-table-column prop="composite" label="综合" width="80">
<template #default="{ row }">
<strong>{{ row.composite.toFixed(2) }}</strong>
</template>
</el-table-column>
<el-table-column prop="signal" label="信号" min-width="160">
<template #default="{ row }">
<el-tag :type="signalTagType(row.signal)">
{{ signalIcon(row.signal) }} {{ row.signal }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="drawerScoreId = row.id">明细</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
<ScoreDetailDrawer :score-id="drawerScoreId" @close="drawerScoreId = null" />
</div> </div>
</template> </template>
@@ -166,10 +379,80 @@ onMounted(async () => {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.result-card {
margin-top: 4px;
}
.ai-section {
margin-top: 12px;
}
.ai-card {
border: 1px solid var(--el-border-color);
border-radius: 6px;
margin-top: 8px;
overflow: hidden;
}
.ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--el-fill-color-light);
font-weight: 600;
}
.ai-body {
padding: 12px;
}
.ai-text {
line-height: 1.5;
}
.ai-error {
color: var(--el-color-danger);
}
.ai-loading {
color: var(--el-text-color-secondary);
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.filter-card :deep(.el-card__body) { .filter-card :deep(.el-card__body) {
padding: 12px 16px; padding: 12px 16px;
} }
.score-table { .signal-group :deep(.el-button) {
transition: none !important;
}
.signal-group :deep(.el-button:focus),
.signal-group :deep(.el-button:active) {
outline: none;
box-shadow: none;
}
.table-wrapper {
background: var(--el-bg-color); background: var(--el-bg-color);
border-radius: 4px;
overflow-x: auto;
}
.score-table {
min-width: 960px;
}
@media (max-width: 768px) {
.signal-item {
flex-wrap: wrap;
}
.signal-group {
flex-wrap: wrap;
}
} }
</style> </style>
<style>
/* AI Markdown 输出段落间距(非 scoped确保 v-html 生效) */
.ai-text p { margin: 3px 0; }
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
.ai-text li { margin: 1px 0; }
.ai-text strong { color: var(--el-color-primary, #409eff); }
</style>