Compare commits

...

37 Commits

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

View File

@@ -45,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 调用处理一个合约一日。
**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` 等端点。启动时自动 `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 流程。
**三层打分模型**(`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`)。
@@ -71,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 中。
- 鉴权已简化:登录接口返回固定 token,`middleware.RequireUser` 直接注入默认管理员上下文,所有请求放行。后端仍保留密码校验与角色检查(`RequireAdmin`)。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
- 前端支持暗/浅色模式切换(`stores/theme.ts`),侧边导航在暗色模式用 `#282828`、浅色模式用 `#f9fafb`
- 侧边栏提供「同步数据」(批量打分)、「数据重置」(管理员清空行情数据)功能。
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行,则自动创建默认管理员 `admin` / `admin`,并标记强制首次登录后改密码。忘记管理员密码的恢复方式:停服 → 清理 PostgreSQL 中的 admin 记录 → 重启。

View File

@@ -49,6 +49,9 @@ curl "http://localhost:4001/api/v1/contracts"
# 查询 K 线数据
curl "http://localhost:4001/api/v1/candles?ts_code=FG2609.ZCE"
# 清空所有行情数据(谨慎操作)
curl -X POST http://localhost:4001/api/v1/admin/reset-data
```
### 5. 跑其他合约或品种
@@ -76,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
逐日打分后取均值
逐日打分后取均值。每日评分 = (象限基础分 + 幅度加成) × 量能确认,产出 0-100 连续值。
| 持仓变化 | 价格方向 | 得分 |
|---------|---------|------|
| 增仓 | 上涨 | 100多头主动进攻 |
| 增仓 | 下跌 | 0空头主动进攻 |
| 仓 | 上涨 | 70空头撤退 |
| 仓 | 下跌 | 30多头撤退 |
| 持平(\|变化\|<1% | 上涨 | 60 |
| 持平(\|变化\|<1% | 下跌 | 40 |
**象限基础分**(持仓与价格方向):
| 象限 | 持仓变化 | 价格方向 | 基础分 |
|------|---------|---------|--------|
| accumulation增仓上涨 | 增仓 | 上涨 | 75 |
| distribution增仓下跌 | 增仓 | 下跌 | 25 |
| covering减仓上涨 | 减仓 | 上涨 | 65 |
| liquidation减仓下跌 | 减仓 | 下跌 | 20 |
| flat持平 | \|变化\|<1% | 上涨 | 60 |
| flat持平 | \|变化\|<1% | 下跌 | 40 |
**幅度加成**(根据 OI 变化率和涨跌幅放大有利方向得分):
- OI 变化率封顶 5%,价格涨跌幅封顶 3%
- 增仓上涨 / 减仓下跌(有利方向):加成 = (OI 幅度 + 价格幅度) / 2 × 20
- 持仓持平:加成 = 价格幅度 × 10
- 增仓下跌 / 减仓上涨(不利方向):无加成
**量能确认**`量比 = 当日成交量 / 7 日均量`,系数范围 [0.9, 1.2],量比 1.5 以上封顶
### 2. 中期趋势15 日窗口,权重 0.35
```
价格信号 = (今收 - 15日前收) / 15日前收
价格信号分 = clamp(50 + 收益率×500, 0, 100)
价格收益率 = (今收 - 15日前收) / 15日前收
价格信号分 = clamp(50 + 收益率 × 500, 0, 100)
资金意愿:
增仓上涨天数 > 增仓下跌天数 → 80
两者相当 → 50
增仓下跌天数 > 增仓上涨天数 → 20
资金意愿 = 50 + (增仓上涨天数 - 增仓下跌天数) / 15 × 50 (连续值 0-100
模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4
```
@@ -109,15 +119,23 @@ docker-compose -f docker-compose.trade.yml run --rm tushare python -m src.main -
### 3. 长期结构30 日窗口,权重 0.25
```
持仓变化幅度 = (30日日均持仓 - 30日前持仓) / 30日前持仓
OI 趋势分 = clamp(50 + OI变化幅度 × 250, 0, 100) (权重 60%
价格趋势分 = clamp(50 + 30日价格收益率 × 200, 0, 100) (权重 40%
> 10% → 90显著增仓
5%~10% → 70温和增仓
-5%~5% → 50基本持平
-10%~-5% → 30温和减仓
< -10% → 10显著减仓
模块得分 = OI 趋势分 × 0.6 + 价格趋势分 × 0.4
```
### 4. 波动率调整
基于近 30 日日收益率标准差和 ATR%(平均真实波幅/均价):
```
日波动率 ≤ 1.5% → 惩罚系数 = 1.0(无惩罚)
日波动率 > 1.5% → 惩罚系数 = max(0.85, 1.0 - (日波动率 - 1.5%) × 10)
```
高波动品种的综合分会被适当打折,最低打 85 折。
### 信号解读
| 综合分数 | 信号 |
@@ -237,8 +255,11 @@ docker-compose -f docker-compose.trade.yml logs -f web
### 3. 页面说明
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分、中期(15d)价格收益与资金意愿、长期(30d)持仓变化
- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分(涨跌幅/OI变化%/量比/象限)、中期(15d)价格收益与资金意愿、长期(30d)OI/价格趋势分与波动率调整
- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
- **同步数据**(侧边栏):点击调用批量打分接口,对所有固定品种执行当日打分,完成后自动跳转并刷新打分列表。
- **手动打分** `/run`:选品种 + 日期,对单个合约执行数据拉取与打分。
- **数据重置**(侧边栏,仅管理员):输入确认文字后清空所有行情数据candles + scores用户表不受影响。
- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,19 @@ def _init_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。"""
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:
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()
# 确保 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"]
for c in numeric:
if c in df.columns:

View File

@@ -1,5 +1,6 @@
import argparse
import sys
from typing import Optional
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("=" * 65)
quadrant_names = {
"accumulation": "增仓上涨", "distribution": "增仓下跌",
"covering": "减仓上涨", "liquidation": "减仓下跌", "flat": "持仓持平",
}
print("\n[短期动力] 近7日逐日打分:")
print("-" * 65)
print("-" * 80)
for d in result.detail.short_details:
tag = "增仓" if d["oi_chg"] > 0 else "减仓"
if abs(d["oi_chg"] / d["oi"]) < 0.01:
tag = "持平"
price_dir = "" if d["close"] >= d["pre_close"] else ""
print(f" {d['trade_date']} {tag:>4} + {price_dir} "
f"持仓{d['oi_chg']:>+8,.0f} 得分: {d['score']:>3}")
q = quadrant_names.get(d["quadrant"], d["quadrant"])
print(f" {d['trade_date']} {q} "
f"涨跌{d['price_chg_pct']*100:>+.2f}% "
f"OI变化{d['oi_chg_pct']*100:>+.2f}% "
f"量比{d['vol_ratio']:.2f} "
f"得分: {d['score']:>5.1f}")
md = result.detail.medium_detail
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['long_up_days']}")
print(f" 增仓下跌天数: {md['long_down_days']}")
print(f" 资金意愿得分: {md['fund_signal']}")
print(f" 资金意愿得分: {md['fund_signal']:.1f}")
ld = result.detail.long_detail
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['oi_before']:,.0f}")
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")
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:
parser = argparse.ArgumentParser(description="期货合约三层打分模型")
parser.add_argument(
@@ -82,11 +161,26 @@ def main() -> int:
"--date",
help="指定打分日期,格式 YYYYMMDD,不传则对最新日期打分",
)
parser.add_argument(
"--start-date",
dest="start_date",
help="区间打分开始日期,格式 YYYYMMDD(与 --end-date 同时使用时生效)",
)
parser.add_argument(
"--end-date",
dest="end_date",
help="区间打分结束日期,格式 YYYYMMDD(与 --start-date 同时使用时生效)",
)
args = parser.parse_args()
ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.ts_code:
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
if args.start_date and args.end_date:
print(f"[RANGE] 区间打分: {args.start_date} ~ {args.end_date}")
return run_range(ts_code, args.start_date, args.end_date)
if args.date:
print(f"[DATE] 指定打分日期: {args.date}")
return run(ts_code, args.date)

View File

@@ -22,6 +22,8 @@ class ScoreDetail:
short_details: list = field(default_factory=list)
medium_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
@@ -37,3 +39,6 @@ class ScoreResult:
composite: float
signal: str
detail: ScoreDetail
vol_penalty: float = 1.0
composite_delta: Optional[float] = None # 与前一日综合分差值
composite_delta_5d: Optional[float] = None # 与5日前综合分差值

View File

@@ -1,50 +1,110 @@
import math
from typing import Optional
import numpy as np
import pandas as pd
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_chg = float(row["oi_chg"])
close = float(row["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
if oi_change_pct < 0.01:
return 60 if price_up else 40
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]:
"""近 window 日逐日打分,指数加权平均(越近权重越高)。"""
recent = df.iloc[-window:].copy()
avg_vol_7d = float(recent["vol"].mean()) if "vol" in recent.columns else 0.0
scores = []
details = []
for _, row in recent.iterrows():
score = _daily_short_score(row)
scores.append(score)
details.append({
"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
detail = _daily_short_score(row, avg_vol_7d)
scores.append(detail["score"])
details.append(detail)
# 指数加权:权重从 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]:
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_before = float(df.iloc[-window - 1]["close"])
# ── 价格信号 ──
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))
long_up = 0
long_down = 0
for _, row in recent.iterrows():
if row["oi_chg"] > 0:
if row["close"] >= row["pre_close"]:
long_up += 1
else:
long_down += 1
# ── 资金意愿(四象限全计入)──
accumulation = 0 # 增仓上涨
distribution = 0 # 增仓下跌
covering = 0 # 减仓上涨
liquidation = 0 # 减仓下跌
for _, row in recent.iterrows():
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
detail = {
"price_return_pct": round(price_return * 100, 2),
"price_signal": round(price_score, 1),
"long_up_days": long_up,
"long_down_days": long_down,
"fund_signal": fund_score,
"accumulation_days": accumulation,
"distribution_days": distribution,
"covering_days": covering,
"liquidation_days": liquidation,
"fund_signal": round(fund_score, 1),
"window": window,
}
return score, detail
# ---------------------------------------------------------------------------
# 长期结构 — 30 日窗口,端点 OI 比较
# ---------------------------------------------------------------------------
def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]:
if len(df) < window + 1:
raise ValueError(f"数据不足,需要至少 {window + 1}")
recent_oi = df.iloc[-window:]["oi"]
avg_oi = recent_oi.mean()
# ── OI 趋势分(端点比较,消除均值滞后)──
oi_now = float(df.iloc[-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 = 90
elif change_pct > 0.05:
score = 70
elif change_pct > -0.05:
score = 50
elif change_pct > -0.10:
score = 30
else:
score = 10
score = oi_score * 0.6 + price_score * 0.4
# 额外统计近 30 日 OI 均值供参考
recent_oi = df.iloc[-window:]["oi"]
detail = {
"avg_oi": round(float(avg_oi), 0),
"oi_now": round(oi_now, 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
# ---------------------------------------------------------------------------
# 信号解读
# ---------------------------------------------------------------------------
def _interpret(composite: float) -> str:
if composite >= 80:
return "强烈看多区域 — 价格与资金共振,趋势多头的温床"
@@ -118,6 +211,10 @@ def _interpret(composite: float) -> str:
return "强烈看空区域 — 资金主动且持续地打压价格"
# ---------------------------------------------------------------------------
# 主打分函数
# ---------------------------------------------------------------------------
def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResult:
"""对 DataFrame 中指定日期或最新一条记录打分。"""
if len(df) < 31:
@@ -135,11 +232,59 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
latest = df.iloc[-1]
# ── 三层原始分 ──
short, short_details = calc_short_term(df, 7)
medium, medium_detail = calc_medium_term(df, 15)
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)
return ScoreResult(
@@ -153,9 +298,94 @@ def score_daily(df: pd.DataFrame, trade_date: Optional[str] = None) -> ScoreResu
long_term=round(long_, 1),
composite=round(composite, 1),
signal=signal,
vol_penalty=round(float(vol_penalty), 4),
detail=ScoreDetail(
short_details=short_details,
medium_detail=medium_detail,
long_detail=long_detail,
volatility={
"daily_vol_pct": round(float(daily_vol), 4),
"atr_pct": round(float(atr_pct), 4),
"vol_risk": round(float(vol_risk), 4),
"vol_penalty": round(float(vol_penalty), 4),
},
adaptive_weights={
"trend_strength": round(float(trend_strength), 2),
"trend_factor": round(float(trend_factor), 2),
"w_short": round(float(w_short), 4),
"w_medium": round(float(w_medium), 4),
"w_long": round(float(w_long), 4),
},
),
)
# ---------------------------------------------------------------------------
# 区间 / 全量打分
# ---------------------------------------------------------------------------
def score_range(
df: pd.DataFrame, start_date: str, end_date: str
) -> tuple[list[ScoreResult], list[str]]:
"""对日期区间内的每一天分别打分,返回 (结果列表, 警告列表)。"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
target_dates = df[
(df["trade_date"].astype(str) >= str(start_date))
& (df["trade_date"].astype(str) <= str(end_date))
]["trade_date"].astype(str).tolist()
for td in target_dates:
try:
result = score_daily(df, td)
results.append(result)
except ValueError as e:
warnings.append(str(e))
_fill_deltas(results)
return results, warnings
def score_all(df: pd.DataFrame) -> tuple[list[ScoreResult], list[str], int, int]:
"""对 DataFrame 中所有有足够前置数据的交易日逐日打分。
Returns:
(results, warnings, total_days, scored_count)
"""
if len(df) < 31:
raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行")
results: list[ScoreResult] = []
warnings: list[str] = []
for idx in range(30, len(df)):
subset = df.iloc[: idx + 1].copy()
trade_date = str(df.iloc[idx]["trade_date"])
try:
result = score_daily(subset)
results.append(result)
except ValueError as e:
warnings.append(f"{trade_date}: {e}")
_fill_deltas(results)
total_days = len(df) - 30
scored_count = len(results)
return results, warnings, total_days, scored_count
# ---------------------------------------------------------------------------
# 辅助:填充分数动量
# ---------------------------------------------------------------------------
def _fill_deltas(results: list[ScoreResult]):
"""为结果列表中的每个 ScoreResult 填充 composite_delta 和 composite_delta_5d。"""
for i, r in enumerate(results):
if i >= 1 and results[i - 1].composite is not None:
r.composite_delta = round(r.composite - results[i - 1].composite, 1)
if i >= 5 and results[i - 5].composite is not None:
r.composite_delta_5d = round(r.composite - results[i - 5].composite, 1)

View File

@@ -68,6 +68,11 @@ def save_candles(df: pd.DataFrame, db_url: str = DEFAULT_DB_URL):
df = df.copy()
df = df.where(pd.notna(df), None)
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:
cur.executemany(
"""
@@ -98,6 +103,19 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
conn = _get_conn(db_url)
try:
with conn.cursor() as cur:
detail_payload = {
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
"volatility": score.detail.volatility,
"adaptive_weights": score.detail.adaptive_weights,
"vol_penalty": score.vol_penalty,
}
if score.composite_delta is not None:
detail_payload["composite_delta"] = score.composite_delta
if score.composite_delta_5d is not None:
detail_payload["composite_delta_5d"] = score.composite_delta_5d
cur.execute(
"""
INSERT INTO scores
@@ -127,11 +145,7 @@ def save_score(score: ScoreResult, db_url: str = DEFAULT_DB_URL):
score.long_term,
score.composite,
score.signal,
json.dumps({
"short_details": score.detail.short_details,
"medium_detail": score.detail.medium_detail,
"long_detail": score.detail.long_detail,
}, ensure_ascii=False, default=str),
json.dumps(detail_payload, ensure_ascii=False, default=str),
),
)
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
finally:
conn.close()
def truncate_all(db_url: str = DEFAULT_DB_URL):
"""清空所有行情数据candles + scores保留用户表。"""
conn = _get_conn(db_url)
try:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE candles, scores")
conn.commit()
finally:
conn.close()

View File

@@ -5,7 +5,8 @@ WORKDIR /ui
# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
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 ./
RUN npm run build
@@ -21,7 +22,7 @@ WORKDIR /src
COPY backend ./
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 && \
go build -trimpath -ldflags="-s -w" -o /out/web ./

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,86 @@ func (d *Deps) RunPipeline(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body)
}
func (d *Deps) RunBatch(w http.ResponseWriter, r *http.Request) {
client := &http.Client{Timeout: 180 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/batch", "application/json", nil)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
type runRangeRequest struct {
Symbol string `json:"symbol,omitempty"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
func (d *Deps) RunRange(w http.ResponseWriter, r *http.Request) {
var req runRangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 180 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/range", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
type runFullRequest struct {
TsCode string `json:"ts_code"`
}
func (d *Deps) RunFull(w http.ResponseWriter, r *http.Request) {
var req runFullRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
if req.TsCode == "" {
writeErr(w, http.StatusBadRequest, "ts_code is required")
return
}
body, err := json.Marshal(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, "encode request failed")
return
}
client := &http.Client{Timeout: 300 * time.Second}
resp, err := client.Post(d.TushareURL+"/api/v1/run/full", "application/json", bytes.NewReader(body))
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("tushare service unavailable: %v", err))
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
func (d *Deps) GetActiveContract(w http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,23 @@ func main() {
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")
if err != nil {

View File

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

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { useMobile } from '@/composables/useMobile'
import { resetAllData } from '@/api/admin'
const auth = useAuthStore()
const theme = useThemeStore()
@@ -12,6 +14,7 @@ const route = useRoute()
const { isMobile } = useMobile()
const drawerOpen = ref(false)
const resetting = ref(false)
const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token)
@@ -29,6 +32,32 @@ function logout() {
function closeDrawer() {
drawerOpen.value = false
}
async function handleReset() {
closeDrawer()
try {
await ElMessageBox.prompt('此操作将清空所有行情数据candles + scores不可恢复。请输入"确认重置"后继续:', '数据重置', {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
inputPattern: /^确认重置$/,
inputErrorMessage: '请输入"确认重置"',
type: 'warning',
})
resetting.value = true
await resetAllData()
ElMessage.success('已清空所有行情数据')
if (route.path === '/scores') {
router.go(0)
} else {
router.push('/scores')
}
} catch {
// user cancelled
} finally {
resetting.value = false
}
}
</script>
<template>
@@ -43,10 +72,15 @@ function closeDrawer() {
:text-color="menuColors.text"
: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="/contract-full">合约全景</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
<el-menu-item v-if="auth.isAdmin" :index="() => {}" @click="handleReset" :disabled="resetting">
数据重置
</el-menu-item>
</el-menu>
</el-aside>
@@ -67,9 +101,11 @@ function closeDrawer() {
:active-text-color="menuColors.active"
@select="closeDrawer"
>
<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="/contract-full">合约全景</el-menu-item>
<el-menu-item index="/run">手动打分</el-menu-item>
<el-menu-item index="/daily-direction">日内方向</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu>
</el-aside>

View File

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

View File

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

View File

@@ -27,7 +27,62 @@ export interface ActiveContract {
}
export function runPipeline(req: RunRequest) {
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) {

View File

@@ -7,26 +7,60 @@ export interface ShortDetail {
oi: number
oi_chg: number
score: number
oi_chg_pct: number
price_chg_pct: number
vol: number
vol_ratio: number
quadrant: string
}
export interface MediumDetail {
price_return_pct: number
price_signal: number
long_up_days: number
long_down_days: number
accumulation_days: number
distribution_days: number
covering_days: number
liquidation_days: number
fund_signal: number
window: number
}
export interface LongDetail {
avg_oi: number
oi_now: 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 {
short_details?: ShortDetail[]
medium_detail?: MediumDetail
long_detail?: LongDetail
volatility?: VolatilityDetail
adaptive_weights?: AdaptiveWeights
vol_penalty?: number
composite_delta?: number | null
composite_delta_5d?: number | null
}
export interface Score {

View File

@@ -5,7 +5,7 @@ import type { Candle } from '@/api/candles'
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 { isMobile } = useMobile()
@@ -28,51 +28,109 @@ function render() {
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi)
const scoreMap = new Map((props.scores || []).map((s) => [s.trade_date, s.composite]))
const compositeData = props.data.map((c) => scoreMap.get(c.trade_date) ?? null)
const hasScores = props.scores && props.scores.length > 0
const legendData = hasScores ? ['K 线', '持仓量', '综合分'] : ['K 线', '持仓量']
const xAxisIndices = hasScores ? [0, 1, 2] : [0, 1]
const grids: any[] = [
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: hasScores ? '52%' : '60%' },
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: hasScores ? '72%' : '78%', height: hasScores ? '14%' : '18%' },
]
const xAxes: any[] = [
{ type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
]
const yAxes: any[] = [
{ scale: true, splitArea: { show: true }, name: '价格', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
{ gridIndex: 1, scale: true, splitNumber: 3, name: '持仓', nameLocation: 'end', nameTextStyle: { fontSize: 11 } },
]
const series: any[] = [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
]
if (hasScores) {
grids.push({ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '88%', height: '10%' })
xAxes.push({ type: 'category', gridIndex: 2, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } })
yAxes.push({ gridIndex: 2, min: 0, max: 100, splitNumber: 2, name: '综合分', nameLocation: 'end', nameTextStyle: { fontSize: 11 } })
series.push({
name: '综合分',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: compositeData,
barWidth: '60%',
itemStyle: {
color: (params: any) => {
const val = params.value as number | null
if (val == null) return 'transparent'
if (val >= 80) return '#ec3a3a'
if (val >= 50) return '#f89898'
if (val >= 40) return '#89d6c7'
return '#26a69a'
},
},
})
}
chart.setOption(
{
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['K 线', '持仓量'], top: 0 },
grid: [
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: 40, height: '60%' },
{ left: isMobile.value ? 48 : 60, right: isMobile.value ? 12 : 40, top: '78%', height: '18%' },
],
xAxis: [
{ type: 'category', data: dates, scale: true, boundaryGap: false },
{ type: 'category', gridIndex: 1, data: dates, scale: true, boundaryGap: false, axisLabel: { show: false } },
],
yAxis: [
{ scale: true, splitArea: { show: true } },
{ gridIndex: 1, scale: true, splitNumber: 3 },
],
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: [0, 1] },
{ type: 'slider', xAxisIndex: [0, 1], height: 18, bottom: 6 },
],
series: [
{
name: 'K 线',
type: 'candlestick',
data: ohlc,
itemStyle: {
color: '#ec3a3a',
color0: '#26a69a',
borderColor: '#ec3a3a',
borderColor0: '#26a69a',
},
},
{
name: '持仓量',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 1,
data: oi,
smooth: true,
showSymbol: false,
lineStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.15, color: '#5470c6' },
},
{ type: 'inside', xAxisIndex: xAxisIndices },
{ type: 'slider', xAxisIndex: xAxisIndices, height: 18, bottom: 6 },
],
series,
},
true,
)

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
updateUser,
type AdminUser,
} from '@/api/users'
import { getLLMConfig, saveLLMConfig, type LLMConfig } from '@/api/admin'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -27,6 +28,52 @@ const resetDialog = reactive({
password: '',
})
// LLM 配置
const llmCfg = reactive({
base_url: 'https://api.deepseek.com/v1',
api_key: '',
model: 'deepseek-chat',
has_api_key: false,
saving: false,
loading: false,
})
async function loadLLMConfig() {
llmCfg.loading = true
try {
const cfg = await getLLMConfig()
llmCfg.base_url = cfg.base_url
llmCfg.api_key = cfg.api_key || ''
llmCfg.model = cfg.model
llmCfg.has_api_key = cfg.has_api_key
} catch {
// ignore
} finally {
llmCfg.loading = false
}
}
async function submitLLMConfig() {
if (!llmCfg.base_url.trim()) {
ElMessage.warning('API 地址不能为空')
return
}
llmCfg.saving = true
try {
await saveLLMConfig({
base_url: llmCfg.base_url.trim(),
api_key: llmCfg.api_key.trim(),
model: llmCfg.model.trim(),
})
ElMessage.success('LLM 配置已保存')
await loadLLMConfig()
} catch (e: any) {
ElMessage.error(e?.response?.data?.error || '保存失败')
} finally {
llmCfg.saving = false
}
}
async function reload() {
loading.value = true
try {
@@ -91,7 +138,10 @@ async function remove(u: AdminUser) {
await reload()
}
onMounted(reload)
onMounted(() => {
reload()
loadLLMConfig()
})
</script>
<template>
@@ -145,6 +195,38 @@ onMounted(reload)
</el-table>
</div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>🤖 LLM 配置</span>
<el-tag :type="llmCfg.has_api_key ? 'success' : 'warning'" size="small">
{{ llmCfg.has_api_key ? '已配置' : '未配置' }}
</el-tag>
</div>
</template>
<el-form label-width="100px" v-loading="llmCfg.loading" @submit.prevent>
<el-form-item label="API 地址">
<el-input v-model="llmCfg.base_url" placeholder="https://api.deepseek.com/v1" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="llmCfg.api_key"
type="password"
show-password
placeholder="sk-..."
/>
</el-form-item>
<el-form-item label="模型">
<el-input v-model="llmCfg.model" placeholder="deepseek-chat" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="llmCfg.saving" @click="submitLLMConfig">
保存配置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-dialog v-model="createDialog.visible" title="新建账号" width="420px">
<el-form label-width="80px">
<el-form-item label="用户名">
@@ -197,6 +279,11 @@ onMounted(reload)
align-items: center;
color: var(--el-text-color-regular);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.table-wrapper {
background: var(--el-bg-color);
border-radius: 4px;

View File

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

View File

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

View File

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

View File

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