Compare commits

..

73 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
fish
1f6a9f6419 登录页面禁止上下滚动 2026-05-03 20:53:33 +08:00
fish
467b667921 删除 docker-compose.server.yml 2026-05-03 20:52:41 +08:00
fish
c46ea04993 删除 tushare/.env,token 已写死在代码中 2026-05-03 20:51:45 +08:00
fish
79d2f292f1 将 auth 数据库从 SQLite 迁移到 PostgreSQL 2026-05-03 20:50:28 +08:00
fish
fbcde3cc71 移除 backend .env,暂时关闭 JWT,改为一次性登录 2026-05-03 20:41:43 +08:00
fish
d0e5ddb678 将 TUSHARE_TOKEN 写死到代码中,移除 .env 引用 2026-05-03 20:35:18 +08:00
fish
d742d4972c 管理员默认密码 admin/admin,首次登录强制改密码;增加服务器部署配置
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:44:08 +08:00
fish
ff09715511 打分列表快捷筛选增加偏多、偏空按钮
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:05:06 +08:00
fish
df28200eb0 打分列表增加信号方向快捷筛选
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:01:51 +08:00
fish
7d49aff6c7 移除 Bark 推送通知模块及相关依赖
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:44:59 +08:00
fish
44909f04e2 手动打分页改为按品种选择,日期限定主力合约范围,结果自动滚动到视图
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:40:15 +08:00
fish
fd1c1c7330 合约信息拆分为品种和合约两个展示字段
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:14:41 +08:00
fish
dc22799985 固定六个交易品种并修正 1/5/9 月合约轮换规则
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 16:09:50 +08:00
fish
a1355d91aa 支持手动指定品种和日期进行打分
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:57:31 +08:00
fish
23776b5e96 同步项目文档:PostgreSQL 迁移、FastAPI 服务、UUID 主键与暗夜模式
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:47:23 +08:00
fish
c64def9031 浅色模式导航面板使用浅色背景
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:42:05 +08:00
fish
8bdabc09c6 侧边导航背景色改为 #282828
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:38:12 +08:00
fish
8d4bcb4292 Web 前端新增暗夜模式切换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:35:26 +08:00
fish
d3ec1de275 迁移 psycopg3 并修复 Postgres 18 兼容性问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:29:08 +08:00
fish
961ab8224e scores 主键改用 UUID v7
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:02:08 +08:00
fish
220f4acc45 迁移 PostgreSQL 并新增 Python API 服务
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 14:58:01 +08:00
fish
750584e619 新增 Web 浏览端(Go+Vue 报表系统)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 14:34:50 +08:00
fish
bf8f578761 新增主力合约自动选取并补全项目文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:34:43 +08:00
fish
b9975d6f91 打分结束后通过 Bark 推送结果
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:11:03 +08:00
fish
3039bc97bf 忽略 .claude 本地配置目录
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:57:36 +08:00
74 changed files with 7230 additions and 347 deletions

11
.gitignore vendored
View File

