Compare commits
39 Commits
6a1541ad9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947e227d5f | ||
|
|
5dad7a6a02 | ||
|
|
b91bffdb4c | ||
|
|
e756c3f300 | ||
|
|
bd48887b88 | ||
|
|
5c30bfa472 | ||
|
|
f5615d9580 | ||
|
|
6ab310cfb3 | ||
|
|
c47735f3b6 | ||
|
|
1d1a6d6cdf | ||
|
|
04636c53d8 | ||
|
|
4cdc542291 | ||
|
|
1094e82e88 | ||
|
|
99c2a5bcbf | ||
|
|
ad9edf7ad4 | ||
|
|
819b327cdb | ||
|
|
f2e4bf7041 | ||
|
|
e6351750cf | ||
|
|
465feaa833 | ||
|
|
f7b60659ab | ||
|
|
fef806f796 | ||
|
|
512d43121c | ||
|
|
01309dd8ff | ||
|
|
ee3acd1c4d | ||
|
|
9d2997a3cb | ||
|
|
cdf793608d | ||
|
|
c54ba5a470 | ||
|
|
01edda923a | ||
|
|
7aa74dc9bc | ||
|
|
b1f824b06d | ||
|
|
d217628ccf | ||
|
|
cff4319321 | ||
|
|
a7e1c9f416 | ||
|
|
fa5fa07ef6 | ||
|
|
7b6732488a | ||
|
|
76ddc495c1 | ||
|
|
23a1149c5f | ||
|
|
b6bacbfae9 | ||
|
|
c852b1d871 |
@@ -26,6 +26,9 @@ docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main -
|
|||||||
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
|
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
|
||||||
-d '{"symbol":"FG"}'
|
-d '{"symbol":"FG"}'
|
||||||
|
|
||||||
|
# 批量触发所有固定品种今日打分
|
||||||
|
curl -X POST http://localhost:4001/api/v1/run/batch
|
||||||
|
|
||||||
# 查询打分列表
|
# 查询打分列表
|
||||||
curl "http://localhost:4001/api/v1/scores?limit=5"
|
curl "http://localhost:4001/api/v1/scores?limit=5"
|
||||||
|
|
||||||
@@ -42,11 +45,11 @@ docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d future
|
|||||||
|
|
||||||
**单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores)`。无后台任务、无队列,每次 CLI 调用处理一个合约一日。
|
**单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores)`。无后台任务、无队列,每次 CLI 调用处理一个合约一日。
|
||||||
|
|
||||||
**FastAPI 服务**(`src.api`):容器默认以 `uvicorn src.api:app` 启动,暴露 `/api/v1/run`(触发流水线)、`/api/v1/scores`、`/api/v1/scores/{id}`、`/api/v1/contracts`、`/api/v1/candles` 等端点。启动时自动 `storage.init_db()` 建表。API 与 CLI 共用同一套 `fetcher/storage/scorer` 逻辑。
|
**FastAPI 服务**(`src.api`):容器默认以 `uvicorn src.api:app` 启动,暴露 `/api/v1/run`(触发流水线)、`/api/v1/run/batch`(批量打分)、`/api/v1/scores`、`/api/v1/scores/{id}`、`/api/v1/contracts`、`/api/v1/candles`、`/api/v1/admin/reset-data`(清空行情数据) 等端点。启动时自动 `storage.init_db()` 建表。API 与 CLI 共用同一套 `fetcher/storage/scorer` 逻辑。
|
||||||
|
|
||||||
**主力轮换规则**(`contracts.py`):每个品种在 `ROLLOVER_RULES` 中维护 `month -> (主力月, 年份偏移)` 表。FG 当前规则:1-3/12 月→05、4-7 月→09、8-11 月→01,其中 8-11 月与 12 月跨年(`year_offset=1`)。新增品种(如 RB、I)只需在该 dict 里加一条,无需改 main 流程。
|
**主力轮换规则**(`contracts.py`):每个品种在 `ROLLOVER_RULES` 中维护 `month -> (主力月, 年份偏移)` 表。FG 当前规则:1-3/12 月→05、4-7 月→09、8-11 月→01,其中 8-11 月与 12 月跨年(`year_offset=1`)。新增品种(如 RB、I)只需在该 dict 里加一条,无需改 main 流程。
|
||||||
|
|
||||||
**三层打分模型**(`scorer.py`):综合 = 短期(7日,0.4) + 中期(15日,0.35) + 长期(30日,0.25)。`score_daily()` 要求 DataFrame ≥31 行,`fetcher.fetch_contract` 默认拉一个合约的全历史(实际 100+ 行),按 `trade_date` 升序排列后供打分使用。打分结果通过 `dataclass ScoreResult` + `ScoreDetail` 流转,`storage.save_score` 把 detail 序列化为 `detail_json` 文本列。
|
**三层打分模型**(`scorer.py`):综合 = (短期(7日,0.4) + 中期(15日,0.35) + 长期(30日,0.25)) × 波动率惩罚系数。短期引入幅度因子(价格/OI变化率)和量能确认,按象限(增仓涨/跌、减仓涨/跌、持平)给基础分再叠加加成;中期资金意愿连续化(50 + (增仓涨-增仓跌)/15×50);长期加入30日价格趋势分与OI趋势分 4:6 合成;高波动品种综合分打 85-100 折。`score_daily()` 要求 DataFrame ≥31 行,`fetcher.fetch_contract` 默认拉一个合约的全历史(实际 100+ 行),按 `trade_date` 升序排列后供打分使用。打分结果通过 `dataclass ScoreResult` + `ScoreDetail` 流转,`storage.save_score` 把 detail 序列化为 `detail_json` 文本列。
|
||||||
|
|
||||||
**PostgreSQL 作为业务数据库**:docker-compose 编排 `postgres:18.3-alpine3.23`,`tushare` 与 `web` 服务均通过 `DATABASE_URL` 连接。`storage.py` 使用 `psycopg3` 驱动,`candles` 与 `scores` 表以 `ON CONFLICT (ts_code, trade_date) DO UPDATE` 实现幂等,可反复重跑同一天。`scores` 表主键为 `UUID DEFAULT uuidv7() PRIMARY KEY`(见 `models.py` 与 `storage.py`)。
|
**PostgreSQL 作为业务数据库**:docker-compose 编排 `postgres:18.3-alpine3.23`,`tushare` 与 `web` 服务均通过 `DATABASE_URL` 连接。`storage.py` 使用 `psycopg3` 驱动,`candles` 与 `scores` 表以 `ON CONFLICT (ts_code, trade_date) DO UPDATE` 实现幂等,可反复重跑同一天。`scores` 表主键为 `UUID DEFAULT uuidv7() PRIMARY KEY`(见 `models.py` 与 `storage.py`)。
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ docker-compose -f docker-compose.trade.yml exec postgres psql -U trade -d future
|
|||||||
- 统一 DB:业务数据与用户鉴权数据均存储在 PostgreSQL `futures` 数据库中,通过 `DATABASE_URL` 访问。`auth.db`(SQLite)已废弃,`users` 表现在由 `AuthStore` 直接管理在 PostgreSQL 中。
|
- 统一 DB:业务数据与用户鉴权数据均存储在 PostgreSQL `futures` 数据库中,通过 `DATABASE_URL` 访问。`auth.db`(SQLite)已废弃,`users` 表现在由 `AuthStore` 直接管理在 PostgreSQL 中。
|
||||||
- 鉴权已简化:登录接口返回固定 token,`middleware.RequireUser` 直接注入默认管理员上下文,所有请求放行。后端仍保留密码校验与角色检查(`RequireAdmin`)。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
|
- 鉴权已简化:登录接口返回固定 token,`middleware.RequireUser` 直接注入默认管理员上下文,所有请求放行。后端仍保留密码校验与角色检查(`RequireAdmin`)。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
|
||||||
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb`。
|
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb`。
|
||||||
|
- 侧边栏提供「同步数据」(批量打分)、「数据重置」(管理员清空行情数据)功能。
|
||||||
|
|
||||||
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行,则自动创建默认管理员 `admin` / `admin`,并标记强制首次登录后改密码。忘记管理员密码的恢复方式:停服 → 清理 PostgreSQL 中的 admin 记录 → 重启。
|
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行,则自动创建默认管理员 `admin` / `admin`,并标记强制首次登录后改密码。忘记管理员密码的恢复方式:停服 → 清理 PostgreSQL 中的 admin 记录 → 重启。
|
||||||
|
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -38,6 +38,9 @@ docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main
|
|||||||
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
|
curl -X POST http://localhost:4001/api/v1/run -H "Content-Type: application/json" \
|
||||||
-d '{"symbol":"FG"}'
|
-d '{"symbol":"FG"}'
|
||||||
|
|
||||||
|
# 批量触发所有固定品种今日打分
|
||||||
|
curl -X POST http://localhost:4001/api/v1/run/batch
|
||||||
|
|
||||||
# 查询最新打分
|
# 查询最新打分
|
||||||
curl "http://localhost:4001/api/v1/scores?limit=5"
|
curl "http://localhost:4001/api/v1/scores?limit=5"
|
||||||
|
|
||||||
@@ -46,6 +49,9 @@ curl "http://localhost:4001/api/v1/contracts"
|
|||||||
|
|
||||||
# 查询 K 线数据
|
# 查询 K 线数据
|
||||||
curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
|
curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
|
||||||
|
|
||||||
|
# 清空所有行情数据(谨慎操作)
|
||||||
|
curl -X POST http://localhost:4001/api/v1/admin/reset-data
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 跑其他合约或品种
|
### 5. 跑其他合约或品种
|
||||||
@@ -73,32 +79,39 @@ docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main -
|
|||||||
### 综合分数公式
|
### 综合分数公式
|
||||||
|
|
||||||
```
|
```
|
||||||
综合分数 = (短期动力 × 0.4) + (中期趋势 × 0.35) + (长期结构 × 0.25)
|
综合分数 = (短期动力 × 0.4 + 中期趋势 × 0.35 + 长期结构 × 0.25) × 波动率惩罚系数
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1. 短期动力(7 日窗口,权重 0.4)
|
### 1. 短期动力(7 日窗口,权重 0.4)
|
||||||
|
|
||||||
逐日打分后取均值:
|
逐日打分后取均值。每日评分 = (象限基础分 + 幅度加成) × 量能确认,产出 0-100 连续值。
|
||||||
|
|
||||||
| 持仓变化 | 价格方向 | 得分 |
|
**象限基础分**(持仓与价格方向):
|
||||||
|---------|---------|------|
|
|
||||||
| 增仓 | 上涨 | 100(多头主动进攻) |
|
| 象限 | 持仓变化 | 价格方向 | 基础分 |
|
||||||
| 增仓 | 下跌 | 0(空头主动进攻) |
|
|------|---------|---------|--------|
|
||||||
| 减仓 | 上涨 | 70(空头撤退) |
|
| accumulation(增仓上涨) | 增仓 | 上涨 | 75 |
|
||||||
| 减仓 | 下跌 | 30(多头撤退) |
|
| distribution(增仓下跌) | 增仓 | 下跌 | 25 |
|
||||||
| 持平(\|变化\|<1%) | 上涨 | 60 |
|
| covering(减仓上涨) | 减仓 | 上涨 | 65 |
|
||||||
| 持平(\|变化\|<1%) | 下跌 | 40 |
|
| liquidation(减仓下跌) | 减仓 | 下跌 | 20 |
|
||||||
|
| flat(持平) | \|变化\|<1% | 上涨 | 60 |
|
||||||
|
| flat(持平) | \|变化\|<1% | 下跌 | 40 |
|
||||||
|
|
||||||
|
**幅度加成**(根据 OI 变化率和涨跌幅放大有利方向得分):
|
||||||
|
- OI 变化率封顶 5%,价格涨跌幅封顶 3%
|
||||||
|
- 增仓上涨 / 减仓下跌(有利方向):加成 = (OI 幅度 + 价格幅度) / 2 × 20
|
||||||
|
- 持仓持平:加成 = 价格幅度 × 10
|
||||||
|
- 增仓下跌 / 减仓上涨(不利方向):无加成
|
||||||
|
|
||||||
|
**量能确认**:`量比 = 当日成交量 / 7 日均量`,系数范围 [0.9, 1.2],量比 1.5 以上封顶
|
||||||
|
|
||||||
### 2. 中期趋势(15 日窗口,权重 0.35)
|
### 2. 中期趋势(15 日窗口,权重 0.35)
|
||||||
|
|
||||||
```
|
```
|
||||||
价格信号 = (今收 - 15日前收) / 15日前收
|
价格收益率 = (今收 - 15日前收) / 15日前收
|
||||||
价格信号得分 = clamp(50 + 收益率×500, 0, 100)
|
价格信号分 = clamp(50 + 收益率 × 500, 0, 100)
|
||||||
|
|
||||||
资金意愿:
|
资金意愿 = 50 + (增仓上涨天数 - 增仓下跌天数) / 15 × 50 (连续值 0-100)
|
||||||
增仓上涨天数 > 增仓下跌天数 → 80
|
|
||||||
两者相当 → 50
|
|
||||||
增仓下跌天数 > 增仓上涨天数 → 20
|
|
||||||
|
|
||||||
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
|
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
|
||||||
```
|
```
|
||||||
@@ -106,15 +119,23 @@ docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main -
|
|||||||
### 3. 长期结构(30 日窗口,权重 0.25)
|
### 3. 长期结构(30 日窗口,权重 0.25)
|
||||||
|
|
||||||
```
|
```
|
||||||
持仓变化幅度 = (30日日均持仓 - 30日前持仓) / 30日前持仓
|
OI 趋势分 = clamp(50 + OI变化幅度 × 250, 0, 100) (权重 60%)
|
||||||
|
价格趋势分 = clamp(50 + 30日价格收益率 × 200, 0, 100) (权重 40%)
|
||||||
|
|
||||||
> 10% → 90(显著增仓)
|
模块得分 = OI 趋势分 × 0.6 + 价格趋势分 × 0.4
|
||||||
5%~10% → 70(温和增仓)
|
|
||||||
-5%~5% → 50(基本持平)
|
|
||||||
-10%~-5% → 30(温和减仓)
|
|
||||||
< -10% → 10(显著减仓)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 4. 波动率调整
|
||||||
|
|
||||||
|
基于近 30 日日收益率标准差和 ATR%(平均真实波幅/均价):
|
||||||
|
|
||||||
|
```
|
||||||
|
日波动率 ≤ 1.5% → 惩罚系数 = 1.0(无惩罚)
|
||||||
|
日波动率 > 1.5% → 惩罚系数 = max(0.85, 1.0 - (日波动率 - 1.5%) × 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
高波动品种的综合分会被适当打折,最低打 85 折。
|
||||||
|
|
||||||
### 信号解读
|
### 信号解读
|
||||||
|
|
||||||
| 综合分数 | 信号 |
|
| 综合分数 | 信号 |
|
||||||
@@ -214,7 +235,7 @@ A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大
|
|||||||
|
|
||||||
**Q: 如何定时自动跑?**
|
**Q: 如何定时自动跑?**
|
||||||
|
|
||||||
A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose -f docker-compose.trade.yml run --rm tushare ...`。也可直接调用 API: `curl -X POST http://localhost:4001/api/v1/run ...`。
|
A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose -f docker-compose.trade.yml run --rm tushare ...`。也可直接调用 API: `curl -X POST http://localhost:4001/api/v1/run ...` 或批量接口 `curl -X POST http://localhost:4001/api/v1/run/batch`。
|
||||||
|
|
||||||
## Web 报表(浏览端)
|
## Web 报表(浏览端)
|
||||||
|
|
||||||
@@ -234,8 +255,11 @@ docker-compose -f docker-compose.trade.yml logs -f web
|
|||||||
|
|
||||||
### 3. 页面说明
|
### 3. 页面说明
|
||||||
|
|
||||||
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分、中期(15d)价格收益与资金意愿、长期(30d)持仓变化。
|
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分(涨跌幅/OI变化%/量比/象限)、中期(15d)价格收益与资金意愿、长期(30d)OI/价格趋势分与波动率调整。
|
||||||
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
|
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
|
||||||
|
- **同步数据**(侧边栏):点击调用批量打分接口,对所有固定品种执行当日打分,完成后自动跳转并刷新打分列表。
|
||||||
|
- **手动打分** `/run`:选品种 + 日期,对单个合约执行数据拉取与打分。
|
||||||
|
- **数据重置**(侧边栏,仅管理员):输入确认文字后清空所有行情数据(candles + scores),用户表不受影响。
|
||||||
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
|
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
|
||||||
|
|
||||||
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
|
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
tushare>=1.4.0
|
tushare>=1.4.0
|
||||||
|
numpy>=2.0.0
|
||||||
pandas>=2.2.0
|
pandas>=2.2.0
|
||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
uvicorn[standard]>=0.34.0
|
uvicorn[standard]>=0.34.0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -16,6 +16,16 @@ class RunRequest(BaseModel):
|
|||||||
trade_date: Optional[str] = None
|
trade_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RunRangeRequest(BaseModel):
|
||||||
|
symbol: str = "FG"
|
||||||
|
start_date: str
|
||||||
|
end_date: str
|
||||||
|
|
||||||
|
|
||||||
|
class RunFullRequest(BaseModel):
|
||||||
|
ts_code: str
|
||||||
|
|
||||||
|
|
||||||
class RunResponse(BaseModel):
|
class RunResponse(BaseModel):
|
||||||
ts_code: str
|
ts_code: str
|
||||||
trade_date: str
|
trade_date: str
|
||||||
@@ -27,6 +37,9 @@ class RunResponse(BaseModel):
|
|||||||
long_term: float
|
long_term: float
|
||||||
composite: float
|
composite: float
|
||||||
signal: str
|
signal: str
|
||||||
|
vol_penalty: float = 1.0
|
||||||
|
composite_delta: Optional[float] = None
|
||||||
|
composite_delta_5d: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
@@ -41,8 +54,11 @@ def health():
|
|||||||
|
|
||||||
@app.post("/api/v1/run", response_model=RunResponse)
|
@app.post("/api/v1/run", response_model=RunResponse)
|
||||||
def run_pipeline(req: RunRequest):
|
def run_pipeline(req: RunRequest):
|
||||||
ts_code = req.ts_code or contracts.active_contract(req.symbol)
|
ref_date = datetime.strptime(req.trade_date, "%Y%m%d").date() if req.trade_date else None
|
||||||
if not req.ts_code:
|
ts_code = req.ts_code or contracts.active_contract(req.symbol, ref_date)
|
||||||
|
if req.ts_code:
|
||||||
|
ts_code = contracts.normalize_ts_code(ts_code)
|
||||||
|
else:
|
||||||
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
|
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
|
||||||
|
|
||||||
df = fetcher.fetch_contract(ts_code)
|
df = fetcher.fetch_contract(ts_code)
|
||||||
@@ -61,6 +77,9 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +107,74 @@ def run_batch():
|
|||||||
return {"results": results, "errors": errors}
|
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),
|
||||||
@@ -193,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": "已清空所有行情数据"}
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ ROLLOVER_RULES: dict[str, dict] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ts_code(ts_code: str) -> str:
|
||||||
|
"""把用户输入的短代码(如 FG2509)补全为带交易所后缀的完整代码(如 FG2509.ZCE)。"""
|
||||||
|
if "." in ts_code:
|
||||||
|
return ts_code
|
||||||
|
# 提取品种代码: 前两个字母
|
||||||
|
symbol = ts_code[:2]
|
||||||
|
if symbol not in ROLLOVER_RULES:
|
||||||
|
raise ValueError(f"未配置 {symbol} 的品种规则,无法自动补全交易所后缀")
|
||||||
|
exchange = ROLLOVER_RULES[symbol]["exchange"]
|
||||||
|
return f"{ts_code}.{exchange}"
|
||||||
|
|
||||||
|
|
||||||
def active_contract(symbol: str, today: Optional[date] = None) -> str:
|
def active_contract(symbol: str, today: Optional[date] = None) -> str:
|
||||||
"""按主力轮换规则,返回当日 ts_code(含交易所后缀)。"""
|
"""按主力轮换规则,返回当日 ts_code(含交易所后缀)。"""
|
||||||
if symbol not in ROLLOVER_RULES:
|
if symbol not in ROLLOVER_RULES:
|
||||||
|
|||||||
@@ -11,10 +11,19 @@ def _init_api():
|
|||||||
return ts.pro_api()
|
return ts.pro_api()
|
||||||
|
|
||||||
|
|
||||||
def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame:
|
def fetch_contract(
|
||||||
|
ts_code: str,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
"""拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。"""
|
"""拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。"""
|
||||||
pro = _init_api()
|
pro = _init_api()
|
||||||
df = pro.fut_daily(ts_code=ts_code)
|
kwargs: dict = {"ts_code": ts_code}
|
||||||
|
if start_date:
|
||||||
|
kwargs["start_date"] = start_date
|
||||||
|
if end_date:
|
||||||
|
kwargs["end_date"] = end_date
|
||||||
|
df = pro.fut_daily(**kwargs)
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足")
|
raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足")
|
||||||
@@ -25,6 +34,10 @@ def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame:
|
|||||||
]
|
]
|
||||||
df = df[[c for c in cols if c in df.columns]].copy()
|
df = df[[c for c in cols if c in df.columns]].copy()
|
||||||
|
|
||||||
|
# 确保 ts_code 列与查询时传入的完整代码一致(Tushare 返回的可能不带交易所后缀)
|
||||||
|
if "ts_code" in df.columns:
|
||||||
|
df["ts_code"] = ts_code
|
||||||
|
|
||||||
numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"]
|
numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"]
|
||||||
for c in numeric:
|
for c in numeric:
|
||||||
if c in df.columns:
|
if c in df.columns:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from . import contracts, fetcher, scorer, storage
|
from . import contracts, fetcher, scorer, storage
|
||||||
|
|
||||||
@@ -37,15 +38,19 @@ def run(ts_code: str, trade_date: Optional[str] = None) -> int:
|
|||||||
print(f"\n信号: {result.signal}")
|
print(f"\n信号: {result.signal}")
|
||||||
print("=" * 65)
|
print("=" * 65)
|
||||||
|
|
||||||
|
quadrant_names = {
|
||||||
|
"accumulation": "增仓上涨", "distribution": "增仓下跌",
|
||||||
|
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
|
||||||
|
}
|
||||||
print("\n[短期动力] 近7日逐日打分:")
|
print("\n[短期动力] 近7日逐日打分:")
|
||||||
print("-" * 65)
|
print("-" * 80)
|
||||||
for d in result.detail.short_details:
|
for d in result.detail.short_details:
|
||||||
tag = "增仓" if d["oi_chg"] > 0 else "减仓"
|
q = quadrant_names.get(d["quadrant"], d["quadrant"])
|
||||||
if abs(d["oi_chg"] / d["oi"]) < 0.01:
|
print(f" {d['trade_date']} {q} "
|
||||||
tag = "持平"
|
f"涨跌{d['price_chg_pct']*100:>+.2f}% "
|
||||||
price_dir = "涨" if d["close"] >= d["pre_close"] else "跌"
|
f"OI变化{d['oi_chg_pct']*100:>+.2f}% "
|
||||||
print(f" {d['trade_date']} {tag:>4} + {price_dir} "
|
f"量比{d['vol_ratio']:.2f} "
|
||||||
f"持仓{d['oi_chg']:>+8,.0f} 得分: {d['score']:>3}")
|
f"得分: {d['score']:>5.1f}")
|
||||||
|
|
||||||
md = result.detail.medium_detail
|
md = result.detail.medium_detail
|
||||||
print(f"\n[中期趋势] 明细:")
|
print(f"\n[中期趋势] 明细:")
|
||||||
@@ -53,19 +58,93 @@ def run(ts_code: str, trade_date: Optional[str] = None) -> int:
|
|||||||
print(f" 价格信号得分: {md['price_signal']:.1f}")
|
print(f" 价格信号得分: {md['price_signal']:.1f}")
|
||||||
print(f" 增仓上涨天数: {md['long_up_days']} 天")
|
print(f" 增仓上涨天数: {md['long_up_days']} 天")
|
||||||
print(f" 增仓下跌天数: {md['long_down_days']} 天")
|
print(f" 增仓下跌天数: {md['long_down_days']} 天")
|
||||||
print(f" 资金意愿得分: {md['fund_signal']} 分")
|
print(f" 资金意愿得分: {md['fund_signal']:.1f} 分")
|
||||||
|
|
||||||
ld = result.detail.long_detail
|
ld = result.detail.long_detail
|
||||||
print(f"\n[长期结构] 明细:")
|
print(f"\n[长期结构] 明细:")
|
||||||
|
print(f" OI趋势得分: {ld['oi_score']:.1f} (权重 60%)")
|
||||||
|
print(f" 价格趋势得分: {ld['price_score']:.1f} (权重 40%)")
|
||||||
|
print(f" 30日价格收益: {ld['price_return_30d_pct']:+.2f}%")
|
||||||
|
print(f" 30日前收盘价: {ld['price_before_30d']:.2f}")
|
||||||
print(f" 近30日日均持仓: {ld['avg_oi']:,.0f}")
|
print(f" 近30日日均持仓: {ld['avg_oi']:,.0f}")
|
||||||
print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
|
print(f" 30日前持仓量: {ld['oi_before']:,.0f}")
|
||||||
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
|
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
|
||||||
|
|
||||||
|
vd = result.detail.volatility
|
||||||
|
print(f"\n[波动率调整]")
|
||||||
|
print(f" 日波动率(30d std): {vd['daily_vol_pct']*100:.2f}%")
|
||||||
|
print(f" ATR%: {vd['atr_pct']*100:.2f}%")
|
||||||
|
vol_risk = vd.get("vol_risk", vd["daily_vol_pct"])
|
||||||
|
print(f" 综合波动风险: {vol_risk*100:.2f}%")
|
||||||
|
print(f" 惩罚系数: {vd['vol_penalty']:.3f}")
|
||||||
|
|
||||||
|
aw = result.detail.adaptive_weights
|
||||||
|
if aw:
|
||||||
|
print(f"\n[自适应权重]")
|
||||||
|
print(f" 趋势强度: {aw['trend_strength']:.2f}")
|
||||||
|
print(f" 短期权重: {aw['w_short']:.2%} (基准 40%)")
|
||||||
|
print(f" 中期权重: {aw['w_medium']:.2%} (基准 35%)")
|
||||||
|
print(f" 长期权重: {aw['w_long']:.2%} (基准 25%)")
|
||||||
|
|
||||||
|
if result.composite_delta is not None:
|
||||||
|
print(f"\n[分数动量]")
|
||||||
|
print(f" 日变化 (Δ1d): {result.composite_delta:+.1f}")
|
||||||
|
if result.composite_delta_5d is not None:
|
||||||
|
print(f" 周变化 (Δ5d): {result.composite_delta_5d:+.1f}")
|
||||||
|
|
||||||
print(f"\n[OK] 数据已持久化到 PostgreSQL")
|
print(f"\n[OK] 数据已持久化到 PostgreSQL")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_range(ts_code: str, start_date: str, end_date: str) -> int:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
storage.init_db()
|
||||||
|
|
||||||
|
print(f"[1/4] 拉取 {ts_code} 数据 ({start_date} ~ {end_date})...")
|
||||||
|
start_dt = datetime.strptime(start_date, "%Y%m%d")
|
||||||
|
fetch_start = (start_dt - timedelta(days=60)).strftime("%Y%m%d")
|
||||||
|
df = fetcher.fetch_contract(ts_code, start_date=fetch_start, end_date=end_date)
|
||||||
|
print(f" 返回 {len(df)} 行")
|
||||||
|
|
||||||
|
print(f"[2/4] 写入/更新 PostgreSQL...")
|
||||||
|
storage.save_candles(df)
|
||||||
|
|
||||||
|
print(f"[3/4] 批量计算打分...")
|
||||||
|
results, warnings = scorer.score_range(df, start_date, end_date)
|
||||||
|
|
||||||
|
print(f"[4/4] 保存打分结果...")
|
||||||
|
for r in results:
|
||||||
|
storage.save_score(r)
|
||||||
|
|
||||||
|
print("\n" + "=" * 65)
|
||||||
|
print(f"合约: {ts_code}")
|
||||||
|
print(f"区间: {start_date} ~ {end_date}")
|
||||||
|
print(f"成功打分: {len(results)} 条")
|
||||||
|
if warnings:
|
||||||
|
print(f"跳过: {len(warnings)} 条")
|
||||||
|
for w in warnings[:5]:
|
||||||
|
print(f" - {w}")
|
||||||
|
if len(warnings) > 5:
|
||||||
|
print(f" ... 还有 {len(warnings) - 5} 条")
|
||||||
|
print("=" * 65)
|
||||||
|
|
||||||
|
quadrant_names = {
|
||||||
|
"accumulation": "增仓上涨", "distribution": "增仓下跌",
|
||||||
|
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
|
||||||
|
}
|
||||||
|
print(f"\n{'日期':<12} {'收盘':>10} {'综合':>8} {'Δ1d':>6} {'Δ5d':>6} {'信号':<20}")
|
||||||
|
print("-" * 70)
|
||||||
|
for r in results:
|
||||||
|
d1 = f"{r.composite_delta:+.1f}" if r.composite_delta is not None else " -"
|
||||||
|
d5 = f"{r.composite_delta_5d:+.1f}" if r.composite_delta_5d is not None else " -"
|
||||||
|
print(f"{r.trade_date:<12} {r.close:>10.2f} {r.composite:>8.1f} {d1:>6} {d5:>6} {r.signal:<20}")
|
||||||
|
|
||||||
|
print(f"\n[OK] {len(results)} 条打分已持久化到 PostgreSQL")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="期货合约三层打分模型")
|
parser = argparse.ArgumentParser(description="期货合约三层打分模型")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -82,11 +161,26 @@ def main() -> int:
|
|||||||
"--date",
|
"--date",
|
||||||
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
|
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--start-date",
|
||||||
|
dest="start_date",
|
||||||
|
help="区间打分开始日期,格式 YYYYMMDD(与 --end-date 同时使用时生效)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--end-date",
|
||||||
|
dest="end_date",
|
||||||
|
help="区间打分结束日期,格式 YYYYMMDD(与 --start-date 同时使用时生效)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ts_code = args.ts_code or contracts.active_contract(args.symbol)
|
ts_code = args.ts_code or contracts.active_contract(args.symbol)
|
||||||
if not args.ts_code:
|
if not args.ts_code:
|
||||||
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
|
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
|
||||||
|
|
||||||
|
if args.start_date and args.end_date:
|
||||||
|
print(f"[RANGE] 区间打分: {args.start_date} ~ {args.end_date}")
|
||||||
|
return run_range(ts_code, args.start_date, args.end_date)
|
||||||
|
|
||||||
if args.date:
|
if args.date:
|
||||||
print(f"[DATE] 指定打分日期: {args.date}")
|
print(f"[DATE] 指定打分日期: {args.date}")
|
||||||
return run(ts_code, args.date)
|
return run(ts_code, args.date)
|
||||||
|
|||||||
@@ -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日前综合分差值
|
||||||
|
|||||||
@@ -1,50 +1,110 @@
|
|||||||
|
import math
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from .models import ScoreDetail, ScoreResult
|
from .models import ScoreDetail, ScoreResult
|
||||||
|
|
||||||
|
|
||||||
def _daily_short_score(row: pd.Series) -> int:
|
# ---------------------------------------------------------------------------
|
||||||
"""单日短期动力打分。"""
|
# 短期动力 — 单日打分
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _daily_short_score(row: pd.Series, avg_vol_7d: float) -> dict:
|
||||||
|
"""单日短期动力打分(连续值 + 方向性幅度加成 + 量能确认)。"""
|
||||||
oi = float(row["oi"])
|
oi = float(row["oi"])
|
||||||
oi_chg = float(row["oi_chg"])
|
oi_chg = float(row["oi_chg"])
|
||||||
close = float(row["close"])
|
close = float(row["close"])
|
||||||
pre_close = float(row["pre_close"])
|
pre_close = float(row["pre_close"])
|
||||||
|
vol = float(row.get("vol", 0))
|
||||||
|
|
||||||
oi_change_pct = abs(oi_chg / oi) if oi != 0 else 0
|
oi_chg_pct = oi_chg / oi if oi != 0 else 0.0
|
||||||
|
price_chg_pct = (close - pre_close) / pre_close if pre_close != 0 else 0.0
|
||||||
price_up = close >= pre_close
|
price_up = close >= pre_close
|
||||||
|
|
||||||
if oi_change_pct < 0.01:
|
|
||||||
return 60 if price_up else 40
|
|
||||||
|
|
||||||
oi_increasing = oi_chg > 0
|
oi_increasing = oi_chg > 0
|
||||||
if oi_increasing and price_up:
|
|
||||||
return 100
|
|
||||||
if oi_increasing and not price_up:
|
|
||||||
return 0
|
|
||||||
if not oi_increasing and price_up:
|
|
||||||
return 70
|
|
||||||
return 30
|
|
||||||
|
|
||||||
|
# ── 象限基础分 ──
|
||||||
|
if abs(oi_chg_pct) < 0.01:
|
||||||
|
base = 60.0 if price_up else 40.0
|
||||||
|
quadrant = "flat"
|
||||||
|
elif oi_increasing and price_up:
|
||||||
|
base = 75.0
|
||||||
|
quadrant = "accumulation" # 增仓上涨
|
||||||
|
elif oi_increasing and not price_up:
|
||||||
|
base = 25.0
|
||||||
|
quadrant = "distribution" # 增仓下跌
|
||||||
|
elif not oi_increasing and price_up:
|
||||||
|
base = 65.0
|
||||||
|
quadrant = "covering" # 减仓上涨
|
||||||
|
else:
|
||||||
|
base = 20.0
|
||||||
|
quadrant = "liquidation" # 减仓下跌
|
||||||
|
|
||||||
|
# ── 幅度加成(方向性)──
|
||||||
|
# OI 变化率封顶 5%,价格涨跌幅封顶 3%
|
||||||
|
oi_mag = min(1.0, abs(oi_chg_pct) / 0.05)
|
||||||
|
price_mag = min(1.0, abs(price_chg_pct) / 0.03)
|
||||||
|
|
||||||
|
if quadrant == "accumulation":
|
||||||
|
boost = (oi_mag + price_mag) / 2.0 * 20.0 # 看多,加成推高分数
|
||||||
|
elif quadrant == "liquidation":
|
||||||
|
boost = -(oi_mag + price_mag) / 2.0 * 20.0 # 看空,扣分强化信号
|
||||||
|
elif quadrant == "flat":
|
||||||
|
boost = price_mag * 10.0 if price_up else -(price_mag * 10.0)
|
||||||
|
elif quadrant == "distribution":
|
||||||
|
boost = -price_mag * 10.0 # 增仓下跌:价格幅度扣分
|
||||||
|
else: # covering
|
||||||
|
boost = price_mag * 10.0 # 减仓上涨:价格幅度加分
|
||||||
|
|
||||||
|
# ── 量能确认(ratio=1.0 时为中性因子 1.0)──
|
||||||
|
vol_ratio = vol / avg_vol_7d if avg_vol_7d > 0 else 1.0
|
||||||
|
vol_factor = 0.8 + 0.2 * min(vol_ratio, 2.0) # 范围 [0.8, 1.2],中性 = 1.0
|
||||||
|
|
||||||
|
score = max(0.0, min(100.0, (base + boost) * vol_factor))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trade_date": str(row["trade_date"]),
|
||||||
|
"close": close,
|
||||||
|
"pre_close": pre_close,
|
||||||
|
"oi": oi,
|
||||||
|
"oi_chg": oi_chg,
|
||||||
|
"oi_chg_pct": round(float(oi_chg_pct), 4),
|
||||||
|
"price_chg_pct": round(float(price_chg_pct), 4),
|
||||||
|
"vol": float(vol),
|
||||||
|
"vol_ratio": round(float(vol_ratio), 2),
|
||||||
|
"quadrant": quadrant,
|
||||||
|
"score": round(float(score), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 短期动力 — 7 日窗口,指数加权平均
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]:
|
def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]:
|
||||||
|
"""近 window 日逐日打分,指数加权平均(越近权重越高)。"""
|
||||||
recent = df.iloc[-window:].copy()
|
recent = df.iloc[-window:].copy()
|
||||||
|
avg_vol_7d = float(recent["vol"].mean()) if "vol" in recent.columns else 0.0
|
||||||
|
|
||||||
scores = []
|
scores = []
|
||||||
details = []
|
details = []
|
||||||
for _, row in recent.iterrows():
|
for _, row in recent.iterrows():
|
||||||
score = _daily_short_score(row)
|
detail = _daily_short_score(row, avg_vol_7d)
|
||||||
scores.append(score)
|
scores.append(detail["score"])
|
||||||
details.append({
|
details.append(detail)
|
||||||
"trade_date": str(row["trade_date"]),
|
|
||||||
"close": float(row["close"]),
|
|
||||||
"pre_close": float(row["pre_close"]),
|
|
||||||
"oi": float(row["oi"]),
|
|
||||||
"oi_chg": float(row["oi_chg"]),
|
|
||||||
"score": score,
|
|
||||||
})
|
|
||||||
return sum(scores) / len(scores), details
|
|
||||||
|
|
||||||
|
# 指数加权:权重从 exp(0)=1 递增到 exp(1)≈2.718
|
||||||
|
weights = np.exp(np.linspace(0, 1, window))
|
||||||
|
weights = weights / weights.sum()
|
||||||
|
weighted_avg = float(np.average(scores, weights=weights))
|
||||||
|
|
||||||
|
return weighted_avg, details
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 中期趋势 — 15 日窗口,四象限资金意愿
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
|
def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
|
||||||
if len(df) < window + 1:
|
if len(df) < window + 1:
|
||||||
@@ -54,60 +114,93 @@ def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]:
|
|||||||
close_now = float(df.iloc[-1]["close"])
|
close_now = float(df.iloc[-1]["close"])
|
||||||
close_before = float(df.iloc[-window - 1]["close"])
|
close_before = float(df.iloc[-window - 1]["close"])
|
||||||
|
|
||||||
|
# ── 价格信号 ──
|
||||||
price_return = (close_now - close_before) / close_before if close_before != 0 else 0
|
price_return = (close_now - close_before) / close_before if close_before != 0 else 0
|
||||||
price_score = max(0.0, min(100.0, 50.0 + price_return * 500))
|
price_score = max(0.0, min(100.0, 50.0 + price_return * 500))
|
||||||
|
|
||||||
long_up = 0
|
# ── 资金意愿(四象限全计入)──
|
||||||
long_down = 0
|
accumulation = 0 # 增仓上涨
|
||||||
for _, row in recent.iterrows():
|
distribution = 0 # 增仓下跌
|
||||||
if row["oi_chg"] > 0:
|
covering = 0 # 减仓上涨
|
||||||
if row["close"] >= row["pre_close"]:
|
liquidation = 0 # 减仓下跌
|
||||||
long_up += 1
|
|
||||||
else:
|
for _, row in recent.iterrows():
|
||||||
long_down += 1
|
oi_inc = row["oi_chg"] > 0
|
||||||
|
price_up = row["close"] >= row["pre_close"]
|
||||||
|
if oi_inc and price_up:
|
||||||
|
accumulation += 1
|
||||||
|
elif oi_inc and not price_up:
|
||||||
|
distribution += 1
|
||||||
|
elif not oi_inc and price_up:
|
||||||
|
covering += 1
|
||||||
|
else:
|
||||||
|
liquidation += 1
|
||||||
|
|
||||||
|
# 增仓驱动权重 1.0,减仓驱动权重 0.5(持续性较差)
|
||||||
|
fund_score = 50.0 + (
|
||||||
|
accumulation * 1.0 - distribution * 1.0
|
||||||
|
+ covering * 0.5 - liquidation * 0.5
|
||||||
|
) / window * 50.0
|
||||||
|
fund_score = max(0.0, min(100.0, fund_score))
|
||||||
|
|
||||||
fund_score = 80 if long_up > long_down else (20 if long_up < long_down else 50)
|
|
||||||
score = price_score * 0.6 + fund_score * 0.4
|
score = price_score * 0.6 + fund_score * 0.4
|
||||||
|
|
||||||
detail = {
|
detail = {
|
||||||
"price_return_pct": round(price_return * 100, 2),
|
"price_return_pct": round(price_return * 100, 2),
|
||||||
"price_signal": round(price_score, 1),
|
"price_signal": round(price_score, 1),
|
||||||
"long_up_days": long_up,
|
"accumulation_days": accumulation,
|
||||||
"long_down_days": long_down,
|
"distribution_days": distribution,
|
||||||
"fund_signal": fund_score,
|
"covering_days": covering,
|
||||||
|
"liquidation_days": liquidation,
|
||||||
|
"fund_signal": round(fund_score, 1),
|
||||||
|
"window": window,
|
||||||
}
|
}
|
||||||
return score, detail
|
return score, detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 长期结构 — 30 日窗口,端点 OI 比较
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
|
def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
|
||||||
if len(df) < window + 1:
|
if len(df) < window + 1:
|
||||||
raise ValueError(f"数据不足,需要至少 {window + 1} 行")
|
raise ValueError(f"数据不足,需要至少 {window + 1} 行")
|
||||||
|
|
||||||
recent_oi = df.iloc[-window:]["oi"]
|
# ── OI 趋势分(端点比较,消除均值滞后)──
|
||||||
avg_oi = recent_oi.mean()
|
oi_now = float(df.iloc[-1]["oi"])
|
||||||
oi_before = float(df.iloc[-window - 1]["oi"])
|
oi_before = float(df.iloc[-window - 1]["oi"])
|
||||||
|
oi_change_pct = (oi_now - oi_before) / oi_before if oi_before != 0 else 0.0
|
||||||
|
oi_score = max(0.0, min(100.0, 50.0 + oi_change_pct * 250))
|
||||||
|
|
||||||
change_pct = (avg_oi - oi_before) / oi_before if oi_before != 0 else 0
|
# ── 价格趋势分 ──
|
||||||
|
close_now = float(df.iloc[-1]["close"])
|
||||||
|
price_before = float(df.iloc[-window - 1]["close"])
|
||||||
|
price_return_30d = (close_now - price_before) / price_before if price_before != 0 else 0.0
|
||||||
|
price_score = max(0.0, min(100.0, 50.0 + price_return_30d * 200))
|
||||||
|
|
||||||
if change_pct > 0.10:
|
score = oi_score * 0.6 + price_score * 0.4
|
||||||
score = 90
|
|
||||||
elif change_pct > 0.05:
|
# 额外统计近 30 日 OI 均值供参考
|
||||||
score = 70
|
recent_oi = df.iloc[-window:]["oi"]
|
||||||
elif change_pct > -0.05:
|
|
||||||
score = 50
|
|
||||||
elif change_pct > -0.10:
|
|
||||||
score = 30
|
|
||||||
else:
|
|
||||||
score = 10
|
|
||||||
|
|
||||||
detail = {
|
detail = {
|
||||||
"avg_oi": round(float(avg_oi), 0),
|
"oi_now": round(oi_now, 0),
|
||||||
"oi_before": round(oi_before, 0),
|
"oi_before": round(oi_before, 0),
|
||||||
"change_pct": round(change_pct * 100, 2),
|
"oi_change_pct": round(oi_change_pct * 100, 2),
|
||||||
|
"oi_score": round(oi_score, 1),
|
||||||
|
"price_score": round(price_score, 1),
|
||||||
|
"price_return_30d_pct": round(price_return_30d * 100, 2),
|
||||||
|
"price_before_30d": round(price_before, 2),
|
||||||
|
"avg_oi_30d": round(float(recent_oi.mean()), 0),
|
||||||
|
"window": window,
|
||||||
}
|
}
|
||||||
return score, detail
|
return score, detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 信号解读
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _interpret(composite: float) -> str:
|
def _interpret(composite: float) -> str:
|
||||||
if composite >= 80:
|
if composite >= 80:
|
||||||
return "强烈看多区域 — 价格与资金共振,趋势多头的温床"
|
return "强烈看多区域 — 价格与资金共振,趋势多头的温床"
|
||||||
@@ -118,6 +211,10 @@ def _interpret(composite: float) -> str:
|
|||||||
return "强烈看空区域 — 资金主动且持续地打压价格"
|
return "强烈看空区域 — 资金主动且持续地打压价格"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 主打分函数
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
|
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
|
||||||
"""对 DataFrame 中指定日期或最新一条记录打分。"""
|
"""对 DataFrame 中指定日期或最新一条记录打分。"""
|
||||||
if len(df) < 31:
|
if len(df) < 31:
|
||||||
@@ -135,11 +232,59 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
|
|||||||
|
|
||||||
latest = df.iloc[-1]
|
latest = df.iloc[-1]
|
||||||
|
|
||||||
|
# ── 三层原始分 ──
|
||||||
short, short_details = calc_short_term(df, 7)
|
short, short_details = calc_short_term(df, 7)
|
||||||
medium, medium_detail = calc_medium_term(df, 15)
|
medium, medium_detail = calc_medium_term(df, 15)
|
||||||
long_, long_detail = calc_long_term(df, 30)
|
long_, long_detail = calc_long_term(df, 30)
|
||||||
|
|
||||||
composite = short * 0.4 + medium * 0.35 + long_ * 0.25
|
# ── 波动率(日收益率标准差 + ATR%)──
|
||||||
|
recent_30 = df.iloc[-30:].copy()
|
||||||
|
recent_30["ret"] = recent_30["close"].pct_change()
|
||||||
|
daily_vol = float(recent_30["ret"].std())
|
||||||
|
|
||||||
|
recent_30["tr"] = recent_30.apply(
|
||||||
|
lambda r: max(
|
||||||
|
r["high"] - r["low"],
|
||||||
|
abs(r["high"] - r["pre_close"]),
|
||||||
|
abs(r["low"] - r["pre_close"]),
|
||||||
|
),
|
||||||
|
axis=1,
|
||||||
|
)
|
||||||
|
atr = float(recent_30["tr"].mean())
|
||||||
|
avg_close_30 = float(recent_30["close"].mean())
|
||||||
|
atr_pct = (atr / avg_close_30) if avg_close_30 else 0.0
|
||||||
|
|
||||||
|
# 综合波动率风险度量:日收益标准差 70% + ATR% 30%
|
||||||
|
vol_risk = daily_vol * 0.7 + atr_pct * 0.3
|
||||||
|
vol_ref = 0.015
|
||||||
|
if vol_risk <= vol_ref:
|
||||||
|
vol_penalty = 1.0
|
||||||
|
else:
|
||||||
|
vol_penalty = max(0.85, 1.0 - (vol_risk - vol_ref) * 10)
|
||||||
|
|
||||||
|
# ── 自适应权重(趋势越强,长期权重越高)──
|
||||||
|
price_return_30d = (
|
||||||
|
(float(df.iloc[-1]["close"]) - float(df.iloc[-31]["close"]))
|
||||||
|
/ float(df.iloc[-31]["close"])
|
||||||
|
if df.iloc[-31]["close"] != 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
# 趋势效率 = |30日收益率| / 日波动率,比率高 = 方向明确
|
||||||
|
trend_strength = abs(price_return_30d) / max(daily_vol, 0.005)
|
||||||
|
trend_factor = min(trend_strength / 3.0, 1.0) # 归一化到 [0, 1]
|
||||||
|
|
||||||
|
w_short_base = 0.40
|
||||||
|
w_medium_base = 0.35
|
||||||
|
w_long_base = 0.25
|
||||||
|
|
||||||
|
shift = trend_factor * 0.10 # 最多转移 10% 权重
|
||||||
|
w_short = w_short_base - shift
|
||||||
|
w_medium = w_medium_base
|
||||||
|
w_long = w_long_base + shift
|
||||||
|
|
||||||
|
# ── 综合分数 ──
|
||||||
|
composite_raw = short * w_short + medium * w_medium + long_ * w_long
|
||||||
|
composite = round(composite_raw * vol_penalty, 1)
|
||||||
signal = _interpret(composite)
|
signal = _interpret(composite)
|
||||||
|
|
||||||
return ScoreResult(
|
return ScoreResult(
|
||||||
@@ -153,9 +298,94 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
|
|||||||
long_term=round(long_, 1),
|
long_term=round(long_, 1),
|
||||||
composite=round(composite, 1),
|
composite=round(composite, 1),
|
||||||
signal=signal,
|
signal=signal,
|
||||||
|
vol_penalty=round(float(vol_penalty), 4),
|
||||||
detail=ScoreDetail(
|
detail=ScoreDetail(
|
||||||
short_details=short_details,
|
short_details=short_details,
|
||||||
medium_detail=medium_detail,
|
medium_detail=medium_detail,
|
||||||
long_detail=long_detail,
|
long_detail=long_detail,
|
||||||
|
volatility={
|
||||||
|
"daily_vol_pct": round(float(daily_vol), 4),
|
||||||
|
"atr_pct": round(float(atr_pct), 4),
|
||||||
|
"vol_risk": round(float(vol_risk), 4),
|
||||||
|
"vol_penalty": round(float(vol_penalty), 4),
|
||||||
|
},
|
||||||
|
adaptive_weights={
|
||||||
|
"trend_strength": round(float(trend_strength), 2),
|
||||||
|
"trend_factor": round(float(trend_factor), 2),
|
||||||
|
"w_short": round(float(w_short), 4),
|
||||||
|
"w_medium": round(float(w_medium), 4),
|
||||||
|
"w_long": round(float(w_long), 4),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 区间 / 全量打分
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def score_range(
|
||||||
|
df: pd.DataFrame, start_date: str, end_date: str
|
||||||
|
) -> tuple[list[ScoreResult], list[str]]:
|
||||||
|
"""对日期区间内的每一天分别打分,返回 (结果列表, 警告列表)。"""
|
||||||
|
if len(df) < 31:
|
||||||
|
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
|
||||||
|
|
||||||
|
results: list[ScoreResult] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
target_dates = df[
|
||||||
|
(df["trade_date"].astype(str) >= str(start_date))
|
||||||
|
& (df["trade_date"].astype(str) <= str(end_date))
|
||||||
|
]["trade_date"].astype(str).tolist()
|
||||||
|
|
||||||
|
for td in target_dates:
|
||||||
|
try:
|
||||||
|
result = score_daily(df, td)
|
||||||
|
results.append(result)
|
||||||
|
except ValueError as e:
|
||||||
|
warnings.append(str(e))
|
||||||
|
|
||||||
|
_fill_deltas(results)
|
||||||
|
return results, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
|
||||||
|
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(results, warnings, total_days, scored_count)
|
||||||
|
"""
|
||||||
|
if len(df) < 31:
|
||||||
|
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
|
||||||
|
|
||||||
|
results: list[ScoreResult] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
for idx in range(30, len(df)):
|
||||||
|
subset = df.iloc[: idx + 1].copy()
|
||||||
|
trade_date = str(df.iloc[idx]["trade_date"])
|
||||||
|
try:
|
||||||
|
result = score_daily(subset)
|
||||||
|
results.append(result)
|
||||||
|
except ValueError as e:
|
||||||
|
warnings.append(f"{trade_date}: {e}")
|
||||||
|
|
||||||
|
_fill_deltas(results)
|
||||||
|
|
||||||
|
total_days = len(df) - 30
|
||||||
|
scored_count = len(results)
|
||||||
|
return results, warnings, total_days, scored_count
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 辅助:填充分数动量
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fill_deltas(results: list[ScoreResult]):
|
||||||
|
"""为结果列表中的每个 ScoreResult 填充 composite_delta 和 composite_delta_5d。"""
|
||||||
|
for i, r in enumerate(results):
|
||||||
|
if i >= 1 and results[i - 1].composite is not None:
|
||||||
|
r.composite_delta = round(r.composite - results[i - 1].composite, 1)
|
||||||
|
if i >= 5 and results[i - 5].composite is not None:
|
||||||
|
r.composite_delta_5d = round(r.composite - results[i - 5].composite, 1)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ WORKDIR /ui
|
|||||||
|
|
||||||
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
|
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
RUN npm config set registry https://registry.npmmirror.com && \
|
||||||
|
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
COPY frontend ./
|
COPY frontend ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -21,7 +22,7 @@ WORKDIR /src
|
|||||||
COPY backend ./
|
COPY backend ./
|
||||||
COPY --from=ui /ui/dist ./dist
|
COPY --from=ui /ui/dist ./dist
|
||||||
|
|
||||||
ENV CGO_ENABLED=0 GOOS=linux
|
ENV CGO_ENABLED=0 GOOS=linux GOPROXY=https://goproxy.cn,direct
|
||||||
|
|
||||||
RUN go mod tidy && \
|
RUN go mod tidy && \
|
||||||
go build -trimpath -ldflags="-s -w" -o /out/web ./
|
go build -trimpath -ldflags="-s -w" -o /out/web ./
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import (
|
|||||||
"trade/web/internal/store"
|
"trade/web/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BootstrapLLMConfig 初始化 llm_config 表。
|
||||||
|
func BootstrapLLMConfig(s *store.FuturesStore) error {
|
||||||
|
return s.EnsureLLMConfigTable()
|
||||||
|
}
|
||||||
|
|
||||||
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
|
// Bootstrap 在 auth.db 没有任何 admin 时,写入默认管理员 admin/admin;
|
||||||
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
|
// 并强制首次登录后改密码。已存在 admin 时静默跳过。
|
||||||
func Bootstrap(s *store.AuthStore) error {
|
func Bootstrap(s *store.AuthStore) error {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type Config struct {
|
|||||||
ListenAddr string
|
ListenAddr string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
TushareAPIURL string
|
TushareAPIURL string
|
||||||
|
LLMBaseURL string
|
||||||
|
LLMAPIKey string
|
||||||
|
LLMModel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -16,6 +19,9 @@ func Load() (*Config, error) {
|
|||||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
|
TushareAPIURL: getenv("TUSHARE_API_URL", "http://tushare:8000"),
|
||||||
|
LLMBaseURL: getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
|
||||||
|
LLMAPIKey: os.Getenv("LLM_API_KEY"),
|
||||||
|
LLMModel: getenv("LLM_MODEL", "deepseek-chat"),
|
||||||
}
|
}
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
|
||||||
|
|||||||
307
web/backend/internal/handlers/ai.go
Normal file
307
web/backend/internal/handlers/ai.go
Normal 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
|
||||||
|
}
|
||||||
409
web/backend/internal/handlers/daily_direction.go
Normal file
409
web/backend/internal/handlers/daily_direction.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type Deps struct {
|
|||||||
Auth *store.AuthStore
|
Auth *store.AuthStore
|
||||||
Futures *store.FuturesStore
|
Futures *store.FuturesStore
|
||||||
TushareURL string
|
TushareURL string
|
||||||
|
AIConfig *AIConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
|||||||
86
web/backend/internal/handlers/llm_config.go
Normal file
86
web/backend/internal/handlers/llm_config.go
Normal 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"})
|
||||||
|
}
|
||||||
22
web/backend/internal/handlers/reset.go
Normal file
22
web/backend/internal/handlers/reset.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -42,6 +42,86 @@ func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = io.Copy(w, resp.Body)
|
_, _ = io.Copy(w, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Deps) RunBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
client := &http.Client{Timeout: 180 * time.Second}
|
||||||
|
resp, err := client.Post(d.TushareURL+"/api/v1/run/batch", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type runRangeRequest struct {
|
||||||
|
Symbol string `json:"symbol,omitempty"`
|
||||||
|
StartDate string `json:"start_date,omitempty"`
|
||||||
|
EndDate string `json:"end_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req runRangeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "encode request failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 180 * time.Second}
|
||||||
|
resp, err := client.Post(d.TushareURL+"/api/v1/run/range", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type runFullRequest struct {
|
||||||
|
TsCode string `json:"ts_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req runFullRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid json")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TsCode == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "ts_code is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "encode request failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 300 * time.Second}
|
||||||
|
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
|
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
|
||||||
symbol := r.URL.Query().Get("symbol")
|
symbol := r.URL.Query().Get("symbol")
|
||||||
if symbol == "" {
|
if symbol == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
|
|||||||
r.Get("/contracts/active", d.GetActiveContract)
|
r.Get("/contracts/active", d.GetActiveContract)
|
||||||
r.Get("/candles", d.ListCandles)
|
r.Get("/candles", d.ListCandles)
|
||||||
r.Post("/run", d.RunPipeline)
|
r.Post("/run", d.RunPipeline)
|
||||||
|
r.Post("/run/batch", d.RunBatch)
|
||||||
|
r.Post("/run/range", d.RunRange)
|
||||||
|
r.Post("/run/full", d.RunFull)
|
||||||
|
r.Get("/ai/analyze", d.Analyze)
|
||||||
|
r.Post("/ai/daily-direction", d.DailyDirectionRun)
|
||||||
|
r.Get("/ai/daily-direction", d.ListDailyDirections)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(mw.RequireAdmin)
|
r.Use(mw.RequireAdmin)
|
||||||
@@ -38,6 +44,9 @@ func New(d *handlers.Deps, dist fs.FS) http.Handler {
|
|||||||
r.Post("/admin/users", d.AdminCreateUser)
|
r.Post("/admin/users", d.AdminCreateUser)
|
||||||
r.Patch("/admin/users/{id}", d.AdminPatchUser)
|
r.Patch("/admin/users/{id}", d.AdminPatchUser)
|
||||||
r.Delete("/admin/users/{id}", d.AdminDeleteUser)
|
r.Delete("/admin/users/{id}", d.AdminDeleteUser)
|
||||||
|
r.Post("/admin/reset-data", d.AdminResetData)
|
||||||
|
r.Get("/admin/llm-config", d.GetLLMConfig)
|
||||||
|
r.Put("/admin/llm-config", d.SaveLLMConfig)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
134
web/backend/internal/store/ai.go
Normal file
134
web/backend/internal/store/ai.go
Normal 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
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
|
|||||||
args = append(args, "%"+f.Signal+"%")
|
args = append(args, "%"+f.Signal+"%")
|
||||||
}
|
}
|
||||||
q += " ORDER BY trade_date DESC, id DESC"
|
q += " ORDER BY trade_date DESC, id DESC"
|
||||||
if f.Limit <= 0 || f.Limit > 500 {
|
if f.Limit <= 0 || f.Limit > 1000 {
|
||||||
f.Limit = 200
|
f.Limit = 200
|
||||||
}
|
}
|
||||||
q += " LIMIT " + next()
|
q += " LIMIT " + next()
|
||||||
@@ -144,14 +144,136 @@ type Candle struct {
|
|||||||
PreClose float64 `json:"pre_close"`
|
PreClose float64 `json:"pre_close"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Daily Direction ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// DailyDirection 日内方向分析结果。
|
||||||
|
type DailyDirection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
TradeDate string `json:"trade_date"`
|
||||||
|
TargetDate string `json:"target_date"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Confidence float64 `json:"confidence"`
|
||||||
|
Support string `json:"support"` // JSONB → string
|
||||||
|
Resistance string `json:"resistance"` // JSONB → string
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
RiskNote string `json:"risk_note"`
|
||||||
|
PromptSnapshot string `json:"prompt_snapshot,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDailyDirectionTable 建 daily_direction 表(幂等)。
|
||||||
|
func (s *FuturesStore) EnsureDailyDirectionTable() error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_direction (
|
||||||
|
id UUID DEFAULT uuidv7() PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
target_date TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
confidence REAL,
|
||||||
|
support JSONB,
|
||||||
|
resistance JSONB,
|
||||||
|
reasoning TEXT,
|
||||||
|
risk_note TEXT,
|
||||||
|
prompt_snapshot TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (symbol, trade_date)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDailyDirection 写入(upsert)一条方向分析。
|
||||||
|
func (s *FuturesStore) SaveDailyDirection(dd *DailyDirection) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO daily_direction
|
||||||
|
(symbol, trade_date, target_date, direction, confidence, support, resistance, reasoning, risk_note, prompt_snapshot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
ON CONFLICT (symbol, trade_date) DO UPDATE SET
|
||||||
|
target_date = EXCLUDED.target_date,
|
||||||
|
direction = EXCLUDED.direction,
|
||||||
|
confidence = EXCLUDED.confidence,
|
||||||
|
support = EXCLUDED.support,
|
||||||
|
resistance = EXCLUDED.resistance,
|
||||||
|
reasoning = EXCLUDED.reasoning,
|
||||||
|
risk_note = EXCLUDED.risk_note,
|
||||||
|
prompt_snapshot = EXCLUDED.prompt_snapshot,
|
||||||
|
created_at = CURRENT_TIMESTAMP
|
||||||
|
`, dd.Symbol, dd.TradeDate, dd.TargetDate, dd.Direction, dd.Confidence,
|
||||||
|
dd.Support, dd.Resistance, dd.Reasoning, dd.RiskNote, dd.PromptSnapshot)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDailyDirections 查询方向分析列表。
|
||||||
|
func (s *FuturesStore) ListDailyDirections(symbol, start, end string, limit int) ([]DailyDirection, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
q := `SELECT id, symbol, trade_date, target_date, direction, confidence,
|
||||||
|
COALESCE(support::text, '[]'), COALESCE(resistance::text, '[]'),
|
||||||
|
reasoning, risk_note, COALESCE(prompt_snapshot, ''),
|
||||||
|
COALESCE(created_at::text, '')
|
||||||
|
FROM daily_direction WHERE 1=1`
|
||||||
|
args := []any{}
|
||||||
|
n := 0
|
||||||
|
next := func() string { n++; return fmt.Sprintf("$%d", n) }
|
||||||
|
if symbol != "" {
|
||||||
|
q += " AND symbol = " + next()
|
||||||
|
args = append(args, symbol)
|
||||||
|
}
|
||||||
|
if start != "" {
|
||||||
|
q += " AND trade_date >= " + next()
|
||||||
|
args = append(args, start)
|
||||||
|
}
|
||||||
|
if end != "" {
|
||||||
|
q += " AND trade_date <= " + next()
|
||||||
|
args = append(args, end)
|
||||||
|
}
|
||||||
|
q += " ORDER BY trade_date DESC, symbol ASC LIMIT " + next()
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DailyDirection{}
|
||||||
|
for rows.Next() {
|
||||||
|
var dd DailyDirection
|
||||||
|
if err := rows.Scan(&dd.ID, &dd.Symbol, &dd.TradeDate, &dd.TargetDate,
|
||||||
|
&dd.Direction, &dd.Confidence, &dd.Support, &dd.Resistance,
|
||||||
|
&dd.Reasoning, &dd.RiskNote, &dd.PromptSnapshot, &dd.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, dd)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveTsCode 通过 scores 表查找某品种在指定日期的活跃合约代码。
|
||||||
|
func (s *FuturesStore) GetActiveTsCode(symbol, tradeDate string) (string, error) {
|
||||||
|
var tsCode string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT ts_code FROM scores WHERE trade_date = $1 AND ts_code LIKE $2 || '%' ORDER BY ts_code DESC LIMIT 1`,
|
||||||
|
tradeDate, symbol,
|
||||||
|
).Scan(&tsCode)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("no active contract for %s on %s: %w", symbol, tradeDate, err)
|
||||||
|
}
|
||||||
|
return tsCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
|
func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
|
||||||
if tsCode == "" {
|
if tsCode == "" {
|
||||||
return nil, ErrMissingTsCode
|
return nil, ErrMissingTsCode
|
||||||
}
|
}
|
||||||
q := `SELECT ts_code, trade_date,
|
q := `SELECT ts_code, trade_date,
|
||||||
COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0),
|
COALESCE(NULLIF(open, 'NaN'::real), 0), COALESCE(NULLIF(high, 'NaN'::real), 0),
|
||||||
COALESCE(vol, 0), COALESCE(amount, 0),
|
COALESCE(NULLIF(low, 'NaN'::real), 0), COALESCE(NULLIF(close, 'NaN'::real), 0),
|
||||||
COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0)
|
COALESCE(NULLIF(vol, 'NaN'::real), 0), COALESCE(NULLIF(amount, 'NaN'::real), 0),
|
||||||
|
COALESCE(NULLIF(oi, 'NaN'::real), 0), COALESCE(NULLIF(oi_chg, 'NaN'::real), 0),
|
||||||
|
COALESCE(NULLIF(pre_close, 'NaN'::real), 0)
|
||||||
FROM candles WHERE ts_code = $1`
|
FROM candles WHERE ts_code = $1`
|
||||||
args := []any{tsCode}
|
args := []any{tsCode}
|
||||||
n := 1
|
n := 1
|
||||||
|
|||||||
@@ -40,7 +40,23 @@ func main() {
|
|||||||
log.Fatalf("bootstrap: %v", err)
|
log.Fatalf("bootstrap: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deps := &handlers.Deps{Auth: authDB, Futures: futures, TushareURL: cfg.TushareAPIURL}
|
if err := auth.BootstrapLLMConfig(futures); err != nil {
|
||||||
|
log.Fatalf("bootstrap llm config: %v", err)
|
||||||
|
}
|
||||||
|
if err := futures.EnsureDailyDirectionTable(); err != nil {
|
||||||
|
log.Fatalf("bootstrap daily_direction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := &handlers.Deps{
|
||||||
|
Auth: authDB,
|
||||||
|
Futures: futures,
|
||||||
|
TushareURL: cfg.TushareAPIURL,
|
||||||
|
AIConfig: &handlers.AIConfig{
|
||||||
|
BaseURL: cfg.LLMBaseURL,
|
||||||
|
APIKey: cfg.LLMAPIKey,
|
||||||
|
Model: cfg.LLMModel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
dist, err := fs.Sub(distFS, "dist")
|
dist, err := fs.Sub(distFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,20 +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="/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"
|
||||||
@@ -86,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;
|
||||||
@@ -100,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;
|
||||||
@@ -111,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;
|
||||||
@@ -124,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>
|
||||||
|
|||||||
20
web/frontend/src/api/admin.ts
Normal file
20
web/frontend/src/api/admin.ts
Normal 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)
|
||||||
|
}
|
||||||
46
web/frontend/src/api/daily.ts
Normal file
46
web/frontend/src/api/daily.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -27,7 +27,62 @@ export interface ActiveContract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runPipeline(req: RunRequest) {
|
export function runPipeline(req: RunRequest) {
|
||||||
return client.post<RunResponse>('/run', req).then((r) => r.data)
|
return client.post<RunResponse>('/run', req, { timeout: 60_000 }).then((r) => r.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunRangeRequest {
|
||||||
|
symbol: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunRangeResult {
|
||||||
|
trade_date: string
|
||||||
|
close: number
|
||||||
|
composite: number
|
||||||
|
signal: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunRangeResponse {
|
||||||
|
ts_code: string
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
scored: number
|
||||||
|
skipped: number
|
||||||
|
warnings: string[]
|
||||||
|
results: RunRangeResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunFullRequest {
|
||||||
|
ts_code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunFullResult {
|
||||||
|
trade_date: string
|
||||||
|
close: number
|
||||||
|
composite: number
|
||||||
|
signal: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunFullResponse {
|
||||||
|
ts_code: string
|
||||||
|
total_days: number
|
||||||
|
scored_count: number
|
||||||
|
skipped_count: number
|
||||||
|
warnings: string[]
|
||||||
|
results: RunFullResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRange(req: RunRangeRequest) {
|
||||||
|
return client.post<RunRangeResponse>('/run/range', req, { timeout: 180_000 }).then((r) => r.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runFull(req: RunFullRequest) {
|
||||||
|
return client.post<RunFullResponse>('/run/full', req, { timeout: 300_000 }).then((r) => r.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runBatch() {
|
||||||
|
return client.post('/run/batch', null, { timeout: 180_000 }).then((r) => r.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveContract(symbol: string) {
|
export function getActiveContract(symbol: string) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
import { getScore, type Score } from '@/api/scores'
|
import { getScore, type Score } from '@/api/scores'
|
||||||
import { parseTsCode } from '@/utils/contract'
|
import { parseTsCode } from '@/utils/contract'
|
||||||
|
import { useMobile } from '@/composables/useMobile'
|
||||||
|
|
||||||
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
const props = defineProps<{ scoreId: number | null }>()
|
const props = defineProps<{ scoreId: number | null }>()
|
||||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
@@ -9,6 +13,43 @@ const emit = defineEmits<{ (e: 'close'): void }>()
|
|||||||
const score = ref<Score | null>(null)
|
const score = ref<Score | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// AI 分析
|
||||||
|
const aiLoading = ref(false)
|
||||||
|
const aiContent = ref('')
|
||||||
|
const aiError = ref('')
|
||||||
|
let es: EventSource | null = null
|
||||||
|
|
||||||
|
function closeAI() {
|
||||||
|
es?.close()
|
||||||
|
es = null
|
||||||
|
aiLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askAI() {
|
||||||
|
if (!score.value) return
|
||||||
|
closeAI()
|
||||||
|
aiLoading.value = true
|
||||||
|
aiContent.value = ''
|
||||||
|
aiError.value = ''
|
||||||
|
|
||||||
|
const url = `/api/ai/analyze?ts_code=${encodeURIComponent(score.value.ts_code)}&trade_date=${encodeURIComponent(score.value.trade_date)}`
|
||||||
|
es = new EventSource(url)
|
||||||
|
es.addEventListener('token', (e) => {
|
||||||
|
aiContent.value += e.data
|
||||||
|
})
|
||||||
|
es.addEventListener('error', (e) => {
|
||||||
|
aiError.value = (e as any)?.data || '请求失败'
|
||||||
|
closeAI()
|
||||||
|
})
|
||||||
|
es.addEventListener('done', () => {
|
||||||
|
closeAI()
|
||||||
|
})
|
||||||
|
es.onerror = () => {
|
||||||
|
if (!aiContent.value && !aiError.value) aiError.value = '连接中断'
|
||||||
|
closeAI()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.scoreId !== null,
|
get: () => props.scoreId !== null,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
@@ -19,6 +60,9 @@ const visible = computed({
|
|||||||
watch(
|
watch(
|
||||||
() => props.scoreId,
|
() => props.scoreId,
|
||||||
async (id) => {
|
async (id) => {
|
||||||
|
closeAI()
|
||||||
|
aiContent.value = ''
|
||||||
|
aiError.value = ''
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
score.value = null
|
score.value = null
|
||||||
return
|
return
|
||||||
@@ -31,12 +75,34 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const quadrantTag = (q: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
accumulation: 'success',
|
||||||
|
distribution: 'warning',
|
||||||
|
covering: 'info',
|
||||||
|
liquidation: 'danger',
|
||||||
|
flat: '',
|
||||||
|
}
|
||||||
|
return map[q] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const quadrantLabel = (q: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
accumulation: '增仓涨',
|
||||||
|
distribution: '增仓跌',
|
||||||
|
covering: '减仓涨',
|
||||||
|
liquidation: '减仓跌',
|
||||||
|
flat: '持平',
|
||||||
|
}
|
||||||
|
return map[q] ?? q
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close>
|
<el-drawer v-model="visible" title="打分明细" :size="isMobile ? '92%' : '720px'" destroy-on-close>
|
||||||
<div v-loading="loading" v-if="score">
|
<div v-loading="loading" v-if="score">
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
|
<el-descriptions-item label="品种">{{ parseTsCode(score.ts_code).symbol }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="合约">{{ parseTsCode(score.ts_code).contract }}</el-descriptions-item>
|
<el-descriptions-item label="合约">{{ parseTsCode(score.ts_code).contract }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item>
|
<el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item>
|
||||||
@@ -48,54 +114,174 @@ watch(
|
|||||||
<el-descriptions-item label="信号">
|
<el-descriptions-item label="信号">
|
||||||
<el-tag>{{ score.signal }}</el-tag>
|
<el-tag>{{ score.signal }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="短期(7d × 0.4)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
|
<el-descriptions-item label="短期(7d)">{{ score.short_term.toFixed(2) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="中期(15d × 0.35)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
|
<el-descriptions-item label="中期(15d)">{{ score.medium_term.toFixed(2) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="长期(30d × 0.25)" :span="2">
|
<el-descriptions-item label="长期(30d)" :span="isMobile ? 1 : 2">
|
||||||
{{ score.long_term.toFixed(2) }}
|
{{ score.long_term.toFixed(2) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="score.detail?.composite_delta != null" label="Δ1d">
|
||||||
|
<span :style="{ color: score.detail.composite_delta >= 0 ? '#e4393c' : '#1ca11c' }">
|
||||||
|
{{ score.detail.composite_delta >= 0 ? '+' : '' }}{{ score.detail.composite_delta.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="score.detail?.composite_delta_5d != null" label="Δ5d">
|
||||||
|
<span :style="{ color: score.detail.composite_delta_5d >= 0 ? '#e4393c' : '#1ca11c' }">
|
||||||
|
{{ score.detail.composite_delta_5d >= 0 ? '+' : '' }}{{ score.detail.composite_delta_5d.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<h4 class="section">短期 7 日逐日打分</h4>
|
<h4 class="section">短期 7 日逐日打分</h4>
|
||||||
<el-table :data="score.detail?.short_details ?? []" size="small" border>
|
<div class="table-wrapper">
|
||||||
|
<el-table
|
||||||
|
: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>
|
||||||
@@ -104,4 +290,70 @@ watch(
|
|||||||
.section {
|
.section {
|
||||||
margin: 18px 0 8px;
|
margin: 18px 0 8px;
|
||||||
}
|
}
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.detail-table {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
.formula-hint {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.ai-section {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.ai-card {
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ai-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ai-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.ai-text {
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.ai-text :deep(p) {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.ai-text :deep(h1),
|
||||||
|
.ai-text :deep(h2),
|
||||||
|
.ai-text :deep(h3),
|
||||||
|
.ai-text :deep(h4) {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
.ai-text :deep(ul),
|
||||||
|
.ai-text :deep(ol) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.ai-text :deep(li) {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.ai-error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
.ai-loading {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* AI Markdown 输出段落间距(非 scoped,确保 v-html 生效) */
|
||||||
|
.ai-text p { margin: 3px 0; }
|
||||||
|
.ai-text h1, .ai-text h2, .ai-text h3, .ai-text h4 { margin: 16px 0 6px; font-size: inherit; }
|
||||||
|
.ai-text ul, .ai-text ol { margin: 3px 0; padding-left: 18px; }
|
||||||
|
.ai-text li { margin: 1px 0; }
|
||||||
|
.ai-text strong { color: var(--el-color-primary, #409eff); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
26
web/frontend/src/composables/useMobile.ts
Normal file
26
web/frontend/src/composables/useMobile.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -25,11 +25,21 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'chart',
|
name: 'chart',
|
||||||
component: () => import('@/views/ChartView.vue'),
|
component: () => import('@/views/ChartView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/contract-full',
|
||||||
|
name: 'contract-full',
|
||||||
|
component: () => import('@/views/ContractFullView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/run',
|
path: '/run',
|
||||||
name: 'run',
|
name: 'run',
|
||||||
component: () => import('@/views/RunView.vue'),
|
component: () => import('@/views/RunView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/daily-direction',
|
||||||
|
name: 'daily-direction',
|
||||||
|
component: () => import('@/views/DailyDirectionView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/users',
|
path: '/admin/users',
|
||||||
name: 'admin-users',
|
name: 'admin-users',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -87,9 +87,12 @@ async function submit() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
|
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
padding: 36px 32px;
|
padding: 36px 32px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
@@ -106,4 +109,9 @@ async function submit() {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
183
web/frontend/src/views/ContractFullView.vue
Normal file
183
web/frontend/src/views/ContractFullView.vue
Normal 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>
|
||||||
304
web/frontend/src/views/DailyDirectionView.vue
Normal file
304
web/frontend/src/views/DailyDirectionView.vue
Normal 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>
|
||||||
@@ -70,9 +70,11 @@ async function submit() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
|
background: linear-gradient(135deg, #1f2d3d 0%, #3a506b 100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
padding: 36px 32px;
|
padding: 36px 32px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
@@ -89,4 +91,9 @@ async function submit() {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ import { nextTick, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
runPipeline,
|
runPipeline,
|
||||||
|
runRange,
|
||||||
getActiveContract,
|
getActiveContract,
|
||||||
type ActiveContract,
|
type ActiveContract,
|
||||||
type RunResponse,
|
type RunResponse,
|
||||||
|
type RunRangeResponse,
|
||||||
} from '@/api/run'
|
} from '@/api/run'
|
||||||
import { parseTsCode } from '@/utils/contract'
|
import { parseTsCode } from '@/utils/contract'
|
||||||
|
import { useMobile } from '@/composables/useMobile'
|
||||||
|
|
||||||
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
|
const SYMBOLS = ['FG', 'SA', 'RB', 'MA', 'CF', 'M']
|
||||||
|
|
||||||
|
const mode = ref<'single' | 'range'>('single')
|
||||||
|
|
||||||
const form = reactive<{
|
const form = reactive<{
|
||||||
symbol: string
|
symbol: string
|
||||||
trade_date: string
|
trade_date: string
|
||||||
@@ -19,20 +26,32 @@ const form = reactive<{
|
|||||||
trade_date: '',
|
trade_date: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const range = reactive<{
|
||||||
|
dates: [string, string] | []
|
||||||
|
}>({
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
|
||||||
const active = ref<ActiveContract | null>(null)
|
const active = ref<ActiveContract | null>(null)
|
||||||
const activeLoading = ref(false)
|
const activeLoading = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const result = ref<RunResponse | null>(null)
|
const result = ref<RunResponse | null>(null)
|
||||||
|
const rangeResult = ref<RunRangeResponse | null>(null)
|
||||||
const resultRef = ref<HTMLElement | null>(null)
|
const resultRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
async function loadActive() {
|
async function loadActive() {
|
||||||
activeLoading.value = true
|
activeLoading.value = true
|
||||||
try {
|
try {
|
||||||
active.value = await getActiveContract(form.symbol)
|
active.value = await getActiveContract(form.symbol)
|
||||||
// 切换品种后,如果原日期落在新合约的可选范围之外,清空它
|
|
||||||
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
|
if (form.trade_date && !isDateAllowed(toDate(form.trade_date))) {
|
||||||
form.trade_date = ''
|
form.trade_date = ''
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(range.dates) && range.dates.length === 2) {
|
||||||
|
const [s, e] = range.dates
|
||||||
|
if (!isDateAllowed(toDate(s)) || !isDateAllowed(toDate(e))) {
|
||||||
|
range.dates = []
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
active.value = null
|
active.value = null
|
||||||
ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
|
ElMessage.error(err?.response?.data?.error || '加载主力合约失败')
|
||||||
@@ -42,17 +61,15 @@ async function loadActive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toDate(s: string) {
|
function toDate(s: string) {
|
||||||
// s 形如 'YYYY-MM-DD'
|
|
||||||
const [y, m, d] = s.split('-').map(Number)
|
const [y, m, d] = s.split('-').map(Number)
|
||||||
return new Date(y, m - 1, d)
|
return new Date(y, m - 1, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDateAllowed(d: Date): boolean {
|
function isDateAllowed(d: Date): boolean {
|
||||||
if (!active.value) return true
|
if (!active.value) return true
|
||||||
const min = toDate(active.value.min_date).getTime()
|
|
||||||
const max = toDate(active.value.max_date).getTime()
|
const max = toDate(active.value.max_date).getTime()
|
||||||
const t = d.getTime()
|
const t = d.getTime()
|
||||||
return t >= min && t <= max
|
return t <= max
|
||||||
}
|
}
|
||||||
|
|
||||||
function disabledDate(d: Date) {
|
function disabledDate(d: Date) {
|
||||||
@@ -66,12 +83,29 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
result.value = null
|
result.value = null
|
||||||
|
rangeResult.value = null
|
||||||
try {
|
try {
|
||||||
|
if (mode.value === 'single') {
|
||||||
const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
|
const req: { symbol: string; trade_date?: string } = { symbol: form.symbol }
|
||||||
if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
|
if (form.trade_date) req.trade_date = form.trade_date.replace(/-/g, '')
|
||||||
const resp = await runPipeline(req)
|
const resp = await runPipeline(req)
|
||||||
result.value = resp
|
result.value = resp
|
||||||
ElMessage.success('打分完成')
|
ElMessage.success('打分完成')
|
||||||
|
} else {
|
||||||
|
if (!Array.isArray(range.dates) || range.dates.length !== 2) {
|
||||||
|
ElMessage.warning('请选择日期区间')
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [start, end] = range.dates
|
||||||
|
const resp = await runRange({
|
||||||
|
symbol: form.symbol,
|
||||||
|
start_date: start.replace(/-/g, ''),
|
||||||
|
end_date: end.replace(/-/g, ''),
|
||||||
|
})
|
||||||
|
rangeResult.value = resp
|
||||||
|
ElMessage.success(`区间打分完成,成功 ${resp.scored} 条`)
|
||||||
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
resultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -101,21 +135,18 @@ onMounted(loadActive)
|
|||||||
<span>手动打分</span>
|
<span>手动打分</span>
|
||||||
</template>
|
</template>
|
||||||
<el-form :model="form" label-width="100px" style="max-width: 480px">
|
<el-form :model="form" label-width="100px" style="max-width: 480px">
|
||||||
|
<el-form-item label="模式">
|
||||||
|
<el-radio-group v-model="mode">
|
||||||
|
<el-radio-button label="single">单日打分</el-radio-button>
|
||||||
|
<el-radio-button label="range">区间打分</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="品种">
|
<el-form-item label="品种">
|
||||||
<el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%">
|
<el-select v-model="form.symbol" :loading="activeLoading" style="width: 100%">
|
||||||
<el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
|
<el-option v-for="s in SYMBOLS" :key="s" :label="s" :value="s" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="主力合约">
|
<el-form-item v-if="mode === 'single'" label="打分日期">
|
||||||
<span v-if="active">
|
|
||||||
{{ parseTsCode(active.ts_code).contract }}
|
|
||||||
<el-text type="info" size="small" style="margin-left: 8px">
|
|
||||||
({{ active.ts_code }})
|
|
||||||
</el-text>
|
|
||||||
</span>
|
|
||||||
<el-text v-else type="info">加载中…</el-text>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="打分日期">
|
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="form.trade_date"
|
v-model="form.trade_date"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -126,9 +157,23 @@ onMounted(loadActive)
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="日期区间">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="range.dates"
|
||||||
|
type="daterange"
|
||||||
|
:placeholder="active ? `${active.min_date} ~ ${active.max_date}` : '加载中…'"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
:disabled="!active"
|
||||||
|
range-separator="→"
|
||||||
|
start-placeholder="开始"
|
||||||
|
end-placeholder="结束"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
|
<el-button type="primary" :loading="loading" :disabled="!active" @click="submit">
|
||||||
执行打分
|
{{ mode === 'single' ? '执行打分' : '批量打分' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -139,7 +184,7 @@ onMounted(loadActive)
|
|||||||
<template #header>
|
<template #header>
|
||||||
<span>打分结果</span>
|
<span>打分结果</span>
|
||||||
</template>
|
</template>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
<el-descriptions-item label="品种">{{ parseTsCode(result.ts_code).symbol }}</el-descriptions-item>
|
<el-descriptions-item label="品种">{{ parseTsCode(result.ts_code).symbol }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="合约">{{ parseTsCode(result.ts_code).contract }}</el-descriptions-item>
|
<el-descriptions-item label="合约">{{ parseTsCode(result.ts_code).contract }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item>
|
<el-descriptions-item label="日期">{{ result.trade_date }}</el-descriptions-item>
|
||||||
@@ -151,12 +196,47 @@ onMounted(loadActive)
|
|||||||
<el-descriptions-item label="综合">
|
<el-descriptions-item label="综合">
|
||||||
<strong>{{ result.composite }}</strong>
|
<strong>{{ result.composite }}</strong>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="信号" :span="2">
|
<el-descriptions-item label="信号" :span="isMobile ? 1 : 2">
|
||||||
<el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag>
|
<el-tag :type="signalTagType(result.signal)">{{ result.signal }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rangeResult" ref="resultRef">
|
||||||
|
<el-card shadow="never" class="result-card">
|
||||||
|
<template #header>
|
||||||
|
<span>区间打分结果</span>
|
||||||
|
</template>
|
||||||
|
<el-descriptions :column="isMobile ? 1 : 2" border>
|
||||||
|
<el-descriptions-item label="合约">{{ parseTsCode(rangeResult.ts_code).symbol }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="区间">{{ rangeResult.start_date }} ~ {{ rangeResult.end_date }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="成功">{{ rangeResult.scored }} 条</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="跳过">{{ rangeResult.skipped }} 条</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-alert
|
||||||
|
v-if="rangeResult.warnings.length > 0"
|
||||||
|
:title="`警告 (${rangeResult.warnings.length} 条)`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-top: 12px"
|
||||||
|
>
|
||||||
|
<div style="max-height: 120px; overflow-y: auto">
|
||||||
|
<div v-for="(w, i) in rangeResult.warnings" :key="i" style="font-size: 12px">{{ w }}</div>
|
||||||
|
</div>
|
||||||
|
</el-alert>
|
||||||
|
<el-table :data="rangeResult.results" stripe style="margin-top: 16px" max-height="400">
|
||||||
|
<el-table-column prop="trade_date" label="日期" width="110" />
|
||||||
|
<el-table-column prop="close" label="收盘" width="90" />
|
||||||
|
<el-table-column prop="composite" label="综合" width="80" />
|
||||||
|
<el-table-column label="信号">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="signalTagType(row.signal)" size="small">{{ row.signal }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -166,4 +246,7 @@ onMounted(loadActive)
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.result-card {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,47 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { nextTick, onMounted, reactive, ref, watch, computed } from 'vue'
|
||||||
import { listContracts, listScores, type Score } from '@/api/scores'
|
import { marked } from 'marked'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { listScores, listContracts, type Score } from '@/api/scores'
|
||||||
|
import { runPipeline, type RunResponse } from '@/api/run'
|
||||||
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
|
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
|
||||||
import { parseTsCode } from '@/utils/contract'
|
import { parseTsCode } from '@/utils/contract'
|
||||||
|
import { useMobile } from '@/composables/useMobile'
|
||||||
|
|
||||||
const filter = reactive<{
|
const { isMobile } = useMobile()
|
||||||
ts_code?: string
|
|
||||||
range: [string, string] | []
|
const EXCHANGES = [
|
||||||
signal?: string
|
{ code: 'ZCE', name: '郑商所' },
|
||||||
limit: number
|
{ code: 'SHF', name: '上期所' },
|
||||||
}>({
|
{ code: 'DCE', name: '大商所' },
|
||||||
ts_code: undefined,
|
]
|
||||||
range: [],
|
|
||||||
signal: undefined,
|
const SYMBOLS_BY_EXCHANGE: Record<string, string[]> = {
|
||||||
limit: 200,
|
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(silent = false) {
|
async function handleScore() {
|
||||||
if (!silent) 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' })
|
||||||
signal: filter.signal,
|
} catch (err: any) {
|
||||||
limit: filter.limit,
|
const msg = err?.response?.data?.error || err.message || '请求失败'
|
||||||
})
|
ElMessage.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) loading.value = false
|
scoring.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSignal(s: string) {
|
|
||||||
filter.signal = filter.signal === s ? undefined : s
|
|
||||||
reload(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function signalTagType(s: string) {
|
function signalTagType(s: string) {
|
||||||
if (s.includes('强烈看多')) return 'success'
|
if (s.includes('强烈看多')) return 'success'
|
||||||
if (s.includes('偏多')) return ''
|
if (s.includes('偏多')) return ''
|
||||||
@@ -50,65 +67,259 @@ 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" style="width: 88px" @click="reload">查询</el-button>
|
<el-button type="primary" :loading="historyLoading" style="width: 88px" @click="reloadHistory">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="快捷">
|
<el-form-item label="快捷" class="signal-item">
|
||||||
<el-button-group class="signal-group">
|
<el-button-group class="signal-group" :size="isMobile ? 'small' : 'default'">
|
||||||
<el-button
|
<el-button
|
||||||
:type="filter.signal === '强烈看多' ? 'success' : ''"
|
:type="historyFilter.signal === '强烈看多' ? 'success' : ''"
|
||||||
@click="toggleSignal('强烈看多')"
|
@click="toggleSignal('强烈看多')"
|
||||||
>
|
>
|
||||||
强烈看多
|
强烈看多
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:type="filter.signal === '偏多' ? 'primary' : ''"
|
:type="historyFilter.signal === '偏多' ? 'primary' : ''"
|
||||||
@click="toggleSignal('偏多')"
|
@click="toggleSignal('偏多')"
|
||||||
>
|
>
|
||||||
偏多
|
偏多
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:type="filter.signal === '偏空' ? 'warning' : ''"
|
:type="historyFilter.signal === '偏空' ? 'warning' : ''"
|
||||||
@click="toggleSignal('偏空')"
|
@click="toggleSignal('偏空')"
|
||||||
>
|
>
|
||||||
偏空
|
偏空
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:type="filter.signal === '强烈看空' ? 'danger' : ''"
|
:type="historyFilter.signal === '强烈看空' ? 'danger' : ''"
|
||||||
@click="toggleSignal('强烈看空')"
|
@click="toggleSignal('强烈看空')"
|
||||||
>
|
>
|
||||||
强烈看空
|
强烈看空
|
||||||
@@ -116,9 +327,9 @@ onMounted(async () => {
|
|||||||
</el-button-group>
|
</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 label="品种" width="80">
|
<el-table-column label="品种" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -143,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">
|
||||||
@@ -152,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>
|
||||||
|
|
||||||
@@ -166,6 +379,45 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -177,7 +429,30 @@ onMounted(async () => {
|
|||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.score-table {
|
.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user