diff --git a/.env.example b/.env.example deleted file mode 100644 index 7245da7..0000000 --- a/.env.example +++ /dev/null @@ -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 diff --git a/README.md b/README.md index dfe2280..52da238 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 75a331d..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -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"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go deleted file mode 100644 index dd72f96..0000000 --- a/backend/cmd/api/main.go +++ /dev/null @@ -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) - } -} diff --git a/backend/go.mod b/backend/go.mod deleted file mode 100644 index 12a04cc..0000000 --- a/backend/go.mod +++ /dev/null @@ -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 -) diff --git a/backend/go.sum b/backend/go.sum deleted file mode 100644 index 21e784c..0000000 --- a/backend/go.sum +++ /dev/null @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go deleted file mode 100644 index 87915aa..0000000 --- a/backend/internal/config/config.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/datasource/tushare.go b/backend/internal/datasource/tushare.go deleted file mode 100644 index b1cf0bf..0000000 --- a/backend/internal/datasource/tushare.go +++ /dev/null @@ -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 - } -} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go deleted file mode 100644 index 57b6402..0000000 --- a/backend/internal/db/db.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/handlers/admin.go b/backend/internal/handlers/admin.go deleted file mode 100644 index 6f156ef..0000000 --- a/backend/internal/handlers/admin.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go deleted file mode 100644 index c3dea86..0000000 --- a/backend/internal/handlers/auth.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/handlers/data_sync.go b/backend/internal/handlers/data_sync.go deleted file mode 100644 index 0b9c5d3..0000000 --- a/backend/internal/handlers/data_sync.go +++ /dev/null @@ -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] + "股票同步完成", - }, - }) -} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go deleted file mode 100644 index 3b3585e..0000000 --- a/backend/internal/middleware/auth.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/models/stock.go b/backend/internal/models/stock.go deleted file mode 100644 index 1a6ee61..0000000 --- a/backend/internal/models/stock.go +++ /dev/null @@ -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{}) -} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go deleted file mode 100644 index feeb368..0000000 --- a/backend/internal/models/user.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go deleted file mode 100644 index 5c0d495..0000000 --- a/backend/internal/routes/routes.go +++ /dev/null @@ -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 -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 3963942..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index a9b5fcb..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index d4f7568..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - A股工具 - - -
- - - diff --git a/frontend/nginx.conf b/frontend/nginx.conf deleted file mode 100644 index 9c06e55..0000000 --- a/frontend/nginx.conf +++ /dev/null @@ -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; - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index d9d7bbd..0000000 --- a/frontend/package.json +++ /dev/null @@ -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" - } -} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index 9b6ff76..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - A - diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx deleted file mode 100644 index c767f5b..0000000 --- a/frontend/src/components/AuthGuard.tsx +++ /dev/null @@ -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(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 ( -
- -
- ) - } - - if (requireAuth && !user) { - return - } - - if (allowedRoles && user && !allowedRoles.includes(user.role)) { - return - } - - return <>{children} -} diff --git a/frontend/src/components/CreateUserDialog.tsx b/frontend/src/components/CreateUserDialog.tsx deleted file mode 100644 index a99389f..0000000 --- a/frontend/src/components/CreateUserDialog.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
-
- -
-

创建用户