@@ -132,6 +132,7 @@ log/
# ===== IDE / 编辑器 ===== # ===== IDE / 编辑器 =====
.idea/ .idea/
.vscode/ .vscode/
.claude/
*.swp *.swp
*.swo *.swo
*.swn *.swn
@@ -150,3 +151,13 @@ temp/
*.tmp *.tmp
*.bak *.bak
*.orig *.orig
# ===== Web 模块 =====
# Go embed 必须看到 web/backend/dist 目录,保留占位文件;真正的产物由 Docker 构建生成
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep
!web/backend/dist/index.html
# 前端构建产物完全忽略
web/frontend/dist/
web/frontend/node_modules/

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
基于 Docker + Python(tushare) + PostgreSQL 的中国期货行情分析系统,实现日线数据采集与三层加权打分模型。运行方式支持两种模式:① 宿主机 cron/launchd 定时调用 `docker-compose run` 执行 CLI;② 通过 FastAPI 服务以 HTTP API 触发。详细业务说明见 `README.md`
## 常用命令
```bash
# === 启动全栈服务(PostgreSQL + tushare API + web) ===
docker-compose -f docker-compose.trade.yml up -d
# === tushare CLI(不传参 = 按当月 FG 主力自动选合约,轮换规则见 contracts.py:ROLLOVER_RULES) ===
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main
# 显式指定合约(注意交易所后缀:.ZCE/.SHF/.DCE,郑商所是 .ZCE 不是 .CZC)
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main RB2510.SHF
# 用品种代号自动选当月主力(目前只配置了 FG)
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main --symbol FG
# === tushare API 服务(容器内运行 uvicorn,端口 8000) ===
# 触发单次流水线
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}'
# 批量触发所有固定品种今日打分
curl -X POST http://localhost:4001/api/v1/run/batch
# 查询打分列表
curl "http://localhost:4001/api/v1/scores?limit=5"
# === 修改代码后必须重建镜像 ===
docker-compose -f docker-compose.trade.yml build tushare
docker-compose -f docker-compose.trade.yml build web
# === 查最新打分(PostgreSQL) ===
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;"
```
## 关键架构
**单进程串行流水线**:`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/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 流程。
**三层打分模型**(`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`)。
**Docker 边界**:`tushare/src/``web/backend/``web/frontend/` 均在 Dockerfile 的 `COPY` 阶段拷进镜像,**没有源码挂载**——改完 Python/Go/Vue 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。
## 配置/密钥规则
`.gitignore` 排除范围广(见文件):`data/``*.db*``.env*`、CTP 流文件(`*.con`/`*.dat`/`ResultInfo.xml` 等)、`.claude/`、所有日志。新增任何账户、token、行情流文件务必先确认匹配 ignore 规则。
注意 `web/backend/dist/``.gitignore` 中有显式例外:目录本身入库,但内部文件除 `.gitkeep`/`index.html` 外都被忽略——这是为了 `go:embed all:dist` 在本地能编译,真正的 SPA 产物在 Docker 构建期生成。
## Web 模块(报告浏览端)
`./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),业务数据与用户鉴权统一由 PostgreSQL 管理。前端 Vue 3 + Vite + Element Plus + ECharts。
- 单进程同源服务:Vue 产物在 Docker 构建期由 `node` 阶段产出 `dist/`,被 `go:embed all:dist` 嵌入二进制,运行时由 Go 同时服务 `/api/*` 与 SPA 静态文件——不引入 nginx 旁车。
- 统一 DB:业务数据与用户鉴权数据均存储在 PostgreSQL `futures` 数据库中,通过 `DATABASE_URL` 访问。`auth.db`(SQLite)已废弃,`users` 表现在由 `AuthStore` 直接管理在 PostgreSQL 中。
- 鉴权已简化:登录接口返回固定 token,`middleware.RequireUser` 直接注入默认管理员上下文,所有请求放行。后端仍保留密码校验与角色检查(`RequireAdmin`)。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb`
- 侧边栏提供「同步数据」(批量打分)、「数据重置」(管理员清空行情数据)功能。
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行,则自动创建默认管理员 `admin` / `admin`,并标记强制首次登录后改密码。忘记管理员密码的恢复方式:停服 → 清理 PostgreSQL 中的 admin 记录 → 重启。
**鉴权已简化**:当前 `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
# 启动 web 服务
docker-compose -f docker-compose.trade.yml up -d web
docker-compose -f docker-compose.trade.yml logs -f web # 看 [bootstrap] 日志确认 admin 是否被创建
# 仅重建 web,不影响 tushare
docker-compose -f docker-compose.trade.yml build web && docker-compose -f docker-compose.trade.yml up -d web
# 本地开发 (后端 + 前端分别起,api 走代理)
cd web/backend && go run ./ # 需要本地 Go 1.25.8;dist/ 目录的占位会被 embed
cd web/frontend && npm install && npm run dev # 默认 5173 端口,/api 代理到 4000
```

308
README.md Normal file
View File

@@ -0,0 +1,308 @@
# 期货行情分析系统 — 使用说明
基于 Docker + Python(tushare) + PostgreSQL 的中国期货行情分析系统。当前阶段已实现数据采集、三层加权打分模型与 Web 报表浏览端。运行方式支持两种模式:① 宿主机定时器触发 `docker-compose run` 执行 CLI;② 通过 FastAPI HTTP API 服务触发。
## 环境准备
- Docker >= 20.10
- Docker Compose >= 2.0
- (可选) psql 或任意 PostgreSQL 客户端用于本地查库
## 快速开始
### 1. 启动全栈服务
```bash
docker-compose -f docker-compose.trade.yml up -d
```
这会同时启动 PostgreSQL、tushare API 服务(端口 8000)与 Web 浏览端(端口 8080)。
### 3. 通过 CLI 跑当月主力
```bash
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 当月主力 -> ...`,然后:
1. 从 tushare 拉取合约日线数据
2. 写入 PostgreSQL `futures` 数据库
3. 运行三层打分模型
4. 保存打分结果并输出到 stdout
### 4. 通过 API 触发流水线
```bash
# 触发 FG 打分
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
-d '{"symbol":"FG"}'
# 批量触发所有固定品种今日打分
curl -X POST http://localhost:4001/api/v1/run/batch
# 查询最新打分
curl "http://localhost:4001/api/v1/scores?limit=5"
# 查询合约列表
curl "http://localhost:4001/api/v1/contracts"
# 查询 K 线数据
curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
# 清空所有行情数据(谨慎操作)
curl -X POST http://localhost:4001/api/v1/admin/reset-data
```
### 5. 跑其他合约或品种
```bash
# 显式指定合约
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main RB2510.SHF
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main I2601.DCE
# 按品种代号自动选当月主力(目前只配置了 FG)
docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main --symbol FG
```
### 6. 玻璃 FG 主力轮换规则
| 当前自然月 | 主力合约 |
|----------|---------|
| 1、2、3 月 | 当年 05 |
| 4、5、6、7 月 | 当年 09 |
| 8、9、10、11 月 | **次年** 01 |
| 12 月 | **次年** 05 |
## 三层打分模型
### 综合分数公式
```
综合分数 = (短期动力 × 0.4 + 中期趋势 × 0.35 + 长期结构 × 0.25) × 波动率惩罚系数
```
### 1. 短期动力7 日窗口,权重 0.4
逐日打分后取均值。每日评分 = (象限基础分 + 幅度加成) × 量能确认,产出 0-100 连续值。
**象限基础分**(持仓与价格方向):
| 象限 | 持仓变化 | 价格方向 | 基础分 |
|------|---------|---------|--------|
| accumulation增仓上涨 | 增仓 | 上涨 | 75 |
| distribution增仓下跌 | 增仓 | 下跌 | 25 |
| covering减仓上涨 | 减仓 | 上涨 | 65 |
| 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
```
价格收益率 = (今收 - 15日前收) / 15日前收
价格信号分 = clamp(50 + 收益率 × 500, 0, 100)
资金意愿 = 50 + (增仓上涨天数 - 增仓下跌天数) / 15 × 50 (连续值 0-100
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
```
### 3. 长期结构30 日窗口,权重 0.25
```
OI 趋势分 = clamp(50 + OI变化幅度 × 250, 0, 100) (权重 60%
价格趋势分 = clamp(50 + 30日价格收益率 × 200, 0, 100) (权重 40%
模块得分 = OI 趋势分 × 0.6 + 价格趋势分 × 0.4
```
### 4. 波动率调整
基于近 30 日日收益率标准差和 ATR%(平均真实波幅/均价):
```
日波动率 ≤ 1.5% → 惩罚系数 = 1.0(无惩罚)
日波动率 > 1.5% → 惩罚系数 = max(0.85, 1.0 - (日波动率 - 1.5%) × 10)
```
高波动品种的综合分会被适当打折,最低打 85 折。
### 信号解读
| 综合分数 | 信号 |
|---------|------|
| 80-100 | 强烈看多 — 价格与资金共振 |
| 50-80 | 偏多/震荡偏强 |
| 40-50 | 偏空/震荡偏弱 |
| 0-40 | 强烈看空 — 资金主动打压 |
## 数据查询
业务数据存储在 PostgreSQL 中,可通过以下方式查询:
```bash
# 查看最新打分
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;"
# 查看合约日线
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;"
# 或通过 API 查询
curl "http://localhost:4001/api/v1/scores?ts_code=FG2609.ZCE&limit=10"
curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
```
## 项目结构
```
trade/
├── docker-compose.trade.yml # Docker Compose 编排(postgres + tushare + web)
├── README.md # 本文件
├── CLAUDE.md # Claude Code 项目指引
├── data/ # 数据目录(gitignored)
│ └── (运行时生成)
├── .gitignore # Git 忽略配置
├── tushare/ # Python 数据服务
│ ├── Dockerfile
│ ├── requirements.txt
│ └── src/ # 数据采集 + 打分 + FastAPI
│ ├── api.py # FastAPI 服务入口
│ ├── models.py
│ ├── fetcher.py
│ ├── scorer.py
│ ├── storage.py # PostgreSQL 读写
│ ├── contracts.py
│ └── main.py # CLI 入口
└── web/ # Web 浏览端
├── .dockerignore
├── backend/ # Go 1.25 后端 (chi + lib/pq)
│ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
│ ├── go.mod
│ ├── main.go
│ ├── embed.go # //go:embed all:dist
│ ├── go.sum
│ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
│ └── internal/
│ ├── config/ # 环境变量加载
│ ├── store/ # PostgreSQL 业务查询 + 用户管理
│ ├── auth/ # bcrypt + 首启 admin 引导
│ ├── middleware/ # RequireUser / RequireAdmin / 日志
│ ├── handlers/ # 登录 / 打分 / K线 / 用户管理
│ └── router/ # chi 路由装配
└── frontend/ # Vue 3 + Vite + Element Plus + ECharts
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
└── src/
├── main.ts / App.vue
├── router/ # 守卫(未登录/管理员路由)
├── stores/ # Pinia: auth.ts(持久化 token) + theme.ts(暗/浅色模式)
├── api/ # axios 封装 + 各端点
├── views/ # 登录 / 打分列表 / 图表 / 用户管理
└── components/ # 抽屉 + ECharts K 线
```
## 技术栈
- **Python 3.13** (alpine) + **tushare** + **pandas** + **FastAPI** + **psycopg3** — 数据采集、打分与 API 服务
- **Go 1.25.8** (alpine 3.23) + **chi** + **lib/pq** — Web 后端
- **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端
- **PostgreSQL 18.3** (alpine 3.23) — 业务数据存储
- **PostgreSQL** — 业务数据与用户鉴权数据统一存储
- **Docker / Docker Compose** — 容器化部署
## 常见问题
**Q: 为什么某些日期返回空数据?**
A: tushare 数据更新有延迟,且不同接口对 token 积分等级有要求。若 `fut_daily` 返回空但 `trade_cal` 正常,通常是该日期实际行情数据尚未入库。
**Q: 合约代码格式?**
A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大商所用 `.DCE`。注意不是 `.CZC`
**Q: 如何定时自动跑?**
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/` 提供一个图形化的浏览端,展示 tushare 流水线写入 PostgreSQL 的打分与行情数据。后端 Go(`golang:1.25.8-alpine3.23`)读取数据库,前端 Vue 3 + Element Plus + ECharts,通过 docker-compose 一起部署。
### 1. 启动
```bash
# 构建并启动 web 服务,不影响现有 tushare
docker-compose -f docker-compose.trade.yml up -d --build web
# 查看启动日志:首启会出现 [bootstrap] admin created
docker-compose -f docker-compose.trade.yml logs -f web
```
浏览器访问 `http://localhost:4000`。首次启动时系统会自动创建默认管理员账号 `admin` / `admin`,首次登录后系统会强制要求修改密码。
### 3. 页面说明
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分(涨跌幅/OI变化%/量比/象限)、中期(15d)价格收益与资金意愿、长期(30d)OI/价格趋势分与波动率调整。
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
- **同步数据**(侧边栏):点击调用批量打分接口,对所有固定品种执行当日打分,完成后自动跳转并刷新打分列表。
- **手动打分** `/run`:选品种 + 日期,对单个合约执行数据拉取与打分。
- **数据重置**(侧边栏,仅管理员):输入确认文字后清空所有行情数据candles + scores用户表不受影响。
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
前端支持暗/浅色模式切换,点击顶部导航栏的「暗/亮」开关即可切换。侧边导航在暗色模式使用深色背景,浅色模式使用浅色背景。
### 4. 子账号维护流程
1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
2. 把账号发给同事即可登录;无注册入口。
3. 离职 / 风险事件:用「禁用」临时停用(被禁用的账号将无法登录),或「删除」彻底清除。
### 5. 数据流向与数据库
```
tushare(写) → PostgreSQL futures 数据库 ←(读写)── web 后端
```
业务数据(`candles` + `scores`)与用户鉴权数据(`users`)统一存储在 PostgreSQL `futures` 数据库中。`users` 表结构:
```sql
users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, password_hash TEXT,
role TEXT CHECK(role IN ('admin','user')), disabled BOOLEAN,
force_password_change BOOLEAN, created_at TEXT, updated_at TEXT)
```
### 6. 常见问题
**Q: 忘记管理员密码怎么办?**
```bash
docker-compose -f docker-compose.trade.yml stop web
docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d futures -c \
"DELETE FROM users WHERE role='admin';"
docker-compose -f docker-compose.trade.yml up -d web
```
启动时会重新触发 bootstrap 写入新的默认管理员 `admin` / `admin`
**Q: 改了 Go / Vue 代码但页面没变?**
源码不挂载,镜像内是 COPY 进去的。重建:`docker-compose -f docker-compose.trade.yml build web && docker-compose -f docker-compose.trade.yml up -d web`
**Q: 为什么 tushare 容器启动后没有立即退出?**
因为默认命令改为 `uvicorn src.api:app` 常驻 API 服务。如需执行单次 CLI,用 `docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main ...`

47
docker-compose.trade.yml Normal file
View File

@@ -0,0 +1,47 @@
name: trade
services:
postgres:
image: postgres:18.3-alpine3.23
container_name: trade-postgres
environment:
POSTGRES_USER: trade
POSTGRES_PASSWORD: trade
POSTGRES_DB: futures
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U trade -d futures"]
interval: 5s
timeout: 5s
retries: 5
tushare:
build: ./tushare
container_name: trade-tushare
# token 已写死在代码中,无需 env_file
environment:
- DATABASE_URL=postgresql://trade:trade@postgres:5432/futures
depends_on:
postgres:
condition: service_healthy
ports:
- "4001:8000"
command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
web:
build:
context: ./web
dockerfile: backend/Dockerfile
container_name: trade-web
# .env 已移除,环境变量直接写在此处
environment:
- DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable
depends_on:
- postgres
ports:
- "4000:8080"
restart: unless-stopped
volumes:
pgdata:

View File

@@ -1,9 +0,0 @@
services:
tushare:
build: ./tushare
env_file: ./tushare/.env
environment:
- DB_PATH=/app/data/futures.db
volumes:
- ./data:/app/data
command: ["python", "-m", "src.main", "FG2609.ZCE"]

View File

@@ -5,11 +5,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \
TZ=Asia/Shanghai \ TZ=Asia/Shanghai \
DB_PATH=/app/data/futures.db DATABASE_URL=postgresql://trade:trade@postgres:5432/futures
WORKDIR /app WORKDIR /app
# 运行时依赖 + 时区 # 时区(psycopg[binary] wheel 自带 libpq,不再需要系统装 libpq)
RUN apk add --no-cache tzdata \ RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone && echo "Asia/Shanghai" > /etc/timezone
@@ -25,4 +25,4 @@ RUN adduser -D -u 1000 app \
COPY --chown=app:app src ./src COPY --chown=app:app src ./src
USER app USER app
CMD ["python", "-m", "src.main", "FG2609.ZCE"] CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,16 +1,12 @@
import os
import sys import sys
import tushare as ts import tushare as ts
TUSHARE_TOKEN = "76efd8465f9f2591aa42a385268e06acf6b80b7a15be2267ad2281b7"
def main() -> int: def main() -> int:
token = os.environ.get("TUSHARE_TOKEN") ts.set_token(TUSHARE_TOKEN)
if not token:
print("[ERROR] 未设置 TUSHARE_TOKEN 环境变量", file=sys.stderr)
return 1
ts.set_token(token)
pro = ts.pro_api() pro = ts.pro_api()
df = pro.trade_cal(exchange="SHFE", start_date="20260101", end_date="20260110") df = pro.trade_cal(exchange="SHFE", start_date="20260101", end_date="20260110")

View File

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

289
tushare/src/api.py Normal file
View File

@@ -0,0 +1,289 @@
from typing import Optional
from datetime import date, datetime, timedelta
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
from . import contracts, fetcher, scorer, storage
app = FastAPI(title="期货数据采集与打分服务")
class RunRequest(BaseModel):
ts_code: Optional[str] = None
symbol: str = "FG"
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):
ts_code: str
trade_date: str
close: float
oi: float
oi_chg: float
short_term: float
medium_term: float
long_term: float
composite: float
signal: str
vol_penalty: float = 1.0
composite_delta: Optional[float] = None
composite_delta_5d: Optional[float] = None
@app.on_event("startup")
def startup():
storage.init_db()
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/api/v1/run", response_model=RunResponse)
def run_pipeline(req: RunRequest):
ref_date = datetime.strptime(req.trade_date, "%Y%m%d").date() if req.trade_date else None
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}")
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
result = scorer.score_daily(df, req.trade_date)
storage.save_score(result)
return RunResponse(
ts_code=result.ts_code,
trade_date=result.trade_date,
close=result.close,
oi=result.oi,
oi_chg=result.oi_chg,
short_term=result.short_term,
medium_term=result.medium_term,
long_term=result.long_term,
composite=result.composite,
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")
def list_scores(
ts_code: Optional[str] = Query(None),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
limit: int = Query(200, ge=1, le=500),
):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
q = """SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, created_at FROM scores WHERE 1=1"""
args = []
if ts_code:
q += " AND ts_code = %s"
args.append(ts_code)
if start:
q += " AND trade_date >= %s"
args.append(start)
if end:
q += " AND trade_date <= %s"
args.append(end)
q += " ORDER BY trade_date DESC, id DESC LIMIT %s"
args.append(limit)
cur.execute(q, args)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
return rows
finally:
conn.close()
@app.get("/api/v1/scores/{score_id}")
def get_score(score_id: str):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""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 id = %s""",
(score_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="not found")
cols = [d[0] for d in cur.description]
return dict(zip(cols, row))
finally:
conn.close()
@app.get("/api/v1/contracts")
def list_contracts():
conn = storage._get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT ts_code FROM scores ORDER BY ts_code ASC")
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
@app.get("/api/v1/contracts/active")
def get_active_contract(symbol: str = Query(...)):
"""返回某品种当前主力合约及可选打分日期范围。"""
if symbol not in contracts.ROLLOVER_RULES:
raise HTTPException(status_code=400, detail=f"未配置 {symbol} 的主力轮换规则")
today = date.today()
return {
"symbol": symbol,
"ts_code": contracts.active_contract(symbol, today),
"min_date": contracts.active_contract_start(symbol, today).isoformat(),
"max_date": today.isoformat(),
}
@app.get("/api/v1/candles")
def list_candles(
ts_code: str = Query(...),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
q = """SELECT ts_code, trade_date,
COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0),
COALESCE(vol, 0), COALESCE(amount, 0),
COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0)
FROM candles WHERE ts_code = %s"""
args = [ts_code]
if start:
q += " AND trade_date >= %s"
args.append(start)
if end:
q += " AND trade_date <= %s"
args.append(end)
q += " ORDER BY trade_date ASC LIMIT 1000"
cur.execute(q, args)
cols = ["ts_code", "trade_date", "open", "high", "low", "close",
"vol", "amount", "oi", "oi_chg", "pre_close"]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
@app.post("/api/v1/admin/reset-data")
def reset_data():
"""清空所有行情数据(仅限管理员调用)。"""
storage.truncate_all()
return {"status": "ok", "message": "已清空所有行情数据"}

108
tushare/src/contracts.py Normal file
View File

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

View File

@@ -1,22 +1,29 @@
import os
from typing import Optional from typing import Optional
import pandas as pd import pandas as pd
import tushare as ts import tushare as ts
TUSHARE_TOKEN = "76efd8465f9f2591aa42a385268e06acf6b80b7a15be2267ad2281b7"
def _init_api(): def _init_api():
token = os.environ.get("TUSHARE_TOKEN") ts.set_token(TUSHARE_TOKEN)
if not token:
raise RuntimeError("TUSHARE_TOKEN 环境变量未设置")
ts.set_token(token)
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 积分不足")
@@ -27,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,21 +1,22 @@
import argparse import argparse
import sys import sys
from typing import Optional
from . import fetcher, scorer, storage from . import contracts, fetcher, scorer, storage
def run(ts_code: str) -> int: def run(ts_code: str, trade_date: Optional[str] = None) -> int:
storage.init_db() storage.init_db()
print(f"[1/4] 拉取 {ts_code} 数据...") print(f"[1/4] 拉取 {ts_code} 数据...")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
print(f" 返回 {len(df)}") print(f" 返回 {len(df)}")
print(f"[2/4] 写入/更新 SQLite...") print(f"[2/4] 写入/更新 PostgreSQL...")
storage.save_candles(df) storage.save_candles(df)
print(f"[3/4] 计算打分...") print(f"[3/4] 计算打分...")
result = scorer.score_daily(df) result = scorer.score_daily(df, trade_date)
print(f"[4/4] 保存打分结果...") print(f"[4/4] 保存打分结果...")
storage.save_score(result) storage.save_score(result)
@@ -37,15 +38,19 @@ def run(ts_code: str) -> 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,23 +58,132 @@ def run(ts_code: str) -> 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}%")
print(f"\n[OK] 数据已持久化到 SQLite") 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")
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 return 0
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="期货合约三层打分模型") parser = argparse.ArgumentParser(description="期货合约三层打分模型")
parser.add_argument("ts_code", help="合约代码,如 FG2609.ZCE") parser.add_argument(
"ts_code",
nargs="?",
help="合约代码,如 FG2609.ZCE;不传则按 --symbol 当月主力自动选取",
)
parser.add_argument(
"--symbol",
default="FG",
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
)
parser.add_argument(
"--date",
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()
return run(args.ts_code)
ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.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:
print(f"[DATE] 指定打分日期: {args.date}")
return run(ts_code, args.date)
if __name__ == "__main__": if __name__ == "__main__":

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,48 +1,110 @@
import math
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:
@@ -52,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 "强烈看多区域 — 价格与资金共振,趋势多头的温床"
@@ -116,18 +211,80 @@ def _interpret(composite: float) -> str:
return "强烈看空区域 — 资金主动且持续地打压价格" return "强烈看空区域 — 资金主动且持续地打压价格"
def score_daily(df: pd.DataFrame) -> ScoreResult: # ---------------------------------------------------------------------------
"""对 DataFrame 中最新一条记录打分。""" # 主打分函数
# ---------------------------------------------------------------------------
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
"""对 DataFrame 中指定日期或最新一条记录打分。"""
if len(df) < 31: if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行") raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
if trade_date:
trade_date_str = str(trade_date)
mask = df["trade_date"].astype(str) == trade_date_str
if not mask.any():
raise ValueError(f"指定日期 {trade_date_str} 不在数据中")
pos = mask.idxmax()
df = df.iloc[:pos + 1].copy()
if len(df) < 31:
raise ValueError(f"指定日期 {trade_date_str} 之前数据不足(仅 {len(df)} 行),需要至少 31 行")
latest = df.iloc[-1] latest = df.iloc[-1]
# ── 三层原始分 ──
short, short_details = calc_short_term(df, 7) short, short_details = calc_short_term(df, 7)
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(
@@ -141,9 +298,94 @@ def score_daily(df: pd.DataFrame) -> ScoreResult:
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

@@ -1,28 +1,26 @@
import json import json
import os import os
import sqlite3
from typing import Optional from typing import Optional
import pandas as pd import pandas as pd
import psycopg
from psycopg.rows import dict_row
from .models import ScoreResult from .models import ScoreResult
DEFAULT_DB_PATH = os.environ.get("DB_PATH", "/app/data/futures.db") DEFAULT_DB_URL = os.environ.get("DATABASE_URL", "postgresql://trade:trade@postgres:5432/futures")
def _get_conn(db_path: str = DEFAULT_DB_PATH) -> sqlite3.Connection: def _get_conn(db_url: str = DEFAULT_DB_URL):
conn = sqlite3.connect(db_path) return psycopg.connect(db_url)
conn.row_factory = sqlite3.Row
return conn
def init_db(db_path: str = DEFAULT_DB_PATH): def init_db(db_url: str = DEFAULT_DB_URL):
"""初始化数据库,创建 candles 和 scores 表。""" """初始化数据库,创建 candles 和 scores 表。"""
os.makedirs(os.path.dirname(db_path), exist_ok=True) conn = _get_conn(db_url)
conn = _get_conn(db_path)
try: try:
conn.execute("PRAGMA journal_mode=WAL") with conn.cursor() as cur:
conn.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS candles ( CREATE TABLE IF NOT EXISTS candles (
ts_code TEXT NOT NULL, ts_code TEXT NOT NULL,
trade_date TEXT NOT NULL, trade_date TEXT NOT NULL,
@@ -38,9 +36,9 @@ def init_db(db_path: str = DEFAULT_DB_PATH):
PRIMARY KEY (ts_code, trade_date) PRIMARY KEY (ts_code, trade_date)
) )
""") """)
conn.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS scores ( CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT, id UUID DEFAULT uuidv7() PRIMARY KEY,
ts_code TEXT NOT NULL, ts_code TEXT NOT NULL,
trade_date TEXT NOT NULL, trade_date TEXT NOT NULL,
close REAL, close REAL,
@@ -52,7 +50,7 @@ def init_db(db_path: str = DEFAULT_DB_PATH):
composite REAL, composite REAL,
signal TEXT, signal TEXT,
detail_json TEXT, detail_json TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (ts_code, trade_date) UNIQUE (ts_code, trade_date)
) )
""") """)
@@ -61,21 +59,37 @@ def init_db(db_path: str = DEFAULT_DB_PATH):
conn.close() conn.close()
def save_candles(df: pd.DataFrame, db_path: str = DEFAULT_DB_PATH): def save_candles(df: pd.DataFrame, db_url: str = DEFAULT_DB_URL):
"""批量写入/更新日线数据。""" """批量写入/更新日线数据。"""
if df.empty: if df.empty:
return return
conn = _get_conn(db_path) conn = _get_conn(db_url)
try: try:
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")
conn.executemany( # 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:
cur.executemany(
""" """
INSERT OR REPLACE INTO candles INSERT INTO candles
(ts_code, trade_date, open, high, low, close, vol, amount, oi, oi_chg, pre_close) (ts_code, trade_date, open, high, low, close, vol, amount, oi, oi_chg, pre_close)
VALUES (:ts_code, :trade_date, :open, :high, :low, :close, VALUES (%(ts_code)s, %(trade_date)s, %(open)s, %(high)s, %(low)s, %(close)s,
:vol, :amount, :oi, :oi_chg, :pre_close) %(vol)s, %(amount)s, %(oi)s, %(oi_chg)s, %(pre_close)s)
ON CONFLICT (ts_code, trade_date) DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
vol = EXCLUDED.vol,
amount = EXCLUDED.amount,
oi = EXCLUDED.oi,
oi_chg = EXCLUDED.oi_chg,
pre_close = EXCLUDED.pre_close
""", """,
records, records,
) )
@@ -84,16 +98,41 @@ def save_candles(df: pd.DataFrame, db_path: str = DEFAULT_DB_PATH):
conn.close() conn.close()
def save_score(score: ScoreResult, db_path: str = DEFAULT_DB_PATH): def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
"""写入打分结果。""" """写入打分结果。"""
conn = _get_conn(db_path) conn = _get_conn(db_url)
try: try:
conn.execute( 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(
""" """
INSERT OR REPLACE INTO scores INSERT INTO scores
(ts_code, trade_date, close, oi, oi_chg, (ts_code, trade_date, close, oi, oi_chg,
short_term, medium_term, long_term, composite, signal, detail_json) short_term, medium_term, long_term, composite, signal, detail_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (ts_code, trade_date) DO UPDATE SET
close = EXCLUDED.close,
oi = EXCLUDED.oi,
oi_chg = EXCLUDED.oi_chg,
short_term = EXCLUDED.short_term,
medium_term = EXCLUDED.medium_term,
long_term = EXCLUDED.long_term,
composite = EXCLUDED.composite,
signal = EXCLUDED.signal,
detail_json = EXCLUDED.detail_json,
created_at = CURRENT_TIMESTAMP
""", """,
( (
score.ts_code, score.ts_code,
@@ -106,11 +145,7 @@ def save_score(score: ScoreResult, db_path: str = DEFAULT_DB_PATH):
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()
@@ -118,14 +153,27 @@ def save_score(score: ScoreResult, db_path: str = DEFAULT_DB_PATH):
conn.close() conn.close()
def get_latest_score(ts_code: str, db_path: str = DEFAULT_DB_PATH) -> Optional[dict]: def get_latest_score(ts_code: str, db_url: str = DEFAULT_DB_URL) -> Optional[dict]:
"""查询最新打分记录。""" """查询最新打分记录。"""
conn = _get_conn(db_path) conn = _get_conn(db_url)
try: try:
row = conn.execute( with conn.cursor(row_factory=dict_row) as cur:
"SELECT * FROM scores WHERE ts_code = ? ORDER BY trade_date DESC LIMIT 1", cur.execute(
"SELECT * FROM scores WHERE ts_code = %s ORDER BY trade_date DESC LIMIT 1",
(ts_code,), (ts_code,),
).fetchone() )
row = cur.fetchone()
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()

8
web/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
**/node_modules
**/dist
**/.env
**/.env.*
!**/.env.example
**/.DS_Store
**/.git
backend/tmp

52
web/backend/Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# ==================== Stage 1: 前端构建 ====================
FROM node:20-alpine AS ui
WORKDIR /ui
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
COPY frontend/package*.json ./
RUN npm config set registry https://registry.npmmirror.com && \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./
RUN npm run build
# ==================== Stage 2: Go 构建 ====================
FROM golang:1.25.8-alpine3.23 AS api
WORKDIR /src
# 国内可选:RUN go env -w GOPROXY=https://goproxy.cn,direct
COPY backend ./
COPY --from=ui /ui/dist ./dist
ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
RUN go mod tidy && \
go build -trimpath -ldflags="-s -w" -o /out/web ./
# ==================== Stage 3: 运行时 ====================
FROM alpine:3.23
RUN apk add --no-cache tzdata ca-certificates && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata && \
adduser -D -u 1000 app && \
mkdir -p /app/data && \
chown -R app:app /app
WORKDIR /app
USER app
COPY --from=api --chown=app:app /out/web /app/web
ENV TZ=Asia/Shanghai \
LISTEN_ADDR=:8080
EXPOSE 8080
CMD ["/app/web"]

0
web/backend/dist/.gitkeep vendored Normal file
View File

11
web/backend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Trade Web</title>
</head>
<body>
<p>请通过 <code>docker-compose build web</code> 构建生产镜像后访问。</p>
<p>本地开发请运行 <code>npm run dev</code> (web/frontend/) 与 <code>go run ./</code> (web/backend/)。</p>
</body>
</html>

6
web/backend/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package main
import "embed"
//go:embed all:dist
var distFS embed.FS

10
web/backend/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module trade/web
go 1.25.8
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.27.0
)

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

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

View File

@@ -0,0 +1,17 @@
package auth
import "golang.org/x/crypto/bcrypt"
const bcryptCost = 12
func HashPassword(plain string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return "", err
}
return string(b), nil
}
func CheckPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}

View File

@@ -0,0 +1,37 @@
package auth
import (
"log"
"trade/web/internal/store"
)
// BootstrapLLMConfig 初始化 llm_config 表。
func BootstrapLLMConfig(s *store.FuturesStore) error {
return s.EnsureLLMConfigTable()
}
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
func Bootstrap(s *store.AuthStore) error {
n, err := s.CountAdmins()
if err != nil {
return err
}
if n > 0 {
return nil
}
hash, err := HashPassword("admin")
if err != nil {
return err
}
u, err := s.CreateUser("admin", hash, store.RoleAdmin)
if err != nil {
return err
}
if err := s.SetForcePasswordChange(u.ID, true); err != nil {
return err
}
log.Printf("[bootstrap] admin created (default password), force password change enabled")
return nil
}

View File

@@ -0,0 +1,55 @@
package auth
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
const tokenTTL = 12 * time.Hour
type Claims struct {
UserID int64 `json:"uid"`
Username string `json:"usr"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type Manager struct{ secret []byte }
func NewManager(secret []byte) *Manager { return &Manager{secret: secret} }
func (m *Manager) Issue(userID int64, username, role string) (string, time.Time, error) {
exp := time.Now().Add(tokenTTL)
claims := Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(exp),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := tok.SignedString(m.secret)
return s, exp, err
}
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
tok, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := tok.Claims.(*Claims)
if !ok || !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,37 @@
package config
import (
"fmt"
"os"
)
type Config struct {
ListenAddr string
DatabaseURL string
TushareAPIURL string
LLMBaseURL string
LLMAPIKey string
LLMModel string
}
func Load() (*Config, error) {
cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
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 == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
}
return cfg, nil
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}

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,147 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"trade/web/internal/auth"
"trade/web/internal/middleware"
"trade/web/internal/store"
)
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResp struct {
Token string `json:"token"`
User publicUserView `json:"user"`
RequirePasswordChange bool `json:"require_password_change"`
}
type publicUserView struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
ForcePasswordChange bool `json:"force_password_change"`
}
type changePasswordReq struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
func (d *Deps) Login(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "用户名和密码不能为空")
return
}
u, err := d.Auth.GetByUsername(req.Username)
if err != nil || u.Disabled {
// 禁用账户与不存在账户返回同样的错误,避免账户枚举
writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
if !auth.CheckPassword(u.PasswordHash, req.Password) {
writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
// 暂时不用 JWT返回固定 token
writeJSON(w, http.StatusOK, loginResp{
Token: "noop",
User: publicUserView{
ID: u.ID,
Username: u.Username,
Role: u.Role,
ForcePasswordChange: u.ForcePasswordChange,
},
RequirePasswordChange: u.ForcePasswordChange,
})
}
func (d *Deps) ChangePassword(w http.ResponseWriter, r *http.Request) {
me, ok := middleware.FromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "no user")
return
}
var req changePasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.OldPassword == "" || req.NewPassword == "" {
writeErr(w, http.StatusBadRequest, "旧密码和新密码都不能为空")
return
}
if len(req.NewPassword) < 6 {
writeErr(w, http.StatusBadRequest, "新密码至少 6 位")
return
}
u, err := d.Auth.GetByID(me.ID)
if err != nil {
writeErr(w, http.StatusUnauthorized, "user not found")
return
}
if !auth.CheckPassword(u.PasswordHash, req.OldPassword) {
writeErr(w, http.StatusUnauthorized, "旧密码错误")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hash failed")
return
}
if err := d.Auth.UpdatePassword(me.ID, hash); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
// 改密码后清除强制改密标记
if err := d.Auth.SetForcePasswordChange(me.ID, false); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (d *Deps) Logout(w http.ResponseWriter, r *http.Request) {
// JWT 是无状态的,服务端 logout 仅形式化;前端丢弃 token 即可。
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (d *Deps) Me(w http.ResponseWriter, r *http.Request) {
u, ok := middleware.FromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "no user in context")
return
}
full, err := d.Auth.GetByID(u.ID)
if err != nil {
writeErr(w, http.StatusUnauthorized, "user not found")
return
}
writeJSON(w, http.StatusOK, sanitize(full))
}
// sanitize 把内部 User 转成对外视图,剥掉 password_hash。
func sanitize(u *store.User) map[string]any {
return map[string]any{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"disabled": u.Disabled,
"force_password_change": u.ForcePasswordChange,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
}
}

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

@@ -0,0 +1,29 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"trade/web/internal/store"
)
// Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。
type Deps struct {
Auth *store.AuthStore
Futures *store.FuturesStore
TushareURL string
AIConfig *AIConfig
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("[handler] encode response: %v", err)
}
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

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

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

View File

@@ -0,0 +1,66 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"trade/web/internal/store"
)
func (d *Deps) ListScores(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := 0
if s := q.Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil {
limit = n
}
}
rows, err := d.Futures.ListScores(store.ScoreFilter{
TsCode: q.Get("ts_code"),
Start: q.Get("start"),
End: q.Get("end"),
Signal: q.Get("signal"),
Limit: limit,
})
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rows)
}
func (d *Deps) GetScore(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := d.Futures.GetScore(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, row)
}
func (d *Deps) ListContracts(w http.ResponseWriter, r *http.Request) {
out, err := d.Futures.ListContracts()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, out)
}
func (d *Deps) ListCandles(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
rows, err := d.Futures.ListCandles(q.Get("ts_code"), q.Get("start"), q.Get("end"))
if err != nil {
if errors.Is(err, store.ErrMissingTsCode) {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rows)
}

View File

@@ -0,0 +1,152 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"trade/web/internal/auth"
"trade/web/internal/middleware"
"trade/web/internal/store"
)
type createUserReq struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
type patchUserReq struct {
Password *string `json:"password,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
}
func (d *Deps) AdminListUsers(w http.ResponseWriter, r *http.Request) {
users, err := d.Auth.ListUsers()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]map[string]any, 0, len(users))
for i := range users {
out = append(out, sanitize(&users[i]))
}
writeJSON(w, http.StatusOK, out)
}
func (d *Deps) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || len(req.Password) < 6 {
writeErr(w, http.StatusBadRequest, "用户名必填,密码至少 6 位")
return
}
role := strings.TrimSpace(req.Role)
if role == "" {
role = store.RoleUser
}
if role != store.RoleAdmin && role != store.RoleUser {
writeErr(w, http.StatusBadRequest, "role 取值必须是 admin 或 user")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hash failed")
return
}
u, err := d.Auth.CreateUser(req.Username, hash, role)
if err != nil {
// UNIQUE 冲突等
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, sanitize(u))
}
func (d *Deps) AdminPatchUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
var req patchUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.Password == nil && req.Disabled == nil {
writeErr(w, http.StatusBadRequest, "无可更新字段")
return
}
if req.Password != nil {
if len(*req.Password) < 6 {
writeErr(w, http.StatusBadRequest, "新密码至少 6 位")
return
}
hash, err := auth.HashPassword(*req.Password)
if err != nil {
writeErr(w, http.StatusInternalServerError, "hash failed")
return
}
if err := d.Auth.UpdatePassword(id, hash); err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
// 管理员重置密码后,强制用户下次登录改密
if err := d.Auth.SetForcePasswordChange(id, true); err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
}
if req.Disabled != nil {
// 禁止禁用自己,避免管理员锁死自己
me, _ := middleware.FromContext(r.Context())
if *req.Disabled && me.ID == id {
writeErr(w, http.StatusBadRequest, "不能禁用自己")
return
}
if err := d.Auth.SetDisabled(id, *req.Disabled); err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
}
u, err := d.Auth.GetByID(id)
if err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
writeJSON(w, http.StatusOK, sanitize(u))
}
func (d *Deps) AdminDeleteUser(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
me, _ := middleware.FromContext(r.Context())
if me.ID == id {
writeErr(w, http.StatusBadRequest, "不能删除自己")
return
}
if err := d.Auth.DeleteUser(id); err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func statusForErr(err error) int {
if errors.Is(err, store.ErrNotFound) {
return http.StatusNotFound
}
return http.StatusInternalServerError
}

View File

@@ -0,0 +1,44 @@
package middleware
import (
"context"
"net/http"
"trade/web/internal/store"
)
type ctxKey string
const userKey ctxKey = "user"
type CtxUser struct {
ID int64
Username string
Role string
}
func FromContext(ctx context.Context) (CtxUser, bool) {
u, ok := ctx.Value(userKey).(CtxUser)
return u, ok
}
// RequireUser 不再校验 JWT直接注入默认管理员用户所有请求放行。
func RequireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), userKey, CtxUser{
ID: 1, Username: "admin", Role: store.RoleAdmin,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := FromContext(r.Context())
if !ok || u.Role != store.RoleAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin only"})
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"encoding/json"
"log"
"net/http"
"runtime/debug"
"time"
)
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start))
})
}
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("[panic] %v\n%s", rec, debug.Stack())
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}()
next.ServeHTTP(w, r)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = 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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

View File

@@ -0,0 +1,75 @@
package router
import (
"io/fs"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"trade/web/internal/handlers"
mw "trade/web/internal/middleware"
)
func New(d *handlers.Deps, dist fs.FS) http.Handler {
r := chi.NewRouter()
r.Use(mw.Recover)
r.Use(mw.Logger)
r.Route("/api", func(r chi.Router) {
r.Post("/login", d.Login)
r.Group(func(r chi.Router) {
r.Use(mw.RequireUser)
r.Post("/logout", d.Logout)
r.Get("/me", d.Me)
r.Post("/change-password", d.ChangePassword)
r.Get("/scores", d.ListScores)
r.Get("/scores/{id}", d.GetScore)
r.Get("/contracts", d.ListContracts)
r.Get("/contracts/active", d.GetActiveContract)
r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline)
r.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.Use(mw.RequireAdmin)
r.Get("/admin/users", d.AdminListUsers)
r.Post("/admin/users", d.AdminCreateUser)
r.Patch("/admin/users/{id}", d.AdminPatchUser)
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)
})
})
})
r.Handle("/*", spa(dist))
return r
}
// spa 返回单文件 SPA handler:文件存在则发文件,否则发 index.html。
func spa(root fs.FS) http.Handler {
fileServer := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(root, path); err != nil {
// 找不到文件 → SPA 路由,回 index.html 让前端 router 处理
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
fileServer.ServeHTTP(w, r2)
return
}
fileServer.ServeHTTP(w, r)
})
}

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

@@ -0,0 +1,188 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
_ "github.com/lib/pq"
)
type AuthStore struct{ db *sql.DB }
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
ForcePasswordChange bool `json:"force_password_change"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
const (
RoleAdmin = "admin"
RoleUser = "user"
)
var ErrNotFound = errors.New("user not found")
func OpenAuth(databaseURL string) (*AuthStore, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("open auth db: %w", err)
}
db.SetMaxOpenConns(8)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping auth db: %w", err)
}
s := &AuthStore{db: db}
if err := s.init(); err != nil {
return nil, err
}
return s, nil
}
func (s *AuthStore) Close() error { return s.db.Close() }
func (s *AuthStore) init() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','user')),
disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
`)
if err != nil {
return err
}
// 兼容旧表:添加 force_password_change 列(已存在则忽略错误)
_, _ = s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN NOT NULL DEFAULT FALSE`)
return nil
}
func (s *AuthStore) CountAdmins() (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&n)
return n, err
}
func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, error) {
now := time.Now().Format("2006-01-02 15:04:05")
var id int64
err := s.db.QueryRow(
`INSERT INTO users(username, password_hash, role, disabled, created_at, updated_at)
VALUES ($1, $2, $3, FALSE, $4, $5) RETURNING id`,
username, passwordHash, role, now, now,
).Scan(&id)
if err != nil {
return nil, err
}
return &User{ID: id, Username: username, PasswordHash: passwordHash, Role: role,
CreatedAt: now, UpdatedAt: now}, nil
}
func (s *AuthStore) GetByUsername(username string) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at
FROM users WHERE username = $1`, username)
return scanUser(row)
}
func (s *AuthStore) GetByID(id int64) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at
FROM users WHERE id = $1`, id)
return scanUser(row)
}
func (s *AuthStore) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at
FROM users ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUserRows(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *AuthStore) UpdatePassword(id int64, hash string) error {
now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(`UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`, hash, now, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *AuthStore) SetForcePasswordChange(id int64, v bool) error {
now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(`UPDATE users SET force_password_change = $1, updated_at = $2 WHERE id = $3`, v, now, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *AuthStore) SetDisabled(id int64, disabled bool) error {
now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(`UPDATE users SET disabled = $1, updated_at = $2 WHERE id = $3`, disabled, now, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *AuthStore) DeleteUser(id int64) error {
res, err := s.db.Exec(`DELETE FROM users WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanUser(r rowScanner) (*User, error) {
var u User
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Disabled, &u.ForcePasswordChange, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &u, nil
}
func scanUserRows(rows *sql.Rows) (*User, error) { return scanUser(rows) }

View File

@@ -0,0 +1,305 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
_ "github.com/lib/pq"
)
var ErrMissingTsCode = errors.New("ts_code 必填")
type FuturesStore struct{ db *sql.DB }
func OpenFutures(databaseURL string) (*FuturesStore, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("open futures db: %w", err)
}
db.SetMaxOpenConns(8)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping futures db: %w", err)
}
return &FuturesStore{db: db}, nil
}
func (s *FuturesStore) Close() error { return s.db.Close() }
type Score struct {
ID string `json:"id"`
TsCode string `json:"ts_code"`
TradeDate string `json:"trade_date"`
Close float64 `json:"close"`
OI float64 `json:"oi"`
OIChg float64 `json:"oi_chg"`
ShortTerm float64 `json:"short_term"`
MediumTerm float64 `json:"medium_term"`
LongTerm float64 `json:"long_term"`
Composite float64 `json:"composite"`
Signal string `json:"signal"`
Detail json.RawMessage `json:"detail,omitempty"`
CreatedAt string `json:"created_at"`
}
type ScoreFilter struct {
TsCode string
Start string
End string
Signal string
Limit int
}
func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
q := `SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term, long_term,
composite, signal, created_at FROM scores WHERE 1=1`
args := []any{}
n := 0
next := func() string { n++; return fmt.Sprintf("$%d", n) }
if f.TsCode != "" {
q += " AND ts_code = " + next()
args = append(args, f.TsCode)
}
if f.Start != "" {
q += " AND trade_date >= " + next()
args = append(args, f.Start)
}
if f.End != "" {
q += " AND trade_date <= " + next()
args = append(args, f.End)
}
if f.Signal != "" {
q += " AND signal LIKE " + next()
args = append(args, "%"+f.Signal+"%")
}
q += " ORDER BY trade_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
}
q += " LIMIT " + next()
args = append(args, f.Limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Score{}
for rows.Next() {
var x Score
if err := rows.Scan(&x.ID, &x.TsCode, &x.TradeDate, &x.Close, &x.OI, &x.OIChg,
&x.ShortTerm, &x.MediumTerm, &x.LongTerm, &x.Composite, &x.Signal, &x.CreatedAt); err != nil {
return nil, err
}
out = append(out, x)
}
return out, rows.Err()
}
func (s *FuturesStore) GetScore(id string) (*Score, error) {
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 id = $1`, id)
var x Score
var detail sql.NullString
if err := row.Scan(&x.ID, &x.TsCode, &x.TradeDate, &x.Close, &x.OI, &x.OIChg,
&x.ShortTerm, &x.MediumTerm, &x.LongTerm, &x.Composite, &x.Signal, &detail, &x.CreatedAt); err != nil {
return nil, err
}
if detail.Valid && strings.TrimSpace(detail.String) != "" {
x.Detail = json.RawMessage(detail.String)
}
return &x, nil
}
func (s *FuturesStore) ListContracts() ([]string, error) {
rows, err := s.db.Query(`SELECT DISTINCT ts_code FROM scores ORDER BY ts_code ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
type Candle struct {
TsCode string `json:"ts_code"`
TradeDate string `json:"trade_date"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Vol float64 `json:"vol"`
Amount float64 `json:"amount"`
OI float64 `json:"oi"`
OIChg float64 `json:"oi_chg"`
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) {
if tsCode == "" {
return nil, ErrMissingTsCode
}
q := `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`
args := []any{tsCode}
n := 1
next := func() string { n++; return fmt.Sprintf("$%d", n) }
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 ASC LIMIT 1000"
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Candle{}
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
}
out = append(out, c)
}
return out, rows.Err()
}

89
web/backend/main.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"context"
"errors"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"trade/web/internal/auth"
"trade/web/internal/config"
"trade/web/internal/handlers"
"trade/web/internal/router"
"trade/web/internal/store"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
futures, err := store.OpenFutures(cfg.DatabaseURL)
if err != nil {
log.Fatalf("open futures: %v", err)
}
defer futures.Close()
authDB, err := store.OpenAuth(cfg.DatabaseURL)
if err != nil {
log.Fatalf("open auth: %v", err)
}
defer authDB.Close()
if err := auth.Bootstrap(authDB); err != nil {
log.Fatalf("bootstrap: %v", err)
}
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")
if err != nil {
log.Fatalf("embed dist: %v", err)
}
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: router.New(deps, dist),
ReadHeaderTimeout: 10 * time.Second,
}
idle := make(chan struct{})
go func() {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutting down ...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idle)
}()
log.Printf("web 监听 %s", cfg.ListenAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
<-idle
}

13
web/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>期货报告 · Trade</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

27
web/frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "trade-web-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"echarts": "^5.5.1",
"marked": "^15.0.0",
"element-plus": "^2.8.4",
"pinia": "^2.2.4",
"vue": "^3.5.10",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@types/node": "^22.7.4",
"@vitejs/plugin-vue": "^5.1.4",
"typescript": "^5.6.2",
"vite": "^5.4.8",
"vue-tsc": "^2.1.6"
}
}

248
web/frontend/src/App.vue Normal file
View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
import { resetAllData } from '@/api/admin'
const auth = useAuthStore()
const theme = useThemeStore()
const router = useRouter()
const route = useRoute()
const { isMobile } = useMobile()
const drawerOpen = ref(false)
const resetting = ref(false)
const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token)
const menuColors = computed(() =>
theme.isDark
? { bg: '#282828', text: '#cfd8e3', active: '#ffffff' }
: { bg: '#f9fafb', text: '#1f2937', active: '#0f172a' },
)
function logout() {
auth.logout()
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>
<template>
<el-container v-if="showLayout" class="app">
<!-- desktop sidebar -->
<el-aside v-if="!isMobile" width="220px" class="aside" :class="{ 'aside-light': !theme.isDark }">
<div class="brand">期货报告</div>
<el-menu
:default-active="route.path"
router
:background-color="menuColors.bg"
:text-color="menuColors.text"
:active-text-color="menuColors.active"
>
<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-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
数据重置
</el-menu-item>
</el-menu>
</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-header class="header">
<div class="left">
<el-button v-if="isMobile" text class="hamburger" @click="drawerOpen = !drawerOpen">
<span style="font-size: 20px; line-height: 1"></span>
</el-button>
<div class="user">
<span>{{ auth.user?.username }}</span>
<el-tag size="small" :type="auth.isAdmin ? 'danger' : 'info'">
{{ auth.isAdmin ? '管理员' : '普通用户' }}
</el-tag>
</div>
</div>
<div class="right">
<el-switch
v-model="theme.isDark"
inline-prompt
active-text=""
inactive-text=""
style="--el-switch-on-color: #2c3e50"
/>
<el-button type="primary" link @click="logout">退出登录</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
<router-view v-else />
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
body {
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
overflow: hidden;
}
.app {
height: 100%;
}
.aside {
background: #282828;
color: #cfd8e3;
transition: width 0.3s ease;
overflow: hidden;
}
.aside-light {
background: #f9fafb;
color: #1f2937;
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 {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
letter-spacing: 2px;
border-bottom: 1px solid #3a3a3a;
white-space: nowrap;
}
.aside-light .brand {
border-bottom: 1px solid #e5e7eb;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--el-bg-color);
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 {
display: flex;
align-items: center;
gap: 10px;
}
.right {
display: flex;
align-items: center;
gap: 16px;
}
.el-menu {
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>

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,24 @@
import client from './client'
import type { AuthUser } from '@/stores/auth'
export interface LoginResp {
token: string
user: AuthUser
require_password_change: boolean
}
export function login(username: string, password: string) {
return client.post<LoginResp>('/login', { username, password }).then((r) => r.data)
}
export function logout() {
return client.post('/logout').then((r) => r.data)
}
export function me() {
return client.get<AuthUser>('/me').then((r) => r.data)
}
export function changePassword(oldPassword: string, newPassword: string) {
return client.post('/change-password', { old_password: oldPassword, new_password: newPassword }).then((r) => r.data)
}

View File

@@ -0,0 +1,19 @@
import client from './client'
export interface Candle {
ts_code: string
trade_date: string
open: number
high: number
low: number
close: number
vol: number
amount: number
oi: number
oi_chg: number
pre_close: number
}
export function listCandles(ts_code: string, start?: string, end?: string) {
return client.get<Candle[]>('/candles', { params: { ts_code, start, end } }).then((r) => r.data)
}

View File

@@ -0,0 +1,34 @@
import axios, { type AxiosInstance } from 'axios'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const baseURL = import.meta.env.VITE_API_BASE || '/api'
const client: AxiosInstance = axios.create({ baseURL, timeout: 15_000 })
client.interceptors.request.use((cfg) => {
const auth = useAuthStore()
if (auth.token) {
cfg.headers = cfg.headers ?? {}
cfg.headers.Authorization = `Bearer ${auth.token}`
}
return cfg
})
client.interceptors.response.use(
(resp) => resp,
(err) => {
const status = err?.response?.status
if (status === 401) {
const auth = useAuthStore()
auth.logout()
router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
}
const msg = err?.response?.data?.error || err.message || '请求失败'
if (status !== 401) ElMessage.error(msg)
return Promise.reject(err)
},
)
export default client

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

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

View File

@@ -0,0 +1,100 @@
import client from './client'
export interface ShortDetail {
trade_date: string
close: number
pre_close: number
oi: number
oi_chg: number
score: number
oi_chg_pct: number
price_chg_pct: number
vol: number
vol_ratio: number
quadrant: string
}
export interface MediumDetail {
price_return_pct: number
price_signal: number
accumulation_days: number
distribution_days: number
covering_days: number
liquidation_days: number
fund_signal: number
window: number
}
export interface LongDetail {
oi_now: number
oi_before: 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 {
short_details?: ShortDetail[]
medium_detail?: MediumDetail
long_detail?: LongDetail
volatility?: VolatilityDetail
adaptive_weights?: AdaptiveWeights
vol_penalty?: number
composite_delta?: number | null
composite_delta_5d?: number | null
}
export interface Score {
id: number
ts_code: string
trade_date: string
close: number
oi: number
oi_chg: number
short_term: number
medium_term: number
long_term: number
composite: number
signal: string
detail?: ScoreDetail
created_at: string
}
export interface ScoreListParams {
ts_code?: string
start?: string
end?: string
signal?: string
limit?: number
}
export function listScores(params: ScoreListParams = {}) {
return client.get<Score[]>('/scores', { params }).then((r) => r.data)
}
export function getScore(id: number) {
return client.get<Score>(`/scores/${id}`).then((r) => r.data)
}
export function listContracts() {
return client.get<string[]>('/contracts').then((r) => r.data)
}

View File

@@ -0,0 +1,26 @@
import client from './client'
export interface AdminUser {
id: number
username: string
role: 'admin' | 'user'
disabled: boolean
created_at: string
updated_at: string
}
export function listUsers() {
return client.get<AdminUser[]>('/admin/users').then((r) => r.data)
}
export function createUser(username: string, password: string, role: 'admin' | 'user' = 'user') {
return client.post<AdminUser>('/admin/users', { username, password, role }).then((r) => r.data)
}
export function updateUser(id: number, patch: { password?: string; disabled?: boolean }) {
return client.patch<AdminUser>(`/admin/users/${id}`, patch).then((r) => r.data)
}
export function deleteUser(id: number) {
return client.delete(`/admin/users/${id}`).then((r) => r.data)
}

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
const theme = useThemeStore()
const { isMobile } = useMobile()
const containerRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
function ensureChart() {
if (!containerRef.value) return
if (chart) {
chart.dispose()
chart = null
}
chart = echarts.init(containerRef.value, theme.isDark ? 'dark' : undefined)
}
function render() {
if (!chart) return
const dates = props.data.map((c) => c.trade_date)
// ECharts K 线顺序: [open, close, low, high]
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
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(
{
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: (params: any) => {
if (!Array.isArray(params)) return ''
const date = params[0]?.axisValue || ''
let html = `<strong>${date}</strong><br/>`
for (const p of params) {
if (p.seriesName === 'K 线') {
const ohlc = p.data as number[]
const labels = ['开盘', '收盘', '最低', '最高']
html += labels.map((n, i) => `${p.marker} ${n}: ${ohlc[i] ?? '-'}`).join('<br/>') + '<br/>'
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: [
{ type: 'inside', xAxisIndex: xAxisIndices },
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
],
series,
},
true,
)
}
function resize() {
chart?.resize()
}
onMounted(() => {
ensureChart()
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.data, render, { deep: true })
watch(
() => theme.isDark,
() => {
ensureChart()
render()
},
)
watch(isMobile, () => {
ensureChart()
render()
})
</script>
<template>
<div ref="containerRef" class="chart"></div>
</template>
<style scoped>
.chart {
width: 100%;
height: 560px;
}
@media (max-width: 768px) {
.chart {
height: 420px;
}
}
</style>

View File

@@ -0,0 +1,359 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { marked } from 'marked'
import { getScore, type Score } from '@/api/scores'
import { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps<{ scoreId: number | null }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const score = ref<Score | null>(null)
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({
get: () => props.scoreId !== null,
set: (v) => {
if (!v) emit('close')
},
})
watch(
() => props.scoreId,
async (id) => {
closeAI()
aiContent.value = ''
aiError.value = ''
if (id === null) {
score.value = null
return
}
loading.value = true
try {
score.value = await getScore(id)
} finally {
loading.value = false
}
},
)
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>
<template>
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" destroy-on-close>
<div v-loading="loading" v-if="score">
<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).contract }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ score.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ score.oi }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ score.composite.toFixed(2) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号">
<el-tag>{{ score.signal }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
{{ score.long_term.toFixed(2) }}
</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>
<h4 class="section">短期 7 日逐日打分</h4>
<div class="table-wrapper">
<el-table
:data="score.detail?.short_details ?? []"
size="small"
border
class="detail-table"
max-height="400"
>
<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>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.medium_detail">
<el-descriptions-item label="价格收益率">
{{ score.detail.medium_detail.price_return_pct.toFixed(2) }}%
</el-descriptions-item>
<el-descriptions-item label="价格信号分">
{{ score.detail.medium_detail.price_signal.toFixed(2) }}
</el-descriptions-item>
<el-descriptions-item label="增仓上涨">
{{ score.detail.medium_detail.accumulation_days }}
</el-descriptions-item>
<el-descriptions-item label="增仓下跌">
{{ score.detail.medium_detail.distribution_days }}
</el-descriptions-item>
<el-descriptions-item label="减仓上涨">
{{ 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>
<h4 class="section">长期(30d)结构</h4>
<el-descriptions :column="isMobile ? 1 : 2" border v-if="score.detail?.long_detail">
<el-descriptions-item label="OI 趋势分">
{{ score.detail.long_detail.oi_score?.toFixed(1) ?? '-' }}
<span class="formula-hint">(权重 60%)</span>
</el-descriptions-item>
<el-descriptions-item label="价格趋势分">
{{ score.detail.long_detail.price_score?.toFixed(1) ?? '-' }}
<span class="formula-hint">(权重 40%)</span>
</el-descriptions-item>
<el-descriptions-item label="30 日价格收益">
{{ 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>
<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>
</el-drawer>
</template>
<style scoped>
.section {
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>

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 }
}

15
web/frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

15
web/frontend/src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,72 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { layout: 'blank', public: true },
},
{
path: '/change-password',
name: 'change-password',
component: () => import('@/views/ChangePasswordView.vue'),
meta: { layout: 'blank' },
},
{ path: '/', redirect: '/scores' },
{
path: '/scores',
name: 'scores',
component: () => import('@/views/ScoresView.vue'),
},
{
path: '/chart',
name: 'chart',
component: () => import('@/views/ChartView.vue'),
},
{
path: '/contract-full',
name: 'contract-full',
component: () => import('@/views/ContractFullView.vue'),
},
{
path: '/run',
name: 'run',
component: () => import('@/views/RunView.vue'),
},
{
path: '/daily-direction',
name: 'daily-direction',
component: () => import('@/views/DailyDirectionView.vue'),
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/AdminUsersView.vue'),
meta: { adminOnly: true },
},
{ path: '/:pathMatch(.*)*', redirect: '/scores' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.public) return true
if (!auth.token) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (auth.requirePasswordChange && to.path !== '/change-password') {
return { path: '/change-password' }
}
if (to.meta.adminOnly && !auth.isAdmin) {
return { path: '/scores' }
}
return true
})
export default router

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
export interface AuthUser {
id: number
username: string
role: 'admin' | 'user'
force_password_change?: boolean
}
interface State {
token: string
user: AuthUser | null
requirePasswordChange: boolean
}
const STORAGE_KEY = 'trade.auth'
function load(): State {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { token: '', user: null, requirePasswordChange: false }
const parsed = JSON.parse(raw) as Partial<State>
return {
token: parsed.token || '',
user: parsed.user || null,
requirePasswordChange: parsed.requirePasswordChange ?? false,
}
} catch {
return { token: '', user: null, requirePasswordChange: false }
}
}
export const useAuthStore = defineStore('auth', {
state: (): State => load(),
getters: {
isAdmin: (s) => s.user?.role === 'admin',
},
actions: {
setSession(token: string, user: AuthUser, requirePasswordChange?: boolean) {
this.token = token
this.user = user
this.requirePasswordChange = requirePasswordChange ?? false
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ token, user, requirePasswordChange: this.requirePasswordChange }),
)
},
clearRequirePasswordChange() {
this.requirePasswordChange = false
if (this.user) {
this.user.force_password_change = false
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ token: this.token, user: this.user, requirePasswordChange: false }),
)
}
},
logout() {
this.token = ''
this.user = null
this.requirePasswordChange = false
localStorage.removeItem(STORAGE_KEY)
},
},
})

View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
const STORAGE_KEY = 'trade.theme'
type Mode = 'dark' | 'light'
function detectInitial(): boolean {
const saved = localStorage.getItem(STORAGE_KEY) as Mode | null
if (saved === 'dark') return true
if (saved === 'light') return false
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
}
function apply(isDark: boolean) {
document.documentElement.classList.toggle('dark', isDark)
}
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(detectInitial())
apply(isDark.value)
watch(isDark, (v) => {
apply(v)
localStorage.setItem(STORAGE_KEY, v ? 'dark' : 'light')
})
function toggle() {
isDark.value = !isDark.value
}
return { isDark, toggle }
})

View File

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

View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createUser,
deleteUser,
listUsers,
updateUser,
type AdminUser,
} from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const createDialog = reactive({
visible: false,
username: '',
password: '',
role: 'user' as 'admin' | 'user',
})
const resetDialog = reactive({
visible: false,
user: null as AdminUser | null,
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() {
loading.value = true
try {
users.value = await listUsers()
} finally {
loading.value = false
}
}
function openCreate() {
createDialog.username = ''
createDialog.password = ''
createDialog.role = 'user'
createDialog.visible = true
}
async function submitCreate() {
const { username, password, role } = createDialog
if (!username.trim() || password.length < 6) {
ElMessage.warning('用户名必填,密码至少 6 位')
return
}
await createUser(username.trim(), password, role)
ElMessage.success('账号已创建')
createDialog.visible = false
await reload()
}
function openReset(u: AdminUser) {
resetDialog.user = u
resetDialog.password = ''
resetDialog.visible = true
}
async function submitReset() {
if (!resetDialog.user) return
if (resetDialog.password.length < 6) {
ElMessage.warning('新密码至少 6 位')
return
}
await updateUser(resetDialog.user.id, { password: resetDialog.password })
ElMessage.success('密码已重置')
resetDialog.visible = false
}
async function toggleDisabled(u: AdminUser) {
await updateUser(u.id, { disabled: !u.disabled })
ElMessage.success(u.disabled ? '已启用' : '已禁用')
await reload()
}
async function remove(u: AdminUser) {
try {
await ElMessageBox.confirm(`确认删除用户「${u.username}」?`, '确认', {
type: 'warning',
})
} catch {
return
}
await deleteUser(u.id)
ElMessage.success('已删除')
await reload()
}
onMounted(() => {
reload()
loadLLMConfig()
})
</script>
<template>
<div class="page">
<el-card shadow="never" class="head-card">
<div class="head">
<span>用户管理 仅管理员可访问,本系统不开放注册</span>
<el-button type="primary" @click="openCreate">新建账号</el-button>
</div>
</el-card>
<div class="table-wrapper" v-loading="loading">
<el-table :data="users" stripe class="user-table">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<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>
</div>
</template>
<el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
<el-form-item label="API 地址">
<el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="llmCfg.api_key"
type="password"
show-password
placeholder="sk-..."
/>
</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-form-item>
</el-form>
</el-card>
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px">
<el-form-item label="用户名">
<el-input v-model="createDialog.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="createDialog.password" type="password" show-password />
</el-form-item>
<el-form-item label="角色">
<el-radio-group v-model="createDialog.role">
<el-radio value="user">普通用户</el-radio>
<el-radio value="admin">管理员</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="resetDialog.visible" title="重置密码" width="420px">
<p>用户:{{ resetDialog.user?.username }}</p>
<el-input
v-model="resetDialog.password"
type="password"
show-password
placeholder="新密码 (至少 6 位)"
/>
<template #footer>
<el-button @click="resetDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitReset">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.head-card :deep(.el-card__body) {
padding: 12px 16px;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
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>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { changePassword } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const form = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const loading = ref(false)
async function submit() {
if (!form.oldPassword || !form.newPassword) {
ElMessage.warning('请输入旧密码和新密码')
return
}
if (form.newPassword.length < 6) {
ElMessage.warning('新密码至少 6 位')
return
}
if (form.newPassword !== form.confirmPassword) {
ElMessage.warning('两次输入的新密码不一致')
return
}
loading.value = true
try {
await changePassword(form.oldPassword, form.newPassword)
ElMessage.success('密码修改成功')
auth.clearRequirePasswordChange()
router.replace('/scores')
} catch {
// axios 拦截器已弹错
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login">
<div class="card">
<h2>修改密码</h2>
<p class="hint">首次登录或管理员重置密码后请修改密码</p>
<el-form @submit.prevent="submit" label-width="0">
<el-form-item>
<el-input
v-model="form.oldPassword"
type="password"
placeholder="旧密码"
show-password
autocomplete="current-password"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="form.newPassword"
type="password"
placeholder="新密码"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="确认新密码"
show-password
autocomplete="new-password"
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" :loading="loading" style="width: 100%" @click="submit">
确认修改
</el-button>
</el-form>
</div>
</div>
</template>
<style scoped>
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
overflow: hidden;
padding: 16px;
}
.card {
width: 360px;
max-width: 100%;
padding: 36px 32px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
}
.card h2 {
margin: 0 0 8px;
text-align: center;
}
.hint {
margin: 0 0 24px;
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: center;
}
@media (max-width: 768px) {
.card {
padding: 28px 20px;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles'
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 loading = ref(false)
async function reload() {
if (!filter.ts_code) {
ElMessage.warning('请选择合约')
return
}
loading.value = true
try {
const [start, end] = filter.range || []
candles.value = await listCandles(filter.ts_code, start, end)
} finally {
loading.value = false
}
}
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="合约">
<el-select
v-model="filter.ts_code"
placeholder="选择合约"
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="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-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="chart-card" v-loading="loading">
<KLineChart :data="candles" />
</el-card>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.chart-card :deep(.el-card__body) {
padding: 8px;
}
</style>

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

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const form = reactive({ username: '', password: '' })
const loading = ref(false)
async function submit() {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const resp = await login(form.username.trim(), form.password)
auth.setSession(resp.token, resp.user, resp.require_password_change)
if (resp.require_password_change) {
router.replace('/change-password')
} else {
const redirect = (route.query.redirect as string) || '/scores'
router.replace(redirect)
}
} catch {
// axios 拦截器已弹错
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login">
<div class="card">
<h2>期货报告系统</h2>
<p class="hint">仅授权账号可登录,联系管理员申请</p>
<el-form @submit.prevent="submit" label-width="0">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" autocomplete="username" />
</el-form-item>
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="密码"
show-password
autocomplete="current-password"
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" :loading="loading" style="width: 100%" @click="submit">
登录
</el-button>
</el-form>
</div>
</div>
</template>
<style scoped>
.login {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
overflow: hidden;
padding: 16px;
}
.card {
width: 360px;
max-width: 100%;
padding: 36px 32px;
background: var(--el-bg-color);
color: var(--el-text-color-primary);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
}
.card h2 {
margin: 0 0 8px;
text-align: center;
}
.hint {
margin: 0 0 24px;
color: var(--el-text-color-secondary);
font-size: 12px;
text-align: center;
}
@media (max-width: 768px) {
.card {
padding: 28px 20px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,458 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref, watch, computed } from 'vue'
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 { parseTsCode } from '@/utils/contract'
import { useMobile } from '@/composables/useMobile'
const { isMobile } = useMobile()
const EXCHANGES = [
{ code: 'ZCE', name: '郑商所' },
{ code: 'SHF', name: '上期所' },
{ code: 'DCE', name: '大商所' },
]
const SYMBOLS_BY_EXCHANGE: Record<string, string[]> = {
ZCE: ['FG', 'SA', 'MA', 'CF'],
SHF: ['RB'],
DCE: ['M'],
}
// 品种打分
const selectedExchange = ref('')
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) {
if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return ''
if (s.includes('偏空')) return 'warning'
if (s.includes('强烈看空')) return 'danger'
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 () => {
contracts.value = await listContracts().catch(() => [])
})
</script>
<template>
<div class="page">
<!-- 品种打分 -->
<el-card shadow="never">
<template #header>
<span>品种打分</span>
</template>
<el-form :inline="!isMobile">
<el-form-item label="交易所">
<el-select
v-model="selectedExchange"
placeholder="选择交易所"
clearable
:style="{ width: isMobile ? '100%' : '160px' }"
>
<el-option
v-for="ex in EXCHANGES"
:key="ex.code"
:label="ex.name"
:value="ex.code"
/>
</el-select>
</el-form-item>
<el-form-item label="品种">
<el-select
v-model="selectedSymbol"
placeholder="选择品种"
:disabled="!selectedExchange"
:style="{ width: isMobile ? '100%' : '120px' }"
>
<el-option
v-for="s in availableSymbols"
:key="s"
:label="s"
:value="s"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="scoring"
:disabled="!selectedSymbol"
@click="handleScore"
>
打分
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 打分结果 -->
<div v-if="scoreResult" 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(scoreResult.ts_code).symbol }}
</el-descriptions-item>
<el-descriptions-item label="合约">
{{ parseTsCode(scoreResult.ts_code).contract }}
</el-descriptions-item>
<el-descriptions-item label="日期">{{ scoreResult.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ scoreResult.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ scoreResult.oi }}</el-descriptions-item>
<el-descriptions-item label="短期(7d)">{{ scoreResult.short_term }}</el-descriptions-item>
<el-descriptions-item label="中期(15d)">{{ scoreResult.medium_term }}</el-descriptions-item>
<el-descriptions-item label="长期(30d)">{{ scoreResult.long_term }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ scoreResult.composite }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
<el-tag :type="signalTagType(scoreResult.signal)">
{{ signalIcon(scoreResult.signal) }} {{ scoreResult.signal }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="ai-section">
<el-button
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>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
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) {
padding: 12px 16px;
}
.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);
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>
/* 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>

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

View File

@@ -1,161 +0,0 @@
# 期货行情分析系统 — 使用说明
基于 Docker + Python(tushare) + Go 的中国期货行情分析系统。当前阶段已实现数据采集与三层加权打分模型。
## 环境准备
- Docker >= 20.10
- Docker Compose >= 2.0
- (可选) sqlite3 CLI 用于本地查库
## 快速开始
### 1. 配置 tushare token
将 token 写入 `tushare/.env`
```bash
echo "TUSHARE_TOKEN=你的token" > tushare/.env
```
该文件已被 gitignore 排除,不会进入版本库。
### 2. 启动并跑默认合约
```bash
docker-compose run --rm tushare
```
默认执行 `FG2609.ZCE`(玻璃期货 2609 合约),流程:
1. 从 tushare 拉取合约日线数据
2. 写入 SQLite `data/futures.db`
3. 运行三层打分模型
4. 保存打分结果并输出到 stdout
### 3. 跑其他合约
```bash
# 螺纹钢 2510 合约(上期所)
docker-compose run --rm tushare python -m src.main RB2510.SHF
# 铁矿石 2601 合约(大商所)
docker-compose run --rm tushare python -m src.main I2601.DCE
```
## 三层打分模型
### 综合分数公式
```
综合分数 = (短期动力 × 0.4) + (中期趋势 × 0.35) + (长期结构 × 0.25)
```
### 1. 短期动力7 日窗口,权重 0.4
逐日打分后取均值:
| 持仓变化 | 价格方向 | 得分 |
|---------|---------|------|
| 增仓 | 上涨 | 100多头主动进攻 |
| 增仓 | 下跌 | 0空头主动进攻 |
| 减仓 | 上涨 | 70空头撤退 |
| 减仓 | 下跌 | 30多头撤退 |
| 持平(\|变化\|<1% | 上涨 | 60 |
| 持平(\|变化\|<1% | 下跌 | 40 |
### 2. 中期趋势15 日窗口,权重 0.35
```
价格信号 = (今收 - 15日前收) / 15日前收
价格信号得分 = clamp(50 + 收益率×500, 0, 100)
资金意愿:
增仓上涨天数 > 增仓下跌天数 → 80
两者相当 → 50
增仓下跌天数 > 增仓上涨天数 → 20
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
```
### 3. 长期结构30 日窗口,权重 0.25
```
持仓变化幅度 = (30日日均持仓 - 30日前持仓) / 30日前持仓
> 10% → 90显著增仓
5%~10% → 70温和增仓
-5%~5% → 50基本持平
-10%~-5% → 30温和减仓
< -10% → 10显著减仓
```
### 信号解读
| 综合分数 | 信号 |
|---------|------|
| 80-100 | 强烈看多 — 价格与资金共振 |
| 50-80 | 偏多/震荡偏强 |
| 40-50 | 偏空/震荡偏弱 |
| 0-40 | 强烈看空 — 资金主动打压 |
## 数据查询
SQLite 数据库位于 `data/futures.db`,可直接用 sqlite3 查询:
```bash
# 查看最新打分
sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;"
# 查看合约日线
sqlite3 data/futures.db "SELECT trade_date, open, high, low, close, vol, oi FROM candles WHERE ts_code='FG2609.ZCE' ORDER BY trade_date DESC LIMIT 10;"
# 查看表结构
sqlite3 data/futures.db ".schema"
```
## 项目结构
```
trade/
├── docker-compose.yml # Docker Compose 编排
├── 使用说明.md # 本文件
├── data/ # SQLite 数据库目录(gitignored)
│ └── futures.db
├── .gitignore # Git 忽略配置
└── tushare/ # Python 数据服务
├── Dockerfile # 镜像构建
├── requirements.txt # Python 依赖
├── .env # TUSHARE_TOKEN(本地,不入库)
└── src/ # Python 包
├── models.py # 数据模型
├── fetcher.py # tushare 数据拉取
├── scorer.py # 打分模型核心
├── storage.py # SQLite 持久化
└── main.py # CLI 入口
```
## 技术栈
- **Python 3.13** (alpine 基础镜像)
- **tushare** — 中国金融数据接口
- **pandas** — 数据处理
- **SQLite** — 本地数据存储
- **Docker / Docker Compose** — 容器化部署
## 常见问题
**Q: 为什么某些日期返回空数据?**
A: tushare 数据更新有延迟,且不同接口对 token 积分等级有要求。若 `fut_daily` 返回空但 `trade_cal` 正常,通常是该日期实际行情数据尚未入库。
**Q: 合约代码格式?**
A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大商所用 `.DCE`。注意不是 `.CZC`
**Q: 如何定时自动跑?**
A: 当前为手动 CLI 触发。后续可在 `docker-compose.yml` 中增加 cron 服务或接入调度器。
**Q: Go 后端怎么读数据?**
A: Go 端可直接用 `database/sql` + `github.com/mattn/go-sqlite3` 读取 `data/futures.db` 中的 `candles``scores` 表。