Compare commits

..

63 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
58 changed files with 4729 additions and 820 deletions

View File

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

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 ...`

View File

@@ -1,3 +1,5 @@
name: trade
services: services:
postgres: postgres:
image: postgres:18.3-alpine3.23 image: postgres:18.3-alpine3.23
@@ -17,14 +19,14 @@ services:
tushare: tushare:
build: ./tushare build: ./tushare
container_name: trade-tushare container_name: trade-tushare
env_file: ./tushare/.env # token 已写死在代码中,无需 env_file
environment: environment:
- DATABASE_URL=postgresql://trade:trade@postgres:5432/futures - DATABASE_URL=postgresql://trade:trade@postgres:5432/futures
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
ports: ports:
- "8000:8000" - "4001:8000"
command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"] command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
web: web:
@@ -32,17 +34,13 @@ services:
context: ./web context: ./web
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
container_name: trade-web container_name: trade-web
env_file: ./web/backend/.env # .env 已移除,环境变量直接写在此处
environment: environment:
- LISTEN_ADDR=:8080
- DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable - DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable
- AUTH_DB_PATH=/app/auth/auth.db
depends_on: depends_on:
- postgres - postgres
ports: ports:
- "8080:8080" - "4000:8080"
volumes:
- ./data:/app/auth
restart: unless-stopped restart: unless-stopped
volumes: volumes:

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,6 +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 fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0
psycopg[binary]>=3.2.0 psycopg[binary]>=3.2.0

View File

@@ -1,9 +1,11 @@
from typing import Optional from typing import Optional
from datetime import date, datetime, timedelta
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from . import contracts, fetcher, notifier, scorer, storage from . import contracts, fetcher, scorer, storage
app = FastAPI(title="期货数据采集与打分服务") app = FastAPI(title="期货数据采集与打分服务")
@@ -11,6 +13,17 @@ app = FastAPI(title="期货数据采集与打分服务")
class RunRequest(BaseModel): class RunRequest(BaseModel):
ts_code: Optional[str] = None ts_code: Optional[str] = None
symbol: str = "FG" symbol: str = "FG"
trade_date: Optional[str] = None
class RunRangeRequest(BaseModel):
symbol: str = "FG"
start_date: str
end_date: str
class RunFullRequest(BaseModel):
ts_code: str
class RunResponse(BaseModel): class RunResponse(BaseModel):
@@ -24,6 +37,9 @@ class RunResponse(BaseModel):
long_term: float long_term: float
composite: float composite: float
signal: str signal: str
vol_penalty: float = 1.0
composite_delta: Optional[float] = None
composite_delta_5d: Optional[float] = None
@app.on_event("startup") @app.on_event("startup")
@@ -38,23 +54,18 @@ def health():
@app.post("/api/v1/run", response_model=RunResponse) @app.post("/api/v1/run", response_model=RunResponse)
def run_pipeline(req: RunRequest): def run_pipeline(req: RunRequest):
ts_code = req.ts_code or contracts.active_contract(req.symbol) ref_date = datetime.strptime(req.trade_date, "%Y%m%d").date() if req.trade_date else None
if not req.ts_code: ts_code = req.ts_code or contracts.active_contract(req.symbol, ref_date)
if req.ts_code:
ts_code = contracts.normalize_ts_code(ts_code)
else:
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
storage.save_candles(df) storage.save_candles(df)
result = scorer.score_daily(df) result = scorer.score_daily(df, req.trade_date)
storage.save_score(result) storage.save_score(result)
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
push_body = (
f"综合 {result.composite:.1f}\n"
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n"
f"{result.signal}"
)
notifier.push_bark(push_title, push_body)
return RunResponse( return RunResponse(
ts_code=result.ts_code, ts_code=result.ts_code,
trade_date=result.trade_date, trade_date=result.trade_date,
@@ -66,9 +77,104 @@ def run_pipeline(req: RunRequest):
long_term=result.long_term, long_term=result.long_term,
composite=result.composite, composite=result.composite,
signal=result.signal, signal=result.signal,
vol_penalty=result.vol_penalty,
composite_delta=result.composite_delta,
composite_delta_5d=result.composite_delta_5d,
) )
@app.post("/api/v1/run/batch")
def run_batch():
"""对所有固定品种执行今日打分。"""
results = []
errors = []
for symbol in contracts.SYMBOLS:
try:
ts_code = contracts.active_contract(symbol)
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
result = scorer.score_daily(df)
storage.save_score(result)
results.append({
"symbol": symbol,
"ts_code": result.ts_code,
"trade_date": result.trade_date,
"signal": result.signal,
"composite": result.composite,
})
except Exception as e:
errors.append({"symbol": symbol, "error": str(e)})
return {"results": results, "errors": errors}
@app.post("/api/v1/run/range")
def run_range(req: RunRangeRequest):
"""对指定日期区间内的每一天分别打分。"""
start_dt = datetime.strptime(req.start_date, "%Y%m%d").date()
ts_code = contracts.active_contract(req.symbol, start_dt)
# 为确保区间开始日有足够前置数据,拉取时 start_date 前推 60 天
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=req.end_date)
storage.save_candles(df)
results, warnings = scorer.score_range(df, req.start_date, req.end_date)
for r in results:
storage.save_score(r)
return {
"ts_code": ts_code,
"start_date": req.start_date,
"end_date": req.end_date,
"scored": len(results),
"skipped": len(warnings),
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.post("/api/v1/run/full")
def run_full(req: RunFullRequest):
"""拉取指定合约全部历史数据,保存 candles对所有可打分日期逐日打分并保存。"""
ts_code = contracts.normalize_ts_code(req.ts_code)
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
results, warnings, total_days, scored_count = scorer.score_all(df)
for r in results:
storage.save_score(r)
skipped_count = total_days - scored_count
return {
"ts_code": ts_code,
"total_days": total_days,
"scored_count": scored_count,
"skipped_count": skipped_count,
"warnings": warnings,
"results": [
{
"trade_date": r.trade_date,
"close": r.close,
"composite": r.composite,
"signal": r.signal,
}
for r in results
],
}
@app.get("/api/v1/scores") @app.get("/api/v1/scores")
def list_scores( def list_scores(
ts_code: Optional[str] = Query(None), ts_code: Optional[str] = Query(None),
@@ -132,6 +238,20 @@ def list_contracts():
conn.close() conn.close()
@app.get("/api/v1/contracts/active")
def get_active_contract(symbol: str = Query(...)):
"""返回某品种当前主力合约及可选打分日期范围。"""
if symbol not in contracts.ROLLOVER_RULES:
raise HTTPException(status_code=400, detail=f"未配置 {symbol} 的主力轮换规则")
today = date.today()
return {
"symbol": symbol,
"ts_code": contracts.active_contract(symbol, today),
"min_date": contracts.active_contract_start(symbol, today).isoformat(),
"max_date": today.isoformat(),
}
@app.get("/api/v1/candles") @app.get("/api/v1/candles")
def list_candles( def list_candles(
ts_code: str = Query(...), ts_code: str = Query(...),
@@ -160,3 +280,10 @@ def list_candles(
return [dict(zip(cols, row)) for row in cur.fetchall()] return [dict(zip(cols, row)) for row in cur.fetchall()]
finally: finally:
conn.close() conn.close()
@app.post("/api/v1/admin/reset-data")
def reset_data():
"""清空所有行情数据(仅限管理员调用)。"""
storage.truncate_all()
return {"status": "ok", "message": "已清空所有行情数据"}

View File

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

View File

@@ -1,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 contracts, fetcher, notifier, scorer, storage from . import contracts, fetcher, scorer, storage
def run(ts_code: str) -> int: def run(ts_code: str, trade_date: Optional[str] = None) -> int:
storage.init_db() storage.init_db()
print(f"[1/4] 拉取 {ts_code} 数据...") print(f"[1/4] 拉取 {ts_code} 数据...")
df = fetcher.fetch_contract(ts_code) df = fetcher.fetch_contract(ts_code)
print(f" 返回 {len(df)}") print(f" 返回 {len(df)}")
print(f"[2/4] 写入/更新 SQLite...") print(f"[2/4] 写入/更新 PostgreSQL...")
storage.save_candles(df) storage.save_candles(df)
print(f"[3/4] 计算打分...") print(f"[3/4] 计算打分...")
result = scorer.score_daily(df) result = scorer.score_daily(df, trade_date)
print(f"[4/4] 保存打分结果...") print(f"[4/4] 保存打分结果...")
storage.save_score(result) storage.save_score(result)
@@ -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,24 +58,90 @@ 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}")
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}" aw = result.detail.adaptive_weights
push_body = ( if aw:
f"综合 {result.composite:.1f}\n" print(f"\n[自适应权重]")
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n" print(f" 趋势强度: {aw['trend_strength']:.2f}")
f"{result.signal}" print(f" 短期权重: {aw['w_short']:.2%} (基准 40%)")
) print(f" 中期权重: {aw['w_medium']:.2%} (基准 35%)")
if notifier.push_bark(push_title, push_body): print(f" 长期权重: {aw['w_long']:.2%} (基准 25%)")
print("[Bark] 推送成功")
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
@@ -86,12 +157,33 @@ def main() -> int:
default="FG", default="FG",
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG", help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
) )
parser.add_argument(
"--date",
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
)
parser.add_argument(
"--start-date",
dest="start_date",
help="区间打分开始日期,格式 YYYYMMDD(与 --end-date 同时使用时生效)",
)
parser.add_argument(
"--end-date",
dest="end_date",
help="区间打分结束日期,格式 YYYYMMDD(与 --start-date 同时使用时生效)",
)
args = parser.parse_args() args = parser.parse_args()
ts_code = args.ts_code or contracts.active_contract(args.symbol) ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.ts_code: if not args.ts_code:
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}") print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
return run(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,28 +0,0 @@
import os
from urllib.parse import quote
import requests
DEFAULT_BARK_KEY = "RvdtHq4py2avatt4AFJn9a"
BARK_BASE_URL = "https://api.day.app"
def push_bark(title: str, body: str, key: str | None = None, timeout: float = 15.0, retries: int = 1) -> bool:
bark_key = key or os.environ.get("BARK_KEY") or DEFAULT_BARK_KEY
url = f"{BARK_BASE_URL}/{bark_key}/{quote(title, safe='')}/{quote(body, safe='')}"
last_err: Exception | None = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, timeout=timeout)
except requests.RequestException as e:
last_err = e
continue
if resp.status_code == 200:
return True
print(f"[WARN] Bark 推送返回非 200: {resp.status_code} {resp.text[:120]}")
return False
print(f"[WARN] Bark 推送失败: {last_err}")
return False

View File

@@ -1,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

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

View File

@@ -1,8 +0,0 @@
# 拷贝为 web/backend/.env 后填入真实值。.env 已被 .gitignore 排除。
# 首次启动时,若 auth.db 中没有任何 admin 用户,会用下面这一对凭据创建管理员;
# 一旦 admin 已存在,这两个变量会被忽略,改它们不会改密码。
ADMIN_USER=admin
ADMIN_PASS=changeme
# JWT 签名密钥;生成方式:openssl rand -hex 32
JWT_SECRET=replace-with-32-bytes-hex

View File

@@ -5,7 +5,8 @@ WORKDIR /ui
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install # 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi RUN npm config set registry https://registry.npmmirror.com && \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
COPY frontend ./ COPY frontend ./
RUN npm run build RUN npm run build
@@ -21,8 +22,7 @@ WORKDIR /src
COPY backend ./ COPY backend ./
COPY --from=ui /ui/dist ./dist COPY --from=ui /ui/dist ./dist
# 用 modernc.org/sqlite 纯 Go 驱动,无 CGO,无需 gcc/musl-dev ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
ENV CGO_ENABLED=0 GOOS=linux
RUN go mod tidy && \ RUN go mod tidy && \
go build -trimpath -ldflags="-s -w" -o /out/web ./ go build -trimpath -ldflags="-s -w" -o /out/web ./
@@ -36,7 +36,7 @@ RUN apk add --no-cache tzdata ca-certificates && \
echo "Asia/Shanghai" > /etc/timezone && \ echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata && \ apk del tzdata && \
adduser -D -u 1000 app && \ adduser -D -u 1000 app && \
mkdir -p /app/data /app/auth && \ mkdir -p /app/data && \
chown -R app:app /app chown -R app:app /app
WORKDIR /app WORKDIR /app
@@ -45,8 +45,7 @@ USER app
COPY --from=api --chown=app:app /out/web /app/web COPY --from=api --chown=app:app /out/web /app/web
ENV TZ=Asia/Shanghai \ ENV TZ=Asia/Shanghai \
LISTEN_ADDR=:8080 \ LISTEN_ADDR=:8080
AUTH_DB_PATH=/app/auth/auth.db
EXPOSE 8080 EXPOSE 8080

View File

@@ -7,5 +7,4 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
modernc.org/sqlite v1.32.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

@@ -6,9 +6,14 @@ import (
"trade/web/internal/store" "trade/web/internal/store"
) )
// Bootstrap 在 auth.db 没有任何 admin 时,从 ADMIN_USER/ADMIN_PASS 写入一条管理员; // BootstrapLLMConfig 初始化 llm_config 表。
// 已存在 admin 时静默跳过,避免轮换 env 时静默改密。 func BootstrapLLMConfig(s *store.FuturesStore) error {
func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error { return s.EnsureLLMConfigTable()
}
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
func Bootstrap(s *store.AuthStore) error {
n, err := s.CountAdmins() n, err := s.CountAdmins()
if err != nil { if err != nil {
return err return err
@@ -16,17 +21,17 @@ func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error {
if n > 0 { if n > 0 {
return nil return nil
} }
if adminUser == "" || adminPass == "" { hash, err := HashPassword("admin")
log.Printf("[bootstrap] auth.db 无 admin,但 ADMIN_USER/ADMIN_PASS 未设置,跳过引导")
return nil
}
hash, err := HashPassword(adminPass)
if err != nil { if err != nil {
return err return err
} }
if _, err := s.CreateUser(adminUser, hash, store.RoleAdmin); err != nil { u, err := s.CreateUser("admin", hash, store.RoleAdmin)
if err != nil {
return err return err
} }
log.Printf("[bootstrap] admin %q created", adminUser) if err := s.SetForcePasswordChange(u.ID, true); err != nil {
return err
}
log.Printf("[bootstrap] admin created (default password), force password change enabled")
return nil return nil
} }

View File

@@ -3,34 +3,29 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
) )
type Config struct { type Config struct {
ListenAddr string ListenAddr string
DatabaseURL string DatabaseURL string
AuthDBPath string TushareAPIURL string
JWTSecret []byte LLMBaseURL string
AdminUser string LLMAPIKey string
AdminPass string LLMModel string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"), TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")), LLMBaseURL: getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
AdminPass: os.Getenv("ADMIN_PASS"), LLMAPIKey: os.Getenv("LLM_API_KEY"),
LLMModel: getenv("LLM_MODEL", "deepseek-chat"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置") return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
} }
secret := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if len(secret) < 16 {
return nil, fmt.Errorf("JWT_SECRET 必须至少 16 个字符 (建议 openssl rand -hex 32)")
}
cfg.JWTSecret = []byte(secret)
return cfg, nil return cfg, nil
} }

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

@@ -18,12 +18,19 @@ type loginReq struct {
type loginResp struct { type loginResp struct {
Token string `json:"token"` Token string `json:"token"`
User publicUserView `json:"user"` User publicUserView `json:"user"`
RequirePasswordChange bool `json:"require_password_change"`
} }
type publicUserView struct { type publicUserView struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` 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) { func (d *Deps) Login(w http.ResponseWriter, r *http.Request) {
@@ -47,15 +54,64 @@ func (d *Deps) Login(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "用户名或密码错误") writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return return
} }
token, _, err := d.JWT.Issue(u.ID, u.Username, u.Role) // 暂时不用 JWT返回固定 token
if err != nil { writeJSON(w, http.StatusOK, loginResp{
writeErr(w, http.StatusInternalServerError, "issue token failed") 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 return
} }
writeJSON(w, http.StatusOK, loginResp{ var req changePasswordReq
Token: token, if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
User: publicUserView{ID: u.ID, Username: u.Username, Role: u.Role}, 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) { func (d *Deps) Logout(w http.ResponseWriter, r *http.Request) {
@@ -84,6 +140,7 @@ func sanitize(u *store.User) map[string]any {
"username": u.Username, "username": u.Username,
"role": u.Role, "role": u.Role,
"disabled": u.Disabled, "disabled": u.Disabled,
"force_password_change": u.ForcePasswordChange,
"created_at": u.CreatedAt, "created_at": u.CreatedAt,
"updated_at": u.UpdatedAt, "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

@@ -5,7 +5,6 @@ import (
"log" "log"
"net/http" "net/http"
"trade/web/internal/auth"
"trade/web/internal/store" "trade/web/internal/store"
) )
@@ -13,7 +12,8 @@ import (
type Deps struct { type Deps struct {
Auth *store.AuthStore Auth *store.AuthStore
Futures *store.FuturesStore Futures *store.FuturesStore
JWT *auth.Manager TushareURL string
AIConfig *AIConfig
} }
func writeJSON(w http.ResponseWriter, status int, body any) { func writeJSON(w http.ResponseWriter, status int, body any) {

View File

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

View File

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

View File

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

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

View File

@@ -100,6 +100,11 @@ func (d *Deps) AdminPatchUser(w http.ResponseWriter, r *http.Request) {
writeErr(w, statusForErr(err), err.Error()) writeErr(w, statusForErr(err), err.Error())
return return
} }
// 管理员重置密码后,强制用户下次登录改密
if err := d.Auth.SetForcePasswordChange(id, true); err != nil {
writeErr(w, statusForErr(err), err.Error())
return
}
} }
if req.Disabled != nil { if req.Disabled != nil {
// 禁止禁用自己,避免管理员锁死自己 // 禁止禁用自己,避免管理员锁死自己

View File

@@ -3,9 +3,7 @@ package middleware
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"trade/web/internal/auth"
"trade/web/internal/store" "trade/web/internal/store"
) )
@@ -24,33 +22,15 @@ func FromContext(ctx context.Context) (CtxUser, bool) {
return u, ok return u, ok
} }
// RequireUser 校验 Authorization Bearer JWT,通过后把 CtxUser 写入 context // RequireUser 不再校验 JWT直接注入默认管理员用户所有请求放行
// 同时校验数据库里的 disabled 状态,被禁用的账户即使持有 token 也会被拒。 func RequireUser(next http.Handler) http.Handler {
func RequireUser(mgr *auth.Manager, s *store.AuthStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing token"})
return
}
claims, err := mgr.Parse(tok)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
u, err := s.GetByID(claims.UserID)
if err != nil || u.Disabled {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "account disabled or removed"})
return
}
ctx := context.WithValue(r.Context(), userKey, CtxUser{ ctx := context.WithValue(r.Context(), userKey, CtxUser{
ID: u.ID, Username: u.Username, Role: u.Role, ID: 1, Username: "admin", Role: store.RoleAdmin,
}) })
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
}
func RequireAdmin(next http.Handler) http.Handler { func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -62,12 +42,3 @@ func RequireAdmin(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
return strings.TrimSpace(h[len(p):])
}
return ""
}

