Compare commits

...

10 Commits

Author SHA1 Message Date
fish
c64def9031 浅色模式导航面板使用浅色背景
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:42:05 +08:00
fish
8bdabc09c6 侧边导航背景色改为 #282828
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:38:12 +08:00
fish
8d4bcb4292 Web 前端新增暗夜模式切换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:35:26 +08:00
fish
d3ec1de275 迁移 psycopg3 并修复 Postgres 18 兼容性问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:29:08 +08:00
fish
961ab8224e scores 主键改用 UUID v7
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 15:02:08 +08:00
fish
220f4acc45 迁移 PostgreSQL 并新增 Python API 服务
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 14:58:01 +08:00
fish
750584e619 新增 Web 浏览端(Go+Vue 报表系统)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 14:34:50 +08:00
fish
bf8f578761 新增主力合约自动选取并补全项目文档
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:34:43 +08:00
fish
b9975d6f91 打分结束后通过 Bark 推送结果
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 23:11:03 +08:00
fish
3039bc97bf 忽略 .claude 本地配置目录
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:57:36 +08:00
55 changed files with 3098 additions and 124 deletions

11
.gitignore vendored
View File

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

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
基于 Docker + Python(tushare) 的中国期货行情分析系统,实现日线数据采集、三层加权打分模型与 Bark 推送通知。运行方式定位为脚本自动化(宿主机 cron/launchd 等定时调用 `docker-compose run`),不规划独立后端服务。详细业务说明见 `使用说明.md`
## 常用命令
```bash
# 不传参 = 按当月 FG 主力自动选合约(轮换规则见 contracts.py:ROLLOVER_RULES)
docker-compose run --rm tushare
# 显式指定合约(注意交易所后缀:.ZCE/.SHF/.DCE,郑商所是 .ZCE 不是 .CZC)
docker-compose run --rm tushare python -m src.main RB2510.SHF
# 用品种代号自动选当月主力(目前只配置了 FG)
docker-compose run --rm tushare python -m src.main --symbol FG
# 修改 tushare/src/ 下任意 .py 后必须重建镜像
docker-compose build tushare
# 查最新打分
sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;"
```
`tushare/.env` 必须存在且含 `TUSHARE_TOKEN=xxx`(已 gitignored)。可选 `BARK_KEY` 覆盖 `notifier.py` 默认 key。
## 关键架构
**单进程串行流水线**:`src.main.main()` 先按命令行参数(显式 `ts_code` 优先,否则 `contracts.active_contract(symbol)` 按当月主力自动选)定下合约,再调 `run()` 顺序执行 `fetcher → storage(candles) → scorer → storage(scores) → notifier`。无后台任务、无队列,每次 CLI 调用处理一个合约一日。
**主力轮换规则**(`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` 文本列。
**SQLite 作为唯一数据面**:`storage.py``candles``scores` 两表都用 `INSERT OR REPLACE`(候选键 `(ts_code, trade_date)`)实现幂等,可反复重跑同一天。`PRAGMA journal_mode=WAL`,提升并发读写。表结构在 `init_db()` 中维护,新增字段需同步该函数。
**Docker 边界**:`docker-compose.yml` 仅把 `./data` 挂为 `/app/data`(数据持久化);`tushare/src/` 是在 Dockerfile 的 `COPY --chown=app:app src ./src` 阶段拷进镜像的,**没有源码挂载**——改完 Python 代码不重建镜像就跑等于跑旧代码。这是重要陷阱。
**Bark 推送**:`notifier.push_bark``requests.get` 走路径形式(`/{key}/{title}/{body}`),所有片段以 `quote(safe='')` URL 编码,失败仅 `print [WARN]` 不抛错。容器内首发请求有时 DNS 慢导致 15s timeout,内置 1 次重试;主机直连通常 <1s。
## 配置/密钥规则
`.gitignore` 排除范围广(见文件):`data/``*.db*``.env*`、CTP 流文件(`*.con`/`*.dat`/`ResultInfo.xml` 等)、`.claude/`、所有日志。新增任何账户、token、行情流文件务必先确认匹配 ignore 规则。
注意 `web/backend/dist/``.gitignore` 中有显式例外:目录本身入库,但内部文件除 `.gitkeep`/`index.html` 外都被忽略——这是为了 `go:embed all:dist` 在本地能编译,真正的 SPA 产物在 Docker 构建期生成。
## Web 模块(报告浏览端)
`./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 `data/futures.db`,web 只读访问。docker-compose 上是新增的 `web` 服务,与 `tushare` 共存不互相依赖。
**架构与边界**:
- 后端 Go(`golang:1.25.8-alpine3.23` 构建,`alpine:3.23` 运行),路由 `chi`,SQLite 驱动 `modernc.org/sqlite`(纯 Go 无 CGO,二进制更小、不需要 gcc)。前端 Vue 3 + Vite + Element Plus + ECharts。
- 单进程同源服务:Vue 产物在 Docker 构建期由 `node` 阶段产出 `dist/`,被 `go:embed all:dist` 嵌入二进制,运行时由 Go 同时服务 `/api/*` 与 SPA 静态文件——不引入 nginx 旁车。
- 双 DB 分离:`futures.db``mode=ro&query_only(true)` 打开,容器挂 `:ro` 双重保险;`auth.db` 由 web 自己 init/写入,落在 `./data/auth.db`(已被 `.gitignore` 覆盖)。
- 鉴权 JWT(HS256, Bearer header),12h 过期,无 sessions 表。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行 ** env 同时存在 `ADMIN_USER`/`ADMIN_PASS`,则用 bcrypt(cost=12) 写一行 admin。一旦 admin 存在,这两个 env 被静默忽略——避免轮换 env 时静默改密。忘记管理员密码的恢复方式:停服 → `sqlite3 data/auth.db "DELETE FROM users WHERE role='admin'"` → 重置 env → 重启。
**修改即重建**:沿用 `tushare` 服务的约定,`web/backend/``web/frontend/` 都通过镜像 COPY 进容器,**没有源码挂载**。改完 Go/Vue 代码不重建镜像就跑等于跑旧代码。重建命令 `docker-compose build web`
**常用命令**:
```bash
# 首启需先在 web/backend/.env 写 ADMIN_USER/ADMIN_PASS/JWT_SECRET (gitignored)
docker-compose up -d web
docker-compose logs -f web # 看 [bootstrap] 日志确认 admin 是否被创建
# 仅重建 web,不影响 tushare
docker-compose build web && docker-compose up -d web
# 本地开发 (后端 + 前端分别起,api 走代理)
cd web/backend && go run ./ # 需要本地 Go 1.25.8;dist/ 目录的占位会被 embed
cd web/frontend && npm install && npm run dev # 默认 5173 端口,/api 代理到 8080
```

View File

@@ -1,9 +1,49 @@
services: services:
postgres:
image: postgres:18.3-alpine3.23
container_name: trade-postgres
environment:
POSTGRES_USER: trade
POSTGRES_PASSWORD: trade
POSTGRES_DB: futures
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U trade -d futures"]
interval: 5s
timeout: 5s
retries: 5
tushare: tushare:
build: ./tushare build: ./tushare
container_name: trade-tushare
env_file: ./tushare/.env env_file: ./tushare/.env
environment: environment:
- DB_PATH=/app/data/futures.db - DATABASE_URL=postgresql://trade:trade@postgres:5432/futures
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
command: ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
web:
build:
context: ./web
dockerfile: backend/Dockerfile
container_name: trade-web
env_file: ./web/backend/.env
environment:
- LISTEN_ADDR=:8080
- DATABASE_URL=postgres://trade:trade@postgres:5432/futures?sslmode=disable
- AUTH_DB_PATH=/app/auth/auth.db
depends_on:
- postgres
ports:
- "8080:8080"
volumes: volumes:
- ./data:/app/data - ./data:/app/auth
command: ["python", "-m", "src.main", "FG2609.ZCE"] restart: unless-stopped
volumes:
pgdata:

View File

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

View File

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

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

@@ -0,0 +1,162 @@
from typing import Optional
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel
from . import contracts, fetcher, notifier, scorer, storage
app = FastAPI(title="期货数据采集与打分服务")
class RunRequest(BaseModel):
ts_code: Optional[str] = None
symbol: str = "FG"
class RunResponse(BaseModel):
ts_code: str
trade_date: str
close: float
oi: float
oi_chg: float
short_term: float
medium_term: float
long_term: float
composite: float
signal: str
@app.on_event("startup")
def startup():
storage.init_db()
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/api/v1/run", response_model=RunResponse)
def run_pipeline(req: RunRequest):
ts_code = req.ts_code or contracts.active_contract(req.symbol)
if not req.ts_code:
print(f"[AUTO] {req.symbol} 当月主力 -> {ts_code}")
df = fetcher.fetch_contract(ts_code)
storage.save_candles(df)
result = scorer.score_daily(df)
storage.save_score(result)
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
push_body = (
f"综合 {result.composite:.1f}\n"
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n"
f"{result.signal}"
)
notifier.push_bark(push_title, push_body)
return RunResponse(
ts_code=result.ts_code,
trade_date=result.trade_date,
close=result.close,
oi=result.oi,
oi_chg=result.oi_chg,
short_term=result.short_term,
medium_term=result.medium_term,
long_term=result.long_term,
composite=result.composite,
signal=result.signal,
)
@app.get("/api/v1/scores")
def list_scores(
ts_code: Optional[str] = Query(None),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
limit: int = Query(200, ge=1, le=500),
):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
q = """SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, created_at FROM scores WHERE 1=1"""
args = []
if ts_code:
q += " AND ts_code = %s"
args.append(ts_code)
if start:
q += " AND trade_date >= %s"
args.append(start)
if end:
q += " AND trade_date <= %s"
args.append(end)
q += " ORDER BY trade_date DESC, id DESC LIMIT %s"
args.append(limit)
cur.execute(q, args)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
return rows
finally:
conn.close()
@app.get("/api/v1/scores/{score_id}")
def get_score(score_id: str):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"""SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
long_term, composite, signal, detail_json, created_at
FROM scores WHERE id = %s""",
(score_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="not found")
cols = [d[0] for d in cur.description]
return dict(zip(cols, row))
finally:
conn.close()
@app.get("/api/v1/contracts")
def list_contracts():
conn = storage._get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT ts_code FROM scores ORDER BY ts_code ASC")
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
@app.get("/api/v1/candles")
def list_candles(
ts_code: str = Query(...),
start: Optional[str] = Query(None),
end: Optional[str] = Query(None),
):
conn = storage._get_conn()
try:
with conn.cursor() as cur:
q = """SELECT ts_code, trade_date,
COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0),
COALESCE(vol, 0), COALESCE(amount, 0),
COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0)
FROM candles WHERE ts_code = %s"""
args = [ts_code]
if start:
q += " AND trade_date >= %s"
args.append(start)
if end:
q += " AND trade_date <= %s"
args.append(end)
q += " ORDER BY trade_date ASC LIMIT 1000"
cur.execute(q, args)
cols = ["ts_code", "trade_date", "open", "high", "low", "close",
"vol", "amount", "oi", "oi_chg", "pre_close"]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()

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

@@ -0,0 +1,31 @@
from datetime import date
from typing import Optional
# 品种主力合约轮换规则。
# 每个品种维护:
# exchange: tushare 合约后缀(交易所)
# active: 当月 -> (主力合约月, 年份偏移)
# 例 FG 12 月用次年 5 月,故 12 -> (5, 1)
ROLLOVER_RULES: dict[str, dict] = {
"FG": {
"exchange": "ZCE",
"active": {
1: (5, 0), 2: (5, 0), 3: (5, 0),
4: (9, 0), 5: (9, 0), 6: (9, 0), 7: (9, 0),
8: (1, 1), 9: (1, 1), 10: (1, 1), 11: (1, 1),
12: (5, 1),
},
},
}
def active_contract(symbol: str, today: Optional[date] = None) -> str:
"""按主力轮换规则,返回当日 ts_code(含交易所后缀)。"""
if symbol not in ROLLOVER_RULES:
raise ValueError(f"未配置 {symbol} 的主力轮换规则,可在 contracts.ROLLOVER_RULES 中追加")
today = today or date.today()
rule = ROLLOVER_RULES[symbol]
contract_month, year_offset = rule["active"][today.month]
year = today.year + year_offset
return f"{symbol}{year % 100:02d}{contract_month:02d}.{rule['exchange']}"

View File

@@ -1,7 +1,7 @@
import argparse import argparse
import sys import sys
from . import fetcher, scorer, storage from . import contracts, fetcher, notifier, scorer, storage
def run(ts_code: str) -> int: def run(ts_code: str) -> int:
@@ -62,14 +62,36 @@ def run(ts_code: str) -> int:
print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%") print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%")
print(f"\n[OK] 数据已持久化到 SQLite") print(f"\n[OK] 数据已持久化到 SQLite")
push_title = f"{result.ts_code.split('.')[0]} {result.trade_date}"
push_body = (
f"综合 {result.composite:.1f}\n"
f"短期 {result.short_term:.1f} | 中期 {result.medium_term:.1f} | 长期 {result.long_term:.1f}\n"
f"{result.signal}"
)
if notifier.push_bark(push_title, push_body):
print("[Bark] 推送成功")
return 0 return 0
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="期货合约三层打分模型") parser = argparse.ArgumentParser(description="期货合约三层打分模型")
parser.add_argument("ts_code", help="合约代码,如 FG2609.ZCE") parser.add_argument(
"ts_code",
nargs="?",
help="合约代码,如 FG2609.ZCE;不传则按 --symbol 当月主力自动选取",
)
parser.add_argument(
"--symbol",
default="FG",
help="品种代号,在未传 ts_code 时按 contracts.ROLLOVER_RULES 选当月主力,默认 FG",
)
args = parser.parse_args() args = parser.parse_args()
return run(args.ts_code)
ts_code = args.ts_code or contracts.active_contract(args.symbol)
if not args.ts_code:
print(f"[AUTO] {args.symbol} 当月主力 -> {ts_code}")
return run(ts_code)
if __name__ == "__main__": if __name__ == "__main__":

28
tushare/src/notifier.py Normal file
View File

@@ -0,0 +1,28 @@
import os
from urllib.parse import quote
import requests
DEFAULT_BARK_KEY = "RvdtHq4py2avatt4AFJn9a"
BARK_BASE_URL = "https://api.day.app"
def push_bark(title: str, body: str, key: str | None = None, timeout: float = 15.0, retries: int = 1) -> bool:
bark_key = key or os.environ.get("BARK_KEY") or DEFAULT_BARK_KEY
url = f"{BARK_BASE_URL}/{bark_key}/{quote(title, safe='')}/{quote(body, safe='')}"
last_err: Exception | None = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, timeout=timeout)
except requests.RequestException as e:
last_err = e
continue
if resp.status_code == 200:
return True
print(f"[WARN] Bark 推送返回非 200: {resp.status_code} {resp.text[:120]}")
return False
print(f"[WARN] Bark 推送失败: {last_err}")
return False

View File

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

8
web/.dockerignore Normal file
View File

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

8
web/backend/.env.example Normal file
View File

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

53
web/backend/Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# ==================== Stage 1: 前端构建 ====================
FROM node:20-alpine AS ui
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
COPY frontend ./
RUN npm run build
# ==================== Stage 2: Go 构建 ====================
FROM golang:1.25.8-alpine3.23 AS api
WORKDIR /src
# 国内可选:RUN go env -w GOPROXY=https://goproxy.cn,direct
COPY backend ./
COPY --from=ui /ui/dist ./dist
# 用 modernc.org/sqlite 纯 Go 驱动,无 CGO,无需 gcc/musl-dev
ENV CGO_ENABLED=0 GOOS=linux
RUN go mod tidy && \
go build -trimpath -ldflags="-s -w" -o /out/web ./
# ==================== Stage 3: 运行时 ====================
FROM alpine:3.23
RUN apk add --no-cache tzdata ca-certificates && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata && \
adduser -D -u 1000 app && \
mkdir -p /app/data /app/auth && \
chown -R app:app /app
WORKDIR /app
USER app
COPY --from=api --chown=app:app /out/web /app/web
ENV TZ=Asia/Shanghai \
LISTEN_ADDR=:8080 \
AUTH_DB_PATH=/app/auth/auth.db
EXPOSE 8080
CMD ["/app/web"]

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,32 @@
package auth
import (
"log"
"trade/web/internal/store"
)
// Bootstrap 在 auth.db 没有任何 admin 时,从 ADMIN_USER/ADMIN_PASS 写入一条管理员;
// 已存在 admin 时静默跳过,避免轮换 env 时静默改密。
func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error {
n, err := s.CountAdmins()
if err != nil {
return err
}
if n > 0 {
return nil
}
if adminUser == "" || adminPass == "" {
log.Printf("[bootstrap] auth.db 无 admin,但 ADMIN_USER/ADMIN_PASS 未设置,跳过引导")
return nil
}
hash, err := HashPassword(adminPass)
if err != nil {
return err
}
if _, err := s.CreateUser(adminUser, hash, store.RoleAdmin); err != nil {
return err
}
log.Printf("[bootstrap] admin %q created", adminUser)
return nil
}

View File

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

View File

@@ -0,0 +1,42 @@
package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
ListenAddr string
DatabaseURL string
AuthDBPath string
JWTSecret []byte
AdminUser string
AdminPass string
}
func Load() (*Config, error) {
cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
AdminPass: os.Getenv("ADMIN_PASS"),
}
if cfg.DatabaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL 环境变量未设置")
}
secret := strings.TrimSpace(os.Getenv("JWT_SECRET"))
if len(secret) < 16 {
return nil, fmt.Errorf("JWT_SECRET 必须至少 16 个字符 (建议 openssl rand -hex 32)")
}
cfg.JWTSecret = []byte(secret)
return cfg, nil
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"trade/web/internal/auth"
"trade/web/internal/middleware"
"trade/web/internal/store"
)
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResp struct {
Token string `json:"token"`
User publicUserView `json:"user"`
}
type publicUserView struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
func (d *Deps) Login(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid json")
return
}
req.Username = strings.TrimSpace(req.Username)
if req.Username == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "用户名和密码不能为空")
return
}
u, err := d.Auth.GetByUsername(req.Username)
if err != nil || u.Disabled {
// 禁用账户与不存在账户返回同样的错误,避免账户枚举
writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
if !auth.CheckPassword(u.PasswordHash, req.Password) {
writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
token, _, err := d.JWT.Issue(u.ID, u.Username, u.Role)
if err != nil {
writeErr(w, http.StatusInternalServerError, "issue token failed")
return
}
writeJSON(w, http.StatusOK, loginResp{
Token: token,
User: publicUserView{ID: u.ID, Username: u.Username, Role: u.Role},
})
}
func (d *Deps) Logout(w http.ResponseWriter, r *http.Request) {
// JWT 是无状态的,服务端 logout 仅形式化;前端丢弃 token 即可。
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (d *Deps) Me(w http.ResponseWriter, r *http.Request) {
u, ok := middleware.FromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "no user in context")
return
}
full, err := d.Auth.GetByID(u.ID)
if err != nil {
writeErr(w, http.StatusUnauthorized, "user not found")
return
}
writeJSON(w, http.StatusOK, sanitize(full))
}
// sanitize 把内部 User 转成对外视图,剥掉 password_hash。
func sanitize(u *store.User) map[string]any {
return map[string]any{
"id": u.ID,
"username": u.Username,
"role": u.Role,
"disabled": u.Disabled,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
package middleware
import (
"context"
"net/http"
"strings"
"trade/web/internal/auth"
"trade/web/internal/store"
)
type ctxKey string
const userKey ctxKey = "user"
type CtxUser struct {
ID int64
Username string
Role string
}
func FromContext(ctx context.Context) (CtxUser, bool) {
u, ok := ctx.Value(userKey).(CtxUser)
return u, ok
}
// RequireUser 校验 Authorization Bearer JWT,通过后把 CtxUser 写入 context。
// 同时校验数据库里的 disabled 状态,被禁用的账户即使持有 token 也会被拒。
func RequireUser(mgr *auth.Manager, s *store.AuthStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := bearer(r)
if tok == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing token"})
return
}
claims, err := mgr.Parse(tok)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
return
}
u, err := s.GetByID(claims.UserID)
if err != nil || u.Disabled {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "account disabled or removed"})
return
}
ctx := context.WithValue(r.Context(), userKey, CtxUser{
ID: u.ID, Username: u.Username, Role: u.Role,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := FromContext(r.Context())
if !ok || u.Role != store.RoleAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin only"})
return
}
next.ServeHTTP(w, r)
})
}
func bearer(r *http.Request) string {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
return strings.TrimSpace(h[len(p):])
}
return ""
}

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
package store
import (
"database/sql"
"errors"
"fmt"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
type AuthStore struct{ db *sql.DB }
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
const (
RoleAdmin = "admin"
RoleUser = "user"
)
var ErrNotFound = errors.New("user not found")
func OpenAuth(path string) (*AuthStore, error) {
if dir := filepath.Dir(path); dir != "" {
_ = ensureDir(dir)
}
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open auth.db: %w", err)
}
db.SetMaxOpenConns(1) // sqlite write 单连接更稳
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping auth.db: %w", err)
}
s := &AuthStore{db: db}
if err := s.init(); err != nil {
return nil, err
}
return s, nil
}
func (s *AuthStore) Close() error { return s.db.Close() }
func (s *AuthStore) init() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','user')),
disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
`)
return err
}
func (s *AuthStore) CountAdmins() (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&n)
return n, err
}
func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, error) {
now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(
`INSERT INTO users(username, password_hash, role, disabled, created_at, updated_at)
VALUES (?, ?, ?, 0, ?, ?)`,
username, passwordHash, role, now, now,
)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, Username: username, PasswordHash: passwordHash, Role: role,
CreatedAt: now, UpdatedAt: now}, nil
}
func (s *AuthStore) GetByUsername(username string) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
FROM users WHERE username = ?`, username)
return scanUser(row)
}
func (s *AuthStore) GetByID(id int64) (*User, error) {
row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
FROM users WHERE id = ?`, id)
return scanUser(row)
}
func (s *AuthStore) ListUsers() ([]User, error) {
rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
FROM users ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
u, err := scanUserRows(rows)
if err != nil {
return nil, err
}
out = append(out, *u)
}
return out, rows.Err()
}
func (s *AuthStore) UpdatePassword(id int64, hash string) error {
now := time.Now().Format("2006-01-02 15:04:05")
res, err := s.db.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, hash, now, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *AuthStore) SetDisabled(id int64, disabled bool) error {
now := time.Now().Format("2006-01-02 15:04:05")
v := 0
if disabled {
v = 1
}
res, err := s.db.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?`, v, now, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *AuthStore) DeleteUser(id int64) error {
res, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanUser(r rowScanner) (*User, error) {
var u User
var disabled int
if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.Disabled = disabled != 0
return &u, nil
}
func scanUserRows(rows *sql.Rows) (*User, error) { return scanUser(rows) }

View File

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
const auth = useAuthStore()
const theme = useThemeStore()
const router = useRouter()
const route = useRoute()
const showLayout = computed(() => route.meta.layout !== 'blank' && !!auth.token)
const menuColors = computed(() =>
theme.isDark
? { bg: '#282828', text: '#cfd8e3', active: '#ffffff' }
: { bg: '#f9fafb', text: '#1f2937', active: '#0f172a' },
)
function logout() {
auth.logout()
router.replace('/login')
}
</script>
<template>
<el-container v-if="showLayout" class="app">
<el-aside width="220px" class="aside" :class="{ 'aside-light': !theme.isDark }">
<div class="brand">期货报告</div>
<el-menu
:default-active="route.path"
router
:background-color="menuColors.bg"
:text-color="menuColors.text"
:active-text-color="menuColors.active"
>
<el-menu-item index="/scores">打分列表</el-menu-item>
<el-menu-item index="/chart">K 线 / 持仓</el-menu-item>
<el-menu-item v-if="auth.isAdmin" index="/admin/users">用户管理</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="user">
<span>{{ auth.user?.username }}</span>
<el-tag size="small" :type="auth.isAdmin ? 'danger' : 'info'">
{{ auth.isAdmin ? '管理员' : '普通用户' }}
</el-tag>
</div>
<div class="right">
<el-switch
v-model="theme.isDark"
inline-prompt
active-text=""
inactive-text=""
style="--el-switch-on-color: #2c3e50"
/>
<el-button type="primary" link @click="logout">退出登录</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
<router-view v-else />
</template>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
body {
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
}
.app {
height: 100%;
}
.aside {
background: #282828;
color: #cfd8e3;
}
.aside-light {
background: #f9fafb;
color: #1f2937;
border-right: 1px solid var(--el-border-color-light);
}
.brand {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
letter-spacing: 2px;
border-bottom: 1px solid #3a3a3a;
}
.aside-light .brand {
border-bottom: 1px solid #e5e7eb;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color-light);
}
.user {
display: flex;
align-items: center;
gap: 10px;
}
.right {
display: flex;
align-items: center;
gap: 16px;
}
.el-menu {
border-right: none !important;
}
</style>

View File

@@ -0,0 +1,19 @@
import client from './client'
import type { AuthUser } from '@/stores/auth'
export interface LoginResp {
token: string
user: AuthUser
}
export function login(username: string, password: string) {
return client.post<LoginResp>('/login', { username, password }).then((r) => r.data)
}
export function logout() {
return client.post('/logout').then((r) => r.data)
}
export function me() {
return client.get<AuthUser>('/me').then((r) => r.data)
}

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import client from './client'
export interface ShortDetail {
trade_date: string
close: number
pre_close: number
oi: number
oi_chg: number
score: number
}
export interface MediumDetail {
price_return_pct: number
price_signal: number
long_up_days: number
long_down_days: number
fund_signal: number
}
export interface LongDetail {
avg_oi: number
oi_before: number
change_pct: number
}
export interface ScoreDetail {
short_details?: ShortDetail[]
medium_detail?: MediumDetail
long_detail?: LongDetail
}
export interface Score {
id: number
ts_code: string
trade_date: string
close: number
oi: number
oi_chg: number
short_term: number
medium_term: number
long_term: number
composite: number
signal: string
detail?: ScoreDetail
created_at: string
}
export interface ScoreListParams {
ts_code?: string
start?: string
end?: string
limit?: number
}
export function listScores(params: ScoreListParams = {}) {
return client.get<Score[]>('/scores', { params }).then((r) => r.data)
}
export function getScore(id: number) {
return client.get<Score>(`/scores/${id}`).then((r) => r.data)
}
export function listContracts() {
return client.get<string[]>('/contracts').then((r) => r.data)
}

View File

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

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import type { Candle } from '@/api/candles'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{ data: Candle[] }>()
const theme = useThemeStore()
const containerRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null
function ensureChart() {
if (!containerRef.value) return
if (chart) {
chart.dispose()
chart = null
}
chart = echarts.init(containerRef.value, theme.isDark ? 'dark' : undefined)
}
function render() {
if (!chart) return
const dates = props.data.map((c) => c.trade_date)
// ECharts K 线顺序: [open, close, low, high]
const ohlc = props.data.map((c) => [c.open, c.close, c.low, c.high])
const oi = props.data.map((c) => c.oi)
chart.setOption(
{
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
legend: { data: ['K 线', '持仓量'], top: 0 },
grid: [
{ left: 60, right: 40, top: 40, height: '60%' },
{ left: 60, right: 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 },
],
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' },
},
],
},
true,
)
}
function resize() {
chart?.resize()
}
onMounted(() => {
ensureChart()
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.data, render, { deep: true })
watch(
() => theme.isDark,
() => {
ensureChart()
render()
},
)
</script>
<template>
<div ref="containerRef" class="chart"></div>
</template>
<style scoped>
.chart {
width: 100%;
height: 560px;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getScore, type Score } from '@/api/scores'
const props = defineProps<{ scoreId: number | null }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const score = ref<Score | null>(null)
const loading = ref(false)
const visible = computed({
get: () => props.scoreId !== null,
set: (v) => {
if (!v) emit('close')
},
})
watch(
() => props.scoreId,
async (id) => {
if (id === null) {
score.value = null
return
}
loading.value = true
try {
score.value = await getScore(id)
} finally {
loading.value = false
}
},
)
</script>
<template>
<el-drawer v-model="visible" title="打分明细" size="640px" destroy-on-close>
<div v-loading="loading" v-if="score">
<el-descriptions :column="2" border>
<el-descriptions-item label="合约">{{ score.ts_code }}</el-descriptions-item>
<el-descriptions-item label="日期">{{ score.trade_date }}</el-descriptions-item>
<el-descriptions-item label="收盘">{{ score.close }}</el-descriptions-item>
<el-descriptions-item label="持仓">{{ score.oi }}</el-descriptions-item>
<el-descriptions-item label="综合">
<strong>{{ score.composite.toFixed(2) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="信号">
<el-tag>{{ score.signal }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="短期(7d × 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="2">
{{ score.long_term.toFixed(2) }}
</el-descriptions-item>
</el-descriptions>
<h4 class="section">短期 7 日逐日打分</h4>
<el-table :data="score.detail?.short_details ?? []" size="small" border>
<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>
<h4 class="section">中期(15d)细节</h4>
<el-descriptions :column="2" border v-if="score.detail?.medium_detail">
<el-descriptions-item label="价格收益率">
{{ (score.detail.medium_detail.price_return_pct * 100).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>
<el-descriptions-item label="增仓下跌日">
{{ score.detail.medium_detail.long_down_days }}
</el-descriptions-item>
<el-descriptions-item label="资金意愿分" :span="2">
{{ score.detail.medium_detail.fund_signal }}
</el-descriptions-item>
</el-descriptions>
<h4 class="section">长期(30d)细节</h4>
<el-descriptions :column="2" border v-if="score.detail?.long_detail">
<el-descriptions-item label="30 日均持仓">
{{ score.detail.long_detail.avg_oi.toFixed(0) }}
</el-descriptions-item>
<el-descriptions-item label="30 日前持仓">
{{ score.detail.long_detail.oi_before.toFixed(0) }}
</el-descriptions-item>
<el-descriptions-item label="变化幅度" :span="2">
{{ (score.detail.long_detail.change_pct * 100).toFixed(2) }}%
</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
</template>
<style scoped>
.section {
margin: 18px 0 8px;
}
</style>

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listContracts } from '@/api/scores'
import { listCandles, type Candle } from '@/api/candles'
import KLineChart from '@/components/KLineChart.vue'
const filter = reactive<{ ts_code: string; range: [string, string] | [] }>({
ts_code: '',
range: [],
})
const contracts = ref<string[]>([])
const candles = ref<Candle[]>([])
const loading = ref(false)
async function reload() {
if (!filter.ts_code) {
ElMessage.warning('请选择合约')
return
}
loading.value = true
try {
const [start, end] = filter.range || []
candles.value = await listCandles(filter.ts_code, start, end)
} finally {
loading.value = false
}
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
if (contracts.value.length > 0) {
filter.ts_code = contracts.value[0]
await reload()
}
})
</script>
<template>
<div class="page">
<el-card shadow="never" class="filter-card">
<el-form :inline="true">
<el-form-item label="合约">
<el-select
v-model="filter.ts_code"
placeholder="选择合约"
filterable
style="width: 200px"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="filter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="reload">刷新</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="chart-card" v-loading="loading">
<KLineChart :data="candles" />
</el-card>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.chart-card :deep(.el-card__body) {
padding: 8px;
}
</style>

View File

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

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { listContracts, listScores, type Score } from '@/api/scores'
import ScoreDetailDrawer from '@/components/ScoreDetailDrawer.vue'
const filter = reactive<{ ts_code?: string; range: [string, string] | []; limit: number }>({
ts_code: undefined,
range: [],
limit: 200,
})
const contracts = ref<string[]>([])
const rows = ref<Score[]>([])
const loading = ref(false)
const drawerScoreId = ref<number | null>(null)
async function reload() {
loading.value = true
try {
const [start, end] = filter.range || []
rows.value = await listScores({
ts_code: filter.ts_code,
start: start || undefined,
end: end || undefined,
limit: filter.limit,
})
} finally {
loading.value = false
}
}
function signalTagType(s: string) {
if (s.includes('强烈看多')) return 'success'
if (s.includes('偏多')) return ''
if (s.includes('偏空')) return 'warning'
if (s.includes('强烈看空')) return 'danger'
return 'info'
}
onMounted(async () => {
contracts.value = await listContracts().catch(() => [])
await reload()
})
</script>
<template>
<div class="page">
<el-card shadow="never" class="filter-card">
<el-form :inline="true">
<el-form-item label="合约">
<el-select
v-model="filter.ts_code"
placeholder="全部合约"
clearable
filterable
style="width: 200px"
>
<el-option v-for="c in contracts" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="filter.range"
type="daterange"
value-format="YYYYMMDD"
range-separator=""
start-placeholder=""
end-placeholder=""
/>
</el-form-item>
<el-form-item label="条数">
<el-input-number v-model="filter.limit" :min="10" :max="500" :step="50" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="reload">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="rows" v-loading="loading" stripe class="score-table">
<el-table-column prop="trade_date" label="日期" width="100" />
<el-table-column prop="ts_code" label="合约" width="140" />
<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>
<ScoreDetailDrawer
:score-id="drawerScoreId"
@close="drawerScoreId = null"
/>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-card :deep(.el-card__body) {
padding: 12px 16px;
}
.score-table {
background: var(--el-bg-color);
}
</style>

View File

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

View File

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

View File

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

View File

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