重置项目

This commit is contained in:
2026-07-04 15:59:20 +08:00
parent 374e587f2d
commit 648a8b7f1c
224 changed files with 19700 additions and 9547 deletions
-15
View File
@@ -1,15 +0,0 @@
# 数据库配置
DB_USER=stock
DB_PASSWORD=stock
DB_NAME=stock
# JWT 密钥(生产环境请务必修改)
JWT_SECRET=change-me-in-production
JWT_EXPIRATION_HOURS=168
# 日志级别
RUST_LOG=info
# 端口
BACKEND_PORT=3019
FRONTEND_PORT=3018
+1 -108
View File
@@ -1,108 +1 @@
# 用户体系
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户。
## 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Go 1.25 + Gin + GORM + JWT + bcrypt |
| 数据库 | PostgreSQL 18 |
| 前端 | React 18 + Vite + Tailwind CSS |
| 部署 | Docker Compose |
## 快速启动
```bash
# 1. 复制环境变量
cp .env.example .env
# 2. 启动服务(首次会编译 Go 后端,可能需要几分钟)
docker compose up -d
# 3. 等待数据库健康检查通过后,访问前端
open http://localhost:3018
```
## 默认端口
| 服务 | 端口 |
|---|---|
| 前端 | 3018 |
| 后端 API | 3019 |
| PostgreSQL | 5432 |
## 默认系统管理员
系统启动时会自动创建一个系统管理员账号(仅当不存在时):
| 用户名 | 密码 |
|---|---|
| `system_admin` | `system_admin` |
整个系统只能有一个系统管理员账号,无法通过管理后台再创建或提升其他系统管理员。
## 角色说明
| 角色 | 权限 |
|---|---|
| 系统管理员 | 管理管理员、用户、系统配置 |
| 管理员 | 管理普通用户 |
| 用户 | 访问业务功能、修改个人资料 |
## 主要 API
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| POST | /api/auth/login | 登录 | 公开 |
| GET | /api/auth/me | 当前用户 | 需登录 |
| POST | /api/auth/logout | 登出 | 需登录 |
| GET | /api/admin/users | 用户列表 | admin / system_admin |
| POST | /api/admin/users | 创建用户 | admin / system_admin |
| PUT | /api/admin/users/:id | 更新用户/角色 | admin / system_admin |
| DELETE | /api/admin/users/:id | 删除用户 | system_admin |
| GET | /api/admin/roles | 角色列表 | admin / system_admin |
| POST | /api/admin/data-sync/init-stocks | 初始化全部上市股票 | admin / system_admin |
| POST | /api/admin/data-sync/stocks/:exchange | 按交易所同步股票(SSE/SZSE/BSE | admin / system_admin |
## 目录结构
```
stock/
├── backend/ # Go 后端
│ ├── cmd/api/main.go
│ ├── internal/
│ │ ├── config/
│ │ ├── db/
│ │ ├── handlers/
│ │ ├── middleware/
│ │ ├── models/
│ │ └── routes/
│ ├── go.mod
│ └── Dockerfile
├── frontend/ # React 前端
│ ├── src/
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
## 开发
```bash
# 后端本地运行(需先启动 PostgreSQL)
cd backend
go run ./cmd/api
# 前端本地运行
cd frontend
npm install
npm run dev
```
## 注意事项
- 生产环境请务必修改 `JWT_SECRET`
- 首次启动时 GORM 会自动创建表并插入默认角色。
# README
-29
View File
@@ -1,29 +0,0 @@
# 多阶段构建:先编译 Go 后端,再使用最小运行时镜像
ARG GO_IMAGE=golang:1.25.8-alpine3.23
FROM ${GO_IMAGE} AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/stock-user-system ./cmd/api
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata
ENV TZ=Asia/Shanghai
WORKDIR /app
COPY --from=builder /app/stock-user-system /app/stock-user-system
ENV DATABASE_URL=""
ENV JWT_SECRET=""
ENV JWT_EXPIRATION_HOURS="168"
ENV PORT="3019"
ENV GIN_MODE="release"
EXPOSE 3019
CMD ["/app/stock-user-system"]
-50
View File
@@ -1,50 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"stock-user-system/internal/config"
"stock-user-system/internal/db"
"stock-user-system/internal/models"
"stock-user-system/internal/routes"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
database, err := db.Init(cfg.DatabaseURL)
if err != nil {
log.Fatalf("init database: %v", err)
}
if err := models.AutoMigrate(database); err != nil {
log.Fatalf("migrate database: %v", err)
}
if err := models.AutoMigrateStocks(database); err != nil {
log.Fatalf("migrate stocks table: %v", err)
}
if err := models.SeedRoles(database); err != nil {
log.Fatalf("seed roles: %v", err)
}
if err := models.SeedSystemAdmin(database); err != nil {
log.Fatalf("seed system admin: %v", err)
}
r := routes.Setup(cfg, database)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
log.Printf("backend listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server error: %v", err)
os.Exit(1)
}
}
-49
View File
@@ -1,49 +0,0 @@
module stock-user-system
go 1.25
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.31.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-119
View File
@@ -1,119 +0,0 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
-64
View File
@@ -1,64 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
JWTSecret string
JWTExpirationHours int
Port string
AllowedOrigins []string
}
func Load() (*Config, error) {
_ = godotenv.Load()
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL must be set")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "change-me-in-production"
fmt.Fprintln(os.Stderr, "WARNING: JWT_SECRET not set, using default secret")
}
expHours, _ := strconv.Atoi(os.Getenv("JWT_EXPIRATION_HOURS"))
if expHours == 0 {
expHours = 168 // 7 days
}
port := os.Getenv("PORT")
if port == "" {
port = "3019"
}
allowedOrigins := []string{"*"}
if v := os.Getenv("ALLOWED_ORIGINS"); v != "" {
allowedOrigins = strings.Split(v, ",")
for i := range allowedOrigins {
allowedOrigins[i] = strings.TrimSpace(allowedOrigins[i])
}
}
return &Config{
DatabaseURL: databaseURL,
JWTSecret: jwtSecret,
JWTExpirationHours: expHours,
Port: port,
AllowedOrigins: allowedOrigins,
}, nil
}
func (c *Config) JWTExpiration() time.Duration {
return time.Duration(c.JWTExpirationHours) * time.Hour
}
-233
View File
@@ -1,233 +0,0 @@
package datasource
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const (
tushareBaseURL = "http://api.tushare.pro"
tushareToken = "76efd8465f9f2591aa42a385268e06acf6b80b7a15be2267ad2281b7"
defaultTimeout = 60 * time.Second
maxRetries = 3
retryBaseDelay = 1 * time.Second
)
// StockBasic 表示股票基础信息。
type StockBasic struct {
TsCode string
Symbol string
Name string
Area string
Industry string
Fullname string
Enname string
Cnspell string
Market string
Exchange string
CurrType string
ListStatus string
ListDate string
DelistDate string
IsHs string
ActName string
ActEntType string
}
const stockBasicFields = "ts_code,symbol,name,area,industry,fullname,enname,cnspell,market,exchange,curr_type,list_status,list_date,delist_date,is_hs,act_name,act_ent_type"
// Client 封装 Tushare Pro HTTP API 调用。
type Client struct {
token string
baseURL string
client *http.Client
}
// NewClient 创建 Tushare 客户端。
func NewClient() *Client {
return &Client{
token: tushareToken,
baseURL: tushareBaseURL,
client: &http.Client{Timeout: defaultTimeout},
}
}
func parseStockBasic(row []any, col map[string]int) StockBasic {
return StockBasic{
TsCode: stringAt(row, col["ts_code"]),
Symbol: stringAt(row, col["symbol"]),
Name: stringAt(row, col["name"]),
Area: stringAt(row, col["area"]),
Industry: stringAt(row, col["industry"]),
Fullname: stringAt(row, col["fullname"]),
Enname: stringAt(row, col["enname"]),
Cnspell: stringAt(row, col["cnspell"]),
Market: stringAt(row, col["market"]),
Exchange: stringAt(row, col["exchange"]),
CurrType: stringAt(row, col["curr_type"]),
ListStatus: stringAt(row, col["list_status"]),
ListDate: stringAt(row, col["list_date"]),
DelistDate: stringAt(row, col["delist_date"]),
IsHs: stringAt(row, col["is_hs"]),
ActName: stringAt(row, col["act_name"]),
ActEntType: stringAt(row, col["act_ent_type"]),
}
}
// ListStocks 通过 stock_basic 接口获取全部上市股票基础信息。
func (c *Client) ListStocks() ([]StockBasic, error) {
params := map[string]any{
"list_status": "L",
"fields": stockBasicFields,
}
return c.listStockBasic(params)
}
// ListStocksByExchange 按交易所获取股票基础信息。
func (c *Client) ListStocksByExchange(exchange string) ([]StockBasic, error) {
params := map[string]any{
"exchange": exchange,
"fields": stockBasicFields,
}
return c.listStockBasic(params)
}
func (c *Client) listStockBasic(params map[string]any) ([]StockBasic, error) {
fields, items, err := c.call("stock_basic", params)
if err != nil {
return nil, fmt.Errorf("stock_basic: %w", err)
}
col := buildColumnMap(fields)
stocks := make([]StockBasic, 0, len(items))
for _, row := range items {
code := stringAt(row, col["ts_code"])
if code == "" {
continue
}
stocks = append(stocks, parseStockBasic(row, col))
}
return stocks, nil
}
// call 调用 Tushare Pro API,返回字段名与数据行。
func (c *Client) call(apiName string, params map[string]any) ([]string, [][]any, error) {
reqBody := map[string]any{
"api_name": apiName,
"token": c.token,
"params": params,
"fields": "",
}
data, err := json.Marshal(reqBody)
if err != nil {
return nil, nil, err
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(retryBaseDelay * time.Duration(1<<(attempt-1)))
}
req, err := http.NewRequest("POST", c.baseURL, bytes.NewReader(data))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
lastErr = err
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if resp.StatusCode >= 500 || resp.StatusCode == 429 {
lastErr = fmt.Errorf("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody))
continue
}
if resp.StatusCode >= 400 {
return nil, nil, fmt.Errorf("tushare %s returned %d: %s", apiName, resp.StatusCode, string(respBody))
}
var wrapper struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *struct {
Fields []string `json:"fields"`
Items [][]any `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &wrapper); err != nil {
return nil, nil, fmt.Errorf("decode tushare response: %w", err)
}
if wrapper.Code != 0 {
return nil, nil, fmt.Errorf("tushare %s error %d: %s", apiName, wrapper.Code, wrapper.Msg)
}
if wrapper.Data == nil {
return nil, nil, nil
}
return wrapper.Data.Fields, wrapper.Data.Items, nil
}
if lastErr != nil {
return nil, nil, fmt.Errorf("tushare %s failed after %d retries: %w", apiName, maxRetries, lastErr)
}
return nil, nil, fmt.Errorf("tushare %s request failed", apiName)
}
func buildColumnMap(fields []string) map[string]int {
m := make(map[string]int, len(fields))
for i, f := range fields {
m[f] = i
}
return m
}
func stringAt(row []any, idx int) string {
if idx < 0 || idx >= len(row) || row[idx] == nil {
return ""
}
switch v := row[idx].(type) {
case string:
return v
case []byte:
return string(v)
default:
return fmt.Sprintf("%v", v)
}
}
func floatAt(row []any, idx int) float64 {
if idx < 0 || idx >= len(row) || row[idx] == nil {
return 0
}
switch v := row[idx].(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case string:
f, _ := strconv.ParseFloat(v, 64)
return f
default:
f, _ := strconv.ParseFloat(fmt.Sprintf("%v", v), 64)
return f
}
}
-27
View File
@@ -1,27 +0,0 @@
package db
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Init(databaseURL string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
return db, nil
}
-242
View File
@@ -1,242 +0,0 @@
package handlers
import (
"net/http"
"strings"
"stock-user-system/internal/config"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AdminHandler struct {
DB *gorm.DB
CFG *config.Config
}
func allowedRolesForCreation(actor models.RoleName) []models.RoleName {
switch actor {
case models.RoleSystemAdmin:
return []models.RoleName{models.RoleAdmin, models.RoleUser}
case models.RoleAdmin:
return []models.RoleName{models.RoleUser}
default:
return nil
}
}
func containsRole(roles []models.RoleName, target models.RoleName) bool {
for _, r := range roles {
if r == target {
return true
}
}
return false
}
func (h *AdminHandler) ListUsers(c *gin.Context) {
var users []models.User
if err := h.DB.Preload("Role").Order("created_at DESC").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
result := make([]models.PublicUserInfo, 0, len(users))
for _, u := range users {
result = append(result, u.ToPublicInfo())
}
c.JSON(http.StatusOK, result)
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
var req struct {
Username string `json:"username" binding:"required"`
Email *string `json:"email"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
targetRole := models.RoleUser
if req.Role != "" {
parsed, err := models.ParseRoleName(req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
return
}
targetRole = parsed
}
if !containsRole(allowedRolesForCreation(current.Role), targetRole) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
if err := validateUsername(req.Username); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
if len(req.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
return
}
var role models.Role
if err := h.DB.Where("name = ?", targetRole.String()).First(&role).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
return
}
hash, err := hashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var email *string
if req.Email != nil && *req.Email != "" {
e := strings.ToLower(strings.TrimSpace(*req.Email))
email = &e
}
user := models.User{
Username: username,
Email: email,
PasswordHash: hash,
RoleID: role.ID,
Status: "active",
}
if err := h.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
h.DB.Preload("Role").First(&user, "id = ?", user.ID)
c.JSON(http.StatusOK, user.ToPublicInfo())
}
func (h *AdminHandler) UpdateUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
userID := c.Param("id")
var req struct {
Email *string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
var target models.User
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
if !current.Role.CanManage(target.Role.NameEnum()) && current.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
updates := map[string]interface{}{}
if req.Email != nil && *req.Email != "" {
updates["email"] = strings.ToLower(strings.TrimSpace(*req.Email))
}
if req.Status != "" {
if target.Role.NameEnum() == models.RoleSystemAdmin && req.Status != "active" {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能禁用系统管理员账号"})
return
}
updates["status"] = req.Status
}
if req.Role != "" {
newRole, err := models.ParseRoleName(req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
return
}
if target.Role.NameEnum() == models.RoleSystemAdmin && newRole != models.RoleSystemAdmin {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能修改系统管理员账号的角色"})
return
}
if !containsRole(allowedRolesForCreation(current.Role), newRole) || !current.Role.CanManage(newRole) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
var role models.Role
if err := h.DB.Where("name = ?", newRole.String()).First(&role).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
return
}
updates["role_id"] = role.ID
}
if err := h.DB.Model(&target).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
h.DB.Preload("Role").First(&target, "id = ?", userID)
c.JSON(http.StatusOK, target.ToPublicInfo())
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
userID := c.Param("id")
if current.ID == userID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能删除自己"})
return
}
var target models.User
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
if !current.Role.CanManage(target.Role.NameEnum()) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
if target.Role.NameEnum() == models.RoleSystemAdmin {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "不能删除系统管理员账号"})
return
}
if err := h.DB.Delete(&target).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户已删除"})
}
func (h *AdminHandler) ListRoles(c *gin.Context) {
var roles []models.Role
if err := h.DB.Order("id").Find(&roles).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, roles)
}
-97
View File
@@ -1,97 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"stock-user-system/internal/config"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AuthHandler struct {
DB *gorm.DB
CFG *config.Config
}
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var user models.User
if err := h.DB.Preload("Role").Where("LOWER(username) = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
if user.Status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "账号已被禁用"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
token, err := middleware.GenerateToken(user.ID, user.Role.NameEnum(), h.CFG)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user.ToPublicInfo(),
})
}
func (h *AuthHandler) Me(c *gin.Context) {
current, ok := middleware.GetCurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
return
}
var user models.User
if err := h.DB.Preload("Role").Where("id = ?", current.ID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
c.JSON(http.StatusOK, user.ToPublicInfo())
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "登出成功"})
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func validateUsername(username string) error {
if len(username) < 3 || len(username) > 32 {
return fmt.Errorf("用户名长度需在 3-32 位之间")
}
for _, r := range username {
if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && r != '_' && r != '-' {
return fmt.Errorf("用户名只能包含字母、数字、下划线和短横线")
}
}
return nil
}
-137
View File
@@ -1,137 +0,0 @@
package handlers
import (
"net/http"
"strings"
"stock-user-system/internal/datasource"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var validExchanges = map[string]string{
"SSE": "上交所",
"SZSE": "深交所",
"BSE": "北交所",
}
func toModelStock(s datasource.StockBasic) models.Stock {
return models.Stock{
TsCode: s.TsCode,
Symbol: s.Symbol,
Name: s.Name,
Area: s.Area,
Industry: s.Industry,
Fullname: s.Fullname,
Enname: s.Enname,
Cnspell: s.Cnspell,
Market: s.Market,
Exchange: s.Exchange,
CurrType: s.CurrType,
ListStatus: s.ListStatus,
ListDate: s.ListDate,
DelistDate: s.DelistDate,
IsHs: s.IsHs,
ActName: s.ActName,
ActEntType: s.ActEntType,
}
}
// DataSyncHandler 处理数据同步相关接口。
type DataSyncHandler struct {
DB *gorm.DB
client *datasource.Client
}
// NewDataSyncHandler 创建数据同步处理器。
func NewDataSyncHandler(db *gorm.DB) *DataSyncHandler {
return &DataSyncHandler{
DB: db,
client: datasource.NewClient(),
}
}
type initStocksResponse struct {
Count int `json:"count"`
Message string `json:"message"`
}
// InitStocks 从 Tushare 拉取全部上市股票列表并写入数据库。
func (h *DataSyncHandler) InitStocks(c *gin.Context) {
stocks, err := h.client.ListStocks()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
records := make([]models.Stock, 0, len(stocks))
for _, s := range stocks {
records = append(records, toModelStock(s))
}
if err := h.DB.Exec("TRUNCATE TABLE stocks RESTART IDENTITY").Error; err != nil {
if !strings.Contains(err.Error(), "does not exist") {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "清空旧数据失败: " + err.Error()})
return
}
}
if err := h.DB.CreateInBatches(records, 500).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "写入股票列表失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": initStocksResponse{
Count: len(records),
Message: "股票列表初始化完成",
},
})
}
type syncStocksByExchangeResponse struct {
Exchange string `json:"exchange"`
Count int `json:"count"`
Message string `json:"message"`
}
// SyncStocksByExchange 按交易所从 Tushare 同步股票基础信息。
func (h *DataSyncHandler) SyncStocksByExchange(c *gin.Context) {
exchange := strings.ToUpper(strings.TrimSpace(c.Param("exchange")))
if _, ok := validExchanges[exchange]; !ok {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的交易所代码,支持 SSE/SZSE/BSE"})
return
}
stocks, err := h.client.ListStocksByExchange(exchange)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
records := make([]models.Stock, 0, len(stocks))
for _, s := range stocks {
records = append(records, toModelStock(s))
}
if err := h.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "ts_code"}},
UpdateAll: true,
}).CreateInBatches(records, 500).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "写入股票列表失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": syncStocksByExchangeResponse{
Exchange: exchange,
Count: len(records),
Message: validExchanges[exchange] + "股票同步完成",
},
})
}
-131
View File
@@ -1,131 +0,0 @@
package middleware
import (
"net/http"
"strings"
"time"
"stock-user-system/internal/config"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
type CurrentUser struct {
ID string
Username string
Role models.RoleName
}
var publicPaths = map[string]struct{}{
"/api/auth/login": {},
}
func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
token := ""
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
if token != "" {
claims, err := parseToken(token, cfg.JWTSecret)
if err == nil {
var user models.User
if err := db.Preload("Role").Where("id = ? AND status = ?", claims.Subject, "active").First(&user).Error; err == nil {
c.Set("currentUser", CurrentUser{
ID: user.ID,
Username: user.Username,
Role: user.Role.NameEnum(),
})
}
}
}
if _, exists := c.Get("currentUser"); !exists {
if c.Request.Method == "OPTIONS" {
c.Next()
return
}
if _, ok := publicPaths[c.Request.URL.Path]; !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
return
}
}
c.Next()
}
}
type Claims struct {
Subject string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID string, role models.RoleName, cfg *config.Config) (string, error) {
claims := Claims{
Subject: userID,
Role: role.String(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWTExpiration())),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func parseToken(tokenString string, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrSignatureInvalid
}
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if _, exists := c.Get("currentUser"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
c.Abort()
return
}
c.Next()
}
}
func RequireRoles(allowed ...models.RoleName) gin.HandlerFunc {
return func(c *gin.Context) {
val, exists := c.Get("currentUser")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
c.Abort()
return
}
current := val.(CurrentUser)
for _, role := range allowed {
if current.Role == role {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
c.Abort()
}
}
func GetCurrentUser(c *gin.Context) (CurrentUser, bool) {
val, exists := c.Get("currentUser")
if !exists {
return CurrentUser{}, false
}
return val.(CurrentUser), true
}
-36
View File
@@ -1,36 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Stock 存储股票基础信息。
type Stock struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
TsCode string `json:"ts_code" gorm:"size:32;not null;uniqueIndex"`
Symbol string `json:"symbol" gorm:"size:32;not null;index"`
Name string `json:"name" gorm:"size:128"`
Area string `json:"area" gorm:"size:64"`
Industry string `json:"industry" gorm:"size:64"`
Fullname string `json:"fullname" gorm:"size:256"`
Enname string `json:"enname" gorm:"size:256"`
Cnspell string `json:"cnspell" gorm:"size:64"`
Market string `json:"market" gorm:"size:16"`
Exchange string `json:"exchange" gorm:"size:16;index"`
CurrType string `json:"curr_type" gorm:"size:16"`
ListStatus string `json:"list_status" gorm:"size:8"`
ListDate string `json:"list_date" gorm:"size:16"`
DelistDate string `json:"delist_date" gorm:"size:16"`
IsHs string `json:"is_hs" gorm:"size:8"`
ActName string `json:"act_name" gorm:"size:128"`
ActEntType string `json:"act_ent_type" gorm:"size:64"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AutoMigrateStocks 迁移股票基础信息表。
func AutoMigrateStocks(db *gorm.DB) error {
return db.AutoMigrate(&Stock{})
}
-151
View File
@@ -1,151 +0,0 @@
package models
import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type RoleName string
const (
RoleSystemAdmin RoleName = "system_admin"
RoleAdmin RoleName = "admin"
RoleUser RoleName = "user"
)
func (r RoleName) String() string { return string(r) }
func (r RoleName) Rank() int {
switch r {
case RoleSystemAdmin:
return 3
case RoleAdmin:
return 2
case RoleUser:
return 1
default:
return 0
}
}
func (r RoleName) CanManage(target RoleName) bool {
return r.Rank() > target.Rank()
}
func ParseRoleName(s string) (RoleName, error) {
switch s {
case "system_admin":
return RoleSystemAdmin, nil
case "admin":
return RoleAdmin, nil
case "user":
return RoleUser, nil
default:
return "", fmt.Errorf("unknown role: %s", s)
}
}
type Role struct {
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;size:32;not null"`
Description *string `json:"description"`
Permissions string `json:"permissions" gorm:"type:jsonb;default:'[]'"`
CreatedAt time.Time `json:"created_at"`
Users []User `json:"-" gorm:"foreignKey:RoleID"`
}
func (r *Role) NameEnum() RoleName {
role, _ := ParseRoleName(r.Name)
return role
}
type User struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Username string `json:"username" gorm:"uniqueIndex;size:32;not null"`
Email *string `json:"email" gorm:"uniqueIndex;size:128"`
PasswordHash string `json:"-" gorm:"size:255"`
RoleID int32 `json:"role_id" gorm:"not null"`
Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID;references:ID"`
Status string `json:"status" gorm:"size:16;default:active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PublicUserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
Role RoleName `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (u *User) ToPublicInfo() PublicUserInfo {
return PublicUserInfo{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role.NameEnum(),
Status: u.Status,
CreatedAt: u.CreatedAt,
}
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(&Role{}, &User{})
}
func SeedRoles(db *gorm.DB) error {
roles := []Role{
{Name: string(RoleSystemAdmin), Description: strPtr("系统管理员,可管理管理员与系统配置"), Permissions: `["*"]`},
{Name: string(RoleAdmin), Description: strPtr("管理员,可管理普通用户"), Permissions: `["users.read", "users.write", "users.create"]`},
{Name: string(RoleUser), Description: strPtr("普通用户,可访问业务功能"), Permissions: `["dashboard.read", "profile.write"]`},
}
for _, role := range roles {
var existing Role
if err := db.Where("name = ?", role.Name).First(&existing).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if err := db.Create(&role).Error; err != nil {
return err
}
} else {
return err
}
}
}
return nil
}
// SeedSystemAdmin 在没有 system_admin 用户时创建默认系统管理员账号。
func SeedSystemAdmin(db *gorm.DB) error {
var existing User
if err := db.Where("username = ?", "system_admin").First(&existing).Error; err == nil {
return nil
} else if err != gorm.ErrRecordNotFound {
return err
}
var role Role
if err := db.Where("name = ?", RoleSystemAdmin).First(&role).Error; err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte("system_admin"), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.Create(&User{
Username: "system_admin",
PasswordHash: string(hash),
RoleID: role.ID,
Status: "active",
}).Error
}
func strPtr(s string) *string {
return &s
}
-59
View File
@@ -1,59 +0,0 @@
package routes
import (
"stock-user-system/internal/config"
"stock-user-system/internal/handlers"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
authHandler := &handlers.AuthHandler{DB: db, CFG: cfg}
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
dataSyncHandler := handlers.NewDataSyncHandler(db)
r := gin.Default()
// CORS
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
r.Use(middleware.AuthMiddleware(cfg, db))
// 公开接口(仅登录)
r.POST("/api/auth/login", authHandler.Login)
// 受保护接口
auth := r.Group("/api/auth")
auth.Use(middleware.RequireAuth())
{
auth.GET("/me", authHandler.Me)
auth.POST("/logout", authHandler.Logout)
}
// 管理员接口
admin := r.Group("/api/admin")
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
{
admin.GET("/users", adminHandler.ListUsers)
admin.POST("/users", adminHandler.CreateUser)
admin.PUT("/users/:id", adminHandler.UpdateUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
admin.GET("/roles", adminHandler.ListRoles)
admin.POST("/data-sync/init-stocks", dataSyncHandler.InitStocks)
admin.POST("/data-sync/stocks/:exchange", dataSyncHandler.SyncStocksByExchange)
}
return r
}
-63
View File
@@ -1,63 +0,0 @@
services:
db:
image: postgres:18.3-alpine3.23
container_name: stock_db
environment:
POSTGRES_USER: ${DB_USER:-stock}
POSTGRES_PASSWORD: ${DB_PASSWORD:-stock}
POSTGRES_DB: ${DB_NAME:-stock}
TZ: Asia/Shanghai
volumes:
- postgres_data:/var/lib/postgresql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-stock} -d ${DB_NAME:-stock}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- stock_network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: stock_backend
environment:
DATABASE_URL: postgres://${DB_USER:-stock}:${DB_PASSWORD:-stock}@db:5432/${DB_NAME:-stock}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
GIN_MODE: ${GIN_MODE:-release}
PORT: 3019
TZ: Asia/Shanghai
ports:
- "3019:3019"
depends_on:
db:
condition: service_healthy
networks:
- stock_network
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: stock_frontend
environment:
TZ: Asia/Shanghai
ports:
- "3018:80"
depends_on:
- backend
networks:
- stock_network
restart: unless-stopped
volumes:
postgres_data:
networks:
stock_network:
driver: bridge
-20
View File
@@ -1,20 +0,0 @@
# 前端构建
ARG NODE_IMAGE=node:20-alpine
FROM ${NODE_IMAGE} AS builder
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm install
COPY . ./
RUN npm run build
# 使用 nginx 提供静态资源
FROM nginx:1.29.0-alpine
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-14
View File
@@ -1,14 +0,0 @@
<!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" />
<meta name="theme-color" content="#0A0A0B" />
<title>A股工具</title>
</head>
<body class="bg-base text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3019;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"name": "stock-user-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.439.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#8B5CF6"/>
<text x="50" y="68" font-family="system-ui, sans-serif" font-size="52" font-weight="bold" fill="white" text-anchor="middle">A</text>
</svg>

Before

Width:  |  Height:  |  Size: 263 B

-48
View File
@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { clearAuth, RoleName } from '@/lib/auth'
interface AuthGuardProps {
children: React.ReactNode
allowedRoles?: RoleName[]
requireAuth?: boolean
}
export function AuthGuard({ children, allowedRoles, requireAuth = true }: AuthGuardProps) {
const location = useLocation()
const [user, setUser] = useState<UserInfo | null | undefined>(undefined)
useEffect(() => {
const token = localStorage.getItem('access_token')
if (!token) {
setUser(null)
return
}
api.me()
.then(setUser)
.catch(() => {
clearAuth()
setUser(null)
})
}, [location.pathname])
if (user === undefined) {
return (
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
if (requireAuth && !user) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />
}
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
@@ -1,178 +0,0 @@
import { useState } from 'react'
import { X, UserPlus, AlertCircle, Loader2 } from 'lucide-react'
import { api } from '@/lib/api'
import { RoleName, roleLabel } from '@/lib/auth'
import { cn } from '@/lib/cn'
interface CreateUserDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
allowedRoles: RoleName[]
}
export function CreateUserDialog({ open, onClose, onSuccess, allowedRoles }: CreateUserDialogProps) {
const [form, setForm] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
role: (allowedRoles[0] || 'user') as RoleName,
})
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
if (!open) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!form.username.trim()) {
setError('请输入用户名')
return
}
if (form.password.length < 6) {
setError('密码长度至少 6 位')
return
}
if (form.password !== form.confirmPassword) {
setError('两次输入的密码不一致')
return
}
if (!allowedRoles.includes(form.role as RoleName)) {
setError('没有权限分配该角色')
return
}
setLoading(true)
try {
await api.createUser({
username: form.username.trim(),
email: form.email.trim() || undefined,
password: form.password,
role: form.role,
})
setForm({ username: '', email: '', password: '', confirmPassword: '', role: (allowedRoles[0] || 'user') as RoleName })
onSuccess()
} catch (err: any) {
setError(err?.message || '创建失败')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative w-full max-w-md rounded-card border border-border bg-surface p-6 shadow-xl">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-btn bg-accent/10 text-accent">
<UserPlus className="h-4 w-4" />
</div>
<h2 className="text-base font-semibold text-foreground"></h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-btn text-secondary hover:bg-elevated hover:text-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger mb-4">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-secondary mb-1"></label>
<input
required
placeholder="用户名"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-xs text-secondary mb-1"></label>
<input
type="email"
placeholder="邮箱"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-secondary mb-1"></label>
<input
required
type="password"
placeholder="密码"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
<div>
<label className="block text-xs text-secondary mb-1"></label>
<input
required
type="password"
placeholder="确认密码"
value={form.confirmPassword}
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
</div>
<div>
<label className="block text-xs text-secondary mb-1"></label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as RoleName })}
className="w-full rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
{allowedRoles.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-btn text-sm text-secondary hover:bg-elevated hover:text-foreground transition-colors"
>
</button>
<button
type="submit"
disabled={loading}
className={cn(
'inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors',
loading && 'opacity-60',
)}
>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
</form>
</div>
</div>
)
}
-151
View File
@@ -1,151 +0,0 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Users,
User,
Database,
LogOut,
Moon,
Sun,
Menu,
} from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { clearAuth, canManageUsers, roleLabel } from '@/lib/auth'
import { cn } from '@/lib/cn'
import { useTheme } from './ThemeProvider'
const BRAND = '#8B5CF6'
export function Layout() {
const { resolved, setTheme, theme } = useTheme()
const isDark = resolved === 'dark'
const navigate = useNavigate()
const [user, setUser] = useState<UserInfo | null>(null)
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
const token = localStorage.getItem('access_token')
if (!token) {
setUser(null)
return
}
api.me()
.then(setUser)
.catch(() => {
clearAuth()
setUser(null)
})
}, [navigate])
const handleLogout = async () => {
try {
await api.logout()
} catch {}
clearAuth()
navigate('/login', { replace: true })
}
const navItems = [
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
...(user && canManageUsers(user.role) ? [{ to: '/data-sync', label: '数据同步', icon: Database }] : []),
{ to: '/profile', label: '个人中心', icon: User },
]
const toggleTheme = () => {
if (theme === 'system') {
setTheme(isDark ? 'light' : 'dark')
} else {
setTheme(theme === 'dark' ? 'light' : 'dark')
}
}
const sidebar = (
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden w-56 lg:w-60">
<div className="px-5 py-5 border-b border-border shrink-0">
<div className="flex items-center gap-2.5">
<div
className="h-7 w-7 rounded-lg flex items-center justify-center text-white font-bold text-sm"
style={{ background: BRAND }}
>
A
</div>
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
<div></div>
<div></div>
</div>
</div>
<div
className="mt-4 h-px"
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
</NavLink>
))}
</nav>
<div className="border-t border-border px-3 py-3 shrink-0 space-y-2">
{user && (
<div className="px-3 py-2 rounded-btn bg-elevated/50">
<div className="text-sm font-medium text-foreground truncate">{user.username}</div>
<div className="text-[10px] text-secondary">{roleLabel(user.role)}</div>
</div>
)}
<div className="flex items-center gap-1">
<button
onClick={toggleTheme}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-secondary hover:bg-elevated hover:text-foreground transition-colors"
>
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{isDark ? '浅色' : '深色'}
</button>
<button
onClick={handleLogout}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-danger hover:bg-danger/10 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
退
</button>
</div>
</div>
</aside>
)
return (
<div className="h-screen flex flex-col lg:grid lg:grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
<div className="flex items-center gap-2.5">
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
<span className="text-sm font-semibold"></span>
</div>
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
<Menu className="h-5 w-5" />
</button>
</div>
<div className={cn('fixed inset-0 z-40 lg:static lg:block', mobileOpen ? 'block' : 'hidden')}>
<div className="absolute inset-0 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
<div className="relative z-10 h-full max-w-[14rem]">{sidebar}</div>
</div>
<main className="flex-1 min-h-0 overflow-auto scrollbar-gutter-stable p-6">
<Outlet />
</main>
</div>
)
}
-137
View File
@@ -1,137 +0,0 @@
import { useState } from 'react'
import { Shield, Lock, AlertCircle, Loader2, LogIn } from 'lucide-react'
import { api, setToken } from '@/lib/api'
import { setStoredUser } from '@/lib/auth'
interface LoginFormProps {
onSuccess: () => void
}
export function LoginForm({ onSuccess }: LoginFormProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!username.trim() || !password.trim()) {
setError('请输入用户名和密码')
return
}
setLoading(true)
try {
const res = await api.login({ username: username.trim(), password })
setToken(res.token)
setStoredUser(res.user)
onSuccess()
} catch (err: any) {
setError(err?.message || '登录失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex flex-col items-center text-center">
<div
className="rounded-2xl p-4 border border-border"
style={{ background: 'linear-gradient(135deg, #8B5CF622, transparent)' }}
>
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
</div>
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight"></h1>
<p className="mt-2 text-sm text-secondary leading-relaxed">
</p>
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Shield className="h-4 w-4" />
</div>
<input
type="text"
autoComplete="username"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Lock className="h-4 w-4" />
</div>
<input
type="password"
autoComplete="current-password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
{loading ? '处理中…' : '登录'}
</button>
</form>
<div className="mt-5 text-center">
<span className="text-xs text-secondary"></span>
</div>
</div>
</motion.div>
)
}
// 简单内联 motion 组件,避免引入 framer-motion 依赖
const motion = {
div: ({ children, className, ...props }: any) => {
return (
<div
ref={(el) => {
if (el && props.initial) {
el.style.opacity = String(props.initial.opacity ?? 1)
el.style.transform = `translateY(${props.initial.y ?? 0}px)`
requestAnimationFrame(() => {
el.style.transition = `all ${props.transition?.duration ?? 0.3}s ${(props.transition?.ease || [0.16, 1, 0.3, 1]).join(',')}`
el.style.opacity = String(props.animate?.opacity ?? 1)
el.style.transform = `translateY(${props.animate?.y ?? 0}px)`
})
}
}}
className={className}
>
{children}
</div>
)
},
}
-81
View File
@@ -1,81 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react'
export type Theme = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
interface ThemeContextValue {
theme: Theme
resolved: ResolvedTheme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function resolveTheme(theme: Theme): ResolvedTheme {
if (theme !== 'system') return theme
if (typeof window === 'undefined') return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function updateMetaThemeColor(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) return
meta.setAttribute('content', resolved === 'dark' ? '#0A0A0B' : '#FAFAFA')
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
try {
const saved = localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
} catch {}
return 'system'
})
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
useEffect(() => {
const nextResolved = resolveTheme(theme)
setResolved(nextResolved)
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(nextResolved)
try {
localStorage.setItem('theme', theme)
} catch {}
updateMetaThemeColor(nextResolved)
}, [theme])
useEffect(() => {
if (theme !== 'system') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const nextResolved = resolveTheme('system')
setResolved(nextResolved)
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(nextResolved)
updateMetaThemeColor(nextResolved)
}
media.addEventListener('change', handler)
return () => media.removeEventListener('change', handler)
}, [theme])
const setTheme = (next: Theme) => {
setThemeState(next)
}
return (
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider')
}
return ctx
}
-72
View File
@@ -1,72 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 暗色为默认(html.dark) / 亮色用 :root 反转 */
:root {
--base: 0 0% 98%;
--surface: 0 0% 100%;
--elevated: 240 5% 96%;
--border: 240 6% 90%;
--fg-primary: 240 6% 10%;
--fg-secondary: 240 4% 35%;
--fg-muted: 240 5% 65%;
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
html.dark {
--base: 240 5% 5%;
--surface: 240 7% 10%;
--elevated: 240 9% 14%;
--border: 240 5% 22%;
--fg-primary: 0 0% 98%;
--fg-secondary: 240 5% 78%;
--fg-muted: 240 5% 58%;
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.72);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent) / 0.75);
background-clip: content-box;
}
.tabular { font-variant-numeric: tabular-nums; }
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
* { border-color: hsl(var(--border)); }
body {
background: hsl(var(--base));
color: hsl(var(--fg-primary));
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
}
-133
View File
@@ -1,133 +0,0 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
export interface ApiResponse<T> {
success?: boolean
data?: T
error?: string
}
export interface UserInfo {
id: string
username: string
email?: string
role: 'system_admin' | 'admin' | 'user'
status: string
created_at: string
}
export interface Role {
id: number
name: 'system_admin' | 'admin' | 'user'
description?: string
permissions: string[]
created_at: string
}
export interface AuthResponse {
token: string
user: UserInfo
}
export interface InitStocksResponse {
success: boolean
data: {
count: number
message: string
}
}
export interface SyncStocksByExchangeResponse {
success: boolean
data: {
exchange: string
count: number
message: string
}
}
export function getToken(): string | null {
try {
return localStorage.getItem('access_token')
} catch {
return null
}
}
export function setToken(token: string) {
localStorage.setItem('access_token', token)
}
export function removeToken() {
localStorage.removeItem('access_token')
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${API_BASE}${path}`
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(url, {
...options,
headers,
})
let data: any
try {
data = await res.json()
} catch {
data = {}
}
if (!res.ok) {
const msg = data.error || data.message || `HTTP ${res.status}`
throw new Error(msg)
}
return data as T
}
export const api = {
login: (body: { username: string; password: string }) =>
request<AuthResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(body),
}),
me: () => request<UserInfo>('/api/auth/me'),
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
listUsers: () => request<UserInfo[]>('/api/admin/users'),
createUser: (body: { username: string; email?: string; password: string; role?: string }) =>
request<UserInfo>('/api/admin/users', {
method: 'POST',
body: JSON.stringify(body),
}),
updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) =>
request<UserInfo>(`/api/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteUser: (id: string) =>
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
listRoles: () => request<Role[]>('/api/admin/roles'),
initStocks: () =>
request<InitStocksResponse>('/api/admin/data-sync/init-stocks', { method: 'POST' }),
syncStocksByExchange: (exchange: string) =>
request<SyncStocksByExchangeResponse>(`/api/admin/data-sync/stocks/${exchange}`, { method: 'POST' }),
}
-66
View File
@@ -1,66 +0,0 @@
import { UserInfo } from './api'
export function isAuthenticated(): boolean {
try {
return !!localStorage.getItem('access_token')
} catch {
return false
}
}
export function getStoredUser(): UserInfo | null {
try {
const raw = localStorage.getItem('user')
return raw ? (JSON.parse(raw) as UserInfo) : null
} catch {
return null
}
}
export function setStoredUser(user: UserInfo | null) {
if (user) {
localStorage.setItem('user', JSON.stringify(user))
} else {
localStorage.removeItem('user')
}
}
export function clearAuth() {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
}
export type RoleName = 'system_admin' | 'admin' | 'user'
const ROLE_RANK: Record<RoleName, number> = {
system_admin: 3,
admin: 2,
user: 1,
}
export function roleRank(role: RoleName): number {
return ROLE_RANK[role] ?? 0
}
export function roleLabel(role: RoleName): string {
switch (role) {
case 'system_admin':
return '系统管理员'
case 'admin':
return '管理员'
case 'user':
return '用户'
}
}
export function canAccess(role: RoleName, allowed: RoleName[]): boolean {
return allowed.includes(role)
}
export function canManageUsers(role: RoleName): boolean {
return role === 'admin' || role === 'system_admin'
}
export function canDeleteUsers(role: RoleName): boolean {
return role === 'system_admin'
}
-7
View File
@@ -1,7 +0,0 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-14
View File
@@ -1,14 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import { ThemeProvider } from './components/ThemeProvider'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</React.StrictMode>,
)
-135
View File
@@ -1,135 +0,0 @@
import { useState } from 'react'
import { Database, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '@/lib/api'
const EXCHANGES = [
{ code: 'SSE', name: '上交所' },
{ code: 'SZSE', name: '深交所' },
{ code: 'BSE', name: '北交所' },
]
export function DataSync() {
const [loading, setLoading] = useState<string | null>(null)
const [result, setResult] = useState<{ exchange?: string; count: number; message: string } | null>(null)
const [error, setError] = useState('')
const handleInitStocks = async () => {
if (!confirm('确定要从 Tushare 初始化股票列表?这会清空现有股票数据。')) {
return
}
setLoading('init')
setError('')
setResult(null)
try {
const res = await api.initStocks()
setResult(res.data)
} catch (err: any) {
setError(err?.message || '初始化失败')
} finally {
setLoading(null)
}
}
const handleSyncByExchange = async (exchange: string, name: string) => {
if (!confirm(`确定要同步${name}${exchange})股票数据?`)) {
return
}
setLoading(exchange)
setError('')
setResult(null)
try {
const res = await api.syncStocksByExchange(exchange)
setResult(res.data)
} catch (err: any) {
setError(err?.message || '同步失败')
} finally {
setLoading(null)
}
}
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<Database className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-secondary"> Tushare </p>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
{result && (
<div className="flex items-start gap-2 rounded-btn border border-success/30 bg-success/10 px-3 py-2.5 text-xs text-success">
<CheckCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>
{result.message} {result.count}
{result.exchange ? `${result.exchange}` : ''}
</span>
</div>
)}
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="text-xs text-secondary mt-1"> Tushare stock_basic A </p>
</div>
<button
onClick={handleInitStocks}
disabled={loading !== null}
className="inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading === 'init' ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{loading === 'init' ? '同步中...' : '开始同步'}
</button>
</div>
</div>
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div>
<h2 className="text-sm font-semibold text-foreground"></h2>
<p className="text-xs text-secondary mt-1"></p>
</div>
<div className="grid grid-cols-3 gap-3">
{EXCHANGES.map(({ code, name }) => (
<button
key={code}
onClick={() => handleSyncByExchange(code, name)}
disabled={loading !== null}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-btn border border-border bg-surface text-foreground text-sm font-medium hover:bg-accent hover:text-white hover:border-accent disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading === code ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{loading === code ? '同步中...' : `${name} (${code})`}
</button>
))}
</div>
</div>
<div className="rounded-card border border-border bg-surface p-5">
<h2 className="text-sm font-semibold text-foreground mb-3"></h2>
<ul className="space-y-2 text-sm text-secondary">
<li> TUSHARE_TOKEN </li>
<li> stocks </li>
<li> upsert ts_code </li>
<li> </li>
</ul>
</div>
</div>
)
}
-50
View File
@@ -1,50 +0,0 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginForm } from '@/components/LoginForm'
import { isAuthenticated } from '@/lib/auth'
const BRAND = '#8B5CF6'
export function Login() {
const navigate = useNavigate()
useEffect(() => {
if (isAuthenticated()) {
navigate('/', { replace: true })
}
}, [navigate])
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
<span className="text-sm font-semibold tracking-tight">A股工具</span>
</div>
</header>
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
<LoginForm onSuccess={() => navigate('/', { replace: true })} />
</main>
</div>
)
}
-73
View File
@@ -1,73 +0,0 @@
import { useEffect, useState } from 'react'
import { User, Loader2, AlertCircle } from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { roleLabel } from '@/lib/auth'
export function Profile() {
const [user, setUser] = useState<UserInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
api.me()
.then(setUser)
.catch((err: any) => setError(err?.message || '加载失败'))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<User className="h-5 w-5" />
</div>
<h1 className="text-xl font-bold text-foreground"></h1>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.username}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user ? roleLabel(user.role) : '-'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.email || '未设置'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.status === 'active' ? '启用' : '禁用'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user ? new Date(user.created_at).toLocaleString() : '-'}</span>
</div>
</div>
<div className="rounded-card border border-border bg-surface p-5">
<h2 className="text-sm font-semibold text-foreground mb-3"></h2>
<p className="text-sm text-secondary leading-relaxed">
A
</p>
</div>
</div>
)
}
-172
View File
@@ -1,172 +0,0 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, Users as UsersIcon, AlertCircle, Loader2 } from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { canDeleteUsers, getStoredUser, roleLabel, RoleName } from '@/lib/auth'
import { CreateUserDialog } from '@/components/CreateUserDialog'
import { cn } from '@/lib/cn'
const creatableRoles: Record<RoleName, RoleName[]> = {
system_admin: ['admin', 'user'],
admin: ['user'],
user: [],
}
export function Users() {
const [users, setUsers] = useState<UserInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const currentUser = getStoredUser()
const allowedRoles = currentUser ? creatableRoles[currentUser.role] : []
const fetchData = async () => {
setLoading(true)
try {
const u = await api.listUsers()
setUsers(u)
setError('')
} catch (err: any) {
setError(err?.message || '加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
const handleDelete = async (id: string) => {
if (!confirm('确定删除该用户?')) return
try {
await api.deleteUser(id)
fetchData()
} catch (err: any) {
setError(err?.message || '删除失败')
}
}
const handleStatusChange = async (user: UserInfo, status: string) => {
try {
await api.updateUser(user.id, { status })
fetchData()
} catch (err: any) {
setError(err?.message || '更新失败')
}
}
const current = users.find((u) => u.username === currentUser?.username)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<UsersIcon className="h-5 w-5" />
</div>
<h1 className="text-xl font-bold text-foreground"></h1>
</div>
<button
onClick={() => setDialogOpen(true)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<div className="rounded-card border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-elevated/50 text-secondary">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted"></td>
</tr>
) : (
users.map((u) => (
<tr key={u.id} className="hover:bg-elevated/30">
<td className="px-4 py-3 text-foreground">{u.username}</td>
<td className="px-4 py-3">
<span className={cn('text-xs px-2 py-0.5 rounded', roleBadge(u.role))}>
{roleLabel(u.role)}
</span>
</td>
<td className="px-4 py-3">
<select
value={u.status}
onChange={(e) => handleStatusChange(u, e.target.value)}
disabled={u.id === current?.id}
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
>
<option value="active"></option>
<option value="disabled"></option>
</select>
</td>
<td className="px-4 py-3 text-muted text-xs">
{new Date(u.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-right">
{canDeleteUsers(current?.role || 'user') && u.id !== current?.id && (
<button
onClick={() => handleDelete(u.id)}
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<CreateUserDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onSuccess={() => {
setDialogOpen(false)
fetchData()
}}
allowedRoles={allowedRoles}
/>
</div>
)
}
function roleBadge(role: string) {
switch (role) {
case 'system_admin':
return 'bg-accent/10 text-accent'
case 'admin':
return 'bg-purple-500/10 text-purple-400'
case 'user':
return 'bg-bear/10 text-bear'
default:
return 'bg-muted/10 text-muted'
}
}
-43
View File
@@ -1,43 +0,0 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { AuthGuard } from './components/AuthGuard'
import { Login } from './pages/Login'
import { Users } from './pages/Users'
import { Profile } from './pages/Profile'
import { DataSync } from './pages/DataSync'
export const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{
path: '/',
element: (
<AuthGuard requireAuth>
<Layout />
</AuthGuard>
),
children: [
{ index: true, element: <Navigate to="/profile" replace /> },
{
path: 'users',
element: (
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
<Users />
</AuthGuard>
),
},
{
path: 'data-sync',
element: (
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
<DataSync />
</AuthGuard>
),
},
{
path: 'profile',
element: <Profile />,
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
])
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-40
View File
@@ -1,40 +0,0 @@
import type { Config } from 'tailwindcss'
import animate from 'tailwindcss-animate'
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
container: { center: true, padding: '1rem' },
extend: {
colors: {
base: 'hsl(var(--base) / <alpha-value>)',
surface: 'hsl(var(--surface) / <alpha-value>)',
elevated: 'hsl(var(--elevated) / <alpha-value>)',
border: 'hsl(var(--border) / <alpha-value>)',
foreground: 'hsl(var(--fg-primary) / <alpha-value>)',
secondary: 'hsl(var(--fg-secondary) / <alpha-value>)',
muted: 'hsl(var(--fg-muted) / <alpha-value>)',
accent: 'hsl(var(--accent) / <alpha-value>)',
bull: 'hsl(var(--bull) / <alpha-value>)',
bear: 'hsl(var(--bear) / <alpha-value>)',
warning: 'hsl(var(--warning) / <alpha-value>)',
danger: 'hsl(var(--danger) / <alpha-value>)',
},
fontFamily: {
sans: ['Inter', '"HarmonyOS Sans SC"', '"PingFang SC"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
borderRadius: {
card: '8px',
btn: '6px',
input: '4px',
dialog: '12px',
},
transitionTimingFunction: {
smooth: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
},
},
plugins: [animate],
} satisfies Config
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-25
View File
@@ -1,25 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3019',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
})
+25 -25
View File
@@ -1,15 +1,14 @@
# 两阶段构建:前端 dist 拷进后端镜像,单容器运行
# 可选:构建网络无法直连官方源时:
# 1. 传入 --build-arg USE_CN_MIRROR=1 启用国内 npm/pypi 镜像
# 2. 传入 --build-arg PYTHON_IMAGE/NODE_IMAGE 使用 Docker Hub 国内镜像站
# 可选:构建网络无法直连官方源时,传入 --build-arg USE_CN_MIRROR=1 启用国内镜像
ARG USE_CN_MIRROR=1
ARG NPM_REGISTRY=https://registry.npmmirror.com
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PYTHON_IMAGE=python:3.11-slim
ARG NODE_IMAGE=node:20-alpine
# 备用 PyPI 源:主源同步延迟/故障时自动兜底(阿里云与清华互为补充)
ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple
ARG BACKEND_EXTRAS=
# === Stage 1: 前端构建 ===
FROM ${NODE_IMAGE} AS frontend-builder
FROM node:20-alpine AS frontend-builder
ARG USE_CN_MIRROR=1
ARG NPM_REGISTRY=https://registry.npmmirror.com
WORKDIR /build
@@ -26,14 +25,21 @@ COPY frontend/ ./
RUN pnpm build
# === Stage 2: Python 运行时 ===
FROM ${PYTHON_IMAGE} AS runtime
FROM python:3.11-slim AS runtime
ARG USE_CN_MIRROR=1
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PYPI_FALLBACK=https://mirrors.aliyun.com/pypi/simple
ARG BACKEND_EXTRAS=
WORKDIR /app
# 安装 uv(快)
# 安装 uv(快) —— 国内镜像下三重兜底:主源 → 备用源 → 官方源,
# 任一成功即可,避免单一镜像同步延迟/故障导致构建失败。
# uv 发版极频繁,国内镜像同步存在时间窗口,不锁版本且无 fallback 时
# 容易遇到 "from versions: none"(索引解析不到最新版)。
RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
pip install --no-cache-dir uv -i "$PYPI_INDEX"; \
pip install --no-cache-dir uv -i "$PYPI_INDEX" || \
pip install --no-cache-dir uv -i "$PYPI_FALLBACK" || \
pip install --no-cache-dir uv; \
else \
pip install --no-cache-dir uv; \
fi
@@ -41,8 +47,16 @@ RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
# Backend deps
COPY README.md /README.md
COPY backend/pyproject.toml backend/uv.lock* ./
RUN if [ "$USE_CN_MIRROR" = "1" ]; then export UV_DEFAULT_INDEX="$PYPI_INDEX"; fi; \
uv sync --frozen --no-dev || uv sync --no-dev
# uv 原生支持同时挂多个 index(主源 + 备用源),会自动在两源中查找,
# 比逐个重试更稳健 —— 任一源缺包时另一源补位。
RUN if [ "$USE_CN_MIRROR" = "1" ]; then \
export UV_DEFAULT_INDEX="$PYPI_INDEX" UV_EXTRA_INDEX_URL="$PYPI_FALLBACK"; \
fi; \
set -- --no-dev; \
for extra in $BACKEND_EXTRAS; do \
set -- "$@" --extra "$extra"; \
done; \
uv sync --frozen "$@" || uv sync "$@"
# Backend code
# 注意:Docker 里 WORKDIR=/app, 而 config.py 的 _PROJECT_ROOT 是按开发布局
@@ -60,17 +74,3 @@ COPY --from=frontend-builder /build/dist ./static
ENV PYTHONPATH=/app
EXPOSE 3018
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3018"]
# === Stage 3: 测试镜像 ===
# 基于 runtime 追加 dev + backtest 依赖,用于运行 pytest。
# 生产镜像保持 --no-dev,此 stage 仅用于 CI/本地测试。
FROM runtime AS test
ARG USE_CN_MIRROR=1
ARG PYPI_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple
RUN if [ "$USE_CN_MIRROR" = "1" ]; then export UV_DEFAULT_INDEX="$PYPI_INDEX"; fi; \
uv sync --frozen --extra backtest --extra dev || uv sync --extra backtest --extra dev
COPY backend/tests ./tests
CMD ["uv", "run", "pytest", "tests"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 tickflow-stock-panel contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+216 -242
View File
@@ -4,32 +4,51 @@
**自托管、零运维的 A 股「选股 + 监控 + 回测」量化工作台**
**面向个人散户与量化爱好者而生**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
[![Python](https://img.shields.io/badge/Python-≥3.11-blue.svg)](https://www.python.org/)
[![React](https://img.shields.io/badge/React-18-61dafb.svg)](https://react.dev/)
[![Data: TickFlow](https://img.shields.io/badge/Data-TickFlow-00b386.svg)](https://tickflow.org/auth/register?ref=V3KDKGXPEA)
[![Deploy: Docker](https://img.shields.io/badge/Deploy-Docker-2496ed.svg)](./Dockerfile)
🚀 **开箱即用**(单容器 / Free 模式无需 Key) · 能力驱动,适配 Free → Expert 全档位订阅 · 🔌 **自由接入第三方扩展数据**(Tushare、自有量化项目数据等)
**[核心功能](#-核心功能)** · **[快速开始](#-快速开始)** · **[架构](#%EF%B8%8F-架构)** · **[配置](#%EF%B8%8F-配置)** · **[路线图](#-路线图)**
[![GitHub stars](https://img.shields.io/github/stars/shy3130/tickflow-stock-panel?style=social)](https://github.com/shy3130/tickflow-stock-panel/stargazers)
</div>
> **⚠️说明**:目前项目默认接入内置数据源。自有数据源需二次开发修改字段映射即可;后续需求人多的话可能会实现切换数据源功能。
<div align="center">
**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#-配置)** · **[路线图](#-路线图)**
</div>
- 🆓 **开箱即用** — 留空 Key 即进 None 模式,历史日 K 免费体验,**无需付费**
- 🏠 **自托管零运维** — Docker 单容器部署,数据完全掌握在自己手里
- 🔍 **三位一体** — 选股(20 内置策略)+ 实时监控 + 向量化回测,Polars 毫秒级扫描全 A 股
- 🤖 **AI 加持** — 一句话生成策略代码,任意 OpenAI 兼容接口均可接入(留空即关闭)
- 🔌 **自由扩展** — 自有量化项目数据,与内置数据同台分析
- 🇨🇳 **A 股专用** — 盘后自动AI复盘并推送至飞书等;连板梯队、涨停动量、内置ths 概念 / 行业
基于 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 数据源。**明确不做**:不对标同花顺 / 通达信,不内置「AI 荐股 / 涨停预测」。
> ⚠️ 考虑到tickflow数据源没有人气/资金流向等个性化数据,我将开放自有的第三方数据以供大佬们研究使用,包括但不限于当前内置的ths概念/ths行业(后续更新在这里)
> 有更多稳定免费数据源推荐,或者提交建议/意见的大佬可以邮件到 415333856@qq.com,q群 109338242
觉得有用可以点个 Star,蟹蟹 🌹
---
## 🎯 项目定位
让任何**个人散户 / 量化爱好者**,**零运维**地拥有一套**与自己订阅档位严格匹配**的 A 股分析、选股、监控工作台。
**任意接入第三方数据**(Tushare 等),页面可视化自定义配置扩展数据表。
**面向个人散户量化爱好者的 A 股分析工作台**,聚焦「**选股 + 监控 + 回测**」三大场景,LLM能力驱动进行市场分析,掌控市场节奏;让普通投资者也能拥有一套可自定义策略的量化工具。
**项目所需配置**:
---
| 配置项 | 说明 | 是否必填 |
| :--- | :--- | :--- |
| **数据源 API Key** | 数据源凭证,留空启用 Free 模式(无需注册即可体验) | 可选 |
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中),任意 OpenAI 兼容接口,留空关闭 | 可选 |
## 📸 界面预览
<table>
<tr>
@@ -37,30 +56,110 @@
<td width="50%" align="center"><b>策略 Screener</b></td>
</tr>
<tr>
<td width="50%"><img src="./docs/screenshots/dashboard.png" alt="看板页面" title="看板页面"></td>
<td width="50%"><img src="./docs/screenshots/screener.png" alt="策略页" title="策略页"></td>
<td width="50%"><img src="./screenshots/dashboard.png" alt="看板页面"></td>
<td width="50%"><img src="./screenshots/screener.png" alt="策略页"></td>
</tr>
<tr>
<td width="50%" align="center"><b>回测 Backtest</b></td>
<td width="50%" align="center"><b>监控中心 Monitor</b></td>
</tr>
<tr>
<td width="50%"><img src="./docs/screenshots/backtest.png" alt="回测页" title="回测页"></td>
<td width="50%"><img src="./docs/screenshots/monitor.png" alt="监控中心" title="监控中心"></td>
<td width="50%"><img src="./screenshots/backtest.png" alt="回测页"></td>
<td width="50%"><img src="./screenshots/monitor.png" alt="监控中心"></td>
</tr>
<tr>
<td width="50%" align="center"><b>连板梯队 Limit Ladder</b></td>
<td width="50%" align="center"><b>概念分析 Concept</b></td>
<td width="50%" align="center"><b>概念分析 Concept</b></td>
</tr>
<tr>
<td width="50%"><img src="./docs/screenshots/limit-ladder.png" alt="连板梯队页" title="连板梯队页"></td>
<td width="50%"><img src="./docs/screenshots/concept-analysis.png" alt="概念分析" title="概念分析"></td>
<td width="50%"><img src="./screenshots/limit-ladder.png" alt="连板梯队页"></td>
<td width="50%"><img src="./screenshots/concept-analysis.png" alt="概念分析"></td>
</tr>
</table>
> ### ⚠️ 🚧 项目持续优化,功能陆续开放,敬请期待。
<div align="center">
> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。
### 📸 [查看更多界面截图 »](./screenshots/README.md)
</div>
---
## 🚀 快速开始
### 前置依赖
| 工具 | 版本 | 安装 |
| :--------------------------------- | :----- | :------------------------------------------------- |
| Python | ≥ 3.11 | [python.org](https://www.python.org/) |
| Node | ≥ 20 | [nodejs.org](https://nodejs.org/) |
| [`uv`](https://docs.astral.sh/uv/) | latest | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| `pnpm` | 9 | `npm i -g pnpm` |
### 方式 A:Dev 模式(二次开发推荐)
```bash
cp .env.example .env # 按需填 TICKFLOW_API_KEY(留空 = None 模式)
./dev.sh # Windows: .\dev.ps1
```
自动检查 / 下载依赖、释放端口、同时起前后端,Ctrl-C 一并关闭。默认:
- 后端 → <http://localhost:3018> · 前端 → <http://localhost:3011>
- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh`
### 方式 B:Docker(部署最省心)
```bash
cp .env.example .env
docker compose up --build
# 打开 http://localhost:3018
```
<details>
<summary><b>环境适配与高级选项(老 CPU · 手动启动 · 回测依赖)</b></summary>
**老 CPU 兼容(avx2/fma 缺失报错或 exit 132)**:桌面客户端安装包已内置兼容内核(新老 CPU 通吃)。Docker / 源码用户在 `.env` 打开 `BACKEND_EXTRAS=legacy-cpu` 后重建,会给 Polars 切到 `rtcompat` 运行时;需回测则 `BACKEND_EXTRAS=legacy-cpu backtest`
**手动分别启动:**
```bash
# 后端
cd backend && uv sync --extra backtest # 含回测依赖
uv run uvicorn app.main:app --reload --port 3018
# 前端
cd frontend && pnpm install && pnpm dev # http://localhost:3011
```
**回测依赖**:vectorbt → numba 体积较大,作为可选 extras(`uv sync --extra backtest`)。macOS / Intel 无预构建 wheel 时需 `brew install cmake` 现场编译。
</details>
### 🔄 更新代码(已部署用户必读)
拉取新版本只需一条命令:
```bash
git pull
```
**整个 `data/` 目录都不纳入 git**——行情 K线、财务、自选、回测、监控记录,乃至概念/行业扩展数据,全部是程序运行时生成/拉取的用户数据,`git pull` 物理上无法影响它们。新用户首次启动时,概念/行业两份扩展数据会自动从远程接口拉取,无需任何手动操作。
> ⚠️ **切勿使用以下命令"解决冲突"或"清理",它们会一次性删光 `data/` 下所有未被 git 跟踪的数据:**
> - `git clean -fdx`(最危险,会删掉所有 `.gitignore` 忽略的文件)
> - `git reset --hard`
> - 直接删除整个项目文件夹重新 `git clone`
>
> 若 `git pull` 报冲突,通常是本地误改了被跟踪的文件,请先 `git stash` 暂存再 pull,或单独联系作者,不要直接执行上面的命令。
### 🧭 跑起来后的第一次使用
1. **设置 → 凭据与能力** → 点 **重新检测**,确认档位标签
2. **设置****立即跑盘后管道**:拉日 K + 计算 enriched 表(None / Free 走 free-api,当日数据盘后 1-2 小时可用)
3. **自选**页加标的 → **选股**页点策略卡片扫描 / 配自定义信号
4. **回测**页选策略 + 区间 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
5. **监控中心**配规则(策略 / 个股信号 / 价格 / 异动),盘中实时弹窗 + 持久化记录
---
@@ -68,225 +167,87 @@
### 🔍 选股引擎(Screener)
**20+ 个内置策略** —— 每个策略一个独立 Python 文件(`backend/app/strategy/builtin/`),基于 Polars 表达式实现:
**20 个内置策略**,每个策略一个独立 Python 文件,基于 Polars 表达式向量化实现(`backend/app/strategy/builtin/`):
| 类型 | 代表策略 |
| :--- | :--- |
| 趋势 | 趋势突破 · 均线多头 · 缩量回踩 |
| 形态 | MA 金叉 · MACD 金叉放量 · 布林突破 |
| 量价 | 量价齐升 · 高换手强势 · 强势高开 |
| 涨停 | 连板股 · 断板反包 · 逼近涨停 · 涨停动量 |
| 反转 | 超跌反弹 · 超卖反转 · 新低反转 |
| 波动 | 低波动龙头 · 回踩 MA20 反弹 |
| 类型 | 代表策略 |
| :---------- | :------------------------------------------------------- |
| 趋势 / 形态 | 趋势突破 · 均线多头 · MA 金叉 · MACD 金叉放量 · 布林突破 |
| 量价 / 涨停 | 量价齐升 · 高换手强势 · 连板股 · 断板反包 · 涨停动量 |
| 反转 / 波动 | 超跌反弹 · 超卖反转 · 新低反转 · 低波动龙头 · 回踩 MA20 |
- **自定义信号系统** —— 在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,**无需写代码**即可定义自己的买卖信号。
- **策略商店** —— 内置策略 + 用户自定义策略统一管理,支持参数覆盖(`params` 暴露阈值)。
**扩展策略的三种方式:**
#### 添加自己的策略
除 20 个内置策略外,你可以用三种方式扩展:
| 方式 | 说明 | 前提 |
| :--- | :--- | :--- |
| **🤖 AI 生成** | 用自然语言描述策略思路,LLM 读取 [strategy-guide.md](./docs/strategy-guide.md) 自动生成完整 Polars 策略文件(经 `ast` 安全校验,限定 `import polars as pl`)。生成后落入 `data/strategies/ai/`,即刻可用 | 需先在 [配置](#%EF%B8%8F-配置) 中填入 AI Key |
| **📝 代码自定义 / 策略迁移** | 参照 [策略开发指南](./docs/strategy-guide.md) 的文件结构模板,把你**已有的自有策略**改写为 Polars 文件放入 `data/strategies/custom/`(文件名/ID 建议 `custom_时间戳`),引擎自动发现加载——**轻松迁移你现成的量化项目策略**,无需从头重写 | 无 |
| **🎛️ 自定义信号配置** | 不写代码,在 UI 上用 `字段 + 操作符 + 阈值` 组合(entry / exit / both),编译成 Polars 表达式热加载,即可定义自己的买卖信号 | 无 |
> 引擎按 `source` 标记来源:`builtin`(内置)/ `custom`(手写或迁移)/ `ai`(生成),三者统一进入策略商店管理。
| 方式 | 说明 |
| :---------------- | :---------------------------------------------------------------------------------------------------- |
| **🎛️ 自定义信号** | 不写代码,UI 上 `字段 + 操作符 + 阈值` 组合编译成 Polars 表达式热加载 |
| **🤖 AI 生成** | 一句话描述思路,LLM 读 `strategy-guide.md` 生成完整策略文件(经 `ast` 校验)→ 落入 `data/strategies/ai/` |
| **📝 代码迁移** | 参照开发指南把已有策略改写为 Polars 文件放入 `data/strategies/custom/`,引擎自动发现 |
### 📊 指标流水线(Indicators)
原生 Polars 向量化计算,全 A 股一次扫表落盘 enriched Parquet:
原生 Polars 向量化,全 A 股一次扫表落盘 enriched Parquet:
| 分类 | 指标 |
| :--- | :--- |
| 均线系 | MA(5/10/20/30/60)· EMA(5/10/12/20/26/30/60) |
| 趋势系 | MACD(DIF/DEA/HIST)· 动量(5/10/20/30/60d)· 布林带(上/下轨) |
| 震荡系 | RSI(可配周期)· KDJ(K/D/J) |
| 波动系 | ATR(14)· 年化波动率(20d)· 振幅 |
| 量能系 | 量比(5d/10d)· 量均线 |
| 涨跌停 | 涨停信号 · 连板数 · 涨跌幅 · 涨跌额 |
| 原子信号 | MA 金叉/死叉 · MA20 突破/跌破 · MACD 金叉/死叉 · N 日新高/新低 · 布林突破 |
| 复权 | 基于除权因子自动计算前复权(`ex_factor` / `cum_factor`),回测与指标一致 |
- **均线 / 趋势**:MA(5-60)· EMA · MACD · 动量 · 布林带
- **震荡 / 波动**:RSI · KDJ · ATR · 年化波动率 · 振幅
- **量能 / 涨跌停**:量比 · 量均线 · 涨停信号 · 连板数
- **原子信号**:MA / MACD 金叉死叉 · N 日新高新低 · 布林突破
- **复权**:基于除权因子自动前复权,回测与指标口径一致
### 🧪 回测引擎(Backtest)
自研 Polars/NumPy 撮合引擎为主,兼容 vectorbt 作为可选依赖:
- **三种回测模式**:个股 · 策略组合 · 自由信号组合
- **真实约束**:T+1 · 手续费 · 滑点(基点) · 止损 · 最大持仓天数
- **组合管理**:最大持仓数 · 最大敞口 · 等权 / 自定义仓位
- **SSE 流式进度**:长任务实时推送进度,支持刷新 / 切页后**重连恢复**(相同参数任务只启动一次)
- **统计输出**:净值曲线 · 夏普 · 最大回撤 · 胜率 · 每笔交易明细
基于 vectorbt:**三种模式**(个股 / 策略组合 / 自由信号组合),真实约束(T+1 · 手续费 · 滑点 · 止损 · 最大持仓天数),组合管理(最大持仓 · 敞口 · 等权 / 自定义仓位)。SSE 流式进度支持切页重连,输出净值曲线 · 夏普 · 最大回撤 · 胜率 · 交易明细。
### 📡 监控中心(Monitor)
**统一监控规则引擎** —— 一个页面管理所有类型的监控,实时推送 + 持久化触发记录:
统一规则引擎,一个页面管理**四类监控**(策略 · 个股信号 · 价格涨跌 · 全市场异动):
- **四类监控**:策略监控 · 个股信号监控(选信号即加) · 个股价格/涨跌监控 · 全市场异动监控
- **灵活条件**:多条件 AND/OR 组合 + 冷却期去重(防刷屏) + 严重级别(info/warn/critical)
- **多入口配置**:监控中心页面新建规则 · 个股详情页「加监控」· 策略卡片一键开启
- **实时 SSE 推送**:命中规则后右下角弹窗通知(可配声效) + 持久化到 `alerts.jsonl`
- **触发记录**:时间倒序展示,支持按来源过滤 · 单条删除 · 清空 · 点击查看个股日K
- **菜单未读徽标**:离开监控中心后有新触发,菜单显示未读数;进入页面后清零
- 多条件 AND/OR + 冷却期去重 + 严重级别(info/warn/critical)
- 多入口配置:监控中心新建 / 个股详情页「加监控」/ 策略卡片一键开启
- 命中后右下角弹窗(可配声效)+ 持久化到 `alerts.jsonl`,菜单未读徽标
- **触发记录详情**:每条记录展示命中的具体条件(如 `RSI>80`)与当前价位,一眼看清为何触发
- **飞书 Webhook 推送**:全局一处配置飞书群机器人地址,启用推送的规则命中即推送到飞书群(支持签名校验);可在设置页设「默认推送渠道」,新建规则自动预填
### 🤖 AI 策略生成(可选)
### 📈 个股分析(Beta)
- **自然语言 → 策略代码**:用一句话描述策略思路,LLM 读取 `docs/strategy-guide.md` 生成完整 Polars 策略文件
- **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达
- **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能
以「行情 + 关键价位」为主体的单标的决策页:
- **专用日 K 图表**:主图 + 成交量 + 滑块,默认近 6 个月
- **9 类关键价位**(纯函数实时计算,毫秒级):压力支撑 · 成交密集区 · 枢轴点 · 前高前低 · Keltner 通道 · ATR 止损 · 缺口位 · 斐波那契 · 整数关口
- **AI 四维分析**:技术 / 基本面 / 财务 / 消息面流式生成,实战派交易员视角
### 🧰 数据与扩展
- **多源数据**:日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情
- **🔌 第三方数据接入(重点)** —— 内置数据源之外的数据也能用:
- 支持 **Tushare** 等第三方数据源,通过 **HTTP 定时拉取**自动入库
- 支持 **CSV / Excel 上传** · **JSON 写入**,自动 schema 发现与符号归一
- **页面可视化配置**扩展数据表,无需改代码
- 可接入**你自己的量化项目数据**,统一并入 DuckDB 查询面,与内置数据同台分析
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched 表 + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch 上限,批量合并 + 增量拉取,同一份数据多面板复用
---
## 🚀 快速开始
本项目**仅通过 Docker 部署**,无论是本地体验还是服务器部署都使用同一套镜像。
### 前置依赖
- [Docker](https://docs.docker.com/get-docker/)
- Docker Compose(已随 Docker Desktop 自带,Linux 需单独安装)
### 启动
```bash
cp .env.example .env # 按需填写 Key(留空即 Free 模式,可直接体验)
docker compose up --build
# 打开 http://localhost:3018
```
### 运行测试
```bash
# 运行后端全部测试(含回测引擎)
docker compose run --rm test
```
> 测试镜像已包含回测依赖,可直接运行 `backend/tests` 下的全部 pytest 用例。
---
## 🧭 第一次使用
1. 打开面板 → **设置 → 凭据与能力** → 点 **重新检测**,确认 Tier Label
2.**立即跑盘后管道** —— 拉日 K + 计算 enriched 表
- **Free 用户**:只同步内置 DEMO_SYMBOLS(浦发 / 招商 / 茅台等 10 只)
- **Starter+**:同步全 A 或根据数据源能力获取的 instruments 列表
3. **自选**页:添加跟踪标的;点代码进 **K 线**页看蜡烛图 + 买卖点
4. **选股**页:点任一内置策略卡片即时扫描;或用自定义信号组合条件
5. **回测**页:选策略 / 信号 + 时间区间 → 跑回测 → 看净值 / 夏普 / 交易明细(SSE 实时进度)
6. **监控中心**页:配置监控规则(策略/个股信号/价格/市场异动),盘中 SSE 实时弹窗通知 + 持久化触发记录;或在个股详情页点「加监控」快速添加
---
## 🏗️ 架构
### 技术栈
| 层 | 选型 |
| :--- | :--- |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储)· PyArrow |
| **回测** | 自研 Polars/NumPy 撮合引擎 · vectorbt(可选依赖) |
| **数据源** | A 股数据源 SDK(`tickflow[all]`) |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **前端** | React 18 · Vite · TypeScript · Tailwind CSS · Framer Motion · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
### 目录结构
```
backend/app/
├── api/ # FastAPI 路由(选股/回测/监控/数据/设置等)
├── services/ # 业务服务(选股/行情/数据同步/告警存储等)
├── strategy/ # 策略引擎(内置/自定义/AI生成/监控规则)
├── indicators/ # Polars 指标流水线
├── backtest/ # 自研回测引擎
├── tickflow/ # 数据源 SDK 适配层
└── jobs/ # 盘后定时管道任务
frontend/src/
├── pages/ # 页面组件(Dashboard/Screener/Backtest/Monitor 等)
├── components/ # 可复用组件(图表/表格/选股/监控等)
└── lib/ # API 客户端/QueryKey/格式化工具等
data/ # 本地数据目录(Parquet 分区文件)
├── kline_daily/ # 原始日 K
├── kline_daily_enriched/ # 带指标日 K
├── instruments/ # 标的维表
├── financials/ # 财务数据
├── ext_data/ # 用户扩展数据
└── backtest_results/ # 回测结果
```
### 数据流
```
tickflow 数据源
kline_sync / instrument_sync / index_sync / financial_sync
Parquet 分区文件 (data/)
DuckDB 内存视图
Polars 内存缓存
选股 / 回测 / 监控 / 行情服务
FastAPI → React 前端
```
### 档位能力体系
`tiers.yaml` 定义了 Free → Expert 五档能力,启动时自动探测真实可用能力:
| 档位 | 能力 |
| :--- | :--- |
| **none** | 无 Key,仅历史日 K(批量) |
| **free** | 免费有效 Key,能力与 none 等价 |
| **starter** | 实时行情、批量、标的池、除权因子 |
| **pro** | 增加分钟 K、五档盘口 |
| **expert** | 增加财务数据、WebSocket |
UI 会显示友好标签(如「≈ Pro」),未解锁的功能自动灰显。
### 安全
- `/api/*` 路径通过 `auth.py` 中间件校验访问令牌
- 支持 `admin` / `user` 两种角色,管理员令牌可在 `.env` 中配置
- AI 生成策略经 `ast` 安全校验,禁止 `open/exec/eval/os/sys/subprocess`,限定 `import polars as pl`
- **TickFlow 多源数据**:日 K / 分钟 K / 指数 / 财务 / 实时行情
- **🔌 第三方接入(重点)**:Tushare 等 HTTP 定时拉取 · CSV / Excel 上传 · JSON 写入,自动 schema 发现 + 符号归一,页面可视化配置,**可与自有量化项目数据并入 DuckDB 同台分析**
- **盘后定时管道**:APScheduler 15:30 CST 自动拉日 K + 重算 enriched + 跑监控
- **令牌桶限流**:适配各档位 rpm / batch,批量合并 + 增量拉取
---
## ⚙️ 配置
所有配置通过项目根目录 `.env` 文件读取(复制 `.env.example` 开始)。配置也可在面板 **设置**面内修改。
所有配置根目录 `.env` 读取(复制 `.env.example` 开始),也可在面板 **设置** 页修改。
### 数据源
当前默认接入内置数据源提供的订阅制 A 股数据。**留空 `TICKFLOW_API_KEY` 即启用 Free 模式,无需注册即可体验**。
### 数据源:TickFlow
```ini
TICKFLOW_API_KEY= # 留空 = Free 模式;填 Key = 按订阅档位解锁
TICKFLOW_API_KEY= # 留空 = None 模式(历史日K免费);填 Key = 按订阅档位解锁
```
> 系统启动时会自动探测你的真实能力集,UI 显示「≈ Pro」等友好标签。
留空即 None 模式,通过 free-api 使用历史日 K(当日数据盘后 1-2 小时可用);免费注册 Key 后进 Free 模式,开启自选股实时监控。**实时行情按档位**:
### AI(可选):策略生成
| 档位 | 实时能力 |
| :------- | :--------------------------------------- |
| Free | 自选页前 5 个标的实时监控(最低 6 秒刷新) |
| Starter+ | 全市场实时行情 |
| Pro | 分钟 K + 盘口 |
| Expert | WebSocket + 财务数据 |
AI 模块用于「自然语言生成策略代码」。**所有配置留空即跳过 AI 功能,不影响核心使用**。支持任何 **OpenAI 兼容接口**:
> 完整能力矩阵见 [tickflow.org/pricing](https://tickflow.org/pricing/),高等档位含较低档全部权益。
### AI(可选)
用于自然语言生成策略。**所有配置留空即跳过**,不影响核心功能。支持任意 OpenAI 兼容接口:
```ini
AI_PROVIDER=openai_compat # openai_compat | ollama
@@ -296,8 +257,6 @@ AI_MODEL=deepseek-chat
AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限
```
> 切换 `AI_PROVIDER=ollama` 时无需 `AI_API_KEY`,适合本地部署大模型。
### 服务与数据
```ini
@@ -305,50 +264,65 @@ HOST=0.0.0.0 # 监听地址
PORT=3018 # 服务端口
LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
DATA_DIR=./data # Parquet / DuckDB 数据存储目录
ACCESS_UUID= # 访问控制 UUID(可选)
ADMIN_TOKEN=admin # 管理员令牌
```
### 访问密码
面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署有两种方式设首个密码:
1. **环境变量预置(推荐)** — 在 `.env` 填入 `AUTH_PASSWORD`,首次启动自动初始化(哈希后写入 `auth.json`,之后不再读取):
```ini
AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖
```
2. **SSH 端口转发** — 本机执行 `ssh -L 3018:127.0.0.1:3018 用户@服务器IP`,浏览器开 `http://127.0.0.1:3018` 设密码
> 详细步骤与重置密码见 [docs/deploy-password.md](./docs/deploy-password.md)。设完密码后改密码走页面 UI(`设置 → 修改密码`)。
---
## 🏗️ 技术栈
| 层 | 选型 |
| :----------- | :------------------------------------------------------------------------------------------------ |
| **后端** | FastAPI · Pydantic v2 · APScheduler · sse-starlette |
| **数据** | Polars(计算)· DuckDB(查询)· Parquet(存储) |
| **回测** | vectorbt(全项目唯一 pandas 边界) |
| **数据源** | [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 官方 SDK 、其他数据源后续迭代实装 |
| **AI**(可选) | OpenAI 兼容接口(DeepSeek / 通义 / Ollama 等) |
| **前端** | React 18 · Vite · TypeScript · Tailwind · Tanstack Query · Lightweight Charts · ECharts · dnd-kit |
| **部署** | Docker 两阶段构建,前端 dist 拷进后端镜像,**单容器** |
---
## 🗺️ 路线图
| Phase | 内容 | 状态 |
| :--- | :--- | :--- |
| **0** | 仓库骨架 / FastAPI 壳 / Vite + React SPA / Docker 一键起 | ✅ |
| **1** | 能力探测 + Kline 同步 + K 线分析页 | ✅ |
| **2** | Polars enriched 流水线 + Screener + 信号扫描 | ✅ |
| **3** | 自研回测引擎 + T+1 + 手续费 + 止损 + max-hold | ✅ |
| **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ |
| **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
| Phase | 内容 | 状态 |
| :----- | :----------------------------------------------------------------- | :--- |
| 0-1 | 仓库骨架 · FastAPI 壳 · 能力探测 · K 线同步与分析页 | ✅ |
| 2-3 | Polars enriched 流水线 · Screener · vectorbt 回测(T+1/手续费/止损) | ✅ |
| 4-5 | 监控引擎 · 四类监控规则 · 实时 SSE 推送 · 持久化记录 | ✅ |
| 6 | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析) | ✅ |
| **v2** | Webhook 推送(QMT/掘金下单)· 板块异动 · 早晚报 · 更多扩展 | 🚧 |
---
## 📚 文档
## 📚 文档与贡献
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写策略的规范)
- [docs/strategy-example.md](./docs/strategy-example.md) —— 策略示例
- [docs/strategy-builder-step1.md](./docs/strategy-builder-step1.md) / [step2.md](./docs/strategy-builder-step2.md) —— 策略构建步骤
- [docs/strategy-guide.md](./docs/strategy-guide.md) —— 策略开发指南(AI 生成与手写规范)
- [docs/](./docs) —— 策略构建步骤、示例
---
## 🤝 贡献
欢迎 Issue 和 PR。请通过 Docker 进行本地验证:
```bash
# 启动应用
docker compose up --build -d
# 运行测试
docker compose run --rm test
```
新增内置策略:在 `backend/app/strategy/builtin/` 参照现有策略文件,实现 `StrategyDef` 即可被引擎自动发现。
欢迎 Issue 和 PR。新增内置策略:在 `backend/app/strategy/builtin/` 参照现有文件实现 `StrategyDef`,引擎自动发现。
---
## ⚠️ 免责声明
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源官方为准。
本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。
## 📄 License
[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。
## 社区
本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。
+1 -1
View File
@@ -1 +1 @@
v1.0.0
v0.1.64
+3 -3
View File
@@ -1,10 +1,10 @@
"""Stock Panel backend."""
"""TickFlow Stock Panel backend."""
import sys
__version__ = "0.1.44"
__version__ = "0.1.70"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),数据源 SDK 内部输出含 emoji 的
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
# 进程加载最早阶段强制 UTF-8,根治此类编码崩溃。
for _stream in (sys.stdout, sys.stderr):
+7 -40
View File
@@ -9,8 +9,6 @@ from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.services.ext_data import ExtConfigStore
router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
@@ -113,44 +111,13 @@ def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu:
def _default_menus(request: Request) -> list[AnalysisMenu]:
ext_store = ExtConfigStore(_data_dir(request))
menus: list[AnalysisMenu] = []
for cfg in ext_store.load_all():
fields = cfg.fields
concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None)
if concept:
detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"]
detail_columns = []
for name in detail_names:
f = next((x for x in fields if x.name == name), None)
if not f:
continue
is_num = f.dtype in ("int", "float")
detail_columns.append(AnalysisColumn(
field=f.name,
label=f.label or f.name,
type="number" if is_num else "string",
sortable=is_num,
precision=2 if f.dtype == "float" else None,
))
menus.append(AnalysisMenu(
id="concept_analysis",
label="概念分析",
icon="tags",
data_source=cfg.id,
template="dimension_rank",
dimension_field=concept.name,
group_columns=[
AnalysisColumn(field="__dimension", label="概念"),
AnalysisColumn(field="__count", label="股票数", type="number", sortable=True),
],
detail_columns=detail_columns,
default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None,
order=100,
builtin=True,
))
break
return menus
"""自动生成的默认分析菜单。
历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。
现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景,
自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。
"""
return []
@router.get("")
+192 -59
View File
@@ -1,80 +1,213 @@
"""访问门控 API 与管理接口。"""
"""访问认证 API
端点:
GET /api/auth/status — 是否已设密码、当前会话是否有效
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
POST /api/auth/logout — 注销当前会话
POST /api/auth/change-password — 改密码(需已登录)
安全:
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
"""
from __future__ import annotations
from fastapi import APIRouter, Request
import logging
import time
from collections import defaultdict
from threading import Lock
from app import auth
from app import uuid_store
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from app.services import auth
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
COOKIE_NAME = "tf_session"
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
# 限流: { ip: (fail_count, lock_until_ts) }
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
_fail_lock = Lock()
_MAX_FAILS = 5
_LOCK_SECONDS = 300
@router.post("/verify")
def verify_credential(req: auth.VerifyIn) -> auth.VerifyOut:
"""校验管理员令牌或普通 UUID,成功后返回访问令牌及角色。"""
role = auth.verify_credential(req.credential)
if role:
token = auth.create_access_token(role)
return auth.VerifyOut(valid=True, role=role.value, token=token)
return auth.VerifyOut(valid=False, role=None, token=None)
def _is_local_network(host: str | None) -> bool:
"""是否本机或内网请求。
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
请在反代配置正确的 X-Forwarded-For(标准做法)。
"""
if not host:
return False
if host in ("127.0.0.1", "::1", "localhost"):
return True
# 内网网段: 10.x / 172.16-31.x / 192.168.x
if host.startswith("10.") or host.startswith("192.168."):
return True
if host.startswith("172."):
try:
second = int(host.split(".")[1])
if 16 <= second <= 31:
return True
except (IndexError, ValueError):
pass
return False
def _client_ip(request: Request) -> str:
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _check_login_rate_limit(ip: str) -> None:
"""登录失败限流检查, 触发则抛 429。"""
with _fail_lock:
count, until = _fail_counter.get(ip, (0, 0.0))
now = time.time()
if until > now:
wait = int(until - now)
raise HTTPException(
status_code=429,
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
)
def _record_login_fail(ip: str) -> None:
"""记录一次登录失败, 达阈值则锁定。"""
with _fail_lock:
count, until = _fail_counter.get(ip, (0, 0.0))
count += 1
if count >= _MAX_FAILS:
until = time.time() + _LOCK_SECONDS
logger.warning("auth login locked for %s after %d fails", ip, count)
_fail_counter[ip] = (count, until)
def _clear_login_fails(ip: str) -> None:
"""登录成功后清除该 IP 的失败计数。"""
with _fail_lock:
_fail_counter.pop(ip, None)
# ================================================================
# 端点
# ================================================================
class PasswordIn(BaseModel):
password: str = Field(min_length=6, max_length=128)
class LoginIn(BaseModel):
password: str = Field(min_length=1, max_length=128)
class ChangePasswordIn(BaseModel):
old_password: str = Field(min_length=1, max_length=128)
new_password: str = Field(min_length=6, max_length=128)
@router.get("/status")
def auth_status(request: Request) -> auth.AuthStatusOut:
"""返回当前门控状态、当前请求是否通过校验及角色"""
enabled = auth.access_control_enabled()
token = auth.get_access_token_from_request(request)
role = auth.validate_access_token(token)
return auth.AuthStatusOut(
enabled=enabled,
verified=role is not None,
role=role.value if role else None,
)
def auth_status(request: Request) -> dict:
"""认证状态: 是否已设密码 + 当前请求是否已登录"""
token = request.cookies.get(COOKIE_NAME)
return {
"configured": auth.is_configured(),
"authenticated": bool(token and auth.is_valid_session(token)),
}
# ===== 管理员 UUID 管理 =====
@router.post("/setup")
def setup_password(req: PasswordIn, request: Request) -> dict:
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
@admin_router.get("/uuids")
def list_uuids(request: Request) -> list[auth.UuidRecordOut]:
"""列出所有动态 UUID(仅管理员)。"""
auth.require_admin(request)
records = uuid_store.list_uuids()
return [
auth.UuidRecordOut(
uuid=r["uuid"],
label=r.get("label", ""),
enabled=r.get("enabled", True),
created_at=r.get("created_at", 0),
若已设置过密码, 返回 409(改密码走 /change-password)。
"""
# 关键: 限制只有服务器主人(本机/内网)能设密码
client_ip = _client_ip(request)
if not _is_local_network(client_ip):
logger.warning("setup rejected from non-local ip: %s", client_ip)
raise HTTPException(
status_code=403,
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
)
for r in records
]
if auth.is_configured():
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
auth.set_password(req.password)
logger.info("access password set up from %s", client_ip)
return {"ok": True, "configured": True}
@admin_router.post("/uuids")
def create_uuid(req: auth.UuidCreateIn, request: Request) -> auth.UuidRecordOut:
"""创建新的访问 UUID(仅管理员)"""
auth.require_admin(request)
record = uuid_store.create(req.label)
return auth.UuidRecordOut(
uuid=record["uuid"],
label=record["label"],
enabled=record["enabled"],
created_at=record["created_at"],
@router.post("/login")
def login(req: LoginIn, request: Request, response: Response) -> dict:
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流"""
ip = _client_ip(request)
_check_login_rate_limit(ip)
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
token = auth.verify_and_create_session(req.password)
if not token:
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="密码错误")
_clear_login_fails(ip)
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
path="/",
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
)
return {"ok": True, "authenticated": True}
@admin_router.delete("/uuids/{uuid}")
def delete_uuid(uuid: str, request: Request) -> dict:
"""删除访问 UUID(仅管理员)"""
auth.require_admin(request)
ok = uuid_store.delete(uuid)
return {"ok": ok}
@router.post("/logout")
def logout(request: Request, response: Response) -> dict:
"""注销当前会话"""
token = request.cookies.get(COOKIE_NAME)
if token:
auth.revoke_session(token)
response.delete_cookie(key=COOKIE_NAME, path="/")
return {"ok": True}
@admin_router.put("/uuids/{uuid}/toggle")
def toggle_uuid(uuid: str, request: Request, enabled: bool) -> dict:
"""启用/禁用访问 UUID(仅管理员)。"""
auth.require_admin(request)
ok = uuid_store.toggle(uuid, enabled)
return {"ok": ok}
@router.post("/change-password")
def change_password(req: ChangePasswordIn, request: Request) -> dict:
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
token = request.cookies.get(COOKIE_NAME)
if not (token and auth.is_valid_session(token)):
raise HTTPException(status_code=401, detail="请先登录")
if not auth.is_configured():
raise HTTPException(status_code=409, detail="尚未设置访问密码")
# 验证旧密码
new_token = auth.verify_and_create_session(req.old_password)
if not new_token:
ip = _client_ip(request)
_record_login_fail(ip)
raise HTTPException(status_code=401, detail="旧密码错误")
# 临时 token 用完即弃
auth.revoke_session(new_token)
# 改密码(set_password 会清空所有会话)
auth.set_password(req.new_password)
return {"ok": True, "message": "密码已修改, 请重新登录"}
+123 -7
View File
@@ -38,6 +38,9 @@ _table_cache: dict[str, dict | None] = {
"index_daily": None,
"index_enriched": None,
"index_instruments": None,
"etf_daily": None,
"etf_enriched": None,
"etf_instruments": None,
"minute": None,
"adj_factor": None,
"instruments": None,
@@ -262,6 +265,85 @@ def _safe_aggregate_index_instruments(repo) -> dict | None:
}
def _safe_aggregate_etf_instruments(repo) -> dict | None:
"""ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。"""
queries = [
"""SELECT count(*) AS rows,
count(DISTINCT symbol) AS symbols,
count_if(name IS NOT NULL AND name != '') AS named
FROM instruments_etf""",
"""SELECT count(*) AS rows,
count(DISTINCT symbol) AS symbols,
count_if(name IS NOT NULL AND name != '') AS named
FROM instruments_index
WHERE asset_type = 'etf'""",
]
for sql in queries:
try:
row = repo.execute_one(sql)
except Exception as e: # noqa: BLE001
logger.debug("aggregate etf instruments fallback failed: %s", e)
continue
if row and row[0]:
return {
"rows": int(row[0]),
"symbols_covered": int(row[1] or 0),
"latest_as_of": None,
"named": int(row[2] or 0),
}
return None
def _safe_aggregate_etf_enriched(repo) -> dict | None:
"""ETF enriched 统计 — 独立 kline_etf_enriched。"""
fields = 0
try:
cols = repo.execute_all("DESCRIBE kline_etf_enriched")
fields = len(cols)
except Exception: # noqa: BLE001
pass
stats = _safe_aggregate(repo, "kline_etf_enriched")
if not stats:
return None
return {**stats, "fields": fields}
def _safe_aggregate_etf_daily(repo) -> dict | None:
"""ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。"""
queries = [
"""SELECT count(*) AS rows,
min(date) AS earliest,
max(date) AS latest,
count(DISTINCT symbol) AS symbols,
count(DISTINCT date) AS trading_days
FROM kline_etf_daily""",
"""SELECT count(*) AS rows,
min(date) AS earliest,
max(date) AS latest,
count(DISTINCT symbol) AS symbols,
count(DISTINCT date) AS trading_days
FROM kline_index_daily
WHERE symbol IN (
SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf'
)""",
]
for sql in queries:
try:
row = repo.execute_one(sql)
except Exception as e: # noqa: BLE001
logger.debug("aggregate etf daily fallback failed: %s", e)
continue
if row and row[0]:
return {
"rows": int(row[0]),
"earliest_date": str(row[1]) if row[1] else None,
"latest_date": str(row[2]) if row[2] else None,
"symbols_covered": int(row[3] or 0),
"trading_days": int(row[4] or 0),
}
return None
def _safe_aggregate_adj_factor(repo) -> dict | None:
"""adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
try:
@@ -405,6 +487,10 @@ def _compute_storage(data_dir: Path) -> dict:
"index_daily": data_dir / "kline_index_daily",
"index_enriched": data_dir / "kline_index_enriched",
"index_instruments": data_dir / "instruments_index",
"etf_daily": data_dir / "kline_etf_daily",
"etf_enriched": data_dir / "kline_etf_enriched",
"etf_instruments": data_dir / "instruments_etf",
"etf_adj_factor": data_dir / "adj_factor_etf",
"minute": data_dir / "kline_minute",
"adj_factor": data_dir / "adj_factor",
"instruments": data_dir / "instruments",
@@ -507,10 +593,13 @@ def status(request: Request) -> dict:
return {
"daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)),
"enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)),
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
"etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo)),
"etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo)),
"etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_instruments(repo)),
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
"adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)),
"instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)),
"financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)),
@@ -537,8 +626,9 @@ def clear_data(request: Request):
deleted = 0
for sub in (
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute",
"adj_factor", "instruments", "instruments_index", "pools", "financials",
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
"backtest_results", "screener_results", "ai_cache",
):
d = data_dir / sub
@@ -596,10 +686,15 @@ def clear_data(request: Request):
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
"kline_minute": f"{d}/kline_minute/**/*.parquet",
"adj_factor": f"{d}/adj_factor/**/*.parquet",
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
"instruments": f"{d}/instruments/**/*.parquet",
"instruments_index": f"{d}/instruments_index/**/*.parquet",
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
}.items():
try:
repo.db.execute(
@@ -638,6 +733,17 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
"amount": "成交额",
},
"kline_index_enriched": ENRICHED_COLUMNS,
"kline_etf_daily": {
"symbol": "ETF代码",
"date": "交易日期",
"open": "开盘价",
"high": "最高价",
"low": "最低价",
"close": "收盘价",
"volume": "成交量",
"amount": "成交额",
},
"kline_etf_enriched": ENRICHED_COLUMNS,
"kline_minute": {
"symbol": "股票代码",
"datetime": "分钟时间戳",
@@ -675,6 +781,13 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
"code": "指数编码(纯数字)",
"asset_type": "资产类型(index)",
},
"instruments_etf": {
"symbol": "ETF代码",
"name": "ETF名称",
"code": "ETF编码(纯数字)",
"asset_type": "资产类型(etf)",
"source": "数据源",
},
}
# view 名 → DuckDB 视图名
@@ -684,6 +797,9 @@ _SCHEMA_VIEWS: dict[str, str] = {
"index_daily": "kline_index_daily",
"index_enriched": "kline_index_enriched",
"index_instruments": "instruments_index",
"etf_daily": "kline_etf_daily",
"etf_enriched": "kline_etf_enriched",
"etf_instruments": "instruments_etf",
"minute": "kline_minute",
"adj_factor": "adj_factor",
"instruments": "instruments",
@@ -730,7 +846,7 @@ def get_version(request: Request) -> dict:
"""
from app import __version__
# 1. 优先用 app.__version__ (开发期 bump_version.py 写入)
# 1. 优先用 app.__version__ (唯一权威版本, 打包期由 PyInstaller 注入)
if __version__:
v = __version__.strip()
return {"version": v if v.startswith("v") else f"v{v}"}
+45
View File
@@ -325,6 +325,27 @@ def list_configs(request: Request):
return {"items": items}
@router.post("/presets/{config_id}/fetch")
async def fetch_preset_data(request: Request, config_id: str):
"""手动触发内置预设 (概念/行业) 的数据拉取。
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
"""
from app.services.ext_presets import fetch_preset
try:
n = await fetch_preset(config_id, _data_dir(request))
except ValueError as e:
raise HTTPException(404, str(e)) from e
except Exception as e:
raise HTTPException(400, f"拉取失败: {e}") from e
_refresh_views(request)
return {"status": "ok", "rows": n}
@router.post("")
def create_config(request: Request, body: CreateExtReq):
"""创建扩展数据配置。"""
@@ -554,6 +575,13 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
# 刷新调度器
pull_scheduler.refresh(_data_dir(request))
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
if not config.pull.enabled:
cleared = store.get(config_id)
if cleared and cleared.pull and cleared.pull.next_run:
cleared.pull.next_run = None
store.upsert(cleared)
return {"status": "ok", "pull": config.pull.to_dict()}
@@ -609,8 +637,25 @@ async def run_pull(request: Request, config_id: str):
try:
n, d = await fetch_and_ingest(config, _data_dir(request))
_refresh_views(request)
# 写回执行状态, 让前端"上次执行"面板立即反映
updated = store.get(config_id)
if updated and updated.pull:
from datetime import datetime, timezone
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
updated.pull.last_status = "success"
updated.pull.last_message = f"{n} rows @ {d}"
updated.pull.last_rows = n
store.upsert(updated)
return {"status": "ok", "rows": n, "date": d}
except Exception as e:
# 失败也写回状态, 记录错误信息
failed = store.get(config_id)
if failed and failed.pull:
from datetime import datetime, timezone
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
failed.pull.last_status = "error"
failed.pull.last_message = str(e)[:200]
store.upsert(failed)
raise HTTPException(400, f"拉取失败: {e}") from e
+92 -2
View File
@@ -5,8 +5,12 @@ import logging
import polars as pl
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services.financial_sync import get_financial_df
from app.services.financial_analyzer import analyze_financials_stream
from app.services import ai_reports
from app.tickflow.capabilities import Cap
logger = logging.getLogger(__name__)
@@ -109,7 +113,12 @@ def get_cash_flow(request: Request, symbol: str | None = None):
@router.post("/sync/{table}")
def sync_table(request: Request, table: str):
"""手动触发同步。table: metrics / income / balance_sheet / cash_flow / all"""
"""手动触发同步(立即返回,后台异步执行)。
table: metrics / income / balance_sheet / cash_flow / all
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
前端通过轮询 GET /status 的 syncing 字段观察进度。
"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
@@ -122,6 +131,87 @@ def sync_table(request: Request, table: str):
return {"status": "error", "message": "FinancialScheduler not available"}
target = None if table == "all" else table
result = fs.run_now(target)
result = fs.trigger(target)
return {"status": "ok", "synced": result}
class AnalyzeRequest(BaseModel):
"""AI 财务分析请求。"""
symbol: str
focus: str = "" # 可选:用户追加的分析关注点
@router.post("/analyze")
async def analyze_financials(request: Request, req: AnalyzeRequest):
"""AI 财务分析 — SSE 流式返回。
后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM →
逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream,
以便前端用 ReadableStream 逐行解析,更简单可靠)。
"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
if not req.symbol:
raise HTTPException(400, "symbol 不能为空")
data_dir = request.app.state.repo.store.data_dir
async def stream_gen():
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# AI 报告 CRUD(历史报告持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 财务分析报告。"""
symbol: str
name: str = ""
focus: str = ""
content: str
periods: int | None = None
summary: str = ""
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
capset = request.app.state.capabilities
if not capset.has(Cap.FINANCIAL):
return {"reports": []}
return {"reports": ai_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
report = ai_reports.save_report({
"symbol": req.symbol,
"name": req.name,
"focus": req.focus,
"content": req.content,
"periods": req.periods,
"summary": req.summary,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
capset = request.app.state.capabilities
capset.require(Cap.FINANCIAL)
ok = ai_reports.delete_report(report_id)
return {"ok": ok}
+1 -1
View File
@@ -94,7 +94,7 @@ def get_index_daily(
try:
raw = kline_sync.sync_daily_batch([symbol], count=days + 150)
except Exception as e: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
if raw.is_empty():
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
+12 -1
View File
@@ -98,7 +98,7 @@ def index_quotes(
request: Request,
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
):
"""返回实时指数行情缓存,不触发数据源请求。"""
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
qs = _get_quote_service(request)
if not qs:
@@ -136,6 +136,9 @@ async def quote_stream(request: Request):
"depth": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
),
"review": asyncio.ensure_future(
asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5)
),
}
done, pending = await asyncio.wait(
@@ -160,6 +163,14 @@ async def quote_stream(request: Request):
}, ensure_ascii=False),
}
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
for evt_json in qs.pop_review_events():
yield {
"event": "review_progress",
"data": evt_json,
}
# 推送行情更新 (行情信号触发)
if tasks["quote"] in done:
try:
+15 -6
View File
@@ -7,7 +7,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from app.indicators.pipeline import compute_enriched_single
from app.indicators.pipeline import compute_enriched, compute_enriched_single
from app.services import kline_sync
logger = logging.getLogger(__name__)
@@ -123,10 +123,19 @@ def get_daily(
try:
raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
except Exception as e:
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
if raw.is_empty():
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
enriched = compute_enriched_single(raw)
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
factors = pl.DataFrame()
capset = getattr(request.app.state, "capabilities", None)
try:
from app.tickflow.capabilities import Cap
if capset and capset.has(Cap.ADJ_FACTOR):
factors = kline_sync.fetch_adj_factor_single(symbol)
except Exception as e: # noqa: BLE001
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
enriched = compute_enriched(raw, factors=factors)
rows = enriched.tail(days).to_dicts()
# 即使 live 模式也尝试追加实时蜡烛
rows = _maybe_inject_live_candle(request, symbol, rows)
@@ -328,7 +337,7 @@ def get_minute(
"""读取某只股票某天的分钟 K 线。
- 本地有完整数据(240条) → 直接返回
- 本地无数据或不完整 → 从数据源实时拉取返回(不写入)
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
"""
repo = request.app.state.repo
stock_info = _get_stock_info(repo, symbol)
@@ -337,7 +346,7 @@ def get_minute(
if trade_date is None:
trade_date = repo.latest_minute_date(symbol)
if trade_date is None:
# 本地无任何分钟K,尝试从数据源拉取当天
# 本地无任何分钟K,尝试从 TickFlow 拉取当天
trade_date = date.today()
df = kline_sync.fetch_minute_single(symbol, trade_date)
return {
@@ -373,7 +382,7 @@ def get_minute(
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
}
# 本地不完整或无数据 → 从数据源实时拉取
# 本地不完整或无数据 → 从 TickFlow 实时拉取
live_df = kline_sync.fetch_minute_single(symbol, trade_date)
return {
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
+115
View File
@@ -0,0 +1,115 @@
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
路由前缀: /api/market-recap
端点:
POST /analyze AI 流式大盘复盘(NDJSON)
GET /reports 历史复盘列表
POST /reports 保存一条复盘报告
DELETE /reports/{report_id} 删除一条复盘报告
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import market_recap_reports
from app.services.market_recap import recap_market_stream
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/market-recap", tags=["market-recap"])
class AnalyzeRequest(BaseModel):
"""AI 大盘复盘请求。"""
as_of: str | None = None # 可选:复盘日期(YYYY-MM-DD),缺省取最新有数据日
focus: str = "" # 可选:用户追加的复盘关注点
@router.post("/analyze")
async def analyze_market(request: Request, req: AnalyzeRequest):
"""AI 大盘复盘 — NDJSON 流式返回。
装配市场总览(指数/涨跌/连板/封板/板块/情绪雷达)→ 复盘提示词 →
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
协议:
{"type":"meta","as_of","emotion_score","emotion_label","summary"}
{"type":"delta","content":"..."}
{"type":"error","message":"..."}
{"type":"done"}
"""
from datetime import date as date_cls
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
as_of = None
if req.as_of:
try:
as_of = date_cls.fromisoformat(req.as_of)
except ValueError:
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen():
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# 报告 CRUD(历史复盘持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 大盘复盘报告。"""
as_of: str
focus: str = ""
content: str
summary: str = ""
emotion_score: int | None = None
emotion_label: str = ""
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
return {"reports": market_recap_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条复盘报告。"""
report = market_recap_reports.save_report({
"as_of": req.as_of,
"focus": req.focus,
"content": req.content,
"summary": req.summary,
"emotion_score": req.emotion_score,
"emotion_label": req.emotion_label,
})
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
# 内部 try/except 静默降级, 不影响归档返回值。
from app.jobs.daily_pipeline import _maybe_push_review
_maybe_push_review(req.content, {
"as_of": req.as_of,
"emotion_label": req.emotion_label,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条复盘报告。"""
ok = market_recap_reports.delete_report(report_id)
return {"ok": ok}
+265 -1
View File
@@ -47,9 +47,12 @@ class RuleModel(BaseModel):
logic: str = "and" # and | or
cooldown_seconds: int = 3600
severity: str = "info" # info | warn | critical
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
webhook_enabled: bool = False
message: str = ""
# ladder 专属 (连板梯队封单监控)
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
# ── 字段选项 ─────────────────────────────────────────────
@@ -128,6 +131,16 @@ def list_rules(request: Request):
@router.post("")
def save_rule(req: RuleModel, request: Request):
rule = monitor_rules.normalize(req.model_dump())
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
if rule.get("type") == "ladder":
from app.tickflow.capabilities import Cap
capset = getattr(request.app.state, "capabilities", None)
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
raise HTTPException(
status_code=403,
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
)
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
if existing and existing.get("created_at"):
@@ -233,3 +246,254 @@ def seed_demo_rules(request: Request):
i += 1
_sync_engine(request)
return {"ok": True, "generated": len(created), "ids": created}
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
@router.post("/test-ladder")
def test_ladder(request: Request):
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
"""
import polars as pl
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
# 最新交易日
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取涨停+跌停封单 {symbol: vol}
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
# 取这些 symbol 的 close (算封单额用)
enriched_today, _ = repo.get_enriched_latest()
cols = ["symbol", "close", "change_pct"]
avail = [c for c in cols if c in enriched_today.columns]
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
# 注入 _sealed_vol
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
mock = mock.join(sealed_df, on="symbol", how="inner")
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
all_events = []
not_triggered = []
for rule in ladder_rules:
syms = rule.get("symbols", [])
sym = syms[0] if syms else None
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
# 取该 symbol 的封单数据
cur_vol = sealed.get(sym) if sym else None
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
# 条件判断: 封单 > 0 且 比较值 <= 阈值
if cur_val is not None and cur_val > 0 and cur_val <= thr:
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
all_events.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"name": sym,
"type": warn_label,
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"severity": rule.get("severity", "warn"),
"sealed_value": cur_val,
"sealed_metric": metric,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
})
else:
reason = "封单数据缺失" if cur_val is None else (
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
)
not_triggered.append({
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"symbol": sym,
"metric": metric,
"threshold": thr,
"current_value": cur_val,
"current_sealed_vol": cur_vol,
"current_sealed_amount": cur_amt,
"reason": reason,
})
return {
"ok": True,
"as_of": str(latest),
"sealed_count": len(sealed),
"triggered": all_events,
"not_triggered": not_triggered,
}
@router.post("/trigger-ladder")
def trigger_ladder(request: Request):
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
让用户看到真实的预警通知。绕过 cooldown 强制触发。
"""
import time
from app.services import alert_store
repo = request.app.state.repo
depth_svc = getattr(request.app.state, "depth_service", None)
engine = getattr(request.app.state, "monitor_engine", None)
quote_svc = getattr(request.app.state, "quote_service", None)
if not depth_svc:
raise HTTPException(status_code=503, detail="depth 服务未初始化")
if not engine or not engine.has_rule_type("ladder"):
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
latest = repo.enriched_latest_date()
if not latest:
raise HTTPException(status_code=400, detail="无 enriched 数据")
# 取封单
sealed: dict[str, int] = {}
for is_down in (False, True):
m = depth_svc.get_sealed_map(latest, is_down=is_down)
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol
if not sealed:
raise HTTPException(status_code=400, detail="无封单数据")
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
import polars as pl
enriched_today, _ = repo.get_enriched_latest()
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
mock = mock.join(sealed_df, on="symbol", how="inner")
now = time.time()
rule_events: list[dict] = []
name_map = {}
try:
inst = repo.get_instruments()
if not inst.is_empty() and "name" in inst.columns:
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
except Exception: # noqa: BLE001
pass
for rule in engine.rules.values():
if rule.get("type") != "ladder" or not rule.get("enabled", True):
continue
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
metric = rule.get("metric", "sealed_vol")
thr = rule.get("threshold", 0)
direction = rule.get("direction", "up")
warn_label = "炸板预警" if direction == "up" else "翘板预警"
row = mock.filter(pl.col("symbol") == sym)
if row.is_empty():
continue
cur_vol = row["_sealed_vol"][0]
close_v = row["close"][0] if "close" in row.columns else None
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
if not cur_val or cur_val <= 0 or cur_val > thr:
continue # 不满足条件, 跳过
if metric == "sealed_amount":
sv_text = f"{cur_val / 1e4:.0f}万元"
th_text = f"{thr / 1e4:.0f}万元"
else:
sv_text = f"{cur_val:,.0f}"
th_text = f"{thr:,.0f}"
rule_events.append({
"ts": int(now * 1000),
"rule_id": rule["id"],
"rule_name": rule.get("name", ""),
"source": "ladder",
"type": warn_label,
"symbol": sym,
"name": name_map.get(sym, sym),
"message": f"{warn_label} · 封单 {sv_text}{th_text}",
"price": close_v,
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
"signals": [],
"severity": rule.get("severity", "warn"),
"conditions": [],
"logic": "and",
"sealed_value": cur_val,
"sealed_metric": metric,
})
if not rule_events:
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
# 1. 落盘到 alerts.jsonl
try:
alert_store.append_many(repo.store.data_dir, rule_events)
except Exception as e: # noqa: BLE001
pass # 落盘失败不阻断推送
# 2. SSE 推送 (入 pending_alerts 队列)
if quote_svc:
sse_alerts = [{
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
"signals": ev["signals"], "severity": ev["severity"],
"conditions": ev["conditions"], "logic": ev["logic"],
} for ev in rule_events]
try:
with quote_svc._lock:
quote_svc._pending_alerts.extend(sse_alerts)
quote_svc._alert_event.set()
except Exception: # noqa: BLE001
pass
# 3. 飞书推送
if quote_svc:
try:
quote_svc._maybe_send_webhook(rule_events, engine)
except Exception: # noqa: BLE001
pass
return {
"ok": True,
"triggered": len(rule_events),
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
}
+11 -209
View File
@@ -343,216 +343,18 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
def _build_overview(request: Request, as_of: date | None = None) -> dict:
repo = request.app.state.repo
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
status = _quote_status(request)
indices = _index_quotes(request, as_of)
"""装配市场总览(委托给 services.market_overview_builder,保持行为一致)。
if not as_of:
return {
"as_of": None,
"quote_status": status,
"indices": indices,
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
"amount": {"total": 0, "avg": 0},
"boards": [],
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
"distribution": [],
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
"radar": [],
"emotion": {"score": 50, "label": "暂无"},
"top_gainers": [],
"top_losers": [],
"turnover_leaders": [],
"active_leaders": [],
"concept_rank": {"leading": [], "lagging": []},
"industry_rank": {"leading": [], "lagging": []},
}
df = svc._load_enriched_for_date(as_of)
if df.is_empty():
rows: list[dict] = []
else:
cols = [
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
]
df = df.select([c for c in cols if c in df.columns])
rows = df.to_dicts()
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
if rows and "volume" in rows[0]:
rows = [r for r in rows
if (_finite(r.get("volume")) or 0) > 0
or (_finite(r.get("change_pct")) or 0) != 0]
total = len(rows)
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
flat = max(0, total - up - down)
up_pct = up / total * 100 if total else 0
down_pct = down / total * 100 if total else 0
amounts = [_finite(r.get("amount")) or 0 for r in rows]
total_amount = sum(amounts)
avg_amount = total_amount / total if total else 0
pct_values = [_finite(r.get("change_pct")) for r in rows]
pct_values = [v for v in pct_values if v is not None]
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
strong_up = sum(1 for v in pct_values if v >= 0.03)
strong_down = sum(1 for v in pct_values if v <= -0.03)
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
depth_svc = getattr(request.app.state, "depth_service", None)
sealed_ready = False
fake_up = 0
fake_down = 0
if depth_svc:
up_map = depth_svc.get_sealed_map(as_of, is_down=False)
down_map = depth_svc.get_sealed_map(as_of, is_down=True)
sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
if sealed_ready:
limit_up = max(0, limit_up - fake_up)
limit_down = max(0, limit_down - fake_down)
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
def above_ma_count(ma_key: str) -> int:
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
above_ma5 = above_ma_count("ma5")
above_ma20 = above_ma_count("ma20")
above_ma60 = above_ma_count("ma60")
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
turnovers = [v for v in turnovers if v is not None]
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
high_turnover = sum(1 for v in turnovers if v >= 5)
boards_map: dict[str, dict] = {}
for r in rows:
b = _board(str(r.get("symbol") or ""))
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
item["count"] += 1
change = _finite(r.get("change_pct")) or 0
if change > 0:
item["up"] += 1
elif change < 0:
item["down"] += 1
item["amount"] += _finite(r.get("amount")) or 0
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
for b in boards:
count = b["count"] or 1
b["up_pct"] = b["up"] / count * 100
tiers_map: dict[int, int] = {}
for r in rows:
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
if n > 0:
tiers_map[n] = tiers_map.get(n, 0) + 1
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
index_changes = [_finite(r.get("change_pct")) for r in indices]
index_changes = [v for v in index_changes if v is not None]
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
vol_ratios = [v for v in vol_ratios if v is not None]
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
concept_rank = _dimension_rank(rows, request, "concept")
industry_rank = _dimension_rank(rows, request, "industry", level=2)
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
high_vol_pct = high_vol_ratio / total * 100 if total else 0
strong_down_pct = strong_down / total * 100 if total else 0
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
radar = [
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
{"key": "mainline", "label": "主线", "value": mainline_score},
]
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
if emotion_score >= 70:
emotion_label = "强势"
elif emotion_score >= 55:
emotion_label = "偏暖"
elif emotion_score >= 45:
emotion_label = "震荡"
elif emotion_score >= 30:
emotion_label = "偏冷"
else:
emotion_label = "冰点"
return _json_safe({
"as_of": str(as_of),
"quote_status": status,
"indices": indices,
"breadth": {
"total": total,
"up": up,
"down": down,
"flat": flat,
"up_pct": up_pct,
"down_pct": down_pct,
"avg_pct": avg_pct,
"median_pct": median_pct,
"strong_up": strong_up,
"strong_down": strong_down,
},
"amount": {"total": total_amount, "avg": avg_amount},
"boards": boards,
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
"distribution": _pct_band_rows(pct_values),
"trend": {
"above_ma5": above_ma5,
"above_ma20": above_ma20,
"above_ma60": above_ma60,
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
"new_high": new_high,
"new_low": new_low,
},
"activity": {
"avg_turnover": avg_turnover,
"high_turnover": high_turnover,
"high_vol_ratio": high_vol_ratio,
"vol_ratio": avg_vol_ratio,
},
"radar": radar,
"emotion": {"score": emotion_score, "label": emotion_label},
"top_gainers": _top_rows(rows, "change_pct", True),
"top_losers": _top_rows(rows, "change_pct", False),
"turnover_leaders": _top_rows(rows, "amount", True),
"active_leaders": _top_rows(rows, "turnover_rate", True),
"concept_rank": concept_rank,
"industry_rank": industry_rank,
})
逻辑已抽离至 build_market_overview,以解耦对 Request 的依赖,
使大盘复盘等无 Request 的调用方可复用同一装配逻辑。
"""
from app.services.market_overview_builder import build_market_overview
return build_market_overview(
repo=request.app.state.repo,
quote_service=getattr(request.app.state, "quote_service", None),
depth_service=getattr(request.app.state, "depth_service", None),
as_of=as_of,
)
@router.get("/market")
+67
View File
@@ -0,0 +1,67 @@
"""涨幅轮动矩阵 API。
供「概念分析 → 涨幅RPS轮动」对话框调用。返回最近 N 个交易日的概念涨幅
排名矩阵:每列(日期)各自把所有概念按当天涨幅从高到低排序。
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.services import rps_rotation
from app.services.concept_rotation_analyzer import analyze_rotation_stream
router = APIRouter(prefix="/api/rps", tags=["rps"])
@router.get("/rotation")
def get_rotation(
request: Request,
days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"),
) -> dict:
"""概念涨幅轮动矩阵。
Returns:
dates: 日期字符串列表(最新在最前)
columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
concept_count: 去重概念总数
"""
return rps_rotation.build_rps_rotation(request.app.state.repo, days)
class AnalyzeRequest(BaseModel):
"""AI 概念轮动分析请求。"""
days: int = 12 # 分析最近 N 个交易日
focus: str = "" # 用户追加的关注点
@router.post("/rotation-analyze")
async def analyze_rotation(request: Request, req: AnalyzeRequest):
"""AI 概念轮动分析 — NDJSON 流式返回。
装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM →
逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
协议:
{"type":"meta","days","summary"}
{"type":"delta","content":"..."}
{"type":"error","message":"..."}
{"type":"done"}
"""
repo = request.app.state.repo
quote_service = getattr(request.app.state, "quote_service", None)
depth_service = getattr(request.app.state, "depth_service", None)
days = max(7, min(30, req.days))
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus, quote_service, depth_service,
):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+25 -1
View File
@@ -278,11 +278,35 @@ def get_cached(
request: Request,
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
):
"""读取策略结果缓存。返回 None 表示无缓存。"""
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
"""
data_dir = request.app.state.repo.store.data_dir
cached = strategy_cache.read_cache(data_dir)
if cached is None:
cached = {"as_of": None, "results": {}, "updated_at": None}
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if monitor_engine is not None:
realtime_results = monitor_engine.latest_strategy_results()
if realtime_results:
results = dict(cached.get("results") or {})
results.update(realtime_results)
cached = dict(cached)
cached["results"] = results
# 有实时数据时, 以最新时间戳为准
import time as _time
cached["updated_at"] = int(_time.time() * 1000)
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
if not cached.get("results") and cached.get("as_of") is None:
return {"as_of": None, "results": {}, "updated_at": None}
ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
return _cache_payload_with_ext(cached, ext_values)
+259 -30
View File
@@ -7,11 +7,10 @@ from __future__ import annotations
import logging
import time
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app import secrets_store
from app.services.financial_sync import financial_scheduler
from app.tickflow import client as tf_client
from app.tickflow.policy import (
detect_capabilities,
@@ -30,25 +29,34 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
def _sync_financial_scheduler_caps(app_state, capset) -> None:
"""把重新探测出的能力同步给财务调度器。
app.state.capabilities 在此已更新, 但 FinancialScheduler 在启动时捕获的是旧引用,
需显式刷新, 否则用户升级到 Expert 后点「全部同步」仍会因调度器读旧 capset 而被拒。
"""
fs = getattr(app_state, "financial_scheduler", None)
if fs is None:
return
try:
fs.update_capabilities(capset)
except Exception as e: # noqa: BLE001
logging.getLogger(__name__).warning("update financial_scheduler capabilities failed: %s", e)
class TickflowKeyIn(BaseModel):
api_key: str
def _sync_financial_scheduler(request: Request, capset) -> None:
"""Key 变更后同步财务调度器状态,无需重启服务。"""
try:
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
except Exception as e: # noqa: BLE001
logger.warning("financial_scheduler update failed: %s", e)
@router.get("")
def get_settings() -> dict:
"""返回当前配置概况(Key 脱敏)。"""
from app.config import settings
from app.services import preferences
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
key = secrets_store.get_tickflow_key()
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
return {
"mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key),
@@ -61,12 +69,14 @@ def get_settings() -> dict:
# 首次使用引导
"onboarding_completed": preferences.get_onboarding_completed(),
# AI 配置
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
"ai_provider": ai_provider,
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
"has_ai_key": bool(secrets_store.get_ai_key()),
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
"ai_configured": ai_configured(ai_provider),
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
}
@@ -76,9 +86,9 @@ class SwitchEndpointIn(BaseModel):
@router.post("/switch_endpoint")
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
"""切换数据源端点并立即生效。
"""切换 TickFlow 端点并立即生效。
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
"""
# none/free 档没有付费端点权限,禁止切换
@@ -102,7 +112,7 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
@router.post("/tickflow-key")
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
"""保存数据源 API Key 并立即重新探测能力。
"""保存 TickFlow API Key 并立即重新探测能力。
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
1. 临时用新 key 探测(付费端点),判定档位
@@ -112,7 +122,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
故自动切到默认付费端点;free 档则清除自定义端点。
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
"""
from app.tickflow.policy import (
base_tier_name, is_invalid_key,
@@ -129,7 +139,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
capset = detect_capabilities(force=True)
request.app.state.capabilities = capset
_sync_financial_scheduler(request, capset)
_sync_financial_scheduler_caps(request.app.state, capset)
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
if is_invalid_key() or base_tier_name() == "none":
@@ -138,7 +148,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
tf_client.reset_clients()
capset = detect_capabilities(force=True)
request.app.state.capabilities = capset
_sync_financial_scheduler(request, capset)
_sync_financial_scheduler_caps(request.app.state, capset)
return {
"ok": False,
"reason": "invalid",
@@ -195,7 +205,7 @@ def clear_tickflow_key(request: Request) -> dict:
capset = detect_capabilities(force=True)
request.app.state.capabilities = capset
_sync_financial_scheduler(request, capset)
_sync_financial_scheduler_caps(request.app.state, capset)
return {
"ok": True,
@@ -223,13 +233,15 @@ class AiSettingsIn(BaseModel):
base_url: str = ""
api_key: str | None = None
model: str = ""
daily_token_budget: int = 500_000
codex_command: str = ""
user_agent: str = ""
@router.post("/ai")
def save_ai_settings(req: AiSettingsIn) -> dict:
"""保存 AI 配置(全部持久化到 secrets.json"""
from app.config import settings
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
updates: dict = {}
if req.provider:
@@ -245,15 +257,52 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
else:
secrets_store.clear("ai_api_key")
settings.ai_api_key = ""
if req.model:
if req.provider == "codex_cli" and not req.model:
secrets_store.clear("ai_model")
settings.ai_model = ""
elif req.model:
updates["ai_model"] = req.model
settings.ai_model = req.model
updates["ai_daily_token_budget"] = req.daily_token_budget
settings.ai_daily_token_budget = req.daily_token_budget
if req.provider == "codex_cli":
try:
codex_command = normalize_codex_command(req.codex_command)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updates["ai_codex_command"] = codex_command
settings.ai_codex_command = codex_command
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
if updates:
secrets_store.save(updates)
provider = current_ai_provider()
return {
"ok": True,
"ai_provider": provider,
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_configured": ai_configured(provider),
}
@router.delete("/ai")
def clear_ai_settings() -> dict:
"""一键清空 AI 配置(provider / base_url / api_key / model)。
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
"""
from app.config import settings
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
# 同步重置运行时内存(provider 回默认值,其余置空)
settings.ai_provider = "openai_compat"
settings.ai_base_url = ""
settings.ai_api_key = ""
settings.ai_model = ""
settings.ai_codex_command = "codex"
return {"ok": True}
@@ -280,6 +329,16 @@ def get_preferences() -> dict:
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
"minute_sync_days": preferences.get_minute_sync_days(),
"daily_data_provider": preferences.get_daily_data_provider(),
"adj_factor_provider": preferences.get_adj_factor_provider(),
"minute_data_provider": preferences.get_minute_data_provider(),
"realtime_data_provider": preferences.get_realtime_data_provider(),
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
**preferences.get_realtime_quote_scope(),
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
"pipeline_index_symbols": preferences.get_pipeline_index_symbols(),
"pipeline_schedule": preferences.get_pipeline_schedule(),
"instruments_schedule": preferences.get_instruments_schedule(),
"enriched_batch_size": preferences.get_enriched_batch_size(),
@@ -290,6 +349,9 @@ def get_preferences() -> dict:
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
"system_notify_enabled": preferences.get_system_notify_enabled(),
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
"nav_order": preferences.get_nav_order(),
"nav_hidden": preferences.get_nav_hidden(),
@@ -297,6 +359,8 @@ def get_preferences() -> dict:
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
"depth_polling_interval": preferences.get_depth_polling_interval(),
"depth_finalize_time": preferences.get_depth_finalize_time(),
"review_schedule": preferences.get_review_schedule(),
"review_push_channels": preferences.get_review_push_channels(),
}
@@ -377,11 +441,19 @@ class RealtimeQuotesPrefs(BaseModel):
realtime_quotes_enabled: bool
class RealtimeQuoteScopePrefs(BaseModel):
realtime_pull_stock: bool | None = None
realtime_pull_etf: bool | None = None
realtime_pull_index: bool | None = None
realtime_index_mode: str | None = None
realtime_index_symbols: list[str] | None = None
@router.put("/preferences/realtime-quotes")
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
"""保存全局实时行情开关。
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
前端据此把开关置灰 / 回弹。
"""
from app.services import preferences
@@ -394,6 +466,9 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
if qs:
qs.disable()
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
preferences.save({"realtime_quotes_enabled": False})
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
if qs:
@@ -405,6 +480,26 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
@router.put("/preferences/realtime-quote-scope")
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
"""保存盘中实时行情范围;独立于盘后管道范围。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
return preferences.set_realtime_quote_scope(cfg)
class RealtimeWatchlistPrefs(BaseModel):
symbols: list[str] = []
@router.put("/preferences/realtime-watchlist")
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
from app.services import preferences
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
return {"realtime_watchlist_symbols": symbols}
class IndicesNavPinnedPrefs(BaseModel):
indices_nav_pinned: bool
@@ -457,6 +552,34 @@ def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Reques
return result
class PipelinePullTypesIn(BaseModel):
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
pipeline_pull_a_share: bool | None = None
pipeline_pull_etf: bool | None = None
pipeline_pull_index: bool | None = None
@router.put("/preferences/pipeline-pull-types")
def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict:
"""更新盘后管道拉取内容开关。"""
from app.services import preferences
cfg = req.model_dump(exclude_none=True)
return preferences.set_pipeline_pull_types(cfg)
class PipelineIndexSymbolsIn(BaseModel):
"""指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。"""
symbols: str = ""
@router.put("/preferences/pipeline-index-symbols")
def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
"""保存指数自定义拉取代码。"""
from app.services import preferences
symbols = preferences.set_pipeline_index_symbols(req.symbols)
return {"pipeline_index_symbols": symbols}
class QuoteIntervalIn(BaseModel):
interval: float
@@ -477,6 +600,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
return {"system_notify_enabled": saved}
class FeishuWebhookPrefsIn(BaseModel):
url: str
secret: str = ""
@router.put("/preferences/feishu-webhook")
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
"""
from app.services import preferences
from app.services import webhook_adapter
url = (req.url or "").strip()
if url and not webhook_adapter.is_valid_feishu_url(url):
raise HTTPException(
status_code=400,
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
)
saved_url = preferences.set_feishu_webhook_url(url)
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
class WebhookEnabledDefaultIn(BaseModel):
enabled: bool
@router.put("/preferences/webhook-enabled-default")
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
单条规则仍可在规则编辑页独立修改此项。
"""
from app.services import preferences
saved = preferences.set_webhook_enabled_default(req.enabled)
return {"webhook_enabled_default": saved}
@router.put("/preferences/quote-interval")
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
"""更新行情轮询间隔。按档位自动 clamp。"""
@@ -510,7 +677,7 @@ class TestEndpointIn(BaseModel):
rounds: int | None = None
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
ENDPOINTS_TTL = 300.0 # 秒
@@ -574,7 +741,7 @@ _endpoints_cache: dict = {"ts": 0.0, "data": None}
@router.get("/endpoints")
def list_endpoints() -> dict:
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
"""代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
@@ -601,7 +768,7 @@ def list_endpoints() -> dict:
data = {
"version": parsed.get("version", 1),
"description": parsed.get(
"description", "API 端点配置"
"description", "TickFlow API 端点配置"
),
"healthPath": parsed.get("healthPath", "/health"),
"testRounds": parsed.get("testRounds", 5),
@@ -614,7 +781,7 @@ def list_endpoints() -> dict:
source = "fallback"
data = {
"version": 1,
"description": "API 端点配置",
"description": "TickFlow API 端点配置",
"healthPath": "/health",
"testRounds": 5,
"endpoints": _FALLBACK_ENDPOINTS,
@@ -652,7 +819,7 @@ async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
async def test_endpoint(req: TestEndpointIn) -> dict:
"""测试端点网络延迟:对 /health 多轮探测取中位数。
参考官方 latency_test.py:
参考 TickFlow 官方 latency_test.py:
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
- 返回 median/min/max/success,前端显示中位数
@@ -852,3 +1019,65 @@ def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> di
return sched
class ReviewScheduleIn(BaseModel):
enabled: bool
hour: int
minute: int
@router.put("/preferences/review-schedule")
def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
"""保存定时复盘调度并立即更新 APScheduler job。
- enabled=True: 注册/更新 job(工作日定时生成复盘报告)
- enabled=False: 移除 job(停止定时复盘)
- 校验: 开启时若 AI Key 未配置则拒绝(复盘依赖 AI), 提示用户先配置。
- 时间下限 15:00(A股收盘), 由 preferences 层强制。
"""
from app.services import preferences
if req.enabled:
# 复盘必须有 AI Key, 否则每日报错刷日志
from app import secrets_store
if not secrets_store.get_ai_key():
raise HTTPException(
status_code=400,
detail="复盘依赖 AI,请先在「设置 → AI」配置 API Key 后再开启定时复盘",
)
sched = preferences.set_review_schedule(req.enabled, req.hour, req.minute)
# 动态操作 APScheduler job
from app.jobs.daily_pipeline import _register_review_job, REVIEW_JOB_ID
scheduler = getattr(request.app.state, "scheduler", None)
if scheduler:
if sched["enabled"]:
_register_review_job(scheduler, request.app.state.repo, sched["hour"], sched["minute"])
logger.info("scheduled_review enabled @%02d:%02d mon-fri", sched["hour"], sched["minute"])
else:
try:
scheduler.remove_job(REVIEW_JOB_ID)
logger.info("scheduled_review disabled (job removed)")
except Exception:
pass # job 本就不存在(从未开过), 无需处理
return sched
class ReviewPushIn(BaseModel):
channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中
@router.put("/preferences/review-push")
def update_review_push(req: ReviewPushIn) -> dict:
"""复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具。
纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。
实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review)
在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。
"""
from app.services import preferences
saved = preferences.set_review_push_channels(req.channels)
return {"review_push_channels": saved}
+217
View File
@@ -0,0 +1,217 @@
"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
路由前缀: /api/stock-analysis
端点:
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
POST /analyze AI 流式四维分析(NDJSON)
GET /reports 历史报告列表
POST /reports 保存一条报告
DELETE /reports/{report_id} 删除一条报告
"""
from __future__ import annotations
import logging
import math
from datetime import date, timedelta
import polars as pl
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.indicators.levels import compute_levels, summarize_levels
from app.services import stock_reports
from app.services.stock_analyzer import analyze_stock_stream
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
def _to_float_list(series: pl.Series) -> list:
"""polars Series → JSON 安全的 float 列表(null/NaN → None)。"""
out: list = []
for v in series.to_list():
if v is None:
out.append(None)
continue
try:
f = float(v)
out.append(round(f, 2) if math.isfinite(f) else None)
except (TypeError, ValueError):
out.append(None)
return out
def _build_series(df: pl.DataFrame) -> dict:
"""提取带状指标(布林带 / Keltner通道 / ATR止损)的每日时间序列。
这些指标的本质是"每日一条线",随 MA/ATR/σ 漂移,画成曲线才能体现通道形态。
其余固定价位(枢轴/前高前低等)不在此,仍用水平 markLine。
返回结构(每个 value 都是按日期对齐的数组):
{
"boll": {"upper": [...], "lower": [...]},
"keltner_s": {"upper": [...], "lower": [...]}, # 短期 MA20±2ATR
"keltner_m": {"upper": [...], "lower": [...]}, # 中期 MA60±2.5ATR
"keltner_l": {"upper": [...], "lower": [...]}, # 长期 MA120±3ATR
"atr": {"stop_loss": [...], "take_profit": [...]}, # close∓2ATR
}
"""
if df.is_empty() or "close" not in df.columns:
return {}
out: dict[str, dict] = {}
close = df["close"]
has_atr = "atr_14" in df.columns
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
if "boll_upper" in df.columns and "boll_lower" in df.columns:
out["boll"] = {
"upper": _to_float_list(df["boll_upper"]),
"lower": _to_float_list(df["boll_lower"]),
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
}
# Keltner 通道三档(需要 ATR)
if has_atr:
atr = df["atr_14"]
# MA120 现场算(不在预计算列中)
ma120 = df.select(pl.col("close").rolling_mean(120))["close"] if df.height >= 120 else None
def _channel(ma: pl.Series, n: float) -> dict:
return {
"upper": _to_float_list(ma + n * atr),
"lower": _to_float_list(ma - n * atr),
}
if "ma20" in df.columns:
out["keltner_s"] = _channel(df["ma20"], 2.0)
if "ma60" in df.columns:
out["keltner_m"] = _channel(df["ma60"], 2.5)
if ma120 is not None:
out["keltner_l"] = _channel(ma120, 3.0)
# ATR 止损/止盈: close ± 2×ATR(跟随行情漂移的动态止损线)
out["atr"] = {
"stop_loss": _to_float_list(close - 2 * atr),
"take_profit": _to_float_list(close + 2 * atr),
}
return out
@router.get("/levels")
def get_levels(
request: Request,
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
):
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
atr_stop, gap, fib, round}, close, summary, dates, series}。
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
"""
if not symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
end = date.today()
start = end - timedelta(days=days * 2)
df = repo.get_daily(symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "pivot": [], "extreme": [],
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
"atr_stop": [], "gap": [], "fib": [], "round": []},
"close": None, "summary": "无数据", "symbol": symbol,
"dates": [], "series": {}}
levels = compute_levels(df)
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
# 日期 + 带状曲线序列(供前端画 Keltner/ATR/布林带曲线)
dates = df["date"].to_list()
series = _build_series(df)
return {
"levels": levels,
"close": close,
"summary": summarize_levels(levels, close),
"symbol": symbol,
"dates": [str(d) for d in dates],
"series": series,
}
class AnalyzeRequest(BaseModel):
"""AI 个股分析请求。"""
symbol: str
focus: str = "" # 可选:用户追加的分析关注点
@router.post("/analyze")
async def analyze_stock(request: Request, req: AnalyzeRequest):
"""AI 个股四维分析 — NDJSON 流式返回。
组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
"""
if not req.symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# 报告 CRUD(历史报告持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 个股分析报告。"""
symbol: str
name: str = ""
focus: str = ""
content: str
summary: str = ""
close: float | None = None
levels: dict | None = None
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
return {"reports": stock_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
report = stock_reports.save_report({
"symbol": req.symbol,
"name": req.name,
"focus": req.focus,
"content": req.content,
"summary": req.summary,
"close": req.close,
"levels": req.levels,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
ok = stock_reports.delete_report(report_id)
return {"ok": ok}
+19 -18
View File
@@ -84,6 +84,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
"entry_signals": s.entry_signals,
"exit_signals": s.exit_signals,
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
"take_profit": getattr(s, "take_profit", None),
"trailing_stop": getattr(s, "trailing_stop", None),
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
@@ -285,12 +286,19 @@ class BuildRequest(BaseModel):
@router.get("/ai/status")
def ai_status(request: Request):
"""检查 AI 配置状态"""
from app.config import settings
"""Check whether the selected AI provider is configured."""
from app import secrets_store
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider
has_key = bool(secrets_store.get_ai_key())
has_model = bool(settings.ai_model)
return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
model = current_ai_model()
provider = current_ai_provider()
return {
"configured": ai_configured(provider) and bool(model or provider == "codex_cli"),
"has_key": has_key,
"has_model": bool(model),
"provider": provider,
}
@router.get("/{strategy_id}/source")
@@ -314,24 +322,17 @@ def get_strategy_source(strategy_id: str, request: Request):
@router.post("/ai/test")
async def ai_test(request: Request):
"""测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
from app.config import settings
from app import secrets_store
from openai import AsyncOpenAI
ai_key = secrets_store.get_ai_key()
if not ai_key:
return {"ok": False, "error": "未配置 API Key"}
"""Send a small prompt through the selected AI provider."""
from app.services.ai_provider import current_ai_model, current_ai_provider, generate_ai_text
try:
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
resp = await client.chat.completions.create(
model=settings.ai_model,
messages=[{"role": "user", "content": "回复 OK"}],
max_tokens=5,
text = await generate_ai_text(
[{"role": "user", "content": "Reply exactly: OK"}],
temperature=0,
max_tokens=8,
timeout=15,
)
return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
except Exception as e:
return {"ok": False, "error": str(e)}
+28 -8
View File
@@ -27,28 +27,48 @@ class BatchAddRequest(BaseModel):
note: str = ""
def _with_names(rows: list[dict], request: Request) -> list[dict]:
if not rows:
return rows
try:
df_i = request.app.state.repo.get_instruments()
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
return rows
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
except Exception as e: # noqa: BLE001
logger.debug("attach watchlist names failed: %s", e)
return rows
@router.get("")
def list_all():
return {"symbols": watchlist.list_symbols()}
def list_all(request: Request):
return {"symbols": _with_names(watchlist.list_symbols(), request)}
@router.post("")
def add_one(req: AddRequest):
def add_one(req: AddRequest, request: Request):
rows = watchlist.add(req.symbol, req.note)
return {"symbols": rows}
return {"symbols": _with_names(rows, request)}
@router.post("/batch")
def add_batch(req: BatchAddRequest):
def add_batch(req: BatchAddRequest, request: Request):
for sym in req.symbols:
watchlist.add(sym, req.note)
return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)}
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
@router.post("/{symbol}/top")
def move_one_to_top(symbol: str, request: Request):
rows = watchlist.move_to_top(symbol)
return {"symbols": _with_names(rows, request)}
@router.delete("/{symbol}")
def remove_one(symbol: str):
def remove_one(symbol: str, request: Request):
rows = watchlist.remove(symbol)
return {"symbols": rows}
return {"symbols": _with_names(rows, request)}
@router.delete("")
-189
View File
@@ -1,189 +0,0 @@
"""访问门控 —— 支持管理员令牌 + 动态 UUID 两种凭证。
部署方式(优先级从高到低):
1. ADMIN_TOKEN: 管理员初始令牌(如 admin7226132)。验证通过后进入管理页,
可创建普通 UUID 供合伙人使用,管理员本身也可访问全部功能。
2. 动态 UUID: 管理员通过 /admin/uuids 创建的访问 UUID,持久化在
data/user_data/access_uuids.json。
3. ACCESS_UUID: 遗留单共享 UUID,保持向后兼容。
以上全部留空则门控不启用。
"""
from __future__ import annotations
import hmac
import logging
import secrets
import time
from enum import Enum
from typing import Any
from fastapi import HTTPException, Request
from pydantic import BaseModel
from app.config import settings
from app import uuid_store
logger = logging.getLogger(__name__)
class AuthRole(str, Enum):
ADMIN = "admin"
USER = "user"
# 内存中的令牌缓存:{token: {"role": role, "expires_at": float}}
_token_cache: dict[str, dict[str, Any]] = {}
# 令牌默认有效期:7 天
TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
class VerifyIn(BaseModel):
credential: str
class VerifyOut(BaseModel):
valid: bool
role: str | None = None
token: str | None = None
class AuthStatusOut(BaseModel):
enabled: bool
verified: bool
role: str | None = None
class UuidRecordOut(BaseModel):
uuid: str
label: str
enabled: bool
created_at: int
class UuidCreateIn(BaseModel):
label: str = ""
def access_control_enabled() -> bool:
"""是否启用了任何访问门控。"""
return bool(settings.admin_token) or bool(settings.access_uuid)
def admin_mode_enabled() -> bool:
"""是否启用了管理员令牌模式。"""
return bool(settings.admin_token)
def _constant_time_compare(a: str, b: str) -> bool:
"""常量时间字符串比较,降低时序攻击风险。"""
return hmac.compare_digest(a.encode(), b.encode())
def verify_admin_token(credential: str | None) -> bool:
"""校验是否为管理员令牌。"""
if not credential or not settings.admin_token:
return False
return _constant_time_compare(credential.strip(), settings.admin_token.strip())
def verify_uuid(credential: str | None) -> bool:
"""校验输入是否为有效访问 UUID(遗留单 UUID 或动态 UUID)。"""
if not credential:
return False
stripped = credential.strip()
# 动态 UUID
if uuid_store.exists(stripped):
return True
# 遗留单共享 UUID
if settings.access_uuid and _constant_time_compare(stripped, settings.access_uuid.strip()):
return True
return False
def verify_credential(credential: str | None) -> AuthRole | None:
"""校验任意凭证,返回对应角色;无效返回 None。"""
if not credential:
return None
if verify_admin_token(credential):
return AuthRole.ADMIN
if verify_uuid(credential):
return AuthRole.USER
return None
def create_access_token(role: AuthRole) -> str:
"""生成一个随机访问令牌并缓存。"""
token = secrets.token_urlsafe(32)
_token_cache[token] = {"role": role, "expires_at": time.time() + TOKEN_TTL_SECONDS}
return token
def validate_access_token(token: str | None) -> AuthRole | None:
"""校验令牌是否有效,返回角色。"""
if not token:
return None
cached = _token_cache.get(token)
if cached is None:
return None
expires_at = cached.get("expires_at", 0)
if expires_at > 0 and time.time() > expires_at:
_token_cache.pop(token, None)
return None
return cached.get("role")
def revoke_access_token(token: str | None) -> None:
"""使指定令牌失效。"""
if token:
_token_cache.pop(token, None)
def get_access_token_from_request(request: Request) -> str | None:
"""从请求头或 query 参数中提取访问令牌。"""
header = request.headers.get("X-Access-Token")
if header:
return header.strip()
return request.query_params.get("access_token")
def require_access(request: Request, allowed_roles: set[AuthRole] | None = None) -> AuthRole:
"""FastAPI 依赖:未通过校验时抛出 401。
allowed_roles: 仅允许指定角色访问;None 表示 admin/user 均可。
"""
if not access_control_enabled():
return AuthRole.ADMIN # 未启用门控时视为最高权限
token = get_access_token_from_request(request)
role = validate_access_token(token)
if role is None:
raise HTTPException(status_code=401, detail="访问令牌无效或已过期")
if allowed_roles is not None and role not in allowed_roles:
raise HTTPException(status_code=403, detail="权限不足")
return role
def require_admin(request: Request) -> AuthRole:
"""FastAPI 依赖:仅管理员可访问。"""
return require_access(request, allowed_roles={AuthRole.ADMIN})
def is_public_path(path: str) -> bool:
"""判断请求路径是否属于白名单(无需校验)。"""
public_prefixes = (
"/health",
"/api/auth/",
"/assets/",
"/index.html",
"/favicon.ico",
"/robots.txt",
"/manifest.json",
)
lowered = path.lower()
return lowered.startswith(public_prefixes) or lowered == "/"
def is_admin_path(path: str) -> bool:
"""判断是否为管理员接口路径。"""
return path.lower().startswith("/api/admin/")
+43 -18
View File
@@ -37,6 +37,7 @@ class MatcherConfig:
fees_pct: float = 0.0002
slippage_bps: float = 5.0
stop_loss_pct: float | None = None
take_profit_pct: float | None = None
trailing_stop_pct: float | None = None
trailing_take_profit_activate_pct: float | None = None
trailing_take_profit_drawdown_pct: float | None = None
@@ -65,7 +66,7 @@ class TradeRecord:
exit_price: float
pnl_pct: float
duration: int
exit_reason: str # "signal" | "stop_loss" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
exit_reason: str # "signal" | "stop_loss" | "take_profit" | "trailing_stop" | "trailing_take_profit" | "max_hold" | "end"
# 退出优先级 (高→低): pending_exit(历史挂单) > 风控(止损/移动止损/移动止盈) > signal(卖点) > max_hold(到期) > end
name: str = ""
shares: float = 0.0
@@ -544,6 +545,7 @@ class BacktestEngine:
return None, None
open_price = float(open_prices[idx])
low_price = float(low_prices[idx])
high_price = float(high_prices[idx])
peak_price = float(pos.get("max_high", entry_price))
risk_lines: list[tuple[float, str]] = []
@@ -560,13 +562,24 @@ class BacktestEngine:
risk_lines.append((entry_price * (1 + peak_profit - abs(float(drawdown_pct))), "trailing_take_profit"))
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
if not risk_lines:
return None, None
stop_price, reason = max(risk_lines, key=lambda item: item[0])
if _valid_price(open_price) and open_price <= stop_price:
return reason, open_price
if _valid_price(low_price) and low_price <= stop_price:
return reason, stop_price
# 止损/移损/回撤止盈: 价格跌破风控线触发 (取最高优先级线)
if risk_lines:
stop_price, reason = max(risk_lines, key=lambda item: item[0])
if _valid_price(open_price) and open_price <= stop_price:
return reason, open_price
if _valid_price(low_price) and low_price <= stop_price:
return reason, stop_price
# 固定止盈: 价格涨破止盈线触发
tp_pct = getattr(config, "take_profit_pct", None)
if tp_pct is not None:
tp_line = entry_price * (1 + abs(float(tp_pct)))
if _valid_price(tp_line):
# 开盘即超过止盈线 → 以开盘价成交; 否则当日触及高点止盈
if _valid_price(open_price) and open_price >= tp_line:
return "take_profit", open_price
if _valid_price(high_price) and high_price >= tp_line:
return "take_profit", tp_line
return None, None
def _try_close(pos: dict, idx: int, reason: str, signal_date: str, exit_price_override: float | None = None) -> bool:
@@ -993,6 +1006,7 @@ class BacktestEngine:
continue
open_price = float(open_prices[idx])
low_price = float(low_prices[idx])
high_price = float(high_prices[idx])
entry_price = float(pos["entry_price"])
peak_price = float(pos.get("max_high", entry_price))
risk_lines: list[tuple[float, str]] = []
@@ -1011,17 +1025,28 @@ class BacktestEngine:
take_profit_line = entry_price * (1 + peak_profit - abs(float(drawdown_pct)))
risk_lines.append((take_profit_line, "trailing_take_profit"))
# 止损/移损/回撤止盈: 价格跌破风控线触发
risk_lines = [(line, reason) for line, reason in risk_lines if _valid_price(line)]
if not risk_lines:
continue
stop_price, reason = max(risk_lines, key=lambda item: item[0])
exit_price_override = None
if _valid_price(open_price) and open_price <= stop_price:
exit_price_override = open_price
elif _valid_price(low_price) and low_price <= stop_price:
exit_price_override = stop_price
if exit_price_override is not None:
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
if risk_lines:
stop_price, reason = max(risk_lines, key=lambda item: item[0])
exit_price_override = None
if _valid_price(open_price) and open_price <= stop_price:
exit_price_override = open_price
elif _valid_price(low_price) and low_price <= stop_price:
exit_price_override = stop_price
if exit_price_override is not None:
_try_sell(sym, idx, reason, d_str, sold_today, exit_price_override)
continue
# 固定止盈: 价格涨破止盈线触发
tp_pct = getattr(config, "take_profit_pct", None)
if tp_pct is not None:
tp_line = entry_price * (1 + abs(float(tp_pct)))
if _valid_price(tp_line):
if _valid_price(open_price) and open_price >= tp_line:
_try_sell(sym, idx, "take_profit", d_str, sold_today, open_price)
elif _valid_price(high_price) and high_price >= tp_line:
_try_sell(sym, idx, "take_profit", d_str, sold_today, tp_line)
def _process_entries(
d_str: str,
+7
View File
@@ -103,6 +103,11 @@ class StrategyBacktestService:
entry_signals = self._effective_signals(overrides, "entry_signals", s.entry_signals)
exit_signals = self._effective_signals(overrides, "exit_signals", s.exit_signals)
stop_loss = self._override_value(overrides, "stop_loss", s.stop_loss)
take_profit = self._normalize_pct(
self._override_value(overrides, "take_profit", getattr(s, "take_profit", None)),
0.01,
5.0,
)
trailing_stop = self._normalize_pct(
self._override_value(overrides, "trailing_stop", getattr(s, "trailing_stop", None)),
0.005,
@@ -195,6 +200,7 @@ class StrategyBacktestService:
fees_pct=config.fees_pct,
slippage_bps=config.slippage_bps,
stop_loss_pct=stop_loss,
take_profit_pct=take_profit,
trailing_stop_pct=trailing_stop,
trailing_take_profit_activate_pct=trailing_take_profit_activate,
trailing_take_profit_drawdown_pct=trailing_take_profit_drawdown,
@@ -246,6 +252,7 @@ class StrategyBacktestService:
"entry_signals": entry_signals,
"exit_signals": exit_signals,
"stop_loss": stop_loss,
"take_profit": take_profit,
"trailing_stop": trailing_stop,
"trailing_take_profit_activate": trailing_take_profit_activate,
"trailing_take_profit_drawdown": trailing_take_profit_drawdown,
+95 -18
View File
@@ -1,27 +1,93 @@
"""全局配置 — 硬编码,个人工具不依赖 .env。"""
"""全局配置 — 从环境变量 / .env 读取"""
from __future__ import annotations
import sys
from pathlib import Path
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# ── 运行环境检测 ──────────────────────────────────────────
# PyInstaller 打包后: __file__ 指向临时解压目录 _MEIPASS, 不能作为路径基准。
# 此时:
# - 只读资源 (tiers.yaml / 前端 dist) 放在 _MEIPASS 内
# - 可写用户数据 (data_dir) 放在可执行文件旁的用户目录
# 非 frozen 模式 (开发/Docker): 保持原有 __file__ 推导, 行为完全不变。
_IS_FROZEN = getattr(sys, "frozen", False)
def _user_data_root() -> Path:
"""桌面版用户数据根目录。
定位策略 (按优先级):
1. 环境变量 DATA_DIR (pydantic-settings 自动注入到 settings.data_dir, 不在此处理)
2. 打包桌面版: exe 同级的 data/ 子目录 (<安装目录>/data/)
与程序同处一个总目录 (用户选择的安装目录), 视觉直观, 便于备份/迁移
3. frozen (开发模式): 项目根 data/
为什么不用 platformdirs 默认 (%LOCALAPPDATA%) 作为主路径:
- 落在 C 盘系统目录, 用户不易察觉, 占系统盘空间
- 用户期望数据跟随程序(便于备份/迁移)
为什么放 {app}/data (exe 旁的 data/) 而非 {app} 外的兄弟目录:
- 用户体验: 用户选了安装目录, 自然期望程序和数据都在这, 单一总目录更直观
- 数据安全: Inno Setup 覆盖安装(升级)时只往 {app} 写新程序文件, 不会清空
目录里不在安装清单上的运行时文件 (data/ 即此类), 故覆盖安装不丢数据
(注意: 卸载时需在 .iss 中豁免 data/, packaging/tickflow.iss [UninstallDelete])
旧版本数据迁移: DataStore._migrate_legacy_data_dir(), 老用户首次启动自动搬迁
"""
# 打包桌面版: exe 同级的 data/ 子目录 (与程序同一总目录, 覆盖安装不丢数据)
if _IS_FROZEN:
exe_dir = Path(sys.executable).resolve().parent
return exe_dir / "data"
# 开发模式: 项目根 data/
return _PROJECT_ROOT / "data"
def _resource_root() -> Path:
"""只读资源根目录。
frozen: PyInstaller 解压目录 (_MEIPASS)
frozen: 项目根目录 (源码树)
"""
if _IS_FROZEN:
# sys._MEIPASS 是 PyInstaller 注入的解压根
return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
return Path(__file__).resolve().parent.parent.parent
def _project_root() -> Path:
"""项目根目录 (非 frozen 用)。"""
return Path(__file__).resolve().parent.parent.parent
_PROJECT_ROOT = _project_root()
_RESOURCE_ROOT = _resource_root()
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=None, # 不读取 .env
env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env",
env_file_encoding="utf-8",
extra="ignore",
)
# 数据源 API Key(个人工具直接写死;如分享代码请改为空字符串或从环境变量注入)
tickflow_api_key: str = "tk_94a20304993f45b5b0e376b9767597cc"
# TickFlow
tickflow_api_key: str = Field(default="", description="留空启用 free 模式")
# AI(可选,留空即关闭)
# AI
ai_provider: str = "openai_compat"
ai_base_url: str = "https://api.deepseek.com/v1"
ai_base_url: str = "https://api.alysc.top"
ai_api_key: str = ""
ai_model: str = "deepseek-chat"
ai_daily_token_budget: int = 500_000
ai_model: str = "gpt-5.5"
ai_codex_command: str = "codex"
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页按需修改。
ai_user_agent: str = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
# Server
host: str = "0.0.0.0"
@@ -29,16 +95,27 @@ class Settings(BaseSettings):
log_level: str = "INFO"
backtest_range_guard: bool = False
# 访问门控:留空则不启用,部署时通过 ACCESS_UUID 环境变量注入
# 优先级:ADMIN_TOKEN > 动态 UUID > ACCESS_UUID
access_uuid: str = ""
# 管理员初始令牌,硬编码以便开箱即用;如需更安全可改为空字符串并从环境变量 ADMIN_TOKEN 注入
admin_token: str = "admin7226132"
# Auth — 首次启动时预置访问密码(明文, 仅用于初始化, 详见 services/auth.bootstrap_from_env)
# 公网服务器部署时免去 SSH 端口转发设密码的麻烦。写入 auth.json(哈希)后即不再读取。
auth_password: str = ""
# 路径 — 硬编码为 Docker 容器内路径,确保数据持久化
data_dir: Path = Path("/app/data")
tiers_yaml: Path = Path("/app/tiers.yaml")
static_dir: Path = Path("/app/static")
# Data — frozen: exe 同级 data/ 子目录; 非 frozen: 项目根 data/
# (均可被环境变量 DATA_DIR 覆盖, pydantic-settings 自动注入)
data_dir: Path = _user_data_root()
# tiers.yaml 路径 — frozen: 资源目录内; 非 frozen: 项目根目录
tiers_yaml: Path = _RESOURCE_ROOT / "tiers.yaml" if _IS_FROZEN else _PROJECT_ROOT / "tiers.yaml"
# 静态文件(前端 dist) — frozen: 资源目录的 static/; 非 frozen: frontend/dist
static_dir: Path = _RESOURCE_ROOT / "static" if _IS_FROZEN else (_PROJECT_ROOT / "frontend" / "dist")
@model_validator(mode="after")
def _resolve_paths(self) -> Settings:
"""确保 data_dir 是绝对路径(环境变量传入的相对路径基于项目根目录解析)。"""
if not self.data_dir.is_absolute():
# 相对路径基于项目根目录解析,而非 CWD
self.data_dir = (_PROJECT_ROOT / self.data_dir).resolve()
return self
@property
def use_free_mode(self) -> bool:
@@ -0,0 +1,8 @@
"""Market data provider abstraction.
Providers normalize external data sources into the internal parquet schema.
"""
from app.data_providers.base import AssetType, MarketDataProvider, ProviderCapabilities
from app.data_providers.registry import get_provider
__all__ = ["AssetType", "MarketDataProvider", "ProviderCapabilities", "get_provider"]
+68
View File
@@ -0,0 +1,68 @@
"""Provider contracts for external market data sources.
The first implementation wraps TickFlow. Other providers (Tushare/AkShare/etc.)
should return the same normalized Polars schemas so storage, indicators and
backtests stay data-source agnostic.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Protocol
import polars as pl
AssetType = Literal["stock", "index", "etf"]
@dataclass(frozen=True)
class ProviderCapabilities:
instruments: bool = False
daily: bool = False
adj_factor: bool = False
minute: bool = False
realtime: bool = False
financial: bool = False
class MarketDataProvider(Protocol):
name: str
capabilities: ProviderCapabilities
def get_instruments(self, asset_type: AssetType) -> pl.DataFrame:
"""Return normalized instruments: symbol/name/code/exchange/asset_type/source."""
def get_daily(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType,
) -> pl.DataFrame:
"""Return normalized daily K rows."""
def get_adj_factors(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType,
) -> pl.DataFrame:
"""Return normalized adjustment factors: symbol/trade_date/ex_factor."""
def get_minute(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType,
freq: str = "1m",
) -> pl.DataFrame:
"""Return normalized minute K rows. Implementations may return empty."""
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
"""Return normalized realtime quotes. Implementations may return empty."""
@@ -0,0 +1,99 @@
"""Normalize provider responses into internal Polars schemas."""
from __future__ import annotations
import polars as pl
from app.indicators.pipeline import filter_halt_days
DAILY_COLS = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
ADJ_FACTOR_COLS = ["symbol", "trade_date", "ex_factor"]
INSTRUMENT_COLS = ["symbol", "name", "code", "exchange", "asset_type", "source"]
def to_polars(data) -> pl.DataFrame:
if data is None:
return pl.DataFrame()
if isinstance(data, pl.DataFrame):
return data
if isinstance(data, dict):
rows: list[dict] = []
for sym, values in data.items():
for item in values or []:
row = dict(item or {})
row.setdefault("symbol", sym)
rows.append(row)
return pl.DataFrame(rows) if rows else pl.DataFrame()
if hasattr(data, "reset_index"):
return pl.from_pandas(data.reset_index())
try:
return pl.DataFrame(data)
except Exception: # noqa: BLE001
return pl.DataFrame()
def normalize_daily(data, default_symbol: str | None = None, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
df = to_polars(data)
if df.is_empty():
return df
rename_map = {
"ts_code": "symbol",
"trade_date": "date",
"datetime": "date",
"vol": "volume",
"amt": "amount",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
if "symbol" not in df.columns and default_symbol:
df = df.with_columns(pl.lit(default_symbol).alias("symbol"))
if "date" in df.columns and df.schema["date"] != pl.Date:
df = df.with_columns(pl.col("date").cast(pl.Date, strict=False))
for col in ("open", "high", "low", "close", "volume", "amount"):
if col in df.columns:
df = df.with_columns(pl.col(col).cast(pl.Float64, strict=False))
df = filter_halt_days(df)
keep = [c for c in DAILY_COLS if c in df.columns]
return df.select(keep) if keep else pl.DataFrame()
def normalize_adj_factors(data, source: str = "tickflow") -> pl.DataFrame: # noqa: ARG001
df = to_polars(data)
if df.is_empty():
return df
rename_map = {
"timestamp": "trade_date",
"date": "trade_date",
"adj_factor": "ex_factor",
}
df = df.rename({k: v for k, v in rename_map.items() if k in df.columns})
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
df = df.with_columns(
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
if "ex_factor" in df.columns:
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
keep = [c for c in ADJ_FACTOR_COLS if c in df.columns]
return df.select(keep).drop_nulls() if len(keep) == len(ADJ_FACTOR_COLS) else pl.DataFrame()
def normalize_instruments(rows: list[dict], asset_type: str, source: str = "tickflow") -> pl.DataFrame:
if not rows:
return pl.DataFrame()
out: list[dict] = []
for item in rows:
symbol = item.get("symbol")
if not symbol:
continue
out.append({
"symbol": str(symbol),
"name": item.get("name") or str(symbol),
"code": item.get("code") or str(symbol).split(".")[0],
"exchange": item.get("exchange"),
"asset_type": asset_type,
"source": source,
})
if not out:
return pl.DataFrame()
return pl.DataFrame(out).select(INSTRUMENT_COLS).unique(subset=["symbol"], keep="last").sort("symbol")
@@ -0,0 +1,15 @@
"""Provider registry."""
from __future__ import annotations
from app.data_providers.tickflow_provider import TickFlowProvider
_PROVIDERS = {
"tickflow": TickFlowProvider,
}
def get_provider(name: str = "tickflow"):
provider_cls = _PROVIDERS.get((name or "tickflow").lower())
if provider_cls is None:
raise ValueError(f"Unsupported data provider: {name}")
return provider_cls()
@@ -0,0 +1,18 @@
"""Internal provider schema column lists."""
from __future__ import annotations
DAILY_COLUMNS = [
"symbol", "asset_type", "source", "date", "open", "high", "low", "close",
"volume", "amount", "pre_close", "change_pct",
]
ADJ_FACTOR_COLUMNS = ["symbol", "asset_type", "source", "trade_date", "ex_factor"]
INSTRUMENT_COLUMNS = [
"symbol", "name", "exchange", "asset_type", "source", "list_date", "status",
]
MINUTE_COLUMNS = [
"symbol", "asset_type", "source", "datetime", "open", "high", "low", "close",
"volume", "amount", "freq",
]
@@ -0,0 +1,120 @@
"""TickFlow provider implementation."""
from __future__ import annotations
import logging
from datetime import datetime
import polars as pl
from app.data_providers.base import AssetType, ProviderCapabilities
from app.data_providers.normalizer import normalize_adj_factors, normalize_daily, normalize_instruments
from app.tickflow.client import get_client
logger = logging.getLogger(__name__)
_EXCHANGES = ["SH", "SZ", "BJ"]
class TickFlowProvider:
name = "tickflow"
capabilities = ProviderCapabilities(
instruments=True,
daily=True,
adj_factor=True,
minute=True,
realtime=True,
financial=True,
)
def get_instruments(self, asset_type: AssetType) -> pl.DataFrame:
tf = get_client()
instrument_type = "stock" if asset_type == "stock" else asset_type
rows: list[dict] = []
for ex in _EXCHANGES:
try:
items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type)
rows.extend([it for it in (items or []) if isinstance(it, dict)])
except Exception as e: # noqa: BLE001
logger.warning("TickFlow instruments %s/%s failed: %s", ex, instrument_type, e)
return normalize_instruments(rows, asset_type=asset_type, source=self.name)
def get_daily(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType, # noqa: ARG002
) -> pl.DataFrame:
if not symbols:
return pl.DataFrame()
tf = get_client()
kwargs = {
"period": "1d",
"adjust": "none",
"count": 10000 if start_time and end_time else 250,
"as_dataframe": True,
"show_progress": False,
}
if start_time and end_time:
from app.services.kline_sync import _datetime_to_ms
kwargs["start_time"] = _datetime_to_ms(start_time)
kwargs["end_time"] = _datetime_to_ms(end_time)
raw = tf.klines.batch(symbols, **kwargs)
frames: list[pl.DataFrame] = []
if isinstance(raw, dict):
for sym, sub in raw.items():
normalized = normalize_daily(sub, default_symbol=sym, source=self.name)
if not normalized.is_empty():
frames.append(normalized)
else:
normalized = normalize_daily(raw, source=self.name)
if not normalized.is_empty():
frames.append(normalized)
return pl.concat(frames, how="diagonal_relaxed") if frames else pl.DataFrame()
def get_adj_factors(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType, # noqa: ARG002
) -> pl.DataFrame:
if not symbols:
return pl.DataFrame()
tf = get_client()
kwargs = {"as_dataframe": False}
if start_time or end_time:
from app.services.kline_sync import _datetime_to_ms
if start_time:
kwargs["start_time"] = _datetime_to_ms(start_time)
if end_time:
kwargs["end_time"] = _datetime_to_ms(end_time)
raw = tf.klines.ex_factors(symbols, **kwargs)
return normalize_adj_factors(raw, source=self.name)
def get_minute(
self,
symbols: list[str],
start_time: datetime | None,
end_time: datetime | None,
asset_type: AssetType, # noqa: ARG002
freq: str = "1m", # noqa: ARG002
) -> pl.DataFrame:
# Existing minute sync remains in app.services.kline_sync for now.
return pl.DataFrame()
def get_realtime(
self,
universes: list[str] | None = None,
symbols: list[str] | None = None,
) -> pl.DataFrame:
tf = get_client()
if universes and symbols:
raise ValueError("TickFlow realtime accepts either universes or symbols, not both")
if universes:
resp = tf.quotes.get_by_universes(universes=universes)
elif symbols:
resp = tf.quotes.get(symbols=symbols)
else:
return pl.DataFrame()
return pl.DataFrame(resp or [])
+241
View File
@@ -0,0 +1,241 @@
"""桌面客户端入口 — uvicorn 后台服务 + pywebview 桌面窗口。
运行方式:
开发模式: python -m app.desktop ( pip install pywebview)
打包后: 双击可执行文件即可
职责:
1. 单实例锁 已运行则聚焦已有窗口并退出
2. 选可用端口 settings.port , 被占则递增
3. 后台线程起 uvicorn (仅监听 127.0.0.1, 不暴露外网)
4. 主线程起 pywebview 窗口渲染前端
5. 窗口关闭 优雅停止 uvicorn 进程退出
不含: 业务逻辑配置持久化监控告警 (全在 app.main )
"""
from __future__ import annotations
import logging
import socket
import sys
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
_APP_NAME = "TickFlow 股票面板"
_BASE_PORT = 3018
_PORT_PROBE_RANGE = 50 # 从 3018 起最多试 50 个端口
def _ensure_data_dir_writable() -> None:
"""确保用户数据目录可写 (lifespan 会创建子目录, 这里只验证根目录)。
data_dir frozen 模式下指向用户目录 ( config.py), 非可写会导致
DuckDB 视图 / parquet 落盘全失败提前失败胜过启动后乱报错
"""
from app.config import settings
data_root = settings.data_dir
try:
data_root.mkdir(parents=True, exist_ok=True)
probe = data_root / ".write_probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink(missing_ok=True)
except Exception as e: # noqa: BLE001
logger.error("数据目录不可写, 桌面版无法运行: %s (%s)", data_root, e)
raise
def _acquire_single_instance() -> bool:
"""单实例锁。已运行返回 False (本进程应退出), 否则 True。
data_dir/.desktop.lock 文件锁实现跨进程, 文件存在即视为已运行
(简单可靠; 不引入 msvcrt/fcntl 平台差异)
"""
from app.config import settings
lock_path = settings.data_dir / ".desktop.lock"
if lock_path.exists():
# 软检测: 写入进程 PID, 若该 PID 已不存在则视为残留锁, 允许接管
try:
pid_str = lock_path.read_text(encoding="utf-8").strip()
pid = int(pid_str) if pid_str.isdigit() else None
except Exception: # noqa: BLE001
pid = None
if pid is not None and _pid_alive(pid):
logger.warning("检测到已有实例运行 (PID %d), 本进程退出", pid)
return False
# 残留锁: 清理后继续
logger.info("清理残留单实例锁 (PID %s 已不存在)", pid)
lock_path.write_text(str(_current_pid()), encoding="utf-8")
return True
def _release_single_instance() -> None:
from app.config import settings
lock_path = settings.data_dir / ".desktop.lock"
try:
lock_path.unlink(missing_ok=True)
except Exception: # noqa: BLE001
pass
def _pid_alive(pid: int) -> bool:
"""检查指定 PID 的进程是否存活。"""
import os
if os.name == "nt":
# Windows: 0 表示存在, 其它是异常
try:
os.kill(pid, 0)
return True
except OSError:
return False
else:
try:
os.kill(pid, 0) # signal 0 = 探测存活, 不实际发信号
return True
except OSError:
return False
def _current_pid() -> int:
import os
return os.getpid()
def _find_free_port(start: int, count: int = _PORT_PROBE_RANGE) -> int:
"""从 start 起找第一个可用端口。全部被占则返回 start (交给 uvicorn 报错)。"""
for port in range(start, start + count):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
return start
def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
"""后台线程: 启动 uvicorn 服务。ready_event 在线程退出时置位 (通知主线程)。"""
import uvicorn
# 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成)
from app.main import app
config = uvicorn.Config(
app,
host="127.0.0.1", # 仅本机, 不暴露外网 (桌面版无需远程访问)
port=port,
log_level="info",
access_log=False, # 桌面版不需要访问日志
loop="auto",
)
server = uvicorn.Server(config)
# 线程结束时通知主线程 (无论正常退出还是异常)
def _signal_done(*exc):
ready_event.set()
server.config.callback_notify = None # 不用 notify 机制
try:
server.run()
finally:
ready_event.set()
def _wait_for_server(port: int, timeout: float = 60.0) -> bool:
"""轮询 health 接口直到后端就绪或超时。
monkey-patch uvicorn 内部方法更健壮, 不依赖版本内部实现
"""
import urllib.request
import urllib.error
url = f"http://127.0.0.1:{port}/health"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as r:
if r.status == 200:
return True
except (urllib.error.URLError, ConnectionError, OSError):
pass
time.sleep(0.5)
return False
def _open_window(url: str) -> None:
"""主线程: 用 pywebview 打开桌面窗口。"""
import webview # type: ignore[import-not-found]
window = webview.create_window(
_APP_NAME,
url,
width=1440,
height=900,
min_size=(1024, 700),
# 桌面版固定单窗口, 禁用外部浏览器跳转
confirm_close=False,
)
# pywebview 会阻塞主线程直到窗口关闭
webview.start(debug=False)
def main() -> int:
"""桌面客户端主入口。返回进程退出码。"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
try:
_ensure_data_dir_writable()
except Exception:
# 数据目录不可写是致命错误, 无法继续
return 1
# 单实例: 已运行则退出
if not _acquire_single_instance():
return 0
try:
port = _find_free_port(_BASE_PORT)
logger.info("桌面版后端将监听 127.0.0.1:%d", port)
# 后台线程起 uvicorn
ready = threading.Event()
server_thread = threading.Thread(
target=_run_uvmicorn, args=(port, ready), daemon=True,
name="uvicorn",
)
server_thread.start()
# 轮询 health 接口等后端就绪 (含 lifespan 初始化, 最多 60s)
if not _wait_for_server(port, timeout=60.0):
logger.error("后端启动超时, 桌面版退出")
_release_single_instance()
return 1
url = f"http://127.0.0.1:{port}"
logger.info("打开桌面窗口: %s", url)
_open_window(url)
# 窗口关闭后, 进程退出 (daemon 线程会被回收)
logger.info("窗口已关闭, 桌面版退出")
return 0
except KeyboardInterrupt:
return 0
finally:
_release_single_instance()
if __name__ == "__main__":
sys.exit(main())
+600
View File
@@ -0,0 +1,600 @@
"""关键价位计算 —— 独立模块,纯函数,无 IO / 无存储。
输入: 已经包含 OHLCV polars K DataFrame(内存中,通常来自 KlineRepository 缓存)
输出: 4 类结构化价位点,:
- 图表 markLine 渲染(压力位 / 支撑位 / 成交密集区 / 枢轴点 / 前高前低)
- AI 个股分析提示词(价位上下文)
设计:
- 纯函数 + polars 向量化,毫秒级,无需落盘
- 每个点位带 {value, label, type, side, strength?},前端直接画水平价格线
- NaN/Inf 全部过滤,空数据返回空列表,不抛异常
"""
from __future__ import annotations
import logging
from typing import Any
import polars as pl
logger = logging.getLogger(__name__)
# ================================================================
# 输出结构
# ================================================================
class PriceLevel:
"""单个价位点的数据结构(用 dict 表达,这里只作文档说明)。
{
"value": 12.34, # 价格
"label": "压力位 R1", # 显示标签
"type": "pivot", # 类型分组(同类型用一个开关按钮控制显隐)
"side": "resistance", # 方向:resistance(压力) / support(支撑) / neutral
"strength": "medium", # 强度:strong / medium / weak(可选,影响线型)
"rank": 1, # 档位(仅 pivot 有):0=P,1=R1/S1,2=R2/S2,3=R3/S3
# 前端按"显示到第几档"过滤,非 pivot 点位无此字段
}
"""
# 价位分组 → 开关 key。前端按这个 type 显隐。
LEVEL_TYPES = {
"sr": "压力支撑", # 成交密集区(价量:Volume Profile POC + 高成交密集区)
"pivot": "枢轴点", # 经典 Pivot P/R/S
"extreme": "前高前低", # 60/250 日极值 + 近期 swing 高低点
"boll": "布林带", # MA20 ± 2σ,标准差波动带(参考性,非真实支撑压力)
"keltner_s": "Keltner短期", # MA20 ± 2×ATR
"keltner_m": "Keltner中期", # MA60 ± 2.5×ATR
"keltner_l": "Keltner长期", # MA120 ± 3×ATR(牛熊趋势边界)
"atr_stop": "ATR止损", # close±nATR 动态止盈止损
"gap": "缺口位", # 未回补跳空缺口
"fib": "斐波那契", # 回撤位 0.236~0.786
"round": "整数关口", # 心理整数位
}
# ================================================================
# 1. 压力位 / 支撑位 —— 成交量分布 (Volume Profile)
# ================================================================
def _support_resistance(df: pl.DataFrame, bins: int = 40) -> list[dict]:
"""成交量分布 (Volume Profile) —— 真正基于价+量的支撑/压力位。
把每个价位层按价格分桶,统计落在该桶的累计成交量,取高成交密集区作为关键
价位带 BOLL/Keltner "波动通道"不同,成交密集区反映的是真实换手堆积,
是经典意义的支撑/压力
密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带:
- POC(控制点):成交量最大的桶,标记为 strong
- 其他高成交区:高于均值,标记为 medium
"""
if df.is_empty() or "volume" not in df.columns or df.height < 20:
return []
hi = float(df["high"].max())
lo = float(df["low"].min())
if not (hi > lo > 0):
return []
# 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型)
df2 = df.select([
((pl.col("high") + pl.col("low")) / 2).alias("mid"),
pl.col("volume").alias("vol"),
]).drop_nulls()
# 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段
step = (hi - lo) / bins
edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值
breaks = edges[1:-1] # 内部 break,bins-1 个
bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1
# 至少要有 1 个不同的内部 break
if len(set(f"{b:.6f}" for b in breaks)) < 1:
return []
df2 = df2.with_columns(
pl.col("mid").cut(breaks, labels=bin_labels).alias("bin")
)
prof = df2.group_by("bin").agg(pl.col("vol").sum())
if prof.is_empty():
return []
# 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引
prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi")
bin_ids = prof["bi"].to_list()
vols = prof["vol"].to_list()
mean_vol = sum(vols) / len(vols) if vols else 0
def bin_mid(bin_id: int) -> float:
return (edges[bin_id] + edges[bin_id + 1]) / 2
close = float(df.tail(1)["close"][0])
out: list[dict] = []
# POC:成交量最大的桶
poc_pos = max(range(len(vols)), key=lambda i: vols[i])
poc_mid = bin_mid(bin_ids[poc_pos])
out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)",
"type": "sr", "side": _side(poc_mid, close), "strength": "strong"})
# 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个
candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos]
candidates.sort(key=lambda x: x[1], reverse=True)
for i, _v in candidates[:2]:
mid = bin_mid(bin_ids[i])
out.append({"value": round(mid, 2), "label": "成交密集区",
"type": "sr", "side": _side(mid, close), "strength": "medium"})
return out
# ================================================================
# 2. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日
# ================================================================
def _pivot_points(df: pl.DataFrame) -> list[dict]:
"""经典 Pivot:P = (H+L+C)/3, R1/R2/R3, S1/S2/S3。
基准:最后 1 K(代表"上一交易日")实务中常用前一日,这里取最后一根
"""
if df.is_empty():
return []
last = df.tail(1)
h = last["high"][0]
l = last["low"][0]
c = last["close"][0]
if not _ok(h) or not _ok(l) or not _ok(c):
return []
h, l, c = float(h), float(l), float(c)
p = (h + l + c) / 3
r1 = 2 * p - l
s1 = 2 * p - h
r2 = p + (h - l)
s2 = p - (h - l)
r3 = h + 2 * (p - l)
s3 = l - 2 * (h - p)
def lv(v: float, label: str, side: str, strength: str, rank: int) -> dict:
# rank:档位标记,前端据此按"显示到第几档"过滤
# 0 = 枢轴位 P(始终显示)
# 1 = R1/S1(第一档压力/支撑)
# 2 = R2/S2(第二档)
# 3 = R3/S3(第三档,极端,实际很少触及)
return {"value": round(v, 2), "label": label, "type": "pivot",
"side": side, "strength": strength, "rank": rank}
return [
lv(p, "枢轴位 P", "neutral", "strong", 0),
lv(r1, "压力位 R1", "resistance", "medium", 1),
lv(r2, "压力位 R2", "resistance", "medium", 2),
lv(r3, "压力位 R3", "resistance", "weak", 3),
lv(s1, "支撑位 S1", "support", "medium", 1),
lv(s2, "支撑位 S2", "support", "medium", 2),
lv(s3, "支撑位 S3", "support", "weak", 3),
]
# ================================================================
# 3. 前高 / 前低 —— 60 / 120 / 250 日极值
# ================================================================
def _extreme_levels(df: pl.DataFrame) -> list[dict]:
"""关键前高 / 前低 —— 历史极值 + 近期 swing 高低点(收敛后)。
设计:把所有"前高前低"类点位集中在本组, sr(通道)区分:
- 60 日极值:近一季度高低点(短期参照)
- 250 日极值:年度高低点(牛熊分界参照);跳过 120 ( 250 日包含,信息冗余)
- swing 高低点:近期局部转折点,每侧只取距当前价最近的 2
"""
if df.is_empty():
return []
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
out: list[dict] = []
# —— 历史极值(只取 60 / 250,避免中间档冗余)——
for n in (60, 250):
if df.height < n:
continue
sub = df.tail(n)
hi = float(sub["high"].max())
lo = float(sub["low"].min())
if _ok(hi):
out.append({"value": round(hi, 2), "label": f"{n}日新高",
"type": "extreme", "side": "resistance", "strength": "strong"})
if _ok(lo):
out.append({"value": round(lo, 2), "label": f"{n}日新低",
"type": "extreme", "side": "support", "strength": "strong"})
# —— 近期 swing 高低点(每侧只取距当前价最近的 2 个,避免点位爆炸)——
win = 5
if df.height > win * 2 and close:
highs = df["high"].to_list()
lows = df["low"].to_list()
swing_highs: list[float] = []
swing_lows: list[float] = []
for i in range(win, len(highs) - win):
if highs[i] == max(highs[i - win:i + win + 1]):
swing_highs.append(float(highs[i]))
if lows[i] == min(lows[i - win:i + win + 1]):
swing_lows.append(float(lows[i]))
# 聚合 ±1% 相近价位,再按距当前价排序取最近 2 个
agg_h = _aggregate_levels(swing_highs, 0.01)
agg_h = [v for v in agg_h if v > close * 1.001]
agg_h.sort(key=lambda v: abs(v - close))
for v in agg_h[:2]:
out.append({"value": round(v, 2), "label": "前高",
"type": "extreme", "side": "resistance", "strength": "medium"})
agg_l = _aggregate_levels(swing_lows, 0.01)
agg_l = [v for v in agg_l if v < close * 0.999]
agg_l.sort(key=lambda v: abs(v - close))
for v in agg_l[:2]:
out.append({"value": round(v, 2), "label": "前低",
"type": "extreme", "side": "support", "strength": "medium"})
return out
# ================================================================
# 4. 波动通道 —— 布林带 + Keltner 三档,各自独立开关
# ================================================================
def _ma_value(df: pl.DataFrame, ma_col: str | None, window: int) -> float | None:
"""取某档均线值:优先用预计算列,缺失则现场 rolling_mean。"""
last = df.tail(1)
if ma_col and ma_col in df.columns:
v = last[ma_col][0]
return float(v) if _ok(v) else None
if df.height >= window:
v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0]
return float(v) if _ok(v) else None
return None
def _keltner_band(
df: pl.DataFrame, ma_col: str | None, window: int, n: float,
label_short: str, type_key: str,
) -> list[dict]:
"""单档 Keltner 通道:均线 ± n×ATR。
ATR 自适应波动,通道宽度随行情自动收缩/扩张type_key 决定归入哪一组
(keltner_s / keltner_m / keltner_l),前端各自独立开关
"""
if df.is_empty() or df.height < 20 or "atr_14" not in df.columns:
return []
last = df.tail(1)
close = float(last["close"][0]) if "close" in df.columns else 0
atr = float(last["atr_14"][0])
if not close or not _ok(atr):
return []
ma_val = _ma_value(df, ma_col, window)
if ma_val is None:
return []
upper = ma_val + n * atr
lower = ma_val - n * atr
return [
{"value": round(upper, 2), "label": f"{label_short}通道上轨",
"type": type_key, "side": _side(upper, close), "strength": "medium"},
{"value": round(lower, 2), "label": f"{label_short}通道下轨",
"type": type_key, "side": _side(lower, close), "strength": "medium"},
]
def _boll_channel(df: pl.DataFrame) -> list[dict]:
"""布林带上下轨(MA20 ± 2σ)。
基于标准差的波动带,反映价格相对均线的统计偏离;非真实支撑压力,
仅作波动边界参考数据直接取预计算列 boll_upper/boll_lower
"""
if df.is_empty() or "boll_upper" not in df.columns or "boll_lower" not in df.columns:
return []
last = df.tail(1)
close = float(last["close"][0]) if "close" in df.columns else 0
if not close:
return []
bu = last["boll_upper"][0]
bl = last["boll_lower"][0]
if not _ok(bu) or not _ok(bl):
return []
bu, bl = float(bu), float(bl)
out = [
{"value": round(bu, 2), "label": "布林上轨",
"type": "boll", "side": _side(bu, close), "strength": "medium"},
{"value": round(bl, 2), "label": "布林下轨",
"type": "boll", "side": _side(bl, close), "strength": "medium"},
]
# 布林中轨 = MA20(多空平衡线,价格在其上下分强弱);数据层已预计算 ma20
if "ma20" in df.columns:
mid = last["ma20"][0]
if _ok(mid):
mid = float(mid)
out.append({"value": round(mid, 2), "label": "布林中轨",
"type": "boll", "side": _side(mid, close), "strength": "medium"})
return out
def _keltner_short(df: pl.DataFrame) -> list[dict]:
"""Keltner 短期:MA20 ± 2×ATR(近期波动带,约一个月)。"""
return _keltner_band(df, "ma20", 20, 2.0, "短期", "keltner_s")
def _keltner_mid(df: pl.DataFrame) -> list[dict]:
"""Keltner 中期:MA60 ± 2.5×ATR(季度波动带)。"""
return _keltner_band(df, "ma60", 60, 2.5, "中期", "keltner_m")
def _keltner_long(df: pl.DataFrame) -> list[dict]:
"""Keltner 长期:MA120 ± 3×ATR(半年波动带,牛熊趋势边界)。"""
return _keltner_band(df, None, 120, 3.0, "长期", "keltner_l")
# ================================================================
# 5. ATR 止损位 —— close ± n × ATR,动态止盈止损
# ================================================================
def _atr_stops(df: pl.DataFrame) -> list[dict]:
"""基于 ATR 的动态止损/止盈位。
ATR 衡量平均真实波幅,close ± n×ATR 是交易者最常用的止损位算法:
- 止损位:close - 2×ATR (跌破即趋势破坏)
- 止盈位:close + 2×ATR (突破即顺势扩展)
- 近端波动带:close ± 1.5×ATR (中短期风控参考)
"""
if df.is_empty() or "atr_14" not in df.columns:
return []
last = df.tail(1)
close = float(last["close"][0])
atr = float(last["atr_14"][0])
if not _ok(close) or not _ok(atr):
return []
def lv(v: float, label: str, side: str, strength: str) -> dict:
return {"value": round(v, 2), "label": label, "type": "atr_stop",
"side": side, "strength": strength}
return [
lv(close + 2 * atr, "ATR 止盈(+2)", "resistance", "medium"),
lv(close + 1.5 * atr, "ATR 上轨(+1.5)", "resistance", "weak"),
lv(close - 1.5 * atr, "ATR 下轨(-1.5)", "support", "weak"),
lv(close - 2 * atr, "ATR 止损(-2)", "support", "medium"),
]
# ================================================================
# 6. 缺口位 (Gap) —— 未回补的跳空缺口
# ================================================================
def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]:
"""近期未回补的向上/向下跳空缺口。
向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补)
向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补)
缺口是天然的支撑/阻力位只保留"未回补"(后续价格未回到缺口区间内),
并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3
"""
if df.is_empty() or df.height < 5:
return []
sub = df.tail(lookback) if df.height > lookback else df
close = float(df.tail(1)["close"][0])
highs = sub["high"].to_list()
lows = sub["low"].to_list()
up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点)
dn_gaps: list[tuple[float, float]] = []
for i in range(1, len(highs)):
if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]):
if lows[i] > highs[i - 1]: # 向上缺口
up_gaps.append((highs[i - 1], lows[i]))
elif highs[i] < lows[i - 1]: # 向下缺口
dn_gaps.append((highs[i], lows[i - 1]))
def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]:
"""过滤掉已被后续价格回补的缺口,取缺口价位中点。"""
mids: list[float] = []
for g_lo, g_hi in gaps:
# 未回补判定:当前价不在缺口区间内
if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方)
mids.append((g_lo + g_hi) / 2)
elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补
mids.append((g_lo + g_hi) / 2)
# 聚合相近缺口 + 按距当前价排序取最近 3 个
agg = _aggregate_levels(mids, 0.005)
agg.sort(key=lambda v: abs(v - close))
return agg[:3]
out: list[dict] = []
for mid in _filter_unfilled(up_gaps, True):
out.append({"value": round(mid, 2), "label": "向上缺口",
"type": "gap", "side": _side(mid, close), "strength": "medium"})
for mid in _filter_unfilled(dn_gaps, False):
out.append({"value": round(mid, 2), "label": "向下缺口",
"type": "gap", "side": _side(mid, close), "strength": "medium"})
return out
# ================================================================
# 7. 斐波那契回撤 —— 基于近期波段的回撤位
# ================================================================
def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]:
"""基于近期一段明确趋势的斐波那契回撤位。
取近 window 个交易日的最高/最低点:
- 若高点出现在低点之后(上涨波段):从低到高,回撤 = high - range × ratio
- 若低点出现在高点之后(下跌波段):从高到低,回撤 = low + range × ratio
比率:0.236 / 0.382 / 0.5 / 0.618 / 0.786
"""
if df.is_empty() or df.height < 10:
return []
sub = df.tail(window) if df.height > window else df
close = float(df.tail(1)["close"][0])
highs = sub["high"].to_list()
lows = sub["low"].to_list()
hi_pos = highs.index(max(highs))
lo_pos = lows.index(min(lows))
hi_val = float(highs[hi_pos])
lo_val = float(lows[lo_pos])
if not _ok(hi_val) or not _ok(lo_val) or hi_val <= lo_val:
return []
ratios = [0.236, 0.382, 0.5, 0.618, 0.786]
rng = hi_val - lo_val
out: list[dict] = []
# 判断波段方向:高点在低点之后 = 上涨波段(从低回撤)
up_trend = hi_pos > lo_pos
for r in ratios:
if up_trend:
val = hi_val - rng * r # 从高点向下回撤
else:
val = lo_val + rng * r # 从低点向上回撤
out.append({"value": round(val, 2), "label": f"Fib {int(r * 1000) / 10:.1f}%",
"type": "fib", "side": _side(val, close), "strength": "medium"})
return out
# ================================================================
# 8. 整数关口 —— 心理支撑/阻力位
# ================================================================
def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> list[dict]:
"""当前价附近的心理整数关口。
整数位( 10/11/12, 60/65/70)是天然的心理支撑/阻力,
低价股尤其明显按价格量级自适应步长:
- 价格 < 10: 步长 0.5 ( 6.5, 7.0, 7.5)
- 价格 < 20: 步长 1 ( 11, 12, 13)
- 价格 < 100: 步长 5 ( 60, 65, 70)
- 价格 < 500: 步长 10 ( 110, 120, 130)
- 价格 >= 500: 步长 50 ( 1100, 1150, 1200)
过滤掉距当前价 <1% (太近,无分析价值),最多 max_count
"""
if df.is_empty():
return []
close = float(df.tail(1)["close"][0])
if not _ok(close):
return []
if close < 10:
step = 0.5
elif close < 20:
step = 1.0
elif close < 100:
step = 5.0
elif close < 500:
step = 10.0
else:
step = 50.0
lo = close * (1 - pct)
hi = close * (1 + pct)
# 找区间 [lo, hi] 内所有 step 的整数倍(严格限定在区间内)
start = (int(lo / step) + (1 if lo % step > 0 else 0)) * step
candidates: list[float] = []
v = start
while v <= hi:
if v > 0:
candidates.append(round(v, 2))
v += step
# 按距当前价从近到远排序,取前 max_count 个
candidates.sort(key=lambda x: abs(x - close))
out: list[dict] = []
for v in candidates[:max_count]:
# 过滤距当前价 <1% 的(太近,无分析价值)
if abs(v - close) / close < 0.01:
continue
out.append({"value": round(v, 2), "label": f"整数关口 {v:g}",
"type": "round", "side": _side(v, close), "strength": "weak"})
return out
def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]:
"""计算 11 类价位点,返回 {分组key: [点位...]}。
分组 key LEVEL_TYPES 一致(sr / pivot / extreme / boll /
keltner_s / keltner_m / keltner_l / atr_stop / gap / fib / round),
前端按 key 渲染开关按钮,逐组显隐
"""
if df.is_empty():
return {k: [] for k in LEVEL_TYPES}
try:
return {
"sr": _support_resistance(df),
"pivot": _pivot_points(df),
"extreme": _extreme_levels(df),
"boll": _boll_channel(df),
"keltner_s": _keltner_short(df),
"keltner_m": _keltner_mid(df),
"keltner_l": _keltner_long(df),
"atr_stop": _atr_stops(df),
"gap": _gap_levels(df),
"fib": _fibonacci_levels(df),
"round": _round_numbers(df),
}
except Exception as e: # noqa: BLE001
logger.warning("compute_levels failed: %s", e)
return {k: [] for k in LEVEL_TYPES}
def summarize_levels(levels: dict[str, list[dict]], close: float | None) -> str:
"""生成给 AI 提示词的价位摘要文本(紧凑,供上下文)。"""
if not close:
return "无价位数据"
parts: list[str] = []
# 当前价
parts.append(f"当前价 {close:.2f}")
# 每组取前 2 个最相关的(距当前价近的优先)
for key, label in LEVEL_TYPES.items():
pts = levels.get(key, [])
if not pts:
continue
# 按距当前价排序,取前 2
ranked = sorted(pts, key=lambda p: abs(p["value"] - close))[:2]
desc = "".join(
f"{p['label']}={p['value']}" for p in ranked
)
parts.append(f"{label}: {desc}")
return " · ".join(parts)
# ================================================================
# 内部工具
# ================================================================
def _ok(v: Any) -> bool:
"""数值有效(非空/非 NaN/非 Inf/正数)。"""
try:
f = float(v)
except (TypeError, ValueError):
return False
import math
return math.isfinite(f) and f > 0
def _side(level: float, close: float) -> str:
"""价位相对当前价的方向。"""
if level > close * 1.001:
return "resistance"
if level < close * 0.999:
return "support"
return "neutral"
def _aggregate_levels(values: list[float], tol: float) -> list[float]:
"""把相近的价位聚合(±tol),返回去重后的代表值(保留最新)。"""
if not values:
return []
values = sorted(values)
out: list[float] = [values[0]]
for v in values[1:]:
if abs(v - out[-1]) / out[-1] <= tol:
out[-1] = v # 聚合到最新(更近期)
else:
out.append(v)
return out
+25 -7
View File
@@ -1381,8 +1381,9 @@ def compute_enriched_today(
def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) -> pl.DataFrame:
"""盘中增量版的涨跌停/换手率/炸板/连板计算。"""
inst_cols = ["symbol"]
if "float_shares" in instruments.columns:
inst_cols.append("float_shares")
for c in ["float_shares", "limit_up", "limit_down"]:
if c in instruments.columns:
inst_cols.append(c)
inst_subset = instruments.select(inst_cols).unique(subset=["symbol"])
if "name" in instruments.columns:
st_flag = (
@@ -1431,14 +1432,31 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
limit_up_price = _limit_price(prev_raw, limit_pct, up=True)
limit_down_price = _limit_price(prev_raw, limit_pct, up=False)
# 生效涨跌停价: 优先用维表权威值 (instruments.limit_up/down, 交易所级别精确价),
# 维表缺失 (新股上市前 5 日: limit_up 为 null 或哨兵 100000) 回退自算理论价。
# 哨兵阈值 10000 用于识别 "新股无涨跌停限制" 的占位值 (实际涨停价不可能上万)。
_SENTINEL = 10000.0
if "limit_up" in df.columns:
effective_limit_up = pl.when(
pl.col("limit_up").is_not_null() & (pl.col("limit_up") < _SENTINEL)
).then(pl.col("limit_up")).otherwise(limit_up_price)
else:
effective_limit_up = limit_up_price
if "limit_down" in df.columns:
effective_limit_down = pl.when(
pl.col("limit_down").is_not_null() & (pl.col("limit_down") < _SENTINEL)
).then(pl.col("limit_down")).otherwise(limit_down_price)
else:
effective_limit_down = limit_down_price
is_limit_up = (
pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
.then((pl.col("raw_close") - limit_up_price).abs() < 0.005)
.then(pl.col("raw_close") >= (effective_limit_up - 0.005))
.otherwise(None).cast(pl.Boolean)
)
is_limit_down = (
pl.when((prev_raw > 0) & (pl.col("raw_close") > 0))
.then((pl.col("raw_close") - limit_down_price).abs() < 0.005)
.then(pl.col("raw_close") <= (effective_limit_down + 0.005))
.otherwise(None).cast(pl.Boolean)
)
@@ -1449,7 +1467,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
pl.when(prev_raw > 0)
.then(
(~is_limit_down.fill_null(True))
& (pl.col("low") <= limit_down_price + 0.005)
& (pl.col("low") <= effective_limit_down + 0.005)
& (pl.col("close") > pl.col("open"))
).otherwise(None).cast(pl.Boolean)
.alias("signal_limit_down_recovery"),
@@ -1457,7 +1475,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
pl.when((prev_raw > 0) & (pl.col("raw_high") > 0))
.then(
(~is_limit_up.fill_null(True))
& (pl.col("raw_high") >= limit_up_price - 0.005)
& (pl.col("raw_high") >= effective_limit_up - 0.005)
).otherwise(None).cast(pl.Boolean)
.alias("signal_broken_limit_up"),
])
@@ -1482,7 +1500,7 @@ def _compute_limit_signals_today(df: pl.DataFrame, instruments: pl.DataFrame) ->
])
# 清理
cleanup = ["_limit_pct", "_is_st"]
cleanup = ["_limit_pct", "_is_st", "limit_up", "limit_down"]
for c in df.columns:
if c.endswith("_inst"):
cleanup.append(c)
+372 -65
View File
@@ -1,7 +1,7 @@
"""盘后管道 + 盘前维表同步。
调度:
09:10 盘前 同步标的维表 instruments (全量覆盖)
09:10 盘前 同步个股维表 instruments (全量覆盖)
15:30 盘后 日K同步 + 增量除权因子 + enriched 计算 + 刷新视图
盘后同步策略:
@@ -20,7 +20,7 @@ from apscheduler.triggers.cron import CronTrigger
from app.indicators.pipeline import run_pipeline
from app.config import settings
from app.services import index_sync, instrument_sync, kline_sync
from app.services import index_sync, instrument_sync, kline_sync, preferences as _prefs
from app.tickflow.capabilities import Cap, CapabilitySet
from app.tickflow.pools import DEMO_SYMBOLS, get_pool
from app.tickflow.repository import KlineRepository
@@ -69,7 +69,7 @@ def _resolve_universe(capset: CapabilitySet) -> list[str]:
def run_instruments_sync(repo: KlineRepository) -> dict:
"""盘前同步标的维表。"""
"""盘前同步个股维表。"""
rows = instrument_sync.sync_instruments(repo.store.data_dir)
_refresh_instruments_view(repo)
_invalidate("instruments")
@@ -89,12 +89,12 @@ def run_now(
emit = on_progress or _noop
skipped: list[str] = []
# Step 0: 先同步标的维表, 再解析标的池 — 确保标的池基于最新 instruments
emit("sync_instruments", 2, "同步标的维表…")
# Step 0: 先同步个股维表, 再解析标的池 — 确保标的池基于最新 instruments
emit("sync_instruments", 2, "同步个股维表…")
inst_rows = instrument_sync.sync_instruments(repo.store.data_dir)
if inst_rows > 0:
_refresh_instruments_view(repo)
emit("sync_instruments", 8, f"标的维表同步完成,{inst_rows} 只标的")
emit("sync_instruments", 8, f"个股维表同步完成,{inst_rows} 只标的")
_invalidate("instruments")
emit("resolve_universe", 9, "解析标的池…")
@@ -102,27 +102,41 @@ def run_now(
emit("resolve_universe", 10, f"标的池规模:{len(universe)}")
# Step 1: 日 K 同步
# 今天有数据 → 实时行情接口拉一次覆写(1请求全市场)
# 今天没数据 → batch K-line API 补齐
# 付费档 + 今天有数据 → 实时行情接口拉一次覆写(1请求全市场)
# 有历史数据 → batch K-line API 补齐缺口
# 无任何数据 → batch K-line API 拉首次 1 年
from datetime import date as _date, timedelta as _td, datetime as _dt
latest_daily = repo.latest_daily_date()
today = _date.today()
today_exists = latest_daily and latest_daily >= today
new_daily_days = 0
# 日K范围拉取的起点(分支3补缺口/分支4首次); 实时增量/跳过时为 None。
# 供 Step 1.5 除权因子回溯范围对齐: 范围拉取→用日K范围, 非范围→最近N天兜底。
daily_range_start: _date | None = None
if today_exists:
# 今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新
# A 股日K拉取开关(默认开);关闭时跳过日K同步,保留已有数据
pull_a_share = _prefs.get_pipeline_pull_a_share()
if not pull_a_share:
emit("sync_daily", 45, "已跳过 A 股日K同步(拉取内容未勾选)")
logger.info("sync_daily: skipped (pipeline_pull_a_share=False)")
elif today_exists and capset.has(Cap.QUOTE_POOL):
# 付费档:今天有数据(QuoteService 已落盘)→ 实时行情覆写,确保最新。
# free/none 档无 quote.pool 能力,即便今天已有数据(如从 expert 降级),
# 也降级到下方 batch 路径刷新,避免调用无权限的实时行情接口。
emit("sync_daily", 12, f"获取日K [{today} ~ {today}] 实时行情…")
written_daily = kline_sync.sync_daily_by_quotes(repo)
new_daily_days = 1
emit("sync_daily", 45, f"日K 完成,{written_daily} 只标的")
logger.info("sync_daily: [%s ~ %s] live quotes, %d symbols", today, today, written_daily)
elif latest_daily:
# 有历史但今天没数据 → batch 补齐缺口
# 有历史 → batch 补齐缺口
# 也覆盖"今天已有数据但无实时行情权限(free/none)"的降级场景:
# 此时 start_date = latest_daily = today,batch 刷新当天日K。
start_date = latest_daily
daily_range_start = start_date
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
logger.info("sync_daily: [%s ~ %s] gap fill", start_date, today)
logger.info("sync_daily: [%s ~ %s] %s", start_date, today,
"refresh today" if today_exists else "gap fill")
def _daily_chunk_progress(cur: int, tot: int) -> None:
emit("sync_daily", 12 + int(33 * cur / tot),
@@ -140,6 +154,7 @@ def run_now(
else:
# 首次:无任何数据 → batch 拉 1 年
start_date = today - _td(days=365)
daily_range_start = start_date
emit("sync_daily", 12, f"获取日K [{start_date} ~ {today}]…")
logger.info("sync_daily: [%s ~ %s] initial fetch", start_date, today)
@@ -157,36 +172,22 @@ def run_now(
logger.info("sync_daily: [%s ~ %s] done", start_date, today)
_invalidate("daily")
# Step 1.5: 增量同步除权因子 — 从已有数据最新日期的下一天开始获取
# Step 1.5: 同步除权因子 — 范围与日K拉取方式对齐
# 日K范围拉取(补缺口/首次) → 除权用日K范围 [daily_range_start, now]
# 首次会覆盖整个日K区间内的历史除权事件; 补缺口天然只增量(起点=latest_daily≈昨天)
# 日K实时增量/跳过(分支2/分支1) → 除权兜底拉最近 30 天, 补可能遗漏的新除权
# (这两类分支不拉历史日K, 除权不能用日K范围, 只能兜底最近几日)
written_adj = 0
affected_symbols: list[str] = []
if capset.has(Cap.ADJ_FACTOR):
from datetime import datetime, timedelta
adj_end = datetime.now()
# 从已有除权因子数据的最新日期开始获取,避免重复拉取
adj_factor_path = repo.store.data_dir / "adj_factor" / "all.parquet"
fallback_start = adj_end - timedelta(days=30)
if adj_factor_path.exists():
try:
from datetime import date as date_cls
max_date = pl.scan_parquet(adj_factor_path).select(
pl.col("trade_date").max()
).collect().item()
if max_date is not None:
# trade_date 可能是 date / datetime / string 类型
if isinstance(max_date, str):
td = date_cls.fromisoformat(max_date)
elif isinstance(max_date, datetime):
td = max_date.date()
else:
td = max_date
adj_start = datetime.combine(td, datetime.min.time())
else:
adj_start = fallback_start
except Exception:
adj_start = fallback_start
if daily_range_start is not None:
adj_start = datetime.combine(daily_range_start, datetime.min.time())
else:
adj_start = fallback_start
# 日K实时增量/跳过时, 除权兜底拉最近 N 天, 覆盖周末/长假/停机期间的新除权事件。
# 15 天: 覆盖春节/国庆最长约10天长假 + 故障恢复缓冲; sync_adj_factor 内部 merge+unique 幂等, 多拉无副作用。
adj_start = adj_end - timedelta(days=15)
adj_start_str = adj_start.strftime("%Y-%m-%d")
adj_end_str = adj_end.strftime("%Y-%m-%d")
emit("sync_adj", 50, f"获取除权因子 [{adj_start_str} ~ {adj_end_str}]…")
@@ -286,33 +287,119 @@ def run_now(
_refresh_single_view(repo, "kline_enriched")
_invalidate("enriched")
# Step 2.3: 指数同步 — 独立 kline_index_* 存储,不进入股票选股/策略链路
# Step 2.3: 指数 / ETF 同步 — 物理分开存储;ETF 可复权,指数不复权
written_index_daily = 0
written_etf_daily = 0
index_count = 0
if capset.has(Cap.KLINE_DAILY_BATCH):
emit("sync_index", 88, "同步指数列表与日K…")
etf_count = 0
etf_adj_symbols = 0
pull_index = _prefs.get_pipeline_pull_index()
pull_etf = _prefs.get_pipeline_pull_etf()
if capset.has(Cap.KLINE_DAILY_BATCH) and (pull_index or pull_etf):
_types = []
if pull_index:
_types.append("指数")
if pull_etf:
_types.append("ETF")
emit("sync_index", 88, f"同步{'+'.join(_types)}日K…")
# 子阶段进度分配: 88.0(开始) → 89.0(完成), 指数占前半, ETF 占后半
try:
index_count = index_sync.sync_index_instruments(repo)
index_dir = repo.store.data_dir / "kline_index_enriched"
index_dates = sorted(
d.name[5:] for d in index_dir.glob("date=*")
if d.is_dir() and d.name.startswith("date=")
) if index_dir.exists() else []
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
written_index_daily = index_sync.sync_and_persist_index_daily(
repo,
capset,
start_date=_dt.combine(index_start, _dt.min.time()),
end_date=_dt.combine(today, _dt.min.time()),
)
if pull_index:
emit("sync_index", 88, "同步指数维表…")
index_count = index_sync.sync_index_instruments(repo, pull_index=True, pull_etf=False)
emit("sync_index", 88, f"指数维表完成,{index_count}")
index_dir = repo.store.data_dir / "kline_index_enriched"
index_dates = sorted(
d.name[5:] for d in index_dir.glob("date=*")
if d.is_dir() and d.name.startswith("date=")
) if index_dir.exists() else []
index_start = _date.fromisoformat(index_dates[-1]) if index_dates else today - _td(days=365)
def _index_chunk(cur: int, tot: int) -> None:
emit("sync_index", 88, f"指数日K批次 {cur}/{tot}",
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
written_index_daily = index_sync.sync_and_persist_index_daily(
repo,
capset,
start_date=_dt.combine(index_start, _dt.min.time()),
end_date=_dt.combine(today, _dt.min.time()),
on_chunk_done=_index_chunk,
)
emit("sync_index", 88, f"指数日K完成,{written_index_daily}")
_invalidate("index_instruments")
_invalidate("index_daily")
_invalidate("index_enriched")
if pull_etf:
emit("sync_index", 88, "同步 ETF 维表…")
etf_count = index_sync.sync_etf_instruments(repo)
emit("sync_index", 88, f"ETF 维表完成,{etf_count}")
etf_symbols: list[str] = []
etf_inst = repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
etf_symbols = sorted(set(etf_inst["symbol"].to_list()))
if etf_symbols and capset.has(Cap.ADJ_FACTOR):
try:
emit("sync_index", 88, "同步 ETF 除权因子…")
from datetime import datetime, timedelta
adj_end = datetime.now()
adj_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
fallback_start = adj_end - timedelta(days=30)
adj_start = fallback_start
if adj_path.exists():
max_date = pl.scan_parquet(adj_path).select(pl.col("trade_date").max()).collect().item()
if max_date is not None:
if isinstance(max_date, str):
adj_start = datetime.combine(_date.fromisoformat(max_date), datetime.min.time())
elif isinstance(max_date, datetime):
adj_start = datetime.combine(max_date.date(), datetime.min.time())
else:
adj_start = datetime.combine(max_date, datetime.min.time())
_, affected_etfs = index_sync.sync_etf_adj_factor(
etf_symbols,
repo,
capset,
start_time=adj_start,
end_time=adj_end,
)
etf_adj_symbols = len(affected_etfs)
emit("sync_index", 88, f"ETF 除权因子完成,{etf_adj_symbols}")
except Exception as e: # noqa: BLE001
logger.warning("ETF adj_factor skipped: %s", e)
etf_dir = repo.store.data_dir / "kline_etf_enriched"
etf_dates = sorted(
d.name[5:] for d in etf_dir.glob("date=*")
if d.is_dir() and d.name.startswith("date=")
) if etf_dir.exists() else []
etf_start = _date.fromisoformat(etf_dates[-1]) if etf_dates else today - _td(days=365)
def _etf_chunk(cur: int, tot: int) -> None:
emit("sync_index", 88, f"ETF 日K批次 {cur}/{tot}",
stage_pct=int(100 * cur / tot) if tot else 100, skip_log=cur < tot)
written_etf_daily = index_sync.sync_and_persist_etf_daily(
repo,
capset,
start_date=_dt.combine(etf_start, _dt.min.time()),
end_date=_dt.combine(today, _dt.min.time()),
on_chunk_done=_etf_chunk,
)
emit("sync_index", 88, f"ETF 日K完成,{written_etf_daily}")
_invalidate("etf_instruments")
_invalidate("etf_daily")
repo.refresh_index_views()
_invalidate("index_instruments")
_invalidate("index_daily")
_invalidate("index_enriched")
emit("sync_index", 89, f"指数完成,{index_count}指数,{written_index_daily}日K")
emit(
"sync_index",
89,
f"同步完成,指数 {index_count}/{written_index_daily}, ETF {etf_count} 只/{written_etf_daily}"
+ (f", ETF复权 {etf_adj_symbols}" if etf_adj_symbols else ""),
)
except Exception as e: # noqa: BLE001
logger.warning("sync_index failed: %s", e)
emit("sync_index", 89, f"指数同步失败:{e}")
logger.warning("sync_index/etf failed: %s", e)
emit("sync_index", 89, f"指数/ETF同步失败:{e}")
else:
skipped.append("sync_index")
@@ -359,6 +446,9 @@ def run_now(
"enriched_days": written_enriched,
"index_count": index_count,
"index_daily_rows": written_index_daily,
"etf_count": etf_count,
"etf_daily_rows": written_etf_daily,
"etf_adj_factor_symbols": etf_adj_symbols,
"minute_rows": written_minute,
"skipped_stages": skipped,
}
@@ -372,10 +462,15 @@ def _refresh_views(repo: KlineRepository) -> None:
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
"kline_minute": f"{d}/kline_minute/**/*.parquet",
"adj_factor": f"{d}/adj_factor/**/*.parquet",
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
"instruments": f"{d}/instruments/**/*.parquet",
"instruments_index": f"{d}/instruments_index/**/*.parquet",
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
}
for name, path in views.items():
try:
@@ -385,6 +480,7 @@ def _refresh_views(repo: KlineRepository) -> None:
)
except Exception as e: # noqa: BLE001
logger.warning("refresh view %s failed: %s", name, e)
repo.store._register_unified_views()
def _refresh_single_view(repo: KlineRepository, name: str) -> None:
@@ -395,10 +491,15 @@ def _refresh_single_view(repo: KlineRepository, name: str) -> None:
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
"kline_minute": f"{d}/kline_minute/**/*.parquet",
"adj_factor": f"{d}/adj_factor/**/*.parquet",
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
"instruments": f"{d}/instruments/**/*.parquet",
"instruments_index": f"{d}/instruments_index/**/*.parquet",
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
}
path = paths.get(name)
if not path:
@@ -449,10 +550,201 @@ def _run_tracked(fn, job_label: str) -> None:
job_store.fail(job_id, f"scheduled {job_label} failed")
# ================================================================
# 定时复盘 (AI 大盘复盘报告)
# ================================================================
REVIEW_JOB_ID = "scheduled_review"
async def _run_scheduled_review(repo) -> None:
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
与手动生成复盘体验一致: 流式事件经 quote_service.push_review_event
/api/intraday/stream review_progress 事件 前端 reviewStore, 用户开着复盘页
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成
LLM 偶发断流(peer closed connection)时自动重试最多 2
任何异常都吞掉只记日志, 绝不影响调度器主循环
"""
import json
try:
from app.services import market_recap_reports
from app import secrets_store as ss
# AI Key 未配置时跳过(避免每日报错刷日志)
if not ss.get_ai_key():
logger.info("scheduled review skipped: AI key not configured")
return
app_state = _get_app_state()
quote_service = getattr(app_state, "quote_service", None) if app_state else None
depth_service = getattr(app_state, "depth_service", None) if app_state else None
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
if not content:
logger.warning("scheduled review produced no content (meta=%s)", meta)
# 通知前端进入 error 态(若有页面在听)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
ensure_ascii=False))
return
# 落盘: 与手动生成完全相同的归档格式
market_recap_reports.save_report({
"as_of": meta.get("as_of"),
"focus": "",
"content": content,
"summary": meta.get("summary", ""),
"emotion_score": meta.get("emotion_score"),
"emotion_label": meta.get("emotion_label", ""),
})
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "done", "archived": True}, ensure_ascii=False))
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
# 失败静默降级, 不影响已归档的报告。
_maybe_push_review(content, meta)
except Exception as e: # noqa: BLE001
logger.exception("scheduled review failed: %s", e)
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
try:
app_state = _get_app_state()
qs = getattr(app_state, "quote_service", None) if app_state else None
if qs:
import json as _json
qs.push_review_event(_json.dumps(
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
ensure_ascii=False))
except Exception: # noqa: BLE001
pass
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]:
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
返回 (content, meta)重试时推一个 retry 事件让前端清空已累积内容重新开始
成功(收到 done/ error)或耗尽重试后返回
"""
import asyncio
import json
from app.services.market_recap import recap_market_stream
max_attempts = 3 # 初次 + 2 次重试
last_meta: dict = {}
content_parts: list[str] = []
for attempt in range(1, max_attempts + 1):
content_parts = [] # 每次重试重新累积
failed = False
try:
async for evt_json in recap_market_stream(repo, quote_service, depth_service):
evt = json.loads(evt_json)
t = evt.get("type")
# 推给前端(让开着页面的用户实时看到, 与手动一致)
if quote_service:
quote_service.push_review_event(evt_json)
if t == "meta":
last_meta = evt
elif t == "delta" and evt.get("content"):
content_parts.append(evt["content"])
elif t == "error":
failed = True
logger.warning("scheduled review stream error (attempt %d/%d): %s",
attempt, max_attempts, evt.get("message"))
break # 触发重试
elif t == "done":
# 正常完成
return "".join(content_parts), last_meta
# 流自然结束(无 done 事件)且有内容, 视为成功
if content_parts and not failed:
return "".join(content_parts), last_meta
except Exception as e: # noqa: BLE001
# LLM 断流等异常(httpx.RemoteProtocolError)落到这里
failed = True
logger.warning("scheduled review stream exception (attempt %d/%d): %s",
attempt, max_attempts, e)
# 失败: 决定是否重试
if attempt < max_attempts:
logger.info("scheduled review retrying in 3s (attempt %d%d)", attempt, attempt + 1)
# 通知前端: 即将重试, 清空已累积内容重新开始
if quote_service:
quote_service.push_review_event(json.dumps(
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
await asyncio.sleep(3)
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
return "".join(content_parts), last_meta
def _maybe_push_review(content: str, meta: dict) -> None:
"""复盘报告归档后, 按 review_push_channels 选定的外部工具逐个推送完整报告。
定时生成与手动生成共用本函数 (手动归档端点 POST /api/market-recap/reports 也会调用)
channels 为空则不推送; 'feishu' 复用监控中心的全局飞书 Webhook 通道
推送失败静默降级 (Webhook 是辅助通道), 不影响已归档的报告
"""
try:
from app.services import preferences, webhook_adapter
channels = preferences.get_review_push_channels()
if not channels:
return
emotion = f"{meta.get('emotion_label') or ''}".strip()
as_of = meta.get("as_of") or ""
subtitle = as_of + (f" · 情绪 {emotion}" if emotion else "")
for ch in channels:
if ch == "feishu":
url = preferences.get_feishu_webhook_url()
if not url:
logger.info("review push(feishu) skipped: webhook not configured")
continue
secret = preferences.get_feishu_webhook_secret()
ok = webhook_adapter.send_feishu_card(
url, "TickFlow · 每日复盘", subtitle, content, secret
)
logger.info("review push(feishu) %s", "sent" if ok else "failed")
# 未来更多渠道在此追加分支
except Exception as e: # noqa: BLE001
logger.warning("review push error: %s", e)
def _register_review_job(scheduler, repo, hour: int, minute: int) -> None:
"""注册/更新定时复盘 job(工作日 mon-fri, Asia/Shanghai)。
start_scheduler(启动时) settings API(改时间时) 共用
replace_existing=True, 重复注册只更新 trigger
注意: _run_scheduled_review 是协程函数, 必须把函数对象本身(配合 args)传给
add_job, 而非用 lambda 包裹 否则 APScheduler 会把 lambda 当同步函数在线程池
执行, 仅得到一个未 await 的协程对象, 复盘实际不会运行
"""
scheduler.add_job(
_run_scheduled_review,
args=[repo],
trigger=CronTrigger(day_of_week="mon-fri",
hour=hour, minute=minute,
timezone="Asia/Shanghai"),
id=REVIEW_JOB_ID,
misfire_grace_time=7200, # 复盘非关键, 允许 2 小时内补跑
replace_existing=True,
)
def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOScheduler:
"""启动调度器。
工作日 09:10 同步标的维表
工作日 09:10 同步个股维表
工作日 HH:MM 盘后管道时间由用户偏好决定默认 15:30
"""
from app.services import preferences
@@ -464,9 +756,9 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
# 盘前: 同步 instruments(时间由偏好决定)
def _instruments_task(on_progress=None):
emit = on_progress or _noop
emit("sync_instruments", 0, "同步标的维表…")
emit("sync_instruments", 0, "同步个股维表…")
result = run_instruments_sync(repo)
emit("done", 100, f"标的维表同步完成,{result.get('instruments_rows', 0)} 只标的")
emit("done", 100, f"个股维表同步完成,{result.get('instruments_rows', 0)} 只标的")
return result
scheduler.add_job(
@@ -480,11 +772,16 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
)
# 盘后: 日 K + enriched(时间由偏好决定)
def _pipeline_then_refresh(on_progress=None):
# 与手动触发 (/api/pipeline/run) 对齐: 管道落盘后重建 Polars 内存缓存,
# 否则 live_agg 的昨日连板数等基准列会停留在旧交易日, 次日开盘连板梯队
# 整体少算一档 (仅手动触发或重启才会刷缓存, cron 调度路径此前漏了这步)。
result = run_now(repo, capset, on_progress=on_progress)
repo.refresh_cache()
return result
scheduler.add_job(
lambda: _run_tracked(
lambda on_progress=None: run_now(repo, capset, on_progress=on_progress),
"daily_pipeline",
),
lambda: _run_tracked(_pipeline_then_refresh, "daily_pipeline"),
trigger=CronTrigger(day_of_week="mon-fri",
hour=sched["hour"], minute=sched["minute"],
timezone="Asia/Shanghai"),
@@ -511,6 +808,16 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
replace_existing=True,
)
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
# quote_service / depth_service 通过 _get_app_state() 延迟取用。
review_sched = preferences.get_review_schedule()
if review_sched["enabled"]:
_register_review_job(scheduler, repo, review_sched["hour"], review_sched["minute"])
logger.info("scheduled_review enabled @%02d:%02d mon-fri",
review_sched["hour"], review_sched["minute"])
scheduler.start()
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
+73 -32
View File
@@ -11,8 +11,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app import __version__
from app import auth as auth_module
from app.api import analysis, auth, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist
from app.api import analysis, auth as auth_api, backtest, data, ext_data, financials, indices, intraday, kline, market_recap, monitor_rules, alerts, overview, pipeline, rps, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist
from app.api.routes import router as core_router
from app.config import settings
from app.jobs import daily_pipeline
@@ -31,10 +30,18 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(
"Stock Panel v%s starting (mode=%s)",
"TickFlow Stock Panel v%s starting (mode=%s)",
__version__, tf_client.current_mode(),
)
# 首次启动: 若配置了 AUTH_PASSWORD 环境变量且未设过密码, 用它初始化。
# 公网部署免 SSH 端口转发; 已设过密码则不覆盖 (改密码走 UI)。
try:
from app.services import auth as auth_service
auth_service.bootstrap_from_env()
except Exception as e: # noqa: BLE001
logger.warning("auth bootstrap failed: %s", e)
# 数据层
store = DataStore()
repo = KlineRepository(store)
@@ -91,7 +98,16 @@ async def lifespan(app: FastAPI):
pull_scheduler.refresh(store.data_dir)
app.state.pull_scheduler = pull_scheduler
# 财务数据独立调度 (需 Expert 套餐)
# 内置扩展表 (概念/行业): 只创建 config (含拉取配置), 不自动拉数据
# 数据获取由用户在概念/行业页点「获取数据」手动触发 (POST /api/ext-data/presets/{id}/fetch)
try:
from app.services.ext_presets import ensure_builtin_presets
await ensure_builtin_presets(store.data_dir)
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表初始化失败 (不影响启动): %s", e)
# 财务数据 (需 Expert 套餐): 仅初始化调度器供 /api/financials/sync/* 手动同步,
# 不启动自动调度——用户在「财务分析」页点「同步」手动拉取。
from app.services.financial_sync import financial_scheduler
financial_scheduler.start(store.data_dir, capset)
app.state.financial_scheduler = financial_scheduler
@@ -122,6 +138,9 @@ async def lifespan(app: FastAPI):
monitor_engine = MonitorRuleEngine()
monitor_engine.set_strategy_engine(strategy_engine)
monitor_engine.set_data_dir(store.data_dir)
# 复用 ScreenerService 的历史窗口加载器 (三级缓存, 启动预计算命中 ~0ms),
# 让声明 filter_history 的策略 (如反包) 也能在实时监控里跑选股 → 盘中触发通知。
monitor_engine.set_history_loader(_screener_svc._load_enriched_history)
# 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页)
try:
@@ -162,9 +181,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Stock Panel",
title="TickFlow Stock Panel",
version=__version__,
description="A 股选股 + 监控 + 回测面板",
description="A 股选股 + 回测面板 — TickFlow 适配",
lifespan=lifespan,
)
@@ -180,37 +199,54 @@ app.add_middleware(
)
# 访问门控中间件:仅对 /api/* 路径校验,静态资源和前端路由放行,
# 由前端 AccessGuard 控制 UI 展示。
# ================================================================
# 访问认证中间件
# ================================================================
# 拦截所有 /api/ 请求, 三种状态:
# 1. 未设密码 + 本机/内网 → 放行(让本机用户访问面板 + 调 /api/auth/setup 设密码)
# 2. 未设密码 + 公网 → 拒绝(403, 防裸奔也防抢占; 引导本机设密码)
# 3. 已设密码 → 检查 session, 无效则 401(前端跳登录)
# 白名单: /api/auth/* (设密码/登录本身)、/health 等探活。
_AUTH_WHITELIST_PREFIX = ("/api/auth/",)
_AUTH_WHITELIST_EXACT = ("/health", "/api/health", "/openapi.json", "/docs", "/redoc")
@app.middleware("http")
async def access_uuid_middleware(request: Request, call_next):
if auth_module.access_control_enabled():
path = request.url.path
# 白名单直接放行
if not auth_module.is_public_path(path):
token = auth_module.get_access_token_from_request(request)
role = auth_module.validate_access_token(token)
# 管理员接口需 admin 角色
if auth_module.is_admin_path(path):
if role != auth_module.AuthRole.ADMIN:
return JSONResponse(
status_code=403 if role else 401,
content={"detail": "需要管理员权限"},
)
# 其它 API 调用需任意有效角色
elif path.startswith("/api/"):
if role is None:
return JSONResponse(
status_code=401,
content={"detail": "访问令牌无效或已过期,请先验证"},
)
return await call_next(request)
async def auth_middleware(request: Request, call_next):
path = request.url.path
# 仅 /api/ 走认证; 静态资源(前端页面/assets)放行, 由前端处理跳转
if not path.startswith("/api/"):
return await call_next(request)
# 白名单放行(设密码/登录/探活本身不拦)
if path.startswith(_AUTH_WHITELIST_PREFIX) or path in _AUTH_WHITELIST_EXACT:
return await call_next(request)
from app.services import auth as auth_service
# 情况 1+2: 未设密码
if not auth_service.is_configured():
# 本机/内网 → 放行(服务器主人可访问, 并去 /login 设密码)
if auth_api._is_local_network(auth_api._client_ip(request)):
return await call_next(request)
# 公网 → 拒绝。不裸奔, 也不给公网设密码的机会(防抢占)
return JSONResponse(
status_code=403,
content={
"detail": "面板尚未初始化访问密码,请通过 SSH/本机浏览器访问以设置密码",
"code": "NOT_INITIALIZED",
},
)
# 情况 3: 已设密码, 检查会话
token = request.cookies.get(auth_api.COOKIE_NAME)
if token and auth_service.is_valid_session(token):
return await call_next(request)
# 未登录: 401(前端跳登录页)
return JSONResponse(status_code=401, content={"detail": "未登录或会话已过期"})
# 路由
app.include_router(core_router)
app.include_router(auth.router)
app.include_router(auth.admin_router)
app.include_router(auth_api.router)
app.include_router(kline.router)
app.include_router(watchlist.router)
app.include_router(screener.router)
@@ -223,16 +259,21 @@ app.include_router(pipeline.router)
app.include_router(data.router)
app.include_router(ext_data.router)
app.include_router(financials.router)
app.include_router(stock_analysis.router)
app.include_router(market_recap.router)
app.include_router(settings_api.router)
app.include_router(strategy.router)
app.include_router(signals.router)
app.include_router(monitor_rules.router)
app.include_router(alerts.router)
app.include_router(rps.router)
# 能力门控异常 → 403(而非默认 500)
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。
from fastapi import Request
from fastapi.responses import JSONResponse
from app.tickflow.capabilities import CapabilityDenied
+1 -1
View File
@@ -61,7 +61,7 @@ def clear(*keys: str) -> dict:
def get_tickflow_key() -> str:
"""取当前数据源 Key:secrets.json 优先,否则 .env。"""
"""取当前 TickFlow Key:secrets.json 优先,否则 .env。"""
val = load().get("tickflow_api_key")
if val:
return val
+429
View File
@@ -0,0 +1,429 @@
"""AI provider adapter for OpenAI-compatible APIs and local Codex CLI."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
import sys
import tempfile
import tomllib
from collections.abc import AsyncIterator, Sequence
from pathlib import Path
from app import secrets_store
from app.config import settings
OPENAI_COMPAT_PROVIDER = "openai_compat"
CODEX_CLI_PROVIDER = "codex_cli"
CODEX_DEFAULT_COMMAND = "codex"
CODEX_SERVICE_TIER_FALLBACK = "fast"
CODEX_SUPPORTED_SERVICE_TIERS = {"fast", "flex"}
Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
return secrets_store.get_ai_config("ai_model", settings.ai_model)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
strict=False,
)
def is_codex_cli_provider(provider: str | None = None) -> bool:
return (provider or current_ai_provider()) == CODEX_CLI_PROVIDER
def normalize_codex_model(model: str) -> str:
value = model.strip()
aliases = {
"gpt5": "gpt-5",
"gpt5.5": "gpt-5.5",
}
return aliases.get(value.lower(), value)
def normalize_codex_command(command: str | None, *, strict: bool = True) -> str:
value = (command or "").strip()
if not value or value.lower() == CODEX_DEFAULT_COMMAND:
return CODEX_DEFAULT_COMMAND
if strict:
raise ValueError("Codex CLI 仅支持使用默认 codex 命令自动解析, 不支持自定义可执行路径")
return CODEX_DEFAULT_COMMAND
def normalize_openai_base_url(url: str) -> str:
"""Return the OpenAI-compatible base URL expected by the OpenAI SDK."""
base = (url or "").strip().rstrip("/")
if base.endswith("/chat/completions"):
base = base[: -len("/chat/completions")].rstrip("/")
if not base.endswith("/v1"):
base = f"{base}/v1"
return base
def codex_cli_available() -> bool:
try:
_codex_base_command()
return True
except RuntimeError:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
async def generate_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
)
async def stream_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
async for chunk in _stream_openai(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
):
yield chunk
async def _run_openai_once(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> str:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
)
if not resp.choices:
return ""
return (resp.choices[0].message.content or "").strip()
async def _stream_openai(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
def _openai_client(api_key: str, timeout: float):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
return AsyncOpenAI(
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
async def _run_codex_cli(
messages: Sequence[Message],
*,
max_tokens: int,
timeout: float,
) -> str:
prompt = _codex_prompt(messages, max_tokens=max_tokens)
with tempfile.TemporaryDirectory(prefix="tickflow-codex-run-") as run_dir:
run_path = Path(run_dir)
codex_home_path = run_path / "codex-home"
workspace_path = run_path / "workspace"
codex_home_path.mkdir()
workspace_path.mkdir()
output_path = codex_home_path / "last-message.txt"
_prepare_codex_home(codex_home_path)
args = [
*_codex_base_command(),
"exec",
"--ephemeral",
"--sandbox",
"read-only",
"--skip-git-repo-check",
"--color",
"never",
"--output-last-message",
str(output_path),
]
model = current_ai_model().strip()
if model:
args.extend(["--model", model])
args.extend(["--cd", str(workspace_path), "-"])
env = os.environ.copy()
env.setdefault("NO_COLOR", "1")
env["CODEX_HOME"] = str(codex_home_path)
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(prompt.encode("utf-8")),
timeout=timeout,
)
except TimeoutError as exc:
proc.kill()
await proc.wait()
raise RuntimeError("Codex CLI 调用超时, 请稍后重试或检查本机 Codex 登录状态") from exc
out = _clean_process_text(stdout)
err = _clean_process_text(stderr)
final_message = _read_output_file(output_path)
if proc.returncode != 0:
detail = err or out or f"exit code {proc.returncode}"
raise RuntimeError(f"Codex CLI 调用失败: {detail[-1200:]}")
result = final_message or out
if not result:
raise RuntimeError("Codex CLI 未返回内容")
return result
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
parts = [
"You are TickFlow Stock Panel's local AI provider.",
"This is a text-generation task. The working directory is intentionally empty.",
"Use only the user-provided prompt content below; do not inspect or modify local files.",
"Return only the final requested content; do not include execution logs.",
]
if max_tokens > 0:
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
for message in messages:
role = message.get("role", "user")
content = message.get("content", "")
parts.append(f"\n<{role}>\n{content}\n</{role}>")
return "\n".join(parts)
def _codex_base_command() -> list[str]:
command = current_codex_command()
resolved = _resolve_command(command)
if not resolved:
raise RuntimeError(f"未找到 Codex CLI 命令: {command}")
if sys.platform == "win32" and resolved.lower().endswith(".ps1"):
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved]
return [resolved]
def _resolve_command(command: str) -> str | None:
if command.lower() != CODEX_DEFAULT_COMMAND:
return None
if sys.platform == "win32":
desktop_codex = _resolve_windows_desktop_codex()
if desktop_codex:
return desktop_codex
resolved = shutil.which(command)
if sys.platform == "win32" and resolved:
resolved_path = Path(resolved)
if not resolved_path.suffix:
cmd_path = resolved_path.with_suffix(".cmd")
if cmd_path.exists():
return str(cmd_path)
if not resolved and sys.platform == "win32" and not command.lower().endswith(".cmd"):
resolved = shutil.which(f"{command}.cmd")
if not resolved and sys.platform == "win32":
resolved = _resolve_windows_codex_command(command)
return resolved
def _resolve_windows_codex_command(command: str) -> str | None:
"""Find npm-installed Codex when the backend process has a minimal PATH."""
raw = Path(command)
if raw.parent != Path("."):
return None
names = [command]
if not raw.suffix:
names = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", f"{command}.ps1", command]
dirs: list[Path] = []
appdata = os.environ.get("APPDATA")
if appdata:
dirs.append(Path(appdata) / "npm")
dirs.append(Path.home() / "AppData" / "Roaming" / "npm")
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
value = os.environ.get(env_name)
if value:
dirs.append(Path(value) / "nodejs")
for directory in dirs:
for name in names:
candidate = directory / name
if candidate.exists():
return str(candidate)
return None
def _resolve_windows_desktop_codex() -> str | None:
"""Prefer the Codex Desktop bundled CLI over an older npm shim."""
local_appdata = os.environ.get("LOCALAPPDATA")
if not local_appdata:
return None
root = Path(local_appdata) / "OpenAI" / "Codex" / "bin"
if not root.exists():
return None
candidates = list(root.glob("*/codex.exe"))
direct = root / "codex.exe"
if direct.exists():
candidates.append(direct)
if not candidates:
return None
newest = max(candidates, key=lambda p: p.stat().st_mtime)
return str(newest)
def _prepare_codex_home(target: Path) -> None:
"""Create an isolated CODEX_HOME that reuses auth but not fragile config."""
source = _codex_home()
auth_file = source / "auth.json"
if auth_file.exists():
shutil.copy2(auth_file, target / "auth.json")
_write_compatible_codex_config(target / "config.toml")
def _codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
def _write_compatible_codex_config(path: Path) -> None:
config = _read_codex_config()
lines: list[str] = []
tier = str(config.get("service_tier") or "").strip()
if tier not in CODEX_SUPPORTED_SERVICE_TIERS:
tier = CODEX_SERVICE_TIER_FALLBACK
lines.append(_toml_string("service_tier", tier))
lines.append(_toml_string("approval_policy", "never"))
lines.append(_toml_string("sandbox_mode", "read-only"))
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _read_codex_config() -> dict:
path = _codex_home() / "config.toml"
if not path.exists():
return {}
try:
with path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError:
return _read_codex_config_lenient(path)
except OSError:
return {}
def _read_codex_config_lenient(path: Path) -> dict:
config: dict[str, str] = {}
pattern = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$')
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
match = pattern.match(line)
if match:
config[match.group(1)] = match.group(2)
except OSError:
pass
return config
def _toml_string(key: str, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'{key} = "{escaped}"'
def _clean_process_text(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
return _ANSI_RE.sub("", text).strip()
def _read_output_file(path: Path) -> str:
if path.exists():
return _ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace")).strip()
return ""
+101
View File
@@ -0,0 +1,101 @@
"""AI 财务分析报告持久化存储。
存储位置: data/user_data/ai_reports.json (数组, created_at 降序)
保留最近 MAX_REPORTS ;超出自动裁剪最旧的
每条报告结构:
{
"id": "rpt_xxx", # 唯一 id
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"periods": 4, # 基于几期数据生成
"summary": "metrics: 1期...", # 数据摘要
"created_at": "2026-06-25T10:00:00"
}
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_reports.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
except Exception as e: # noqa: BLE001
logger.warning("ai_reports.json malformed: %s", e)
return []
def _save_all(reports: list[dict]) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
# 保持降序
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
自动补全 id created_at(若缺),并裁剪到上限
"""
reports = list_reports()
if not report.get("id"):
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
return True
return False
def clear_reports() -> int:
"""清空全部报告。返回删除数量。"""
reports = list_reports()
n = len(reports)
if n > 0:
_save_all([])
return n
def _now_iso() -> str:
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+201
View File
@@ -0,0 +1,201 @@
"""访问密码认证 — 单用户, 自托管场景。
设计:
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt
即使 auth.json 泄露, 也无法逆向出明文密码
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式
安全要点:
- 设密码接口必须限制本机/内网( auth router), 防黑客抢占域名抢先设密码
- 登录限流: 错5次锁5分钟( auth router 内存计数)
- 单密码, 不做多用户(避免重构全项目数据层)
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import secrets as _secrets
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应)
_PBKDF2_ITER = 200_000
_SALT_LEN = 16
_TOKEN_BYTES = 32
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
SESSION_TTL = 30 * 24 * 3600
_lock = threading.Lock()
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
_sessions: dict[str, float] = {}
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "auth.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _load() -> dict:
p = _path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("auth.json malformed: %s", e)
return {}
def _save(data: dict) -> None:
p = _path()
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(p, 0o600)
except OSError:
pass
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
if salt is None:
salt = os.urandom(_SALT_LEN)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
return salt.hex(), dk.hex()
def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
"""恒定时间比较, 防时序攻击。"""
try:
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
return _secrets.compare_digest(actual, expected)
# ================================================================
# 密码管理
# ================================================================
def is_configured() -> bool:
"""是否已设置访问密码。"""
d = _load()
return bool(d.get("password_hash"))
def set_password(password: str) -> None:
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
if len(password) < 6:
raise ValueError("密码至少 6 位")
salt_hex, hash_hex = _hash_password(password)
with _lock:
_sessions.clear() # 改密码 = 旧会话全部失效
_save({
"password_hash": hash_hex,
"password_salt": salt_hex,
"updated_at": int(time.time()),
"sessions": {}, # 清空持久化会话
})
logger.info("access password set")
def bootstrap_from_env() -> bool:
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码
明文密码只在内存/配置中, set_password() 哈希后写入 auth.json (chmod 0600)
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)
Returns:
True 表示本次用环境变量初始化了密码; False 表示无需初始化
"""
from app.config import settings
pwd = (settings.auth_password or "").strip()
if not pwd:
return False
if is_configured():
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
return False
try:
set_password(pwd)
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
return True
except ValueError as e:
# 密码不合规 (< 6 位), 记日志但不阻断启动
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
return False
def verify_and_create_session(password: str) -> str | None:
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
d = _load()
if not d.get("password_hash"):
return None
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
return None
token = _secrets.token_urlsafe(_TOKEN_BYTES)
expire = time.time() + SESSION_TTL
with _lock:
_sessions[token] = expire
_persist_sessions_locked()
return token
def revoke_session(token: str) -> None:
"""注销会话(登出)。"""
with _lock:
_sessions.pop(token, None)
_persist_sessions_locked()
def is_valid_session(token: str) -> bool:
"""检查会话是否有效(存在且未过期)。过期则清理。"""
if not token:
return False
with _lock:
expire = _sessions.get(token)
if expire is None:
return False
if time.time() > expire:
_sessions.pop(token, None)
_persist_sessions_locked()
return False
return True
def _persist_sessions_locked() -> None:
"""把当前内存会话写回 auth.json(需持锁调用)。"""
d = _load()
d["sessions"] = {t: exp for t, exp in _sessions.items()}
_save(d)
def _restore_sessions() -> None:
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
with _lock:
d = _load()
now = time.time()
saved = d.get("sessions") or {}
for token, expire in saved.items():
if isinstance(expire, (int, float)) and expire > now:
_sessions[token] = expire
if len(_sessions) != len(saved):
# 有过期会话被清理, 落盘一次
_persist_sessions_locked()
# 模块加载时恢复会话
try:
_restore_sessions()
except Exception as e: # noqa: BLE001
logger.warning("restore sessions failed: %s", e)
@@ -0,0 +1,357 @@
"""AI 概念轮动分析 — 从概念涨幅排名矩阵提炼主线/新晋/退潮信号。
数据来源:
- rps_rotation.build_rps_rotation: 概念涨幅排名矩阵 (N × ~387 概念)
- market_overview_builder.build_market_overview: 大盘背景 (指数/情绪/涨停)
架构 (复刻 market_recap):
预计算轮动信号 拼装 prompt stream_ai_text 流式调用 NDJSON 协议输出
协议事件: meta(摘要) / delta(文本片段) / error / done
"""
from __future__ import annotations
import json
import logging
import math
from collections.abc import AsyncIterator
logger = logging.getLogger(__name__)
# ================================================================
# System Prompt — 轮动策略师人格 + 固定章节模板
# ================================================================
_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的资深策略师,拥有 12 年一线实战经验,擅长从概念板块的**涨幅排名矩阵**中识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出可直接指导题材跟踪与节奏把握的轮动分析。
## 输出规范
**Markdown** 格式输出,严格遵循以下结构不要输出任何 JSON 或代码块,直接输出 Markdown 正文
### 1. 🎯 主线研判(2-3 句)
点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并判断是**主升期/加速期/扩散期/见顶期**结尾用主线强度: / / 定性
### 2. 🆕 新晋强势
列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出:
- 概念名 + N 日排名变化( `45208`)
- 涨幅加速度(连日递增 = 趋势加强)
- 可能的驱动逻辑(从板块属性推断,不要编造具体消息)
- 判断是**主力切入**还是**消息脉冲**
### 3. 📉 退潮预警
列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出:
- 概念名 + 排名下滑轨迹
- 退潮性质(高位分歧/资金撤离/补跌)
- 是否扩散风险
### 4. 🏛️ 机构主线 vs 🎰 游资轮动
基于排名稳定性区分两类资金行为:
- **机构主线**:排名标准差小长期稳居前列的概念 持续性判断是否可作底仓方向
- **游资轮动**:排名剧烈波动脉冲式冲高的概念 短线节奏提示追高风险
给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断
### 5. 🌐 结合大盘
结合提供的大盘数据(指数涨跌/情绪/涨停数),判断:
- 当前大盘环境对题材轮动是助力还是阻力
- 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段)
### 6. 🎯 操作建议
- **跟踪方向**:主线延续 + 新晋确认的概念
- **规避方向**:明确退潮 + 高位脉冲的概念
- **节奏提示**:当前适合追高 / 低吸 / 观望,及切换信号("主线概念连续 2 日跌出前 10 则确认退潮")
### 7. ⚠️ 风险提示
列出需要盯的风险(如主线断层情绪与轮动背离成交萎缩)末尾附一行:
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
## 分析准则(务必遵守)
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤不要写"我先看...""基于上述数据我认为"直接给结论
1. **数据说话**:每个判断引用具体排名/涨幅数值,严禁空泛套话("强势"必须改成"连续 4 日稳居前 5,均涨 +4.2%")
2. **诚实中立**:数据不支持的结论就直言"信号不足,暂无法判断",不要硬凑
3. **区分资金性质**:这是本分析的核心价值机构 vs 游资的判断必须基于排名稳定性(标准差),不要凭感觉
4. **不重复数字**:正文负责解读信号含义,不要照抄罗列已提供的全部原始数据
5. **简明实战**:总字数 1000-1800 ,重在可执行
6. **客观推断**:若无明确消息,从量价异动推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻
现在请基于下方概念轮动数据进行分析"""
# ================================================================
# 预计算: 把排名矩阵转成结构化轮动信号
# ================================================================
# 每类信号最多取多少个概念喂给 AI (控制 token)
_TOP_N = 8
def _compute_rotation_signals(dates: list[str], columns: dict) -> dict:
"""从概念涨幅排名矩阵计算轮动信号。
Args:
dates: 日期列表 (最新在最前, columns key 一致)
columns: {日期: [[概念, 涨幅], ...]} 每列各自降序
Returns:
{
"persistent_leaders": [...], # 连续多日稳居前列 (主线)
"rising": [...], # 排名快速跃升 (新晋)
"fading": [...], # 从高位滑落 (退潮)
"institutional": [...], # 排名稳定 (机构特征)
"hot_money": [...], # 排名波动大 (游资特征)
}
每项含: concept, ranks ( dates 顺序), pcts, avg_rank, rank_std
ranks 时间方向: ranks[0] = 最早日, ranks[-1] = 最新日 (已反转, 左老右新)
"""
if not dates or not columns:
return {}
# 按时间正序 (左老右新) 处理
dates_asc = list(reversed(dates))
# 收集每个概念在各日期的 (排名, 涨幅)。排名 = 该日在列中的索引 + 1。
concept_data: dict[str, list[tuple[int, float]]] = {}
for d in dates_asc:
col = columns.get(d) or []
for idx, (name, pct) in enumerate(col):
concept_data.setdefault(name, []).append((idx + 1, pct))
n_dates = len(dates_asc)
def _stats(ranks_pcts: list[tuple[int, float]]) -> dict:
ranks = [r for r, _ in ranks_pcts]
pcts = [p for _, p in ranks_pcts]
avg = sum(ranks) / len(ranks) if ranks else 0
var = sum((r - avg) ** 2 for r in ranks) / len(ranks) if ranks else 0
return {
"ranks": ranks,
"pcts": [round(p, 4) for p in pcts],
"avg_rank": round(avg, 1),
"rank_std": round(math.sqrt(var), 1),
}
persistent: list[dict] = []
rising: list[dict] = []
fading: list[dict] = []
institutional: list[dict] = []
hot_money: list[dict] = []
for concept, rp in concept_data.items():
# 缺失日补 (大排名, 0 涨幅) 保持时间轴对齐
if len(rp) < n_dates:
rp = rp + [(999, 0.0)] * (n_dates - len(rp))
s = _stats(rp)
s["concept"] = concept
ranks = s["ranks"]
latest_rank = ranks[-1]
earliest_rank = ranks[0]
# 最近 3 日 (不足则全部) 均排名, 判断近期强度
recent = ranks[-min(3, len(ranks)):]
recent_avg = sum(recent) / len(recent)
# 主线: 近期稳居前 10
if recent_avg <= 10 and latest_rank <= 10:
persistent.append(s)
# 新晋: 早期排名靠后(>30), 最新冲进前 20, 跃升幅度大
jump = earliest_rank - latest_rank
if earliest_rank > 30 and latest_rank <= 20 and jump >= 20:
rising.append(s)
# 退潮: 早期排名靠前(<=10), 最新滑落到 30 外
drop = latest_rank - earliest_rank
if earliest_rank <= 10 and latest_rank > 30 and drop >= 20:
fading.append(s)
# 机构: 排名标准差小且平均排名靠前 (稳定强势)
if s["rank_std"] <= 5 and s["avg_rank"] <= 20:
institutional.append(s)
# 游资: 排名标准差大 (波动剧烈)
if s["rank_std"] >= 20:
hot_money.append(s)
# 排序: 主线按近期排名升序; 新晋按跃升幅度降序; 退潮按跌幅降序
persistent.sort(key=lambda x: x["avg_rank"])
rising.sort(key=lambda x: x["ranks"][0] - x["ranks"][-1], reverse=True)
fading.sort(key=lambda x: x["ranks"][-1] - x["ranks"][0], reverse=True)
institutional.sort(key=lambda x: (x["rank_std"], x["avg_rank"]))
hot_money.sort(key=lambda x: x["rank_std"], reverse=True)
return {
"persistent_leaders": persistent[:_TOP_N],
"rising": rising[:_TOP_N],
"fading": fading[:_TOP_N],
"institutional": institutional[:_TOP_N],
"hot_money": hot_money[:_TOP_N],
}
# ================================================================
# Prompt 构建
# ================================================================
def _fmt_pct(v) -> str:
if v is None:
return ""
return f"{v*100:+.2f}%"
def _build_market_block(overview: dict) -> str:
"""大盘背景精简块 (复用 market_overview 已算好的字段)。"""
indices = overview.get("indices") or []
emo = overview.get("emotion") or {}
lim = overview.get("limit") or {}
amt = overview.get("amount") or {}
idx_lines = []
for idx in indices[:4]:
name = idx.get("name") or idx.get("symbol") or "?"
chg = idx.get("change_pct")
idx_lines.append(f"{name} {_fmt_pct(chg)}")
idx_str = " / ".join(idx_lines) or "指数缺失"
total_amount = (amt.get("total") or 0) / 1e8 # 元 → 亿
return (
f"- 指数: {idx_str}\n"
f"- 情绪: {emo.get('score', 50)} ({emo.get('label', '')})\n"
f"- 涨停/炸板/跌停: {lim.get('limit_up', 0)} / {lim.get('broken', 0)} / {lim.get('limit_down', 0)}"
f" (最高连板 {lim.get('max_boards', 0)})\n"
f"- 两市成交额: {total_amount:.0f} 亿元"
)
def _build_signal_block(title: str, items: list[dict]) -> str:
"""轮动信号块: 把预计算的概念信号转成紧凑文本。"""
if not items:
return f"### {title}\n(本类无明显信号)"
lines = [f"### {title}"]
for it in items:
ranks_str = "".join(str(r) if r < 999 else "" for r in it["ranks"])
avg_pct = sum(it["pcts"]) / len(it["pcts"]) if it["pcts"] else 0
lines.append(
f"- {it['concept']}: 排名 {ranks_str} | 均排名 {it['avg_rank']} "
f"| 排名波动σ {it['rank_std']} | 区间均涨 {_fmt_pct(avg_pct)}"
)
return "\n".join(lines)
def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str:
"""组装 user 消息: 大盘背景 + 轮动信号 + focus。"""
dates_asc = list(reversed(dates))
date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else ""
parts = [
f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})",
"",
"## 大盘背景",
_build_market_block(overview),
"",
"## 轮动信号 (排名时间方向: 左→右 = 旧→新, 排名越小越强)",
"",
_build_signal_block("🎯 主线 (连续霸榜)", signals.get("persistent_leaders", [])),
"",
_build_signal_block("🆕 新晋强势 (排名跃升)", signals.get("rising", [])),
"",
_build_signal_block("📉 退潮预警 (高位滑落)", signals.get("fading", [])),
"",
_build_signal_block("🏛️ 机构特征 (排名稳定)", signals.get("institutional", [])),
"",
_build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])),
]
if focus.strip():
parts.extend(["", f"## 用户关注点\n{focus.strip()}"])
return "\n".join(parts)
def _build_summary(signals: dict) -> str:
"""meta 事件的摘要 (前端可立即展示)。"""
leaders = signals.get("persistent_leaders", [])
rising = signals.get("rising", [])
fading = signals.get("fading", [])
leader_names = "".join(it["concept"] for it in leaders[:3]) or "暂无明确主线"
return f"主线: {leader_names} | 新晋 {len(rising)} | 退潮 {len(fading)}"
# ================================================================
# 流式主入口
# ================================================================
async def analyze_rotation_stream(
repo,
days: int = 12,
focus: str = "",
quote_service=None,
depth_service=None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
Args:
repo: KlineRepository (必填)
days: 分析最近 N 个交易日 (7-30)
focus: 用户追加的关注点
quote_service / depth_service: 可选, 大盘背景装配依赖
"""
from app.services.rps_rotation import build_rps_rotation
from app.services.market_overview_builder import build_market_overview
# 1. 取轮动矩阵
rotation = build_rps_rotation(repo, days)
dates = rotation.get("dates") or []
columns = rotation.get("columns") or {}
if not dates or not columns:
yield json.dumps({
"type": "error",
"message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源",
}, ensure_ascii=False)
return
# 2. 预计算轮动信号
signals = _compute_rotation_signals(dates, columns)
# 3. 大盘背景 (失败不阻断, 降级为空)
try:
overview = build_market_overview(repo, quote_service, depth_service)
except Exception as e: # noqa: BLE001
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
overview = {}
# 4. meta 事件
yield json.dumps({
"type": "meta",
"days": days,
"summary": _build_summary(signals),
}, ensure_ascii=False)
# 5. 构建 prompt + 流式调用 LLM
try:
from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured():
yield json.dumps({
"type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
}, ensure_ascii=False)
return
user_prompt = _build_user_prompt(signals, overview, days, dates, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4000,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI concept rotation analyze failed: %s", e)
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
yield json.dumps({"type": "done"}, ensure_ascii=False)
+12 -1
View File
@@ -316,7 +316,18 @@ class DepthService:
"status": e.get("status"),
"fetched_at": e.get("fetched_ts"),
})
df = pl.DataFrame(rows)
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
# "could not append value: false of type: bool to the builder"。
df = pl.DataFrame(rows, schema={
"symbol": pl.Utf8,
"sealed_up": pl.Boolean,
"sealed_down": pl.Boolean,
"ask1_vol": pl.Int64,
"bid1_vol": pl.Int64,
"status": pl.Utf8,
"fetched_at": pl.Float64,
})
ds = today.isoformat()
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
+11 -4
View File
@@ -38,6 +38,7 @@ class PullConfig:
"url", "method", "headers", "body", "response_path",
"field_map", "schedule_minutes", "enabled",
"last_run", "last_status", "last_message", "last_rows",
"next_run",
)
def __init__(
@@ -54,6 +55,7 @@ class PullConfig:
last_status: str | None = None,
last_message: str | None = None,
last_rows: int | None = None,
next_run: str | None = None,
) -> None:
self.url = url
self.method = method # GET | POST
@@ -67,6 +69,7 @@ class PullConfig:
self.last_status = last_status # "success" | "error"
self.last_message = last_message
self.last_rows = last_rows
self.next_run = next_run # 下次预计运行 (ISO, 调度器写入)
def to_dict(self) -> dict:
return {
@@ -82,6 +85,7 @@ class PullConfig:
"last_status": self.last_status,
"last_message": self.last_message,
"last_rows": self.last_rows,
"next_run": self.next_run,
}
@classmethod
@@ -101,6 +105,7 @@ class PullConfig:
last_status=d.get("last_status"),
last_message=d.get("last_message"),
last_rows=d.get("last_rows"),
next_run=d.get("next_run"),
)
@@ -407,8 +412,10 @@ def write_ext_parquet(
existing = pl.read_parquet(out_path)
key = "symbol" if "symbol" in df.columns else df.columns[0]
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
except Exception:
pass
except Exception as e:
# schema 不一致 (列不同) 时 concat 失败 → 直接用新 df 覆盖。
# 记日志而非静默吞掉, 便于排查"数据结构错乱"类问题。
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
else:
# 时序: timeseries/ 下按日期分区
out_dir = cfg_dir / "timeseries" / f"date={snap}"
@@ -421,8 +428,8 @@ def write_ext_parquet(
existing = pl.read_parquet(out_path)
key = "symbol" if "symbol" in df.columns else df.columns[0]
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
except Exception:
pass
except Exception as e:
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
df = cast_df_to_schema(df, config.fields)
df.write_parquet(out_path)
+236
View File
@@ -0,0 +1,236 @@
"""内置扩展数据预设 — 概念/行业首次启动自动拉取。
设计原则:
- 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动)
- 仅在本模块做接口结构 本地 schema的转换
- 已存在则跳过: 绝不覆盖用户已有数据, 老用户零影响
- 拉取失败只记 warning, 不阻断启动 (保持没数据也能跑)
种子数据来源: https://files.688798.xyz/ths/{concepts,industries}.json
作者更新数据只需改接口上的 JSON, 用户下次拉取自动同步, 无需发版
接入点: app.main.lifespan ensure_builtin_presets(store.data_dir)
"""
from __future__ import annotations
import logging
from pathlib import Path
from app.services.ext_data import (
ExtConfig,
ExtConfigStore,
ExtField,
PullConfig,
rows_to_parquet,
)
logger = logging.getLogger(__name__)
# 种子数据源 (作者维护, 改这里即对所有用户生效)
_THS_BASE = "https://files.688798.xyz/ths"
# ---------------------------------------------------------------------------
# 预设定义: 字段结构 + 拉取配方
# ---------------------------------------------------------------------------
def _concept_preset() -> ExtConfig:
"""扩展概念 (ext_gn_ths)。
接口结构: [{symbol, name, concepts: [概念1, 概念2, ...]}]
本地 schema: 股票代码 / 股票简称 / 所属概念(分号拼接) / symbol / code
"""
return ExtConfig(
id="ext_gn_ths",
label="扩展概念",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("code", "string", "代码"),
ExtField("股票代码", "string", "股票代码"),
ExtField("股票简称", "string", "股票简称"),
ExtField("所属概念", "string", "所属概念"),
],
description="同花顺概念分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
url=f"{_THS_BASE}/concepts.json",
method="GET",
schedule_minutes=1440,
enabled=False,
),
)
def _industry_preset() -> ExtConfig:
"""扩展行业 (ext_hy_ths)。
接口结构: [{symbol, name, industries: [一级行业, 二级行业, 三级行业]}]
本地 schema: 股票代码 / 股票简称 / 所属同花顺行业(横杠拼接) / symbol / code
"""
return ExtConfig(
id="ext_hy_ths",
label="扩展行业",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("code", "string", "代码"),
ExtField("股票代码", "string", "股票代码"),
ExtField("股票简称", "string", "股票简称"),
ExtField("所属同花顺行业", "string", "所属同花顺行业"),
],
description="同花顺行业分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
url=f"{_THS_BASE}/industries.json",
method="GET",
schedule_minutes=1440,
enabled=False,
),
)
def _presets() -> list[ExtConfig]:
return [_concept_preset(), _industry_preset()]
# ---------------------------------------------------------------------------
# 接口结构 → 本地 schema 转换 (仅预设使用)
# ---------------------------------------------------------------------------
def _symbol_to_code(symbol: str) -> str:
"""symbol (000001.SZ) → code (000001)。"""
return symbol.split(".", 1)[0] if "." in symbol else symbol
def _flatten_concept_rows(raw_rows: list[dict]) -> list[dict]:
"""概念: concepts 数组 → 分号拼接成「所属概念」字符串。
[{symbol, name, concepts:[...]}] [{股票代码, 股票简称, 所属概念, symbol, code}]
: code symbol 派生 (000001.SZ 000001), rows_to_parquet 不执行 code_map
"""
out: list[dict] = []
for r in raw_rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
concepts = r.get("concepts") or []
out.append({
"股票代码": sym,
"股票简称": r.get("name") or "",
"所属概念": ";".join(str(c) for c in concepts if c),
"symbol": sym,
"code": _symbol_to_code(sym),
})
return out
def _flatten_industry_rows(raw_rows: list[dict]) -> list[dict]:
"""行业: industries 数组 → 横杠拼接成「所属同花顺行业」字符串。
[{symbol, name, industries:[...]}] [{股票代码, 股票简称, 所属同花顺行业, symbol, code}]
"""
out: list[dict] = []
for r in raw_rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
inds = r.get("industries") or []
out.append({
"股票代码": sym,
"股票简称": r.get("name") or "",
"所属同花顺行业": "-".join(str(i) for i in inds if i),
"symbol": sym,
"code": _symbol_to_code(sym),
})
return out
# ---------------------------------------------------------------------------
# 拉取执行 (复用 httpx, 不依赖 fetch_and_ingest 的 PullConfig 路径)
# ---------------------------------------------------------------------------
async def _fetch_json(url: str) -> list[dict]:
"""请求 JSON 接口, 返回行数组。超时 30s, 失败抛异常由调用方兜底。"""
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
raise ValueError(f"接口返回不是数组: {type(data)}")
return data
async def _seed_one(config: ExtConfig, flatten, data_dir: Path) -> int:
"""拉取 + 转换 + 写入单个预设。返回写入行数。"""
from datetime import date
raw = await _fetch_json(config.pull.url)
rows = flatten(raw)
if not rows:
raise ValueError(f"接口返回 0 行: {config.pull.url}")
n = rows_to_parquet(rows, config, data_dir, snapshot_date=date.today())
return n
# ---------------------------------------------------------------------------
# 对外入口
# ---------------------------------------------------------------------------
def get_preset(config_id: str) -> ExtConfig | None:
"""按 id 取预设定义 (供 API 层校验 id 合法性)。"""
for c in _presets():
if c.id == config_id:
return c
return None
async def ensure_builtin_presets(data_dir: Path) -> None:
"""启动时: 为缺失的预设创建 config.json (含 pull 配置), 但【不拉取数据】。
设计: 数据获取改为用户在概念/行业页手动点获取数据触发, 避免启动时
网络请求阻塞, 也避免自动拉取用户自主控制的预期冲突
安全保证:
- 已存在则完全跳过 (绝不覆盖用户数据)
- 只写 config.json, 失败只记 warning 不阻断启动
"""
store = ExtConfigStore(data_dir)
for config in _presets():
existing = store.get(config.id)
if existing is not None:
# 用户已有此表 (老用户 / 自己重建过) → 一律不动
continue
try:
store.upsert(config)
logger.info("内置扩展表 %s 配置已就绪 (待用户手动获取数据)", config.id)
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表 %s 配置写入失败 (不影响启动): %s", config.id, e)
async def fetch_preset(config_id: str, data_dir: Path) -> int:
"""手动触发某个预设的数据拉取 (供 API 调用)。
Raises:
ValueError: config_id 不是内置预设
Exception: 网络请求/解析/写入失败 ( API 层转 HTTP 错误)
"""
config = get_preset(config_id)
if config is None:
raise ValueError(f"未知的内置预设: {config_id}")
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
# 确保 config.json 存在 (用户可能从未启动过 ensure_builtin_presets)
store = ExtConfigStore(data_dir)
if store.get(config_id) is None:
store.upsert(config)
n = await _seed_one(config, flatten, data_dir)
logger.info("内置扩展表 %s 手动拉取成功: %d", config_id, n)
return n
+157 -34
View File
@@ -75,6 +75,19 @@ def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
# 拉取执行
# ---------------------------------------------------------------------------
def _apply_preset_flatten(config_id: str, rows: list[dict]) -> list[dict]:
"""对内置预设 (概念/行业) 应用结构转换, 与 fetch_preset 保持一致。
延迟导入避免与 ext_presets 形成循环依赖
非预设 id 原样返回
"""
if config_id not in ("ext_gn_ths", "ext_hy_ths"):
return rows
from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
return flatten(rows)
async def fetch_and_ingest(
config: ExtConfig,
data_dir,
@@ -111,6 +124,12 @@ async def fetch_and_ingest(
if not rows:
raise ValueError("提取到的行数为 0")
# 内置预设 (概念/行业): 应用结构转换, 让产出 schema 与分析页一致。
# 否则 raw 接口列 (concepts/industries 数组、name) 会直接覆盖正确的 part.parquet,
# 导致分析页因找不到维度字段 (所属概念/所属同花顺行业) 而"数据消失"。
# 见 ext_presets._flatten_* —— 手动拉取 / 定时拉取都必须走同一套转换。
rows = _apply_preset_flatten(config.id, rows)
# 字段映射
rows = _apply_field_map(rows, pull.field_map)
@@ -129,21 +148,46 @@ async def fetch_and_ingest(
# ---------------------------------------------------------------------------
class PullScheduler:
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。"""
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。
线程安全说明:
refresh()/stop() 可能从主事件循环 (lifespan startup) 或同步路由的
worker 线程 (configure_pull def 而非 async def, FastAPI 丢进线程池)
调用worker 线程里没有 running loop, 直接 asyncio.create_task 会抛
"no running event loop"因此对 task 的增删一律通过
call_soon_threadsafe 提交到主循环执行 同一套代码两种调用场景都安全
"""
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task] = {}
self._running = False
self._lock = threading.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
def start(self, data_dir) -> None:
"""启动调度(在 lifespan startup 调用)。"""
"""启动调度(在 lifespan startup 调用,主事件循环内)。"""
self._running = True
self._data_dir = data_dir
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
logger.info("PullScheduler started")
def _submit(self, fn, *args) -> None:
"""把一个 callable 提交到主事件循环执行 (线程安全)。
startup 在主循环内调用时 fn 立即排队; worker 线程调用时跨线程排队
两者都通过 call_soon_threadsafe, 保证 _tasks 字典的读写只在主循环里发生
"""
loop = self._loop
if loop is None or loop.is_closed():
raise RuntimeError(
"PullScheduler: 事件循环不可用 (start() 未在事件循环中调用?)"
)
loop.call_soon_threadsafe(fn, *args)
def stop(self) -> None:
"""停止所有任务。"""
"""停止所有任务 (从 shutdown 调用)"""
self._running = False
for task in self._tasks.values():
task.cancel()
@@ -151,47 +195,61 @@ class PullScheduler:
logger.info("PullScheduler stopped")
def refresh(self, data_dir) -> None:
"""重新加载配置,更新调度任务(增/删/改)。"""
"""重新加载配置,更新调度任务(增/删/改)。线程安全。"""
self._data_dir = data_dir
store = ExtConfigStore(data_dir)
configs = store.load_all()
active_ids: set[str] = set()
new_configs: list[ExtConfig] = []
for config in configs:
if not config.pull or not config.pull.enabled or not config.pull.url:
continue
active_ids.add(config.id)
if config.id not in self._tasks:
# 新增调度
task = asyncio.create_task(self._run_loop(config))
self._tasks[config.id] = task
logger.info("PullScheduler: scheduled %s (every %d min)", config.id, config.pull.schedule_minutes)
new_configs.append(config)
# 移除不再活跃的
for cid in list(self._tasks):
if cid not in active_ids:
self._tasks[cid].cancel()
del self._tasks[cid]
logger.info("PullScheduler: removed %s", cid)
# 需要移除的 id (快照当前 task 字典的键, 避免遍历时改字典)
remove_ids = [cid for cid in list(self._tasks) if cid not in active_ids]
# 所有对 _tasks 的修改都提交到主循环里执行, 保证线程安全
def _apply() -> None:
for config in new_configs:
if config.id not in self._tasks: # 二次校验, 防重复
self._tasks[config.id] = self._loop.create_task(
self._run_loop(config)
)
logger.info(
"PullScheduler: scheduled %s (every %d min)",
config.id, config.pull.schedule_minutes,
)
for cid in remove_ids:
task = self._tasks.pop(cid, None)
if task is not None:
task.cancel()
logger.info("PullScheduler: removed %s", cid)
self._submit(_apply)
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。"""
"""单个配置的定时拉取循环。
策略: 启用后立即执行一次, 之后按 interval 循环
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
这样用户中途修改间隔也能立即生效 (无需重启)
"""
try:
while self._running:
pull = config.pull
if not pull:
break
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
await asyncio.sleep(interval)
if not self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
try:
# 重新加载最新配置(用户可能中途修改)
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
n, d = await fetch_and_ingest(fresh, self._data_dir)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
@@ -200,14 +258,79 @@ class PullScheduler:
store.upsert(fresh)
logger.info("PullScheduler: %s success, %d rows", config.id, n)
except Exception as e:
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if fresh and fresh.pull:
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "error"
fresh.pull.last_message = str(e)[:200]
store.upsert(fresh)
fresh2 = store.get(config.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
store.upsert(fresh2)
logger.warning("PullScheduler: %s error: %s", config.id, e)
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(timezone.utc).timestamp() + interval
latest = store.get(config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=timezone.utc
).isoformat()
store.upsert(latest)
await asyncio.sleep(interval)
if not self._running:
break
except asyncio.CancelledError:
pass
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。
策略: 启用后立即执行一次, 之后按 interval 循环
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
这样用户中途修改间隔也能立即生效 (无需重启)
"""
try:
while self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
try:
n, d = await fetch_and_ingest(fresh, self._data_dir)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
fresh.pull.last_message = f"{n} rows @ {d}"
fresh.pull.last_rows = n
store.upsert(fresh)
logger.info("PullScheduler: %s success, %d rows", config.id, n)
except Exception as e:
fresh2 = store.get(config.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
store.upsert(fresh2)
logger.warning("PullScheduler: %s error: %s", config.id, e)
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(timezone.utc).timestamp() + interval
latest = store.get(config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=timezone.utc
).isoformat()
store.upsert(latest)
await asyncio.sleep(interval)
if not self._running:
break
except asyncio.CancelledError:
pass
@@ -0,0 +1,187 @@
"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。
职责: 拉取单只标的的 4 张财务表 转成紧凑 JSON 拼装 CFA 分析师级系统提示词
流式调用 OpenAI 兼容 API chunk 吐给前端
不知道: HTTP前端配置持久化
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import AsyncIterator
import polars as pl
from app.services.financial_sync import get_financial_df
logger = logging.getLogger(__name__)
# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费
_MAX_PERIODS = 4
def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
"""读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。
数值统一做 NaN/Inf null 清洗,保证 JSON 序列化不报错
"""
result: dict[str, list[dict]] = {}
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
df = get_financial_df(data_dir, table)
if df.is_empty():
result[table] = []
continue
df = df.filter(pl.col("symbol") == symbol)
if df.is_empty():
result[table] = []
continue
# 按 period_end 降序,截取最新 N 期
if "period_end" in df.columns:
df = df.sort("period_end", descending=True).head(_MAX_PERIODS)
# 清洗 NaN/Inf,转成 JSON 安全的 dict 列表
rows = []
for rec in df.to_dicts():
clean = {}
for k, v in rec.items():
if k == "symbol":
continue # 不需要重复回传 symbol
if isinstance(v, float):
import math
clean[k] = None if not math.isfinite(v) else v
else:
clean[k] = v
rows.append(clean)
result[table] = rows
return result
def _summarize(fins: dict[str, list[dict]]) -> str:
"""生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。"""
parts = []
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
rows = fins.get(table, [])
if rows:
periods = [r.get("period_end") for r in rows if r.get("period_end")]
parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})")
else:
parts.append(f"{table}: 无数据")
return " · ".join(parts)
# ================================================================
# 系统提示词 —— CFA 分析师级,九维分析框架
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。
## 输出规范
**Markdown** 格式输出,严格遵循以下结构不要输出任何 JSON 或代码块,直接输出 Markdown 正文
### 1. 📌 核心摘要(1-2 句)
用一句话概括该公司的财务画像:盈利质量成长动能财务健康度的最关键判断结尾用综合评级:给出 1-5 星评级
### 2. ✅ 亮点(2-3 条)
列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑例如盈利高增ROE 持续提升现金流充沛等
### 3. ⚠️ 风险提示(2-3 条)
客观指出**潜在风险或值得警惕的信号**,例如应收激增存货堆积经营现金流与净利润背离债务攀升等宁可保守,不要回避
### 4. 📊 分项诊断
**表格**呈现各维度的诊断结论,列为维度 / 关键指标 / 判断维度包括:
- **盈利能力**:ROE / ROA / 毛利率 / 净利率
- **成长性**:营收同比 / 净利润同比
- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算)
- **现金流**:经营现金流净额 / 与净利润的匹配度
- **营运效率**:存货周转率等(有数据时)
每个判断给优秀 / 良好 / 一般 / 偏弱 / 警惕之一,并一句话说明依据
### 5. 🎯 综合评估与展望
2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)核心驱动力未来需重点跟踪的指标**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类
## 分析准则(务必遵守)
1. **数据说话**:每个判断必须引用具体数值("营收同比 +28.5%"),严禁空泛套话
2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期
3. **交叉验证**:经营现金流 vs 净利润(是否造血)毛利率 vs 费用率(盈利结构)负债 vs 资产(杠杆)
4. **行业常识**:对照 A 股常识判断水平( ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低)
5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎
6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500
## 重要免责
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。"
现在请基于下方数据进行分析"""
def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str:
"""构建用户消息:标的代码 + 数据 JSON + 可选关注点。"""
data_json = json.dumps(fins, ensure_ascii=False, indent=2)
lines = [
f"标的标准代码: {symbol}",
f"数据概览: {_summarize(fins)}",
"",
"以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):",
"```json",
data_json,
"```",
]
if focus.strip():
lines.extend([
"",
f"本次分析请特别关注: {focus.strip()}",
])
return "\n".join(lines)
async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
- 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要
- 之后逐 chunk yield {"type":"delta","content":"..."}
- 出错时 yield {"type":"error","message":"..."}
- 结束 yield {"type":"done"}
"""
# 1. 加载数据
fins = _load_stock_financials(data_dir, symbol)
total_rows = sum(len(v) for v in fins.values())
if total_rows == 0:
yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False)
return
# 2. meta
yield json.dumps({
"type": "meta",
"symbol": symbol,
"summary": _summarize(fins),
"periods": total_rows,
}, ensure_ascii=False)
# 3. 调用 LLM 流式
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
max_tokens=4000,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+141 -45
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -200,15 +200,82 @@ class FinancialScheduler:
# 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。
self._is_syncing = False
def start(self, data_dir: Path, capset: CapabilitySet) -> None:
def start(self, data_dir: Path, capset: CapabilitySet, *, auto_schedule: bool = False) -> None:
"""初始化调度器,并按需启动周期同步后台任务。
auto_schedule=False (默认): 仅初始化 (设置数据目录/能力 + 恢复 last_sync),
/api/financials/sync/* 手动同步使用, 不启动自动调度
auto_schedule=True: 额外启动每周一次的 metrics 自动同步 (启动后 60s 首跑)
"""
# 先记录 data_dir/capset, 即使当前无 FINANCIAL 也保留引用:
# 用户稍后在「设置」页升级到 Expert Key 时, update_capabilities() 会把新 capset
# 推进来,trigger()/run_now() 才能用上 FINANCIAL。否则 _capset 永远是 None,
# 即便 app.state.capabilities 已更新, 调度器仍报 "no FINANCIAL capability"。
self._data_dir = data_dir
self._capset = capset
if not capset.has(Cap.FINANCIAL):
logger.info("FinancialScheduler skipped: no FINANCIAL capability")
return
self._data_dir = data_dir
self._capset = capset
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
try:
from app.services import preferences
restored = dict(preferences.get_financial_sync_times())
# 老用户迁移兜底: 若某表在 preferences 无记录但 parquet 已存在(升级前同步过),
# 用 parquet 文件的修改时间作为同步时间并补写持久化。
for table in FINANCIAL_TABLES:
if table in restored:
continue
parquet = data_dir / "financials" / table / "part.parquet"
if parquet.exists():
mtime = datetime.fromtimestamp(parquet.stat().st_mtime, tz=timezone.utc).isoformat()
restored[table] = mtime
preferences.set_financial_sync_time(table, mtime)
logger.info("FinancialScheduler backfilled last_sync for %s from parquet mtime", table)
self._last_sync = restored
if self._last_sync:
logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys()))
except Exception as e: # noqa: BLE001
logger.warning("restore financial_sync_times failed: %s", e)
if not auto_schedule:
# 仅初始化 (手动同步用), 不启动周期任务。
logger.info("FinancialScheduler initialized (auto-schedule disabled; manual sync only)")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("FinancialScheduler started")
logger.info("FinancialScheduler started (auto-schedule enabled)")
def _record_sync(self, table: str) -> None:
"""记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。
持久化确保即使重启,前端 /status 仍返回真实的最后同步时间,
不会错误地显示"尚未同步"
"""
ts = datetime.now(timezone.utc).isoformat()
self._last_sync[table] = ts
try:
from app.services import preferences
preferences.set_financial_sync_time(table, ts)
except Exception as e: # noqa: BLE001
logger.warning("persist financial_sync_time(%s) failed: %s", e)
def update_capabilities(self, capset: CapabilitySet) -> None:
"""刷新调度器持有的能力集。
用户在设置页新增/清除 API Key , settings API 会重新探测能力并更新
app.state.capabilities; 必须同步推给本调度器, 否则 trigger()/run_now() 仍读
启动时的旧 capset, 即便 app.state 已含 FINANCIAL, 调度器仍报
"no FINANCIAL capability" 而拒绝同步 (表现为前端全部同步按钮闪一下无动作)
"""
prev = self._capset
self._capset = capset
had = bool(prev) and prev.has(Cap.FINANCIAL)
now = capset.has(Cap.FINANCIAL)
if had != now:
logger.info(
"FinancialScheduler capabilities updated: FINANCIAL %s -> %s", had, now
)
def stop(self) -> None:
self._running = False
@@ -217,22 +284,6 @@ class FinancialScheduler:
self._task = None
logger.info("FinancialScheduler stopped")
def update(self, data_dir: Path, capset: CapabilitySet) -> None:
"""运行时更新数据目录和能力集。
用户在设置页更换/清除 Key ,能力集可能变化,无需重启服务即可让
财务调度器生效或失效
"""
had_financial = self._capset is not None and self._capset.has(Cap.FINANCIAL)
has_financial = capset.has(Cap.FINANCIAL)
self._data_dir = data_dir
self._capset = capset
if has_financial and not self._running:
self.start(data_dir, capset)
elif had_financial and not has_financial and self._running:
self.stop()
async def _run_loop(self) -> None:
"""每周执行一次 metrics 同步。"""
try:
@@ -245,7 +296,7 @@ class FinancialScheduler:
# 每周: 只同步 metrics
try:
rows = sync_metrics(self._data_dir, self._capset)
self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat()
self._record_sync("metrics")
logger.info("FinancialScheduler: metrics synced, %d rows", rows)
except Exception as e:
logger.warning("FinancialScheduler: metrics sync failed: %s", e)
@@ -259,8 +310,39 @@ class FinancialScheduler:
except asyncio.CancelledError:
pass
def _run_body(self, table: str | None) -> dict[str, int]:
"""同步逻辑本体(不加锁,假设调用方已持有 _is_syncing)。
table=None 同步全部 4 张表;否则只同步指定表
每张表完成立即更新 last_sync,让前端轮询 /status 能看到进度递增
"""
if table:
fn = {
"metrics": sync_metrics,
"income": sync_income,
"balance_sheet": sync_balance_sheet,
"cash_flow": sync_cash_flow,
}.get(table)
if not fn:
return {}
rows = fn(self._data_dir, self._capset)
self._record_sync(table)
return {table: rows}
# 全部同步
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
self._record_sync(t)
_refresh_financials_views(self._data_dir)
return result
def run_now(self, table: str | None = None) -> dict[str, int]:
"""手动触发同步。table=None 同步全部
"""同步执行一次同步(阻塞调用线程)
全量同步需数分钟,务必在后台线程调用,不要直接在 HTTP 请求线程里阻塞,
否则请求会长时间 pending 直至被浏览器/代理超时掐断(表现为"点击无反应")
HTTP 接口应调用 trigger() 立即返回,再让前端轮询 /status.syncing 看进度
_is_syncing 标志防并发:若已有同步在进行,本次直接跳过,
避免重复请求拖慢服务端 / 触发上游限流
@@ -273,32 +355,46 @@ class FinancialScheduler:
return {"_skipped": 1}
self._is_syncing = True
try:
if table:
fn = {
"metrics": sync_metrics,
"income": sync_income,
"balance_sheet": sync_balance_sheet,
"cash_flow": sync_cash_flow,
}.get(table)
if not fn:
return {}
rows = fn(self._data_dir, self._capset)
self._last_sync[table] = datetime.now(timezone.utc).isoformat()
return {table: rows}
else:
# 全部同步: 逐表执行, 每张完成立即更新 last_sync,
# 让前端轮询 /status 能看到进度递增 (而非等全部完成才一次性更新)。
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
self._last_sync[t] = datetime.now(timezone.utc).isoformat()
_refresh_financials_views(self._data_dir)
return result
return self._run_body(table)
finally:
with self._lock:
self._is_syncing = False
def trigger(self, table: str | None = None) -> dict[str, int]:
"""触发一次同步(非阻塞,立即返回)。
在后台线程执行同步体,HTTP 请求无需等待
返回 {"started": True/False}:
- False = 能力不足或已有同步在进行(被防并发跳过)
- True = 已在后台开始,前端应轮询 /status.syncing 观察进度
_is_syncing 在此处置 True(持锁),确保 trigger 返回时前端轮询
/status 已能看到 syncing=True,无竞态窗口;同时防止快速重复点击
启动多个后台线程后台线程复用 _run_body 执行真正的同步逻辑
"""
if not self._capset or not self._capset.has(Cap.FINANCIAL):
return {"started": False, "reason": "no FINANCIAL capability"}
with self._lock:
if self._is_syncing:
logger.info("financial sync trigger skipped: already running")
return {"started": False, "reason": "already running"}
# 持锁置位:保证 trigger 返回前 syncing 已为 True
self._is_syncing = True
def _bg() -> None:
try:
self._run_body(table)
except Exception as e: # noqa: BLE001
logger.exception("background financial sync failed: %s", e)
finally:
with self._lock:
self._is_syncing = False
t = threading.Thread(target=_bg, name="financial-sync", daemon=True)
t.start()
logger.info("financial sync triggered in background: table=%s", table or "all")
return {"started": True}
@property
def is_syncing(self) -> bool:
"""手动同步是否正在进行(供 /status 返回,前端据此显示"同步中")。"""
+245 -31
View File
@@ -1,8 +1,14 @@
"""指数数据同步服务。"""
"""指数 / ETF 数据同步服务。
标的列表优先用免费的 exchanges.get_instruments(type=index/etf) 拉取
(None/Free 档均可用,无需 quote.pool 权限);付费档可额外用
quotes.get_by_universes 作为补充来源日K统一走 klines.batch
"""
from __future__ import annotations
import logging
import gc
from collections.abc import Callable
from datetime import datetime, timedelta
import polars as pl
@@ -15,9 +21,15 @@ from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
# exchanges.get_instruments 查询的交易所(沪深京)
_EXCHANGES = ["SH", "SZ", "BJ"]
def _quotes_to_index_instruments(resp) -> pl.DataFrame:
"""数据源 quotes 响应规范为指数 instruments。"""
""" TickFlow quotes 响应(get_by_universes)规范为指数 instruments。
付费档(Starter+)的补充来源,免费档用不到
"""
if resp is None:
return pl.DataFrame()
@@ -61,33 +73,119 @@ def _quotes_to_index_instruments(resp) -> pl.DataFrame:
return result.unique(subset=["symbol"], keep="last").sort("symbol")
def sync_index_instruments(repo: KlineRepository) -> int:
"""同步 CN_Index 指数标的维表,返回指数数量。"""
def _fetch_instruments_by_type(instrument_type: str, asset_type_label: str) -> pl.DataFrame:
"""用免费的 exchanges.get_instruments 拉取指定类型的标的列表。
None/Free 档均可使用(标的信息查询免费开放)
instrument_type: 'index' / 'etf'
asset_type_label: 写入 instruments 表的 asset_type 标记('index' / 'etf')
"""
tf = get_client()
resp = None
errors: list[str] = []
for kwargs in (
{"universes": ["CN_Index"]},
{"universes": ["CN_Index"], "as_dataframe": False},
):
rows: list[dict] = []
for ex in _EXCHANGES:
try:
resp = tf.quotes.get_by_universes(**kwargs)
if resp is not None and len(resp) > 0:
break
items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type)
for it in items or []:
item = it if isinstance(it, dict) else {}
symbol = item.get("symbol")
if not symbol:
continue
rows.append({
"symbol": str(symbol),
"name": item.get("name") or str(symbol),
})
except Exception as e: # noqa: BLE001
errors.append(str(e))
resp = None
logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e)
if resp is None or len(resp) == 0:
logger.warning("CN_Index universe returned empty: %s", "; ".join(errors))
return 0
if not rows:
return pl.DataFrame()
instruments = _quotes_to_index_instruments(resp)
if instruments.is_empty():
return (
pl.DataFrame(rows)
.with_columns([
pl.col("symbol").str.split(".").list.first().alias("code"),
pl.lit(asset_type_label).alias("asset_type"),
])
.unique(subset=["symbol"], keep="last")
.sort("symbol")
)
def sync_index_instruments(
repo: KlineRepository,
pull_index: bool = True,
pull_etf: bool = True,
) -> int:
"""同步指数 / ETF 标的维表,返回标的总数。
新版物理分开保存: 指数写 instruments_index, ETF instruments_etf
读取层仍兼容旧版 instruments_index asset_type='etf' 的历史数据
"""
index_parts: list[pl.DataFrame] = []
etf_parts: list[pl.DataFrame] = []
# 1) 免费通道:按开关分别拉 index / etf
if pull_index:
index_df = _fetch_instruments_by_type("index", "index")
if not index_df.is_empty():
index_parts.append(index_df)
if pull_etf:
etf_df = _fetch_instruments_by_type("etf", "etf")
if not etf_df.is_empty():
etf_parts.append(etf_df)
# 2) 付费补充:Starter+ 用 get_by_universes 补指数(仅当开启指数拉取)
if pull_index:
capset = None
try:
from app.tickflow import policy
capset = policy.detect_capabilities(force=False)
except Exception: # noqa: BLE001
pass
if capset is not None and capset.has(Cap.QUOTE_POOL):
tf = get_client()
for kwargs in (
{"universes": ["CN_Index"]},
{"universes": ["CN_Index"], "as_dataframe": False},
):
try:
resp = tf.quotes.get_by_universes(**kwargs)
if resp is not None and len(resp) > 0:
sup = _quotes_to_index_instruments(resp)
if not sup.is_empty():
index_parts.append(sup)
break
except Exception as e: # noqa: BLE001
logger.debug("CN_Index universe supplement failed: %s", e)
total = 0
if index_parts:
index_inst = pl.concat(index_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
if not index_inst.is_empty():
repo.save_index_instruments(index_inst)
total += index_inst.height
if etf_parts:
etf_inst = pl.concat(etf_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
if not etf_inst.is_empty():
repo.save_etf_instruments(etf_inst)
total += etf_inst.height
if total == 0:
logger.warning("指数/ETF 标的列表为空(pull_index=%s, pull_etf=%s)", pull_index, pull_etf)
return 0
repo.save_index_instruments(instruments)
repo.refresh_index_views()
return instruments.height
logger.info("指数/ETF 标的同步完成: %d", total)
return total
def sync_etf_instruments(repo: KlineRepository) -> int:
"""单独同步 ETF 标的维表(返回 ETF 数量)。"""
etf_df = _fetch_instruments_by_type("etf", "etf")
if etf_df.is_empty():
return 0
repo.save_etf_instruments(etf_df)
repo.refresh_index_views()
return etf_df.height
def sync_and_persist_index_daily(
@@ -96,19 +194,32 @@ def sync_and_persist_index_daily(
count: int | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步指数日K到独立 parquet并计算指数 enriched。"""
"""同步指数/ETF 日K到独立 parquet,并计算 enriched。
symbols_override 非空时,只拉这些代码(跳过 instruments ),用于自定义范围
否则取 index_instruments 表全量(指数+ETF 合并存储)
on_chunk_done(current, total) 每个批次完成后回调
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
instruments = repo.get_index_instruments()
if instruments.is_empty():
sync_index_instruments(repo)
if symbols_override:
symbols = sorted(set(s for s in symbols_override if s))
if not symbols:
return 0
else:
instruments = repo.get_index_instruments()
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
if instruments.is_empty():
sync_index_instruments(repo, pull_index=True, pull_etf=False)
instruments = repo.get_index_instruments()
if not instruments.is_empty() and "asset_type" in instruments.columns:
instruments = instruments.filter(pl.col("asset_type") != "etf")
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
batch_size = preferences.get_index_daily_batch_size()
if lim and lim.batch:
@@ -139,7 +250,110 @@ def sync_and_persist_index_daily(
enriched = compute_enriched(raw, factors=None, instruments=None)
repo.append_index_enriched(enriched)
total_rows += raw.height
logger.info("index daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
return total_rows
def _load_etf_factors(repo: KlineRepository) -> pl.DataFrame:
factor_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
if not factor_path.exists():
return pl.DataFrame()
try:
return pl.read_parquet(factor_path)
except Exception as e: # noqa: BLE001
logger.warning("ETF 复权因子读取失败: %s", e)
return pl.DataFrame()
def sync_etf_adj_factor(
symbols: list[str],
repo: KlineRepository,
capset: CapabilitySet,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done=None,
) -> tuple[int, list[str]]:
"""同步 ETF 复权因子;失败由调用方降级为 warning。"""
return kline_sync.sync_adj_factor(
symbols,
repo,
capset,
start_time=start_time,
end_time=end_time,
on_chunk_done=on_chunk_done,
asset_type="etf",
)
def sync_and_persist_etf_daily(
repo: KlineRepository,
capset: CapabilitySet,
count: int | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。
on_chunk_done(current, total) 每个批次完成后回调
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
if symbols_override:
symbols = sorted(set(s for s in symbols_override if s))
else:
instruments = repo.get_etf_instruments()
if instruments.is_empty():
sync_etf_instruments(repo)
instruments = repo.get_etf_instruments()
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
if not symbols:
return 0
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
batch_size = preferences.get_index_daily_batch_size()
if lim and lim.batch:
batch_size = min(batch_size, lim.batch)
rpm = lim.rpm if lim else None
end_time = end_date or datetime.now()
start_time = start_date or (end_time - timedelta(days=365))
total_rows = 0
interval = (60.0 / rpm) if rpm else 0
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
factors = _load_etf_factors(repo)
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
import time
time.sleep(interval)
raw = kline_sync.sync_daily_batch(
chunk,
count=count,
batch_size=None,
start_time=start_time,
end_time=end_time,
)
if raw.is_empty():
continue
repo.append_etf_daily(raw)
batch_factors = factors.filter(pl.col("symbol").is_in(chunk)) if not factors.is_empty() else factors
# ETF 使用复权和通用技术指标;不传 instruments,避免套用 A股涨跌停/连板逻辑。
enriched = compute_enriched(raw, factors=batch_factors, instruments=None)
repo.append_etf_enriched(enriched)
total_rows += raw.height
logger.info("etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
+64 -7
View File
@@ -223,11 +223,53 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int:
return daily_df.height
def _normalize_adj_factor(raw) -> pl.DataFrame:
"""Normalize SDK ex_factors response to symbol/trade_date/ex_factor."""
if raw is None or len(raw) == 0:
return pl.DataFrame()
if isinstance(raw, dict):
rows: list[dict] = []
for sym, values in raw.items():
for item in values or []:
row = dict(item or {})
row.setdefault("symbol", sym)
rows.append(row)
df = pl.DataFrame(rows) if rows else pl.DataFrame()
elif isinstance(raw, pl.DataFrame):
df = raw
else:
df = pl.from_pandas(raw.reset_index() if hasattr(raw, "reset_index") else raw)
if df.is_empty():
return df
# rename: timestamp/date → trade_date, adj_factor → ex_factor
# 注意: 新版 SDK 可能同时返回 timestamp 和 trade_date (或 adj_factor 和 ex_factor),
# 直接 rename 会产生重复列报错。仅当目标列不存在时才 rename。
rename_map: dict[str, str] = {}
for src, dst in (("timestamp", "trade_date"), ("date", "trade_date"), ("adj_factor", "ex_factor")):
if src in df.columns and dst not in df.columns:
rename_map[src] = dst
df = df.rename(rename_map)
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
df = df.with_columns(
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
if "ex_factor" in df.columns:
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
cols = [c for c in ["symbol", "trade_date", "ex_factor"] if c in df.columns]
if len(cols) < 3:
return pl.DataFrame()
return df.select(cols).drop_nulls()
def sync_adj_factor(symbols: list[str], repo: KlineRepository,
capset: CapabilitySet,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done: Callable[[int, int], None] | None = None) -> tuple[int, list[str]]:
on_chunk_done: Callable[[int, int], None] | None = None,
asset_type: str = "stock") -> tuple[int, list[str]]:
"""同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。
支持增量: start_time/end_time 只拉取该时间范围内的新除权事件
@@ -257,10 +299,9 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
time.sleep(interval)
try:
raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
if raw is not None and len(raw) > 0:
all_dfs.append(pl.from_pandas(
raw.reset_index() if hasattr(raw, "reset_index") else raw
))
normalized = _normalize_adj_factor(raw)
if not normalized.is_empty():
all_dfs.append(normalized)
logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk))
except Exception as e: # noqa: BLE001
logger.warning("adj_factor chunk %d failed: %s", i + 1, e)
@@ -276,7 +317,8 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
# 提取受影响的 symbol 列表(合并前)
affected = new_data["symbol"].unique().to_list()
out = repo.store.data_dir / "adj_factor" / "all.parquet"
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
out = repo.store.data_dir / factor_dir / "all.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
@@ -420,7 +462,7 @@ def sync_minute_batch(
def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
"""数据源实时拉取单股单日分钟 K(不写入本地)。"""
""" TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
from datetime import datetime
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
@@ -445,6 +487,21 @@ def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
return pl.DataFrame()
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股除权因子(不写入本地), 用于单股 K 线即时前复权。
返回结构: symbol, trade_date, ex_factor ( DataFrame 表示无除权事件或拉取失败)
_apply_adj_factor / compute_enriched factors 参数格式一致
"""
tf = get_client()
try:
raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
return pl.DataFrame()
return _normalize_adj_factor(raw)
def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
"""本地分钟 K 数据的最新时间。"""
try:
@@ -0,0 +1,576 @@
"""市场总览数据装配(与 HTTP Request 解耦)。
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让大盘复盘
等无 Request 的调用方(定时任务复盘服务)也能复用同一套聚合逻辑
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
quote_service,depth_service}` 的依赖改为显式参数
公共入口:
build_market_overview(repo, quote_service, depth_service, as_of)
"""
from __future__ import annotations
import math
import re
from datetime import date
from typing import Any
import polars as pl
from app.services.ext_data import ExtConfig, ExtConfigStore
from app.services.screener import ScreenerService
# ================================================================
# 常量(与 overview.py 保持同步;复盘复盘仅 A 股核心指数)
# ================================================================
CORE_INDEX_NAMES = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
"000680.SH": "科创综指",
}
CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys())
_DIMENSION_SEP = re.compile(r"[、,;|/\s]+")
# ================================================================
# 通用工具
# ================================================================
def _finite(v: Any) -> float | None:
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if math.isfinite(f) else None
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {k: _json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_json_safe(v) for v in value]
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def _board(symbol: str) -> str:
if symbol.endswith(".BJ"):
return "北交所"
if symbol.startswith(("300", "301")):
return "创业板"
if symbol.startswith(("688", "689")):
return "科创板"
if symbol.endswith(".SH"):
return "沪主板"
if symbol.endswith(".SZ"):
return "深主板"
return "其他"
def _score(value: float, low: float, high: float) -> int:
if high <= low:
return 50
return max(0, min(100, round((value - low) / (high - low) * 100)))
# ================================================================
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL)
# ================================================================
def _quote_status(quote_service) -> dict:
qs = quote_service
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
rows: list[dict] = []
if quote_service and as_of is None:
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows and repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
f"""
WITH ranked AS (
SELECT symbol, date, close,
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
FROM kline_index_daily
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), latest AS (
SELECT symbol,
max(CASE WHEN rn = 1 THEN date END) AS date,
max(CASE WHEN rn = 1 THEN close END) AS last_price,
max(CASE WHEN rn = 2 THEN close END) AS prev_close
FROM ranked
WHERE rn <= 2
GROUP BY symbol
)
SELECT symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
by_symbol = {r.get("symbol"): r for r in rows}
out = []
for symbol in CORE_INDEX_SYMBOLS:
r = by_symbol.get(symbol, {"symbol": symbol})
out.append({
"symbol": symbol,
"name": r.get("name") or CORE_INDEX_NAMES[symbol],
"last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")),
"change_pct": _finite(r.get("change_pct")),
"change_amount": _finite(r.get("change_amount")),
})
return out
# ================================================================
# 扩展数据(行业 / 概念)维度聚合
# ================================================================
def _dimension_field(config: ExtConfig, kind: str) -> str | None:
candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"]
for candidate in candidates:
needle = candidate.lower()
for field in config.fields:
haystack = f"{field.name} {field.label}".lower()
if needle in haystack:
return field.name
return None
def _ext_files(data_dir, config: ExtConfig) -> list[str]:
base = data_dir / "ext_data" / config.id
if config.mode == "timeseries":
root = base / "timeseries"
return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()]
return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()]
def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]:
files = _ext_files(data_dir, config)
if not files:
return []
try:
df = pl.read_parquet(files, hive_partitioning=True)
except TypeError:
try:
df = pl.read_parquet(files)
except Exception: # noqa: BLE001
return []
except Exception: # noqa: BLE001
return []
if df.is_empty() or dimension_field not in df.columns:
return []
if config.mode == "timeseries" and "date" in df.columns:
latest = df.get_column("date").max()
if latest is not None:
df = df.filter(pl.col("date") == latest)
symbol_cols = ["symbol", "code", "股票代码", "代码"]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
symbol_cols.append(str(mapping["col"]))
cols = []
for col in [dimension_field, *symbol_cols]:
if col in df.columns and col not in cols:
cols.append(col)
return df.select(cols).to_dicts()
def _dimension_values(raw: Any) -> list[str]:
if raw is None:
return []
values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()]
return values
def _symbol_keys(row: dict, config: ExtConfig) -> list[str]:
fields = ["symbol", "code", "股票代码", "代码"]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
fields.append(str(mapping["col"]))
keys: list[str] = []
for field in fields:
raw = row.get(field)
if raw is None:
continue
text = str(raw).strip().upper()
if not text:
continue
keys.append(text)
if "." in text:
keys.append(text.split(".", 1)[0])
return keys
def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: int | None = None) -> dict:
if not rows:
return {"leading": [], "lagging": []}
quote_map: dict[str, dict] = {}
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
if not symbol:
continue
quote_map[symbol] = row
quote_map[symbol.split(".", 1)[0]] = row
store = ExtConfigStore(repo.store.data_dir)
groups: dict[str, dict[str, dict]] = {}
for config in store.load_all():
field = _dimension_field(config, kind)
if not field:
continue
for ext_row in _read_ext_rows(repo.store.data_dir, config, field):
quote = None
for key in _symbol_keys(ext_row, config):
quote = quote_map.get(key)
if quote:
break
if not quote:
continue
symbol = str(quote.get("symbol") or "")
for value in _dimension_values(ext_row.get(field)):
# 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级)
if level is not None and "-" in value:
parts = value.split("-")
value = parts[level - 1] if level <= len(parts) else parts[-1]
groups.setdefault(value, {})[symbol] = quote
items = []
for name, by_symbol in groups.items():
stocks = list(by_symbol.values())
changes = [_finite(s.get("change_pct")) for s in stocks]
changes = [v for v in changes if v is not None]
if not changes:
continue
leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999)
items.append({
"name": name,
"count": len(stocks),
"avg_pct": sum(changes) / len(changes),
"up_count": sum(1 for v in changes if v > 0),
"down_count": sum(1 for v in changes if v < 0),
"amount": sum(_finite(s.get("amount")) or 0 for s in stocks),
"leader": {
"symbol": leader.get("symbol"),
"name": leader.get("name"),
"change_pct": _finite(leader.get("change_pct")),
},
})
leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit]
lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit]
return {"leading": leading, "lagging": lagging}
# ================================================================
# Top 行 / 涨跌幅分桶
# ================================================================
def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]:
filtered = [r for r in rows if _finite(r.get(key)) is not None]
filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending)
return [
{
"symbol": r.get("symbol"),
"name": r.get("name"),
"close": _finite(r.get("close")),
"change_pct": _finite(r.get("change_pct")),
"amount": _finite(r.get("amount")),
"turnover_rate": _finite(r.get("turnover_rate")),
"board": _board(str(r.get("symbol") or "")),
}
for r in filtered[:limit]
]
def _pct_band_rows(values: list[float]) -> list[dict]:
bands = [
("<-5%", None, -0.05),
("-5~-3%", -0.05, -0.03),
("-3~-1%", -0.03, -0.01),
("-1~0%", -0.01, 0),
("0~1%", 0, 0.01),
("1~3%", 0.01, 0.03),
("3~5%", 0.03, 0.05),
(">5%", 0.05, None),
]
total = len(values) or 1
out = []
for label, low, high in bands:
count = 0
for v in values:
if low is None and v < high:
count += 1
elif high is None and v >= low:
count += 1
elif low is not None and high is not None and low <= v < high:
count += 1
out.append({"label": label, "count": count, "pct": count / total * 100})
return out
# ================================================================
# 主装配入口
# ================================================================
def build_market_overview(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
) -> dict:
"""装配市场总览(与原 overview._build_overview 行为一致)。
Args:
repo: KlineRepository(必填)
quote_service: QuoteService(可选;实时指数行情来源)
depth_service: DepthService(可选;五档封板修正)
as_of: 指定日期,None 则取最新有数据日
"""
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
status = _quote_status(quote_service)
indices = _index_quotes(repo, quote_service, as_of)
if not as_of:
return {
"as_of": None,
"quote_status": status,
"indices": indices,
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
"amount": {"total": 0, "avg": 0},
"boards": [],
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
"distribution": [],
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
"radar": [],
"emotion": {"score": 50, "label": "暂无"},
"top_gainers": [],
"top_losers": [],
"turnover_leaders": [],
"active_leaders": [],
"concept_rank": {"leading": [], "lagging": []},
"industry_rank": {"leading": [], "lagging": []},
}
df = svc._load_enriched_for_date(as_of)
if df.is_empty():
rows: list[dict] = []
else:
cols = [
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
]
df = df.select([c for c in cols if c in df.columns])
rows = df.to_dicts()
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
if rows and "volume" in rows[0]:
rows = [r for r in rows
if (_finite(r.get("volume")) or 0) > 0
or (_finite(r.get("change_pct")) or 0) != 0]
total = len(rows)
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
flat = max(0, total - up - down)
up_pct = up / total * 100 if total else 0
down_pct = down / total * 100 if total else 0
amounts = [_finite(r.get("amount")) or 0 for r in rows]
total_amount = sum(amounts)
avg_amount = total_amount / total if total else 0
pct_values = [_finite(r.get("change_pct")) for r in rows]
pct_values = [v for v in pct_values if v is not None]
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
strong_up = sum(1 for v in pct_values if v >= 0.03)
strong_down = sum(1 for v in pct_values if v <= -0.03)
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
sealed_ready = False
fake_up = 0
fake_down = 0
if depth_service:
up_map = depth_service.get_sealed_map(as_of, is_down=False)
down_map = depth_service.get_sealed_map(as_of, is_down=True)
sealed_ready = bool(up_map or down_map) and depth_service.is_sealed_ready(as_of)
if up_map:
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
if down_map:
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
if sealed_ready:
limit_up = max(0, limit_up - fake_up)
limit_down = max(0, limit_down - fake_down)
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
def above_ma_count(ma_key: str) -> int:
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
above_ma5 = above_ma_count("ma5")
above_ma20 = above_ma_count("ma20")
above_ma60 = above_ma_count("ma60")
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
turnovers = [v for v in turnovers if v is not None]
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
high_turnover = sum(1 for v in turnovers if v >= 5)
boards_map: dict[str, dict] = {}
for r in rows:
b = _board(str(r.get("symbol") or ""))
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
item["count"] += 1
change = _finite(r.get("change_pct")) or 0
if change > 0:
item["up"] += 1
elif change < 0:
item["down"] += 1
item["amount"] += _finite(r.get("amount")) or 0
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
for b in boards:
count = b["count"] or 1
b["up_pct"] = b["up"] / count * 100
tiers_map: dict[int, int] = {}
for r in rows:
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
if n > 0:
tiers_map[n] = tiers_map.get(n, 0) + 1
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
index_changes = [_finite(r.get("change_pct")) for r in indices]
index_changes = [v for v in index_changes if v is not None]
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
vol_ratios = [v for v in vol_ratios if v is not None]
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
concept_rank = _dimension_rank(rows, repo, "concept")
industry_rank = _dimension_rank(rows, repo, "industry", level=2)
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
high_vol_pct = high_vol_ratio / total * 100 if total else 0
strong_down_pct = strong_down / total * 100 if total else 0
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
radar = [
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
{"key": "mainline", "label": "主线", "value": mainline_score},
]
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
if emotion_score >= 70:
emotion_label = "强势"
elif emotion_score >= 55:
emotion_label = "偏暖"
elif emotion_score >= 45:
emotion_label = "震荡"
elif emotion_score >= 30:
emotion_label = "偏冷"
else:
emotion_label = "冰点"
return _json_safe({
"as_of": str(as_of),
"quote_status": status,
"indices": indices,
"breadth": {
"total": total,
"up": up,
"down": down,
"flat": flat,
"up_pct": up_pct,
"down_pct": down_pct,
"avg_pct": avg_pct,
"median_pct": median_pct,
"strong_up": strong_up,
"strong_down": strong_down,
},
"amount": {"total": total_amount, "avg": avg_amount},
"boards": boards,
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
"distribution": _pct_band_rows(pct_values),
"trend": {
"above_ma5": above_ma5,
"above_ma20": above_ma20,
"above_ma60": above_ma60,
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
"new_high": new_high,
"new_low": new_low,
},
"activity": {
"avg_turnover": avg_turnover,
"high_turnover": high_turnover,
"high_vol_ratio": high_vol_pct,
"vol_ratio": avg_vol_ratio,
},
"radar": radar,
"emotion": {"score": emotion_score, "label": emotion_label},
"top_gainers": _top_rows(rows, "change_pct", True),
"top_losers": _top_rows(rows, "change_pct", False),
"turnover_leaders": _top_rows(rows, "amount", True),
"active_leaders": _top_rows(rows, "turnover_rate", True),
"concept_rank": concept_rank,
"industry_rank": industry_rank,
})
+343
View File
@@ -0,0 +1,343 @@
"""AI 大盘复盘 —— 流式 LLM 复盘生成。
复刻 stock_analyzer.py NDJSON 流式协议(meta/delta/error/done),
市场总览聚合数据交给 LLM 生成结构化复盘报告
数据来源:services.market_overview_builder.build_market_overview
( GET /api/overview/market 同源,保证复盘与看板数据口径一致)
流式协议( stock_analyzer / financial_analyzer 一致,前端解析无差异):
{"type":"meta", "as_of", "emotion_score", "emotion_label", "summary"}
{"type":"delta","content":"..."} chunk 文本
{"type":"error","message":"..."}
{"type":"done"}
"""
from __future__ import annotations
import json
import logging
from datetime import date
from typing import AsyncIterator
from app.services.market_overview_builder import build_market_overview
logger = logging.getLogger(__name__)
# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。
_INDEX_SHORT = {
"上证指数": "",
"深证成指": "",
"创业板指": "",
"科创综指": "",
"科创50": "",
}
# ================================================================
# 系统提示词(市场策略师人格 + 固定七节模板)
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深市场策略师,擅长从指数结构、涨跌家数、连板梯队、板块轮动与资金情绪中提炼交易主线,产出可直接指导次日仓位与节奏的盘后复盘报告。
## 输出规范
**Markdown** 格式输出,严格遵循以下结构不要输出任何 JSON 或代码块,直接输出 Markdown 正文
### 1. 🎯 一句话定调(1-2 句)
用一句话概括今日市场的**核心矛盾与状态**("放量普涨、情绪修复,主线围绕科技扩散"/"指数虚高、个股杀跌,赚钱效应冰点")结尾用明日基调:进攻 / 均衡 / 防守给出明确倾向
### 2. 📊 盘面总览
- 三大指数(上证/深证/创业板)表现:谁强谁弱量能配合
- 涨跌家数涨停/跌停/炸板结构两市成交额(放量/缩量判断)
- 情绪温度(强势/偏暖/震荡/偏冷/冰点)及一句话依据
### 3. 📈 指数结构
谁在护盘谁在拖累;指数是否同步;关键支撑/压力位(基于当日点位推断);是否存在量价背离
### 4. 🔥 板块主线
- 领涨板块:背后的逻辑(消息/业绩/资金/技术)持续性判断是否形成可交易主线
- 领跌板块:风险信号是否扩散
- 连板梯队与投机情绪:最高连板封板率炸板率反映的资金激进程度
### 5. 💰 资金与情绪
成交额结构(增量/存量)市场宽度(上涨占比站上均线占比)量能指标(量比)解读;风险偏好是修复还是转弱
### 6. 📰 消息催化
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现""待发酵"**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息**
### 7. 🎯 明日交易计划
- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调
- 仓位区间建议(轻仓/半仓/重仓的粗略指引)
- 关注方向(领涨延续 / 低吸 / 反包)与回避方向(高位滞涨 / 杀跌扩散)
- 一个明确的触发失效条件("若上证跌破 X 点则转为防守")
### 8. ⚠️ 风险提示
列出需要重点盯的风险点(如量能跟不上外资流出连板断层等)末尾附一行:
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
## 分析准则(务必遵守)
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语直接给结论读者要的是复盘结果,不是你怎么推导出来的
1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%")
2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断
3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息
4. **不重复数字**:正文负责解读表格数据背后的含义,不要照抄罗列已提供的大段原始数字
5. **风险前置**:任何进攻建议都要配触发失效条件
6. **简明实战**:用交易员能扫读的密度输出,总字数 1200-2000 ,重在可执行
现在请基于下方数据进行复盘"""
# ================================================================
# 用户消息构建(精简切片,控制 token)
# ================================================================
def _fmt_pct(v, suffix="%") -> str:
if v is None:
return ""
return f"{v:+.2f}{suffix}" if suffix else f"{v:.2f}"
def _build_indices_block(overview: dict) -> str:
"""指数行情精简块。"""
indices = overview.get("indices") or []
if not indices:
return "(暂无指数)"
lines = []
for idx in indices:
name = idx.get("name") or idx.get("symbol")
price = idx.get("last_price")
chg = idx.get("change_pct")
price_s = f"{price:.2f}" if price is not None else ""
lines.append(f"- {name}: {price_s} {_fmt_pct(chg)}")
return "\n".join(lines)
def _build_breadth_block(overview: dict) -> str:
b = overview.get("breadth") or {}
amt = overview.get("amount") or {}
lim = overview.get("limit") or {}
tr = overview.get("trend") or {}
act = overview.get("activity") or {}
total_amount = amt.get("total") or 0
# 成交额单位换算为亿元(原始为元)
amount_yi = total_amount / 1e8 if total_amount else 0
lines = [
f"- 上涨/下跌/平盘: {b.get('up',0)} / {b.get('down',0)} / {b.get('flat',0)}"
f" (上涨占比 {b.get('up_pct',0):.1f}%)",
f"- 涨停/炸板/跌停: {lim.get('limit_up',0)} / {lim.get('broken',0)} / {lim.get('limit_down',0)}"
f" (封板率 {lim.get('seal_rate',0):.0f}%, 最高连板 {lim.get('max_boards',0)})",
]
if lim.get("tiers"):
tiers_str = "".join(f"{t['boards']}板×{t['count']}" for t in lim["tiers"][:5])
lines.append(f"- 连板梯队: {tiers_str}")
lines.append(f"- 两市成交额: {amount_yi:.0f} 亿元")
lines.append(
f"- 均线站位: MA5 {tr.get('above_ma5_pct',0):.0f}% / "
f"MA20 {tr.get('above_ma20_pct',0):.0f}% / MA60 {tr.get('above_ma60_pct',0):.0f}%"
)
lines.append(
f"- 量能: 平均换手 {act.get('avg_turnover',0):.2f}%, "
f"量比5日均 {act.get('vol_ratio',1):.2f}"
)
return "\n".join(lines)
def _build_sector_block(rank: dict, label: str) -> str:
"""板块排名精简块(领涨/领跌 top5)。"""
if not rank:
return f"### {label}\n(暂无数据)"
def _fmt(items):
if not items:
return ""
return "".join(
f"{it.get('name')}({(it.get('avg_pct') or 0)*100:+.2f}%,领涨:{it.get('leader',{}).get('name','')})"
for it in items[:5]
)
return (
f"- 领涨{label}: {_fmt(rank.get('leading'))}\n"
f"- 领跌{label}: {_fmt(rank.get('lagging'))}"
)
def _build_emotion_block(overview: dict) -> str:
emo = overview.get("emotion") or {}
radar = overview.get("radar") or []
score = emo.get("score", 50)
label = emo.get("label", "")
lines = [f"- 情绪温度: {score} ({label})"]
if radar:
dims = "".join(f"{r.get('label')}{r.get('value',0)}" for r in radar)
lines.append(f"- 六维雷达: {dims}")
return "\n".join(lines)
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
as_of = overview.get("as_of") or "今日"
parts: list[str] = [
f"复盘日期: {as_of}",
"",
"## 主要指数",
_build_indices_block(overview),
"",
"## 盘面数据",
_build_breadth_block(overview),
"",
"## 市场情绪",
_build_emotion_block(overview),
"",
"## 概念板块排名",
_build_sector_block(overview.get("concept_rank"), "概念"),
"",
"## 行业板块排名",
_build_sector_block(overview.get("industry_rank"), "行业"),
]
if news:
news_lines = []
for i, n in enumerate(news[:8], 1):
title = (n.get("title") or "").strip()
snippet = (n.get("snippet") or "").strip()
source = (n.get("source") or "").strip()
pub = (n.get("published_date") or "").strip()
meta = " / ".join(p for p in (source, pub) if p)
news_lines.append(f"{i}. {title} ({meta})\n {snippet}" if meta else f"{i}. {title}\n {snippet}")
parts.extend(["", "## 近期市场新闻", "\n".join(news_lines)])
else:
parts.extend([
"",
"## 近期市场新闻",
"(暂无新闻数据:本功能新闻检索能力将在后续版本接入。"
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
])
if focus.strip():
parts.extend(["", f"本次复盘请特别关注: {focus.strip()}"])
return "\n".join(parts)
# ================================================================
# 摘要生成(供 meta 事件 / 历史报告 summary)
# ================================================================
def _recap_summary(overview: dict) -> str:
"""一句话摘要(供 meta 事件与历史列表展示)。
指数用简称(///),与前端摘要条一致,避免列表里全称放不下
"""
indices = overview.get("indices") or []
emo = overview.get("emotion") or {}
lim = overview.get("limit") or {}
amt = overview.get("amount") or {}
total_amount = (amt.get("total") or 0) / 1e8
idx_str = "".join(
f"{_INDEX_SHORT.get(i.get('name') or '', i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%"
for i in indices[:4]
) or "指数缺失"
return (
f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','')}) | "
f"涨停{lim.get('limit_up',0)} | 成交{total_amount:.0f}亿"
)
# ================================================================
# 流式主入口
# ================================================================
async def recap_market_stream(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。
Args:
repo: KlineRepository(必填)
quote_service / depth_service: 可选,数据装配依赖
as_of: 复盘日期,None 取最新有数据日
focus: 用户追加的复盘关注点
news: 预检索的新闻列表(P1 不传, None 走降级说明;P3 news_search 注入)
"""
# 1. 装配市场总览
overview = build_market_overview(repo, quote_service, depth_service, as_of)
as_of_str = overview.get("as_of")
if not as_of_str:
yield json.dumps({
"type": "error",
"message": "暂无市场数据,请先在「数据」页同步日 K 与指数后再复盘",
}, ensure_ascii=False)
return
emo = overview.get("emotion") or {}
# 2. meta 事件(前端据此先渲染信号灯/看板)
yield json.dumps({
"type": "meta",
"as_of": as_of_str,
"emotion_score": emo.get("score", 50),
"emotion_label": emo.get("label", ""),
"summary": _recap_summary(overview),
}, ensure_ascii=False)
# 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死)
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI market recap failed for %s: %s", as_of_str, e)
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
async def recap_market_once(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
) -> tuple[str | None, dict]:
"""非流式版本(供定时任务调用):累积全部 delta,返回 (content, meta)。
content 为完整 Markdown 文本;失败时为 None
meta as_of / emotion_score / emotion_label / summary(即使失败也尽量回填)
"""
content_parts: list[str] = []
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
try:
obj = json.loads(evt)
except Exception: # noqa: BLE001
continue
t = obj.get("type")
if t == "meta":
meta = obj
elif t == "delta":
content_parts.append(obj.get("content", ""))
elif t == "error":
logger.warning("market recap error event: %s", obj.get("message"))
return None, meta
return "".join(content_parts), meta
@@ -0,0 +1,92 @@
"""AI 大盘复盘报告持久化存储。
stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立
单独的文件字段上限,互不影响刻意不复用,避免引入 kind 判别字段与分支
(解耦 > 抽象)
存储位置: data/user_data/ai_market_recaps.json (数组, created_at 降序)
保留最近 MAX_REPORTS ;超出自动裁剪最旧的
每条报告结构:
{
"id": "mkr_xxx", # 唯一 id(market-recap-report)
"as_of": "2026-06-27", # 复盘日期
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "三大指数齐涨...", # 一句话摘要
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
"created_at": "2026-06-27T15:35:00"
}
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
except Exception as e: # noqa: BLE001
logger.warning("ai_market_recaps.json malformed: %s", e)
return []
def _save_all(reports: list[dict]) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports()
if not report.get("id"):
report["id"] = f"mkr_{int(time.time() * 1000)}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("Market recap saved: %s (as_of=%s), total %d",
report.get("id"), report.get("as_of"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
return True
return False
def _now_iso() -> str:
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+30 -6
View File
@@ -1,12 +1,12 @@
"""系统通知适配器 — 调用操作系统原生通知命令
"""系统通知适配器 — 三平台原生通知中心
职责: 把后端产生的告警事件推送到操作系统通知中心
窗口最小化 / 被遮挡 / 后台运行时都能弹通知
窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)
平台实现:
- macOS: osascript (系统已内置)
- Linux: notify-send (系统已内置)
- Windows: 暂不支持原生通知中心 (无额外依赖实现)
- Windows: winotify (进现代操作中心, 支持图标)
- macOS: osascript (系统已内置, 无需额外依赖)
- Linux: notify-send (系统已内置) / plyer 兜底
设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)
通知去重不在本层做, 复用 MonitorRuleEngine cooldown 逻辑
@@ -34,7 +34,15 @@ def _detect_backend() -> str | None:
return _backend_cache if _backend_cache != "none" else None
backend = None
if sys.platform == "darwin":
if sys.platform == "win32":
try:
import winotify # type: ignore[import-not-found] # noqa: F401
backend = "winotify"
except ImportError:
logger.debug("winotify 不可用, Windows 通知降级")
backend = None
elif sys.platform == "darwin":
backend = "osascript"
elif sys.platform.startswith("linux"):
backend = "notify-send"
@@ -71,6 +79,8 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
return False
try:
if backend == "winotify":
return _notify_winotify(title, message)
if backend == "osascript":
return _notify_osascript(title, message)
if backend == "notify-send":
@@ -82,6 +92,20 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
return False
def _notify_winotify(title: str, message: str) -> bool:
"""Windows 通知 (winotify) — 进现代操作中心。"""
from winotify import Notifier # type: ignore[import-not-found]
Notifier().create_notification(
title=title,
msg=message,
# winotify 要求 duration 为 "short" 或 "long"
duration="short",
# 无可点击动作 (桌面版不实现"点击回到窗口"的复杂交互)
).show()
return True
def _notify_osascript(title: str, message: str) -> bool:
"""macOS 通知 (osascript) — 调用系统 AppleScript。"""
# 转义双引号, 避免 AppleScript 注入
+272
View File
@@ -53,6 +53,29 @@ def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 10.0)
def get_realtime_watchlist_symbols() -> list[str]:
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
try:
from app.services import watchlist
rows = watchlist.list_symbols()
except Exception as e: # noqa: BLE001
logger.warning("load watchlist for realtime failed: %s", e)
return []
out: list[str] = []
for row in rows:
symbol = str((row or {}).get("symbol") or "").strip().upper()
if symbol and symbol not in out:
out.append(symbol)
if len(out) >= 5:
break
return out
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
return get_realtime_watchlist_symbols()
def set_realtime_quote_interval(interval: float) -> float:
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
current = load()
@@ -71,6 +94,83 @@ def get_minute_sync_days() -> int:
return max(1, min(30, load().get("minute_sync_days", 5)))
# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) =====
_ALLOWED_DATA_PROVIDERS = {"tickflow"}
def get_daily_data_provider() -> str:
provider = str(load().get("daily_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_adj_factor_provider() -> str:
provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower()
if provider == "same_as_daily":
return provider
return provider if provider in _ALLOWED_DATA_PROVIDERS else "same_as_daily"
def get_minute_data_provider() -> str:
provider = str(load().get("minute_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_realtime_data_provider() -> str:
# 盘中实时现阶段仅支持 TickFlow。
return "tickflow"
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
def get_pipeline_pull_a_share() -> bool:
"""A 股日K固定拉取。"""
return True
def get_pipeline_pull_etf() -> bool:
"""是否拉取 ETF 日K。默认 False(标的多,首次较慢)。"""
return load().get("pipeline_pull_etf", False)
def get_pipeline_pull_index() -> bool:
"""是否拉取指数日K。默认 True。"""
return load().get("pipeline_pull_index", True)
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
def get_pipeline_pull_types() -> dict:
"""返回三个拉取开关的当前值。"""
return {
"pipeline_pull_a_share": get_pipeline_pull_a_share(),
"pipeline_pull_etf": get_pipeline_pull_etf(),
"pipeline_pull_index": get_pipeline_pull_index(),
}
def set_pipeline_pull_types(cfg: dict) -> dict:
"""批量保存拉取开关。只接受白名单内的布尔字段。"""
updates = {
k: bool(v) for k, v in cfg.items()
if k in _PIPELINE_PULL_KEYS and v is not None
}
save(updates)
return get_pipeline_pull_types()
def get_pipeline_index_symbols() -> str:
"""指数自定义拉取代码(逗号/换行/空格分隔)。空串表示全量。"""
return str(load().get("pipeline_index_symbols", "") or "").strip()
def set_pipeline_index_symbols(symbols: str) -> str:
"""保存指数自定义代码,返回规范化后的字符串。"""
save({"pipeline_index_symbols": symbols})
return get_pipeline_index_symbols()
def get_pipeline_schedule() -> dict:
"""返回盘后管道调度时间 {"hour": 15, "minute": 30}。"""
d = load().get("pipeline_schedule", {"hour": 15, "minute": 30})
@@ -165,6 +265,73 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict:
return {"hour": h, "minute": m}
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
# 多选: 不推送 = 空数组, 而非 'none'
REVIEW_PUSH_CHANNELS = {"feishu"}
def get_review_schedule() -> dict:
"""定时复盘调度 {"enabled": False, "hour": 15, "minute": 10}。默认关闭。
A股 15:00 收盘, 默认时间设为 15:10(收盘后即时复盘), 强制下限 15:00
"""
d = load().get("review_schedule", {"enabled": False, "hour": 15, "minute": 10})
return {
"enabled": bool(d.get("enabled", False)),
"hour": d.get("hour", 15),
"minute": d.get("minute", 10),
}
def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
"""保存定时复盘调度。强制时间下限 15:00(A股收盘)。
enabled=False 时时间仍保存(下次开启可沿用), 但调度器不会注册 job
"""
h = max(0, min(23, hour))
m = max(0, min(59, minute))
# 下限 15:00: A股 15:00 收盘, 收盘后才有当日完整数据复盘
if h * 60 + m < 15 * 60:
h, m = 15, 0
save({"review_schedule": {"enabled": bool(enabled), "hour": h, "minute": m}})
return {"enabled": bool(enabled), "hour": h, "minute": m}
def get_review_push_channels() -> list[str]:
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
review_schedule / 实时行情完全独立, 常驻可单独设置
空列表 = 不推送; ['feishu'] = 推送到飞书(复用监控中心全局 feishu_webhook_url/secret)
向后兼容:
- 老多版本单选 review_push_channel=='feishu' ['feishu']
- 更老布尔 review_push_enabled==True ['feishu']
"""
d = load()
raw = d.get("review_push_channels")
if isinstance(raw, list):
return [c for c in raw if c in REVIEW_PUSH_CHANNELS]
# 兼容老单选字符串
if d.get("review_push_channel") == "feishu":
return ["feishu"]
# 兼容更老布尔开关
if d.get("review_push_enabled") is True:
return ["feishu"]
return []
def set_review_push_channels(channels: list[str]) -> list[str]:
"""保存复盘推送渠道(多选)。过滤白名单外的值、去重、保序。空列表 = 不推送。"""
seen: set[str] = set()
cleaned: list[str] = []
for c in channels or []:
if c in REVIEW_PUSH_CHANNELS and c not in seen:
seen.add(c)
cleaned.append(c)
save({"review_push_channels": cleaned})
return cleaned
# ===== 实时监控 =====
@@ -178,6 +345,59 @@ SSE_REFRESH_PAGES_DEFAULT = {
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
def get_realtime_pull_stock() -> bool:
return load().get("realtime_pull_stock", True)
def get_realtime_pull_etf() -> bool:
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
return load().get("realtime_pull_etf", False)
def get_realtime_pull_index() -> bool:
return load().get("realtime_pull_index", True)
def get_realtime_index_mode() -> str:
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
return mode if mode in {"core", "all"} else "core"
def get_realtime_index_symbols() -> list[str]:
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
if isinstance(stored, str):
import re
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
return [str(s) for s in stored if str(s).strip()]
def set_realtime_quote_scope(cfg: dict) -> dict:
updates = {}
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
if key in cfg and cfg[key] is not None:
updates[key] = bool(cfg[key])
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
if updates:
save(updates)
return get_realtime_quote_scope()
def get_realtime_quote_scope() -> dict:
return {
"realtime_pull_stock": get_realtime_pull_stock(),
"realtime_pull_etf": get_realtime_pull_etf(),
"realtime_pull_index": get_realtime_pull_index(),
"realtime_index_mode": get_realtime_index_mode(),
"realtime_index_symbols": get_realtime_index_symbols(),
}
def get_sse_refresh_pages() -> dict[str, bool]:
"""返回每个页面的 SSE 刷新开关。"""
stored = load().get("sse_refresh_pages", {})
@@ -216,6 +436,43 @@ def set_system_notify_enabled(enabled: bool) -> bool:
return bool(enabled)
def get_feishu_webhook_url() -> str:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "")
def get_feishu_webhook_secret() -> str:
"""飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。"""
return load().get("feishu_webhook_secret", "")
def set_feishu_webhook_url(url: str) -> str:
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
save({"feishu_webhook_url": str(url or "").strip()})
return get_feishu_webhook_url()
def set_feishu_webhook_secret(secret: str) -> str:
"""保存飞书签名密钥。传入空串表示不验签。"""
save({"feishu_webhook_secret": str(secret or "").strip()})
return get_feishu_webhook_secret()
def get_webhook_enabled_default() -> bool:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改
"""
return load().get("webhook_enabled_default", False)
def set_webhook_enabled_default(enabled: bool) -> bool:
"""保存飞书推送默认勾选态。"""
save({"webhook_enabled_default": bool(enabled)})
return get_webhook_enabled_default()
def get_screener_auto_run() -> bool:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
return load().get("screener_auto_run", True)
@@ -311,3 +568,18 @@ def set_onboarding_completed(done: bool = True) -> bool:
"""标记首次使用向导完成状态。"""
save({"onboarding_completed": bool(done)})
return bool(done)
# ===== 财务数据同步时间(持久化,重启不丢失) =====
# 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... }
def get_financial_sync_times() -> dict[str, str]:
"""返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。"""
return load().get("financial_sync_times", {}) or {}
def set_financial_sync_time(table: str, iso_ts: str) -> None:
"""更新单张财务表的最后同步时间(合并写入,不清除其他表)。"""
times = get_financial_sync_times()
times[table] = iso_ts
save({"financial_sync_times": times})
+310 -118
View File
@@ -3,7 +3,7 @@
集中管理全市场行情拉取 + enriched 缓存供盘中选股自选股等所有模块复用
架构:
- 后台线程轮询数据源 get_by_universes(["CN_Equity_A", "CN_Index"])
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
- 拉取行情 kline_daily (不复权) + 增量计算 enriched 写盘 + 更新缓存
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
@@ -43,6 +43,7 @@ class QuoteService:
"expert": 1.0,
"pro": 2.0,
"starter": 3.0,
"free": 6.0,
}
DEFAULT_INTERVAL = 10.0
MAX_INTERVAL = 60.0
@@ -59,6 +60,10 @@ class QuoteService:
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
self._pending_alerts: list[dict] = [] # 待推送的告警
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
# 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端
self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set
self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串)
self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧
self._strategy_monitor = None # 延迟注入
self._app_state = None # 延迟注入 (FastAPI app.state)
@@ -68,6 +73,7 @@ class QuoteService:
self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
self._symbol_count: int = 0
self._index_symbol_count: int = 0
self._etf_symbol_count: int = 0
self._index_quotes_cache: pl.DataFrame | None = None
# ================================================================
@@ -102,11 +108,11 @@ class QuoteService:
def enable(self) -> bool:
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
none/free 档无实时行情权限,拒绝开启并返回 False;
starter+ 正常启动返回值表示是否真正开启
none 档无实时行情权限,拒绝开启并返回 False;
free 档开启自选股实时,starter+ 开启全市场实时返回值表示是否真正开启
"""
if not self.is_realtime_allowed():
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
return False
self._enabled = True
self._save_enabled(True)
@@ -126,14 +132,14 @@ class QuoteService:
def boot_check(self) -> None:
"""启动时检查 preferences,若 enabled 则自动启动。
none/free 档无实时行情权限:即使 preferences 标记为 enabled,
none 档无实时行情权限:即使 preferences 标记为 enabled,
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)
"""
from app.services import preferences
if not self.is_realtime_allowed():
if preferences.get_realtime_quotes_enabled():
self._save_enabled(False)
logger.info("实时行情未启动:当前档位(none/free)无实时行情权限")
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
return
if preferences.get_realtime_quotes_enabled():
self.start()
@@ -188,6 +194,34 @@ class QuoteService:
self._pending_alerts = []
return alerts
# ================================================================
# 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端
# ================================================================
def push_review_event(self, event_json: str) -> None:
"""追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。
事件格式与 recap_market_stream 的产出一致(meta/delta/error/done),
前端 reviewStore 直接消费背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)
"""
with self._lock:
self._pending_review.append(event_json)
if len(self._pending_review) > self._max_pending_review:
overflow = len(self._pending_review) - self._max_pending_review
self._pending_review = self._pending_review[overflow:]
self._review_event.set()
def wait_for_review(self, timeout: float = 30.0) -> bool:
"""阻塞等待复盘进度事件 (供 SSE 线程使用)。"""
self._review_event.clear()
return self._review_event.wait(timeout=timeout)
def pop_review_events(self) -> list[str]:
"""取走所有待推送的复盘事件 (线程安全)。"""
with self._lock:
events = self._pending_review
self._pending_review = []
return events
# ================================================================
# 档位感知间隔限制
# ================================================================
@@ -199,13 +233,19 @@ class QuoteService:
return tier_label().split()[0].split("+")[0].strip().lower()
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。
def realtime_mode(cls) -> str:
"""当前实时行情模式: none / watchlist / full_market。"""
tier = cls._current_tier()
if tier == "none":
return "none"
if tier == "free":
return "watchlist"
return "full_market"
none/free 档走 free-api 服务器,无实时行情权限 不允许;
starter+ 付费档走付费端点,有实时行情 允许
"""
return cls._current_tier() not in ("none", "free")
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。"""
return cls.realtime_mode() != "none"
@classmethod
def _tier_min_interval(cls) -> float:
@@ -251,7 +291,7 @@ class QuoteService:
return df
def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
"""返回实时指数行情缓存。不会触发数据源请求。"""
"""返回实时指数行情缓存。不会触发 TickFlow 请求。"""
with self._lock:
df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
if df.is_empty():
@@ -262,13 +302,19 @@ class QuoteService:
def status(self) -> dict:
"""返回行情服务状态。"""
from app.services import preferences
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
mode = self.realtime_mode()
return {
"enabled": self._enabled,
"running": self._running,
"mode": mode,
"realtime_allowed": mode != "none",
"watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()),
"interval_s": self._interval,
"symbol_count": self._symbol_count,
"index_symbol_count": self._index_symbol_count,
"etf_symbol_count": self._etf_symbol_count,
"quote_age_ms": round(age, 0) if age >= 0 else None,
"is_trading_hours": self._is_trading_hours(),
"last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
@@ -299,17 +345,47 @@ class QuoteService:
waited += 0.5
def _fetch_quotes(self) -> None:
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存"""
from app.tickflow.client import get_client
"""按当前档位拉取行情"""
if self.realtime_mode() == "watchlist":
self._fetch_watchlist_quotes()
return
self._fetch_full_market_quotes()
tf = get_client()
def _fetch_full_market_quotes(self) -> None:
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
from app.tickflow.client import get_paid_realtime_client
tf = get_paid_realtime_client()
if tf is None:
logger.warning("实时行情拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
from app.services import preferences
all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
all_index_symbols.update(self.CORE_INDEX_SYMBOLS)
resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"])
core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS)
all_index_symbols.update(core_index_symbols)
all_etf_symbols = set()
if self._repo:
etf_inst = self._repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list())
universes: list[str] = []
if preferences.get_realtime_pull_stock():
universes.append("CN_Equity_A")
if preferences.get_realtime_pull_etf() and all_etf_symbols:
universes.append("CN_ETF")
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all":
universes.append("CN_Index")
resp = []
if universes:
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
except Exception as e: # noqa: BLE001
logger.warning("行情拉取失败: %s", e)
return
@@ -349,7 +425,11 @@ class QuoteService:
})
index_records = [r for r in records if r.get("symbol") in all_index_symbols]
stock_records = [r for r in records if r.get("symbol") not in all_index_symbols]
etf_records = [r for r in records if r.get("symbol") in all_etf_symbols]
stock_records = [
r for r in records
if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols
]
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
@@ -361,9 +441,10 @@ class QuoteService:
self._fetched_at = fetched_at
self._symbol_count = len(stock_records)
self._index_symbol_count = len(index_records)
self._etf_symbol_count = len(etf_records)
self._index_quotes_cache = self._build_index_quotes(index_records)
logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms)
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
daily_df = self._build_daily(stock_records)
@@ -373,12 +454,22 @@ class QuoteService:
except Exception as e: # noqa: BLE001
logger.warning("日K写盘失败: %s", e)
etf_daily_df = self._build_daily(etf_records)
if not etf_daily_df.is_empty() and self._repo:
try:
self._repo.flush_live_daily_asset("etf", etf_daily_df)
except Exception as e: # noqa: BLE001
logger.warning("ETF 日K写盘失败: %s", e)
# ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
quote_extra = self._build_quote_extra(stock_records)
etf_quote_extra = self._build_quote_extra(etf_records)
# ---- 增量计算 enriched + 写盘 + 更新缓存 ----
if not daily_df.is_empty() and self._repo:
self._flush_live_enriched(daily_df, quote_extra)
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock")
if not etf_daily_df.is_empty() and self._repo:
self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf")
# ---- 通知 SSE ----
self._update_event.set()
@@ -386,6 +477,87 @@ class QuoteService:
# ---- 策略监控 + 告警评估 ----
self._evaluate_monitors(daily_df, quote_extra)
def _fetch_watchlist_quotes(self) -> None:
"""Free 档自选股实时: 只拉取最多 5 个 symbols。"""
from app.services import preferences
from app.tickflow.client import get_paid_realtime_client
symbols = preferences.get_realtime_watchlist_symbols()
if not symbols:
logger.info("自选实时未配置标的, 跳过行情拉取")
return
tf = get_paid_realtime_client()
if tf is None:
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
resp = tf.quotes.get(symbols=symbols) or []
except Exception as e: # noqa: BLE001
logger.warning("自选实时拉取失败: %s", e)
return
if not resp:
logger.warning("自选实时行情数据为空")
return
records = []
for q in resp:
ext = q.get("ext") or {}
last_price = q.get("last_price")
prev_close = q.get("prev_close")
change_amount = ext.get("change_amount")
change_pct = ext.get("change_pct")
if change_amount is None and last_price is not None and prev_close is not None:
change_amount = float(last_price) - float(prev_close)
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
change_pct = float(change_amount) / float(prev_close) * 100
records.append({
"symbol": q.get("symbol"),
"name": q.get("name") or ext.get("name"),
"last_price": last_price,
"prev_close": prev_close,
"open": q.get("open"),
"high": q.get("high"),
"low": q.get("low"),
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": change_pct,
"change_amount": change_amount,
"amplitude": ext.get("amplitude"),
"turnover_rate": ext.get("turnover_rate"),
"timestamp": q.get("timestamp"),
"session": q.get("session"),
})
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
with self._lock:
self._fetch_time = now_ts
self._fetch_ms = fetch_ms
self._fetched_at = fetched_at
self._symbol_count = len(records)
self._index_symbol_count = 0
self._etf_symbol_count = 0
self._index_quotes_cache = None
logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms)
daily_df = self._build_daily(records)
quote_extra = self._build_quote_extra(records)
if not daily_df.is_empty() and self._repo:
try:
self._repo.merge_live_daily_asset("stock", daily_df)
except Exception as e: # noqa: BLE001
logger.warning("自选实时日K写盘失败: %s", e)
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True)
self._update_event.set()
self._evaluate_monitors(daily_df, quote_extra)
# ================================================================
# 工具
# ================================================================
@@ -495,6 +667,8 @@ class QuoteService:
return
all_alerts: list[dict] = []
rule_events: list[dict] = []
engine = None
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
@@ -511,7 +685,11 @@ class QuoteService:
})
except Exception as e: # noqa: BLE001
logger.debug("name_map 构建失败 (不影响监控): %s", e)
rule_events = engine.evaluate(enriched_today)
# 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched
eval_df = enriched_today
if engine.has_rule_type("ladder"):
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
rule_events = engine.evaluate(eval_df)
if rule_events:
# 落盘到 alerts.jsonl
try:
@@ -535,11 +713,13 @@ class QuoteService:
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
"conditions": ev.get("conditions") or [],
"logic": ev.get("logic") or "and",
})
# 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
if self._enabled and self._app_state:
self._refresh_strategy_cache(enriched_today, enriched_date)
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
# 推入待推送队列 + 通知 SSE (含背压保护)
if all_alerts:
@@ -556,9 +736,95 @@ class QuoteService:
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
self._maybe_send_system_notifications(all_alerts)
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
# 紧随系统通知, 同样静默降级不阻断主流程。
if rule_events:
self._maybe_send_webhook(rule_events, engine)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
涨停封单(买一量) + 跌停封单(卖一量)合并, ladder 规则评估
depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)
"""
try:
depth_svc = getattr(self._app_state, "depth_service", None)
if not depth_svc:
return enriched_today
# enriched_date 可能是 date 或字符串, 统一为 date
from datetime import date as date_cls
target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date))
# 取涨停 + 跌停封单, 合并 {symbol: vol}
up_map = depth_svc.get_sealed_map(target_date, is_down=False)
down_map = depth_svc.get_sealed_map(target_date, is_down=True)
sealed: dict[str, int] = {}
for m in (up_map, down_map):
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单)
if not sealed:
return enriched_today
# 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
# 若已有残留列先移除 (避免重复 join 报错)
df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today
return df.join(sealed_df, on="symbol", how="left")
except Exception as e: # noqa: BLE001
logger.debug("封单注入失败 (ladder 规则将不触发): %s", e)
return enriched_today
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
- 全局飞书 URL 未配置: 直接返回
- 仅推送 webhook_enabled=True 的规则触发的告警
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine cooldown, 此处不重复去重
注意: rule_events ( rule_id) 而非重建后的 all_alerts,
以便反查引擎规则判断是否启用推送
"""
try:
from app.services import preferences
from app.services import webhook_adapter
url = preferences.get_feishu_webhook_url()
if not url:
return
secret = preferences.get_feishu_webhook_secret()
# 反查规则, 过滤出启用推送的事件
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}
rules = engine.rules if engine is not None else {}
pushed = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
if not rule or not rule.get("webhook_enabled"):
continue
source = ev.get("source", "")
source_label = source_labels.get(source, source or "通知")
symbol = ev.get("symbol") or ""
name = ev.get("name") or ""
message = ev.get("message") or ""
title = f"TickFlow · {source_label}"
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
if webhook_adapter.send_feishu(url, title, body, secret):
pushed += 1
if pushed:
logger.info("飞书 Webhook 推送: %d", pushed)
except Exception as e: # noqa: BLE001
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
@@ -592,95 +858,11 @@ class QuoteService:
else:
body = message or name
title = f"Stock Panel · {source_label}"
title = f"TickFlow · {source_label}"
notify_adapter.notify(title, body)
except Exception as e: # noqa: BLE001
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None:
"""利用已计算好的 enriched 数据,运行策略池并写入缓存。"""
import math
from dataclasses import asdict
from app.services import strategy_cache
from app.services.screener import PRESET_STRATEGIES, ScreenerService
from app.strategy import config as strategy_config
try:
if enriched_date is None:
return
as_of = enriched_date
data_dir = self._repo.store.data_dir
svc = ScreenerService(self._repo)
engine = getattr(self._app_state, "strategy_engine", None)
# 确定要运行的策略: 策略监控池中的策略
monitor_ids = self._get_monitor_pool_ids()
if not monitor_ids:
return
# 一次加载所有 override
all_overrides = strategy_config.list_overrides(data_dir)
# 历史策略: 只在需要时加载
shared_history = None
history_strats = []
if engine:
id_set = set(monitor_ids)
history_strats = [
(sid, s) for sid, s in engine._strategies.items()
if s.filter_history_fn and sid in id_set
]
if history_strats:
max_lb = max(s.lookback_days for _, s in history_strats)
shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
results: dict[str, dict] = {}
for sid in monitor_ids:
try:
overrides = all_overrides.get(sid, {})
bf = overrides.get("basic_filter") if overrides else None
dl = overrides.get("display_limit") if overrides else None
if dl is None and overrides and "display_limit" in overrides:
dl = 0
if sid in PRESET_STRATEGIES:
r = svc.run_preset(sid, as_of=as_of, precomputed=enriched_today, basic_filter=bf, display_limit=dl)
elif engine:
r = engine.run(
sid, as_of, overrides=overrides or None,
precomputed=enriched_today, precomputed_history=shared_history,
)
if dl is not None and dl > 0:
r.rows = r.rows[:dl]
r.total = min(r.total, dl)
else:
continue
# sanitize NaN/Inf
rows = []
for row_dict in asdict(r).get("rows", []):
for k, v in list(row_dict.items()):
if isinstance(v, float) and not math.isfinite(v):
row_dict[k] = None
rows.append(row_dict)
results[sid] = {"total": r.total, "as_of": str(as_of), "rows": rows}
except Exception: # noqa: BLE001
continue
if results:
strategy_cache.write_cache(data_dir, str(as_of), results)
except Exception as e: # noqa: BLE001
logger.warning("策略缓存刷新失败: %s", e)
def _get_monitor_pool_ids(self) -> list[str]:
"""获取策略监控池中的策略 ID 列表。"""
from app.services import preferences
ids = preferences.get_strategy_monitor_ids()
if not ids:
return []
return [sid for sid in ids if sid]
@staticmethod
def _get_strategy_monitor():
"""获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
@@ -690,7 +872,7 @@ class QuoteService:
# enriched 增量计算
# ================================================================
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None:
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None:
"""增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
quote_extra: API 直接提供的补充字段 (prev_close, change_pct ),
@@ -701,11 +883,16 @@ class QuoteService:
t0 = time.perf_counter()
# ---- 尝试增量路径 ----
live_agg = self._repo.get_live_agg()
prev_enriched, prev_date = self._repo.get_enriched_latest()
live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame()
prev_enriched, prev_date = (
self._repo.get_enriched_latest()
if asset_type == "stock"
else self._repo.get_enriched_latest_asset(asset_type)
)
use_incremental = (
not live_agg.is_empty()
asset_type == "stock"
and not live_agg.is_empty()
and not prev_enriched.is_empty()
and prev_date is not None
)
@@ -736,7 +923,8 @@ class QuoteService:
"ok" if not live_agg.is_empty() else "", prev_date)
cutoff = today - timedelta(days=90)
daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.parquet")
table = "kline_etf_daily" if asset_type == "etf" else "kline_daily"
daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet")
ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
hist_df = (
pl.scan_parquet(daily_glob)
@@ -753,14 +941,15 @@ class QuoteService:
full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
full_df = full_df.sort(["symbol", "date"])
factor_path = self._repo.store.data_dir / "adj_factor" / "all.parquet"
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
factor_path = self._repo.store.data_dir / factor_dir / "all.parquet"
factors = pl.DataFrame()
if factor_path.exists():
try:
factors = pl.read_parquet(factor_path)
except Exception:
pass
instruments = self._repo.get_instruments()
instruments = self._repo.get_instruments() if asset_type == "stock" else None
enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
enriched_today = enriched_full.filter(pl.col("date") == today)
@@ -769,7 +958,10 @@ class QuoteService:
return
# ---- 写盘 + 更新缓存 ----
self._repo.flush_live_enriched(enriched_today)
if merge:
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
else:
self._repo.flush_live_enriched_asset(asset_type, enriched_today)
elapsed = time.perf_counter() - t0
mode_label = "增量" if use_incremental else "全量"
+214
View File
@@ -0,0 +1,214 @@
"""概念涨幅轮动矩阵 service。
输出每列(日期)各自把所有概念按当天涨幅从高到低排序的矩阵,供前端
概念分析 涨幅RPS轮动对话框渲染
数据来源全部复用现有资产, 不引入新数据源:
- 个股历史涨跌幅: repo.get_enriched_range(..., columns=["symbol","date","change_pct"])
命中启动时构建的 _enriched_history_cache (0ms, change_pct 小数列)
- 概念成分股映射: 复用 market_overview_builder _dimension_field / _read_ext_rows /
_symbol_keys / _dimension_values, 与看板/复盘的概念聚合口径完全一致
性能: 387 概念 × 30 天的 group_by + sort polars 内存操作, 实测 <50ms;
另加进程级结果缓存 (_CACHE_TTL=120s), 重复请求 <1ms
"""
from __future__ import annotations
import logging
import time
from datetime import date, timedelta
import polars as pl
from app.services.market_overview_builder import (
_dimension_field,
_dimension_values,
_read_ext_rows,
_symbol_keys,
)
from app.services.ext_data import ExtConfigStore
logger = logging.getLogger(__name__)
# 进程级结果缓存 (照搬 overview.py:18 的模式, TTL 拉长到 120s —— 轮动矩阵
# 不像看板那样需要近实时, 盘后数据稳定, 缓存久一点无妨)
_CACHE_TTL = 120.0
_cache: dict[str, dict] = {}
_cache_ts: dict[str, float] = {}
def invalidate_cache() -> None:
"""清空轮动矩阵结果缓存(数据管道完成后调用, 避免返回旧数据)。"""
_cache.clear()
_cache_ts.clear()
def _latest_enriched_date(repo) -> date | None:
"""取 enriched 缓存里的最新交易日(矩阵的右端=最新日期)。"""
cache = repo._enriched_history_cache # noqa: SLF001 —— 缓存字段无公开 getter
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
return cache["date"].max()
def _load_concept_map_df(repo) -> tuple[pl.DataFrame, int]:
"""构建并缓存 {symbol_upper → 概念} 的已展开 polars 映射表。
复用 market_overview_builder 的概念识别 + 成分股读取逻辑(_dimension_field /
_read_ext_rows / _symbol_keys / _dimension_values), 但要的是反向映射
(symbol 概念), 且直接产出 polars DataFrame join 使用
返回 (map_df, concept_count):
- map_df: 两列 (_sym_up: 大写 symbol, concept: 概念名), explode, 一个
symbol 属多概念时有多行无概念数据时返回空 DataFrame
- concept_count: 去重概念总数
缓存: 概念成分股是 snapshot, 进程内不变, 缓存 600s
直接缓存 DataFrame 而非 Python dict 后续 join 时省掉每次 ~1s dictDataFrame
重建开销(这是结果缓存失效后重算的主要瓶颈)
"""
global _concept_map_cache, _concept_map_count, _concept_map_ts
now = time.time()
if _concept_map_cache is not None and (now - _concept_map_ts) < 600:
return _concept_map_cache, _concept_map_count
data_dir = repo.store.data_dir
store = ExtConfigStore(data_dir)
# 先收集成扁平的 (sym, concept) 行, 再一次性构造 DataFrame(比 list 列快得多)
pairs: list[tuple[str, str]] = []
concepts_seen: set[str] = set()
for config in store.load_all():
field = _dimension_field(config, "concept")
if not field:
continue
for ext_row in _read_ext_rows(data_dir, config, field):
concepts = _dimension_values(ext_row.get(field))
if not concepts:
continue
keys = _symbol_keys(ext_row, config)
for key in keys:
for c in concepts:
pairs.append((key, c))
concepts_seen.add(c)
if pairs:
# 去重: 同一 (symbol, concept) 对会因多 key 形式(SZ/000001)和
# 多 config 重复出现, 去重后从 ~48万 行降到 ~14万, join 快 3x+
_concept_map_cache = pl.DataFrame(
{"_sym_up": [p[0] for p in pairs], "concept": [p[1] for p in pairs]},
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8},
).unique()
_concept_map_count = len(concepts_seen)
else:
_concept_map_cache = pl.DataFrame(
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8}
)
_concept_map_count = 0
_concept_map_ts = now
return _concept_map_cache, _concept_map_count
_concept_map_cache: pl.DataFrame | None = None
_concept_map_count: int = 0
_concept_map_ts: float = 0.0
def build_rps_rotation(repo, days: int = 12) -> dict:
"""构建概念涨幅轮动矩阵。
Args:
repo: KlineRepository( _enriched_history_cache 内存历史)
days: 取最近 N 个交易日, 范围 [7, 30], 默认 12
Returns:
{
"dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days
"columns": {"2026-06-30": [[概念, 涨幅], ...], ...}, # 每列各自排序(高→低)
"concept_count": 387, # 去重概念总数(0 表示无概念数据)
}
涨幅是小数(0.0522 = +5.22%)无数据时返回空 columns
"""
days = max(7, min(30, days))
# 结果缓存: 同 days(→ 同 start/end)的请求在 TTL 内直接返回
latest = _latest_enriched_date(repo)
if latest is None:
return {"dates": [], "columns": {}, "concept_count": 0}
cache_key = latest.isoformat()
now = time.time()
cached = _cache.get(cache_key)
if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL:
# 缓存的是所有日期, 按需要的 days 截取(避免不同 days 各存一份)
return _slice_cached(cached, days)
# 1. 概念映射(symbol → 概念), 已缓存为 polars DataFrame
map_df, concept_count = _load_concept_map_df(repo)
if map_df.is_empty():
logger.info("rps_rotation: no concept data (ext_gn_ths not fetched yet)")
return {"dates": [], "columns": {}, "concept_count": 0}
# 2. 取最近 N 交易日的个股 change_pct(命中内存缓存)
start = latest - timedelta(days=days * 2 + 10) # 日历天 ≈ 2/3 交易日, 多取余量
df = repo.get_enriched_range(
start, latest, columns=["symbol", "date", "change_pct"]
)
if df is None or df.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 3. 把个股 symbol 映射到概念, 一只股票拆成多行(每个概念一行)
# symbol 大写匹配(map_df 的 _sym_up 已大写)
df = df.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up"))
joined = df.join(map_df, on="_sym_up", how="inner").drop("_sym_up")
if joined.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 4. 按 (date, concept) 聚合 avg change_pct —— 与 _dimension_rank:288 的简单平均口径一致
agg = joined.group_by(["date", "concept"]).agg(
pl.col("change_pct").mean().alias("avg_pct")
)
# 去掉 NaN/Null(停牌等无行情的概念日)
agg = agg.filter(pl.col("avg_pct").is_not_null() & pl.col("avg_pct").is_not_nan())
# 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (concept, avg_pct)
# 收集成并行 list —— 一次 polars 操作拿到全部列, 避免 partition_by 的 tuple key 歧义
agg = agg.sort(["date", "avg_pct"], descending=[False, True])
grouped = agg.group_by("date", maintain_order=True).agg(
pl.col("concept"), pl.col("avg_pct")
)
# 最新日期排最前
grouped = grouped.sort("date", descending=True)
columns: dict[str, list[list]] = {}
all_dates_sorted: list[str] = []
for row in grouped.iter_rows(named=True):
d_str = str(row["date"])
all_dates_sorted.append(d_str)
columns[d_str] = list(zip(row["concept"], row["avg_pct"]))
full = {
"dates": [str(d) for d in all_dates_sorted],
"columns": columns,
"concept_count": concept_count,
}
# 写缓存(存全量, 按需 slice)
_cache[cache_key] = full
_cache_ts[cache_key] = now
return _slice_cached(full, days)
def _slice_cached(full: dict, days: int) -> dict:
"""从全量缓存截取最近 N 天(days)。"""
dates_all = full["dates"]
if len(dates_all) <= days:
return full
keep_dates = dates_all[:days]
return {
"dates": keep_dates,
"columns": {d: full["columns"][d] for d in keep_dates},
"concept_count": full["concept_count"],
}

Some files were not shown because too many files have changed in this diff Show More