View File

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

View File

@@ -7,13 +7,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"trade/web/internal/auth"
"trade/web/internal/handlers" "trade/web/internal/handlers"
mw "trade/web/internal/middleware" mw "trade/web/internal/middleware"
"trade/web/internal/store"
) )
func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist fs.FS) http.Handler { func New(d *handlers.Deps, dist fs.FS) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(mw.Recover) r.Use(mw.Recover)
r.Use(mw.Logger) r.Use(mw.Logger)
@@ -22,14 +20,23 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f
r.Post("/login", d.Login) r.Post("/login", d.Login)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireUser(mgr, authStore)) r.Use(mw.RequireUser)
r.Post("/logout", d.Logout) r.Post("/logout", d.Logout)
r.Get("/me", d.Me) r.Get("/me", d.Me)
r.Post("/change-password", d.ChangePassword)
r.Get("/scores", d.ListScores) r.Get("/scores", d.ListScores)
r.Get("/scores/{id}", d.GetScore) r.Get("/scores/{id}", d.GetScore)
r.Get("/contracts", d.ListContracts) r.Get("/contracts", d.ListContracts)
r.Get("/contracts/active", d.GetActiveContract)
r.Get("/candles", d.ListCandles) r.Get("/candles", d.ListCandles)
r.Post("/run", d.RunPipeline)
r.Post("/run/batch", d.RunBatch)
r.Post("/run/range", d.RunRange)
r.Post("/run/full", d.RunFull)
r.Get("/ai/analyze", d.Analyze)
r.Post("/ai/daily-direction", d.DailyDirectionRun)
r.Get("/ai/daily-direction", d.ListDailyDirections)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(mw.RequireAdmin) r.Use(mw.RequireAdmin)
@@ -37,6 +44,9 @@ func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist f
r.Post("/admin/users", d.AdminCreateUser) r.Post("/admin/users", d.AdminCreateUser)
r.Patch("/admin/users/{id}", d.AdminPatchUser) r.Patch("/admin/users/{id}", d.AdminPatchUser)
r.Delete("/admin/users/{id}", d.AdminDeleteUser) r.Delete("/admin/users/{id}", d.AdminDeleteUser)
r.Post("/admin/reset-data", d.AdminResetData)
r.Get("/admin/llm-config", d.GetLLMConfig)
r.Put("/admin/llm-config", d.SaveLLMConfig)
}) })
}) })
}) })