-
- -
- - {error && ( -
- - {error} -
- )} - -
-
- - 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" - /> -
- -
- - 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" - /> -
- -
-
- - 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" - /> -
-
- - 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" - /> -
-
- -
- - -
- -
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx deleted file mode 100644 index 80b4e19..0000000 --- a/frontend/src/components/Layout.tsx +++ /dev/null @@ -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(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 = ( - - ) - - return ( -
-
-
-
A
- 用户体系工作台 -
- -
-
-
setMobileOpen(false)} /> -
{sidebar}
-
-
- -
-
- ) -} diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx deleted file mode 100644 index 45a9043..0000000 --- a/frontend/src/components/LoginForm.tsx +++ /dev/null @@ -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 ( - -
-
-
- -
-

登录账号

-

- 请输入用户名和密码进入系统。 -

-
- -
-
-
- -
- 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" - /> -
- -
-
- -
- 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" - /> -
- - {error && ( -
- - {error} -
- )} - - -
- -
- 没有账号?请联系管理员创建 -
-
-
- ) -} - -// 简单内联 motion 组件,避免引入 framer-motion 依赖 -const motion = { - div: ({ children, className, ...props }: any) => { - return ( -
{ - 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} -
- ) - }, -} diff --git a/frontend/src/components/ThemeProvider.tsx b/frontend/src/components/ThemeProvider.tsx deleted file mode 100644 index d642c7a..0000000 --- a/frontend/src/components/ThemeProvider.tsx +++ /dev/null @@ -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(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(() => { - try { - const saved = localStorage.getItem('theme') - if (saved === 'light' || saved === 'dark' || saved === 'system') return saved - } catch {} - return 'system' - }) - const [resolved, setResolved] = useState(() => 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 ( - - {children} - - ) -} - -export function useTheme(): ThemeContextValue { - const ctx = useContext(ThemeContext) - if (!ctx) { - throw new Error('useTheme must be used within ThemeProvider') - } - return ctx -} diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index dfb2c24..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts deleted file mode 100644 index a0312f4..0000000 --- a/frontend/src/lib/api.ts +++ /dev/null @@ -1,133 +0,0 @@ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '' - -export interface ApiResponse { - 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( - path: string, - options: RequestInit = {}, -): Promise { - const url = `${API_BASE}${path}` - const token = getToken() - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - } - 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('/api/auth/login', { - method: 'POST', - body: JSON.stringify(body), - }), - - me: () => request('/api/auth/me'), - - logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }), - - listUsers: () => request('/api/admin/users'), - - createUser: (body: { username: string; email?: string; password: string; role?: string }) => - request('/api/admin/users', { - method: 'POST', - body: JSON.stringify(body), - }), - - updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) => - request(`/api/admin/users/${id}`, { - method: 'PUT', - body: JSON.stringify(body), - }), - - deleteUser: (id: string) => - request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }), - - listRoles: () => request('/api/admin/roles'), - - initStocks: () => - request('/api/admin/data-sync/init-stocks', { method: 'POST' }), - - syncStocksByExchange: (exchange: string) => - request(`/api/admin/data-sync/stocks/${exchange}`, { method: 'POST' }), -} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts deleted file mode 100644 index e7ecd71..0000000 --- a/frontend/src/lib/auth.ts +++ /dev/null @@ -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 = { - 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' -} diff --git a/frontend/src/lib/cn.ts b/frontend/src/lib/cn.ts deleted file mode 100644 index abba253..0000000 --- a/frontend/src/lib/cn.ts +++ /dev/null @@ -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)) -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index 57accb9..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -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( - - - - - , -) diff --git a/frontend/src/pages/DataSync.tsx b/frontend/src/pages/DataSync.tsx deleted file mode 100644 index 5750f36..0000000 --- a/frontend/src/pages/DataSync.tsx +++ /dev/null @@ -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(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 ( -
-
-
- -
-
-

数据同步

-

从 Tushare 拉取基础数据

-
-
- - {error && ( -
- - {error} -
- )} - - {result && ( -
- - - {result.message},共 {result.count} 条记录 - {result.exchange ? `(${result.exchange})` : ''} - -
- )} - -
-
-
-

初始化股票列表

-

调用 Tushare stock_basic 接口,获取全部 A 股基础信息

-
- -
-
- -
-
-

按交易所同步

-

按交易所分别同步,不影响其他交易所数据

-
-
- {EXCHANGES.map(({ code, name }) => ( - - ))} -
-
- -
-

说明

-
    -
  • • 需要配置 TUSHARE_TOKEN 环境变量
  • -
  • • 初始化会清空 stocks 表并重新写入
  • -
  • • 按交易所同步采用 upsert 策略,以 ts_code 为唯一键更新或插入
  • -
  • • 同步字段包括:代码、名称、交易所、行业、上市状态、上市日期、实控人等
  • -
-
-
- ) -} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx deleted file mode 100644 index 63541af..0000000 --- a/frontend/src/pages/Login.tsx +++ /dev/null @@ -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 ( -
-
-
-
-
-
- -
-
-
A
- A股工具 -
-
- -
- navigate('/', { replace: true })} /> -
-
- ) -} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx deleted file mode 100644 index d15d026..0000000 --- a/frontend/src/pages/Profile.tsx +++ /dev/null @@ -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(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 ( -
- -
- ) - } - - return ( -
-
-
- -
-

个人中心

-
- - {error && ( -
- - {error} -
- )} - -
-
- 用户名 - {user?.username} -
-
- 角色 - {user ? roleLabel(user.role) : '-'} -
-
- 邮箱 - {user?.email || '未设置'} -
-
- 状态 - {user?.status === 'active' ? '启用' : '禁用'} -
-
- 注册时间 - {user ? new Date(user.created_at).toLocaleString() : '-'} -
-
- -
-

关于

-

- 本系统为 A 股复盘工具的用户权限管理模块。不同角色拥有不同的操作权限,系统管理员可在「用户管理」中分配角色。 -

-
-
- ) -} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx deleted file mode 100644 index b82b6ce..0000000 --- a/frontend/src/pages/Users.tsx +++ /dev/null @@ -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 = { - system_admin: ['admin', 'user'], - admin: ['user'], - user: [], -} - -export function Users() { - const [users, setUsers] = useState([]) - 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 ( -
-
-
-
- -
-

用户管理

-
- -
- - {error && ( -
- - {error} -
- )} - -
- - - - - - - - - - - - {loading ? ( - - - - ) : users.length === 0 ? ( - - - - ) : ( - users.map((u) => ( - - - - - - - - )) - )} - -
用户名角色状态创建时间操作
- -
暂无用户
{u.username} - - {roleLabel(u.role)} - - - - - {new Date(u.created_at).toLocaleString()} - - {canDeleteUsers(current?.role || 'user') && u.id !== current?.id && ( - - )} -
-
- - setDialogOpen(false)} - onSuccess={() => { - setDialogOpen(false) - fetchData() - }} - allowedRoles={allowedRoles} - /> -
- ) -} - -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' - } -} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx deleted file mode 100644 index 806774c..0000000 --- a/frontend/src/router.tsx +++ /dev/null @@ -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: }, - { - path: '/', - element: ( - - - - ), - children: [ - { index: true, element: }, - { - path: 'users', - element: ( - - - - ), - }, - { - path: 'data-sync', - element: ( - - - - ), - }, - { - path: 'profile', - element: , - }, - ], - }, - { path: '*', element: }, -]) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/frontend/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts deleted file mode 100644 index d367068..0000000 --- a/frontend/tailwind.config.ts +++ /dev/null @@ -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) / )', - surface: 'hsl(var(--surface) / )', - elevated: 'hsl(var(--elevated) / )', - border: 'hsl(var(--border) / )', - foreground: 'hsl(var(--fg-primary) / )', - secondary: 'hsl(var(--fg-secondary) / )', - muted: 'hsl(var(--fg-muted) / )', - accent: 'hsl(var(--accent) / )', - bull: 'hsl(var(--bull) / )', - bear: 'hsl(var(--bear) / )', - warning: 'hsl(var(--warning) / )', - danger: 'hsl(var(--danger) / )', - }, - 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 diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index c20738e..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -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" }] -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json deleted file mode 100644 index 97ede7e..0000000 --- a/frontend/tsconfig.node.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index 56a7819..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -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', - }, -}) diff --git a/refer/Dockerfile b/refer/Dockerfile index 3927899..36ec384 100644 --- a/refer/Dockerfile +++ b/refer/Dockerfile @@ -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"] diff --git a/refer/LICENSE b/refer/LICENSE new file mode 100644 index 0000000..6d33010 --- /dev/null +++ b/refer/LICENSE @@ -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. diff --git a/refer/README.md b/refer/README.md index cf44d99..35ec5a0 100644 --- a/refer/README.md +++ b/refer/README.md @@ -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)
-> **⚠️说明**:目前项目默认接入内置数据源。自有数据源需二次开发修改字段映射即可;后续需求人多的话可能会实现切换数据源功能。 +
+ +**[快速开始](#-快速开始)** · **[核心功能](#-核心功能)** · **[配置](#️-配置)** · **[路线图](#-路线图)** + +
+ +- 🆓 **开箱即用** — 留空 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 兼容接口,留空关闭 | 可选 | +## 📸 界面预览 @@ -37,30 +56,110 @@ - - + + - - + + - + - - + +
策略 Screener
看板页面策略页看板页面策略页
回测 Backtest 监控中心 Monitor
回测页监控中心回测页监控中心
连板梯队 Limit Ladder概念分析 Concept概念分析 Concept
连板梯队页概念分析连板梯队页概念分析
-> ### ⚠️ 🚧 项目持续优化,功能陆续开放,敬请期待。 +
-> **明确不做**:不对标同花顺/通达信的全功能股票软件;不内置任何「AI 荐股 / 涨停预测」。 +### 📸 [查看更多界面截图 »](./screenshots/README.md) + +
+ +--- + +## 🚀 快速开始 + +### 前置依赖 + +| 工具 | 版本 | 安装 | +| :--------------------------------- | :----- | :------------------------------------------------- | +| 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 一并关闭。默认: + +- 后端 → · 前端 → +- 自定义端口:`BACKEND_PORT=8000 FRONTEND_PORT=5173 ./dev.sh` + +### 方式 B:Docker(部署最省心) + +```bash +cp .env.example .env +docker compose up --build +# 打开 http://localhost:3018 +``` + +
+环境适配与高级选项(老 CPU · 手动启动 · 回测依赖) + +**老 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` 现场编译。 + +
+ +### 🔄 更新代码(已部署用户必读) + +拉取新版本只需一条命令: + +```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 股有风险,入市需谨慎。数据准确性以数据源官方为准。 \ No newline at end of file +本项目仅供**学习与量化研究**,**不构成任何投资建议**。回测结果不代表未来收益。A 股有风险,入市需谨慎。数据准确性以数据源 TickFlow 官方为准。 + +## 📄 License + +[MIT](./LICENSE) © tickflow-stock-panel contributors · 本项目依赖 [TickFlow](https://tickflow.org/auth/register?ref=V3KDKGXPEA) 提供数据服务,使用前请遵守其服务条款。 + +## 社区 + +本开源项目已链接并认可 [LINUX DO 社区](https://linux.do)。 diff --git a/refer/VERSION b/refer/VERSION index 0ec25f7..9a48d65 100644 --- a/refer/VERSION +++ b/refer/VERSION @@ -1 +1 @@ -v1.0.0 +v0.1.64 diff --git a/refer/backend/app/__init__.py b/refer/backend/app/__init__.py index 65a8c44..9714d24 100644 --- a/refer/backend/app/__init__.py +++ b/refer/backend/app/__init__.py @@ -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): diff --git a/refer/backend/app/api/analysis.py b/refer/backend/app/api/analysis.py index 18b572e..2045b45 100644 --- a/refer/backend/app/api/analysis.py +++ b/refer/backend/app/api/analysis.py @@ -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("") diff --git a/refer/backend/app/api/auth.py b/refer/backend/app/api/auth.py index 5060e15..05e7771 100644 --- a/refer/backend/app/api/auth.py +++ b/refer/backend/app/api/auth.py @@ -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": "密码已修改, 请重新登录"} diff --git a/refer/backend/app/api/data.py b/refer/backend/app/api/data.py index 716f9f5..cbe72d3 100644 --- a/refer/backend/app/api/data.py +++ b/refer/backend/app/api/data.py @@ -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}"} diff --git a/refer/backend/app/api/ext_data.py b/refer/backend/app/api/ext_data.py index 3e2f172..a2b3ff2 100644 --- a/refer/backend/app/api/ext_data.py +++ b/refer/backend/app/api/ext_data.py @@ -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 diff --git a/refer/backend/app/api/financials.py b/refer/backend/app/api/financials.py index 2cdf433..b8414b3 100644 --- a/refer/backend/app/api/financials.py +++ b/refer/backend/app/api/financials.py @@ -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} diff --git a/refer/backend/app/api/indices.py b/refer/backend/app/api/indices.py index 2b806aa..e78c2a2 100644 --- a/refer/backend/app/api/indices.py +++ b/refer/backend/app/api/indices.py @@ -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"} diff --git a/refer/backend/app/api/intraday.py b/refer/backend/app/api/intraday.py index fc715be..64b5d8a 100644 --- a/refer/backend/app/api/intraday.py +++ b/refer/backend/app/api/intraday.py @@ -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: diff --git a/refer/backend/app/api/kline.py b/refer/backend/app/api/kline.py index ee2a54a..8e0261f 100644 --- a/refer/backend/app/api/kline.py +++ b/refer/backend/app/api/kline.py @@ -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, diff --git a/refer/backend/app/api/market_recap.py b/refer/backend/app/api/market_recap.py new file mode 100644 index 0000000..7215d23 --- /dev/null +++ b/refer/backend/app/api/market_recap.py @@ -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} diff --git a/refer/backend/app/api/monitor_rules.py b/refer/backend/app/api/monitor_rules.py index e0856af..b5e332c 100644 --- a/refer/backend/app/api/monitor_rules.py +++ b/refer/backend/app/api/monitor_rules.py @@ -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], + } diff --git a/refer/backend/app/api/overview.py b/refer/backend/app/api/overview.py index c6367c1..3bd7ede 100644 --- a/refer/backend/app/api/overview.py +++ b/refer/backend/app/api/overview.py @@ -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") diff --git a/refer/backend/app/api/rps.py b/refer/backend/app/api/rps.py new file mode 100644 index 0000000..558aa9a --- /dev/null +++ b/refer/backend/app/api/rps.py @@ -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"}, + ) diff --git a/refer/backend/app/api/screener.py b/refer/backend/app/api/screener.py index a64a837..d35faef 100644 --- a/refer/backend/app/api/screener.py +++ b/refer/backend/app/api/screener.py @@ -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) diff --git a/refer/backend/app/api/settings.py b/refer/backend/app/api/settings.py index 19bd1a1..39c51ea 100644 --- a/refer/backend/app/api/settings.py +++ b/refer/backend/app/api/settings.py @@ -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} + diff --git a/refer/backend/app/api/stock_analysis.py b/refer/backend/app/api/stock_analysis.py new file mode 100644 index 0000000..8926fc1 --- /dev/null +++ b/refer/backend/app/api/stock_analysis.py @@ -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} diff --git a/refer/backend/app/api/strategy.py b/refer/backend/app/api/strategy.py index dae6ddc..b355209 100644 --- a/refer/backend/app/api/strategy.py +++ b/refer/backend/app/api/strategy.py @@ -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)} diff --git a/refer/backend/app/api/watchlist.py b/refer/backend/app/api/watchlist.py index 183b875..81fc34d 100644 --- a/refer/backend/app/api/watchlist.py +++ b/refer/backend/app/api/watchlist.py @@ -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("") diff --git a/refer/backend/app/auth.py b/refer/backend/app/auth.py deleted file mode 100644 index 8f7f4e7..0000000 --- a/refer/backend/app/auth.py +++ /dev/null @@ -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/") diff --git a/refer/backend/app/backtest/engine.py b/refer/backend/app/backtest/engine.py index 8d43975..943490c 100644 --- a/refer/backend/app/backtest/engine.py +++ b/refer/backend/app/backtest/engine.py @@ -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, diff --git a/refer/backend/app/backtest/strategy.py b/refer/backend/app/backtest/strategy.py index 125169e..0518fb3 100644 --- a/refer/backend/app/backtest/strategy.py +++ b/refer/backend/app/backtest/strategy.py @@ -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, diff --git a/refer/backend/app/config.py b/refer/backend/app/config.py index 26a3387..f6c4f43 100644 --- a/refer/backend/app/config.py +++ b/refer/backend/app/config.py @@ -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: diff --git a/refer/backend/app/data_providers/__init__.py b/refer/backend/app/data_providers/__init__.py new file mode 100644 index 0000000..4d3e9fd --- /dev/null +++ b/refer/backend/app/data_providers/__init__.py @@ -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"] diff --git a/refer/backend/app/data_providers/base.py b/refer/backend/app/data_providers/base.py new file mode 100644 index 0000000..c17ca21 --- /dev/null +++ b/refer/backend/app/data_providers/base.py @@ -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.""" diff --git a/refer/backend/app/data_providers/normalizer.py b/refer/backend/app/data_providers/normalizer.py new file mode 100644 index 0000000..edf047e --- /dev/null +++ b/refer/backend/app/data_providers/normalizer.py @@ -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") diff --git a/refer/backend/app/data_providers/registry.py b/refer/backend/app/data_providers/registry.py new file mode 100644 index 0000000..e7a39e6 --- /dev/null +++ b/refer/backend/app/data_providers/registry.py @@ -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() diff --git a/refer/backend/app/data_providers/schemas.py b/refer/backend/app/data_providers/schemas.py new file mode 100644 index 0000000..51e8dc3 --- /dev/null +++ b/refer/backend/app/data_providers/schemas.py @@ -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", +] diff --git a/refer/backend/app/data_providers/tickflow_provider.py b/refer/backend/app/data_providers/tickflow_provider.py new file mode 100644 index 0000000..a086949 --- /dev/null +++ b/refer/backend/app/data_providers/tickflow_provider.py @@ -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 []) diff --git a/refer/backend/app/desktop.py b/refer/backend/app/desktop.py new file mode 100644 index 0000000..82efaf6 --- /dev/null +++ b/refer/backend/app/desktop.py @@ -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()) diff --git a/refer/backend/app/indicators/levels.py b/refer/backend/app/indicators/levels.py new file mode 100644 index 0000000..ad3f204 --- /dev/null +++ b/refer/backend/app/indicators/levels.py @@ -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 diff --git a/refer/backend/app/indicators/pipeline.py b/refer/backend/app/indicators/pipeline.py index dcab138..c753943 100644 --- a/refer/backend/app/indicators/pipeline.py +++ b/refer/backend/app/indicators/pipeline.py @@ -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) diff --git a/refer/backend/app/jobs/daily_pipeline.py b/refer/backend/app/jobs/daily_pipeline.py index d22edfc..a8ad488 100644 --- a/refer/backend/app/jobs/daily_pipeline.py +++ b/refer/backend/app/jobs/daily_pipeline.py @@ -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"], diff --git a/refer/backend/app/main.py b/refer/backend/app/main.py index 0739d17..d863c26 100644 --- a/refer/backend/app/main.py +++ b/refer/backend/app/main.py @@ -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 diff --git a/refer/backend/app/secrets_store.py b/refer/backend/app/secrets_store.py index 4ae774b..28d2c1b 100644 --- a/refer/backend/app/secrets_store.py +++ b/refer/backend/app/secrets_store.py @@ -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 diff --git a/refer/backend/app/services/ai_provider.py b/refer/backend/app/services/ai_provider.py new file mode 100644 index 0000000..203f711 --- /dev/null +++ b/refer/backend/app/services/ai_provider.py @@ -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") + 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 "" diff --git a/refer/backend/app/services/ai_reports.py b/refer/backend/app/services/ai_reports.py new file mode 100644 index 0000000..caf4115 --- /dev/null +++ b/refer/backend/app/services/ai_reports.py @@ -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") diff --git a/refer/backend/app/services/auth.py b/refer/backend/app/services/auth.py new file mode 100644 index 0000000..77c7b35 --- /dev/null +++ b/refer/backend/app/services/auth.py @@ -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) diff --git a/refer/backend/app/services/concept_rotation_analyzer.py b/refer/backend/app/services/concept_rotation_analyzer.py new file mode 100644 index 0000000..e6721a0 --- /dev/null +++ b/refer/backend/app/services/concept_rotation_analyzer.py @@ -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 日排名变化(如 `45→20→8`) +- 涨幅加速度(连日递增 = 趋势加强) +- 可能的驱动逻辑(从板块属性推断,不要编造具体消息) +- 判断是**主力切入**还是**消息脉冲** + +### 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) diff --git a/refer/backend/app/services/depth_service.py b/refer/backend/app/services/depth_service.py index 4978a92..1e5c423 100644 --- a/refer/backend/app/services/depth_service.py +++ b/refer/backend/app/services/depth_service.py @@ -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) diff --git a/refer/backend/app/services/ext_data.py b/refer/backend/app/services/ext_data.py index 63cd8be..7e7695d 100644 --- a/refer/backend/app/services/ext_data.py +++ b/refer/backend/app/services/ext_data.py @@ -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) diff --git a/refer/backend/app/services/ext_presets.py b/refer/backend/app/services/ext_presets.py new file mode 100644 index 0000000..6ec0e6a --- /dev/null +++ b/refer/backend/app/services/ext_presets.py @@ -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 diff --git a/refer/backend/app/services/ext_pull.py b/refer/backend/app/services/ext_pull.py index d15a638..5f04ea7 100644 --- a/refer/backend/app/services/ext_pull.py +++ b/refer/backend/app/services/ext_pull.py @@ -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 diff --git a/refer/backend/app/services/financial_analyzer.py b/refer/backend/app/services/financial_analyzer.py new file mode 100644 index 0000000..8dcd57a --- /dev/null +++ b/refer/backend/app/services/financial_analyzer.py @@ -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) diff --git a/refer/backend/app/services/financial_sync.py b/refer/backend/app/services/financial_sync.py index 5c4f242..39f4102 100644 --- a/refer/backend/app/services/financial_sync.py +++ b/refer/backend/app/services/financial_sync.py @@ -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 返回,前端据此显示"同步中")。""" diff --git a/refer/backend/app/services/index_sync.py b/refer/backend/app/services/index_sync.py index dab5db5..42f3b1b 100644 --- a/refer/backend/app/services/index_sync.py +++ b/refer/backend/app/services/index_sync.py @@ -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() diff --git a/refer/backend/app/services/kline_sync.py b/refer/backend/app/services/kline_sync.py index bab2295..7a4d5f5 100644 --- a/refer/backend/app/services/kline_sync.py +++ b/refer/backend/app/services/kline_sync.py @@ -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: diff --git a/refer/backend/app/services/market_overview_builder.py b/refer/backend/app/services/market_overview_builder.py new file mode 100644 index 0000000..8a1adfd --- /dev/null +++ b/refer/backend/app/services/market_overview_builder.py @@ -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, + }) diff --git a/refer/backend/app/services/market_recap.py b/refer/backend/app/services/market_recap.py new file mode 100644 index 0000000..2fe0d7d --- /dev/null +++ b/refer/backend/app/services/market_recap.py @@ -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 diff --git a/refer/backend/app/services/market_recap_reports.py b/refer/backend/app/services/market_recap_reports.py new file mode 100644 index 0000000..06be304 --- /dev/null +++ b/refer/backend/app/services/market_recap_reports.py @@ -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") diff --git a/refer/backend/app/services/notify_adapter.py b/refer/backend/app/services/notify_adapter.py index ea3e0fe..1d95c17 100644 --- a/refer/backend/app/services/notify_adapter.py +++ b/refer/backend/app/services/notify_adapter.py @@ -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 注入 diff --git a/refer/backend/app/services/preferences.py b/refer/backend/app/services/preferences.py index a7e88d9..f4e31db 100644 --- a/refer/backend/app/services/preferences.py +++ b/refer/backend/app/services/preferences.py @@ -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}) diff --git a/refer/backend/app/services/quote_service.py b/refer/backend/app/services/quote_service.py index 9c2bfc6..2948f9b 100644 --- a/refer/backend/app/services/quote_service.py +++ b/refer/backend/app/services/quote_service.py @@ -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 "全量" diff --git a/refer/backend/app/services/rps_rotation.py b/refer/backend/app/services/rps_rotation.py new file mode 100644 index 0000000..95921e8 --- /dev/null +++ b/refer/backend/app/services/rps_rotation.py @@ -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 的 dict→DataFrame + 重建开销(这是结果缓存失效后重算的主要瓶颈)。 + """ + 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"], + } diff --git a/refer/backend/app/services/stock_analyzer.py b/refer/backend/app/services/stock_analyzer.py new file mode 100644 index 0000000..c312459 --- /dev/null +++ b/refer/backend/app/services/stock_analyzer.py @@ -0,0 +1,309 @@ +"""AI 个股分析服务 — 技术面 / 基本面 / 财务面 / 消息面 四维综合分析。 + +职责: + 组合一只股票的 K 线(含已算好的技术指标)+ 财务表 + 关键价位 → + 拼装"实战派交易员"级系统提示词 → 流式调用 LLM → 逐 chunk 吐给前端。 + +与 financial_analyzer.py 的区别(刻意区分,非复用): + - 角色:A 股实战派交易员 / 技术分析师(非 CFA 财务分析师) + - 数据源:K 线 + 技术指标为主,财务表为辅(财务分析以财务表为主) + - 输出框架:技术面→基本面→财务面→消息面(四维),落点是买卖区间与操作建议 + (财务分析的落点是财务质量评级) + +不知道: HTTP、前端、配置持久化。 +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import AsyncIterator + +import polars as pl + +from app.indicators.levels import compute_levels, summarize_levels +from app.services.financial_sync import get_financial_df + +logger = logging.getLogger(__name__) + +# 注入最近多少根日 K(技术面分析样本) +_KLINE_WINDOW = 90 +# 注入财务表的最近期数 +_MAX_PERIODS = 4 + + +# ================================================================ +# 数据加载 +# ================================================================ + +def _load_kline(repo, symbol: str) -> pl.DataFrame: + """读取该标的最近 N 根日 K(已含技术指标 / 信号)。 + + repo: KlineRepository;走内存缓存,性能可控。 + """ + from datetime import date, timedelta + + end = date.today() + start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够 + df = repo.get_daily(symbol, start, end) + if df.is_empty(): + return df + return df.tail(_KLINE_WINDOW) + + +def _clean_rows(df: pl.DataFrame, keep_cols: list[str]) -> list[dict]: + """把 DataFrame 转成 JSON 安全的 dict 列表(只保留关键列 + 清洗 NaN/Inf + date→字符串)。 + + polars 的 date 列会变成 Python datetime.date,json.dumps 无法直接序列化, + 必须转成 ISO 字符串,否则 json.dumps 抛 TypeError 让整个流静默失败。 + """ + import datetime + import math + cols = [c for c in keep_cols if c in df.columns] + sub = df.select(cols) + rows = [] + for rec in sub.to_dicts(): + clean = {} + for k, v in rec.items(): + if isinstance(v, float): + clean[k] = None if not math.isfinite(v) else round(v, 4) + elif isinstance(v, (datetime.date, datetime.datetime)): + clean[k] = v.isoformat() + else: + clean[k] = v + rows.append(clean) + return rows + + +def _load_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]: + """读取该标的核心财务指标 + 利润表(只取最有信息量的两张表)。 + + 财务面只需要关键指标(ROE / 增速 / 毛利率 等),不需要把 4 张表全塞进上下文 + (那是 financial_analyzer 的职责)。这里取轻量,留给技术面更多 token。 + """ + out: dict[str, list[dict]] = {} + for table in ("metrics", "income"): + df = get_financial_df(data_dir, table) + if df.is_empty(): + out[table] = [] + continue + df = df.filter(pl.col("symbol") == symbol) + if df.is_empty(): + out[table] = [] + continue + if "period_end" in df.columns: + df = df.sort("period_end", descending=True).head(2) # 只取最近 2 期 + import math + rows = [] + for rec in df.to_dicts(): + clean = {} + for k, v in rec.items(): + if k == "symbol": + continue + if isinstance(v, float): + clean[k] = None if not math.isfinite(v) else v + else: + clean[k] = v + rows.append(clean) + out[table] = rows + return out + + +# ================================================================ +# 系统提示词 —— 实战派交易员四维框架(与财务分析明确区分) +# ================================================================ + +_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深交易员兼技术分析师,擅长从 K 线、量价、关键价位与基本面交叉验证中把握买卖时机。你的任务是:基于提供的个股数据,产出一份**实战、可直接指导交易决策**的综合分析报告。 + +## 输出规范 + +用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 + +### 1. 🎯 一句话定调(1-2 句) +用一句话概括该股当前的**技术状态与交易属性**(如"高位放量滞涨,需警惕回调"/"底部筹码集中,放量突破在即")。结尾用【操作建议:观望 / 轻仓试探 / 逢低吸纳 / 持有 / 减仓 / 规避】给出明确倾向。 + +### 2. 📈 技术面分析(核心维度) +这是你的主战场,务必深入: +- **趋势判断**:均线多头/空头排列、20/60 日均线方向、价格在均线之上/下 +- **形态结构**:近期是否有突破/破位/双底/双顶/旗形等关键形态 +- **指标信号**:MACD 金叉/死叉/背离、KDJ 超买超卖、RSI 强弱、布林通道位置 +- **量价配合**:放量上涨/缩量回调/量价背离/换手率异动 +每条结论必须引用具体数值(如"MACD 在 6/12 出现金叉,DIF 0.32 上穿 DEA 0.18")。 + +### 3. 💰 关键价位(买卖区间) +基于提供的关键价位数据,明确指出: +- **上方压力位**(逐档列出,标注强度):第一压力、第二压力 +- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑 +- 给出**建议买入区间**与**止损位**(基于支撑位) +用数据说话,引用提供的压力/支撑(成交密集区)/枢轴点数值。 + +### 4. 🏭 基本面与财务面(辅助验证) +简要点评(2-4 句,不展开长篇): +- 盈利质量(ROE / 毛利率水平)、成长性(营收/利润增速) +- 与技术面的**交叉验证**:好公司 + 技术面走坏 → 仍需谨慎;差公司 + 技术面强势 → 警惕炒作风险 + +**当用户消息中标注了"该标的暂无财务数据"时**,本节请输出: +> 📌 财务面分析能力正在接入中。当前版本(Free)未同步该标的的财务报表,基本面维度暂无法评估。 +> 技术面分析不依赖财务数据,以下结论依然有效;升级套餐或等待财务数据同步后可补充本维度。 + +**绝对不要**在无数据时编造 ROE / 增速等数字。 + +### 5. 📰 消息面(价量异动推断) +**注意:本期无直接新闻数据输入。** 请基于 K 线的**异动信号**进行推断(如: +- 涨停/连板/炸板 → 可能有利好或资金炒作 +- 放量暴跌 → 可能有未公开利空 +- 突破放量 → 可能有催化剂 +明确标注"[推断]",告诉用户这是基于价量的推测,真实消息面数据待接入。若无明显异动,直说"近期价量平稳,无明显消息面信号"。 + +### 6. ⚖️ 综合研判与操作建议 +2-3 段: +- 该股当前处于(底部启动 / 上升途中 / 高位震荡 / 下跌趋势 / 底部企稳)哪个阶段 +- 风险收益比评估(距支撑位的空间 vs 距压力位的空间) +- **明确操作建议**:激进型 / 稳健型 / 保守型 分别怎么应对 +- **需要重点盯的信号**(如跌破 X 支撑止损、站上 Y 压力加仓) + +## 分析准则(务必遵守) + +1. **技术面优先**:作为交易员,技术面和量价是主要依据,基本面是验证手段,主次分明 +2. **数据说话**:每个判断引用具体数值,严禁空泛套话("走势良好"必须改成"连续 3 日站稳 20 日均线且放量") +3. **诚实中立**:看多就写多,看空就写空,不要模棱两可骑墙;数据不支持时直言无法判断 +4. **价位精确**:买卖区间必须落到具体价格,基于提供的关键价位数据推演 +5. **风险前置**:任何买入建议都要配止损位;提示潜在风险不回避 +6. **简明实战**:用交易员能扫读的密度输出,总字数 1000-1800 字,重在可执行 + +## 重要免责 +报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开行情与财务数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。" + +现在请基于下方数据进行分析。""" + + +# ================================================================ +# 用户消息构建 +# ================================================================ + +def _build_user_prompt( + kline_tail: list[dict], + fins: dict[str, list[dict]], + levels: dict[str, list[dict]], + close: float | None, + symbol: str, + focus: str, +) -> str: + """构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。""" + parts: list[str] = [ + f"标的标准代码: {symbol}", + f"关键价位概览: {summarize_levels(levels, close)}", + "", + "以下是该标的最近日 K 数据(JSON,含 OHLCV 与已计算的技术指标。" + f"最近 {_KLINE_WINDOW} 个交易日,升序):", + "```json", + json.dumps(kline_tail, ensure_ascii=False), + "```", + ] + + has_fin = any(fins.values()) + if has_fin: + parts.extend([ + "", + "以下是该标的最新财务数据(JSON,核心指标 + 利润表,金额单位为元):", + "```json", + json.dumps(fins, ensure_ascii=False), + "```", + ]) + else: + parts.extend([ + "", + "(该标的暂无财务数据:当前为 Free 模式或尚未同步财务报表。" + "请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)", + ]) + + if focus.strip(): + parts.extend(["", f"本次分析请特别关注: {focus.strip()}"]) + return "\n".join(parts) + + +# ================================================================ +# 关键列筛选(控制上下文体积) +# ================================================================ + +_KLINE_KEEP_COLS = [ + "date", "open", "high", "low", "close", "volume", "change_pct", + "ma5", "ma10", "ma20", "ma60", + "macd_dif", "macd_dea", "macd_hist", + "kdj_k", "kdj_d", "kdj_j", + "rsi_6", "rsi_14", "rsi_24", + "boll_upper", "boll_mid", "boll_lower", + "atr_14", "vol_ratio_5d", "turnover_rate", + "consecutive_limit_ups", + # 信号类(布尔)——只挑对消息面推断有用的几个 + "signal_limit_up", "signal_broken_limit_up", "signal_macd_golden", + "signal_macd_death", "signal_ma_golden_5_20", "signal_volume_surge", + "signal_boll_breakout_upper", "signal_boll_breakout_lower", +] + + +# ================================================================ +# 流式分析入口 +# ================================================================ + +async def analyze_stock_stream( + repo, + data_dir: Path, + symbol: str, + focus: str = "", +) -> AsyncIterator[str]: + """流式个股分析:yield 出每个 NDJSON 事件。 + + 协议(与 financial_analyzer 一致,前端解析无差异): + {"type":"meta","symbol","summary","levels"} 数据 + 价位摘要 + {"type":"delta","content":"..."} 逐 chunk 文本 + {"type":"error","message":"..."} + {"type":"done"} + """ + # 1. 加载 K 线 + df = _load_kline(repo, symbol) + if df.is_empty(): + yield json.dumps({ + "type": "error", + "message": f"标的 {symbol} 暂无日 K 数据,请先同步", + }, ensure_ascii=False) + return + + # 2. 价位计算(基于 K 线) + levels = compute_levels(df) + close = float(df.tail(1)["close"][0]) if "close" in df.columns else None + + # 3. 财务(辅助) + fins = _load_financials(data_dir, symbol) + + # 4. meta + yield json.dumps({ + "type": "meta", + "symbol": symbol, + "summary": summarize_levels(levels, close), + "levels": levels, + "close": close, + }, ensure_ascii=False) + + # 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死) + try: + from app.services.ai_provider import stream_ai_text + + kline_tail = _clean_rows(df, _KLINE_KEEP_COLS) + user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, 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 stock 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) diff --git a/refer/backend/app/services/stock_reports.py b/refer/backend/app/services/stock_reports.py new file mode 100644 index 0000000..d522d23 --- /dev/null +++ b/refer/backend/app/services/stock_reports.py @@ -0,0 +1,91 @@ +"""AI 个股分析报告持久化存储。 + +与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限, +互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。 + +存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序) +保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。 + +每条报告结构: +{ + "id": "sar_xxx", # 唯一 id(stock-analysis-report) + "symbol": "600519.SH", + "name": "贵州茅台", + "focus": "", # 用户追加的关心点(可为空) + "content": "# ...markdown", # 报告正文 + "summary": "当前价 12.3 · 压力位...", # 价位/数据摘要 + "levels": {...}, # 报告生成时的关键价位(供图表回放) + "close": 12.3, # 报告生成时的收盘价 + "created_at": "2026-06-26T10:00:00" +} +""" +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +MAX_REPORTS = 50 + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "ai_stock_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_stock_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)。""" + reports = list_reports() + if not report.get("id"): + report["id"] = f"sar_{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("Stock 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 _now_iso() -> str: + from datetime import datetime + return datetime.now().isoformat(timespec="seconds") diff --git a/refer/backend/app/services/strategy_cache.py b/refer/backend/app/services/strategy_cache.py index 7dc700f..201134d 100644 --- a/refer/backend/app/services/strategy_cache.py +++ b/refer/backend/app/services/strategy_cache.py @@ -54,7 +54,14 @@ def _get_enriched_mtime(data_dir: Path, as_of: str) -> float | None: def read_cache(data_dir: Path) -> dict | None: - """读取策略缓存文件。返回 None 表示无缓存、读取失败或 enriched 数据已更新导致缓存过期。""" + """读取策略缓存文件。返回 None 表示无缓存或读取失败。 + + 说明: 原先有 enriched mtime 过期校验 (数据文件变化 → 判过期返回 None), + 但在有实时行情的系统里, enriched parquet 每轮被刷新 → mtime 必然变化 → + 缓存被永久判死, 策略页读不到数据。且判过期后不触发重算, 只能让用户手动重跑, + 保护价值有限。故移除: 盘后缓存总能读出, 实时新鲜度由 /api/screener/cached + 端点叠加监控引擎的内存实时结果 (latest_strategy_results) 来保证。 + """ path = _cache_path(data_dir) if not path.exists(): return None @@ -67,15 +74,6 @@ def read_cache(data_dir: Path) -> dict | None: logger.warning("读取策略缓存失败: %s", e) return None - # 校验 enriched mtime: 数据文件变化 → 缓存过期 - as_of = cached.get("as_of") - stored_mtime = cached.get("enriched_mtime") - if as_of and stored_mtime: - current_mtime = _get_enriched_mtime(data_dir, as_of) - if current_mtime is not None and current_mtime != stored_mtime: - logger.info("策略缓存过期: enriched 数据已更新 (as_of=%s)", as_of) - return None - return cached @@ -130,7 +128,8 @@ def write_cache( # 从 ever_rows 提取 symbol 列表 (用于快速计数) today_ever_matched = {sid: sorted(maps.keys()) for sid, maps in today_ever_rows.items()} - # 记录 enriched parquet 文件的 mtime,用于后续校验缓存是否过期 + # enriched_mtime: 盘后缓存写入时记录 (向后兼容旧字段)。read_cache 已不再用它 + # 做过期校验, 实时新鲜度改由 /cached 端点叠加监控引擎内存结果保证。 enriched_mtime = _get_enriched_mtime(data_dir, as_of) payload = { diff --git a/refer/backend/app/services/watchlist.py b/refer/backend/app/services/watchlist.py index 879e6bd..7515b72 100644 --- a/refer/backend/app/services/watchlist.py +++ b/refer/backend/app/services/watchlist.py @@ -63,6 +63,20 @@ def remove(symbol: str) -> list[dict]: return df.to_dicts() +def move_to_top(symbol: str) -> list[dict]: + p = _path() + if not p.exists(): + return [] + df = pl.read_parquet(p) + if df.is_empty() or symbol not in df["symbol"].to_list(): + return df.to_dicts() + target = df.filter(pl.col("symbol") == symbol) + rest = df.filter(pl.col("symbol") != symbol) + out = pl.concat([target, rest], how="diagonal_relaxed") + out.write_parquet(p) + return out.to_dicts() + + def clear() -> int: """清空自选列表。返回移除的数量。""" p = _path() diff --git a/refer/backend/app/services/webhook_adapter.py b/refer/backend/app/services/webhook_adapter.py new file mode 100644 index 0000000..afb76f5 --- /dev/null +++ b/refer/backend/app/services/webhook_adapter.py @@ -0,0 +1,170 @@ +"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。 + +职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。 + 目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。 + +飞书自定义机器人接入: + 1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」 + 2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx) + 3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret) + 4. 填入设置页「飞书 Webhook」配置 + +设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。 + 去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time + +logger = logging.getLogger(__name__) + +# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏) +_MAX_LEN = 500 + +# 卡片消息正文最长字符 (飞书 interactive 卡片上限 30KB, 保守留余量给标题/结构) +_CARD_MAX_LEN = 28000 + +# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验) +FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/" + + +def _truncate(text: str) -> str: + """截断超长文本。""" + text = (text or "").strip() + return text[:_MAX_LEN] + ("…" if len(text) > _MAX_LEN else "") + + +def is_valid_feishu_url(url: str) -> bool: + """校验是否为合法的飞书自定义机器人 Webhook 地址。""" + return bool(url) and url.startswith(FEISHU_HOOK_PREFIX) + + +def _gen_sign(timestamp: str, secret: str) -> str: + """计算飞书自定义机器人签名。 + + 算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key), + 用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。 + """ + string_to_sign = f"{timestamp}\n{secret}" + hmac_code = hmac.new( + string_to_sign.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + return base64.b64encode(hmac_code).decode("utf-8") + + +def _truncate_card(text: str) -> str: + """截断卡片正文 (留余量给标题与卡片结构)。""" + text = (text or "").strip() + return text[:_CARD_MAX_LEN] + ("…" if len(text) > _CARD_MAX_LEN else "") + + +def _post_feishu(webhook_url: str, payload: dict, secret: str) -> bool: + """发送一次飞书 webhook 请求并判定成败 (供 text / card 共用)。 + + 成功响应: HTTP 200 且业务 code=0 (或非 JSON 的 200)。失败静默返回 False。 + """ + try: + import httpx + + # 启用签名校验时, 请求体须带 timestamp + sign (秒级时间戳) + if secret: + timestamp = str(int(time.time())) + payload["timestamp"] = timestamp + payload["sign"] = _gen_sign(timestamp, secret) + + resp = httpx.post(webhook_url, json=payload, timeout=5.0) + # 飞书成功响应: {"code":0,"msg":"success"} (或 StatusCode 200 + Extra) + if resp.status_code == 200: + try: + data = resp.json() + # code=0 表示飞书业务侧成功; 部分版本无 code 字段则按 msg 判断 + if isinstance(data, dict): + code = data.get("code", data.get("StatusCode", 0)) + if code == 0: + return True + logger.debug("飞书推送业务失败: %s", data) + return False + except ValueError: + # 非 JSON 响应但 HTTP 200, 视为成功 + return True + logger.debug("飞书推送 HTTP %s: %s", resp.status_code, resp.text[:200]) + return False + except Exception as e: # noqa: BLE001 + logger.debug("飞书 Webhook 推送失败: %s", e) + return False + + +def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool: + """推送一条文本消息到飞书群机器人。 + + Args: + webhook_url: 飞书自定义机器人 Webhook 地址 + title: 消息标题 (与正文拼接为一条文本) + body: 消息正文 + secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名) + + Returns: + True=成功送达, False=失败或 URL 非法。 + 失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。 + """ + if not is_valid_feishu_url(webhook_url): + return False + + text = _truncate(f"{title}\n{body}".strip()) + if not text: + return False + + payload: dict = {"msg_type": "text", "content": {"text": text}} + return _post_feishu(webhook_url, payload, secret) + + +def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str, secret: str = "") -> bool: + """推送一条 interactive 卡片消息到飞书群机器人 —— 用 lark_md 渲染完整 markdown 报告。 + + 飞书「自定义机器人」webhook 不支持文件附件, 但 interactive 卡片的 lark_md 元素 + 可渲染 markdown, 能承载完整复盘报告(通常 2-5KB, 远小于卡片 30KB 上限)。 + + Args: + webhook_url: 飞书自定义机器人 Webhook 地址 + title: 卡片标题 (显示在蓝色 header) + subtitle: 副标题 (加粗显示, 如日期/情绪标签; 留空则省略) + body_md: 卡片正文 markdown (报告全文) + secret: 签名密钥 (启用签名校验时必填) + + Returns: + True=成功送达, False=失败或 URL 非法。 + 失败静默, 不抛异常 (与 send_feishu 一致, 不阻断告警主流程)。 + """ + if not is_valid_feishu_url(webhook_url): + return False + + body = _truncate_card(body_md) + elements: list[dict] = [] + if subtitle.strip(): + elements.append({ + "tag": "div", + "text": {"tag": "lark_md", "content": f"**{subtitle.strip()}**"}, + }) + elements.append({"tag": "hr"}) + elements.append({ + "tag": "div", + "text": {"tag": "lark_md", "content": body}, + }) + + payload: dict = { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "title": {"tag": "plain_text", "content": title}, + "template": "blue", + }, + "elements": elements, + }, + } + return _post_feishu(webhook_url, payload, secret) diff --git a/refer/backend/app/strategy/ai_generator.py b/refer/backend/app/strategy/ai_generator.py index 38fa054..4db6a5e 100644 --- a/refer/backend/app/strategy/ai_generator.py +++ b/refer/backend/app/strategy/ai_generator.py @@ -75,38 +75,18 @@ class AIStrategyGenerator: return {"code": code, "meta": meta, "valid": True, "error": None} async def _call_llm(self, user_prompt: str, guide: str) -> str: - """调用 OpenAI 兼容 API(流式,避免 CDN 长连接超时)""" - from openai import AsyncOpenAI - from app import secrets_store + """Call the configured AI provider and return generated strategy code.""" + from app.services.ai_provider import generate_ai_text - ai_key = secrets_store.get_ai_key() - if not ai_key: - raise RuntimeError("AI API Key 未配置,请在设置页面配置") - - client = AsyncOpenAI( - api_key=ai_key, - base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"), - timeout=180.0, - max_retries=2, - ) - # 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时 - stream = await client.chat.completions.create( - model=secrets_store.get_ai_config("ai_model", "gpt-5.5"), - messages=[ + content = await generate_ai_text( + [ {"role": "system", "content": _SYSTEM_PREFIX + guide}, {"role": "user", "content": user_prompt}, ], temperature=0.3, max_tokens=3000, - stream=True, ) - chunks: list[str] = [] - async for chunk in stream: - delta = chunk.choices[0].delta if chunk.choices else None - if delta and delta.content: - chunks.append(delta.content) - content = "".join(chunks).strip() - # 提取代码块 + # Extract fenced code if the model wrapped the answer in Markdown. if "```python" in content: content = content.split("```python", 1)[1].split("```", 1)[0].strip() elif "```" in content: diff --git a/refer/backend/app/strategy/monitor.py b/refer/backend/app/strategy/monitor.py index 41cf219..99d92ca 100644 --- a/refer/backend/app/strategy/monitor.py +++ b/refer/backend/app/strategy/monitor.py @@ -24,6 +24,44 @@ from app.strategy import config as _strategy_config logger = logging.getLogger(__name__) +# 信号 / 字段中文名映射 — 与前端 lib/signals.ts 对齐, 用于告警 message / 推送文案。 +# signal_* 为内置原子信号, 其余为技术指标/行情字段。 +_SIGNAL_CN: dict[str, str] = { + # 内置信号 + "signal_ma_golden_5_20": "MA5上穿MA20", "signal_ma_dead_5_20": "MA5下穿MA20", + "signal_ma_golden_20_60": "MA20上穿MA60", "signal_macd_golden": "MACD金叉", + "signal_macd_dead": "MACD死叉", "signal_ma20_breakout": "突破MA20", + "signal_ma20_breakdown": "跌破MA20", "signal_n_day_high": "60日新高", + "signal_n_day_low": "60日新低", "signal_boll_breakout_upper": "突破布林上轨", + "signal_boll_breakdown_lower": "跌破布林下轨", "signal_volume_surge": "放量", + "signal_limit_up": "涨停", "signal_limit_down": "跌停", + "signal_limit_down_recovery": "跌停翘板", "signal_broken_limit_up": "炸板", + # 行情字段 + "close": "收盘价", "open": "开盘价", "high": "最高价", "low": "最低价", + "change_pct": "涨跌幅", "change_amount": "涨跌额", "amplitude": "振幅", + "turnover_rate": "换手率", "volume": "成交量", "amount": "成交额", + # 均线 + "ma5": "MA5", "ma10": "MA10", "ma20": "MA20", "ma30": "MA30", "ma60": "MA60", + "ema5": "EMA5", "ema10": "EMA10", "ema20": "EMA20", + # MACD / BOLL / KDJ / RSI + "macd_dif": "MACD-DIF", "macd_dea": "MACD-DEA", "macd_hist": "MACD柱", + "boll_upper": "布林上轨", "boll_lower": "布林下轨", + "kdj_k": "KDJ-K", "kdj_d": "KDJ-D", "kdj_j": "KDJ-J", + "rsi_6": "RSI6", "rsi_14": "RSI14", "rsi_24": "RSI24", + # 量能 / 动量 / 波动 + "vol_ratio_5d": "5日量比", "vol_ratio_20d": "20日量比", + "vol_ma5": "5日均量", "vol_ma10": "10日均量", + "high_60d": "60日最高", "low_60d": "60日最低", + "momentum_5d": "5日动量", "momentum_20d": "20日动量", "momentum_60d": "60日动量", + "atr_14": "ATR14", "annual_vol_20d": "20日年化波动", + "consecutive_limit_ups": "连板数", "consecutive_limit_downs": "跌停连板", +} + + +def _signal_cn_name(name: str) -> str: + """返回信号/字段的中文名, 找不到原样返回 (与前端 cnSignal 对齐)。""" + return _SIGNAL_CN.get(name, name) + @dataclass class StrategyAlert: @@ -276,6 +314,13 @@ class MonitorRuleEngine: self._strategy_pools: dict[str, set[str]] = {} # 数据目录 (用于加载策略 overrides) self._data_dir = None + # 历史窗口加载器: (target_date, lookback_days) → 多日 enriched DataFrame。 + # 用于声明 filter_history 的策略 (如反包), 实时监控时拼历史窗口 + 今日行情跑选股。 + # 为 None 时, filter_history 策略仍会被跳过 (保持旧行为, 不破坏无历史场景)。 + self._history_loader: Callable[[_dt.date, int], "pl.DataFrame"] | None = None + # 本轮 evaluate() 产出的策略选股结果: strategy_id → {rows, total, as_of} + # 供策略页实时回显复用 (/api/screener/cached 端点直接读取此内存结果), 避免重跑 + self._latest_strategy_results: dict[str, dict] = {} def set_strategy_engine(self, engine) -> None: """注入 StrategyEngine, type=strategy 规则据此跑选股。""" @@ -285,6 +330,15 @@ class MonitorRuleEngine: """注入数据目录, 用于加载策略的用户覆盖配置。""" self._data_dir = data_dir + def set_history_loader(self, fn) -> None: + """注入历史窗口加载器, 用于声明 filter_history 的策略跑实时监控。 + + loader 签名: (target_date, lookback_days) → 多日 enriched DataFrame。 + 复用 ScreenerService._load_enriched_history (三级缓存, 命中 ~0ms)。 + 为 None 时 filter_history 策略退回到跳过逻辑 (不破坏无历史场景)。 + """ + self._history_loader = fn + def set_name_map(self, name_map: dict[str, str]) -> None: """注入 symbol → 股票名 映射, 用于在告警事件里回填 name 字段。 @@ -325,6 +379,23 @@ class MonitorRuleEngine: def rule_count(self) -> int: return len(self._rules) + def latest_strategy_results(self) -> dict[str, dict]: + """返回本轮 evaluate() 产出的策略选股结果 (strategy_id → {rows, total, as_of})。 + + 供策略页实时回显复用: /api/screener/cached 端点直接读取此内存结果, + 避免对被监控的策略重跑第二遍。无 type=strategy 规则时返回空 dict。 + """ + return self._latest_strategy_results + + def has_rule_type(self, rtype: str) -> bool: + """是否存在指定类型的 (已启用) 规则。供 quote_service 判断是否需要注入特殊数据。""" + if not self._rules: + return False + return any( + r.get("enabled", True) and r.get("type") == rtype + for r in self._rules.values() + ) + # ── 评估 ─────────────────────────────────────────── def evaluate(self, df: pl.DataFrame) -> list[dict]: """行情更新后评估所有规则。 @@ -339,6 +410,8 @@ class MonitorRuleEngine: now = time.time() events: list[dict] = [] + # 每轮重置: 只保留本次 evaluate 产出的策略结果 + self._latest_strategy_results = {} for rule_id, rule in self._rules.items(): try: @@ -363,6 +436,9 @@ class MonitorRuleEngine: if rtype == "strategy": # 策略类型: 跑策略选股 → 对比上期选股池 → 产出 new_entry/dropped 事件 hit_rows = self._match_strategy(scoped, rule) + elif rtype == "ladder": + # 连板梯队封单监控: 独立处理 (需带预警封单值, 走专属 message) + return self._evaluate_ladder(scoped, rule, now) else: # signal / price / market: 通用条件匹配 for sym, name, price, pct, hit_sigs in self._match_conditions(scoped, rule): @@ -395,7 +471,11 @@ class MonitorRuleEngine: message = name # name 字段即批量消息 else: resolved_name = name if name else self._name_map.get(sym) - message = rule.get("message", "") or self._default_message(rule, ev_type=ev_type, sym=sym, name=resolved_name, pct=pct) + message = rule.get("message", "") or self._default_message( + rule, ev_type=ev_type, sym=sym, name=resolved_name, + pct=pct, price=price, + conditions=list(rule.get("conditions", [])) if rule.get("type") != "strategy" else None, + ) ev = { "ts": int(now * 1000), @@ -410,6 +490,10 @@ class MonitorRuleEngine: "change_pct": pct, "signals": hit_sigs, "severity": severity, + # 触发条件快照 (signal/price/market 类型): 用于触发记录展示 + # 「命中了什么条件」。strategy 类型靠策略选股池 diff, 不写条件。 + "conditions": list(rule.get("conditions", [])) if rtype != "strategy" else [], + "logic": rule.get("logic", "and") if rtype != "strategy" else "and", } events.append(ev) if self._alert_handler: @@ -458,11 +542,6 @@ class MonitorRuleEngine: if s is None: return [] - # 需要历史数据的策略跳过 (实时监控不支持 history loader) - if s.filter_history_fn: - logger.debug("策略 %s 需要历史数据, 跳过实时监控", sid) - return [] - # 运行策略选股: 复用当前 enriched DataFrame 跳过数据加载 overrides = {} if self._data_dir: @@ -471,17 +550,63 @@ class MonitorRuleEngine: except Exception: pass + # 声明 filter_history 的策略 (如反包) 需要多日历史窗口才能判定形态。 + # 旧实现因"实时监控不支持 history loader"直接跳过 → 反包等策略盘中永不触发。 + # 现接入 history_loader, 拼历史窗口 + 今日实时行情, 经 precomputed_history 喂给引擎。 + # loader 为 None (未装配) 时退回跳过, 保持旧行为, 不破坏无历史场景。 + run_kwargs: dict = { + "as_of": _dt.date.today(), + "overrides": overrides, + } + if s.filter_history_fn: + if self._history_loader is None: + logger.debug("策略 %s 需要历史数据但未注入 history_loader, 跳过实时监控", sid) + return [] + try: + today = _dt.date.today() + lookback = max(1, getattr(s, "lookback_days", 30)) + hist_df = self._history_loader(today, lookback) + if hist_df is None or hist_df.is_empty(): + logger.debug("策略 %s 历史数据为空, 跳过本轮实时监控", sid) + return [] + # 历史窗口可能与今日已落盘数据重叠: 排掉 hist_df 中 date==today 的行, + # 今日行情始终以实时 df 为准 (盘中逐轮更新, 最接近收盘真相)。 + # 否则 today 行重复会污染 filter_history 的 .over("symbol") 窗口判定。 + if "date" in hist_df.columns: + hist_df = hist_df.filter(pl.col("date") != today) + # 拼接历史窗口 + 今日实时行情 (filter_history 用 .over("symbol") 窗口, 多日天然可用) + run_kwargs["precomputed_history"] = pl.concat( + [hist_df, df], how="diagonal_relaxed" + ) + except Exception as e: + logger.warning("策略 %s 加载历史窗口失败, 跳过: %s", sid, e) + return [] + else: + # 普通策略: 复用当前 enriched DataFrame 跳过数据加载 + run_kwargs["precomputed"] = df + try: - result = self._strategy_engine.run( - sid, - as_of=_dt.date.today(), - precomputed=df, - overrides=overrides, - ) + result = self._strategy_engine.run(sid, **run_kwargs) except Exception as e: logger.warning("策略 %s 选股执行失败: %s", sid, e) return [] + # 记录本轮完整选股结果 (供策略页实时回显: /cached 端点直接读取, 不落盘)。 + # 与下面的 diff 事件无关 — 无论是否产生 new_entry/dropped, 结果都该可用于回显。 + try: + import math + self._latest_strategy_results[sid] = { + "total": result.total, + "as_of": str(_dt.date.today()), + "rows": [ + {k: (None if isinstance(v, float) and not math.isfinite(v) else v) + for k, v in row.items()} + for row in result.rows + ], + } + except Exception: # noqa: BLE001 + pass + current_pool: set[str] = {r["symbol"] for r in result.rows} prev_pool = self._strategy_pools.get(sid) @@ -577,9 +702,93 @@ class MonitorRuleEngine: results.append((sym, name, price, pct, hit_sigs)) return results + def _evaluate_ladder(self, scoped: pl.DataFrame, rule: dict, now: float) -> list[dict]: + """评估连板梯队封单监控规则。 + + 封单量从注入的临时列 _sealed_vol (手) 读取 (由 quote_service 评估前注入)。 + 命中条件: 封单比较值 <= threshold (且封单 > 0, 排除无 depth 数据的股票)。 + 涨停(direction=up) → 炸板预警; 跌停(direction=down) → 翘板预警。 + """ + if "_sealed_vol" not in scoped.columns: + return [] # 无封单数据 (depth 未拉取), 安全降级 + + metric = rule.get("metric", "sealed_vol") + threshold = rule.get("threshold", 0) + direction = rule.get("direction", "up") + cooldown = rule.get("cooldown_seconds", 600) + severity = rule.get("severity", "warn") + + # 比较值: sealed_vol 直接用 (手), sealed_amount = 手 × 100股 × close + if metric == "sealed_amount": + cmp_expr = pl.col("_sealed_vol") * 100 * pl.col("close") + unit = "元" + else: + cmp_expr = pl.col("_sealed_vol") + unit = "手" + + # 命中: 封单 > 0 (有数据) 且 比较值 <= 阈值 + hit = scoped.filter( + pl.col("_sealed_vol").is_not_null() + & (pl.col("_sealed_vol") > 0) + & (cmp_expr <= threshold) + ) + if hit.is_empty(): + return [] + + warn_label = "炸板预警" if direction == "up" else "翘板预警" + events: list[dict] = [] + for row in hit.iter_rows(named=True): + sym = row.get("symbol", "") + key = (rule["id"], sym) + last = self._last_fire.get(key) + if last is not None and (now - last) < cooldown: + continue + self._last_fire[key] = now + + name = row.get("name") or self._name_map.get(sym) or sym + price = row.get("close") + pct = row.get("change_pct") + sealed_vol = row.get("_sealed_vol") + # 预警封单值 (展示用) + sealed_value = sealed_vol * 100 * (price or 0) if metric == "sealed_amount" else sealed_vol + + # message 体现预警封单量 + 阈值 + if metric == "sealed_amount": + sv_text = f"{sealed_value / 1e4:.0f}万{unit}" + th_text = f"{threshold / 1e4:.0f}万{unit}" + else: + sv_text = f"{sealed_value:,.0f} {unit}" + th_text = f"{threshold:,.0f} {unit}" + message = f"{warn_label} · 封单 {sv_text} ≤ {th_text}" + + events.append({ + "ts": int(now * 1000), + "rule_id": rule["id"], + "rule_name": rule.get("name", ""), + "source": "ladder", + "type": warn_label, + "symbol": sym, + "name": name, + "message": message, + "price": price, + "change_pct": pct, + "signals": [], + "severity": severity, + "conditions": [], + "logic": "and", + "sealed_value": sealed_value, # 预警封单量/额 (飞书+记录展示) + "sealed_metric": metric, + }) + return events + def _default_message(self, rule: dict, ev_type: str = "", sym: str = "", - name: str = "", pct: Any = None) -> str: - """生成默认 message。策略类型按变更方向生成。""" + name: str = "", pct: Any = None, price: Any = None, + conditions: list[dict] | None = None) -> str: + """生成默认 message。 + + - strategy: 按变更方向生成 (进入/移出 + 涨跌幅) + - signal/price/market: 条件摘要 + 现价 + 涨跌幅 (避免笼统的「信号触发」) + """ rtype = rule.get("type", "signal") if rtype == "strategy": # 从 StrategyEngine 取策略名; 失败则退化为 rule_name 里截取的部分 @@ -609,5 +818,40 @@ class MonitorRuleEngine: return f"策略「{sname}」移出 {name}{pct_text}" return f"策略「{sname}」变更" - name_map = {"signal": "信号触发", "price": "价格触发", "market": "市场异动"} - return name_map.get(rtype, "监控触发") + # signal / price / market: 条件摘要 + 现价 + 涨跌幅 + # 条件摘要: 把 conditions (truth/比较) 拼成可读串, 如 "MA20金叉 且 量比>2" + cond_text = self._format_conditions_text(rule, conditions) + price_text = f"现价 {price}" if price is not None else "" + pct_text = "" + if pct is not None: + sign = "+" if pct >= 0 else "" + pct_text = f"{sign}{pct * 100:.1f}%" + tail = " · ".join(s for s in (price_text, pct_text) if s) + if cond_text and tail: + return f"{cond_text} · {tail}" + return cond_text or tail or "监控触发" + + @staticmethod + def _format_conditions_text(rule: dict, conditions: list[dict] | None) -> str: + """把 rule.conditions 拼成可读文本 (用于 message / 推送)。 + + op=truth: 直接用信号中文名 (如 "MA20金叉") + op=比较: 字段中文名 + 操作符 + 值 (如 "涨跌幅≥5") + logic: and → "且", or → "或" + """ + conds = conditions if conditions is not None else list(rule.get("conditions", [])) + if not conds: + return "" + logic_word = "且" if rule.get("logic", "and") == "and" else "或" + parts: list[str] = [] + for c in conds: + field = c.get("field", "") + op = c.get("op", "truth") + value = c.get("value") + label = _signal_cn_name(field) or field + if op == "truth": + parts.append(label) + else: + op_map = {"gte": "≥", "lte": "≤", "gt": ">", "lt": "<", "eq": "="} + parts.append(f"{label}{op_map.get(op, op)}{value}") + return f" {logic_word} ".join(parts) diff --git a/refer/backend/app/strategy/monitor_rules.py b/refer/backend/app/strategy/monitor_rules.py index 08392fb..d074f4e 100644 --- a/refer/backend/app/strategy/monitor_rules.py +++ b/refer/backend/app/strategy/monitor_rules.py @@ -26,12 +26,16 @@ logger = logging.getLogger(__name__) # ── 常量 ──────────────────────────────────────────────── ID_RE = re.compile(r"^[a-z0-9_]{1,40}$") -RULE_TYPES = {"strategy", "signal", "price", "market"} +RULE_TYPES = {"strategy", "signal", "price", "market", "ladder"} SCOPES = {"symbols", "all", "sector"} LOGICS = {"and", "or"} DIRECTIONS = {"entry", "exit", "both"} SEVERITIES = {"info", "warn", "critical"} OPS = {">", ">=", "<", "<=", "==", "!="} +# ladder 规则: 封单监控的指标 (量=手, 额=元) +LADDER_METRICS = {"sealed_vol", "sealed_amount"} +# ladder 规则: 方向 (up=涨停炸板预警, down=跌停翘板预警) +LADDER_DIRECTIONS = {"up", "down"} # 布尔信号列前缀 (op=truth 时 field 取这些) _SIGNAL_PREFIXES = ("signal_", "csg_") @@ -107,6 +111,15 @@ def validate(rule: dict) -> None: raise ValueError("策略类型规则必须指定 strategy_id") if rule.get("direction", "entry") not in DIRECTIONS: raise ValueError(f"direction 必须是 {DIRECTIONS} 之一") + elif rule.get("type") == "ladder": + # 连板梯队封单监控: 需 metric + threshold + direction(up/down), 不用 conditions + if rule.get("metric", "sealed_vol") not in LADDER_METRICS: + raise ValueError(f"metric 必须是 {LADDER_METRICS} 之一") + if rule.get("direction", "up") not in LADDER_DIRECTIONS: + raise ValueError(f"direction 必须是 {LADDER_DIRECTIONS} 之一 (up=涨停炸板, down=跌停翘板)") + thr = rule.get("threshold") + if not isinstance(thr, (int, float)) or thr < 0: + raise ValueError("threshold 必须是非负数字 (封单 ≤ 此值时报警)") else: # 信号/价格/市场类型: 需要 conditions conds = rule.get("conditions") @@ -158,8 +171,12 @@ def normalize(rule: dict) -> dict: r.setdefault("symbols", []) r.setdefault("sector", None) r.setdefault("strategy_id", None) - r.setdefault("direction", "entry") + # direction 默认值: ladder 用 "up", 其余用 "entry" + r.setdefault("direction", "up" if r.get("type") == "ladder" else "entry") r.setdefault("conditions", []) + # ladder 专属默认字段 + r.setdefault("metric", "sealed_vol") + r.setdefault("threshold", 0) r.setdefault("logic", "and") r.setdefault("cooldown_seconds", 3600) r.setdefault("severity", "info") diff --git a/refer/backend/app/tickflow/__init__.py b/refer/backend/app/tickflow/__init__.py index ad866df..ff7675d 100644 --- a/refer/backend/app/tickflow/__init__.py +++ b/refer/backend/app/tickflow/__init__.py @@ -1 +1 @@ -"""数据源适配层 — 能力探测 / 调度 / Repository。""" +"""TickFlow 适配层 — 能力探测 / 调度 / Repository。""" diff --git a/refer/backend/app/tickflow/client.py b/refer/backend/app/tickflow/client.py index dd8172f..d02b608 100644 --- a/refer/backend/app/tickflow/client.py +++ b/refer/backend/app/tickflow/client.py @@ -1,12 +1,12 @@ -"""数据源 SDK 封装(§5)。 +"""TickFlow SDK 封装(§5)。 进程内单例;Key 来源(优先级):secrets.json > .env。 用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。 5 档体系下服务器归属: - - none 档(无 key / 无效 key) → 数据源 SDK .free()(free-api 服务器) - - free 档(免费有效 key) → 数据源 SDK .free()(key 被 SDK 忽略,运行时走 free-api) - - starter/pro/expert(付费 key) → 数据源 SDK (api_key=key, base_url) + - none 档(无 key / 无效 key) → TickFlow.free()(free-api 服务器) + - free 档(免费有效 key) → TickFlow.free()(key 被 SDK 忽略,运行时走 free-api) + - starter/pro/expert(付费 key) → TickFlow(api_key=key, base_url) """ from __future__ import annotations @@ -18,6 +18,7 @@ from app import secrets_store _sync_client: TickFlow | None = None _async_client: AsyncTickFlow | None = None +_paid_realtime_client: TickFlow | None = None # ===== 服务器归属判定 ===== @@ -71,11 +72,27 @@ def get_async_client() -> AsyncTickFlow: return _async_client +def get_paid_realtime_client() -> TickFlow | None: + """实时行情专用付费服务器客户端。 + + none/free 的历史日K仍走 get_client() 的 free-api;实时行情全部走付费服务器。 + Free 档如果有有效 key,也使用这里的 paid endpoint 调按标的实时接口。 + """ + global _paid_realtime_client + key = secrets_store.get_tickflow_key() + if not key: + return None + if _paid_realtime_client is None: + _paid_realtime_client = TickFlow(api_key=key, base_url=_base_url()) + return _paid_realtime_client + + def reset_clients() -> None: """Key 变化后调用 — 让下一次 get_client() 拿新实例。""" - global _sync_client, _async_client + global _sync_client, _async_client, _paid_realtime_client _sync_client = None _async_client = None + _paid_realtime_client = None def current_mode() -> str: diff --git a/refer/backend/app/tickflow/policy.py b/refer/backend/app/tickflow/policy.py index 8bf7490..0442f48 100644 --- a/refer/backend/app/tickflow/policy.py +++ b/refer/backend/app/tickflow/policy.py @@ -31,9 +31,8 @@ _CAPSET_CACHE_FILE = "capabilities.json" # 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。 # v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量) # v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测) -# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器, -# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。 -_CACHE_SCHEMA_VERSION = 4 +# v5: Free 档补充付费服务器 quote.by_symbol(10rpm/5标的),用于自选股实时监控。 +_CACHE_SCHEMA_VERSION = 5 # 探测用最小代价请求:挑流通性最好的 1 只标的试 _PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市 @@ -110,7 +109,7 @@ def _call_with_retry(fn, attempts: int = 3, backoff: float = 0.6) -> None: def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: """逐 capability 试探。需要 API key。 - **关键**:探测始终在付费端点上进行,用 key 鉴权验证有效性。 + **关键**:探测始终在付费端点(api.tickflow.org)上进行,用 key 鉴权验证有效性。 绝不能读旧 capabilities 缓存的档位来选服务器 —— 否则首次保存 key 时, 旧缓存是 none 档 → get_client() 返回 free 服务器 → free 服务器忽略 key → 乱填 key 也能拿到日K → 误判成 free 档(鸡生蛋蛋生鸡的循环依赖 bug)。 @@ -122,7 +121,7 @@ def _probe_real(tiers: dict) -> tuple[CapabilitySet, list[str]]: key = secrets_store.get_tickflow_key() # 探测专用客户端:强制走付费端点验证 key。 - # base_url 用用户自定义端点(若已配置测速切换),否则默认付费端点。 + # base_url 用用户自定义端点(若已配置测速切换),否则默认 api.tickflow.org。 probe_base = _base_url() or PAID_ENDPOINT tf = TickFlow(api_key=key, base_url=probe_base) available: dict[Cap, CapabilityLimits] = {} @@ -278,7 +277,7 @@ def detect_capabilities(force: bool = False) -> CapabilitySet: _persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True) return capset if classified.is_free: - # 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器) + # 免费有效 key:按 free 档能力持久化(日K free-api + 按标的实时)。 capset = _tier_to_capset(tiers["free"]) _persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[]) return capset diff --git a/refer/backend/app/tickflow/pools.py b/refer/backend/app/tickflow/pools.py index 313f3a0..77a45d9 100644 --- a/refer/backend/app/tickflow/pools.py +++ b/refer/backend/app/tickflow/pools.py @@ -1,7 +1,7 @@ """标的池(Universe)定义(§6.3)。 Phase 1 实现: - - 常用指数成份(沪深 300 / 中证 500 / 上证 50)用数据源 `quote.pool` 端点拉取并缓存 + - 常用指数成份(沪深 300 / 中证 500 / 上证 50)用 TickFlow `quote.pool` 端点拉取并缓存 - 全 A 通过 instruments.batch 获取 - 自选池 = 用户的 watchlist """ @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) PoolId = Literal["CSI300", "CSI500", "SSE50", "CN_Equity_A", "CN_Index", "watchlist"] -# 数据源 universe id 是内部命名(见 tf.universes.list())。 +# TickFlow universe id 是它内部命名(见 tf.universes.list())。 # 没有官方对照表,启动时按名称模糊匹配从 universes.list() 里找。 # 常见名:沪深300 / 中证500 / 上证50 / 全 A _POOL_NAME_HINTS = { @@ -70,7 +70,7 @@ def get_pool(pool_id: PoolId, refresh: bool = False) -> list[str]: def _fetch_pool(pool_id: PoolId) -> list[str]: - """从数据源拉取池成份。 + """从 TickFlow 拉取池成份。 实现:先用 universes.list 找到 universe id,再 quotes.get_by_universes 拉成份。 """ @@ -79,7 +79,7 @@ def _fetch_pool(pool_id: PoolId) -> list[str]: if pool_id in _POOL_NAME_HINTS: uid = _find_universe_id(_POOL_NAME_HINTS[pool_id]) if not uid: - logger.warning("无法在数据源 universes 列表里匹配到 %s", pool_id) + logger.warning("无法在 TickFlow universes 列表里匹配到 %s", pool_id) return [] try: df = tf.quotes.get_by_universes([uid], as_dataframe=True) diff --git a/refer/backend/app/tickflow/repository.py b/refer/backend/app/tickflow/repository.py index a247316..7ddd521 100644 --- a/refer/backend/app/tickflow/repository.py +++ b/refer/backend/app/tickflow/repository.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import sys import threading from datetime import date from pathlib import Path @@ -32,17 +33,26 @@ class DataStore: self.data_dir = Path(data_dir or settings.data_dir) self.data_dir.mkdir(parents=True, exist_ok=True) + # 一次性数据迁移: 旧桌面版把数据放在 exe 同级的兄弟目录 TickFlowStockPanel_Data/, + # 新版改为 {app}/data/。老用户首次启动时自动把旧数据搬过来, 无感升级。 + self._migrate_legacy_data_dir() + # 关键子目录(§7.2) for sub in ( "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", "financials", "instruments", "instruments_index", + "instruments_etf", "instruments_ext", "kline_ext", "pools", @@ -62,6 +72,64 @@ class DataStore: self.db = duckdb.connect(database=":memory:") self._register_views() + def _migrate_legacy_data_dir(self) -> None: + """把旧桌面版数据目录 (<安装目录>/../TickFlowStockPanel_Data/) 迁移到新位置 (<安装目录>/data/)。 + + 背景: 旧版 data_dir = exe_dir.parent / "TickFlowStockPanel_Data" (兄弟目录), + 新版改为 exe_dir / "data" (子目录)。老用户首次升级时旧数据在兄弟目录, + 若不迁移会导致历史行情/策略/回测/监控全部"丢失"(实际还在旧位置)。 + + 策略 (仅打包桌面版触发, 开发/Docker 不受影响): + 1. 旧目录存在且新 data/ 还基本为空 → 整目录搬迁 (shutil.move, 跨盘符安全)。 + 2. 新旧目录都已有数据 (用户在两套路径都跑过) → 不自动搬, 仅记日志, 避免覆盖。 + 3. 旧目录不存在 → 新装用户, 无需迁移。 + 所有异常都吞掉只记警告 —— 数据迁移失败绝不能阻塞应用启动。 + """ + # 仅打包桌面版需要迁移; 开发/Docker 模式 _PROJECT_ROOT/data 本就是唯一路径 + if not getattr(sys, "frozen", False): + return + + import shutil + + try: + legacy_dir = self.data_dir.parent / "TickFlowStockPanel_Data" + if not legacy_dir.exists(): + return # 新装用户, 无旧数据 + + # 新 data/ 目录里已有实质性内容 → 用户已在新路径跑过, 不覆盖 + # (用 .parquet 作为"有真实数据"的判据, 避免空子目录误判) + has_new_data = any(self.data_dir.rglob("*.parquet")) or any( + self.data_dir.rglob("*.jsonl") + ) + if has_new_data: + logger.info( + "legacy data dir %s exists but new %s already has data, skip migration", + legacy_dir, self.data_dir, + ) + return + + logger.info("migrating legacy data %s -> %s", legacy_dir, self.data_dir) + # 逐项 move 而非整目录 move: data/ 可能已被 __init__ 创建了空子目录, + # 直接 shutil.move(legacy, data) 会因目标已存在失败。 + for item in legacy_dir.iterdir(): + dest = self.data_dir / item.name + if dest.exists(): + # 同名子目录 (如 kline_daily): 合并内容 + if dest.is_dir(): + shutil.move(str(item), str(dest / item.name)) + else: + item.unlink() # 同名文件, 以新路径为准, 删旧 + else: + shutil.move(str(item), str(dest)) + # 搬完后清理空的旧目录 + try: + shutil.rmtree(legacy_dir) + except OSError: + logger.warning("legacy dir %s not empty, kept", legacy_dir) + logger.info("legacy data migration done") + except Exception as e: # noqa: BLE001 + logger.warning("legacy data migration failed (startup continues): %s", e) + def _register_views(self) -> None: """把 Parquet 目录挂载为 DuckDB 视图(§7.3)。""" d = self.data_dir.as_posix() @@ -74,14 +142,24 @@ class DataStore: SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_index_enriched AS SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_daily AS + SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_enriched AS + SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_minute AS + SELECT * FROM read_parquet('{d}/kline_etf_minute/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_minute AS SELECT * FROM read_parquet('{d}/kline_minute/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW adj_factor AS SELECT * FROM read_parquet('{d}/adj_factor/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW adj_factor_etf AS + SELECT * FROM read_parquet('{d}/adj_factor_etf/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments AS SELECT * FROM read_parquet('{d}/instruments/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_index AS SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW instruments_etf AS + SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_ext AS SELECT * FROM read_parquet('{d}/instruments_ext/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_ext AS @@ -104,6 +182,91 @@ class DataStore: self.db.execute(sql) except duckdb.IOException: logger.debug("view registration skipped (no parquet yet): %s", sql[:60]) + self._register_unified_views() + + def _has_parquet(self, subdir: str) -> bool: + return any((self.data_dir / subdir).rglob("*.parquet")) + + def _register_unified_views(self) -> None: + """Register optional all-asset views when their backing parquet exists. + + Physical storage remains split for performance and compatibility. These + views are convenience read models for new APIs/features. + """ + daily_parts: list[str] = [] + enriched_parts: list[str] = [] + minute_parts: list[str] = [] + inst_parts: list[str] = [] + + if self._has_parquet("kline_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'stock' AS asset_type, 'tickflow' AS source + FROM kline_daily + """) + if self._has_parquet("kline_index_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'index' AS asset_type, 'tickflow' AS source + FROM kline_index_daily + """) + if self._has_parquet("kline_etf_daily"): + daily_parts.append(""" + SELECT symbol, date, open, high, low, close, volume, amount, + 'etf' AS asset_type, 'tickflow' AS source + FROM kline_etf_daily + """) + + if self._has_parquet("kline_daily_enriched"): + enriched_parts.append("SELECT *, 'stock' AS asset_type, 'tickflow' AS source FROM kline_enriched") + if self._has_parquet("kline_index_enriched"): + enriched_parts.append("SELECT *, 'index' AS asset_type, 'tickflow' AS source FROM kline_index_enriched") + if self._has_parquet("kline_etf_enriched"): + enriched_parts.append("SELECT *, 'etf' AS asset_type, 'tickflow' AS source FROM kline_etf_enriched") + + if self._has_parquet("kline_minute"): + minute_parts.append(""" + SELECT symbol, datetime, open, high, low, close, volume, amount, + 'stock' AS asset_type, 'tickflow' AS source + FROM kline_minute + """) + if self._has_parquet("kline_etf_minute"): + minute_parts.append(""" + SELECT symbol, datetime, open, high, low, close, volume, amount, + 'etf' AS asset_type, 'tickflow' AS source + FROM kline_etf_minute + """) + + if self._has_parquet("instruments"): + inst_parts.append(""" + SELECT symbol, name, code, exchange, 'stock' AS asset_type, 'tickflow' AS source + FROM instruments + """) + if self._has_parquet("instruments_index"): + inst_parts.append(""" + SELECT symbol, name, code, NULL AS exchange, 'index' AS asset_type, 'tickflow' AS source + FROM instruments_index + WHERE coalesce(asset_type, 'index') != 'etf' + """) + if self._has_parquet("instruments_etf"): + inst_parts.append(""" + SELECT symbol, name, code, NULL AS exchange, 'etf' AS asset_type, 'tickflow' AS source + FROM instruments_etf + """) + + unions = { + "kline_daily_all": daily_parts, + "kline_enriched_all": enriched_parts, + "kline_minute_all": minute_parts, + "instruments_all": inst_parts, + } + for name, parts in unions.items(): + if not parts: + continue + try: + self.db.execute(f"CREATE OR REPLACE VIEW {name} AS " + " UNION ALL BY NAME ".join(parts)) + except Exception as e: # noqa: BLE001 + logger.debug("unified view %s skipped: %s", name, e) class KlineRepository: @@ -119,18 +282,27 @@ class KlineRepository: self._enriched_cache_date: date | None = None self._live_agg_cache: pl.DataFrame | None = None # 预计算聚合表 (~5500行) self._live_agg_cache_date: date | None = None + self._live_agg_check_date: date | None = None # 上次跨日校验时的 today (快路径节流) self._instruments_cache: pl.DataFrame | None = None # 完整 enriched 历史 (含所有指标, 供 filter_history 策略使用) self._enriched_history_cache: pl.DataFrame | None = None # ~100万行 self._enriched_history_start: date | None = None self._index_instruments_cache: pl.DataFrame | None = None + self._etf_enriched_cache: pl.DataFrame | None = None + self._etf_enriched_cache_date: date | None = None + self._etf_live_agg_cache: pl.DataFrame | None = None + self._etf_live_agg_cache_date: date | None = None + self._etf_instruments_cache: pl.DataFrame | None = None # parquet glob 路径 self._enriched_glob = str(store.data_dir / "kline_daily_enriched" / "**" / "*.parquet") self._index_enriched_glob = str(store.data_dir / "kline_index_enriched" / "**" / "*.parquet") + self._etf_enriched_glob = str(store.data_dir / "kline_etf_enriched" / "**" / "*.parquet") self._minute_glob = str(store.data_dir / "kline_minute" / "**" / "*.parquet") + self._etf_minute_glob = str(store.data_dir / "kline_etf_minute" / "**" / "*.parquet") self._inst_glob = str(store.data_dir / "instruments" / "**" / "*.parquet") self._index_inst_glob = str(store.data_dir / "instruments_index" / "**" / "*.parquet") + self._etf_inst_glob = str(store.data_dir / "instruments_etf" / "**" / "*.parquet") def execute_all(self, sql: str, params: list | None = None) -> list[tuple]: """线程安全的 SELECT → fetchall。DuckDB 单 connection 非线程安全,所有读路径须走此方法。""" @@ -150,6 +322,7 @@ class KlineRepository: """刷新 Polars 缓存。在 pipeline 完成后、服务启动时调用。""" self._refresh_instruments() self._refresh_index_instruments() + self._refresh_etf_instruments() self._refresh_enriched() def clear_cache(self) -> None: @@ -165,8 +338,14 @@ class KlineRepository: self._enriched_history_start = None self._live_agg_cache = None self._live_agg_cache_date = None + self._live_agg_check_date = None self._instruments_cache = None self._index_instruments_cache = None + self._etf_enriched_cache = None + self._etf_enriched_cache_date = None + self._etf_live_agg_cache = None + self._etf_live_agg_cache_date = None + self._etf_instruments_cache = None def _refresh_enriched(self) -> None: """从 parquet 加载 enriched 最新日到内存 + 构建聚合表。 @@ -462,6 +641,47 @@ class KlineRepository: return df_hist, agg_a + def _refresh_etf_enriched(self) -> None: + """从 ETF enriched parquet 加载最新日到内存缓存。""" + try: + enriched_dir = self.store.data_dir / "kline_etf_enriched" + dates = sorted( + p.name[5:] for p in enriched_dir.glob("date=*") + if p.is_dir() and p.name.startswith("date=") + ) if enriched_dir.exists() else [] + if not dates: + self._etf_enriched_cache = None + self._etf_enriched_cache_date = None + return + latest = date.fromisoformat(dates[-1]) + target_parquet = enriched_dir / f"date={dates[-1]}" / "part.parquet" + df_latest = pl.read_parquet(target_parquet) + if df_latest.is_empty(): + return + + from datetime import timedelta + from app.indicators.pipeline import compute_indicators, compute_signals + start_full = latest - timedelta(days=300) + read_cols = [c for c in ["symbol", "date", "open", "high", "low", "close", + "volume", "amount", "raw_close", "raw_high", "raw_low"] + if c in df_latest.columns] + df_hist = ( + pl.scan_parquet(self._etf_enriched_glob, + cast_options=pl.ScanCastOptions(integer_cast="allow-float")) + .filter(pl.col("date") >= start_full) + .select(read_cols) + .sort(["symbol", "date"]) + .collect() + ) + if df_hist.is_empty(): + self._etf_enriched_cache = df_latest.sort(["symbol"]) + else: + df_full = compute_signals(compute_indicators(df_hist)) + self._etf_enriched_cache = df_full.filter(pl.col("date") == latest).sort(["symbol"]) + self._etf_enriched_cache_date = latest + except Exception as e: # noqa: BLE001 + logger.debug("ETF enriched 缓存刷新跳过: %s", e) + def _refresh_instruments(self) -> None: """加载 instruments 到内存。""" try: @@ -482,6 +702,28 @@ class KlineRepository: except Exception as e: # noqa: BLE001 logger.debug("index instruments 缓存刷新跳过: %s", e) + def _refresh_etf_instruments(self) -> None: + """加载 ETF instruments 到内存;兼容旧版 instruments_index 中的 ETF。""" + parts: list[pl.DataFrame] = [] + try: + df = pl.scan_parquet(self._etf_inst_glob).collect() + if not df.is_empty(): + parts.append(df) + except Exception as e: # noqa: BLE001 + logger.debug("etf instruments 缓存刷新跳过(new): %s", e) + try: + legacy = self.get_index_instruments() + if not legacy.is_empty() and "asset_type" in legacy.columns: + legacy = legacy.filter(pl.col("asset_type") == "etf") + if not legacy.is_empty(): + parts.append(legacy) + except Exception as e: # noqa: BLE001 + logger.debug("etf instruments legacy fallback skipped: %s", e) + if parts: + df_all = pl.concat(parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol") + self._etf_instruments_cache = df_all + logger.info("ETF instruments 缓存已加载: %d 只", len(df_all)) + def get_enriched_latest(self) -> tuple[pl.DataFrame, date | None]: """返回缓存的 enriched 最新日 DataFrame + 日期。如无缓存则懒加载。""" if self._enriched_cache is None: @@ -490,6 +732,18 @@ class KlineRepository: return pl.DataFrame(), self._enriched_cache_date return self._enriched_cache, self._enriched_cache_date + def get_enriched_latest_asset(self, asset_type: str) -> tuple[pl.DataFrame, date | None]: + """按资产类型返回最新 enriched 缓存。stock 保持旧缓存语义。""" + if asset_type == "stock": + return self.get_enriched_latest() + if asset_type == "etf": + if self._etf_enriched_cache is None: + self._refresh_etf_enriched() + if self._etf_enriched_cache is None: + return pl.DataFrame(), self._etf_enriched_cache_date + return self._etf_enriched_cache, self._etf_enriched_cache_date + return pl.DataFrame(), None + def get_enriched_history(self, target_date: date, lookback_days: int) -> pl.DataFrame | None: """返回预计算的 enriched 历史数据 (仅 lookback 范围, 不含 warmup)。 @@ -544,9 +798,36 @@ class KlineRepository: return df.sort(["symbol", "date"]) def get_live_agg(self) -> pl.DataFrame: - """返回盘中实时指标预计算聚合表。如无缓存则懒加载。""" + """返回盘中实时指标预计算聚合表。如无缓存则懒加载。 + + live_agg 的核心列 _prev_consec_up/down (昨日连板数) 取自基准日 enriched。 + 基准日由 _live_agg_baseline_date 决定: 盘中(today 有实时分区) 取上一交易日, + 非盘中(磁盘最新日 < today) 取该最新日本身。一旦跨日, 期望基准日会前移, + 旧缓存会把连板数整体少算一档, 故这里除首次懒加载外还要校验基准日是否仍 + 符合当前预期, 不符则重建 (无需等盘后管道刷缓存)。 + + 性能: get_live_agg 被每轮实时行情调用 (expert 档 1s 一次)。跨日只在 + date.today() 翻天时发生, 故先用 today 做廉价的 fast-path (μs 级), + 仅当 today 变化时才查磁盘确认 (DuckDB 扫 132 万行约 100ms+) 并按需重建。 + """ if self._live_agg_cache is None: self._refresh_enriched() + self._live_agg_check_date = date.today() # 刚建过, 当天不必再查磁盘 + else: + today = date.today() + if self._live_agg_check_date != today: + # today 翻天了 (次日开盘首次轮询): 校验基准日是否需要前移重建。 + # 同一天内多次调用直接跳过, 避免每轮都扫 parquet。 + self._live_agg_check_date = today + disk_latest = self._latest_enriched_date_duckdb() + if disk_latest is not None: + expected = self._live_agg_baseline_date(disk_latest) + if self._live_agg_cache_date != expected: + logger.info( + "live_agg 跨日失效, 重建: 缓存基准=%s, 期望基准=%s", + self._live_agg_cache_date, expected, + ) + self._refresh_enriched() if self._live_agg_cache is None: return pl.DataFrame() return self._live_agg_cache @@ -567,6 +848,27 @@ class KlineRepository: return pl.DataFrame() return self._index_instruments_cache + def get_etf_instruments(self) -> pl.DataFrame: + """返回缓存的 ETF instruments DataFrame;兼容旧版 instruments_index 中的 ETF。""" + if self._etf_instruments_cache is None: + self._refresh_etf_instruments() + if self._etf_instruments_cache is None: + return pl.DataFrame() + return self._etf_instruments_cache + + def get_instruments_asset(self, asset_type: str) -> pl.DataFrame: + """按资产类型返回 instruments;老 stock 路径保持原样。""" + if asset_type == "stock": + return self.get_instruments() + if asset_type == "index": + df = self.get_index_instruments() + if not df.is_empty() and "asset_type" in df.columns: + return df.filter(pl.col("asset_type") != "etf") + return df + if asset_type == "etf": + return self.get_etf_instruments() + return pl.DataFrame() + def get_index_symbol_set(self) -> set[str]: """返回已缓存指数 symbol 集合。""" df = self.get_index_instruments() @@ -656,6 +958,45 @@ class KlineRepository: df = df.select(existing) return df + def get_etf_daily( + self, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + """ETF 日K查询 — 优先读独立 ETF enriched,兼容旧版 index enriched 中的 ETF。""" + from datetime import timedelta + + warmup_start = start - timedelta(days=150) + df = self._scan_etf_daily_symbol(symbol, warmup_start, end, None) + if df.is_empty(): + # 旧版 ETF 曾存入 kline_index_enriched;没有独立数据时回退读取。 + df = self._scan_index_daily_symbol(symbol, warmup_start, end, None) + if not df.is_empty(): + df = self._compute_index_enriched_range(df) + df = df.filter((pl.col("date") >= start) & (pl.col("date") <= end)) + if columns and not df.is_empty(): + existing = [c for c in columns if c in df.columns] + df = df.select(existing) + return df + + def get_daily_asset( + self, + asset_type: str, + symbol: str, + start: date, + end: date, + columns: list[str] | None = None, + ) -> pl.DataFrame: + if asset_type == "stock": + return self.get_daily(symbol, start, end, columns) + if asset_type == "index": + return self.get_index_daily(symbol, start, end, columns) + if asset_type == "etf": + return self.get_etf_daily(symbol, start, end, columns) + return pl.DataFrame() + def get_minute( self, symbol: str, @@ -770,6 +1111,23 @@ class KlineRepository: logger.warning("指数日K查询失败: %s", e) return pl.DataFrame() + def _scan_etf_daily_symbol(self, symbol: str, start: date, end: date, columns: list[str] | None) -> pl.DataFrame: + try: + lf = pl.scan_parquet(self._etf_enriched_glob, + cast_options=pl.ScanCastOptions(integer_cast="allow-float")).filter( + (pl.col("symbol") == symbol) + & (pl.col("date") >= start) + & (pl.col("date") <= end) + ).sort("date") + if columns: + schema_names = lf.collect_schema().names() + existing = [c for c in columns if c in schema_names] + lf = lf.select(existing) + return lf.collect() + except Exception as e: # noqa: BLE001 + logger.debug("ETF 日K查询跳过: %s", e) + return pl.DataFrame() + def _merge_cached_and_scan( self, cached: pl.DataFrame, @@ -911,6 +1269,39 @@ class KlineRepository: df_storage = df.select(storage_cols) self._write_daily_partition(df_storage, "kline_index_enriched") + def append_etf_daily(self, df: pl.DataFrame) -> None: + """按日分区写入 ETF 日K数据 (merge-upsert)。""" + if df.is_empty(): + return + self._write_daily_partition(df, "kline_etf_daily") + + def append_etf_enriched(self, df: pl.DataFrame) -> None: + """按日分区写入 ETF enriched 数据。磁盘仅写入基础行情窄表。""" + if df.is_empty(): + return + from app.indicators.pipeline import ENRICHED_STORAGE_COLS + storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + df_storage = df.select(storage_cols) + self._write_daily_partition(df_storage, "kline_etf_enriched") + + def append_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按资产类型写入日K;stock/index 保持旧目录兼容。""" + if asset_type == "stock": + self.append_daily(df) + elif asset_type == "index": + self.append_index_daily(df) + elif asset_type == "etf": + self.append_etf_daily(df) + + def append_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按资产类型写入 enriched;stock/index 保持旧目录兼容。""" + if asset_type == "stock": + self.append_enriched(df) + elif asset_type == "index": + self.append_index_enriched(df) + elif asset_type == "etf": + self.append_etf_enriched(df) + def save_index_instruments(self, df: pl.DataFrame) -> None: """保存指数标的维表。""" if df.is_empty() or "symbol" not in df.columns: @@ -919,8 +1310,21 @@ class KlineRepository: out.parent.mkdir(parents=True, exist_ok=True) df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out) self._index_instruments_cache = None + self._etf_instruments_cache = None self._refresh_index_instruments() + def save_etf_instruments(self, df: pl.DataFrame) -> None: + """保存 ETF 标的维表到独立目录。""" + if df.is_empty() or "symbol" not in df.columns: + return + if "asset_type" not in df.columns: + df = df.with_columns(pl.lit("etf").alias("asset_type")) + out = self.store.data_dir / "instruments_etf" / "instruments_etf.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + df.unique(subset=["symbol"], keep="last").sort("symbol").write_parquet(out) + self._etf_instruments_cache = None + self._refresh_etf_instruments() + def refresh_index_views(self) -> None: """刷新指数相关 DuckDB 视图。""" d = self.store.data_dir.as_posix() @@ -929,15 +1333,23 @@ class KlineRepository: SELECT * FROM read_parquet('{d}/kline_index_daily/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW kline_index_enriched AS SELECT * FROM read_parquet('{d}/kline_index_enriched/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_daily AS + SELECT * FROM read_parquet('{d}/kline_etf_daily/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW kline_etf_enriched AS + SELECT * FROM read_parquet('{d}/kline_etf_enriched/**/*.parquet', union_by_name=true)""", f"""CREATE OR REPLACE VIEW instruments_index AS SELECT * FROM read_parquet('{d}/instruments_index/**/*.parquet', union_by_name=true)""", + f"""CREATE OR REPLACE VIEW instruments_etf AS + SELECT * FROM read_parquet('{d}/instruments_etf/**/*.parquet', union_by_name=true)""", ] for sql in statements: try: with self._lock: self.db.execute(sql) except Exception as e: # noqa: BLE001 - logger.debug("index view refresh skipped: %s", e) + logger.debug("index/etf view refresh skipped: %s", e) + with self._lock: + self.store._register_unified_views() def _write_daily_partition(self, df: pl.DataFrame, table: str) -> None: """按 date 分区写入 parquet,每个日期一个文件,支持 merge-upsert。""" @@ -955,11 +1367,92 @@ class KlineRepository: date_df = date_df.sort(["symbol", "date"]) date_df.write_parquet(out) + def merge_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按 symbol 合并当天指定资产日K分区。用于少量自选实时,不覆盖全市场。""" + if df.is_empty() or "date" not in df.columns: + return + table = { + "stock": "kline_daily", + "index": "kline_index_daily", + "etf": "kline_etf_daily", + }.get(asset_type) + if not table: + return + base = self.store.data_dir / table + dt = df["date"][0] + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + date_df = df.sort(["symbol", "date"]) + if out.exists(): + existing = pl.read_parquet(out) + date_df = pl.concat([existing, date_df], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + date_df.sort(["symbol", "date"]).write_parquet(out) + + def merge_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """按 symbol 合并当天 enriched 分区和内存缓存。用于少量自选实时。""" + if df.is_empty() or "date" not in df.columns: + return + dt = df["date"][0] + if asset_type == "stock": + table = "kline_daily_enriched" + existing_cache = self._enriched_cache if self._enriched_cache_date == dt else pl.DataFrame() + elif asset_type == "etf": + table = "kline_etf_enriched" + existing_cache = self._etf_enriched_cache if self._etf_enriched_cache_date == dt else pl.DataFrame() + elif asset_type == "index": + table = "kline_index_enriched" + existing_cache = pl.DataFrame() + else: + return + + merged_cache = df + if existing_cache is not None and not existing_cache.is_empty(): + merged_cache = pl.concat([existing_cache, df], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + merged_cache = merged_cache.sort(["symbol"]) + if asset_type == "stock": + self._enriched_cache = merged_cache + self._enriched_cache_date = dt + elif asset_type == "etf": + self._etf_enriched_cache = merged_cache + self._etf_enriched_cache_date = dt + + from app.indicators.pipeline import ENRICHED_STORAGE_COLS + storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] + df_storage = df.select(storage_cols).sort(["symbol"]) + base = self.store.data_dir / table + ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) + out = base / f"date={ds}" / "part.parquet" + out.parent.mkdir(parents=True, exist_ok=True) + if out.exists(): + existing = pl.read_parquet(out) + df_storage = pl.concat([existing, df_storage], how="diagonal_relaxed").unique( + subset=["symbol", "date"], keep="last" + ) + df_storage.sort(["symbol"]).write_parquet(out) + def flush_live_daily(self, df: pl.DataFrame) -> None: """覆写当天 kline_daily 分区 (实时行情落盘, 非merge)。""" if df.is_empty() or "date" not in df.columns: return - base = self.store.data_dir / "kline_daily" + self.flush_live_daily_asset("stock", df) + + def flush_live_daily_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """覆写当天指定资产日K分区 (实时行情落盘, 非merge)。""" + if df.is_empty() or "date" not in df.columns: + return + table = { + "stock": "kline_daily", + "index": "kline_index_daily", + "etf": "kline_etf_daily", + }.get(asset_type) + if not table: + return + base = self.store.data_dir / table dt = df["date"][0] ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" @@ -971,17 +1464,30 @@ class KlineRepository: 内存缓存保留完整指标列供各服务使用,磁盘仅写入 14 列存储列。 """ + self.flush_live_enriched_asset("stock", df) + + def flush_live_enriched_asset(self, asset_type: str, df: pl.DataFrame) -> None: + """覆写当天指定资产 enriched 分区 (实时 enriched 落盘, 非merge)。""" if df.is_empty() or "date" not in df.columns: return - # 内存缓存: 保留完整 66 列 - self._enriched_cache = df.sort(["symbol"]) dt = df["date"][0] - self._enriched_cache_date = dt - # 磁盘写入: 仅 14 列存储列 + if asset_type == "stock": + self._enriched_cache = df.sort(["symbol"]) + self._enriched_cache_date = dt + table = "kline_daily_enriched" + elif asset_type == "etf": + self._etf_enriched_cache = df.sort(["symbol"]) + self._etf_enriched_cache_date = dt + table = "kline_etf_enriched" + elif asset_type == "index": + table = "kline_index_enriched" + else: + return + from app.indicators.pipeline import ENRICHED_STORAGE_COLS storage_cols = [c for c in ENRICHED_STORAGE_COLS if c in df.columns] df_storage = df.select(storage_cols).sort(["symbol"]) - base = self.store.data_dir / "kline_daily_enriched" + base = self.store.data_dir / table ds = dt.isoformat() if hasattr(dt, "isoformat") else str(dt) out = base / f"date={ds}" / "part.parquet" out.parent.mkdir(parents=True, exist_ok=True) diff --git a/refer/backend/app/uuid_store.py b/refer/backend/app/uuid_store.py deleted file mode 100644 index 18ec2ff..0000000 --- a/refer/backend/app/uuid_store.py +++ /dev/null @@ -1,98 +0,0 @@ -"""管理员创建的访问 UUID 持久化存储。 - -位置:`data/user_data/access_uuids.json`,权限 0600。 -与 secrets.json 分离,避免凭据文件过大且便于独立管理。 -""" -from __future__ import annotations - -import json -import logging -import os -import time -import uuid -from pathlib import Path - -logger = logging.getLogger(__name__) - - -def _path() -> Path: - from app.config import settings - p = settings.data_dir / "user_data" / "access_uuids.json" - p.parent.mkdir(parents=True, exist_ok=True) - return p - - -def _load() -> list[dict]: - p = _path() - if p.exists(): - try: - data = json.loads(p.read_text(encoding="utf-8")) - if isinstance(data, list): - return data - except Exception as e: # noqa: BLE001 - logger.warning("access_uuids.json malformed: %s", e) - return [] - - -def _save(records: list[dict]) -> list[dict]: - p = _path() - p.write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8") - try: - os.chmod(p, 0o600) - except OSError: - pass - return records - - -def list_uuids() -> list[dict]: - """返回所有 UUID 记录(按创建时间倒序)。""" - records = _load() - records.sort(key=lambda r: r.get("created_at", 0), reverse=True) - return records - - -def exists(uuid: str) -> bool: - """检查 UUID 是否存在且启用。""" - normalized = uuid.strip() - return any(r.get("uuid") == normalized and r.get("enabled", True) for r in _load()) - - -def create(label: str = "") -> dict: - """创建一个新的访问 UUID。""" - records = _load() - new_uuid = str(uuid.uuid4()) - record = { - "uuid": new_uuid, - "label": (label or "").strip(), - "enabled": True, - "created_at": int(time.time()), - } - records.append(record) - _save(records) - return record - - -def delete(uuid: str) -> bool: - """删除指定 UUID。""" - records = _load() - original_len = len(records) - records = [r for r in records if r.get("uuid") != uuid.strip()] - if len(records) == original_len: - return False - _save(records) - return True - - -def toggle(uuid: str, enabled: bool) -> bool: - """启用/禁用指定 UUID。""" - records = _load() - found = False - for r in records: - if r.get("uuid") == uuid.strip(): - r["enabled"] = enabled - found = True - break - if not found: - return False - _save(records) - return True diff --git a/refer/backend/pyproject.toml b/refer/backend/pyproject.toml index 1d2af09..62e3677 100644 --- a/refer/backend/pyproject.toml +++ b/refer/backend/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "stock-panel-backend" -version = "0.1.44" -description = "A 股选股 + 监控 + 回测面板" +name = "tickflow-stock-panel-backend" +version = "0.1.70" +description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" license = { text = "MIT" } @@ -19,7 +19,7 @@ dependencies = [ "pyarrow>=16.0", "pandas>=2.2", # 仅在 BacktestService 边界使用,见 §7.4 / ADR-19 "fastexcel>=0.10", # Polars 读取 xlsx/xls - # A 股数据源 SDK + # TickFlow 官方 SDK "tickflow[all]>=0.1.23", # Scheduling "apscheduler>=3.10", @@ -29,9 +29,19 @@ dependencies = [ # AI(可选,但默认装上) "openai>=1.40", # OpenAI 兼容适配器复用 openai SDK "httpx>=0.27", + "platformdirs>=4.0", # 桌面版用户数据目录 (跨平台持久可写) + "winotify>=1.1; sys_platform == 'win32'", # Windows 系统通知 (进操作中心) + "plyer>=2.1", # 系统通知跨平台兜底 (macOS/Linux) ] [project.optional-dependencies] +# Legacy CPU runtime: install the Polars compatibility kernel on +# machines without AVX2/FMA support. +# Enable with: uv sync --extra legacy-cpu +legacy-cpu = [ + "polars[rtcompat]>=1.0", +] + # 回测依赖 vectorbt → numba → llvmlite,体积大且 macOS/Intel 上无预构建 wheel 时 # 需要 brew install cmake 现场编译。挪到可选 extras,主依赖瘦身。 # 启用:`uv sync --extra backtest` @@ -39,6 +49,12 @@ backtest = [ "vectorbt>=0.26", ] +# 桌面客户端依赖: pywebview 桌面窗口。打包用 (PyInstaller), 生产/Docker 不需要。 +# 启用:`uv sync --extra desktop` +desktop = [ + "pywebview>=5.0", +] + dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/refer/backend/tests/test_ai_provider.py b/refer/backend/tests/test_ai_provider.py new file mode 100644 index 0000000..3db10c7 --- /dev/null +++ b/refer/backend/tests/test_ai_provider.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from app.services.ai_provider import normalize_openai_base_url + + +def test_normalize_openai_base_url_adds_v1_for_root_gateway(): + assert normalize_openai_base_url("http://ai.zedbox.cn:8080") == "http://ai.zedbox.cn:8080/v1" + + +def test_normalize_openai_base_url_preserves_v1_base(): + assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1") == "http://ai.zedbox.cn:8080/v1" + + +def test_normalize_openai_base_url_strips_chat_completions_path(): + assert normalize_openai_base_url("http://ai.zedbox.cn:8080/v1/chat/completions") == "http://ai.zedbox.cn:8080/v1" diff --git a/refer/backend/uv.lock b/refer/backend/uv.lock index ca4bb59..394230b 100644 --- a/refer/backend/uv.lock +++ b/refer/backend/uv.lock @@ -105,6 +105,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -114,6 +123,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -215,6 +250,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] +[[package]] +name = "clr-loader" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1442,6 +1489,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "plotly" version = "6.7.0" @@ -1464,6 +1520,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "plyer" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/85/f61425aa9be1f9108eec1c13861c1e11c9a04eb786eb4832a8f7188317df/plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61", size = 121371, upload-time = "2022-11-12T13:36:48.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266, upload-time = "2022-11-12T13:36:47.181Z" }, +] + [[package]] name = "polars" version = "1.40.1" @@ -1476,6 +1541,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, ] +[package.optional-dependencies] +rtcompat = [ + { name = "polars-runtime-compat" }, +] + [[package]] name = "polars-runtime-32" version = "1.40.1" @@ -1492,6 +1562,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, ] +[[package]] +name = "polars-runtime-compat" +version = "1.40.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/f8340030f94a45231ad02abff171c8c10f45df0984e5a4fd0f39a48500f4/polars_runtime_compat-1.40.1.tar.gz", hash = "sha256:6149aa764439cec26a5f883fb9921a50f61a0f6c4549df51c735626701a73a18", size = 2935443, upload-time = "2026-04-22T19:16:00.906Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c7/978481a2a2f5b50636d485bc176e16084fa1bb3b1d7e7e34f580cef240dc/polars_runtime_compat-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a683d287237f1dcc3dee95b5b2b6408cec318abbbb412ca49206492513be862f", size = 52016973, upload-time = "2026-04-22T19:15:25.228Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c9/e308b0bbb331330a762f5f495597d34e9933c965ca6d4026a2eb481ef4ad/polars_runtime_compat-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c83750f5593cec088134614fbf783fd10c5a92030d71b35f3dea0fff682d92c6", size = 46246682, upload-time = "2026-04-22T19:15:28.666Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/eaecb4747695e2e200ed6d13ce40dd7b0cdc7499d0b9af1e64e83af0e46d/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffa15c4a8b7f67911412c849dc3d3aac313f682d834eb3f374d0f6cb7e080efc", size = 50123217, upload-time = "2026-04-22T19:15:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/0a881905d94341111d38e6a7ae94674511e6797cf3be2500998f06963edf/polars_runtime_compat-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0373dabf942135d13b453d50be7a84b14420b495bd1242570545f58866396e7", size = 55869626, upload-time = "2026-04-22T19:15:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/be/6e/ac57e39bb94ebd63d6714895881ee0461e39039daa208916e5cad93174f9/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bcdc488472d2c57dd3315a897456b47cdac222a1520fcf3d8d38410a5bf47ec1", size = 50287716, upload-time = "2026-04-22T19:15:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f3/b75afc9f79cd4cadff52bdc4566119892b378f739a295573bf6bd0190a6c/polars_runtime_compat-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:34d03962169fc7c0ef6f6efd324d0a51350e7b5abf3b0cd118eb7a32e4d947ec", size = 53796312, upload-time = "2026-04-22T19:15:44.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/68/0df61011d894d1c94c915b39423c9aab79b3f7b18b836031ed32987227e0/polars_runtime_compat-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:d9ccfe58eeb776567cf9556a83b980b9face9f0b1bb629bf2cd2334f4d9daf57", size = 51696665, upload-time = "2026-04-22T19:15:48.65Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/1bc7e868d3e4055b1ae756ad8ccc2a5d2bce57d7c88c3b4427babd43450b/polars_runtime_compat-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:0c662e6acf5d4e3784eee8e8a1ec6eb185132ac90689cb84034132b5787903b1", size = 45701223, upload-time = "2026-04-22T19:15:52.189Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1504,6 +1590,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "proxy-tools" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/cf/77d3e19b7fabd03895caca7857ef51e4c409e0ca6b37ee6e9f7daa50b642/proxy_tools-0.1.0.tar.gz", hash = "sha256:ccb3751f529c047e2d8a58440d86b205303cf0fe8146f784d1cbcd94f0a28010", size = 2978, upload-time = "2014-05-05T21:02:24.606Z" } + [[package]] name = "psutil" version = "7.2.2" @@ -1600,6 +1692,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1740,6 +1841,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, +] + +[[package]] +name = "pyobjc-framework-security" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/b8/4267b802d8dba6de468e7d0765b05cc4e146fa376ed9f55e0b6461016bef/pyobjc_framework_security-12.2.1.tar.gz", hash = "sha256:d7831b1537f4346892e7f2f0e2b09d79bee98919b0767f4061278d0e03028f2d", size = 181065, upload-time = "2026-06-19T16:21:40.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/f2ff946edfaf16b4ce5e31afac5e519f83705c0f4842fd25134ecb8f2f4a/pyobjc_framework_security-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce461296b003b2ba17c8b65f6339f9d2fd5dcfa2b3b52ddc0a696334cc8974c5", size = 41306, upload-time = "2026-06-19T16:17:16.816Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5b/2719bc4062e6c27083191fd20e365ae02d0bf1c22f4d1a88211e3d96b369/pyobjc_framework_security-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76ff6e44e62d3e15651540493879bf16687d862c4f10f3cadade757811c8b8d0", size = 41300, upload-time = "2026-06-19T16:17:17.702Z" }, + { url = "https://files.pythonhosted.org/packages/15/90/dccd4cd6877ef208957dc1f3675287d8614a4dcd2a3ee0a5e56f5fb5a1ba/pyobjc_framework_security-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:990013baba29d6f985d8950b23701129b2597b3d16f628b785fe97596d8a8de3", size = 41299, upload-time = "2026-06-19T16:17:18.511Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/f9e8040e0c3ef6a50392a46ad1df482a666aa615180d40730b00282ff81f/pyobjc_framework_security-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:066a3e5e9d368e7a6ba8dd52be2077a634ef12a54fbfcc78b3b8154a8f988a1d", size = 42179, upload-time = "2026-06-19T16:17:19.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3c/76e2a8bb8d5fe48f0e8e25c6abec1609f3667cc39935017badfe9e9603f2/pyobjc_framework_security-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:5319ae49b8874363ab51c6ff4d85d4ea0cfa6d836fe0306e901ba9ae560b880d", size = 41370, upload-time = "2026-06-19T16:17:20.501Z" }, + { url = "https://files.pythonhosted.org/packages/14/6e/7120956e9833b2c70757eec1f65f57c191e00662cf74c4545d88315643fa/pyobjc_framework_security-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:21618431e0dbfbd3d4029445e3118af88e5d7e52ddecf9a2d17c759c51628d85", size = 42926, upload-time = "2026-06-19T16:17:21.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/0bafc557523e5755f74dd5363386a1e9b03f611e2e36df0737a508cd5ab4/pyobjc_framework_security-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:fa192e9df479375e6242adcadb9a44f32907dd7fe1207608710cd3af65fe3c84", size = 41376, upload-time = "2026-06-19T16:17:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/47/33/33d266117e46fef148caa4f986b3d896cb9bfd76bef48bd761cb60c758ee/pyobjc_framework_security-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:07cd044a7996f9a897040c49055fa3bdf565acac4a25b834a72e60602376146d", size = 42944, upload-time = "2026-06-19T16:17:23.371Z" }, +] + +[[package]] +name = "pyobjc-framework-uniformtypeidentifiers" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/108fa1e5a3dd8aff626f98fb97de370323b290404b04ffa2ef9420665ed3/pyobjc_framework_uniformtypeidentifiers-12.2.1.tar.gz", hash = "sha256:1fb89d13aa3c2df8e6d6536f6df3493fe5a6caefd2a5adebf17c5af3b29ed4a2", size = 20679, upload-time = "2026-06-19T16:21:55.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/44/18a7b3c3b4f9f6784fddf64ed5a2c148577d0300705a50e8ab81da8fc71d/pyobjc_framework_uniformtypeidentifiers-12.2.1-py2.py3-none-any.whl", hash = "sha256:ea08413ad895a7dfea13670e26548bcf5b00154084cdfb5d8f96603320e77cf3", size = 5042, upload-time = "2026-06-19T16:18:54.085Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/d2/b230c594f70ecb970b4cef67bae2648d1bfa5b381e9b7e3710bf24ec8887/pyobjc_framework_webkit-12.2.1.tar.gz", hash = "sha256:a56acae55b50d549b20dff2921ad1099add8fbc377d0de09ddc2ba50957f7def", size = 332374, upload-time = "2026-06-19T16:22:01.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/d3/2ab99d3975dd4624dd943e5a7c8d37e40258d3c9fcf4f26baf09a24e6c9b/pyobjc_framework_webkit-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af5c4ccdf03845adac082823a3b4341b5b2fe62d2d664550afa705b5286a06fc", size = 50264, upload-time = "2026-06-19T16:19:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/7a2099eb2e062c6230a9440f1795cf34056ca5e16ef25c8aad7c059b8734/pyobjc_framework_webkit-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7e04dcc08cdc59380113ea1232af75a0a04c2426418ebe967b4c0045c973f776", size = 50372, upload-time = "2026-06-19T16:19:31.581Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a4/202ec288808011d3f459d000d593e88b1118f2d1d5a4dfaaf5232f2c2ac2/pyobjc_framework_webkit-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:23bee8bf7077f91da4e3ae54a00c7f5e4414319e15f98be8584dbd67c4043fae", size = 50387, upload-time = "2026-06-19T16:19:32.522Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/f796e94b43a66704b6ae17c747c7b97fd4b79348f1cfa9bef7b008aaa718/pyobjc_framework_webkit-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:00ffb254f97e9ffdd0a82c1faa61a07f6072ba900fa8aba70c83c21198b52e4e", size = 50853, upload-time = "2026-06-19T16:19:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/d24716fef19ccc3d880e99029458803f0174c05df310d991eb97ea3a0799/pyobjc_framework_webkit-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:67030258c3cd66e8495ccfccef3d2d58010ff0209284c5115e5afdb0e9fd6de1", size = 50499, upload-time = "2026-06-19T16:19:34.45Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6c/817119a52efcc229a30ceff56a0641005a431806a1f555e0571626ba313a/pyobjc_framework_webkit-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5d91527c9950c79269dd0d70f2bb8668c298dd06930637c1c063ce5f274a87e5", size = 50967, upload-time = "2026-06-19T16:19:35.474Z" }, + { url = "https://files.pythonhosted.org/packages/2d/59/5fac0754d53b2a72aed6f424dfc72e5fa245f83cb57c2e00d02e45390fca/pyobjc_framework_webkit-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:657825081484c9920c50b76b469b9583f116225b0449c9d95c46cbc8c640adc8", size = 50498, upload-time = "2026-06-19T16:19:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/da/0c/e997e33d99d4ad91da2cf70f0e51ac39b03c58ba210548e9e944bbb421be/pyobjc_framework_webkit-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:f46adcc6227873f2b14d74b2e789c937f227722274ab59b9fa3c04c6ecb46dd5", size = 50958, upload-time = "2026-06-19T16:19:37.424Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1808,6 +2017,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, ] +[[package]] +name = "pythonnet" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -1817,6 +2039,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] +[[package]] +name = "pywebview" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-security", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-uniformtypeidentifiers", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, + { name = "qtpy", marker = "sys_platform == 'openbsd6'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/4a/05307135dafba67778669d194bd1a3822a7685ec9ee8a6d7e70856c1a551/pywebview-6.2.1.tar.gz", hash = "sha256:71b7136752e40824655304d938efb62014218d1a90bd8e87e1cbdb1ce9c466af", size = 513126, upload-time = "2026-04-15T09:02:16.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/25/9491695c22c4842c5b3903b4dc172e0eecf67a27c0af34a71512c9b76a0a/pywebview-6.2.1-py3-none-any.whl", hash = "sha256:9d07275f53894ab4d5e2e0e996227193e7187dec276d9b624dccbce029216b46", size = 525463, upload-time = "2026-04-15T09:02:10.186Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1872,6 +2116,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qtpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/76/37c0ccd5ab968a6a438f9c623aeecc84c202ab2fabc6a8fd927580c15b5a/QtPy-2.4.3-py3-none-any.whl", hash = "sha256:72095afe13673e017946cc258b8d5da43314197b741ed2890e563cf384b51aa1", size = 95045, upload-time = "2025-02-11T15:09:24.162Z" }, +] + [[package]] name = "regex" version = "2026.5.9" @@ -2234,8 +2490,8 @@ all = [ ] [[package]] -name = "stock-panel-backend" -version = "0.1.44" +name = "tickflow-stock-panel-backend" +version = "0.1.66" source = { editable = "." } dependencies = [ { name = "apscheduler" }, @@ -2245,6 +2501,8 @@ dependencies = [ { name = "httpx" }, { name = "openai" }, { name = "pandas" }, + { name = "platformdirs" }, + { name = "plyer" }, { name = "polars" }, { name = "pyarrow" }, { name = "pydantic" }, @@ -2255,18 +2513,25 @@ dependencies = [ { name = "sse-starlette" }, { name = "tickflow", extra = ["all"] }, { name = "uvicorn", extra = ["standard"] }, + { name = "winotify", marker = "sys_platform == 'win32'" }, ] [package.optional-dependencies] backtest = [ { name = "vectorbt" }, ] +desktop = [ + { name = "pywebview" }, +] dev = [ { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] +legacy-cpu = [ + { name = "polars", extra = ["rtcompat"] }, +] [package.metadata] requires-dist = [ @@ -2278,7 +2543,10 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "openai", specifier = ">=1.40" }, { name = "pandas", specifier = ">=2.2" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "plyer", specifier = ">=2.1" }, { name = "polars", specifier = ">=1.0" }, + { name = "polars", extras = ["rtcompat"], marker = "extra == 'legacy-cpu'", specifier = ">=1.0" }, { name = "pyarrow", specifier = ">=16.0" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, @@ -2286,14 +2554,16 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "python-dotenv", specifier = ">=1.0" }, { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "pywebview", marker = "extra == 'desktop'", specifier = ">=5.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "sse-starlette", specifier = ">=2.0" }, { name = "tickflow", extras = ["all"], specifier = ">=0.1.23" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, { name = "vectorbt", marker = "extra == 'backtest'", specifier = ">=0.26" }, + { name = "winotify", marker = "sys_platform == 'win32'", specifier = ">=1.1" }, ] -provides-extras = ["backtest", "dev"] +provides-extras = ["legacy-cpu", "backtest", "desktop", "dev"] [[package]] name = "tqdm" @@ -2636,3 +2906,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] + +[[package]] +name = "winotify" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/b0/1b304fdd8fd810f1f8e81a2708f8bf72e0987de1a763f0ee81f6d08bcae7/winotify-1.1.0.tar.gz", hash = "sha256:f8a0d6ff00cb2c1b3dcdfe825431f46f6aa5dc8ce84ffc59e8fda8c7e36687fe", size = 10101, upload-time = "2022-02-07T12:34:46.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/85/6cc4c738080d60b62cad59d0f32386b8277d40e2bf8c06fd1e4101a17238/winotify-1.1.0-py3-none-any.whl", hash = "sha256:13aa9b1196b02ab3e699645b4407371ca73348421f8662565100d70c7cf552d9", size = 15034, upload-time = "2022-02-07T12:34:44.341Z" }, +] diff --git a/refer/dev.ps1 b/refer/dev.ps1 new file mode 100644 index 0000000..c9bc16a --- /dev/null +++ b/refer/dev.ps1 @@ -0,0 +1,242 @@ +# tickflow-stock-panel - one-shot launcher for backend + frontend (Windows / PowerShell) +# +# Usage: +# .\dev.ps1 +# .\dev.ps1 -BackendPort 8000 -FrontendPort 5173 +# $env:BACKEND_PORT='8000'; .\dev.ps1 +# +# Ctrl-C closes both processes. +# +# If you see "running scripts is disabled": +# Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned + +[CmdletBinding()] +param( + [int]$BackendPort = 0, + [int]$FrontendPort = 0 +) + +$ErrorActionPreference = 'Stop' + +# Port precedence: CLI arg > env var > default +if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 3018 } } +if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } } + +# Force UTF-8 console output so child process logs aren't garbled +try { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false + $OutputEncoding = New-Object System.Text.UTF8Encoding $false +} catch {} + +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$BackendDir = Join-Path $Root 'backend' +$FrontendDir = Join-Path $Root 'frontend' + +function Log-Info($m) { Write-Host "[dev] $m" -ForegroundColor DarkGray } +function Log-Ok ($m) { Write-Host "[dev] $m" -ForegroundColor Green } +function Log-Warn($m) { Write-Host "[dev] $m" -ForegroundColor Yellow } +function Log-Err ($m) { Write-Host "[dev] $m" -ForegroundColor Red } + +# ===== 1. Dependency check ===== +function Require-Cmd($cmd, $hint) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + Log-Err "$cmd not found" + Write-Host " install via: $hint" + exit 1 + } +} + +Require-Cmd 'uv' 'powershell -c "irm https://astral.sh/uv/install.ps1 | iex" OR winget install --id=astral-sh.uv' +Require-Cmd 'pnpm' 'npm i -g pnpm OR corepack enable; corepack prepare pnpm@9 --activate' + +# ===== 2. Port check - kill anything listening on the target ports ===== +function Free-Port($name, $port) { + $conns = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue + if (-not $conns) { return } + $pids = @($conns.OwningProcess | Where-Object { $_ -gt 0 } | Sort-Object -Unique) + if ($pids.Count -eq 0) { return } + + # Filter to PIDs that still exist as running processes. + # A zombie TCP endpoint can linger after the process is already dead. + $alive = @($pids | Where-Object { + try { [System.Diagnostics.Process]::GetProcessById($_) | Out-Null; $true } + catch { $false } + }) + + if ($alive.Count -eq 0) { + # All processes are dead but kernel still holds the socket (zombie endpoint). + # On Windows this can linger for minutes, but uvicorn/vite can still bind + # via SO_REUSEADDR — no point waiting, just proceed. + Log-Warn "port ${port} (${name}) - zombie socket (processes gone), starting anyway" + return + } + + Log-Warn "port $port ($name) is in use, killing PID: $($alive -join ', ')" + # Use taskkill /F /T to kill the entire process tree (parent + children), + # not just the parent. Stop-Process only kills one process, leaving child + # processes (e.g. uvicorn spawned by uv) as orphans holding the socket. + foreach ($p in $alive) { + # Suppress stderr properly for Windows PowerShell (5.x) + $null = & cmd /c "taskkill /F /T /PID $p 2>nul" + # Fallback: if taskkill failed, try Stop-Process + try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {} + } + + # Wait up to 5 seconds for the kernel to release the TCP endpoint + for ($i = 0; $i -lt 10; $i++) { + Start-Sleep -Milliseconds 500 + $still = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue + if (-not $still) { + Log-Ok "port $port freed" + return + } + } + + # Port still stuck — process might be dead with zombie socket + $anyAlive = $still | Where-Object { + try { [System.Diagnostics.Process]::GetProcessById($_.OwningProcess) | Out-Null; $true } + catch { $false } + } + if ($anyAlive.Count -eq 0) { + Log-Warn "port ${port} - processes gone but socket lingers, starting anyway" + } else { + Log-Err "port ${port} still in use by live process(es). Inspect: Get-NetTCPConnection -LocalPort ${port}" + exit 1 + } +} + +Free-Port 'backend' $BackendPort +Free-Port 'frontend' $FrontendPort + +# ===== 3. First-time dependency install ===== +if (-not (Test-Path (Join-Path $BackendDir '.venv'))) { + Log-Info 'first run - installing Python deps (1-2 min)...' + Push-Location $BackendDir + try { & uv sync } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { Log-Err 'uv sync failed'; exit 1 } + Log-Ok 'backend deps installed' +} + +if (-not (Test-Path (Join-Path $FrontendDir 'node_modules'))) { + Log-Info 'first run - installing Node deps...' + Push-Location $FrontendDir + try { & pnpm install } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { Log-Err 'pnpm install failed'; exit 1 } + Log-Ok 'frontend deps installed' +} + +# ===== 4. Banner (ASCII so it renders on any codepage) ===== +Write-Host '' +Write-Host '+----------------------------------------------+' -ForegroundColor Blue +Write-Host '| tickflow-stock-panel |' -ForegroundColor Blue +Write-Host '| |' -ForegroundColor Blue +Write-Host "| backend http://localhost:$BackendPort" -ForegroundColor Blue +Write-Host "| frontend http://localhost:$FrontendPort" -ForegroundColor Blue +Write-Host '| |' -ForegroundColor Blue +Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue +Write-Host '+----------------------------------------------+' -ForegroundColor Blue +Write-Host '' + +# ===== 5. Launch jobs ===== +# Each job writes its $PID to a temp file so the main thread can find the +# child powershell.exe and taskkill /T the whole process tree on exit. +$backendPidFile = [System.IO.Path]::GetTempFileName() +$frontendPidFile = [System.IO.Path]::GetTempFileName() + +$backendJob = Start-Job -Name 'backend' -ScriptBlock { + param($pidFile, $dir, $port) + $PID | Out-File -FilePath $pidFile -Encoding ascii -Force + $env:PYTHONUNBUFFERED = '1' + Set-Location $dir + & .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1 +} -ArgumentList $backendPidFile, $BackendDir, $BackendPort + +$frontendJob = Start-Job -Name 'frontend' -ScriptBlock { + param($pidFile, $dir, $port) + $PID | Out-File -FilePath $pidFile -Encoding ascii -Force + Set-Location $dir + & pnpm dev --host 0.0.0.0 --port $port 2>&1 +} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort + +# Wait up to 5 seconds for the PID files to materialise +function Read-JobPid($file) { + for ($i = 0; $i -lt 50; $i++) { + try { + $c = (Get-Content $file -ErrorAction SilentlyContinue) -as [string] + if ($c -and $c.Trim()) { return [int]$c.Trim() } + } catch {} + Start-Sleep -Milliseconds 100 + } + return $null +} +$backendChildPid = Read-JobPid $backendPidFile +$frontendChildPid = Read-JobPid $frontendPidFile + +# ===== 6. Cleanup ===== +$script:cleaning = $false +function Cleanup-All { + if ($script:cleaning) { return } + $script:cleaning = $true + Write-Host '' + Log-Info 'shutting down...' + + foreach ($p in @($backendChildPid, $frontendChildPid)) { + if ($p) { + # /T kills the whole process tree (the job's powershell + uvicorn/vite) + $null = & cmd /c "taskkill /F /T /PID $p 2>nul" + } + } + foreach ($j in @($backendJob, $frontendJob)) { + if ($j) { + Stop-Job $j -ErrorAction SilentlyContinue + Remove-Job $j -Force -ErrorAction SilentlyContinue + } + } + foreach ($f in @($backendPidFile, $frontendPidFile)) { + Remove-Item $f -Force -ErrorAction SilentlyContinue + } + Log-Ok 'bye' +} + +# ===== 7. Main loop - pump output, handle Ctrl-C ===== +# Treat Ctrl-C as input so try/finally is guaranteed to run. +$prevCtrlC = [Console]::TreatControlCAsInput +try { + [Console]::TreatControlCAsInput = $true + + while ($true) { + if ([Console]::KeyAvailable) { + $key = [Console]::ReadKey($true) + if (($key.Modifiers -band [ConsoleModifiers]::Control) -and $key.Key -eq 'C') { + break + } + } + + $bOut = Receive-Job $backendJob -ErrorAction SilentlyContinue + if ($bOut) { + foreach ($line in $bOut) { + Write-Host '[backend ] ' -NoNewline -ForegroundColor Blue + Write-Host $line + } + } + + $fOut = Receive-Job $frontendJob -ErrorAction SilentlyContinue + if ($fOut) { + foreach ($line in $fOut) { + Write-Host '[frontend] ' -NoNewline -ForegroundColor Green + Write-Host $line + } + } + + if ($backendJob.State -ne 'Running' -or $frontendJob.State -ne 'Running') { + Log-Warn 'one of the processes exited; closing the other...' + break + } + + Start-Sleep -Milliseconds 150 + } +} +finally { + [Console]::TreatControlCAsInput = $prevCtrlC + Cleanup-All +} diff --git a/refer/dev.sh b/refer/dev.sh new file mode 100755 index 0000000..4304b36 --- /dev/null +++ b/refer/dev.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# tickflow-stock-panel — 一键启动前后端 +# +# 用法: +# ./dev.sh # 默认 backend:3018 frontend:3011 +# BACKEND_PORT=8000 ./dev.sh # 改后端端口 +# FRONTEND_PORT=5173 ./dev.sh # 改前端端口 +# +# Ctrl-C 同时关闭两端。 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$ROOT/backend" +FRONTEND_DIR="$ROOT/frontend" +BACKEND_PORT="${BACKEND_PORT:-3018}" +FRONTEND_PORT="${FRONTEND_PORT:-3011}" + +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +GRAY='\033[0;90m' +NC='\033[0m' + +info() { echo -e "${GRAY}[dev]${NC} $*"; } +ok() { echo -e "${GREEN}[dev]${NC} $*"; } +warn() { echo -e "${YELLOW}[dev]${NC} $*"; } +err() { echo -e "${RED}[dev]${NC} $*" >&2; } + +# ===== 1. 依赖检查 ===== +require_cmd() { + local cmd="$1" hint="$2" + if ! command -v "$cmd" >/dev/null 2>&1; then + err "$cmd 未安装" + echo " 安装方式:$hint" + exit 1 + fi +} + +require_cmd uv "curl -LsSf https://astral.sh/uv/install.sh | sh" +require_cmd pnpm "npm i -g pnpm 或 corepack enable && corepack prepare pnpm@9 --activate" + +# ===== 2. 端口占用检查 —— 占用就直接 kill ===== +free_port() { + local name="$1" port="$2" + local pids + pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) + if [ -z "$pids" ]; then + return 0 + fi + warn "端口 $port($name)被占用,kill 现有进程 PID: $(echo "$pids" | xargs)" + # 先 TERM + echo "$pids" | xargs kill 2>/dev/null || true + sleep 1 + # 还活着就 KILL + pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) + if [ -n "$pids" ]; then + warn "TERM 没杀掉,改用 KILL -9" + echo "$pids" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + # 再确认一次 + pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true) + if [ -n "$pids" ]; then + err "端口 $port 仍被占用 — kill 失败。请手动处理:lsof -i :$port" + exit 1 + fi + ok "端口 $port 已释放" +} +free_port backend "$BACKEND_PORT" +free_port frontend "$FRONTEND_PORT" + +# ===== 3. 首次依赖安装 ===== +if [ ! -d "$BACKEND_DIR/.venv" ]; then + info "后端首次启动 — 安装 Python 依赖(约 1-2 分钟)..." + ( cd "$BACKEND_DIR" && uv sync ) + ok "后端依赖装好了" +fi + +if [ ! -d "$FRONTEND_DIR/node_modules" ]; then + info "前端首次启动 — 安装 Node 依赖..." + ( cd "$FRONTEND_DIR" && pnpm install ) + ok "前端依赖装好了" +fi + +# ===== 4. 启动 + 日志前缀 ===== +PIDS=() + +cleanup() { + echo + info "关闭服务..." + for pid in "${PIDS[@]:-}"; do + if [ -n "$pid" ]; then + kill "$pid" 2>/dev/null || true + fi + done + # 等子进程退出,避免孤儿 + wait 2>/dev/null || true + ok "已退出" + exit 0 +} +trap cleanup INT TERM + +# 用 awk 加前缀(macOS sed 没有 -u line-buffered,改用 awk + fflush 兼容) +prefix_awk() { + awk -v p="$1" '{ print p $0; fflush() }' +} + +echo +echo -e "${BLUE}╭──────────────────────────────────────────────╮${NC}" +echo -e "${BLUE}│${NC} ${GREEN}tickflow-stock-panel${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} backend ${YELLOW}http://localhost:$BACKEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} frontend ${YELLOW}http://localhost:$FRONTEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} Ctrl-C 同时关闭两端 ${BLUE}│${NC}" +echo -e "${BLUE}╰──────────────────────────────────────────────╯${NC}" +echo + +( + cd "$BACKEND_DIR" + uv run uvicorn app.main:app --reload --host 0.0.0.0 --port "$BACKEND_PORT" 2>&1 \ + | prefix_awk "$(printf "${BLUE}[backend ]${NC} ")" +) & +PIDS+=("$!") + +( + cd "$FRONTEND_DIR" + pnpm dev --host 0.0.0.0 --port "$FRONTEND_PORT" 2>&1 \ + | prefix_awk "$(printf "${GREEN}[frontend]${NC} ")" +) & +PIDS+=("$!") + +# 等任一退出(bash 4.3+)或全部退出(老 bash) +if wait -n 2>/dev/null; then + warn "其中一个进程退出,正在关闭另一个..." + cleanup +else + # 老 bash 没有 wait -n,退化为 wait 全部 + wait +fi diff --git a/refer/docker-compose.yml b/refer/docker-compose.yml index 8dccc9c..0399a4e 100644 --- a/refer/docker-compose.yml +++ b/refer/docker-compose.yml @@ -1,19 +1,18 @@ +# Phase 0 单 service:FastAPI 启动后既跑 API 又托管前端 dist。 +# 见 ADR-17 / §8.1。 services: app: build: context: . dockerfile: Dockerfile - target: runtime args: - - PYTHON_IMAGE=${PYTHON_IMAGE:-python:3.11-slim} - - NODE_IMAGE=${NODE_IMAGE:-node:20-alpine} - container_name: Stock_Panel + BACKEND_EXTRAS: ${BACKEND_EXTRAS:-} + container_name: TickFlow_Stock_Panel ports: - - "3018:3018" - environment: - - ACCESS_UUID=${ACCESS_UUID:-} + - "${PORT:-3018}:3018" + env_file: + - .env volumes: - ./data:/app/data - ./tiers.yaml:/app/tiers.yaml:ro restart: unless-stopped - diff --git a/refer/docs/deploy-password.md b/refer/docs/deploy-password.md new file mode 100644 index 0000000..c828b31 --- /dev/null +++ b/refer/docs/deploy-password.md @@ -0,0 +1,99 @@ +# 公网部署:如何设置访问密码 + +面板部署在公网服务器时,首次设置访问密码有限制 —— **必须从本机或内网访问**,以防公网上陌生人抢先设置密码锁死你的面板。 + +如果你在公网浏览器直接打开页面,会看到提示: + +> 首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作 + +有两种方式解决,任选其一。 + +--- + +## 方式一:环境变量预置密码(最简单,推荐) + +在 `.env` 文件(或 Docker / 系统环境变量)里设置 `AUTH_PASSWORD`: + +```bash +# 编辑服务器上的 .env (通常在项目根目录或 backend/ 下) +AUTH_PASSWORD=你的密码 +``` + +然后重启服务。启动时会自动: +1. 读取 `AUTH_PASSWORD` +2. 用 PBKDF2 哈希后写入 `auth.json`(`chmod 600`,只存哈希不存明文) +3. **之后这个环境变量就不再被读取** —— 是一次性的初始化 + +设完后即可用公网地址 + 这个密码正常登录。后续改密码请用页面 UI(`设置 → 修改密码`),不受环境变量影响。 + +### 注意事项 + +- **密码至少 6 位**,否则会被跳过并记一条 warning 日志 +- **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码) +- `.env` 文件权限保持 `600`,**不要提交到 Git** +- 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json` + +### 重置密码(忘密码时) + +如果忘了密码想重置:删除或清空 `data/user_data/auth.json`,重启服务,会回到"未设密码"状态,此时 `AUTH_PASSWORD` 会重新生效。 + +```bash +# 停服后执行, 清空后重启 +rm data/user_data/auth.json +``` + +--- + +## 方式二:SSH 端口转发 + +不用改配置,在你**自己电脑**的终端执行(不是服务器上): + +```bash +ssh -L 3018:127.0.0.1:3018 用户名@服务器IP +``` + +例如服务器是 `123.45.67.89`、用户名 `root`、面板端口 `3018`: + +```bash +ssh -L 3018:127.0.0.1:3018 root@123.45.67.89 +``` + +保持这个 SSH 连接**不要关**,然后在**自己电脑的浏览器**打开: + +``` +http://127.0.0.1:3018 +``` + +此时后端看到的客户端 IP 是 `127.0.0.1`(本机),能通过校验,正常显示设置密码界面。 + +**设完密码后**,SSH 连接可以断开 —— 密码已存进服务器,之后直接用公网地址 + 刚设的密码访问即可。 + +### 端口说明 + +上面的 `3018` 是默认端口。如果你用 `PORT` 环境变量改过端口(比如 `PORT=8080`),两处都要替换: + +```bash +ssh -L 8080:127.0.0.1:8080 root@123.45.67.89 +``` + +--- + +## 原理说明 + +- **为什么限制本机/内网?** 面板部署到公网后,任何人都能访问 URL。如果不限制,攻击者可以在你之前打开页面、设置一个密码,把你的面板锁死。 +- **本机/内网如何判断?** 后端检查客户端 IP 是否属于 `127.0.0.1 / ::1 / 10.x / 192.168.x / 172.16-31.x`。 +- **SSH 转发为什么有效?** `-L` 把本机端口通过 SSH 隧道转发到服务器的 `127.0.0.1`,等同于在服务器本地访问,客户端 IP 变成 `127.0.0.1`,通过校验。 +- **反向代理注意:** 若面板在 Nginx 等反代之后,需正确配置 `X-Forwarded-For` 头,后端据此取真实客户端 IP。 + +--- + +## 两种方式怎么选 + +| | 环境变量 | SSH 转发 | +|---|---|---| +| 操作 | 改一行配置 + 重启 | 一条 ssh 命令 | +| 需要改配置 | 是 | 否 | +| 适合 | Docker / 自动化部署 / 不熟 SSH | 临时设密码 / 能 SSH 到服务器 | +| 后续改密码 | UI(`设置 → 修改密码`) | 同左 | + +推荐**方式一(环境变量)**,一次配置即可,Docker 部署尤其方便。 diff --git a/refer/frontend/index.html b/refer/frontend/index.html index 15f1563..7f38b92 100644 --- a/refer/frontend/index.html +++ b/refer/frontend/index.html @@ -1,11 +1,11 @@ - + - A股工作台 + TickFlow Stock Panel · Quant Terminal diff --git a/refer/frontend/package-lock.json b/refer/frontend/package-lock.json deleted file mode 100644 index 0b1b0ee..0000000 --- a/refer/frontend/package-lock.json +++ /dev/null @@ -1,3041 +0,0 @@ -{ - "name": "stock-panel-frontend", - "version": "0.1.44", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "stock-panel-frontend", - "version": "0.1.44", - "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-query": "^5.55.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "echarts": "^5.5.0", - "echarts-for-react": "^3.0.2", - "framer-motion": "^11.5.0", - "lightweight-charts": "^4.2.0", - "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": "^25.9.3", - "@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" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", - "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", - "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "license": "MIT", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.1", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", - "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", - "license": "MIT", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.3.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tanstack/query-core": { - "version": "5.101.1", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.101.1.tgz", - "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.101.1", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.101.1.tgz", - "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.101.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.9.4", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.4.tgz", - "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.5.2", - "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.5.2.tgz", - "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.4", - "caniuse-lite": "^1.0.30001799", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/echarts": { - "version": "5.6.0", - "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", - "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "2.3.0", - "zrender": "5.6.1" - } - }, - "node_modules/echarts-for-react": { - "version": "3.0.6", - "resolved": "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.6.tgz", - "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "size-sensor": "^1.0.1" - }, - "peerDependencies": { - "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", - "react": "^15.0.0 || >=16.0.0" - } - }, - "node_modules/echarts/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.378", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", - "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fancy-canvas": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fancy-canvas/-/fancy-canvas-2.1.0.tgz", - "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/framer-motion": { - "version": "11.18.2", - "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-11.18.2.tgz", - "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", - "license": "MIT", - "dependencies": { - "motion-dom": "^11.18.1", - "motion-utils": "^11.18.1", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lightweight-charts": { - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/lightweight-charts/-/lightweight-charts-4.2.3.tgz", - "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", - "license": "Apache-2.0", - "dependencies": { - "fancy-canvas": "2.1.0" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.439.0", - "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.439.0.tgz", - "integrity": "sha512-PafSWvDTpxdtNEndS2HIHxcNAbd54OaqSYJO90/b63rab2HWYqDbH194j0i82ZFdWOAcf0AHinRykXRRK2PJbw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/motion-dom": { - "version": "11.18.1", - "resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-11.18.1.tgz", - "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", - "license": "MIT", - "dependencies": { - "motion-utils": "^11.18.1" - } - }, - "node_modules/motion-utils": { - "version": "11.18.1", - "resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-11.18.1.tgz", - "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/size-sensor": { - "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/size-sensor/-/size-sensor-1.0.3.tgz", - "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwind-merge": { - "version": "2.6.1", - "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.1.tgz", - "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/zrender": { - "version": "5.6.1", - "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", - "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", - "license": "BSD-3-Clause", - "dependencies": { - "tslib": "2.3.0" - } - }, - "node_modules/zrender/node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" - } - } -} diff --git a/refer/frontend/package.json b/refer/frontend/package.json index ddcc83f..20ff333 100644 --- a/refer/frontend/package.json +++ b/refer/frontend/package.json @@ -1,7 +1,7 @@ { - "name": "stock-panel-frontend", + "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.44", + "version": "0.1.70", "type": "module", "scripts": { "dev": "vite", diff --git a/refer/frontend/src/components/AccessGuard.tsx b/refer/frontend/src/components/AccessGuard.tsx deleted file mode 100644 index 15374c9..0000000 --- a/refer/frontend/src/components/AccessGuard.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' -import { api } from '@/lib/api' - -function getStoredToken(): string | null { - try { return localStorage.getItem('access_token') } catch { return null } -} - -export function AccessGuard({ children, requireAdmin = false }: { children: React.ReactNode; requireAdmin?: boolean }) { - const location = useLocation() - const token = getStoredToken() - - const { data: status, isLoading } = useQuery({ - queryKey: ['access-auth-status', token], - queryFn: () => api.accessAuthStatus(), - // 即使 token 为空也查询一次,用于确认服务端是否启用了门控 - enabled: true, - staleTime: 5 * 60 * 1000, - }) - - if (isLoading) { - return ( -
- -
- ) - } - - // 未启用门控,直接放行 - if (!status?.enabled) { - return <>{children} - } - - // 未验证,跳转到登录页 - if (!status.verified) { - return - } - - // 需要管理员权限但当前非管理员 - if (requireAdmin && status.role !== 'admin') { - return - } - - return <>{children} -} diff --git a/refer/frontend/src/components/AdminGuard.tsx b/refer/frontend/src/components/AdminGuard.tsx deleted file mode 100644 index fc9e9ea..0000000 --- a/refer/frontend/src/components/AdminGuard.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Navigate } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' -import { api } from '@/lib/api' - -function getStoredRole(): string | null { - try { return localStorage.getItem('access_role') } catch { return null } -} - -export function AdminGuard({ children }: { children: React.ReactNode }) { - const { data: status, isLoading } = useQuery({ - queryKey: ['access-auth-status'], - queryFn: () => api.accessAuthStatus(), - enabled: true, - staleTime: 5 * 60 * 1000, - }) - - if (isLoading) { - return ( -
- -
- ) - } - - // 未启用门控或本地角色为 admin,均放行 - const localRole = getStoredRole() - if (!status?.enabled || localRole === 'admin' || status.role === 'admin') { - return <>{children} - } - - return -} diff --git a/refer/frontend/src/components/AlertToast.tsx b/refer/frontend/src/components/AlertToast.tsx index cf2f898..bb9fc1a 100644 --- a/refer/frontend/src/components/AlertToast.tsx +++ b/refer/frontend/src/components/AlertToast.tsx @@ -161,10 +161,10 @@ export function AlertToastContainer() { {ev.price != null && {fmtPrice(ev.price)}}
) : ( -
+
+ {/* message 已含「条件摘要 · 现价 · 涨跌幅」(后端生成), 直接展示避免重复 */} {ev.message && {ev.message}} - {ev.price != null && {fmtPrice(ev.price)}}
)} diff --git a/refer/frontend/src/components/EChartsCandlestick.tsx b/refer/frontend/src/components/EChartsCandlestick.tsx index af51f06..f7bd977 100644 --- a/refer/frontend/src/components/EChartsCandlestick.tsx +++ b/refer/frontend/src/components/EChartsCandlestick.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useCallback, useMemo } from 'react' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' -import { useChartTheme, type ChartTheme } from '@/lib/chartTheme' export interface OHLC { date: string @@ -294,25 +293,19 @@ interface Props { activeIndicators?: string[] } -function getTHEME(ct: ChartTheme) { - return { - bull: '#C74040', - bear: '#2D9B65', - bullAlpha: 'rgba(240,68,56,0.7)', - bearAlpha: 'rgba(18,183,106,0.7)', - ma5: '#A1A1AA', - ma10: '#3B82F6', - ma20: '#F97316', - ma60: '#8B5CF6', - text: ct.text, - grid: ct.splitLine, - border: ct.axisLine, - bg: 'transparent', - tooltipBg: ct.tooltipBg, - tooltipBorder: ct.tooltipBorder, - tooltipText: ct.tooltipText, - crosshair: ct.crosshair, - } +const THEME = { + bull: '#C74040', + bear: '#2D9B65', + bullAlpha: 'rgba(240,68,56,0.7)', + bearAlpha: 'rgba(18,183,106,0.7)', + ma5: '#A1A1AA', + ma10: '#3B82F6', + ma20: '#F97316', + ma60: '#8B5CF6', + text: '#A1A1AA', + grid: 'rgba(255,255,255,0.04)', + border: '#27272A', + bg: 'transparent', } /** 可见蜡烛超过此数量时,涨停/炸板标签切换为小圆点。 */ @@ -328,7 +321,6 @@ function buildSubInfoGraphics( infoIdx: number, activeIndicators: string[], subStartTop: number, - theme: ReturnType, ): any[] { const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null const graphics: any[] = [] @@ -357,7 +349,7 @@ function buildSubInfoGraphics( id: `sub-sep-${key}`, type: 'line', shape: { x1: 0, y1: curTop, x2: 2000, y2: curTop }, - style: { stroke: theme.grid, lineWidth: 1 }, + style: { stroke: 'rgba(255,255,255,0.08)', lineWidth: 1 }, silent: true, z: 0, }) graphics.push({ @@ -422,7 +414,6 @@ function buildOption( containerHeight: number, infoIdx: number, linkedPrice: number | null | undefined, - theme: ReturnType, ): EChartsOption { const candleData = data.map(d => [d.open, d.close, d.low, d.high]) @@ -438,7 +429,7 @@ function buildOption( const isSell = m.kind === 'sell' if (m.above) { - const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text) + const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text) if (compact) { markPointData.push({ name: m.date, coord: [m.date, d.high], @@ -466,11 +457,11 @@ function buildOption( symbol: 'arrow', symbolSize: 12, symbolRotate: isBuy ? 0 : 180, symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'], - itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text }, + itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text }, label: { show: !!m.label, formatter: m.label ?? '', position: isBuy ? 'bottom' : 'top', distance: 8, - color: theme.text, fontSize: 10, + color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace', }, }) @@ -506,8 +497,8 @@ function buildOption( grids.push({ left, right, top: topPad, height: candleAvail }) xAxes.push({ type: 'category', data: dates, boundaryGap: true, - axisLine: { lineStyle: { color: theme.border } }, - axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' }, + axisLine: { lineStyle: { color: THEME.border } }, + axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' }, axisTick: { show: false }, splitLine: { show: false }, }) @@ -517,8 +508,8 @@ function buildOption( boundaryGap: [0.03, 0.03], splitArea: { show: false }, axisLine: { show: false }, axisTick: { show: false }, - splitLine: { lineStyle: { color: theme.grid } }, - axisLabel: { color: theme.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' }, + splitLine: { lineStyle: { color: THEME.grid } }, + axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' }, }) xAxisIndices.push(0) @@ -533,9 +524,9 @@ function buildOption( show: !!r.label, position: 'insideTop', distance: 8, - color: theme.tooltipText, - backgroundColor: theme.tooltipBg, - borderColor: theme.tooltipBorder, + color: '#DBEAFE', + backgroundColor: 'rgba(15,23,42,0.72)', + borderColor: 'rgba(59,130,246,0.35)', borderWidth: 1, borderRadius: 4, padding: [2, 6], @@ -550,7 +541,7 @@ function buildOption( .filter(line => Number.isFinite(line.value)) .map(line => { const lineStyle = { - color: line.color ?? theme.text, + color: line.color ?? THEME.text, type: 'dashed' as const, width: 1, opacity: 0.92, @@ -559,8 +550,8 @@ function buildOption( show: !!line.label, formatter: line.label ?? '', position: 'insideEndTop' as const, - color: line.color ?? theme.text, - backgroundColor: theme.tooltipBg, + color: line.color ?? THEME.text, + backgroundColor: 'rgba(15,23,42,0.72)', borderRadius: 4, padding: [2, 6], fontSize: 10, @@ -586,7 +577,7 @@ function buildOption( color: '#3B82F6', fontSize: 10, fontFamily: 'JetBrains Mono, monospace', - backgroundColor: theme.tooltipBg, + backgroundColor: 'rgba(24,24,27,0.85)', borderColor: '#3B82F6', borderWidth: 1, padding: [1, 4], @@ -600,8 +591,8 @@ function buildOption( name: 'K', type: 'candlestick', data: candleData, animation: false, itemStyle: { - color: theme.bull, color0: theme.bear, - borderColor: theme.bull, borderColor0: theme.bear, + color: THEME.bull, color0: THEME.bear, + borderColor: THEME.bull, borderColor0: THEME.bear, cursor: 'pointer', }, markPoint: markPointData.length > 0 ? { data: markPointData, animation: false } : undefined, @@ -617,10 +608,10 @@ function buildOption( silent: true, lineStyle: { width: 1, color }, itemStyle: { color }, }) - series.push(maLine('ma5', theme.ma5, 'MA5')) - series.push(maLine('ma10', theme.ma10, 'MA10')) - series.push(maLine('ma20', theme.ma20, 'MA20')) - series.push(maLine('ma60', theme.ma60, 'MA60')) + series.push(maLine('ma5', THEME.ma5, 'MA5')) + series.push(maLine('ma10', THEME.ma10, 'MA10')) + series.push(maLine('ma20', THEME.ma20, 'MA20')) + series.push(maLine('ma60', THEME.ma60, 'MA60')) } // BOLL 布林带 — 需在 activeIndicators 中激活 @@ -651,7 +642,7 @@ function buildOption( top: chartTop, height: def.height, show: true, - borderColor: theme.grid, + borderColor: 'rgba(255,255,255,0.06)', borderWidth: 1, }) @@ -669,9 +660,9 @@ function buildOption( gridIndex: gridIdx, splitNumber: 2, axisLine: { show: false }, axisTick: { show: false }, - splitLine: { lineStyle: { color: theme.grid } }, + splitLine: { lineStyle: { color: THEME.grid } }, axisLabel: { - show: true, color: theme.text, fontSize: 9, + show: true, color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace', }, }) @@ -688,14 +679,14 @@ function buildOption( // 子图信息栏 graphic const subStartTop = topPad + candleAvail + candleBottomPad - const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop, theme) + const infoGraphics = buildSubInfoGraphics(data, infoIdx, activeIndicators, subStartTop) return { animation: false, - backgroundColor: theme.bg, + backgroundColor: THEME.bg, tooltip: { trigger: 'axis', - axisPointer: { type: 'cross', crossStyle: { color: theme.crosshair } }, + axisPointer: { type: 'cross', crossStyle: { color: '#555' } }, backgroundColor: 'transparent', borderWidth: 0, textStyle: { fontSize: 0 }, @@ -704,8 +695,7 @@ function buildOption( axisPointer: { link: [{ xAxisIndex: 'all' }], label: { - backgroundColor: theme.tooltipBg, - color: theme.tooltipText, + backgroundColor: '#333', fontFamily: 'JetBrains Mono, monospace', fontSize: 10, }, @@ -746,9 +736,6 @@ export function EChartsCandlestick({ visibleBars = 60, activeIndicators = [], }: Props) { - const chartTheme = useChartTheme() - const theme = useMemo(() => getTHEME(chartTheme), [chartTheme]) - const containerRef = useRef(null) const chartRef = useRef(null) const dataRef = useRef(data) @@ -767,8 +754,6 @@ export function EChartsCandlestick({ const chartHeightRef = useRef(300) const subTotalHRef = useRef(0) const getInfoBarHTMLRef = useRef<() => string>(() => '') - const themeRef = useRef(theme) - themeRef.current = theme // 强制刷新信息栏 DOM 的回调 const infoBarRef = useRef(null) @@ -780,7 +765,7 @@ export function EChartsCandlestick({ const chart = chartRef.current if (!chart) return const subStartTop = chartHeightRef.current - subTotalHRef.current - const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop, themeRef.current) + const infoGraphics = buildSubInfoGraphics(curData, idx, activeIndicatorsRef.current, subStartTop) if (infoGraphics.length > 0) { chart.setOption({ graphic: infoGraphics }, { lazyUpdate: true }) } @@ -829,19 +814,19 @@ export function EChartsCandlestick({ const prev = idx > 0 ? data[idx - 1] : null const chg = prev ? d.close - prev.close : 0 const isUp = chg >= 0 - const clr = isUp ? theme.bull : theme.bear + const clr = isUp ? THEME.bull : THEME.bear const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null let html = `
` - html += `${d.date}` - html += `` - html += `${d.open.toFixed(2)}` - html += `` - html += `${d.high.toFixed(2)}` - html += `` - html += `${d.low.toFixed(2)}` - html += `` + html += `${d.date}` + html += `` + html += `${d.open.toFixed(2)}` + html += `` + html += `${d.high.toFixed(2)}` + html += `` + html += `${d.low.toFixed(2)}` + html += `` html += `${d.close.toFixed(2)}` // 涨跌幅 (收盘后, 换手前; 和收间隔一些距离) if (prev) { @@ -849,18 +834,18 @@ export function EChartsCandlestick({ html += `${isUp ? '+' : ''}${chgPct.toFixed(2)}%` } if (turnoverRate != null) { - html += `换手` - html += `${turnoverRate.toFixed(2)}%` + html += `换手` + html += `${turnoverRate.toFixed(2)}%` } html += `
` // 第二行: MA + BOLL if (showMA) { html += `
` - if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` - if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` - if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` - if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` + if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` + if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` + if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` + if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` if (d.boll_upper != null && activeIndicators.includes('boll')) { html += `BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}` } @@ -868,7 +853,7 @@ export function EChartsCandlestick({ } return html - }, [data, stockInfo, showMA, activeIndicators, theme]) + }, [data, stockInfo, showMA, activeIndicators]) getInfoBarHTMLRef.current = getInfoBarHTML // data 变化时重置 infoIdx @@ -976,7 +961,7 @@ export function EChartsCandlestick({ const isBuy = m.kind === 'buy' const isSell = m.kind === 'sell' if (m.above) { - const dotColor = m.color ?? (isBuy ? '#FACC15' : theme.text) + const dotColor = m.color ?? (isBuy ? '#FACC15' : THEME.text) if (compact) { markPointData.push({ name: m.date, coord: [m.date, d.high], @@ -1004,11 +989,11 @@ export function EChartsCandlestick({ symbol: 'arrow', symbolSize: 12, symbolRotate: isBuy ? 0 : 180, symbolOffset: isBuy ? [0, '60%'] : [0, '-60%'], - itemStyle: { color: isBuy ? theme.bull : isSell ? theme.bear : theme.text }, + itemStyle: { color: isBuy ? THEME.bull : isSell ? THEME.bear : THEME.text }, label: { show: !!m.label, formatter: m.label ?? '', position: isBuy ? 'bottom' : 'top', distance: 8, - color: theme.text, fontSize: 10, + color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace', }, }) @@ -1036,7 +1021,6 @@ export function EChartsCandlestick({ activeIndicators, chartHeight, infoIdxRef.current, linkedPrice, - theme, ) chart.setOption(option, true) @@ -1054,7 +1038,7 @@ export function EChartsCandlestick({ if (infoEl) { infoEl.innerHTML = getInfoBarHTML() } - }, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML, theme]) + }, [data, markers, ranges, priceLines, linkedPrice, showMA, showMarkersProp, activeIndicators, chartHeight, dates, dateIndexMap, initialZoom, getInfoBarHTML]) // 渲染信息栏容器 (内容由 JS 直接写入) const initialHTML = useMemo(() => { @@ -1064,16 +1048,16 @@ export function EChartsCandlestick({ const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null let html = `
` - html += `${d.date}` - html += `` - html += `${d.open.toFixed(2)}` - html += `` - html += `${d.high.toFixed(2)}` - html += `` - html += `${d.low.toFixed(2)}` - html += `` + html += `${d.date}` + html += `` + html += `${d.open.toFixed(2)}` + html += `` + html += `${d.high.toFixed(2)}` + html += `` + html += `${d.low.toFixed(2)}` + html += `` const prevClose0 = data[idx-1]?.close ?? d.close - const clr0 = d.close >= prevClose0 ? theme.bull : theme.bear + const clr0 = d.close >= prevClose0 ? THEME.bull : THEME.bear html += `${d.close.toFixed(2)}` // 涨跌幅 (收盘后, 换手前; 和收间隔一些距离) if (idx > 0) { @@ -1081,29 +1065,30 @@ export function EChartsCandlestick({ html += `${chgPct0 >= 0 ? '+' : ''}${chgPct0.toFixed(2)}%` } if (turnoverRate != null) { - html += `换手` - html += `${turnoverRate.toFixed(2)}%` + html += `换手` + html += `${turnoverRate.toFixed(2)}%` } html += `
` if (showMA) { html += `
` - if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` - if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` - if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` - if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` + if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` + if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` + if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` + if (d.ma60 != null) html += `MA60:${Number(d.ma60).toFixed(2)}` if (d.boll_upper != null && activeIndicators.includes('boll')) { html += `BOLL:${Number(d.boll_upper).toFixed(2)}/${Number(d.ma20).toFixed(2)}/${Number(d.boll_lower).toFixed(2)}` } html += `
` } return html - }, [data, stockInfo, showMA, activeIndicators, theme]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return (
{/* 主图信息栏 — 内容由 JS 直接操作 innerHTML */} {showInfoBar && ( -
)} diff --git a/refer/frontend/src/components/EChartsIntraday.tsx b/refer/frontend/src/components/EChartsIntraday.tsx index 264bc7a..65bb6ca 100644 --- a/refer/frontend/src/components/EChartsIntraday.tsx +++ b/refer/frontend/src/components/EChartsIntraday.tsx @@ -2,26 +2,19 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' import type { MinuteKlineRow } from '@/lib/api' -import { useChartTheme, type ChartTheme } from '@/lib/chartTheme' type YMode = 'adaptive' | 'limit' -function getTHEME(ct: ChartTheme) { - return { - line: '#3B82F6', - areaFill: 'rgba(59,130,246,0.40)', - avgLine: '#F59E0B', - refLine: ct.crosshair, - volUp: 'rgba(240,68,56,0.6)', - volDown: 'rgba(18,183,106,0.6)', - text: ct.text, - grid: ct.splitLine, - border: ct.axisLine, - tooltipBg: ct.tooltipBg, - tooltipBorder: ct.tooltipBorder, - tooltipText: ct.tooltipText, - crosshair: ct.crosshair, - } +const THEME = { + line: '#3B82F6', + areaFill: 'rgba(59,130,246,0.40)', + avgLine: '#F59E0B', + refLine: 'rgba(255,255,255,0.25)', + volUp: 'rgba(240,68,56,0.6)', + volDown: 'rgba(18,183,106,0.6)', + text: '#A1A1AA', + grid: 'rgba(255,255,255,0.04)', + border: '#27272A', } interface Props { @@ -115,18 +108,7 @@ function getLimitPrices(prevClose: number, symbol?: string): { return { limitUp, limitDown, upPct, downPct } } -function buildOption( - data: MinuteKlineRow[], - prevClose: number | undefined, - avgPrices: number[], - lineColor: string, - areaColor: string, - yMode: YMode, - theme: ReturnType, - symbol?: string, - showLimitLines = true, - showAvgLine = true, -): EChartsOption { +function buildOption(data: MinuteKlineRow[], prevClose: number | undefined, avgPrices: number[], lineColor: string, areaColor: string, yMode: YMode, symbol?: string, showLimitLines = true, showAvgLine = true): EChartsOption { // 将数据映射到全天时间轴上的正确位置 const timeIndexMap = new Map(FULL_DAY_TIMES.map((t, i) => [t, i])) const closes = new Array(FULL_DAY_TIMES.length).fill(null) as (number | null)[] @@ -147,7 +129,7 @@ function buildOption( volumes[idx] = { value: data[i].volume, itemStyle: { - color: data[i].close > data[i].open ? theme.volUp : data[i].close < data[i].open ? theme.volDown : volNeutral, + color: data[i].close > data[i].open ? THEME.volUp : data[i].close < data[i].open ? THEME.volDown : volNeutral, }, } } @@ -168,7 +150,7 @@ function buildOption( if (prevClose != null) { markLineData.push({ yAxis: prevClose, - lineStyle: { color: theme.refLine, type: 'dashed', width: 1 }, + lineStyle: { color: THEME.refLine, type: 'dashed', width: 1 }, label: { show: false }, symbol: 'none', }) @@ -255,16 +237,16 @@ function buildOption( type: 'cross', label: { show: true, - backgroundColor: theme.tooltipBg, - borderColor: theme.tooltipBorder, + backgroundColor: 'rgba(39,39,42,0.9)', + borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, padding: [2, 5], - color: theme.text, + color: '#A1A1AA', fontSize: 10, fontFamily: 'JetBrains Mono, monospace', }, - crossStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, - lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + crossStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 }, + lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 }, }, }, axisPointer: { @@ -281,14 +263,14 @@ function buildOption( boundaryGap: false, axisPointer: { show: true, - lineStyle: { color: theme.crosshair, type: 'dashed', width: 1 }, + lineStyle: { color: 'rgba(255,255,255,0.2)', type: 'dashed', width: 1 }, label: { show: true, - backgroundColor: theme.tooltipBg, - borderColor: theme.tooltipBorder, + backgroundColor: 'rgba(39,39,42,0.9)', + borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, padding: [2, 4], - color: theme.text, + color: '#A1A1AA', fontSize: 10, fontFamily: 'JetBrains Mono, monospace', formatter: (params: any) => { @@ -298,7 +280,7 @@ function buildOption( }, axisLine: { show: false }, axisLabel: { - color: theme.text, + color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace', formatter: xAxisLabelFormatter, @@ -307,7 +289,7 @@ function buildOption( axisTick: { show: false }, splitLine: { show: true, - lineStyle: { color: theme.grid }, + lineStyle: { color: 'rgba(255,255,255,0.04)' }, }, }, { @@ -330,7 +312,7 @@ function buildOption( splitArea: { show: false }, axisLine: { show: false }, axisTick: { show: false }, - splitLine: { lineStyle: { color: theme.grid } }, + splitLine: { lineStyle: { color: THEME.grid } }, axisPointer: { label: { formatter: (params: any) => { @@ -340,7 +322,7 @@ function buildOption( }, }, axisLabel: { - color: theme.text, + color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace', formatter: (v: number) => v.toFixed(2), @@ -378,7 +360,7 @@ function buildOption( }, }, axisLabel: { - color: theme.text, + color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace', formatter: (v: number) => { @@ -409,7 +391,7 @@ function buildOption( smooth: false, symbol: 'none', cursor: 'crosshair', - lineStyle: { width: 1, color: theme.avgLine }, + lineStyle: { width: 1, color: THEME.avgLine }, connectNulls: true, }] : []), { @@ -425,9 +407,6 @@ function buildOption( } export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, onPriceHover, showLimitLines = true, showAvgLine = true }: Props) { - const chartTheme = useChartTheme() - const theme = useMemo(() => getTHEME(chartTheme), [chartTheme]) - const containerRef = useRef(null) const chartRef = useRef(null) const roRef = useRef(null) @@ -515,11 +494,11 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o } fullDayToDataIdx.current = mapping - chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, theme, symbol, showLimitLines, showAvgLine), true) + chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine), true) } else { chart.clear() } - }, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine, theme]) + }, [data, prevClose, height, lineColor, areaFill, yMode, symbol, showLimitLines, showAvgLine]) useEffect(() => { return () => { @@ -569,7 +548,7 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o
} -
+
{/* 第一行: 日期 + OHLC */}
{!d && } @@ -596,8 +575,8 @@ export function EChartsIntraday({ data, height = 320, prevClose, date, symbol, o {d.close.toFixed(2)} {showAvgLine && - - {avg?.toFixed(2)} + + {avg?.toFixed(2)} } {d.volume.toFixed(0)} diff --git a/refer/frontend/src/components/EndpointTestDialog.tsx b/refer/frontend/src/components/EndpointTestDialog.tsx index ac2e7e7..86bca85 100644 --- a/refer/frontend/src/components/EndpointTestDialog.tsx +++ b/refer/frontend/src/components/EndpointTestDialog.tsx @@ -22,7 +22,7 @@ export function EndpointTestDialog({ hasKey, tierLabel, currentEndpoint, onClose const [testing, setTesting] = useState>({}) const [switching, setSwitching] = useState(null) - // 动态加载端点清单 —— 前端无法跨域直连数据源官网,走后端代理 + // 动态加载端点清单 —— 前端无法跨域直连 tickflow.org,走后端代理 const { data, isLoading } = useQuery({ queryKey: QK.endpoints, queryFn: api.listEndpoints, diff --git a/refer/frontend/src/components/LastStockChip.tsx b/refer/frontend/src/components/LastStockChip.tsx new file mode 100644 index 0000000..dd29f58 --- /dev/null +++ b/refer/frontend/src/components/LastStockChip.tsx @@ -0,0 +1,31 @@ +import { Clock } from 'lucide-react' +import type { StockRef } from '@/lib/useLastStock' + +/** + * "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。 + * 上方名称、下方代码,小字体二排;点击恢复该个股的查看。 + */ +export function LastStockChip({ + stock, + onSelect, +}: { + stock: StockRef | null + onSelect?: (symbol: string, name: string) => void +}) { + if (!stock) return null + return ( + + ) +} diff --git a/refer/frontend/src/components/Layout.tsx b/refer/frontend/src/components/Layout.tsx index dc5f159..e46dbbe 100644 --- a/refer/frontend/src/components/Layout.tsx +++ b/refer/frontend/src/components/Layout.tsx @@ -5,6 +5,10 @@ import { motion } from 'framer-motion' import { useQuoteStream } from '@/lib/useQuoteStream' import { ToastContainer } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' +import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' +import { AiReportBubble } from '@/components/financials/AiReportBubble' +import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' +import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' import { useCapabilities, useSettings, @@ -18,23 +22,37 @@ import { import { QK } from '@/lib/queryKeys' import { tierRank } from '@/lib/capability-labels' import { + Star, + ScanSearch, + History, + FileText, Settings, Key, Database, - Timer, Loader2, LayoutDashboard, + Tags, + TrendingUp, + Flame, + BarChart3, Sparkles, + Layers3, + Landmark, + Cable, + RadioTower, CheckCircle2, + BookOpenCheck, + ExternalLink, + X, } from 'lucide-react' import { Logo } from './Logo' -import { useTheme } from './ThemeProvider' import { api, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge' // 品牌色 — 只用于 logo / brand 区域,不影响功能语义色 const BRAND = '#8B5CF6' +const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA' const CORE_INDEXES = [ { symbol: '000001.SH', name: '上证指数' }, @@ -46,8 +64,20 @@ const CORE_INDEXES = [ type CoreIndex = (typeof CORE_INDEXES)[number] const nav = [ - { to: '/', label: '看板', icon: LayoutDashboard }, - { to: '/data', label: '数据', icon: Database }, + { to: '/', label: '看板', icon: LayoutDashboard }, + { to: '/watchlist', label: '自选', icon: Star }, + { to: '/screener', label: '策略', icon: ScanSearch }, + { to: '/backtest', label: '回测', icon: History }, + { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, + { to: '/limit-ladder', label: '连板梯队', icon: Flame }, + { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, + { to: '/industry-analysis', label: '行业分析', icon: Landmark }, + { to: '/financials', label: '财务分析', icon: FileText }, + { to: '/monitor', label: '监控中心', icon: RadioTower }, + { to: '/review', label: '复盘', icon: BookOpenCheck }, + { to: '/indices', label: '指数', icon: BarChart3 }, + { to: '/trading', label: '交易', icon: Cable }, + { to: '/data', label: '数据', icon: Database }, ] as const function fmtIndexValue(v: number | null | undefined) { @@ -130,7 +160,7 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { labelTextStyle: { color: '#71717a' }, }, free: { - desc: '基础日K · 单股查询', + desc: '基础日K · 自选实时', tagBg: { background: 'rgba(113,113,122,0.3)' }, dotStyle: { background: '#71717a' }, labelTextStyle: { color: '#a1a1aa' }, @@ -156,8 +186,8 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) { } const t = tierConfig[base] || tierConfig.none - // none 档显示中文「无」,无 label 时显示「无档」 - const displayLabel = isNone ? '无' : (label || '无') + // none 档显示英文「None」,无 label 时也显示「None」 + const displayLabel = isNone ? 'None' : (label || 'None') return (
- 数据源 + TickFlow p.symbol) const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol)) @@ -281,8 +317,10 @@ export function Layout() { const toggleQuote = useToggleRealtimeQuotes() const isRunning = quoteStatus?.running ?? false const isTrading = quoteStatus?.is_trading_hours ?? false - // none/free 档(无实时行情权限)→ rank < starter(1) - const isFreeTier = tierRank(caps?.label ?? '') < 1 + const tier = tierRank(caps?.label ?? '') + const isNoneTier = tier < 0 + const isWatchlistMode = tier === 0 + const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场' // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒) const alertsTotalQuery = useQuery({ @@ -298,8 +336,10 @@ export function Layout() { if (alertsTotal != null) setAlertTotal(alertsTotal) }, [alertsTotal]) - // 当前仅开放看板和数据业务,扩展分析菜单暂不展示 - const analysisNav: { to: string; label: string; icon: typeof LayoutDashboard }[] = [] + // 合并内置页面 + 可见的扩展分析菜单 + const analysisNav = (analysisMenus?.items ?? []) + .filter(m => m.visible) + .map(m => ({ to: `/analysis/${m.id}`, label: m.label, icon: m.icon === 'tags' ? Tags : BarChart3 })) const allNav = [...nav, ...analysisNav] const savedOrder = prefs?.nav_order ?? [] @@ -325,7 +365,12 @@ export function Layout() { queryKey: QK.capabilities, queryFn: api.capabilities, }) - if (tierRank(fresh.label ?? '') < 1) return + const freshTier = tierRank(fresh.label ?? '') + if (freshTier < 0) return + if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) { + navigate('/watchlist') + return + } } await toggleQuote.mutateAsync(enabled) // 仅在交易时段立即获取一次行情 @@ -342,20 +387,20 @@ export function Layout() {
-
A股
-
工作台
+
TickFlow
+
Stock Panel
- 量化工具 + Quant · Terminal
- {settingsState?.mode === 'none' && ( - - )} +
@@ -393,6 +436,12 @@ export function Layout() { <> {label} + {/* 个股分析 Beta 标识 */} + {(to === '/stock-analysis' || to === '/review') && ( + + Beta + + )} {/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */} {to === '/data' && isDataSyncing && ( @@ -410,13 +459,27 @@ export function Layout() { {/* 全局行情开关 */}
- {isFreeTier ? ( - /* Free 档位 — 显示升级提示 */ -
- 实时行情 - - 需 Starter+ - + {isNoneTier ? ( +
+
+ 实时行情 + + Free+ + +
+
+ 免费注册 + + TickFlow + + + 开启个股监控 +
) : ( /* Starter+ — 开关 + 跳转设置 */ @@ -430,14 +493,14 @@ export function Layout() { : 'bg-muted' }`} /> - 实时行情 + 实时行情 · {realtimeModeLabel}
+
+ )} {isRunning && isTrading ? ( - 行情运行中 +
行情运行中
) : realtimeEnabled && !isTrading ? ( - 非交易时段,将在交易时间自动开启 +
非交易时段,将在交易时间自动开启
) : null}
)} - {showSidebarQuotes && !isFreeTier && ( + {showSidebarQuotes && !isWatchlistMode && !isNoneTier && ( )}
@@ -504,6 +579,10 @@ export function Layout() { + + + +
) } diff --git a/refer/frontend/src/components/Logo.tsx b/refer/frontend/src/components/Logo.tsx index d86e515..cc4baff 100644 --- a/refer/frontend/src/components/Logo.tsx +++ b/refer/frontend/src/components/Logo.tsx @@ -22,7 +22,7 @@ export function Logo({ className, size = 32, style }: LogoProps) { className={className} style={style} role="img" - aria-label="Stock Panel" + aria-label="TickFlow Stock Panel" > {/* 左方括号 */} void +} + +const DEFAULT_DAYS = 12 +const ROW_HEIGHT = 30 // 每行高度(px), 与单元格样式配合 +const OVERSCAN = 8 // 上下额外渲染行数, 减少滚动时的白屏闪烁 +const MIN_DAYS = 7 +const MAX_DAYS = 30 + +// 涨幅 → 背景色梯度(A 股语义: 红涨绿跌)。强度越大色越深, 一眼看出强势/弱势概念 +function pctBgClass(pct: number): string { + if (pct >= 0.05) return 'bg-bull/25' + if (pct >= 0.03) return 'bg-bull/18' + if (pct >= 0.01) return 'bg-bull/10' + if (pct > -0.01) return '' + if (pct > -0.03) return 'bg-bear/10' + if (pct > -0.05) return 'bg-bear/18' + return 'bg-bear/25' +} + +// 把 "2026-07-01" 格式化成 "7/01" 紧凑显示(表头窄列) +function shortDate(s: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) + if (!m) return s + return `${Number(m[2])}/${m[3]}` +} + +// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。 +// total 兜底: 概念总数未知时只判前 10, 不判后 10。 +function rankColorClass(rank: number, total: number): string { + if (rank <= 10) return 'text-bull' + if (total > 20 && rank > total - 10) return 'text-bear' + return 'text-accent' +} + +export function RpsRotationDialog({ onClose }: Props) { + const [days, setDays] = useState(DEFAULT_DAYS) + const [reversed, setReversed] = useState(false) // false=高→低, true=低→高 + const [selected, setSelected] = useState(null) // 点中的概念名, 高亮追踪 + + // ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ---- + const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告 + const [analyzing, setAnalyzing] = useState(false) // 生成中 + const [analysisError, setAnalysisError] = useState('') // 错误信息 + const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null) + const [focus, setFocus] = useState('') // 用户追加的关注点 + + const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => { + setAnalyzing(true) + setAnalysis('') + setAnalysisError('') + setAnalysisMeta(null) + try { + for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) { + if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary }) + else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? '')) + else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误') + // done: 无操作 + } + } catch (e) { + setAnalysisError(e instanceof Error ? e.message : String(e)) + } finally { + setAnalyzing(false) + } + }, []) + + // 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开 + const { data, isLoading, error } = useQuery({ + queryKey: QK.rpsRotation(days), + queryFn: () => api.rpsRotation(days), + staleTime: 5 * 60 * 1000, + }) + + const dates = data?.dates ?? [] + const columns = data?.columns ?? {} + const conceptCount = data?.concept_count ?? 0 + + // 行数 = 最长那列的长度(理论上每天概念数应一致, 取最大兜底) + const rowCount = useMemo( + () => dates.reduce((m, d) => Math.max(m, columns[d]?.length ?? 0), 0), + [dates, columns], + ) + + // 行索引: 翻转时不重排数据, 只翻转访问索引(省一次大数组操作) + const getRowIndex = useCallback( + (displayIdx: number) => (reversed ? rowCount - 1 - displayIdx : displayIdx), + [reversed, rowCount], + ) + + // ---- 手写虚拟滚动 ---- + // 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。 + // 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。 + const scrollRef = useRef(null) + // AI 报告区滚动容器: 流式生成时自动滚到底部 + const analysisRef = useRef(null) + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 }) + + // 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字 + useEffect(() => { + if (!analyzing) return + const el = analysisRef.current + if (el) el.scrollTop = el.scrollHeight + }, [analysis, analyzing]) + + const handleScroll = useCallback(() => { + const el = scrollRef.current + if (!el) return + const scrollTop = el.scrollTop + const viewportH = el.clientHeight + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN) + const end = Math.min(rowCount, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN) + setVisibleRange(prev => (prev.start === start && prev.end === end ? prev : { start, end })) + }, [rowCount]) + + useEffect(() => { + // rowCount 变化(切天数/数据到达)时重算可视范围 + handleScroll() + }, [handleScroll, rowCount]) + + // ESC 关闭 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + // 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。 + // 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。 + // 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。 + const selectedRow = useMemo(() => { + if (!selected) return null + const cells: ({ rank: number; pct: number } | null)[] = [] + for (const d of dates) { + const col = columns[d] ?? [] + const idx = col.findIndex(([name]) => name === selected) + cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null) + } + return cells + }, [selected, dates, columns]) + + const renderRows = useMemo(() => { + const rows: JSX.Element[] = [] + for (let displayIdx = visibleRange.start; displayIdx < visibleRange.end; displayIdx++) { + const rawIdx = getRowIndex(displayIdx) + const cells = dates.map((d) => { + const cell = columns[d]?.[rawIdx] + if (!cell) { + return ( + + + + ) + } + const [name, pct] = cell + const isSelected = selected === name + return ( + setSelected(prev => prev === name ? null : name)} + className={cn( + 'px-2 py-1 cursor-pointer whitespace-nowrap text-center align-middle transition-colors', + pctBgClass(pct), + isSelected && 'ring-1 ring-inset ring-accent bg-accent/20', + )} + > +
+ {name} + 0 ? 'text-bull' : pct < 0 ? 'text-bear' : 'text-muted', + )}>{fmtPct(pct)} +
+ + ) + }) + rows.push( + + + {displayIdx + 1} + + {cells} + , + ) + } + return rows + }, [visibleRange, getRowIndex, dates, columns, selected]) + + return ( + + { if (e.target === e.currentTarget) onClose() }} + > + + {/* 标题栏 */} +
+
+ + 概念涨幅轮动 + + {conceptCount > 0 ? `${dates.length} 天 · ${conceptCount} 个概念` : '暂无数据'} + +
+ +
+ + {/* 上半区: AI 轮动分析 */} +
+ {/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */} +
+ + AI 轮动分析 + {analysisMeta?.summary && ( + {analysisMeta.summary} + )} +
+ setFocus(e.target.value)} + placeholder="关注点(可选)" + disabled={analyzing} + className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50" + /> + +
+
+ + {/* 报告内容区: 四态渲染 */} +
+ {analysisError ? ( +
+ + {analysisError} + +
+ ) : analysis || analyzing ? ( +
+ + {analyzing && ( + + )} +
+ ) : ( +
+ 点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动 +
+ )} +
+
+ + {/* 工具栏 */} +
+
+ 天数 + setDays(Number(e.target.value))} + className="w-24 accent-accent cursor-pointer" + /> + {days} +
+ + {selected && ( + + )} +
+ + {/* 下半区: 涨幅轮动矩阵(虚拟滚动) */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+ 加载失败,请稍后重试 +
+ ) : rowCount === 0 ? ( +
+ 暂无概念数据,请先在「概念分析」页配置并获取概念数据源 +
+ ) : ( +
+ + {/* 表头: 日期列, 最新在最左 */} + + + + {dates.map(d => ( + + ))} + + {/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */} + + {selected && selectedRow && ( + + + {selectedRow.map((cell, i) => ( + + ))} + + )} + + + + {/* 顶部占位: 把滚动位置撑起来 */} + {visibleRange.start > 0 && ( + + + )} + {renderRows} + {/* 底部占位 */} + {visibleRange.end < rowCount && ( + + + )} + +
+ # + + {shortDate(d)} +
+ + {selected} + + + {cell ? ( +
+ + #{cell.rank} + + 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted', + )}> + {fmtPct(cell.pct)} + +
+ ) : ( + + )} +
+
+
+
+ )} +
+ + {/* 底部提示 */} +
+ + 每列各自按当日涨幅排序 · 点击单元格追踪概念在各日的排名变化 + +
+ + + + ) +} diff --git a/refer/frontend/src/components/SealedBadge.tsx b/refer/frontend/src/components/SealedBadge.tsx index b70b966..191ba6d 100644 --- a/refer/frontend/src/components/SealedBadge.tsx +++ b/refer/frontend/src/components/SealedBadge.tsx @@ -31,7 +31,7 @@ function SealedDirBlock({ title, color, counts, rawTotal }: {
真封 {real} - 假 {fake} + 假 {fake} {pending > 0 && ( 待 {pending} )} @@ -77,11 +77,11 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea
{showHint && ( diff --git a/refer/frontend/src/components/StockIntradayChart.tsx b/refer/frontend/src/components/StockIntradayChart.tsx index d252a9b..e4fbb0a 100644 --- a/refer/frontend/src/components/StockIntradayChart.tsx +++ b/refer/frontend/src/components/StockIntradayChart.tsx @@ -42,7 +42,7 @@ export function StockIntradayChart({ }) const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows]) - // source=none 表示本地无数据且数据源也拉不到 (停牌/复牌延迟/非交易日) + // source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日) // 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据 const sourceIsNone = minute.data?.source === 'none' diff --git a/refer/frontend/src/components/ThemeProvider.tsx b/refer/frontend/src/components/ThemeProvider.tsx deleted file mode 100644 index 1c2fb88..0000000 --- a/refer/frontend/src/components/ThemeProvider.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react' -import { storage } from '@/lib/storage' - -export type Theme = 'light' | 'dark' | 'system' -export type ResolvedTheme = 'light' | 'dark' - -interface ThemeContextValue { - theme: Theme - resolved: ResolvedTheme - setTheme: (theme: Theme) => void -} - -const ThemeContext = createContext(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' ? '#8B5CF6' : '#FAFAFA') -} - -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState(() => { - const saved = storage.theme.get('system') - return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system' - }) - const [resolved, setResolved] = useState(() => resolveTheme(theme)) - - useEffect(() => { - const nextResolved = resolveTheme(theme) - setResolved(nextResolved) - - const root = document.documentElement - root.classList.remove('light', 'dark') - root.classList.add(nextResolved) - storage.theme.set(theme) - 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 ( - - {children} - - ) -} - -export function useTheme(): ThemeContextValue { - const ctx = useContext(ThemeContext) - if (!ctx) { - throw new Error('useTheme must be used within ThemeProvider') - } - return ctx -} diff --git a/refer/frontend/src/components/WarmupBadge.tsx b/refer/frontend/src/components/WarmupBadge.tsx new file mode 100644 index 0000000..2f36d84 --- /dev/null +++ b/refer/frontend/src/components/WarmupBadge.tsx @@ -0,0 +1,106 @@ +/** + * 回测预热期徽标 — 点击弹出说明气泡。 + * + * 解释「回测开头几个月没有交易」这一高频疑问: 技术指标需要历史数据预热, + * 系统会自动在回测起点之前多取约 120 天 (≈4 个月) 数据; 若本地数据恰好从 + * 起点才开始, 开头几个月指标算不出、信号不触发, 属正常现象。 + * + * 实现要点: + * - 点击触发 (非 hover), 移动端友好 + * - 用 createPortal 渲染到 body, 绕开父容器 overflow 裁剪 (回测配置面板有 overflow-y-auto) + * - 全屏透明遮罩点击关闭 + ESC 关闭 + * - 气泡位置 = 锚点 rect 实时计算, 自动判断向左/向右展开避免溢出屏幕 + */ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { AnimatePresence, motion } from 'framer-motion' +import { Info } from 'lucide-react' + +interface Pos { top: number; left: number } + +export function WarmupBadge() { + const [open, setOpen] = useState(false) + const anchorRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0 }) + + // 打开时根据锚点 rect 计算气泡位置 (向下方弹出) + useLayoutEffect(() => { + if (!open || !anchorRef.current) return + const rect = anchorRef.current.getBoundingClientRect() + const POPUP_W = 272 + const GAP = 8 + // 优先左对齐锚点; 右侧不够则右对齐; 兜底贴左边 + let left = rect.left + if (left + POPUP_W > window.innerWidth - 8) { + left = rect.right - POPUP_W + } + left = Math.max(8, left) + setPos({ top: rect.bottom + GAP, left }) + }, [open]) + + // ESC 关闭 + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open]) + + return ( + <> + + + {createPortal( + + {open && ( + <> + {/* 全屏透明遮罩: 点击关闭 */} +
setOpen(false)} + /> + {/* 气泡: 绝对定位到 body, 绕开 overflow 裁剪 */} + e.stopPropagation()} + > +
为什么开头几个月可能没有交易?
+

+ 技术指标 (MA / MACD / RSI 等) 需要历史数据才能算出。系统会自动在回测起点之前多取约 + 120 天 (≈4 个月) 数据做预热。 +

+

+ 若本地数据恰好从回测起点才开始, 开头几个月指标算不出、信号不触发, + 属正常现象, 不是 bug。等数据攒够后自然开始产生交易。 +

+
+ 解决: 把历史数据补到回测起点之前至少半年, 或把起点往后挪。 +
+ {/* 小箭头指向锚点 */} +
+ + + )} + , + document.body, + )} + + ) +} diff --git a/refer/frontend/src/components/analysis-shared.tsx b/refer/frontend/src/components/analysis-shared.tsx index 5a1ad33..6680f2b 100644 --- a/refer/frontend/src/components/analysis-shared.tsx +++ b/refer/frontend/src/components/analysis-shared.tsx @@ -12,9 +12,12 @@ import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' import { + AlertCircle, BarChart3, ChevronDown, Database, + DownloadCloud, + RefreshCw, Search, Settings2, Tags, @@ -449,3 +452,50 @@ export function ConfigButton({ onClick }: { onClick: () => void }) { ) } + +/** + * 内置预设 (概念/行业) 数据获取空状态。 + * + * 当检测到内置预设存在但无数据时, 展示图标 + 提示 + 「获取数据」按钮, + * 让用户手动触发拉取 (POST /api/ext-data/presets/{id}/fetch), 而非自动拉取。 + */ +export function PresetFetchState({ + title, + hint, + isLoading, + error, + onFetch, +}: { + title: string + hint: string + isLoading: boolean + error: unknown + onFetch: () => void +}) { + const errMsg = error instanceof Error ? error.message : error ? String(error) : '' + return ( +
+
+ +

{title}

+

{hint}

+ + {errMsg && ( +

+ {errMsg} +

+ )} +
+
+ ) +} diff --git a/refer/frontend/src/components/data/ActiveJobCard.tsx b/refer/frontend/src/components/data/ActiveJobCard.tsx index 025227a..69cd447 100644 --- a/refer/frontend/src/components/data/ActiveJobCard.tsx +++ b/refer/frontend/src/components/data/ActiveJobCard.tsx @@ -8,7 +8,7 @@ import type { PipelineJob } from '@/lib/api' export const STAGE_LABELS: Record = { init: '初始化', resolve_universe: '解析标的池', - sync_instruments: '同步标的维表', + sync_instruments: '同步个股维表', sync_daily: '同步日 K', sync_adj: '同步除权因子', compute_enriched: '计算技术指标', diff --git a/refer/frontend/src/components/data/PageSettingsModal.tsx b/refer/frontend/src/components/data/PageSettingsModal.tsx new file mode 100644 index 0000000..86abaae --- /dev/null +++ b/refer/frontend/src/components/data/PageSettingsModal.tsx @@ -0,0 +1,253 @@ +import { useState } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Check, GripVertical } from 'lucide-react' +import { storage } from '@/lib/storage' + +export type CardKey = + | 'instruments' | 'daily' | 'adj_factor' | 'enriched' + | 'index' | 'etf' | 'minute' | 'financials' + +interface CardDef { + key: CardKey + label: string + desc: string + /** 档位能力不足时该卡片是否默认隐藏(减少干扰) */ + defaultHiddenIfNoCap: boolean + /** 无条件默认隐藏(用户可在设置里手动开启) */ + defaultHidden?: boolean +} + +/** 数据画像卡片定义 —— 默认顺序即此数组顺序 */ +export const DATA_CARD_DEFS: CardDef[] = [ + { key: 'instruments', label: '个股维表', desc: 'A 股股票元数据', defaultHiddenIfNoCap: false }, + { key: 'daily', label: '日 K', desc: 'A 股日K线数据', defaultHiddenIfNoCap: false }, + { key: 'adj_factor', label: '除权因子', desc: '复权计算因子', defaultHiddenIfNoCap: true }, + { key: 'enriched', label: 'Enriched', desc: '技术指标计算结果', defaultHiddenIfNoCap: false }, + { key: 'index', label: '指数', desc: '主要市场指数日K', defaultHiddenIfNoCap: false }, + { key: 'etf', label: 'ETF', desc: '场内交易基金日K', defaultHiddenIfNoCap: false, defaultHidden: true }, + { key: 'minute', label: '分钟 K', desc: '分钟级K线(需 Pro+)', defaultHiddenIfNoCap: true }, + { key: 'financials', label: '财务数据', desc: '财报数据(需 Expert)', defaultHiddenIfNoCap: true }, +] + +const DEFAULT_ORDER = DATA_CARD_DEFS.map(d => d.key) +/** 恢复默认时显示的卡片数量(按默认顺序取前 N 张) */ +const DEFAULT_VISIBLE_COUNT = 5 + +const CAP_KEY_MAP: Partial> = { + adj_factor: 'adj_factor', + minute: 'kline.minute.batch', + financials: 'financial', +} + +/** + * 读取卡片显隐状态。结合档位能力决定默认值: + * - 用户显式设置过 → 用设置值 + * - 未设置 + defaultHidden → 隐藏(无条件默认隐藏) + * - 未设置 + defaultHiddenIfNoCap + 当前无能力 → 隐藏 + * - 其他 → 显示 + */ +export function getCardVisibility( + caps: Record | undefined, +): Record { + const has = (capKey: string) => !capKey || !!caps?.[capKey] + const override = storage.dataCardVisible.get({}) + const result: Record = {} + for (const def of DATA_CARD_DEFS) { + if (def.key in override) { + result[def.key] = override[def.key] + } else if (def.defaultHidden) { + result[def.key] = false + } else { + result[def.key] = def.defaultHiddenIfNoCap ? has(CAP_KEY_MAP[def.key] ?? '') : true + } + } + return result +} + +/** + * 读取卡片显示顺序。 + * - 用户拖拽设置过 → 用设置值(过滤掉已不存在的 key, 补齐新增的 key) + * - 未设置 → 用 DATA_CARD_DEFS 默认顺序 + */ +export function getCardOrder(): CardKey[] { + const saved = storage.dataCardOrder.get([]) + if (!saved.length) return [...DEFAULT_ORDER] + const known = new Set(DEFAULT_ORDER) + const ordered = saved.filter(k => known.has(k as CardKey)) as CardKey[] + // 补齐新增的 key(默认顺序里新增的卡片追加到末尾) + for (const k of DEFAULT_ORDER) { + if (!ordered.includes(k)) ordered.push(k) + } + return ordered +} + +export function PageSettingsModal({ + caps, +}: { + caps: Record | undefined +}) { + const [visible, setVisible] = useState>(() => getCardVisibility(caps)) + const [order, setOrder] = useState(() => getCardOrder()) + + const persistVisible = (next: Record) => { + setVisible(next) + storage.dataCardVisible.set(next) + window.dispatchEvent(new CustomEvent('data-card-visible-change')) + } + const persistOrder = (next: CardKey[]) => { + setOrder(next) + storage.dataCardOrder.set(next) + window.dispatchEvent(new CustomEvent('data-card-visible-change')) + } + + const toggle = (key: CardKey) => persistVisible({ ...visible, [key]: !(visible[key] ?? true) }) + + const reset = () => { + // 恢复默认: 默认顺序 + 仅勾选前 5 张卡片, 其余隐藏 + const defaultOrder = [...DEFAULT_ORDER] + const defaultVisible: Record = {} + defaultOrder.forEach((k, i) => { defaultVisible[k] = i < DEFAULT_VISIBLE_COUNT }) + storage.dataCardVisible.set(defaultVisible) + storage.dataCardOrder.set(defaultOrder) + setVisible(defaultVisible) + setOrder(defaultOrder) + window.dispatchEvent(new CustomEvent('data-card-visible-change')) + } + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const oldIdx = order.indexOf(active.id as CardKey) + const newIdx = order.indexOf(over.id as CardKey) + if (oldIdx < 0 || newIdx < 0) return + persistOrder(arrayMove(order, oldIdx, newIdx)) + } + + // 按 order 排序卡片定义 + const defByKey = new Map(DATA_CARD_DEFS.map(d => [d.key, d])) + const orderedDefs = order.map(k => defByKey.get(k)!).filter(Boolean) + + return ( +
+

+ 拖动手柄调整卡片顺序,勾选控制显隐。未勾选的卡片将隐藏,不影响数据本身。 +

+ + +
+ {orderedDefs.map((def) => { + const on = visible[def.key] ?? true + return ( + toggle(def.key)} + /> + ) + })} +
+
+
+
+ +
+
+ ) +} + +// ── 可拖拽的卡片行 ── +function SortableCardRow({ + id, label, desc, on, onToggle, +}: { + id: CardKey + label: string + desc: string + on: boolean + onToggle: () => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + zIndex: isDragging ? 10 : undefined, + } + + return ( +
+ {/* 拖拽手柄 */} + + {/* 显隐勾选 */} + +
+
{label}
+
{desc}
+
+
+ ) +} diff --git a/refer/frontend/src/components/data/PipelineScopeConfig.tsx b/refer/frontend/src/components/data/PipelineScopeConfig.tsx new file mode 100644 index 0000000..fa54297 --- /dev/null +++ b/refer/frontend/src/components/data/PipelineScopeConfig.tsx @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Check, Loader2 } from 'lucide-react' +import { api } from '@/lib/api' +import { QK } from '@/lib/queryKeys' + +type PullKey = 'pipeline_pull_a_share' | 'pipeline_pull_etf' | 'pipeline_pull_index' + +interface ScopeItem { + key: PullKey + label: string + desc: string + defaultOn: boolean +} + +const ITEMS: ScopeItem[] = [ + { key: 'pipeline_pull_a_share', label: 'A股', desc: '沪深京 A 股日K(约 5500 只)', defaultOn: true }, + { key: 'pipeline_pull_index', label: '指数', desc: '主要市场指数(默认全量约 600 只)', defaultOn: true }, + { key: 'pipeline_pull_etf', label: 'ETF', desc: '场内交易基金(约 1500 只,首次较慢)', defaultOn: false }, +] + +export function PipelineScopeConfig() { + const qc = useQueryClient() + const prefs = useQuery({ queryKey: QK.preferences, queryFn: api.preferences }) + + const updateToggle = useMutation({ + mutationFn: (cfg: Partial>) => api.updatePipelinePullTypes(cfg), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.preferences }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) + }, + }) + + const getValue = (key: PullKey, def: boolean) => prefs.data?.[key] ?? def + + return ( +
+

+ 勾选盘后管道每次自动拉取的数据类型。仅影响后续同步,已存储的历史数据不受影响。 +

+
+ {ITEMS.map((item) => { + const locked = item.key === 'pipeline_pull_a_share' + const on = locked || getValue(item.key, item.defaultOn) + return ( +
+ +
+ ) + })} +
+ {updateToggle.isPending && ( +
+ 保存中… +
+ )} +
+ 数据通道基于免费接口,所有档位均可拉取。 +
+
+ ) +} diff --git a/refer/frontend/src/components/data/SchemaModal.tsx b/refer/frontend/src/components/data/SchemaModal.tsx index 0a42867..8f9d05e 100644 --- a/refer/frontend/src/components/data/SchemaModal.tsx +++ b/refer/frontend/src/components/data/SchemaModal.tsx @@ -4,7 +4,7 @@ import { api, type EnrichedField } from '@/lib/api' import { QK } from '@/lib/queryKeys' const TABLE_TITLES: Record = { - instruments: '标的维表', + instruments: '个股维表', daily: '日 K', adj_factor: '除权因子', enriched: 'Enriched', @@ -12,6 +12,9 @@ const TABLE_TITLES: Record = { index_instruments: '指数维表', index_daily: '指数日 K', index_enriched: '指数 Enriched', + etf_instruments: 'ETF 维表', + etf_daily: 'ETF 日 K', + etf_enriched: 'ETF Enriched', } function categorize(name: string): string { diff --git a/refer/frontend/src/components/data/StatCard.tsx b/refer/frontend/src/components/data/StatCard.tsx index 3b95777..3e51498 100644 --- a/refer/frontend/src/components/data/StatCard.tsx +++ b/refer/frontend/src/components/data/StatCard.tsx @@ -5,7 +5,7 @@ import { fmtDate } from '@/lib/format' import { Skeleton } from './Skeleton' // 卡片能力定义:capKey → 查 capability limits;tierReq → 无权限时显示的档位要求 -// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。 +// capKey 为空串表示该数据在 free-api 服务器(None 档/Free 档)即可获取,无需付费能力门控。 export const CARD_META: Record(null) const [testResult, setTestResult] = useState<{ total_rows: number; preview: Record[]; has_symbol: boolean } | null>(null) const [error, setError] = useState('') - const handleSave = () => { - let headers: Record | undefined - if (headerStr.trim()) { - try { headers = JSON.parse(headerStr) } - catch { setError('Headers 不是有效 JSON'); return } - } - let field_map: Record | undefined - if (fieldMapStr.trim()) { - try { field_map = JSON.parse(fieldMapStr) } - catch { setError('字段映射不是有效 JSON'); return } - } - setSaving(true); setError('') - api.extDataPullConfig(config.id, { + // 解析 JSON 输入, 失败时设置 error 并返回 null + const parseJson = (str: string, label: string): Record | undefined | null => { + if (!str.trim()) return undefined + try { return JSON.parse(str) } + catch { setError(`${label} 不是有效 JSON`); return null } + } + + // 构建保存 payload (复用当前编辑态), enabledOverride 用于开关自动保存 + const buildPayload = (enabledOverride?: boolean) => { + const headers = parseJson(headerStr, 'Headers') + if (headers === null) return null + const field_map = parseJson(fieldMapStr, '字段映射') + if (field_map === null) return null + return { url, method, headers, body: body || undefined, response_path: responsePath, field_map, - schedule_minutes: schedule, enabled, - }).then(() => onSaved()) + schedule_minutes: schedule, enabled: enabledOverride ?? enabled, + } + } + + const handleSave = (silent = false) => { + const payload = buildPayload() + if (!payload) return + setSaving(true); setError('') + api.extDataPullConfig(config.id, payload) + .then(() => { + onSaved() + if (!silent) toast('配置已保存', 'success') + }) .catch(e => setError(e.message || '保存失败')) .finally(() => setSaving(false)) } const handleTest = () => { setTesting(true); setError(''); setTestResult(null) - let headers: Record | undefined - if (headerStr.trim()) { - try { headers = JSON.parse(headerStr) } - catch { setError('Headers 不是有效 JSON'); setTesting(false); return } - } - let field_map: Record | undefined - if (fieldMapStr.trim()) { - try { field_map = JSON.parse(fieldMapStr) } - catch { setError('字段映射不是有效 JSON'); setTesting(false); return } - } - api.extDataPullConfig(config.id, { - url, method, headers, body: body || undefined, - response_path: responsePath, field_map, - schedule_minutes: schedule, enabled, - }).then(() => api.extDataPullTest(config.id)) + const payload = buildPayload() + if (!payload) { setTesting(false); return } + api.extDataPullConfig(config.id, payload) + .then(() => api.extDataPullTest(config.id)) .then(r => { setTestResult(r); onSaved() }) .catch(e => setError(e.message || '测试失败')) .finally(() => setTesting(false)) } const handleRun = () => { - setRunning(true); setError('') + setRunning(true); setError(''); setRunResult(null) api.extDataPullRun(config.id) - .then(() => onSaved()) + .then(r => { + setRunResult({ rows: r.rows, date: r.date }) + onSaved() + toast(`拉取成功 · ${r.rows} 行`, 'success') + }) .catch(e => setError(e.message || '执行失败')) .finally(() => setRunning(false)) } + // 开关 toggle: 自动保存全量配置 (切换 enabled), 后端 refresh 后立即首次拉取 + const [toggling, setToggling] = useState(false) + const handleToggle = (next: boolean) => { + if (toggling) return + if (next && !url.trim()) { + toast('请先填写拉取 URL', 'error') + return + } + const payload = buildPayload(next) + if (!payload) return + setToggling(true); setError(''); setEnabled(next) + api.extDataPullConfig(config.id, payload) + .then(() => { + onSaved() + toast(next ? '定时拉取已启用 · 立即执行首次拉取' : '定时拉取已关闭', 'success') + }) + .catch(e => { + setEnabled(!next) // 回滚 + setError(e.message || '切换失败') + }) + .finally(() => setToggling(false)) + } + + // 格式化时间显示 + const fmtTime = (iso: string | null | undefined) => { + if (!iso) return null + const d = new Date(iso) + if (isNaN(d.getTime())) return null + const mm = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + const hh = String(d.getHours()).padStart(2, '0') + const mi = String(d.getMinutes()).padStart(2, '0') + return `${mm}-${dd} ${hh}:${mi}` + } + return ( -
-
- - setUrl(e.target.value)} - placeholder="https://api.example.com/data" - className="flex-1 min-w-0 rounded-md border border-border bg-elevated px-2.5 py-1.5 text-[11px] font-mono text-foreground placeholder:text-muted/50" - /> -
+
+ {/* ===== 分区 ①: 请求配置 ===== */} +
+
+ + 请求配置 +
-
-
Headers (JSON,可选)
-