View File

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

View File

@@ -4,10 +4,9 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"time" "time"
_ "modernc.org/sqlite" _ "github.com/lib/pq"
) )
type AuthStore struct{ db *sql.DB } type AuthStore struct{ db *sql.DB }
@@ -18,6 +17,7 @@ type User struct {
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
Role string `json:"role"` Role string `json:"role"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
ForcePasswordChange bool `json:"force_password_change"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
@@ -29,18 +29,14 @@ const (
var ErrNotFound = errors.New("user not found") var ErrNotFound = errors.New("user not found")
func OpenAuth(path string) (*AuthStore, error) { func OpenAuth(databaseURL string) (*AuthStore, error) {
if dir := filepath.Dir(path); dir != "" { db, err := sql.Open("postgres", databaseURL)
_ = ensureDir(dir)
}
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open auth.db: %w", err) return nil, fmt.Errorf("open auth db: %w", err)
} }
db.SetMaxOpenConns(1) // sqlite write 单连接更稳 db.SetMaxOpenConns(8)
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping auth.db: %w", err) return nil, fmt.Errorf("ping auth db: %w", err)
} }
s := &AuthStore{db: db} s := &AuthStore{db: db}
if err := s.init(); err != nil { if err := s.init(); err != nil {
@@ -54,18 +50,23 @@ func (s *AuthStore) Close() error { return s.db.Close() }
func (s *AuthStore) init() error { func (s *AuthStore) init() error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','user')), role TEXT NOT NULL CHECK(role IN ('admin','user')),
disabled INTEGER NOT NULL DEFAULT 0, disabled BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
`) `)
if err != nil {
return err 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) { func (s *AuthStore) CountAdmins() (int, error) {
var n int var n int
@@ -75,33 +76,33 @@ func (s *AuthStore) CountAdmins() (int, error) {
func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, error) { func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, error) {
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec( var id int64
err := s.db.QueryRow(
`INSERT INTO users(username, password_hash, role, disabled, created_at, updated_at) `INSERT INTO users(username, password_hash, role, disabled, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?)`, VALUES ($1, $2, $3, FALSE, $4, $5) RETURNING id`,
username, passwordHash, role, now, now, username, passwordHash, role, now, now,
) ).Scan(&id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, PasswordHash: passwordHash, Role: role, return &User{ID: id, Username: username, PasswordHash: passwordHash, Role: role,
CreatedAt: now, UpdatedAt: now}, nil CreatedAt: now, UpdatedAt: now}, nil
} }
func (s *AuthStore) GetByUsername(username string) (*User, error) { func (s *AuthStore) GetByUsername(username string) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at
FROM users WHERE username = ?`, username) FROM users WHERE username = $1`, username)
return scanUser(row) return scanUser(row)
} }
func (s *AuthStore) GetByID(id int64) (*User, error) { func (s *AuthStore) GetByID(id int64) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, force_password_change, created_at, updated_at
FROM users WHERE id = ?`, id) FROM users WHERE id = $1`, id)
return scanUser(row) return scanUser(row)
} }
func (s *AuthStore) ListUsers() ([]User, error) { func (s *AuthStore) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, created_at, updated_at 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`) FROM users ORDER BY id ASC`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -120,7 +121,20 @@ func (s *AuthStore) ListUsers() ([]User, error) {
func (s *AuthStore) UpdatePassword(id int64, hash string) error { func (s *AuthStore) UpdatePassword(id int64, hash string) error {
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, hash, now, id) 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 { if err != nil {
return err return err
} }
@@ -133,11 +147,7 @@ func (s *AuthStore) UpdatePassword(id int64, hash string) error {
func (s *AuthStore) SetDisabled(id int64, disabled bool) error { func (s *AuthStore) SetDisabled(id int64, disabled bool) error {
now := time.Now().Format("2006-01-02 15:04:05") now := time.Now().Format("2006-01-02 15:04:05")
v := 0 res, err := s.db.Exec(`UPDATE users SET disabled = $1, updated_at = $2 WHERE id = $3`, disabled, now, id)
if disabled {
v = 1
}
res, err := s.db.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?`, v, now, id)
if err != nil { if err != nil {
return err return err
} }
@@ -149,7 +159,7 @@ func (s *AuthStore) SetDisabled(id int64, disabled bool) error {
} }
func (s *AuthStore) DeleteUser(id int64) error { func (s *AuthStore) DeleteUser(id int64) error {
res, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id) res, err := s.db.Exec(`DELETE FROM users WHERE id = $1`, id)
if err != nil { if err != nil {
return err return err
} }
@@ -166,14 +176,12 @@ type rowScanner interface {
func scanUser(r rowScanner) (*User, error) { func scanUser(r rowScanner) (*User, error) {
var u User var u User
var disabled int if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Disabled, &u.ForcePasswordChange, &u.CreatedAt, &u.UpdatedAt); err != nil {
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
u.Disabled = disabled != 0
return &u, nil return &u, nil
} }

View File

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

View File

@@ -1,10 +0,0 @@
package store
import "os"
func ensureDir(dir string) error {
if _, err := os.Stat(dir); err == nil {
return nil
}
return os.MkdirAll(dir, 0o755)
}

View File

@@ -30,18 +30,33 @@ func main() {
} }
defer futures.Close() defer futures.Close()
authDB, err := store.OpenAuth(cfg.AuthDBPath) authDB, err := store.OpenAuth(cfg.DatabaseURL)
if err != nil { if err != nil {
log.Fatalf("open auth: %v", err) log.Fatalf("open auth: %v", err)
} }
defer authDB.Close() defer authDB.Close()
if err := auth.Bootstrap(authDB, cfg.AdminUser, cfg.AdminPass); err != nil { if err := auth.Bootstrap(authDB); err != nil {
log.Fatalf("bootstrap: %v", err) log.Fatalf("bootstrap: %v", err)
} }
mgr := auth.NewManager(cfg.JWTSecret) if err := auth.BootstrapLLMConfig(futures); err != nil {
deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr} log.Fatalf("bootstrap llm config: %v", err)
}
if err := futures.EnsureDailyDirectionTable(); err != nil {
log.Fatalf("bootstrap daily_direction: %v", err)
}
deps := &handlers.Deps{
Auth: authDB,
Futures: futures,
TushareURL: cfg.TushareAPIURL,
AIConfig: &handlers.AIConfig{
BaseURL: cfg.LLMBaseURL,
APIKey: cfg.LLMAPIKey,
Model: cfg.LLMModel,
},
}
dist, err := fs.Sub(distFS, "dist") dist, err := fs.Sub(distFS, "dist")
if err != nil { if err != nil {
@@ -50,7 +65,7 @@ func main() {
srv := &http.Server{ srv := &http.Server{
Addr: cfg.ListenAddr, Addr: cfg.ListenAddr,
Handler: router.New(deps, mgr, authDB, dist), Handler: router.New(deps, dist),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import type { AuthUser } from '@/stores/auth'
export interface LoginResp { export interface LoginResp {
token: string token: string
user: AuthUser user: AuthUser
require_password_change: boolean
} }
export function login(username: string, password: string) { export function login(username: string, password: string) {
@@ -17,3 +18,7 @@ export function logout() {
export function me() { export function me() {
return client.get<AuthUser>('/me').then((r) => r.data) 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,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

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

View File

@@ -3,9 +3,11 @@ import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import type { Candle } from '@/api/candles' import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
const props = defineProps<{ data: Candle[] }>() const props = defineProps<{ data: Candle[]; scores?: { trade_date: string; composite: number }[] }>()
const theme = useThemeStore() const theme = useThemeStore()
const { isMobile } = useMobile()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null let chart: echarts.ECharts | null = null
@@ -26,28 +28,25 @@ function render() {
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high]) const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi) const oi = props.data.map((c) => c.oi)
chart.setOption( 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)
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, const hasScores = props.scores && props.scores.length > 0
legend: { data: ['K 线', '持仓量'], top: 0 }, const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
grid: [ const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
{ left: 60, right: 40, top: 40, height: '60%' }, const grids: any[] = [
{ left: 60, right: 40, top: '78%', height: '18%' }, { 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%' },
xAxis: [ ]
const xAxes: any[] = [
{ type: 'category', data: dates, scale: true, boundaryGap: false }, { type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } }, { type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
], ]
yAxis: [ const yAxes: any[] = [
{ scale: true, splitArea: { show: true } }, { scale: true, splitArea: { show: true }, name: '价格', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
{ gridIndex: 1, scale: true, splitNumber: 3 }, { gridIndex: 1, scale: true, splitNumber: 3, name: '持仓', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
], ]
dataZoom: [ const series: any[] = [
{ type: 'inside', xAxisIndex: [0, 1] },
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 },
],
series: [
{ {
name: 'K 线', name: 'K 线',
type: 'candlestick', type: 'candlestick',
@@ -70,7 +69,68 @@ function render() {
lineStyle: { color: '#5470c6' }, lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, 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, true,
) )
@@ -100,6 +160,10 @@ watch(
render() render()
}, },
) )
watch(isMobile, () => {
ensureChart()
render()
})
</script> </script>
<template> <template>
@@ -111,4 +175,9 @@ watch(
width: 100%; width: 100%;
height: 560px; height: 560px;
} }
@media (max-width: 768px) {
.chart {
height: 420px;
}
}
</style> </style>

View File

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

View File

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

View File

@@ -8,6 +8,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/LoginView.vue'), component: () => import('@/views/LoginView.vue'),
meta: { layout: 'blank', public: true }, meta: { layout: 'blank', public: true },
}, },
{
path: '/change-password',
name: 'change-password',
component: () => import('@/views/ChangePasswordView.vue'),
meta: { layout: 'blank' },
},
{ path: '/', redirect: '/scores' }, { path: '/', redirect: '/scores' },
{ {
path: '/scores', path: '/scores',
@@ -19,6 +25,21 @@ const routes: RouteRecordRaw[] = [
name: 'chart', name: 'chart',
component: () => import('@/views/ChartView.vue'), component: () => import('@/views/ChartView.vue'),
}, },
{
path: '/contract-full',
name: 'contract-full',
component: () => import('@/views/ContractFullView.vue'),
},
{
path: '/run',
name: 'run',
component: () => import('@/views/RunView.vue'),
},
{
path: '/daily-direction',
name: 'daily-direction',
component: () => import('@/views/DailyDirectionView.vue'),
},
{ {
path: '/admin/users', path: '/admin/users',
name: 'admin-users', name: 'admin-users',
@@ -39,6 +60,9 @@ router.beforeEach((to) => {
if (!auth.token) { if (!auth.token) {
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
} }
if (auth.requirePasswordChange && to.path !== '/change-password') {
return { path: '/change-password' }
}
if (to.meta.adminOnly && !auth.isAdmin) { if (to.meta.adminOnly && !auth.isAdmin) {
return { path: '/scores' } return { path: '/scores' }
} }

View File

@@ -4,11 +4,13 @@ export interface AuthUser {
id: number id: number
username: string username: string
role: 'admin' | 'user' role: 'admin' | 'user'
force_password_change?: boolean
} }
interface State { interface State {
token: string token: string
user: AuthUser | null user: AuthUser | null
requirePasswordChange: boolean
} }
const STORAGE_KEY = 'trade.auth' const STORAGE_KEY = 'trade.auth'
@@ -16,10 +18,15 @@ const STORAGE_KEY = 'trade.auth'
function load(): State { function load(): State {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { token: '', user: null } if (!raw) return { token: '', user: null, requirePasswordChange: false }
return JSON.parse(raw) as State const parsed = JSON.parse(raw) as Partial<State>
return {
token: parsed.token || '',
user: parsed.user || null,
requirePasswordChange: parsed.requirePasswordChange ?? false,
}
} catch { } catch {
return { token: '', user: null } return { token: '', user: null, requirePasswordChange: false }
} }
} }
@@ -29,14 +36,29 @@ export const useAuthStore = defineStore('auth', {
isAdmin: (s) => s.user?.role === 'admin', isAdmin: (s) => s.user?.role === 'admin',
}, },
actions: { actions: {
setSession(token: string, user: AuthUser) { setSession(token: string, user: AuthUser, requirePasswordChange?: boolean) {
this.token = token this.token = token
this.user = user this.user = user
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, 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() { logout() {
this.token = '' this.token = ''
this.user = null this.user = null
this.requirePasswordChange = false
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY)
}, },
}, },

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

@@ -8,6 +8,7 @@ import {
updateUser, updateUser,
type AdminUser, type AdminUser,
} from '@/api/users' } from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore() const auth = useAuthStore()
@@ -27,6 +28,52 @@ const resetDialog = reactive({
password: '', password: '',
}) })
// LLM 配置
const llmCfg = reactive({
base_url: 'https://api.deepseek.com/v1',
api_key: '',
model: 'deepseek-chat',
has_api_key: false,
saving: false,
loading: false,
})
async function loadLLMConfig() {
llmCfg.loading = true
try {
const cfg = await getLLMConfig()
llmCfg.base_url = cfg.base_url
llmCfg.api_key = cfg.api_key || ''
llmCfg.model = cfg.model
llmCfg.has_api_key = cfg.has_api_key
} catch {
// ignore
} finally {
llmCfg.loading = false
}
}
async function submitLLMConfig() {
if (!llmCfg.base_url.trim()) {
ElMessage.warning('API 地址不能为空')
return
}
llmCfg.saving = true
try {
await saveLLMConfig({
base_url: llmCfg.base_url.trim(),
api_key: llmCfg.api_key.trim(),
model: llmCfg.model.trim(),
})
ElMessage.success('LLM 配置已保存')
await loadLLMConfig()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '保存失败')
} finally {
llmCfg.saving = false
}
}
async function reload() { async function reload() {
loading.value = true loading.value = true
try { try {
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
await reload() await reload()
} }
onMounted(reload) onMounted(() => {
reload()
loadLLMConfig()
})
</script> </script>
<template> <template>
@@ -103,7 +153,8 @@ onMounted(reload)
</div> </div>
</el-card> </el-card>
<el-table :data="users" v-loading="loading" stripe> <div class="table-wrapper" v-loading="loading">
<el-table :data="users" stripe class="user-table">
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" label="用户名" /> <el-table-column prop="username" label="用户名" />
<el-table-column prop="role" label="角色" width="100"> <el-table-column prop="role" label="角色" width="100">
@@ -142,6 +193,39 @@ onMounted(reload)
</template> </template>
</el-table-column> </el-table-column>
</el-table> </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-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px"> <el-form label-width="80px">
@@ -195,4 +279,24 @@ onMounted(reload)
align-items: center; align-items: center;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
} }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper {
background: var(--el-bg-color);
border-radius: 4px;
overflow-x: auto;
}
.user-table {
min-width: 960px;
}
@media (max-width: 768px) {
.head {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style> </style>

View File

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

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

View File

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

View File

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

View File

@@ -20,9 +20,13 @@ async function submit() {
loading.value = true loading.value = true
try { try {
const resp = await login(form.username.trim(), form.password) const resp = await login(form.username.trim(), form.password)
auth.setSession(resp.token, resp.user) 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' const redirect = (route.query.redirect as string) || '/scores'
router.replace(redirect) router.replace(redirect)
}
} catch { } catch {
// axios 拦截器已弹错 // axios 拦截器已弹错
} finally { } finally {
@@ -60,14 +64,17 @@ async function submit() {
<style scoped> <style scoped>
.login { .login {
min-height: 100vh; height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%); background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
overflow: hidden;
padding: 16px;
} }
.card { .card {
width: 360px; width: 360px;
max-width: 100%;
padding: 36px 32px; padding: 36px 32px;
background: var(--el-bg-color); background: var(--el-bg-color);
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
@@ -84,4 +91,9 @@ async function submit() {
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
} }
@media (max-width: 768px) {
.card {
padding: 28px 20px;
}
}
</style> </style>

View File

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

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

View File

@@ -1,282 +0,0 @@
# 期货行情分析系统 — 使用说明
基于 Docker + Python(tushare) 的中国期货行情分析系统。当前阶段已实现数据采集与三层加权打分模型,运行方式为脚本自动化(宿主机定时器触发 `docker-compose run`)。
## 环境准备
- 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
```
不传参时,按 `tushare/src/contracts.py``ROLLOVER_RULES` 自动选 FG 玻璃当月主力(例如 2026-05 -> `FG2609.ZCE`),启动后会先打印 `[AUTO] FG 当月主力 -> ...`,然后:
1. 从 tushare 拉取合约日线数据
2. 写入 SQLite `data/futures.db`
3. 运行三层打分模型
4. 保存打分结果并输出到 stdout
5. 通过 Bark 推送评分摘要
### 3. 跑其他合约或品种
```bash
# 显式指定合约
docker-compose run --rm tushare python -m src.main RB2510.SHF
docker-compose run --rm tushare python -m src.main I2601.DCE
# 按品种代号自动选当月主力(目前只配置了 FG)
docker-compose run --rm tushare python -m src.main --symbol FG
```
### 4. 玻璃 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
逐日打分后取均值:
| 持仓变化 | 价格方向 | 得分 |
|---------|---------|------|
| 增仓 | 上涨 | 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 编排(tushare + web 两个服务)
├── 使用说明.md # 本文件
├── data/ # SQLite 数据库目录(gitignored)
│ ├── futures.db # tushare 写入,web 只读
│ └── auth.db # web 自己维护的用户表
├── .gitignore # Git 忽略配置
├── tushare/ # Python 数据服务
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── .env # TUSHARE_TOKEN(本地,不入库)
│ └── src/ # 数据采集 + 打分 + Bark 推送
│ ├── models.py
│ ├── fetcher.py
│ ├── scorer.py
│ ├── storage.py
│ ├── contracts.py
│ ├── notifier.py
│ └── main.py
└── web/ # Web 浏览端
├── .dockerignore
├── backend/ # Go 1.25 后端 (chi + modernc.org/sqlite + JWT)
│ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
│ ├── go.mod
│ ├── main.go
│ ├── embed.go # //go:embed all:dist
│ ├── .env.example # ADMIN_USER/ADMIN_PASS/JWT_SECRET 示例
│ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
│ └── internal/
│ ├── config/ # 环境变量加载
│ ├── store/ # futures.db 只读 + auth.db 用户表
│ ├── auth/ # JWT + 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/auth.ts # Pinia,持久化 token
├── api/ # axios 封装 + 各端点
├── views/ # 登录 / 打分列表 / 图表 / 用户管理
└── components/ # 抽屉 + ECharts K 线
```
## 技术栈
- **Python 3.13** (alpine) + **tushare** + **pandas** — 数据采集与打分
- **Go 1.25.8** (alpine 3.23) + **chi** + **modernc.org/sqlite** + **JWT** — Web 后端
- **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端
- **SQLite** — 本地数据存储(双库:`futures.db` 业务 + `auth.db` 鉴权)
- **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 run --rm tushare ...`。打分结束会通过 Bark 推送结果(见 `tushare/src/notifier.py`)。
## Web 报表(浏览端)
`./web/` 提供一个图形化的浏览端,展示 tushare 流水线写入 `data/futures.db` 的打分与行情数据。后端 Go(`golang:1.25.8-alpine3.23`)读取数据库,前端 Vue 3 + Element Plus + ECharts,通过 docker-compose 一起部署。
### 1. 配置首启凭据
`web/backend/.env` 写入(`.env` 已 gitignored,可参考 `web/backend/.env.example`):
```bash
ADMIN_USER=admin
ADMIN_PASS=请改成强密码
JWT_SECRET=$(openssl rand -hex 32)
```
`ADMIN_USER`/`ADMIN_PASS` 仅在 `auth.db` 中没有任何 admin 时生效,首次启动会以这一对凭据建立管理员;之后即使改这两个变量也不会改密。`JWT_SECRET` 必须 ≥16 字符。
### 2. 启动
```bash
# 构建并启动 web 服务,不影响现有 tushare
docker-compose up -d --build web
# 查看启动日志:首启会出现 [bootstrap] admin 'xxx' created
docker-compose logs -f web
```
浏览器访问 `http://localhost:8080`,用上一步的管理员账号登录。
### 3. 页面说明
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分、中期(15d)价格收益与资金意愿、长期(30d)持仓变化。
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
### 4. 子账号维护流程
1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
2. 把账号发给同事即可登录;无注册入口。
3. 离职 / 风险事件:用「禁用」临时停用(token 立即失效,前端不能再请求),或「删除」彻底清除。
### 5. 数据流向与数据库分离
```
tushare(写) → data/futures.db ──(只读挂载 :ro)──> web 服务 ←(读写)→ data/auth.db
```
`futures.db` 的 schema 与 Python 端一致(`candles` + `scores`)。`auth.db` 表为:
```sql
users(id, username UNIQUE, password_hash, role IN ('admin','user'),
disabled, created_at, updated_at)
```
两个 DB 都在 `./data/` 目录,均被 `.gitignore` 覆盖。
### 6. 常见问题
**Q: 忘记管理员密码怎么办?**
```bash
docker-compose stop web
sqlite3 data/auth.db "DELETE FROM users WHERE role='admin';"
# 修改 web/backend/.env 里的 ADMIN_USER/ADMIN_PASS
docker-compose up -d web
```
启动时会重新触发 bootstrap 写入新的 admin。
**Q: 改了 Go / Vue 代码但页面没变?**
源码不挂载,镜像内是 COPY 进去的。重建:`docker-compose build web && docker-compose up -d web`
**Q: 登录提示 "JWT_SECRET 必须至少 16 个字符"?**
`web/backend/.env` 没设或太短,用 `openssl rand -hex 32` 生成一个 64 字符的十六进制字符串即可。
**Q: 容器内能不能误写 futures.db?**
不能。容器以 `./data:/app/data:ro` 挂载,Go 又用 `mode=ro&query_only(true)` 打开数据库,双层保险。auth.db 走另一个挂载点 `./data:/app/auth`(同物理目录但路径不同,无 `:ro`)。