Compare commits

...
2 Commits
46 changed files with 1847 additions and 96 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.github
.gitea
**/node_modules
**/dist
**/.expo
**/.env
**/.env.*
**/*.log
bin
logs
.cache
.idea
.DS_Store
**/__pycache__
+90
View File
@@ -0,0 +1,90 @@
name: Veyra CI/CD
on:
push:
branches: [master]
env:
REMOTE_DIR: /home/workspace/sundynix-veyra
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout exact commit from Gitea
env:
GITEA_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
"https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \
| tar -xz --strip-components=1
- name: Check App, embedded admin, Go and PostgreSQL integration
run: bash deploy/check.sh
deploy:
needs: verify
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout verified commit from Gitea
env:
GITEA_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
"https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \
| tar -xz --strip-components=1
- name: Build and package immutable image
env:
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
docker build -t "sundynix/veyra:${COMMIT_SHA}" .
docker save "sundynix/veyra:${COMMIT_SHA}" | gzip > image.tar.gz
sha256sum image.tar.gz > image.tar.gz.sha256
- name: Install SSH tools
run: |
set -eu
if command -v sshpass >/dev/null && command -v ssh >/dev/null; then exit 0; fi
if command -v apk >/dev/null; then
apk add --no-cache sshpass openssh-client
elif command -v apt-get >/dev/null; then
apt-get update -qq
apt-get install -y -qq --no-install-recommends sshpass openssh-client
else
echo 'Runner requires ssh and sshpass' >&2
exit 1
fi
- name: Transfer and deploy
env:
SSHPASS: ${{ secrets.DEPLOY_PASSWORD }}
DEPLOY_HOST: 192.168.100.132
DEPLOY_USER: root
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
VEYRA_ENV_FILE: ${{ secrets.VEYRA_ENV_FILE }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
: "${SSHPASS:?DEPLOY_PASSWORD is required}"
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
: "${DEPLOY_USER:?DEPLOY_USER is required}"
: "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS is required}"
[[ "$DEPLOY_HOST" =~ ^[a-zA-Z0-9.-]+$ && "$DEPLOY_USER" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 2
[[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2
known_hosts="$(mktemp)"
trap 'rm -f "$known_hosts"' EXIT
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$known_hosts"
chmod 600 "$known_hosts"
options=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts" -o ConnectTimeout=15)
target="$DEPLOY_USER@$DEPLOY_HOST"
release="$REMOTE_DIR/releases/$COMMIT_SHA"
sshpass -e ssh "${options[@]}" "$target" "umask 077; mkdir -p '$release' '$REMOTE_DIR/configs'"
sshpass -e scp "${options[@]}" deploy/bootstrap-config.sh "$target:$release/"
# Bootstrap once via secret; never replace an existing server configuration.
if [[ -n "$VEYRA_ENV_FILE" ]]; then
printf '%s\n' "$VEYRA_ENV_FILE" | sshpass -e ssh "${options[@]}" "$target" \
"bash '$release/bootstrap-config.sh' '$REMOTE_DIR'"
fi
sshpass -e scp "${options[@]}" image.tar.gz image.tar.gz.sha256 deploy/docker-compose.yml deploy/release.sh "$target:$release/"
sshpass -e ssh "${options[@]}" "$target" "bash '$release/release.sh' '$REMOTE_DIR' '$COMMIT_SHA'"
+3 -1
View File
@@ -1,5 +1,7 @@
name: Checks name: Checks
on: [push, pull_request] on:
push:
branches: [master]
jobs: jobs:
verify: verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4
View File
@@ -18,3 +18,7 @@ internal/adminui/dist/*
expo-env.d.ts expo-env.d.ts
/logs/ /logs/
# CI 临时镜像归档和 Python 缓存
/image.tar.gz*
**/__pycache__/
+2
View File
@@ -23,3 +23,5 @@
- 后端环境文件与模板位于 configs/;管理端和 App 分别维护自己的 npm 配置、锁文件与依赖,不在根目录建立 npm workspace。 - 后端环境文件与模板位于 configs/;管理端和 App 分别维护自己的 npm 配置、锁文件与依赖,不在根目录建立 npm workspace。
- 后端日志使用 zap:控制台易读,当前运行目录 logs/ 下自动写文件与轮转;日志不提交,不记录密钥、口令、Cookie 或请求正文。 - 后端日志使用 zap:控制台易读,当前运行目录 logs/ 下自动写文件与轮转;日志不提交,不记录密钥、口令、Cookie 或请求正文。
- 开发在 dev 分支进行,禁止直接提交或推送 master(管理员也须遵守);通过合并请求进入 master。CI/CD 仅监听 master 的 push,包含合并提交;dev 提交不触发部署。
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS admin
WORKDIR /src/web/admin
COPY web/admin/package*.json ./
RUN npm ci
COPY web/admin/ ./
RUN npm run build
FROM node:22-bookworm-slim AS mobile-check
WORKDIR /src/apps/mobile
ENV CI=1 EXPO_PUBLIC_API_URL=https://example.invalid
COPY apps/mobile/package*.json ./
RUN npm ci
COPY apps/mobile/ ./
RUN npm run typecheck && npm run test && npm run build
FROM golang:1.26.2-bookworm AS test
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=admin /src/internal/adminui/dist ./internal/adminui/dist
RUN test -z "$(gofmt -l main.go internal)" && go vet ./...
CMD ["go", "test", "-race", "./..."]
FROM test AS build
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/veyra .
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata python3 curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 veyra && useradd --uid 10001 --gid 10001 --no-create-home veyra
WORKDIR /app
ENV TZ=Asia/Shanghai HTTP_ADDR=0.0.0.0:8080 PYTHON_EXECUTABLE=python3 PYTHONDONTWRITEBYTECODE=1
COPY --from=build /out/veyra ./veyra
RUN mkdir logs && chown veyra:veyra logs
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \
CMD curl --fail --silent --output /dev/null http://127.0.0.1:8080/readyz || exit 1
ENTRYPOINT ["/app/veyra"]
+20 -4
View File
@@ -94,7 +94,7 @@ npm --prefix apps/mobile run start
### 后台供应商配置 ### 后台供应商配置
「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,当前尚未实现供应商调用与连通性测试。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。 「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,模型调用与连通性测试尚未实现;行情适配器已接通,实际状态见数据健康页面。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。
`SECRET_ENCRYPTION_KEY` 是部署层加密主密钥,使用 Base64 编码的 32 字节随机值,在本地 `configs/.env` 或部署秘密配置中持久保存。本机已生成此值,仓库模板留空。可用 `openssl rand -base64 32` 为新环境生成;主密钥必须与数据库一起备份,切勿在已有密文后直接替换。未配置主密钥时仍可保存无密钥的关闭配置,提交密钥会被拒绝。 `SECRET_ENCRYPTION_KEY` 是部署层加密主密钥,使用 Base64 编码的 32 字节随机值,在本地 `configs/.env` 或部署秘密配置中持久保存。本机已生成此值,仓库模板留空。可用 `openssl rand -base64 32` 为新环境生成;主密钥必须与数据库一起备份,切勿在已有密文后直接替换。未配置主密钥时仍可保存无密钥的关闭配置,提交密钥会被拒绝。
@@ -144,9 +144,9 @@ go.mod / go.sum 根目录唯一 Go module
## 待完成 ## 待完成
2026-10-08 已验证 PostgreSQL、Redis 内网连接,应用全部四版迁移并初始化开发管理员。真实 PostgreSQL 集成测试已在临时 schema 中执行;测试不会修改开发账户或供应商配置。 2026-10-09 已验证 PostgreSQL、Redis 内网连接,应用全部十四版迁移并初始化开发管理员。真实 PostgreSQL 集成测试已在临时 schema 中执行;测试不会修改开发账户或供应商配置。
尚未接入候选审核、交易计划、成交账本、行情、AI、Worker 或回测。App 已有基础界面和登录/账户真实 API,偏好、机会、计划、持仓明细、提醒与复盘仍待相应业务接口。下一步建设候选修订、审核发布与计划闭环,再落实成交账本和风险监控。 候选审核、计划预算、不可覆盖模拟成交与冲正、FIFO 持仓与账本复盘、站内提醒、公开行情、异步 Python 回测和自动日线筛选已接通。App 对应 API 与页面已实现,真机流程仍待验证。AI、偏好/关注、正式认证、远程推送与生产交易规则尚未完成;详细验证事实见 docs/development-state.md。
管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。 管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。
@@ -154,6 +154,22 @@ go.mod / go.sum 根目录唯一 Go module
后端使用 zap,默认在运行目录创建 `logs/veyra.log`,控制台易读、文件 JSON,自动轮转压缩。通过 `configs/.env` 的 `LOG_*` 配置目录、级别和保留规则。 后端使用 zap,默认在运行目录创建 `logs/veyra.log`,控制台易读、文件 JSON,自动轮转压缩。通过 `configs/.env` 的 `LOG_*` 配置目录、级别和保留规则。
后台「系统设置」配置公开行情适配器与地址,启用后启动同步、每五分钟更新沪深报价;「数据健康」查询股票和同步结果。研究回测可直接按代码、交易所和日期获取不复权日线,无需逐只新建股票或导入 CSV。当前公开源为开发研究用途;供应商时间、历史快照与失败状态如实展示。策略筛选、正式行情授权和实际交易规则仍待接入。 后台「系统设置」配置公开行情适配器与地址,启用后启动同步、每五分钟更新沪深报价;「数据健康」查询股票和同步结果。研究回测可直接按代码、交易所和日期获取不复权日线,无需逐只新建股票或导入 CSV。当前公开源为开发研究用途;供应商时间、历史快照与失败状态如实展示。可配置策略筛选已接通;正式行情授权和完整实际交易规则仍待接入。
App 在「机会」→「查看自动同步的 A 股行情」查看数据。当前预览 Metro `exp://192.168.100.2:8081`,API 为 `http://192.168.100.2:8083`,同一内网使用 Expo Go;开发身份读取后端本地 `DEV_USER_*` 配置。尚未完成真机界面验证。 App 在「机会」→「查看自动同步的 A 股行情」查看数据。当前预览 Metro `exp://192.168.100.2:8081`,API 为 `http://192.168.100.2:8083`,同一内网使用 Expo Go;开发身份读取后端本地 `DEV_USER_*` 配置。尚未完成真机界面验证。
## 自动候选筛选与提醒
管理端「策略中心」修订研究版本,勾选自动候选筛选,从自动同步股票搜索选择沪深范围(最多 100 只),配置整数基点价格规则和有效时长,再选用此版本。已有策略默认不启用此功能,价格规则留空由后台配置。无需手工新建股票或逐条录入候选证据。
Worker 每轮最多处理 20 个目标,完成后间隔 5 分钟再次检查。仅使用完整未复权日线,最近报价观察不超过 24 小时,来源与日期必须一致。符合 SMA 上穿规则时自动提交候选待审核;没有信号或数据不足不生成候选。候选经人工审核发布后才进入 App「机会」。参数、日线快照、来源与版本摘要可在筛选记录中查看,不代表已验证策略收益。
暂停或切换策略版本会使其自动候选及未完成计划失效并释放保留预算;手工候选独立维护,成交事实与持仓保留。行情同步为有效计划生成近期报价事件,只允许最近一分钟的供应商报价触发一次站内提醒,重复报价及失效来源跳过。提醒不产生成交、不改变资金,五分钟轮询不保证捕获所有瞬时行情。
管理端总览现以股票为核心,展示实际行情、证券代码、日线与待审核信号;“股票行情”提供股票池与历史走势,“候选审核”可立即请求自动筛选并查看运行状态。固定范围每批最多检查 20 只;重复点击运行中的任务不会另起 Worker。后台已启用开发基线的固定 96 只范围,并从真实日线生成两条待审核候选,尚未自动发布。参数仅用于开发研究,可在策略中心修订。
## CI/CD 自动部署
开发使用 `dev`,通过 PR 合并进入受保护的 `master`;仅 `master` push 触发 CI/CD。Gitea Runner 验证后,通过 SSH 部署内嵌管理端的 Go 镜像至 `192.168.100.132:/home/workspace/sundynix-veyra`,执行版本化迁移、依赖就绪检查与应用回滚。详见 [部署说明](deploy/README.md)。
+143 -8
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Veyra API title: Veyra API
version: 0.12.0 version: 0.15.0
description: 开发身份、账户、供应商配置、候选、计划、成交账本、站内通知、研究策略、Python 回测与公开 A 股开发行情。模拟工作流仅限 local/test;正式行情授权、完整交易规则及远程推送未完成。 description: 开发身份、账户、供应商配置、候选、计划、成交账本、站内通知、研究策略、Python 回测与公开 A 股开发行情。模拟工作流仅限 local/test;正式行情授权、完整交易规则及远程推送未完成。
servers: servers:
- url: http://127.0.0.1:8080 - url: http://127.0.0.1:8080
@@ -1538,6 +1538,91 @@ paths:
$ref: "#/components/responses/Error" $ref: "#/components/responses/Error"
"503": "503":
$ref: "#/components/responses/Error" $ref: "#/components/responses/Error"
/api/admin/v1/strategy-screenings:
get:
tags:
- Admin
summary: 最近自动日线筛选记录
description: 管理员专用。只记录已完成筛选;不满足数据时效或来源失败时不生成候选。SIGNAL 提交到待审核,不自动发布。
security:
- adminCookie: []
responses:
"200": &a3
description: 成功
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"503":
$ref: "#/components/responses/Error"
/api/admin/v1/strategy-screenings/{id}:
get:
tags:
- Admin
summary: 查看自动筛选不可覆盖的参数与日线输入快照
description: 管理员专用。只记录已完成筛选;不满足数据时效或来源失败时不生成候选。SIGNAL 提交到待审核,不自动发布。
security:
- adminCookie: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200": *a3
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"404":
$ref: "#/components/responses/Error"
"503":
$ref: "#/components/responses/Error"
/api/admin/v1/strategy-screening-status:
get:
operationId: getScreeningWorkerState
summary: 当前进程的最近一批筛选状态
description: 状态与数量仅对应当前进程最近一批;持久化结果使用 strategy-screenings。
security:
- adminCookie: []
responses:
"200":
description: 成功
content: &a5
application/json:
schema:
$ref: "#/components/schemas/Envelope"
"401": &a4
$ref: "#/components/responses/Error"
"403": *a4
"503": *a4
/api/admin/v1/strategy-screenings/run:
post:
operationId: requestStrategyScreening
summary: 请求立即自动筛选
description: 同源管理员请求,异步执行。运行中或已排队时复用现有请求,不另起 Worker。每批最多 20 只,固定范围最大 100 只;不自动发布候选。
security:
- adminCookie: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EmptyObject"
responses:
"202":
description: 已排队或已有正在执行的任务
content: *a5
"401": *a4
"403": *a4
"422": *a4
"503": *a4
components: components:
securitySchemes: securitySchemes:
adminCookie: adminCookie:
@@ -2219,7 +2304,7 @@ components:
code: *a2 code: *a2
name: *a2 name: *a2
description: *a2 description: *a2
expected_version: &a3 expected_version: &a6
type: integer type: integer
parameters: parameters:
type: object type: object
@@ -2230,11 +2315,13 @@ components:
- slippage_bps - slippage_bps
- lot_size - lot_size
properties: properties:
short_window: *a3 short_window: *a6
long_window: *a3 long_window: *a6
fee_bps: *a3 fee_bps: *a6
slippage_bps: *a3 slippage_bps: *a6
lot_size: *a3 lot_size: *a6
screening:
$ref: "#/components/schemas/ScreeningParameters"
StrategyDeployment: StrategyDeployment:
type: object type: object
required: required:
@@ -2248,7 +2335,7 @@ components:
enum: enum:
- ACTIVE - ACTIVE
- PAUSED - PAUSED
expected_version: *a3 expected_version: *a6
reason: *a2 reason: *a2
HistoricalBar: HistoricalBar:
type: object type: object
@@ -2318,3 +2405,51 @@ components:
code: *a2 code: *a2
message: *a2 message: *a2
request_id: *a2 request_id: *a2
ScreeningParameters:
type: object
additionalProperties: false
required:
- securities
- entry_band_bps
- stop_loss_bps
- target_bps
- valid_hours
properties:
securities:
type: array
minItems: 1
maxItems: 100
uniqueItems: true
items:
type: object
additionalProperties: false
required:
- symbol
- exchange
properties:
symbol:
type: string
pattern: ^[0-9]{6}$
exchange:
type: string
enum:
- SSE
- SZSE
entry_band_bps:
type: integer
minimum: 0
maximum: 1000
stop_loss_bps:
type: integer
minimum: 1
maximum: 5000
description: 必须大于 entry_band_bps
target_bps:
type: integer
minimum: 1
maximum: 10000
description: 必须大于 entry_band_bps
valid_hours:
type: integer
minimum: 1
maximum: 168
+1 -1
View File
@@ -7,5 +7,5 @@ import type {Candidate} from "../../src/lib/candidates"
export default function Opportunities(){ export default function Opportunities(){
const {session,client}=useSession(),router=useRouter() const {session,client}=useSession(),router=useRouter()
const query=useQuery({queryKey:["candidates",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Candidate[]>("/api/v1/candidates",{signal}),refetchInterval:30000}) const query=useQuery({queryKey:["candidates",session?.user.id],enabled:!!session,queryFn:({signal})=>client<Candidate[]>("/api/v1/candidates",{signal}),refetchInterval:30000})
return <Screen><Title>机会</Title><Body>从证据、反证与失效条件理解一个候选。</Body><Panel accent><Body>模拟研究候选 · 由管理端手工录入并审核发布,当前未接入自动行情筛选。</Body></Panel><Button secondary title="查看自动同步的 A 股行情" onPress={()=>router.push("/market")}/><Button secondary title={query.isFetching?"刷新中…":"刷新候选"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取已发布候选…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {query.data?.length===0&&<EmptyState title="当前没有有效候选" detail="审核发布且未过期的模拟候选会显示在这里。"/>}{query.data?.map(c=><Panel key={c.id}><View style={styles.row}><Text style={styles.subtitle}>{c.content.name}</Text><Text style={styles.badge}>模拟候选</Text></View><Body>{c.content.exchange}:{c.content.symbol} · 修订 {c.revision}</Body><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.thesis}</Body><Body>有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Button title="查看证据与风险" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:c.id}})}/></Panel>)}</Screen> return <Screen><Title>机会</Title><Body>从证据、反证与失效条件理解一个候选。</Body><Panel accent><Body>模拟研究候选 · 由后台自动日线筛选或手工录入,经人工审核后发布。自动筛选仅为研究线索。</Body></Panel><Button secondary title="查看自动同步的 A 股行情" onPress={()=>router.push("/market")}/><Button secondary title={query.isFetching?"刷新中…":"刷新候选"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取已发布候选…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {query.data?.length===0&&<EmptyState title="当前没有有效候选" detail="审核发布且未过期的模拟候选会显示在这里。"/>}{query.data?.map(c=><Panel key={c.id}><View style={styles.row}><Text style={styles.subtitle}>{c.content.name}</Text><Text style={styles.badge}>模拟候选</Text></View><Body>{c.content.exchange}:{c.content.symbol} · 修订 {c.revision}</Body><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.thesis}</Body><Body>有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Button title="查看证据与风险" onPress={()=>router.push({pathname:"/candidates/[id]",params:{id:c.id}})}/></Panel>)}</Screen>
} }
+1 -1
View File
@@ -10,5 +10,5 @@ export default function CandidateDetail(){
const query=useQuery({queryKey:["candidate",session?.user.id,id],enabled:!!session&&typeof id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(id)}`,{signal}),refetchInterval:15000}) const query=useQuery({queryKey:["candidate",session?.user.id,id],enabled:!!session&&typeof id==="string",queryFn:({signal})=>client<Candidate>(`/api/v1/candidates/${encodeURIComponent(id)}`,{signal}),refetchInterval:15000})
const c=query.data,router=useRouter() const c=query.data,router=useRouter()
async function open(url:string){setLinkError("");try{const parsed=new URL(url);if(parsed.protocol!=="https:"||parsed.username||parsed.password)throw new Error("证据链接无效。");await Linking.openURL(url)}catch{setLinkError("无法打开证据来源,请稍后重试。")}} async function open(url:string){setLinkError("");try{const parsed=new URL(url);if(parsed.protocol!=="https:"||parsed.username||parsed.password)throw new Error("证据链接无效。");await Linking.openURL(url)}catch{setLinkError("无法打开证据来源,请稍后重试。")}}
return <Screen><Title>{c?.content.name??"候选详情"}</Title><Button secondary title={query.isFetching?"刷新中…":"刷新状态"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取修订…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {c&&<><Text style={styles.badge}>模拟候选 · 修订 {c.revision}</Text>{!publicationAvailable(c)&&<Panel accent><Text style={styles.subtitle}>此候选已失效</Text><Body>{c.status==="WITHDRAWN"?c.withdrawal_reason||"管理员已撤回此修订。":"此修订已过期或已被新发布版本替代。"}历史依据保留供查看。</Body></Panel>}<Button title="预览模拟买入计划" disabled={!publicationAvailable(c)||query.isFetching||!!query.error} onPress={()=>router.push({pathname:"/plans/create",params:{candidate_id:c.id}})}/><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.exchange}:{c.content.symbol} · 观察 {new Date(c.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Panel><Text style={styles.subtitle}>观察逻辑</Text><Body>{c.content.thesis}</Body></Panel><Panel><Text style={styles.subtitle}>反证与风险</Text><Body>{c.content.counter_evidence}</Body><Text style={styles.subtitle}>失效条件</Text><Body>{c.content.invalidation}</Body></Panel><Text style={styles.subtitle}>证据来源</Text>{c.content.evidence.map((e,i)=><Panel key={i}><Text style={styles.subtitle}>{e.locator}</Text><Body>{e.summary}</Body><Button secondary title="打开原始来源" onPress={()=>void open(e.source_url)}/></Panel>)}<ErrorMessage message={linkError}/><Panel><Text style={styles.subtitle}>参考价格</Text><Body>区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</Body><Body>参考价格来自手工录入,当前不代表实时行情或已确认交易计划。</Body></Panel></>}</Screen> return <Screen><Title>{c?.content.name??"候选详情"}</Title><Button secondary title={query.isFetching?"刷新中…":"刷新状态"} disabled={query.isFetching} onPress={()=>void query.refetch()}/>{query.isPending&&<Body>正在读取修订…</Body>}{query.error&&<ErrorMessage message={query.error.message}/>} {c&&<><Text style={styles.badge}>模拟候选 · 修订 {c.revision}</Text>{!publicationAvailable(c)&&<Panel accent><Text style={styles.subtitle}>此候选已失效</Text><Body>{c.status==="WITHDRAWN"?c.withdrawal_reason||"管理员已撤回此修订。":"此修订已过期、被替代,或关联策略已暂停 / 切换版本。"}历史依据保留供查看。</Body></Panel>}<Button title="预览模拟买入计划" disabled={!publicationAvailable(c)||query.isFetching||!!query.error} onPress={()=>router.push({pathname:"/plans/create",params:{candidate_id:c.id}})}/><Text style={styles.subtitle}>{c.content.title}</Text><Body>{c.content.exchange}:{c.content.symbol} · 观察 {new Date(c.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(c.valid_until).toLocaleString("zh-CN")}</Body><Panel>{c.screening&&<Body>自动筛选 · {c.screening.strategy_name} 修订 {c.screening.strategy_revision} · 日线 {c.screening.bar_date} · {c.screening.source_adapter} 配置版本 {c.screening.source_version}</Body>}<Text style={styles.subtitle}>观察逻辑</Text><Body>{c.content.thesis}</Body></Panel><Panel><Text style={styles.subtitle}>反证与风险</Text><Body>{c.content.counter_evidence}</Body><Text style={styles.subtitle}>失效条件</Text><Body>{c.content.invalidation}</Body></Panel><Text style={styles.subtitle}>证据来源</Text>{c.content.evidence.map((e,i)=><Panel key={i}><Text style={styles.subtitle}>{e.locator}</Text><Body>{e.summary}</Body><Button secondary title="打开原始来源" onPress={()=>void open(e.source_url)}/></Panel>)}<ErrorMessage message={linkError}/><Panel><Text style={styles.subtitle}>参考价格</Text><Body>区间 ¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</Body><Body>风险价格 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</Body><Body>{c.screening&&c.screening.candidate_revision_id===c.id?"参考价格按后台策略规则从日线收盘价生成,经审核发布;不代表实时行情或已确认交易计划。":"参考价格经人工录入或修订,不代表实时行情或已确认交易计划。"}</Body></Panel></>}</Screen>
} }
+1 -1
View File
@@ -1,2 +1,2 @@
export type Candidate={id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}} export type Candidate={screening?:{candidate_revision_id:string;strategy_version_id:string;strategy_name:string;strategy_revision:number;bar_date:string;source_adapter:string;source_version:number;input_hash:string};id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";valid_until:string;observed_at:string;is_active_publication:boolean;withdrawal_reason:string;content:{symbol:string;exchange:string;name:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}}
export function publicationAvailable(candidate:Pick<Candidate,"status"|"is_active_publication"|"valid_until">,now=Date.now()){return candidate.status==="PUBLISHED"&&candidate.is_active_publication&&Date.parse(candidate.valid_until)>now} export function publicationAvailable(candidate:Pick<Candidate,"status"|"is_active_publication"|"valid_until">,now=Date.now()){return candidate.status==="PUBLISHED"&&candidate.is_active_publication&&Date.parse(candidate.valid_until)>now}
+41
View File
@@ -0,0 +1,41 @@
# Veyra 自动部署
开发在 `dev` 分支进行,不直接提交或推送 `master`。Gitea 中的 `master` 保护规则禁用直接推送、强制推送和管理员绕过;通过合并请求进入 `master`。流水线只监听 `master` 的 push,合并提交也会触发;不监听 `dev` 或 PR 事件。因此合并前由本地检查把关,流水线在合并后验证,验证失败不部署。
## 当前部署目标
- Gitea:`https://git.sundynix.cn/Blizzard/Veyra`,使用已有全局 Runner 的 `ubuntu-latest` 标签。
- SSH:`root@192.168.100.132`;目录:`/home/workspace/sundynix-veyra`。
- 默认管理端与 API:`http://192.168.100.132:8080`,同一个镜像、同源服务。
- 应用配置:服务器 `configs/.env`,权限 600;日志:服务器 `logs/`,由容器 UID 10001 写入并轮转。
- PostgreSQL、Redis 使用已有内网服务;不额外部署数据库容器,不覆盖数据、身份或加密主密钥。
仓库 Actions 已启用,部署使用 `DEPLOY_PASSWORD`、`DEPLOY_KNOWN_HOSTS` 两个仓库 Secret。服务器地址、用户和目录在工作流中明确配置;SSH 严格校验已知服务器公钥。可选 `VEYRA_ENV_FILE` 仅用于新服务器首次创建配置,已有文件永不覆盖。当前服务器已通过 SSH 安全初始化,无需设置此可选 Secret。
应用环境文件必须采用无外层引号的 `KEY=VALUE` 格式。Compose 使用 `env_file.format: raw`,保留密码中的 `$`、`#` 等字符,不把应用配置作为 Compose 插值文件。需要 Docker、Compose >=2.30、bash、flock、gzip、sha256sum;当前服务器 Compose 2.36.2 满足要求。
## 流水线
1. 使用 Gitea job token 下载当前提交归档,与参考项目保持一致,避免 Git HTTP pack 检出问题。
2. Docker 内检查 App 类型、12 项客户端测试、iOS/Android Hermes 导出;管理端类型检查、构建、Go embed;gofmt、go vet 与 Go race 测试。构建阶段使用已验证可达的 `https://goproxy.cn,direct` 下载 Go 依赖,保留默认 checksum 校验;运行镜像不包含此构建设置。PostgreSQL 集成测试使用独立、用后清理的 CI 容器,不访问开发库。部署脚本也有成功、校验失败、迁移失败、健康失败回滚、配置保留测试。
3. 所有检查通过才构建 `sundynix/veyra:<完整提交 SHA>`,打包压缩并生成 SHA-256 校验文件,通过 SSH 传入服务器的独立发布目录。
4. 服务器加锁串行发布,校验压缩包、加载镜像,执行同一可执行文件的 `-command db-create` 和 `-command db-migrate`,再启动 Compose。
5. `/readyz` 检查 PostgreSQL 与 Redis。新版本就绪后才更新 `.current-image`;失败则恢复上一版镜像与 Compose,首次部署失败则停止应用。迁移失败不替换运行中的服务。不会自动回退数据库迁移;后续迁移应保持与上一版应用兼容,破坏性迁移需单独设计。
回测 Python 引擎和管理端已包含在运行镜像中;App 导出仅用于 CI 检查,不是 TestFlight、IPA 发布或 Expo OTA 更新。
目前认证与行情、交易研究仍处于开发阶段,服务器沿用本地 `APP_ENV=local` 配置以支持现有开发身份。此部署不代表生产认证、安全与正式行情授权已完成;改为生产环境前需完成正式登录流程。
## 本地检查
```sh
bash deploy/check.sh # 需要运行中的 Docker;完成容器内全部检查
python3 deploy/tests/test_release.py # 无需 Docker,检查发布决策与配置保留
npm --prefix web/admin run build
go vet ./...
go build -trimpath -o bin/veyra .
```
服务器排查使用 `docker compose --env-file /dev/null -f docker-compose.yml`,同时将 `VEYRA_IMAGE` 设为 `.current-image` 中的值。不要将含凭据的 `docker compose config`、环境文件或容器完整 inspect 输出到日志;配置语法检查使用 `config --quiet`。
配置语义参考 [Gitea Actions](https://docs.gitea.com/usage/actions/) 与 [Docker Compose env_file](https://docs.docker.com/reference/compose-file/services/#env_file)。
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
root="${1:?deployment directory required}"
[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2
umask 077
mkdir -p "$root/configs"
tmp="$(mktemp "$root/configs/.env.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
[[ -s "$tmp" ]] || exit 1
# Atomic create-if-absent. Existing configuration and encryption key win.
ln "$tmp" "$root/configs/.env" 2>/dev/null || test -s "$root/configs/.env"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
run_id="veyra-ci-$(date +%s)-${RANDOM}"
network="$run_id"
postgres="$run_id-postgres"
cleanup() {
docker rm -f "$postgres" >/dev/null 2>&1 || true
docker network rm "$network" >/dev/null 2>&1 || true
docker image rm "veyra-mobile-check:$run_id" "veyra-test:$run_id" >/dev/null 2>&1 || true
}
trap cleanup EXIT
docker build --target mobile-check -t "veyra-mobile-check:$run_id" .
docker build --target test -t "veyra-test:$run_id" .
docker run --rm --entrypoint python3 "veyra-test:$run_id" /src/deploy/tests/test_release.py
docker network create "$network" >/dev/null
docker run -d --name "$postgres" --network "$network" --network-alias postgres \
-e POSTGRES_USER=veyra_test -e POSTGRES_PASSWORD=test_only -e POSTGRES_DB=veyra_test postgres:17 >/dev/null
ready=false
for ((i=0; i<60; i++)); do
if docker exec "$postgres" pg_isready -U veyra_test -d veyra_test >/dev/null 2>&1; then ready=true; break; fi
sleep 1
done
if [[ "$ready" != true ]]; then echo 'CI PostgreSQL did not become ready' >&2; exit 1; fi
docker run --rm --network "$network" \
-e VEYRA_TEST_DATABASE_URL='postgres://veyra_test:test_only@postgres:5432/veyra_test?sslmode=disable' \
"veyra-test:$run_id"
+25
View File
@@ -0,0 +1,25 @@
name: veyra
services:
app:
image: ${VEYRA_IMAGE:?VEYRA_IMAGE is required}
restart: unless-stopped
init: true
env_file:
- path: ./configs/.env
format: raw
environment:
HTTP_ADDR: 0.0.0.0:8080
LOG_DIR: logs
PYTHON_EXECUTABLE: python3
ports:
- "${VEYRA_BIND_ADDR:-0.0.0.0}:${VEYRA_PORT:-8080}:8080"
volumes:
- ./logs:/app/logs
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 30s
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Run on the deployment host: release.sh /home/workspace/veyra <commit SHA>
set -euo pipefail
root="${1:?deployment directory required}"
sha="${2:?commit SHA required}"
[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 2
release="$root/releases/$sha"
image="sundynix/veyra:$sha"
cd "$root"
# Serialize releases even when several master commits queue up.
exec 9> .deploy.lock
flock -w 600 9
if [[ ! -s configs/.env ]]; then echo 'Missing server configs/.env (or VEYRA_ENV_FILE bootstrap secret)' >&2; exit 1; fi
(cd "$release" && sha256sum -c image.tar.gz.sha256)
gunzip -c "$release/image.tar.gz" | docker load
mkdir -p logs
docker run --rm --user 0:0 --entrypoint sh -v "$root/logs:/app/logs" "$image" -c 'chown 10001:10001 /app/logs'
previous=''
if [[ -s .current-image ]]; then
previous="$(cat .current-image)"
[[ "$previous" =~ ^sundynix/veyra:[0-9a-f]{40}$ ]] || exit 2
fi
# Keep the previous Compose file as well as the previously healthy image.
if [[ -f docker-compose.yml ]]; then cp docker-compose.yml "$release/previous-compose.yml"; fi
cp "$release/docker-compose.yml" docker-compose.yml
export VEYRA_IMAGE="$image"
# Use an empty interpolation file: application secrets are only read via raw env_file.
compose() { docker compose --env-file /dev/null -f "$root/docker-compose.yml" "$@"; }
if ! compose run --rm --no-deps app -command db-create || ! compose run --rm --no-deps app -command db-migrate; then
if [[ -f "$release/previous-compose.yml" ]]; then cp "$release/previous-compose.yml" docker-compose.yml; fi
echo 'Database preparation failed; existing application was not replaced' >&2
exit 1
fi
if compose up -d --no-build --wait --wait-timeout 120; then
printf '%s\n' "$image" > .current-image.tmp
mv .current-image.tmp .current-image
rm -f "$release/image.tar.gz"
echo "Veyra deployed: $sha"
else
echo 'New application failed readiness; restoring previous application' >&2
if [[ -n "$previous" && -f "$release/previous-compose.yml" ]]; then
cp "$release/previous-compose.yml" docker-compose.yml
export VEYRA_IMAGE="$previous"
compose up -d --no-build --wait --wait-timeout 120
else
compose stop app
fi
# Schema migrations are never automatically undone.
exit 1
fi
+112
View File
@@ -0,0 +1,112 @@
"""Verify deployment decisions without connecting to a server or Docker daemon."""
import gzip
import hashlib
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
SHA = "a" * 40
OLD = "sundynix/veyra:" + "b" * 40
class ReleaseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="veyra-release-")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / "configs").mkdir()
(self.root / "configs/.env").write_text("TEST_SETTING=unchanged\n")
self.release = self.root / "releases" / SHA
self.release.mkdir(parents=True)
archive = gzip.compress(b"fake image")
(self.release / "image.tar.gz").write_bytes(archive)
digest = hashlib.sha256(archive).hexdigest()
(self.release / "image.tar.gz.sha256").write_text(digest + " image.tar.gz\n")
(self.release / "docker-compose.yml").write_text("new compose\n")
(self.root / "docker-compose.yml").write_text("old compose\n")
(self.root / ".current-image").write_text(OLD + "\n")
tools = self.root / "tools"
tools.mkdir()
mock = tools / "docker"
mock.write_text(r"""#!/usr/bin/env python3
import os, sys
from pathlib import Path
args = sys.argv[1:]
with open(os.environ['MOCK_LOG'], 'a') as log:
log.write(os.environ.get('VEYRA_IMAGE', '') + ' ' + ' '.join(args) + '\n')
if args and args[0] == 'load':
sys.stdin.buffer.read()
if '-command' in args and args[-1] == 'db-migrate' and os.environ.get('FAIL_MIGRATION'):
sys.exit(1)
if 'up' in args and os.environ.get('FAIL_READINESS') and os.environ.get('VEYRA_IMAGE') != os.environ['OLD_IMAGE']:
sys.exit(1)
""")
mock.chmod(0o755)
# macOS has no flock; emulate it for this single-process decision test.
if not __import__('shutil').which('flock'):
(tools / 'flock').write_text('#!/bin/sh\nexit 0\n')
(tools / 'flock').chmod(0o755)
if not __import__('shutil').which('sha256sum'):
checksum = tools / 'sha256sum'
checksum.write_text("""#!/usr/bin/env python3
import hashlib, sys
from pathlib import Path
for line in Path(sys.argv[2]).read_text().splitlines():
digest, name = line.split(' ', 1)
if hashlib.sha256(Path(name).read_bytes()).hexdigest() != digest:
sys.exit(1)
""")
checksum.chmod(0o755)
self.env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
MOCK_LOG=str(self.root / 'commands'), OLD_IMAGE=OLD)
def run_release(self, **flags):
return subprocess.run(['bash', str(SCRIPTS / 'release.sh'), str(self.root), SHA],
env=dict(self.env, **flags), capture_output=True, text=True)
def test_success_promotes_exact_commit(self):
result = self.run_release()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / '.current-image').read_text().strip(), 'sundynix/veyra:' + SHA)
self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n')
self.assertFalse((self.release / 'image.tar.gz').exists())
calls = (self.root / 'commands').read_text()
self.assertLess(calls.index('db-migrate'), calls.index(' up '))
def test_failed_readiness_restores_previous_application(self):
self.assertNotEqual(self.run_release(FAIL_READINESS='1').returncode, 0)
self.assertEqual((self.root / '.current-image').read_text().strip(), OLD)
self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n')
self.assertIn(OLD + ' compose', (self.root / 'commands').read_text())
def test_failed_migration_does_not_replace_running_application(self):
self.assertNotEqual(self.run_release(FAIL_MIGRATION='1').returncode, 0)
self.assertNotIn(' up ', (self.root / 'commands').read_text())
self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n')
def test_invalid_archive_never_loads_image(self):
(self.release / 'image.tar.gz').write_bytes(b'corrupt')
self.assertNotEqual(self.run_release().returncode, 0)
self.assertFalse((self.root / 'commands').exists())
def test_missing_configuration_fails_before_image_load(self):
(self.root / 'configs/.env').unlink()
self.assertNotEqual(self.run_release().returncode, 0)
self.assertFalse((self.root / 'commands').exists())
def test_bootstrap_preserves_existing_configuration(self):
for _ in range(2):
result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)],
input='TEST_SETTING=new\n', capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n')
(self.root / 'configs/.env').unlink()
result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)],
input='TEST_SETTING=new\n', capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / 'configs/.env').stat().st_mode & 0o777, 0o600)
if __name__ == '__main__':
unittest.main()
+60 -4
View File
@@ -1,6 +1,62 @@
# 开发记忆与交接状态 # 开发记忆与交接状态
更新日期:2026-10-08。 更新日期:2026-10-09。
## 最新 CI/CD 配置(2026-10-09)
- 用户要求参考 sundynix-pets 接入自动部署,并指定先创建 dev、禁止直接提交 master、CI/CD 只由 master push(包括 PR 合并)触发。已创建 dev 并保留当天全部修改;AGENTS.md 已记录分支规则。
- Gitea 仓库 Actions 已启用;全局 sundynix-global-128 Runner 在线,标签 ubuntu-latest。已在仓库网页保存 DEPLOY_PASSWORD 与 DEPLOY_KNOWN_HOSTS Secret,不在代码或文档记录凭据。
- 已在 Gitea 创建 master 分支保护:禁用推送、禁用强制推送、管理员须遵守,无绕过白名单;保留通过 PR 合并。按用户仅 master 触发要求,不配置依赖 dev/PR 流水线的合并前状态检查。
- 部署目标为 192.168.100.132:/home/workspace/sundynix-veyra,默认端口 8080;SSH 已验证。服务器 Docker 28.2.2、Compose 2.36.2、flock 可用,检查时 8080 未监听。
- 服务器 configs/.env 已经通过 SSH 初始化为 600 权限,使用已有开发配置与加密主密钥,不覆盖已存在的服务器配置,不包含 SSH 部署字段。保持开发环境认证,不声称正式生产认证已实现。
- 新增 .gitea/workflows/ci-cd.yml、根 Dockerfile/.dockerignore 和 deploy/ 脚本与说明。Gitea token 归档检出、Docker 内验证 App/管理端/Go/独立 PostgreSQL,成功后构建完整 SHA 镜像并 SSH 传输校验包。运行镜像包含管理端 embed、Python、CA、时区,UID 10001,日志 bind 至部署目录 logs/。
- 发布脚本加锁,保留服务器配置,先建库/迁移,再等待 /readyz;健康失败回滚上一镜像与 Compose,迁移失败不替换原运行服务。数据库迁移不自动回退。脚本测试覆盖成功、迁移失败、健康失败、损坏包、缺配置与首次配置保留,共 6 项。
- 本地脚本 6 项、Go race(真实 PostgreSQL 临时 schema)、go vet、gofmt、Go 构建、管理端类型/构建/embed、App 12 项测试/类型检查/双平台导出通过;工作流 YAML、内嵌 shell 与服务器 Compose config --quiet 已校验。服务器完整 Docker 检查与最终运行镜像构建也已通过,包含容器内发布测试和独立 PostgreSQL 17 的全部 Go race 测试。服务器 proxy.golang.org 超时、goproxy.cn 返回 200,构建阶段已切换 goproxy.cn 并验证成功,不关闭 checksum。
- 运行镜像已在隔离网络、只读文件系统、临时日志目录中启动验证:内嵌管理端 200,缺少数据库/Redis 时 readyz 正确返回 503,Python 3.11.2 可执行。验证容器已清理,没有连接开发数据库或启动实际部署实例。
- 本机 Docker daemon 未运行,使用指定部署主机的独立 /tmp/veyra-verify-* 构建目录验证。CI 文件在 dev,通过 PR 进入 master 后才触发首次自动部署。自动筛选与股票工作台已在 dev 提交 57882ff;后续 CI 配置提交不直接操作 master。
## 最新开发状态(2026-10-09,管理端重排与真实候选联调)
- 用户要求配置并实际生成自动候选,以及重新规划管理端 UI,使其围绕股票而非服务状态。已完成可见的管理端重排与真实源验证;不再要求用户逐条录入候选。
- 总览改为 A 股研究总览:实际已同步证券数量、近期待审核/有效发布候选数量、已启用策略数、股票名称/代码/价格/行情时间、真实未复权日线与待审核信号卡片。候选与策略统计明确为最近 100 条范围,不冒充全量;没有指数、涨跌幅、成交额和行业数据,不虚构相应指标。
- 新增“股票行情”一级入口,支持代码/名称搜索、股票报价与真实日线图。导航将股票研究与运行配置分组,原 PostgreSQL/Redis/服务状态移至数据健康,模拟回放收进辅助开发区。管理端保持暖白、炭黑、焦糖橙。
- 候选审核以自动筛选为主入口,手工补录为辅助。股票卡片突出名称、交易所/代码、参考区间、风险/目标价格;详情包含真实日线、来源、版本、依据、风险与审核记录。总览信号卡可直达对应候选详情。
- 新增同源管理员 POST /api/admin/v1/strategy-screenings/run(202 异步)与 GET /api/admin/v1/strategy-screening-status。当前进程状态显示排队/运行/完成/失败、最近一批检查/生成/跳过/失败数量与时间;运行或排队中的重复请求复用现有任务,不创建新 Worker。进程状态不声称跨重启持久化,实际筛选记录与候选仍持久化。
- 选定范围配置上限扩为 100 只,每批最多 20 只,定时轮转。后台已按用户本轮要求创建并启用均线交叉研究基线修订 3:5/20 日 SMA,上涨交叉;固定取行情列表按代码排序的前 100 个有效报价中剔除名称含 ST 的 96 只。参考区间 ±100 基点、风险观察 −500 基点、目标观察 +1000 基点、有效 24 小时。此为开发研究预览参数,已保存在不可覆盖的后台策略版本,可修订;不是经过验证的投资建议或全市场自动选股。
- 真实新浪公开日线的 96 只已分五批全部完成,无跳过、无失败,生成两条 PENDING_REVIEW:中洲控股 000042、胜利股份 000407,信号日 2026-10-09。证券、依据、参考价格、来源及版本均自动生成;没有植入演示候选,没有自动审核发布。首批旧修订 2 的 20 只没有信号,记录如实保留。
- 十三版 veyra_strategy_screening 保存不可覆盖的策略版本、来源/配置版本、日线输入快照和 SHA-256;按版本/证券/日期/来源唯一去重。自动创建、提交审核、筛选记录与审计同事务,并发重复不会留额外候选。
- 只使用完整日线与实际观察时间,未复权;当日上海时间 16:00 前不筛选。报价观察须最近 24 小时,报价与日线接收时间在 10 分钟内,来源/版本与最后日线日期匹配;不把重新接收陈旧收盘价当新观察。
- 自动候选及后续人工修订保留策略版本绑定。策略暂停/切换时不能发布旧自动候选或建立新计划,App 有效列表排除它,未完成计划失效并释放预算;手工候选独立,已记录成交与持仓保留。
- 十四版公开行情报价与模拟回放分开保存;同步只为有效计划产生近期报价事件,最近一分钟实际观察可触发一次站内通知,重复时间/关闭来源/错误版本跳过。提醒不写账。五分钟同步不保证捕获瞬时变化,远程推送尚未接通。
- 开发库已应用 14 版迁移。PostgreSQL 临时 schema race 测试覆盖并发去重、输入不可覆盖、审核前 App 隔离、公开提醒不改变账本、失效来源跳过、暂停后预算释放;新增手动触发权限、重复排队、Worker 完成与取消验证。Go vet 与管理端类型检查/构建/embed通过;App 前轮 12 项测试/类型检查/双平台 Hermes 导出通过,本轮未改 App 代码。
- Chrome 已验证新版总览的 5213 条行情快照/2 条待审核信号、股票价格及 40 条实际日线;候选页已打开中洲控股,确认自动生成的依据、来源/策略版本、价格区间和审核发布/退回入口可见。页面保留在待审核详情,未执行审核发布。
- 本次预览后端 0.0.0.0:8083,管理端 http://127.0.0.1:8083;Metro 8081,电脑内网 IP 192.168.100.2,App API 指向 8083。Expo 地址 exp://192.168.100.2:8081。App 真机 UI 未验证,APK/IPA 未生成;两条候选还待人工审核,App 有效候选仍为空。
- 本段功能开发时尚未提交;后续分支与提交状态以最新 CI/CD 记录为准。昨晚交接修改保留;凭据只在私有配置,主密钥不重置。
### 接下来
1. 人工核验两条真实自动候选并决定是否发布,真机验证 App 候选 → 计划 → 提醒 → 成交及冲正。当前公开源与研究参数不替代正式市场规则。
2. 补全交易日历、复权、证券及板块交易限制;推进全市场筛选的容量、授权与增量调度,不把当前 96 只范围称为全市场。
3. AI 分析、用户偏好/关注、正式认证、远程推送及生产行情授权仍待开发。
## 2026-10-08 交接(历史,以下未实现项以最新记录为准)
- 用户已要求今天停止开发,明天再继续;未创建自动任务,不在用户未继续指示时推进开发。
- 今日代码已提交并推送至 origin/master,提交 92329dd(完整 SHA:92329ddce2860a77fc492e70c752b76799e94713);已核对远端一致。此次交接文档更新在该提交之后保存,本地尚未另行提交。
- 已完成:单 Go module / 单 main 入口、独立前端依赖、管理端 embed、供应商加密配置、App 模拟交易闭环、候选审核、计划预算、不可覆盖成交及冲正、FIFO 持仓与复盘、站内提醒、zap 文件日志、研究策略版本、Python 异步回测、可配置 A 股公开行情自动同步。
- 自动行情已实际成功同步 5213 条沪深有效报价;新浪为当前开发源,东方财富保留适配器但联调出现连接关闭。日线自动获取与回测已完成实际验证。十二版迁移已应用开发数据库。
- 自动行情尚未驱动策略筛选、候选生成或计划条件提醒;现有候选仍由后台录入/审核,条件提醒仍由模拟价格回放驱动。研究版本选用和暂停也尚未联动候选/计划,不能描述为完整自动选股产品。
- 已完成真实 PostgreSQL 临时 schema 的 race 测试、go vet、管理端类型检查/构建/embed、App 12 项测试及双平台 Hermes 导出;App 真机/模拟器界面未验证,未生成 APK/IPA。
- 最近保留的预览:后端 8083,Metro 8081;App 本地 API 地址指向电脑内网 IP 的 8083。下次先检查进程和网卡地址,不假设这些进程隔夜仍存活;需要启动时使用同一 Go 可执行文件,不新增 main 或 Makefile。常规后端配置端口为 8080。
- 配置与凭据保持本地 configs/.env;禁止写入代码、文档或提交。供应商加密主密钥不得重置。LLM 已保存连接地址和加密密钥,模型名称为空且关闭;AI 调用尚未接通。
### 下次继续顺序
1. 将已有自动行情/日线与后台策略版本接起来,生成带来源、参数版本和数据时间的候选草稿,进入审核队列;减少手工新建,不自动发布未经审核的候选。
2. 联动有效行情、策略暂停、候选版本与计划时效,接通自动条件提醒;保持提醒不产生成交或现金变动,陈旧行情不重新触发。
3. 验证 App 真机完整流程与公开数据展示,按实际验证修复问题;再推进后台配置的 AI 分析、偏好/关注和正式推送。
以上顺序为交接建议,用户明天的新要求优先。正式行情授权、完整交易规则与正式认证仍待完成。
## 产品与依据 ## 产品与依据
@@ -132,7 +188,7 @@
- 管理端候选审核、通知任务、数据健康中的模拟价格回放已替代占位;App 候选/证据/预算/计划/成交/持仓/冲正/站内通知及累计账本复盘已接通。 - 管理端候选审核、通知任务、数据健康中的模拟价格回放已替代占位;App 候选/证据/预算/计划/成交/持仓/冲正/站内通知及累计账本复盘已接通。
- 真实 PostgreSQL 临时 schema 下的 race 测试通过:候选并发审核、历史保护、发布与事件失败回滚、计划现金争用与取消、部分成交、偏离说明、冲正不重开计划、FIFO 舍入、重复请求、冲正失败回滚、不可覆盖成交、模拟可卖时间、过期快照、不重复提醒、提醒不改账本与复盘金额。 - 真实 PostgreSQL 临时 schema 下的 race 测试通过:候选并发审核、历史保护、发布与事件失败回滚、计划现金争用与取消、部分成交、偏离说明、冲正不重开计划、FIFO 舍入、重复请求、冲正失败回滚、不可覆盖成交、模拟可卖时间、过期快照、不重复提醒、提醒不改账本与复盘金额。
- 管理端类型检查/生产构建/embed、App 类型检查/12 项客户端测试/iOS 与 Android Hermes 导出、go vet/唯一二进制编译已通过;后续代码变更按需复验。 - 管理端类型检查/生产构建/embed、App 类型检查/12 项客户端测试/iOS 与 Android Hermes 导出、go vet/唯一二进制编译已通过;后续代码变更按需复验。
- 本机 8080 由用户 IDE 的旧进程占用,没有关闭用户进程。当前自有验证服务运行 8081;新增管理端与 App HTTP 读接口及登录/退出已通过,不向开发账户插入候选、资金或成交。 - 本机 8080 由用户 IDE 的旧进程占用,没有关闭用户进程。当时自有验证服务运行 8081,后续已改为 8083;新增管理端与 App HTTP 读接口及登录/退出已通过,不向开发账户插入候选、资金或成交。
- 本轮 Chrome 已可用,实际验证新版候选队列、空白录入表单、通知任务与模拟价格入口,未保存任何业务数据。先前无浏览器是历史环境事实;尚未验证 App 真机/模拟器。 - 本轮 Chrome 已可用,实际验证新版候选队列、空白录入表单、通知任务与模拟价格入口,未保存任何业务数据。先前无浏览器是历史环境事实;尚未验证 App 真机/模拟器。
- 待继续:策略版本管理、Python 历史回测、自动行情与供应商调用、偏好与关注、生产服务授权及正式认证/远程推送。本轮改动纳入此次用户授权的提交。 - 待继续:策略版本管理、Python 历史回测、自动行情与供应商调用、偏好与关注、生产服务授权及正式认证/远程推送。本轮改动纳入此次用户授权的提交。
@@ -152,7 +208,7 @@
- 用户要求“做完这个功能就先停止”,本轮在 A 股自动接入验证后停止开发,预览服务保留。后续待自动筛选/审核、AI 分析、偏好与关注、生产授权、正式认证和远程推送;不自动继续扩展。本轮改动纳入此次用户授权的提交,原始两份设计文件未修改。 - 用户要求“做完这个功能就先停止”,本轮在 A 股自动接入验证后停止开发,预览服务保留。后续待自动筛选/审核、AI 分析、偏好与关注、生产授权、正式认证和远程推送;不自动继续扩展。本轮改动纳入此次用户授权的提交,原始两份设计文件未修改。
## 本轮提交准备 ## 本轮代码提交记录
- 用户已明确要求提交并 push,本次提交包含工程目录整理、后台配置、App 模拟业务闭环、zap 日志、策略与回测及 A 股自动接入。验证状态和未完成能力以上文最新记录为准。 - 用户已明确要求提交并 push,本次提交包含工程目录整理、后台配置、App 模拟业务闭环、zap 日志、策略与回测及 A 股自动接入。验证状态和未完成能力以上文最新记录为准。
- 本地环境、密钥、日志、依赖目录和构建产物不纳入提交;原始设计文件保持不变。目标分支为 master,远端为 origin。 - 本地环境、密钥、日志、依赖目录和构建产物不纳入提交;原始设计文件保持不变。已推送至 origin/master,提交为 92329dd,远端 SHA 已核对一致。
@@ -0,0 +1,21 @@
CREATE TABLE veyra_strategy_screening (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
strategy_version_id varchar(36) NOT NULL REFERENCES veyra_strategy_version(id),
symbol varchar(6) NOT NULL CHECK(symbol ~ '^[0-9]{6}$'),
exchange varchar(4) NOT NULL CHECK(exchange IN ('SSE','SZSE')),
bar_date date NOT NULL,
source_id varchar(36) NOT NULL REFERENCES veyra_provider_config(id),
source_version bigint NOT NULL CHECK(source_version>0),
source_adapter varchar(32) NOT NULL CHECK(source_adapter IN ('sina_public','eastmoney_public')),
observed_at timestamptz NOT NULL,
received_at timestamptz NOT NULL,
input_hash varchar(64) NOT NULL CHECK(input_hash ~ '^[0-9a-f]{64}$'),
input jsonb NOT NULL,
status varchar(16) NOT NULL CHECK(status IN ('SIGNAL','NO_SIGNAL')),
candidate_revision_id varchar(36) UNIQUE REFERENCES veyra_candidate_revision(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(strategy_version_id,exchange,symbol,bar_date,source_id,source_version),
CHECK ((status='SIGNAL')=(candidate_revision_id IS NOT NULL))
);
CREATE INDEX veyra_strategy_screening_created_idx ON veyra_strategy_screening(created_at DESC,id DESC);
CREATE TRIGGER veyra_strategy_screening_immutable BEFORE UPDATE OR DELETE ON veyra_strategy_screening FOR EACH ROW EXECUTE FUNCTION veyra_reject_fact_mutation();
@@ -0,0 +1,5 @@
ALTER TABLE veyra_quote_snapshot DROP CONSTRAINT veyra_quote_snapshot_data_mode_check;
ALTER TABLE veyra_quote_snapshot ADD CONSTRAINT veyra_quote_snapshot_data_mode_check CHECK(data_mode IN ('simulation','public'));
ALTER TABLE veyra_quote_snapshot ADD COLUMN source_id varchar(36) REFERENCES veyra_provider_config(id);
ALTER TABLE veyra_quote_snapshot ADD COLUMN source_version bigint;
ALTER TABLE veyra_quote_snapshot ADD CONSTRAINT veyra_quote_snapshot_source_check CHECK((data_mode='simulation' AND source_id IS NULL AND source_version IS NULL) OR (data_mode='public' AND source_id IS NOT NULL AND source_version IS NOT NULL AND source_version>0));
+1
View File
@@ -21,6 +21,7 @@ type LoginLimiter interface {
Login(context.Context, string, string) (bool, error) Login(context.Context, string, string) (bool, error)
} }
type IdentityOptions struct { type IdentityOptions struct {
Screenings *service.ScreeningService
Market *service.MarketService Market *service.MarketService
Backtests *service.BacktestService Backtests *service.BacktestService
Auth *service.AuthService Auth *service.AuthService
+52
View File
@@ -16,6 +16,42 @@ func bindStrategies(r *gin.Engine, options IdentityOptions) {
} }
c.Next() c.Next()
}) })
group.GET("/strategy-screenings", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.List(c.Request.Context(), currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategy-screenings/:id", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.Detail(c.Request.Context(), currentUser(c), c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategy-screening-status", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
result, err := options.Screenings.State(currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, result)
})
group.GET("/strategies", func(c *gin.Context) { group.GET("/strategies", func(c *gin.Context) {
result, err := options.Strategies.List(c.Request.Context(), currentUser(c)) result, err := options.Strategies.List(c.Request.Context(), currentUser(c))
if err != nil { if err != nil {
@@ -39,6 +75,22 @@ func bindStrategies(r *gin.Engine, options IdentityOptions) {
} }
c.Next() c.Next()
}) })
group.POST("/strategy-screenings/run", func(c *gin.Context) {
if options.Screenings == nil {
fail(c, apperror.ErrUnavailable)
return
}
var input struct{}
if !decode(c, &input) {
return
}
result, err := options.Screenings.RequestRun(currentUser(c))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusAccepted, result)
})
for _, path := range []string{"/strategies", "/strategies/:id/versions"} { for _, path := range []string{"/strategies", "/strategies/:id/versions"} {
group.POST(path, func(c *gin.Context) { group.POST(path, func(c *gin.Context) {
var input service.StrategyInput var input service.StrategyInput
@@ -197,6 +197,7 @@ func TestIdentityAccountLifecycle(t *testing.T) {
} }
testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1) testCandidateLifecycle(t, ctx, pool, auth, adminUser, u1)
testStrategyBacktest(t, ctx, pool, adminUser, u1) testStrategyBacktest(t, ctx, pool, adminUser, u1)
testScreeningLifecycle(t, ctx, pool, adminUser, u1)
testTradeLedger(t, ctx, pool, auth) testTradeLedger(t, ctx, pool, auth)
testAdminCredentialChange(t, ctx, pool, auth) testAdminCredentialChange(t, ctx, pool, auth)
} }
+297
View File
@@ -0,0 +1,297 @@
package integration
import (
"context"
"encoding/json"
"errors"
"go.uber.org/zap"
"strings"
"sync"
"testing"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
"git.sundynix.cn/Blizzard/Veyra/internal/service"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func testScreeningLifecycle(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin, user model.User) {
strategies := service.NewStrategyService(repository.NewStrategyRepository(pool), true)
input := service.StrategyInput{Code: "auto_screening", Name: "自动筛选测试", Description: "integration fixture", Parameters: model.StrategyParameters{ShortWindow: 2, LongWindow: 3, LotSize: 100, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}}
st, err := strategies.Save(ctx, admin, "", input, uuid.NewString())
if err != nil {
t.Fatal(err)
}
st, err = strategies.Deploy(ctx, admin, st.ID, st.Versions[0].ID, "ACTIVE", st.Version, "fixture activation", uuid.NewString())
if err != nil {
t.Fatal(err)
}
providerRepo := repository.NewProviderRepository(pool)
provider, err := providerRepo.Get(ctx, "market_primary")
if err != nil {
t.Fatal(err)
}
// Only disposable-schema fixtures are modified, never the development configuration.
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true,market_adapter='sina_public',history_base_url='https://quotes.example.cn' WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
provider, err = providerRepo.Get(ctx, "market_primary")
if err != nil {
t.Fatal(err)
}
observed := time.Now().UTC().Add(-23 * time.Hour)
instrument := model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", PriceCents: 2001, ObservedAt: observed}
if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
repo := repository.NewScreeningRepository(pool)
targets, err := repo.Targets(ctx, provider)
if err != nil || len(targets) != 1 {
t.Fatalf("targets %d: %v", len(targets), err)
}
target := targets[0]
zone := time.FixedZone("Shanghai", 8*3600)
run := model.StrategyScreening{StrategyVersionID: target.Version.ID, Symbol: "600000", Exchange: "SSE", BarDate: observed.In(zone).Format("2006-01-02"), SourceID: provider.ID, SourceVersion: provider.Version, SourceAdapter: "sina_public", ObservedAt: observed, ReceivedAt: time.Now().UTC(), InputHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Input: json.RawMessage(`{"fixture":true}`), Status: "SIGNAL"}
draft := model.CandidateDraftInput{ObservedAt: observed, ValidUntil: time.Now().UTC().Add(time.Hour), Content: model.CandidateContent{Symbol: "600000", Exchange: "SSE", Name: "fixture", StrategyCode: "trend_following", Title: "fixture signal", Thesis: "fixture", CounterEvidence: "fixture risk", Invalidation: "fixture pause", Evidence: []model.CandidateEvidence{{SourceURL: "https://quotes.example.cn", Locator: "fixture", Summary: "fixture"}}, ReferencePlan: model.CandidateReferencePlan{EntryLow: "19.80", EntryHigh: "20.22", StopLoss: "19.00", TargetPrice: "22.02"}}}
var before int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&before); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make(chan error, 2)
for range 2 {
wg.Add(1)
go func() { defer wg.Done(); results <- repo.Record(ctx, target, run, &draft) }()
}
wg.Wait()
close(results)
success, conflict := 0, 0
for e := range results {
if e == nil {
success++
} else if errors.Is(e, apperror.ErrConflict) {
conflict++
} else {
t.Fatal(e)
}
}
if success != 1 || conflict != 1 {
t.Fatal("concurrent duplicate screening did not converge")
}
var after int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_candidate`).Scan(&after); err != nil || after != before+1 {
t.Fatal("duplicate screening left orphan candidate")
}
records, err := repo.List(ctx)
if err != nil || len(records) != 1 || records[0].CandidateRevisionID == nil {
t.Fatal("screening provenance missing")
}
if _, err := pool.Exec(ctx, `UPDATE veyra_strategy_screening SET status='NO_SIGNAL' WHERE id=$1`, records[0].ID); err == nil {
t.Fatal("screening evidence mutable")
}
recordDetail, err := repo.Detail(ctx, records[0].ID)
if err != nil || string(recordDetail.Input) != "{\"fixture\": true}" {
t.Fatalf("immutable input detail: %s %v", recordDetail.Input, err)
}
candidates := service.NewCandidateService(repository.NewCandidateRepository(pool), true)
candidate, err := candidates.Detail(ctx, admin, *records[0].CandidateRevisionID, true)
if err != nil || candidate.Status != "PENDING_REVIEW" || candidate.Screening == nil {
t.Fatalf("review queue provenance: %v", err)
}
if _, err := candidates.Detail(ctx, user, candidate.ID, false); !errors.Is(err, apperror.ErrNotFound) {
t.Fatal("unreviewed signal leaked to App")
}
pub, err := candidates.Transition(ctx, admin, candidate.ID, "PUBLISH", uuid.NewString(), model.CandidateActionInput{ExpectedVersion: candidate.Version, Reason: "fixture review"})
if err != nil || !pub.IsActivePublication {
t.Fatalf("review publish: %v", err)
}
accounts := service.NewAccountService(repository.NewAccountRepository(pool))
cash := "100000.00"
auth, err := service.NewAuthService(repository.NewIdentityRepository(pool))
if err != nil {
t.Fatal(err)
}
owner, err := auth.ProvisionUser(ctx, "scanner_user", "a-test-only-password", "Scanner User", "USER")
if err != nil {
t.Fatal(err)
}
account, err := accounts.Create(ctx, owner.ID, "simulation", "scanner fixture", &cash)
if err != nil {
t.Fatal(err)
}
plans := service.NewTradePlanService(repository.NewTradePlanRepository(pool), true)
preview, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"})
if err != nil {
t.Fatal(err)
}
plan, err := plans.Confirm(ctx, owner, preview.ID, uuid.NewString())
if err != nil {
t.Fatal(err)
}
// Real public quote ingestion uses the existing outbox and never writes ledger facts.
if err := repository.NewMarketRepository(pool).Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
var publicCount int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_quote_snapshot WHERE data_mode='public'`).Scan(&publicCount); err != nil || publicCount != 0 {
t.Fatal("old market close queued a public alert")
}
instrument.ObservedAt = time.Now().UTC()
marketRepo := repository.NewMarketRepository(pool)
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
notifications := repository.NewNotificationRepository(pool)
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err := notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatalf("public alert missing: %v count %d", err, len(notices))
}
if !strings.Contains(notices[0].Body, "公开行情价格") {
t.Fatal("public price mislabeled as manual simulation")
}
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err = notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatal("unchanged quote duplicated reminder")
}
var balance int64
var fills int
if err := pool.QueryRow(ctx, `SELECT sum(amount_minor)::bigint FROM veyra_cash_entry WHERE account_id=$1`, account.ID).Scan(&balance); err != nil || balance != 10000000 {
t.Fatal("public alert altered cash")
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM veyra_trade_fill WHERE account_id=$1`, account.ID).Scan(&fills); err != nil || fills != 0 {
t.Fatal("public alert posted trade")
}
// A queued observation from a disabled source must not trigger another plan.
preview2, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"})
if err != nil {
t.Fatal(err)
}
if _, err := plans.Confirm(ctx, owner, preview2.ID, uuid.NewString()); err != nil {
t.Fatal(err)
}
instrument.ObservedAt = time.Now().UTC()
if err := marketRepo.Save(ctx, provider, []model.Instrument{instrument}, time.Now().UTC(), ""); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=false WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
for range 100 {
worked, err := notifications.ProcessNext(ctx)
if err != nil {
t.Fatal(err)
}
if !worked {
break
}
}
notices, err = notifications.List(ctx, owner.ID)
if err != nil || len(notices) != 1 {
t.Fatal("disabled source generated delayed reminder")
}
if _, err := pool.Exec(ctx, `UPDATE veyra_provider_config SET enabled=true WHERE id=$1`, provider.ID); err != nil {
t.Fatal(err)
}
st, err = strategies.Deploy(ctx, admin, st.ID, "", "PAUSED", st.Version, "fixture pause", uuid.NewString())
if err != nil {
t.Fatal(err)
}
detail, err := candidates.Detail(ctx, user, pub.ID, false)
if err != nil || detail.IsActivePublication {
t.Fatal("paused signal stayed actionable")
}
if _, err := plans.Preview(ctx, owner, service.PlanPreviewInput{AccountID: account.ID, CandidateRevisionID: pub.ID, ExpectedCandidateVersion: pub.Version, Quantity: "100", FeeBudget: "0"}); !errors.Is(err, apperror.ErrCandidateUnavailable) {
t.Fatal("paused strategy accepted plan")
}
list, err := plans.List(ctx, owner)
if err != nil || len(list) != 2 {
t.Fatalf("pause did not release plan: %v", err)
}
found := false
for _, p := range list {
if p.ID == plan.ID {
found = true
}
if p.Status != "INVALIDATED" || p.ReservedMinor != 0 {
t.Fatal("paused plan kept reserve")
}
}
if !found {
t.Fatal("original plan disappeared")
}
run.Symbol = "600001"
if err := repo.Record(ctx, target, run, &draft); !errors.Is(err, apperror.ErrConflict) {
t.Fatal("paused deployment accepted late worker")
}
worker := service.NewScreeningService(repo, service.NewMarketService(repository.NewMarketRepository(pool), providerRepo, idleScreeningAdapter{}, true), true)
if _, err := worker.RequestRun(user); !errors.Is(err, apperror.ErrForbidden) {
t.Fatal("app user requested research run")
}
first, err := worker.RequestRun(admin)
if err != nil || first.State != "QUEUED" {
t.Fatal("run did not queue")
}
second, err := worker.RequestRun(admin)
if err != nil || second.State != "QUEUED" {
t.Fatal("duplicate queue request changed state")
}
workerCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() { defer close(done); worker.Run(workerCtx, zap.NewNop()) }()
deadline := time.After(3 * time.Second)
for {
state, err := worker.State(admin)
if err != nil {
cancel()
<-done
t.Fatal(err)
}
if state.State == "COMPLETED" && state.FinishedAt != nil {
break
}
select {
case <-deadline:
cancel()
<-done
t.Fatal("queued worker did not complete")
case <-time.After(10 * time.Millisecond):
}
}
cancel()
<-done
}
// There are no eligible targets after pause, so this adapter must never be called.
type idleScreeningAdapter struct{}
func (idleScreeningAdapter) Snapshot(context.Context, model.ProviderConfig) ([]model.Instrument, error) {
return nil, apperror.ErrUnavailable
}
func (idleScreeningAdapter) History(context.Context, model.ProviderConfig, string, string, string, string) (model.MarketHistory, error) {
return model.MarketHistory{}, apperror.ErrUnavailable
}
+1
View File
@@ -45,6 +45,7 @@ type CandidateContent struct {
} }
type CandidateRevision struct { type CandidateRevision struct {
Screening *StrategyScreening `json:"screening,omitempty"`
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
CandidateID string `db:"candidate_id" json:"candidate_id"` CandidateID string `db:"candidate_id" json:"candidate_id"`
Revision int `db:"revision" json:"revision"` Revision int `db:"revision" json:"revision"`
+2
View File
@@ -16,6 +16,8 @@ type Notification struct {
func (Notification) TableName() string { return "veyra_notification" } func (Notification) TableName() string { return "veyra_notification" }
type QuoteSnapshot struct { type QuoteSnapshot struct {
SourceID *string `db:"source_id" json:"source_id,omitempty"`
SourceVersion *int64 `db:"source_version" json:"source_version,omitempty"`
ID string `db:"id" json:"id"` ID string `db:"id" json:"id"`
Symbol string `db:"symbol" json:"symbol"` Symbol string `db:"symbol" json:"symbol"`
Exchange string `db:"exchange" json:"exchange"` Exchange string `db:"exchange" json:"exchange"`
+79
View File
@@ -0,0 +1,79 @@
package model
import (
"encoding/json"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
)
// Screening rules belong to an immutable strategy version, and are opt-in.
type ScreeningSecurity struct {
Symbol string `json:"symbol"`
Exchange string `json:"exchange"`
}
type ScreeningParameters struct {
Securities []ScreeningSecurity `json:"securities"`
EntryBandBPS int64 `json:"entry_band_bps"`
StopLossBPS int64 `json:"stop_loss_bps"`
TargetBPS int64 `json:"target_bps"`
ValidHours int `json:"valid_hours"`
}
func ValidateScreening(p *ScreeningParameters) error {
if p == nil {
return nil
}
if len(p.Securities) < 1 || len(p.Securities) > 100 || p.EntryBandBPS < 0 || p.EntryBandBPS > 1000 || p.StopLossBPS <= p.EntryBandBPS || p.StopLossBPS > 5000 || p.TargetBPS <= p.EntryBandBPS || p.TargetBPS > 10000 || p.ValidHours < 1 || p.ValidHours > 168 {
return apperror.ErrInvalidInput
}
seen := map[ScreeningSecurity]bool{}
for _, v := range p.Securities {
if !symbolPattern.MatchString(v.Symbol) || (v.Exchange != "SSE" && v.Exchange != "SZSE") || seen[v] {
return apperror.ErrInvalidInput
}
seen[v] = true
}
return nil
}
type ScreeningTarget struct {
StrategyID string
StrategyName string
Version StrategyVersion
Instrument Instrument
}
type StrategyScreening struct {
ID string `db:"id" json:"id"`
StrategyVersionID string `db:"strategy_version_id" json:"strategy_version_id"`
StrategyName string `json:"strategy_name"`
StrategyRevision int `json:"strategy_revision"`
Symbol string `db:"symbol" json:"symbol"`
Exchange string `db:"exchange" json:"exchange"`
BarDate string `db:"bar_date" json:"bar_date"`
SourceID string `db:"source_id" json:"source_id"`
SourceVersion int64 `db:"source_version" json:"source_version"`
SourceAdapter string `db:"source_adapter" json:"source_adapter"`
ObservedAt time.Time `db:"observed_at" json:"observed_at"`
ReceivedAt time.Time `db:"received_at" json:"received_at"`
InputHash string `db:"input_hash" json:"input_hash"`
Input json.RawMessage `db:"input" json:"input,omitempty"`
Status string `db:"status" json:"status"`
CandidateRevisionID *string `db:"candidate_revision_id" json:"candidate_revision_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (StrategyScreening) TableName() string { return "veyra_strategy_screening" }
// Worker state is process-local; durable results remain in StrategyScreening.
type ScreeningWorkerState struct {
State string `json:"state"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Targets int `json:"targets"`
Completed int `json:"completed"`
Generated int `json:"generated"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Message string `json:"message"`
}
+1
View File
@@ -3,6 +3,7 @@ package model
import "time" import "time"
type StrategyParameters struct { type StrategyParameters struct {
Screening *ScreeningParameters `json:"screening,omitempty"`
ShortWindow int `json:"short_window"` ShortWindow int `json:"short_window"`
LongWindow int `json:"long_window"` LongWindow int `json:"long_window"`
FeeBPS int `json:"fee_bps"` FeeBPS int `json:"fee_bps"`
+45 -5
View File
@@ -19,25 +19,33 @@ func NewCandidateRepository(pool *pgxpool.Pool) *CandidateRepository {
return &CandidateRepository{pool: pool} return &CandidateRepository{pool: pool}
} }
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now(),false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),'')` // All successors of an automatically generated candidate retain the deployment binding.
const screeningEligibility = `NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id AND (s.status<>'ACTIVE' OR s.active_version_id IS DISTINCT FROM sv.id))`
const candidateColumns = `r.id,r.candidate_id,r.revision,r.version,r.status,r.data_mode,r.snapshot,r.author_id,r.observed_at,r.valid_until,r.published_at,r.created_at,r.updated_at,(r.revision=c.latest_revision),COALESCE(c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `,false),COALESCE((SELECT reason FROM veyra_candidate_review v WHERE v.revision_id=r.id AND v.decision='WITHDRAW' ORDER BY v.input_version DESC LIMIT 1),''),(SELECT jsonb_build_object('id',x.id,'strategy_version_id',x.strategy_version_id,'strategy_name',s.name,'strategy_revision',sv.revision,'symbol',x.symbol,'exchange',x.exchange,'bar_date',x.bar_date::text,'source_id',x.source_id,'source_version',x.source_version,'source_adapter',x.source_adapter,'observed_at',x.observed_at,'received_at',x.received_at,'input_hash',x.input_hash,'status',x.status,'candidate_revision_id',x.candidate_revision_id,'created_at',x.created_at) FROM veyra_strategy_screening x JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id JOIN veyra_strategy_version sv ON sv.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=sv.strategy_id WHERE original.candidate_id=c.id LIMIT 1)`
func scanCandidate(row pgx.Row) (model.CandidateRevision, error) { func scanCandidate(row pgx.Row) (model.CandidateRevision, error) {
var r model.CandidateRevision var r model.CandidateRevision
var snapshot []byte var snapshot, screening []byte
err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason) err := row.Scan(&r.ID, &r.CandidateID, &r.Revision, &r.Version, &r.Status, &r.DataMode, &snapshot, &r.AuthorID, &r.ObservedAt, &r.ValidUntil, &r.PublishedAt, &r.CreatedAt, &r.UpdatedAt, &r.IsLatest, &r.IsActivePublication, &r.WithdrawalReason, &screening)
if err != nil { if err != nil {
return r, mapError(err) return r, mapError(err)
} }
if err := json.Unmarshal(snapshot, &r.Snapshot); err != nil { if err := json.Unmarshal(snapshot, &r.Snapshot); err != nil {
return r, apperror.ErrUnavailable return r, apperror.ErrUnavailable
} }
if len(screening) > 0 {
if err := json.Unmarshal(screening, &r.Screening); err != nil {
return r, apperror.ErrUnavailable
}
}
return r, nil return r, nil
} }
const candidateJoin = ` FROM veyra_candidate_revision r JOIN veyra_candidate c ON c.id=r.candidate_id ` const candidateJoin = ` FROM veyra_candidate_revision r JOIN veyra_candidate c ON c.id=r.candidate_id `
func (r *CandidateRepository) List(ctx context.Context, admin bool, status string, limit int) ([]model.CandidateRevision, error) { func (r *CandidateRepository) List(ctx context.Context, admin bool, status string, limit int) ([]model.CandidateRevision, error) {
query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now()) ORDER BY r.created_at DESC,r.id DESC LIMIT $3` query := `SELECT ` + candidateColumns + candidateJoin + ` WHERE ($1 AND (r.revision=c.latest_revision OR c.published_revision_id=r.id) AND ($2='' OR r.status=$2)) OR (NOT $1 AND c.published_revision_id=r.id AND r.status='PUBLISHED' AND r.valid_until>now() AND ` + screeningEligibility + `) ORDER BY r.created_at DESC,r.id DESC LIMIT $3`
rows, err := r.pool.Query(ctx, query, admin, status, limit) rows, err := r.pool.Query(ctx, query, admin, status, limit)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
@@ -84,6 +92,22 @@ func lockCandidate(ctx context.Context, tx pgx.Tx, source string) (model.Candida
if err := tx.QueryRow(ctx, `SELECT candidate_id FROM veyra_candidate_revision WHERE id=$1`, source).Scan(&parent); err != nil { if err := tx.QueryRow(ctx, `SELECT candidate_id FROM veyra_candidate_revision WHERE id=$1`, source).Scan(&parent); err != nil {
return model.CandidateRevision{}, mapError(err) return model.CandidateRevision{}, mapError(err)
} }
// Match scanner lock order: deployment, then candidate aggregate.
rows, err := tx.Query(ctx, `SELECT s.id FROM veyra_strategy s JOIN veyra_strategy_version v ON v.strategy_id=s.id JOIN veyra_strategy_screening x ON x.strategy_version_id=v.id JOIN veyra_candidate_revision original ON original.id=x.candidate_revision_id WHERE original.candidate_id=$1 ORDER BY s.id FOR SHARE OF s`, parent)
if err != nil {
return model.CandidateRevision{}, mapError(err)
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return model.CandidateRevision{}, mapError(err)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return model.CandidateRevision{}, mapError(err)
}
var locked string var locked string
if err := tx.QueryRow(ctx, `SELECT id FROM veyra_candidate WHERE id=$1 FOR UPDATE`, parent).Scan(&locked); err != nil { if err := tx.QueryRow(ctx, `SELECT id FROM veyra_candidate WHERE id=$1 FOR UPDATE`, parent).Scan(&locked); err != nil {
return model.CandidateRevision{}, mapError(err) return model.CandidateRevision{}, mapError(err)
@@ -96,6 +120,15 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
return model.CandidateRevision{}, mapError(err) return model.CandidateRevision{}, mapError(err)
} }
defer tx.Rollback(context.Background()) defer tx.Rollback(context.Background())
created, err := createCandidate(ctx, tx, input, actor, requestID, source, expected)
if err != nil {
return created, err
}
return created, mapError(tx.Commit(ctx))
}
func createCandidate(ctx context.Context, tx pgx.Tx, input model.CandidateDraftInput, actor, requestID, source string, expected int64) (model.CandidateRevision, error) {
var err error
parent, revision := uuid.NewString(), 1 parent, revision := uuid.NewString(), 1
if source == "" { if source == "" {
_, err = tx.Exec(ctx, `INSERT INTO veyra_candidate(id,latest_revision) VALUES($1,1)`, parent) _, err = tx.Exec(ctx, `INSERT INTO veyra_candidate(id,latest_revision) VALUES($1,1)`, parent)
@@ -129,7 +162,7 @@ func (r *CandidateRepository) Create(ctx context.Context, input model.CandidateD
if err != nil { if err != nil {
return created, err return created, err
} }
return created, mapError(tx.Commit(ctx)) return created, nil
} }
func appendCandidateEvent(ctx context.Context, tx pgx.Tx, r model.CandidateRevision, eventType string, details any) error { func appendCandidateEvent(ctx context.Context, tx pgx.Tx, r model.CandidateRevision, eventType string, details any) error {
payload, err := json.Marshal(details) payload, err := json.Marshal(details)
@@ -157,6 +190,13 @@ func (r *CandidateRepository) Transition(ctx context.Context, id, action string,
return revision, err return revision, err
} }
if action == "PUBLISH" { if action == "PUBLISH" {
var eligible bool
if err := tx.QueryRow(ctx, `SELECT `+screeningEligibility+candidateJoin+` WHERE r.id=$1`, id).Scan(&eligible); err != nil {
return revision, mapError(err)
}
if !eligible {
return revision, apperror.ErrCandidateUnavailable
}
// Superseded publications keep their historical content, with an invalidation event. // Superseded publications keep their historical content, with an invalidation event.
previous, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE c.id=$1 AND c.published_revision_id=r.id`, revision.CandidateID)) previous, err := scanCandidate(tx.QueryRow(ctx, `SELECT `+candidateColumns+candidateJoin+` WHERE c.id=$1 AND c.published_revision_id=r.id`, revision.CandidateID))
if err != nil && err != apperror.ErrNotFound { if err != nil && err != apperror.ErrNotFound {
+36
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"encoding/json"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror" "git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model" "git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid" "github.com/google/uuid"
@@ -54,6 +55,41 @@ func (r *MarketRepository) Save(ctx context.Context, c model.ProviderConfig, lis
return mapError(e) return mapError(e)
} }
} }
if message == "" {
// Quote events are produced only for tracked plans and truly recent vendor observations.
// Re-reading an unchanged close cannot enqueue another price event.
rows, err := tx.Query(ctx, `INSERT INTO veyra_quote_snapshot(id,symbol,exchange,data_mode,price_minor,observed_at,version,source_id,source_version)
SELECT gen_random_uuid()::text,i.symbol,i.exchange,'public',i.price_cents,i.observed_at,1,i.source_id,i.source_version FROM veyra_instrument i
WHERE i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '1 minute' AND i.observed_at<=now()+interval '1 minute'
AND EXISTS(SELECT 1 FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE `+activePlanPredicate+` AND r.snapshot->>'symbol'=i.symbol AND r.snapshot->>'exchange'=i.exchange)
ON CONFLICT(exchange,symbol,data_mode) DO UPDATE SET price_minor=EXCLUDED.price_minor,observed_at=EXCLUDED.observed_at,version=veyra_quote_snapshot.version+1,source_id=EXCLUDED.source_id,source_version=EXCLUDED.source_version,updated_at=now()
WHERE EXCLUDED.observed_at>veyra_quote_snapshot.observed_at RETURNING `+quoteColumns, c.ID, c.Version)
if err != nil {
return mapError(err)
}
quotes := make([]model.QuoteSnapshot, 0)
for rows.Next() {
q, err := scanQuote(rows)
if err != nil {
rows.Close()
return err
}
quotes = append(quotes, q)
}
rows.Close()
if err := rows.Err(); err != nil {
return mapError(err)
}
for _, q := range quotes {
payload, err := json.Marshal(q)
if err != nil {
return apperror.ErrUnavailable
}
if _, err := tx.Exec(ctx, `INSERT INTO veyra_outbox_event(id,aggregate_type,aggregate_id,aggregate_version,event_type,payload) VALUES($1,'quote_snapshot',$2,$3,'quote.observed',$4)`, uuid.NewString(), q.ID, q.Version, payload); err != nil {
return mapError(err)
}
}
}
_, e = tx.Exec(ctx, `INSERT INTO veyra_market_sync(id,source_id,source_version,instrument_count,status,message,started_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, uuid.NewString(), c.ID, c.Version, len(list), status, message, started) _, e = tx.Exec(ctx, `INSERT INTO veyra_market_sync(id,source_id,source_version,instrument_count,status,message,started_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, uuid.NewString(), c.ID, c.Version, len(list), status, message, started)
if e != nil { if e != nil {
return mapError(e) return mapError(e)
+23 -6
View File
@@ -60,16 +60,16 @@ func (r *NotificationRepository) Tasks(ctx context.Context) ([]model.Notificatio
return list, mapError(rows.Err()) return list, mapError(rows.Err())
} }
const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at` const quoteColumns = `id,symbol,exchange,data_mode,price_minor,observed_at,version,created_at,updated_at,source_id,source_version`
func scanQuote(row pgx.Row) (model.QuoteSnapshot, error) { func scanQuote(row pgx.Row) (model.QuoteSnapshot, error) {
var q model.QuoteSnapshot var q model.QuoteSnapshot
err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt) err := row.Scan(&q.ID, &q.Symbol, &q.Exchange, &q.DataMode, &q.PriceMinor, &q.ObservedAt, &q.Version, &q.CreatedAt, &q.UpdatedAt, &q.SourceID, &q.SourceVersion)
q.Price = model.FormatMoney(q.PriceMinor) q.Price = model.FormatMoney(q.PriceMinor)
return q, mapError(err) return q, mapError(err)
} }
func (r *NotificationRepository) Quotes(ctx context.Context) ([]model.QuoteSnapshot, error) { func (r *NotificationRepository) Quotes(ctx context.Context) ([]model.QuoteSnapshot, error) {
rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot ORDER BY updated_at DESC,id DESC LIMIT 100`) rows, err := r.pool.Query(ctx, `SELECT `+quoteColumns+` FROM veyra_quote_snapshot WHERE data_mode='simulation' ORDER BY updated_at DESC,id DESC LIMIT 100`)
if err != nil { if err != nil {
return nil, mapError(err) return nil, mapError(err)
} }
@@ -127,6 +127,19 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
if current.Version != event.AggregateVersion || time.Since(current.ObservedAt) > time.Minute || current.ObservedAt.After(time.Now().Add(time.Minute)) { if current.Version != event.AggregateVersion || time.Since(current.ObservedAt) > time.Minute || current.ObservedAt.After(time.Now().Add(time.Minute)) {
return nil return nil
} }
if current.DataMode == "public" {
if current.SourceID == nil || current.SourceVersion == nil {
return apperror.ErrUnavailable
}
var version int64
var enabled bool
if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, *current.SourceID).Scan(&version, &enabled); err != nil {
return mapError(err)
}
if !enabled || version != *current.SourceVersion {
return nil
}
}
rows, err := tx.Query(ctx, `SELECT p.id,p.candidate_revision_id,a.user_id FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_account a ON a.id=p.account_id JOIN veyra_user u ON u.id=a.user_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND u.status='ACTIVE' AND r.snapshot->>'symbol'=$1 AND r.snapshot->>'exchange'=$2 ORDER BY r.candidate_id,p.id`, current.Symbol, current.Exchange) rows, err := tx.Query(ctx, `SELECT p.id,p.candidate_revision_id,a.user_id FROM veyra_trade_plan p JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_account a ON a.id=p.account_id JOIN veyra_user u ON u.id=a.user_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND u.status='ACTIVE' AND r.snapshot->>'symbol'=$1 AND r.snapshot->>'exchange'=$2 ORDER BY r.candidate_id,p.id`, current.Symbol, current.Exchange)
if err != nil { if err != nil {
return mapError(err) return mapError(err)
@@ -176,7 +189,11 @@ func processQuote(ctx context.Context, tx pgx.Tx, event model.OutboxEvent) error
if tag.RowsAffected() == 0 { if tag.RowsAffected() == 0 {
continue continue
} }
body := fmt.Sprintf("模拟价格 ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339)) label := "模拟价格"
if current.DataMode == "public" {
label = "公开行情价格"
}
body := fmt.Sprintf("%s ¥%s 进入 %s 的参考区间。观察时间 %s。请打开计划核对当前状态;此提醒不代表成交。", label, current.Price, publication.Snapshot.Name, current.ObservedAt.Format(time.RFC3339))
if err := insertNotification(ctx, tx, target.user, event.ID, &target.plan, "模拟计划条件已满足", body); err != nil { if err := insertNotification(ctx, tx, target.user, event.ID, &target.plan, "模拟计划条件已满足", body); err != nil {
return err return err
} }
@@ -228,7 +245,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
var event model.OutboxEvent var event model.OutboxEvent
err = tx.QueryRow(ctx, `SELECT id,aggregate_type,aggregate_id,aggregate_version,event_type,payload,created_at,processed_at FROM veyra_outbox_event WHERE processed_at IS NULL ORDER BY created_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`).Scan(&event.ID, &event.AggregateType, &event.AggregateID, &event.AggregateVersion, &event.EventType, &event.Payload, &event.CreatedAt, &event.ProcessedAt) err = tx.QueryRow(ctx, `SELECT id,aggregate_type,aggregate_id,aggregate_version,event_type,payload,created_at,processed_at FROM veyra_outbox_event WHERE processed_at IS NULL ORDER BY created_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`).Scan(&event.ID, &event.AggregateType, &event.AggregateID, &event.AggregateVersion, &event.EventType, &event.Payload, &event.CreatedAt, &event.ProcessedAt)
if err == pgx.ErrNoRows { if err == pgx.ErrNoRows {
rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) LIMIT 100`) rows, err := tx.Query(ctx, `SELECT DISTINCT a.user_id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id JOIN veyra_candidate_revision r ON r.id=p.candidate_revision_id JOIN veyra_candidate c ON c.id=r.candidate_id WHERE p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) LIMIT 100`)
if err != nil { if err != nil {
return false, mapError(err) return false, mapError(err)
} }
@@ -265,7 +282,7 @@ func (r *NotificationRepository) ProcessNext(ctx context.Context) (bool, error)
var plan string var plan string
err = tx.QueryRow(ctx, `SELECT a.user_id,p.id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.id=$1`, event.AggregateID).Scan(&user, &plan) err = tx.QueryRow(ctx, `SELECT a.user_id,p.id FROM veyra_trade_plan p JOIN veyra_account a ON a.id=p.account_id WHERE p.id=$1`, event.AggregateID).Scan(&user, &plan)
if err == nil { if err == nil {
err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。") err = insertNotification(ctx, tx, user, event.ID, &plan, "计划已失效", "对应候选、策略版本已失效或计划已过期,保留预算已释放。已记录成交与持仓保留。")
} }
case "candidate.published", "position.changed": case "candidate.published", "position.changed":
default: default:
+128
View File
@@ -0,0 +1,128 @@
package repository
import (
"context"
"encoding/json"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type ScreeningRepository struct{ pool *pgxpool.Pool }
func NewScreeningRepository(pool *pgxpool.Pool) *ScreeningRepository {
return &ScreeningRepository{pool}
}
func (r *ScreeningRepository) Targets(ctx context.Context, c model.ProviderConfig) ([]model.ScreeningTarget, error) {
rows, err := r.pool.Query(ctx, `SELECT s.id,s.name,v.id,v.strategy_id,v.revision,v.algorithm,v.parameters,v.description,v.author_id,v.created_at,i.id,i.symbol,i.exchange,i.name,i.price_cents,i.observed_at,i.received_at,i.source_id,i.source_version
FROM veyra_strategy s JOIN veyra_strategy_version v ON v.id=s.active_version_id
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(v.parameters->'screening'->'securities','[]'::jsonb)) security
JOIN veyra_instrument i ON i.symbol=security->>'symbol' AND i.exchange=security->>'exchange'
JOIN veyra_user u ON u.id=v.author_id AND u.role='ADMIN' AND u.status='ACTIVE'
WHERE s.status='ACTIVE' AND i.source_id=$1 AND i.source_version=$2 AND i.observed_at>=now()-interval '24 hours' AND i.observed_at<=now()+interval '1 minute' AND i.received_at>=now()-interval '10 minutes'
AND ((i.observed_at AT TIME ZONE 'Asia/Shanghai')::date < (now() AT TIME ZONE 'Asia/Shanghai')::date OR EXTRACT(hour FROM now() AT TIME ZONE 'Asia/Shanghai')>=16)
AND NOT EXISTS(SELECT 1 FROM veyra_strategy_screening x WHERE x.strategy_version_id=v.id AND x.symbol=i.symbol AND x.exchange=i.exchange AND x.bar_date=(i.observed_at AT TIME ZONE 'Asia/Shanghai')::date AND x.source_id=$1 AND x.source_version=$2)
ORDER BY s.updated_at,s.id,i.symbol LIMIT 2000`, c.ID, c.Version)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
targets := make([]model.ScreeningTarget, 0)
for rows.Next() {
var t model.ScreeningTarget
var raw []byte
v := &t.Version
i := &t.Instrument
if err := rows.Scan(&t.StrategyID, &t.StrategyName, &v.ID, &v.StrategyID, &v.Revision, &v.Algorithm, &raw, &v.Description, &v.AuthorID, &v.CreatedAt, &i.ID, &i.Symbol, &i.Exchange, &i.Name, &i.PriceCents, &i.ObservedAt, &i.ReceivedAt, &i.SourceID, &i.SourceVersion); err != nil {
return nil, mapError(err)
}
if json.Unmarshal(raw, &v.Parameters) != nil {
return nil, apperror.ErrUnavailable
}
targets = append(targets, t)
}
return targets, mapError(rows.Err())
}
func (r *ScreeningRepository) Record(ctx context.Context, t model.ScreeningTarget, run model.StrategyScreening, draft *model.CandidateDraftInput) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return mapError(err)
}
defer tx.Rollback(context.Background())
// Lock deployment before creating a candidate; pause/version changes cannot race publication.
var status string
var active *string
if err := tx.QueryRow(ctx, `SELECT status,active_version_id FROM veyra_strategy WHERE id=$1 FOR SHARE`, t.StrategyID).Scan(&status, &active); err != nil {
return mapError(err)
}
if status != "ACTIVE" || active == nil || *active != t.Version.ID {
return apperror.ErrConflict
}
var sourceVersion int64
var enabled bool
if err := tx.QueryRow(ctx, `SELECT version,enabled FROM veyra_provider_config WHERE id=$1 FOR SHARE`, run.SourceID).Scan(&sourceVersion, &enabled); err != nil {
return mapError(err)
}
if !enabled || sourceVersion != run.SourceVersion {
return apperror.ErrConflict
}
var actorValid bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM veyra_user WHERE id=$1 AND status='ACTIVE' AND role='ADMIN')`, t.Version.AuthorID).Scan(&actorValid); err != nil {
return mapError(err)
}
if !actorValid {
return apperror.ErrConflict
}
requestID := uuid.NewString()
var revisionID *string
if draft != nil {
if err := model.ValidateDraft(*draft, time.Now().UTC()); err != nil {
return err
}
candidate, err := createCandidate(ctx, tx, *draft, t.Version.AuthorID, requestID, "", 0)
if err != nil {
return err
}
// Machine submission leaves the approval decision to an administrator.
if _, err := tx.Exec(ctx, `UPDATE veyra_candidate_revision SET status='PENDING_REVIEW',version=version+1 WHERE id=$1`, candidate.ID); err != nil {
return mapError(err)
}
if _, err := tx.Exec(ctx, `INSERT INTO veyra_candidate_review(id,revision_id,reviewer_id,decision,input_version,reason) VALUES($1,$2,$3,'SUBMIT',1,'自动日线筛选提交,等待人工审核')`, uuid.NewString(), candidate.ID, t.Version.AuthorID); err != nil {
return mapError(err)
}
revisionID = &candidate.ID
}
_, err = tx.Exec(ctx, `INSERT INTO veyra_strategy_screening(id,strategy_version_id,symbol,exchange,bar_date,source_id,source_version,source_adapter,observed_at,received_at,input_hash,input,status,candidate_revision_id) VALUES($1,$2,$3,$4,$5::date,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, uuid.NewString(), t.Version.ID, run.Symbol, run.Exchange, run.BarDate, run.SourceID, run.SourceVersion, run.SourceAdapter, run.ObservedAt, run.ReceivedAt, run.InputHash, run.Input, run.Status, revisionID)
if err != nil {
return mapError(err)
} // Unique key rolls back candidate/audit too, including concurrent workers.
if err := appendAudit(ctx, tx, t.Version.AuthorID, "STRATEGY_SCREENING_COMPLETED", "strategy_version", t.Version.ID, requestID, map[string]any{"symbol": run.Symbol, "exchange": run.Exchange, "bar_date": run.BarDate, "status": run.Status, "input_hash": run.InputHash, "candidate_revision_id": revisionID}); err != nil {
return err
}
return mapError(tx.Commit(ctx))
}
func (r *ScreeningRepository) List(ctx context.Context) ([]model.StrategyScreening, error) {
rows, err := r.pool.Query(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id ORDER BY x.created_at DESC,x.id DESC LIMIT 100`)
if err != nil {
return nil, mapError(err)
}
defer rows.Close()
list := make([]model.StrategyScreening, 0)
for rows.Next() {
var x model.StrategyScreening
if err := rows.Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt); err != nil {
return nil, mapError(err)
}
list = append(list, x)
}
return list, mapError(rows.Err())
}
func (r *ScreeningRepository) Detail(ctx context.Context, id string) (model.StrategyScreening, error) {
var x model.StrategyScreening
err := r.pool.QueryRow(ctx, `SELECT x.id,x.strategy_version_id,s.name,v.revision,x.symbol,x.exchange,x.bar_date::text,x.source_id,x.source_version,x.source_adapter,x.observed_at,x.received_at,x.input_hash,x.status,x.candidate_revision_id,x.created_at,x.input FROM veyra_strategy_screening x JOIN veyra_strategy_version v ON v.id=x.strategy_version_id JOIN veyra_strategy s ON s.id=v.strategy_id WHERE x.id=$1`, id).Scan(&x.ID, &x.StrategyVersionID, &x.StrategyName, &x.StrategyRevision, &x.Symbol, &x.Exchange, &x.BarDate, &x.SourceID, &x.SourceVersion, &x.SourceAdapter, &x.ObservedAt, &x.ReceivedAt, &x.InputHash, &x.Status, &x.CandidateRevisionID, &x.CreatedAt, &x.Input)
return x, mapError(err)
}
+2 -2
View File
@@ -27,7 +27,7 @@ func lockSimulationAccount(ctx context.Context, tx pgx.Tx, user, id string) erro
return nil return nil
} }
const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now()` const activePlanPredicate = `p.status IN ('ACTIVE','PART_FILLED') AND p.valid_until>now() AND r.status='PUBLISHED' AND c.published_revision_id=r.id AND r.valid_until>now() AND ` + screeningEligibility
func budgetMinor(ctx context.Context, tx pgx.Tx, account, exclude string) (int64, int64, error) { func budgetMinor(ctx context.Context, tx pgx.Tx, account, exclude string) (int64, int64, error) {
var cash, reserved int64 var cash, reserved int64
@@ -151,7 +151,7 @@ func (r *TradePlanRepository) Confirm(ctx context.Context, user, id, requestID s
return plan, mapError(tx.Commit(ctx)) return plan, mapError(tx.Commit(ctx))
} }
func invalidatePlans(ctx context.Context, tx pgx.Tx, user string) error { func invalidatePlans(ctx context.Context, tx pgx.Tx, user string) error {
rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id) RETURNING p.id,p.version`, user) rows, err := tx.Query(ctx, `UPDATE veyra_trade_plan p SET status='INVALIDATED',reserved_minor=0,version=p.version+1,updated_at=now() FROM veyra_candidate_revision r,veyra_candidate c,veyra_account a WHERE p.candidate_revision_id=r.id AND c.id=r.candidate_id AND a.id=p.account_id AND a.user_id=$1 AND p.status IN ('ACTIVE','PART_FILLED') AND (p.valid_until<=now() OR r.valid_until<=now() OR r.status<>'PUBLISHED' OR c.published_revision_id IS DISTINCT FROM r.id OR NOT (`+screeningEligibility+`)) RETURNING p.id,p.version`, user)
if err != nil { if err != nil {
return mapError(err) return mapError(err)
} }
+259
View File
@@ -0,0 +1,259 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"github.com/google/uuid"
"go.uber.org/zap"
)
type ScreeningRepository interface {
Targets(context.Context, model.ProviderConfig) ([]model.ScreeningTarget, error)
Record(context.Context, model.ScreeningTarget, model.StrategyScreening, *model.CandidateDraftInput) error
List(context.Context) ([]model.StrategyScreening, error)
Detail(context.Context, string) (model.StrategyScreening, error)
}
type ScreeningService struct {
repo ScreeningRepository
market *MarketService
development bool
cursor int
mu sync.Mutex
wake chan struct{}
state model.ScreeningWorkerState
}
func NewScreeningService(repo ScreeningRepository, market *MarketService, development bool) *ScreeningService {
return &ScreeningService{repo: repo, market: market, development: development, wake: make(chan struct{}, 1), state: model.ScreeningWorkerState{State: "IDLE", Message: "尚未运行"}}
}
func (s *ScreeningService) List(ctx context.Context, actor model.User) ([]model.StrategyScreening, error) {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return nil, apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return nil, apperror.ErrUnavailable
}
return s.repo.List(ctx)
}
func (s *ScreeningService) Detail(ctx context.Context, actor model.User, id string) (model.StrategyScreening, error) {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return model.StrategyScreening{}, apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return model.StrategyScreening{}, apperror.ErrUnavailable
}
if _, err := uuid.Parse(id); err != nil {
return model.StrategyScreening{}, apperror.ErrInvalidInput
}
return s.repo.Detail(ctx, id)
}
func (s *ScreeningService) State(actor model.User) (model.ScreeningWorkerState, error) {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return model.ScreeningWorkerState{}, apperror.ErrForbidden
}
if s == nil || s.repo == nil || s.market == nil || s.market.providers == nil || s.market.adapter == nil || !s.development {
return model.ScreeningWorkerState{}, apperror.ErrUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
return s.state, nil
}
func (s *ScreeningService) RequestRun(actor model.User) (model.ScreeningWorkerState, error) {
if _, err := s.State(actor); err != nil {
return model.ScreeningWorkerState{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.state.State == "RUNNING" || s.state.State == "QUEUED" {
return s.state, nil
}
s.state = model.ScreeningWorkerState{State: "QUEUED", Message: "已排队,等待后台开始筛选"}
select {
case s.wake <- struct{}{}:
default:
}
return s.state, nil
}
var screeningZone = time.FixedZone("Asia/Shanghai", 8*60*60)
// EvaluateScreening uses completed daily bars only. Re-fetching a historical quote
// never makes it fresh, and an unclosed session cannot generate a daily signal.
func EvaluateScreening(t model.ScreeningTarget, c model.ProviderConfig, h model.MarketHistory, now time.Time) (model.StrategyScreening, *model.CandidateDraftInput, error) {
var result model.StrategyScreening
p := t.Version.Parameters
q := p.Screening
i := t.Instrument
if q == nil || model.ValidateScreening(q) != nil || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || h.Symbol != i.Symbol || h.Exchange != i.Exchange || h.Source != c.MarketAdapter || h.SourceVersion != c.Version || h.Adjustment != "unadjusted" || i.SourceID != c.ID || i.SourceVersion != c.Version || i.ObservedAt.Before(now.Add(-24*time.Hour)) || i.ObservedAt.After(now.Add(time.Minute)) || i.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.Before(now.Add(-10*time.Minute)) || h.ReceivedAt.After(now.Add(time.Minute)) {
return result, nil, apperror.ErrUnavailable
}
day := i.ObservedAt.In(screeningZone).Format("2006-01-02")
localNow := now.In(screeningZone)
if day == localNow.Format("2006-01-02") && localNow.Hour() < 16 {
return result, nil, apperror.ErrUnavailable
}
if len(h.Bars) < p.LongWindow+1 || h.Bars[len(h.Bars)-1].Date != day {
return result, nil, apperror.ErrUnavailable
}
bars := append([]model.HistoricalBar(nil), h.Bars[len(h.Bars)-p.LongWindow-1:]...)
closes := make([]int64, len(bars))
previous := ""
for j, b := range bars {
d, e := time.Parse("2006-01-02", b.Date)
o, e1 := model.ParseMoney(b.Open)
hi, e2 := model.ParseMoney(b.High)
lo, e3 := model.ParseMoney(b.Low)
cl, e4 := model.ParseMoney(b.Close)
if e != nil || d.Format("2006-01-02") != b.Date || b.Date <= previous || b.Date > day || e1 != nil || e2 != nil || e3 != nil || e4 != nil || lo <= 0 || hi < lo || o < lo || o > hi || cl < lo || cl > hi {
return result, nil, apperror.ErrInvalidInput
}
closes[j] = cl
previous = b.Date
}
sum := func(end, n int) int64 {
var v int64
for _, x := range closes[end-n : end] {
v += x
}
return v
}
end := len(closes)
prevShort, prevLong := sum(end-1, p.ShortWindow), sum(end-1, p.LongWindow)
short, long := sum(end, p.ShortWindow), sum(end, p.LongWindow)
signal := prevShort*int64(p.LongWindow) <= prevLong*int64(p.ShortWindow) && short*int64(p.LongWindow) > long*int64(p.ShortWindow)
input, _ := json.Marshal(struct {
Parameters model.StrategyParameters `json:"parameters"`
Bars []model.HistoricalBar `json:"bars"`
}{p, bars})
hash := sha256.Sum256(input)
result = model.StrategyScreening{StrategyVersionID: t.Version.ID, Symbol: i.Symbol, Exchange: i.Exchange, BarDate: day, SourceID: c.ID, SourceVersion: c.Version, SourceAdapter: c.MarketAdapter, ObservedAt: i.ObservedAt, ReceivedAt: h.ReceivedAt, InputHash: hex.EncodeToString(hash[:]), Input: input, Status: "NO_SIGNAL"}
if !signal {
return result, nil, nil
}
base := closes[end-1]
low := base * (10000 - q.EntryBandBPS) / 10000
high := (base*(10000+q.EntryBandBPS) + 9999) / 10000
stop := base * (10000 - q.StopLossBPS) / 10000
target := (base*(10000+q.TargetBPS) + 9999) / 10000
draft := model.CandidateDraftInput{ObservedAt: i.ObservedAt, ValidUntil: now.Add(time.Duration(q.ValidHours) * time.Hour), Content: model.CandidateContent{
Symbol: i.Symbol, Exchange: i.Exchange, Name: i.Name, StrategyCode: "trend_following", Title: i.Name + " · 日线均线交叉待审核",
Thesis: fmt.Sprintf("已完成日线 %s 出现 %d / %d 日均线上穿。策略 %s 修订 %d,仅为规则筛选结果,需人工核验后发布。", day, p.ShortWindow, p.LongWindow, t.StrategyName, t.Version.Revision),
Evidence: []model.CandidateEvidence{{SourceURL: c.HistoryBaseURL, Locator: fmt.Sprintf("%s:%s 日线 %s", i.Exchange, i.Symbol, day), Summary: fmt.Sprintf("来源 %s 配置版本 %d;未复权收盘价 %s;输入摘要 %s。参数与日线快照在筛选记录中保留。", c.MarketAdapter, c.Version, model.FormatMoney(base), result.InputHash)}},
CounterEvidence: "公开数据可能缺失或延迟;未复权均线会受除权影响。此规则未核验公告、停复牌、涨跌停、流动性及策略有效性。",
Invalidation: "策略暂停或切换版本、候选过期或撤回后失效;复核价格区间及数据来源后方可发布。",
ReferencePlan: model.CandidateReferencePlan{EntryLow: model.FormatMoney(low), EntryHigh: model.FormatMoney(high), StopLoss: model.FormatMoney(stop), TargetPrice: model.FormatMoney(target)},
}}
if e := model.ValidateDraft(draft, now); e != nil {
return result, nil, e
}
result.Status = "SIGNAL"
return result, &draft, nil
}
func (s *ScreeningService) Process(ctx context.Context, logger *zap.Logger) (processErr error) {
started := time.Now().UTC()
s.mu.Lock()
if s.state.State == "RUNNING" {
s.mu.Unlock()
return apperror.ErrConflict
}
s.state = model.ScreeningWorkerState{State: "RUNNING", StartedAt: &started, Message: "正在获取日线并计算信号"}
s.mu.Unlock()
defer func() {
finished := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
s.state.FinishedAt = &finished
if processErr != nil {
s.state.State = "FAILED"
s.state.Message = "筛选未完成,请检查行情源与后台日志"
} else {
s.state.State = "COMPLETED"
s.state.Message = fmt.Sprintf("检查 %d 只,完成 %d 只,生成 %d 条待审核候选,跳过 %d 只,失败 %d 只", s.state.Targets, s.state.Completed, s.state.Generated, s.state.Skipped, s.state.Failed)
}
}()
c, err := s.market.configuration(ctx)
if err != nil {
return err
}
targets, err := s.repo.Targets(ctx, c)
if err != nil {
return err
}
// Rotate across eligible targets so an unavailable feed cannot starve other strategies.
if len(targets) == 0 {
return nil
}
s.mu.Lock()
start := s.cursor % len(targets)
count := min(20, len(targets))
s.state.Targets = count
s.cursor = (start + count) % len(targets)
s.mu.Unlock()
for offset := 0; offset < count; offset++ {
t := targets[(start+offset)%len(targets)]
if ctx.Err() != nil {
return ctx.Err()
}
now := time.Now().UTC()
task, cancel := context.WithTimeout(ctx, 30*time.Second)
h, e := s.market.adapter.History(task, c, t.Instrument.Symbol, t.Instrument.Exchange, now.AddDate(-3, 0, 0).Format("2006-01-02"), now.Format("2006-01-02"))
if e == nil {
var run model.StrategyScreening
var draft *model.CandidateDraftInput
run, draft, e = EvaluateScreening(t, c, h, time.Now().UTC())
if e == nil {
e = s.repo.Record(task, t, run, draft)
if e == nil {
s.mu.Lock()
s.state.Completed++
if draft != nil {
s.state.Generated++
}
s.mu.Unlock()
}
}
}
cancel()
if e != nil {
s.mu.Lock()
if errors.Is(e, apperror.ErrConflict) || errors.Is(e, apperror.ErrUnavailable) {
s.state.Skipped++
} else {
s.state.Failed++
}
s.mu.Unlock()
}
if e != nil && !errors.Is(e, apperror.ErrConflict) && !errors.Is(e, apperror.ErrUnavailable) && ctx.Err() == nil {
logger.Warn("strategy screening failed", zap.String("strategy_version_id", t.Version.ID), zap.String("symbol", t.Instrument.Symbol), zap.Error(e))
}
}
return nil
}
func (s *ScreeningService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || s.market == nil || s.market.adapter == nil || !s.development {
return
}
for {
if err := s.Process(ctx, logger); err != nil && !errors.Is(err, apperror.ErrUnavailable) && !errors.Is(err, apperror.ErrNotFound) && ctx.Err() == nil {
logger.Warn("strategy screening worker failed", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-s.wake:
case <-time.After(5 * time.Minute):
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"testing"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
)
func screeningFixture() (model.ScreeningTarget, model.ProviderConfig, model.MarketHistory, time.Time) {
now := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC) // 17:00 Shanghai
c := model.ProviderConfig{ID: "source", Version: 2, MarketAdapter: "sina_public", HistoryBaseURL: "https://quotes.example.cn"}
p := model.StrategyParameters{ShortWindow: 2, LongWindow: 3, Screening: &model.ScreeningParameters{Securities: []model.ScreeningSecurity{{Symbol: "600000", Exchange: "SSE"}}, EntryBandBPS: 100, StopLossBPS: 500, TargetBPS: 1000, ValidHours: 24}}
t := model.ScreeningTarget{StrategyID: "strategy", StrategyName: "均线研究", Version: model.StrategyVersion{ID: "version", Revision: 3, Parameters: p}, Instrument: model.Instrument{Symbol: "600000", Exchange: "SSE", Name: "fixture", SourceID: c.ID, SourceVersion: c.Version, ObservedAt: now.Add(-2 * time.Hour), ReceivedAt: now}}
h := model.MarketHistory{Symbol: "600000", Exchange: "SSE", Source: c.MarketAdapter, SourceVersion: c.Version, Adjustment: "unadjusted", ReceivedAt: now, Bars: []model.HistoricalBar{{Date: "2026-10-06", Open: "10.00", High: "10.00", Low: "10.00", Close: "10.00"}, {Date: "2026-10-07", Open: "9.00", High: "9.00", Low: "9.00", Close: "9.00"}, {Date: "2026-10-08", Open: "8.00", High: "8.00", Low: "8.00", Close: "8.00"}, {Date: "2026-10-09", Open: "20.01", High: "20.01", Low: "20.01", Close: "20.01"}}}
return t, c, h, now
}
func TestScreeningExactCompletedBarSignal(t *testing.T) {
target, c, h, now := screeningFixture()
run, draft, e := EvaluateScreening(target, c, h, now)
if e != nil || draft == nil || run.Status != "SIGNAL" || len(run.InputHash) != 64 {
t.Fatalf("signal: %v %#v", e, run)
}
plan := draft.Content.ReferencePlan
if plan.EntryLow != "19.80" || plan.EntryHigh != "20.22" || plan.StopLoss != "19.00" || plan.TargetPrice != "22.02" {
t.Fatalf("price precision: %+v", plan)
}
again, _, _ := EvaluateScreening(target, c, h, now.Add(time.Minute))
if again.InputHash != run.InputHash {
t.Fatal("hash depends on receive time")
}
h.Bars[3] = model.HistoricalBar{Date: "2026-10-09", Open: "7", High: "7", Low: "7", Close: "7"}
run, draft, e = EvaluateScreening(target, c, h, now)
if e != nil || draft != nil || run.Status != "NO_SIGNAL" {
t.Fatal("falling average manufactured signal")
}
}
func TestScreeningRejectsUnusableInputs(t *testing.T) {
for _, name := range []string{"intraday", "stale_observation", "stale_receive", "wrong_source", "missing_bar", "duplicate_bar", "invalid_ohlc", "invalid_rules", "future_receive"} {
t.Run(name, func(t *testing.T) {
target, c, h, now := screeningFixture()
switch name {
case "intraday":
now = now.Add(-2 * time.Hour)
target.Instrument.ReceivedAt = now
h.ReceivedAt = now
case "stale_observation":
target.Instrument.ObservedAt = now.Add(-25 * time.Hour)
case "stale_receive":
target.Instrument.ReceivedAt = now.Add(-11 * time.Minute)
case "wrong_source":
h.SourceVersion++
case "missing_bar":
h.Bars = h.Bars[:3]
case "duplicate_bar":
h.Bars[1].Date = h.Bars[0].Date
case "invalid_ohlc":
h.Bars[3].High = "19"
case "invalid_rules":
target.Version.Parameters.Screening.StopLossBPS = 100
case "future_receive":
h.ReceivedAt = now.Add(2 * time.Minute)
}
if _, draft, e := EvaluateScreening(target, c, h, now); e == nil || draft != nil {
t.Fatal("unsafe data generated candidate")
}
})
}
}
+3
View File
@@ -78,6 +78,9 @@ func (s *StrategyService) Save(ctx context.Context, actor model.User, id string,
if !strategyCodePattern.MatchString(input.Code) || input.Name == "" || utf8.RuneCountInString(input.Name) > 80 || input.Description == "" || utf8.RuneCountInString(input.Description) > 2000 || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || p.FeeBPS < 0 || p.FeeBPS > 1000 || p.SlippageBPS < 0 || p.SlippageBPS > 1000 || p.LotSize < 1 || p.LotSize > 10000 { if !strategyCodePattern.MatchString(input.Code) || input.Name == "" || utf8.RuneCountInString(input.Name) > 80 || input.Description == "" || utf8.RuneCountInString(input.Description) > 2000 || p.ShortWindow < 2 || p.LongWindow <= p.ShortWindow || p.LongWindow > 200 || p.FeeBPS < 0 || p.FeeBPS > 1000 || p.SlippageBPS < 0 || p.SlippageBPS > 1000 || p.LotSize < 1 || p.LotSize > 10000 {
return model.StrategyDetail{}, apperror.ErrInvalidInput return model.StrategyDetail{}, apperror.ErrInvalidInput
} }
if err := model.ValidateScreening(p.Screening); err != nil {
return model.StrategyDetail{}, err
}
return s.repo.Save(ctx, id, input, actor.ID, requestID) return s.repo.Save(ctx, id, input, actor.ID, requestID)
} }
func (s *StrategyService) Deploy(ctx context.Context, actor model.User, id, versionID, status string, expected int64, reason, requestID string) (model.StrategyDetail, error) { func (s *StrategyService) Deploy(ctx context.Context, actor model.User, id, versionID, status string, expected int64, reason, requestID string) (model.StrategyDetail, error) {
+7 -2
View File
@@ -99,6 +99,7 @@ func serve(ctx context.Context, c config.Config, logger *zap.Logger) error {
var strategyRepo service.StrategyRepository var strategyRepo service.StrategyRepository
var backtestRepo service.BacktestRepository var backtestRepo service.BacktestRepository
var marketRepo service.MarketRepository var marketRepo service.MarketRepository
var screeningRepo service.ScreeningRepository
vault, err := secret.NewVault(c.SecretEncryptionKey) vault, err := secret.NewVault(c.SecretEncryptionKey)
if err != nil { if err != nil {
return err return err
@@ -123,6 +124,7 @@ func serve(ctx context.Context, c config.Config, logger *zap.Logger) error {
strategyRepo = repository.NewStrategyRepository(pool) strategyRepo = repository.NewStrategyRepository(pool)
backtestRepo = repository.NewBacktestRepository(pool) backtestRepo = repository.NewBacktestRepository(pool)
marketRepo = repository.NewMarketRepository(pool) marketRepo = repository.NewMarketRepository(pool)
screeningRepo = repository.NewScreeningRepository(pool)
} }
redisClient := redis.NewClient(&redis.Options{Addr: c.RedisAddr, Username: c.RedisUsername, Password: c.RedisPassword, DB: c.RedisDB, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, MaxRetries: -1, ContextTimeoutEnabled: true}) redisClient := redis.NewClient(&redis.Options{Addr: c.RedisAddr, Username: c.RedisUsername, Password: c.RedisPassword, DB: c.RedisDB, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, MaxRetries: -1, ContextTimeoutEnabled: true})
defer redisClient.Close() defer redisClient.Close()
@@ -133,6 +135,7 @@ func serve(ctx context.Context, c config.Config, logger *zap.Logger) error {
} }
development := c.Environment == "local" || c.Environment == "test" development := c.Environment == "local" || c.Environment == "test"
marketService := service.NewMarketService(marketRepo, providerRepo, market.New(), development) marketService := service.NewMarketService(marketRepo, providerRepo, market.New(), development)
screenings := service.NewScreeningService(screeningRepo, marketService, development)
notifications := service.NewNotificationService(notificationRepo, development) notifications := service.NewNotificationService(notificationRepo, development)
backtests := service.NewBacktestService(backtestRepo, research.New(c.PythonExecutable), development, marketService) backtests := service.NewBacktestService(backtestRepo, research.New(c.PythonExecutable), development, marketService)
workerContext, workerCancel := context.WithCancel(ctx) workerContext, workerCancel := context.WithCancel(ctx)
@@ -142,8 +145,10 @@ func serve(ctx context.Context, c config.Config, logger *zap.Logger) error {
go func() { defer close(researchDone); backtests.Run(workerContext, logger) }() go func() { defer close(researchDone); backtests.Run(workerContext, logger) }()
marketDone := make(chan struct{}) marketDone := make(chan struct{})
go func() { defer close(marketDone); marketService.Run(workerContext, logger) }() go func() { defer close(marketDone); marketService.Run(workerContext, logger) }()
defer func() { workerCancel(); <-workerDone; <-researchDone; <-marketDone }() screeningDone := make(chan struct{})
identityOptions := handler.IdentityOptions{Market: marketService, Backtests: backtests, Strategies: service.NewStrategyService(strategyRepo, development), Notifications: notifications, Auth: auth, Fills: service.NewTradeFillService(fillRepo, development), Plans: service.NewTradePlanService(planRepo, development), Candidates: service.NewCandidateService(candidateRepo, development), Providers: service.NewProviderService(providerRepo, vault), Accounts: service.NewAccountService(accountRepo), Limiter: ratelimit.NewRedis(redisClient), DevelopmentLogin: development, SecureCookie: !development} go func() { defer close(screeningDone); screenings.Run(workerContext, logger) }()
defer func() { workerCancel(); <-workerDone; <-researchDone; <-marketDone; <-screeningDone }()
identityOptions := handler.IdentityOptions{Screenings: screenings, Market: marketService, Backtests: backtests, Strategies: service.NewStrategyService(strategyRepo, development), Notifications: notifications, Auth: auth, Fills: service.NewTradeFillService(fillRepo, development), Plans: service.NewTradePlanService(planRepo, development), Candidates: service.NewCandidateService(candidateRepo, development), Providers: service.NewProviderService(providerRepo, vault), Accounts: service.NewAccountService(accountRepo), Limiter: ratelimit.NewRedis(redisClient), DevelopmentLogin: development, SecureCookie: !development}
server := &http.Server{Addr: c.HTTPAddr, Handler: handler.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} server := &http.Server{Addr: c.HTTPAddr, Handler: handler.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
result := make(chan error, 1) result := make(chan error, 1)
go func() { result <- server.ListenAndServe() }() go func() { result <- server.ListenAndServe() }()
+17 -14
View File
@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react" import { Fragment, useEffect, useRef, useState } from "react"
import { Activity, ArrowUpRight, BarChart3, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users } from "lucide-react" import { Activity, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users, CandlestickChart } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
import { getHealth, type Health } from "@/lib/api" import { getHealth, type Health } from "@/lib/api"
import {type User} from "@/lib/auth" import {type User} from "@/lib/auth"
import {ProvidersPanel} from "@/components/ProvidersPanel" import {ProvidersPanel} from "@/components/ProvidersPanel"
import {NotificationTasksPanel} from "@/components/NotificationTasksPanel" import {NotificationTasksPanel} from "@/components/NotificationTasksPanel"
import {SimulationQuotesPanel} from "@/components/SimulationQuotesPanel" import {SimulationQuotesPanel} from "@/components/SimulationQuotesPanel"
import {TradingOverview} from "@/components/TradingOverview"
import {MarketPanel} from "@/components/MarketPanel" import {MarketPanel} from "@/components/MarketPanel"
import {BacktestsPanel} from "@/components/BacktestsPanel" import {BacktestsPanel} from "@/components/BacktestsPanel"
import {StrategiesPanel} from "@/components/StrategiesPanel" import {StrategiesPanel} from "@/components/StrategiesPanel"
@@ -16,7 +17,7 @@ import {AuditPanel} from "@/components/AuditPanel"
import {UsersPanel} from "@/components/UsersPanel" import {UsersPanel} from "@/components/UsersPanel"
const workspaces = [ const workspaces = [
{name:"总览", icon:LayoutDashboard}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2}, {name:"总览", icon:LayoutDashboard}, {name:"股票行情",icon:CandlestickChart}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2},
{name:"回测研究",icon:FlaskConical}, {name:"数据健康",icon:Database}, {name:"模型配置",icon:Sparkles}, {name:"回测研究",icon:FlaskConical}, {name:"数据健康",icon:Database}, {name:"模型配置",icon:Sparkles},
{name:"通知任务",icon:Bell}, {name:"用户管理",icon:Users}, {name:"审计日志",icon:ShieldCheck}, {name:"系统设置",icon:LockKeyhole}, {name:"通知任务",icon:Bell}, {name:"用户管理",icon:Users}, {name:"审计日志",icon:ShieldCheck}, {name:"系统设置",icon:LockKeyhole},
] ]
@@ -24,6 +25,9 @@ const label = {ready:"连接正常",unavailable:"无法连接",not_configured:"
export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout:()=>Promise<void>;onSessionExpired:()=>void}) { export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout:()=>Promise<void>;onSessionExpired:()=>void}) {
const [page,setPage] = useState("总览") const [page,setPage] = useState("总览")
const [marketSymbol,setMarketSymbol]=useState<string|undefined>()
const [candidateID,setCandidateID]=useState<string|undefined>()
function navigate(next:string,symbol?:string,candidate?:string){setMarketSymbol(symbol);setCandidateID(candidate);setPage(next)}
const [health,setHealth] = useState<Health | null>(null) const [health,setHealth] = useState<Health | null>(null)
const [error,setError] = useState("") const [error,setError] = useState("")
const [loading,setLoading] = useState(false) const [loading,setLoading] = useState(false)
@@ -41,19 +45,19 @@ export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout
finally {if (!next.signal.aborted) setLoading(false)} finally {if (!next.signal.aborted) setLoading(false)}
} }
useEffect(()=>{void refresh();return ()=>controller.current?.abort()},[]) useEffect(()=>{void refresh();return ()=>controller.current?.abort()},[])
const overview = page === "总览" || page === "数据健康" const overview = page === "数据健康"
return <div className="min-h-screen lg:grid lg:grid-cols-[238px_1fr]"> return <div className="min-h-screen lg:grid lg:grid-cols-[238px_1fr]">
<aside className="flex flex-col border-r bg-card px-5 py-7 lg:min-h-screen"> <aside className="flex flex-col border-r bg-card px-5 py-7 lg:min-h-screen">
<a href="#" onClick={()=>setPage("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">OPERATIONS CONSOLE</p></div></a> <a href="#" onClick={()=>navigate("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">A-SHARE RESEARCH</p></div></a>
<p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">工作空间</p> <p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">股票研究与审核</p>
<nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><button key={name} onClick={()=>setPage(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button>)}</nav> <nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><Fragment key={name}>{name==="数据健康"&&<p className="col-span-full mb-2 mt-6 px-3 text-[10px] tracking-[.18em] text-muted-foreground">运行与配置</p>}<button onClick={()=>navigate(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button></Fragment>)}</nav>
<div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>模拟业务流程已接通</div></div> <div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>公开行情 · 模拟业务<br/>自动筛选与人工审核</div></div>
</aside> </aside>
<div className="min-w-0"> <div className="min-w-0">
<header className="flex h-20 items-center justify-between border-b px-6 lg:px-10"><div className="flex items-center gap-2 text-sm text-muted-foreground">工作空间 <ChevronRight size={13}/><span className="text-foreground">{page}</span></div><div className="flex items-center gap-3"><span className="hidden text-xs text-muted-foreground sm:block">{user.display_name}</span><Badge variant="outline">LOCAL · 开发环境</Badge><Button variant="ghost" size="sm" onClick={()=>void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出"}</Button></div></header> <header className="flex h-20 items-center justify-between border-b px-6 lg:px-10"><div className="flex items-center gap-2 text-sm text-muted-foreground">工作空间 <ChevronRight size={13}/><span className="text-foreground">{page}</span></div><div className="flex items-center gap-3"><span className="hidden text-xs text-muted-foreground sm:block">{user.display_name}</span><Badge variant="outline">LOCAL · 开发环境</Badge><Button variant="ghost" size="sm" onClick={()=>void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出"}</Button></div></header>
<main className="mx-auto max-w-7xl p-6 lg:p-10"> <main className="mx-auto max-w-7xl p-6 lg:p-8">
{logoutError && <p role="alert" className="mb-6 text-sm text-destructive">{logoutError}</p>} {logoutError && <p role="alert" className="mb-6 text-sm text-destructive">{logoutError}</p>}
<div className="mb-8 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-3xl font-semibold tracking-tight">{page === "总览" ? "把每一步,建立在依据之上。" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "录入证据、审核修订并发布模拟候选。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div> <div className="mb-6 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-2xl font-semibold tracking-tight">{page === "总览" ? "A 股研究总览" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{page === "总览" ? "从真实股票行情,到策略信号与候选审核。" : page === "股票行情" ? "搜索沪深股票,查看报价、数据时间与真实日线走势。" : overview ? "检查行情链路、存储与任务依赖。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "审核自动生成的股票信号、证据与参考价格,发布到 App。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
{overview ? <> {overview ? <>
{error && <div role="alert" className="mb-6 rounded-lg border border-destructive/25 bg-destructive/5 p-4 text-sm text-destructive">无法读取当前服务状态。请检查 API 是否启动。{error}</div>} {error && <div role="alert" className="mb-6 rounded-lg border border-destructive/25 bg-destructive/5 p-4 text-sm text-destructive">无法读取当前服务状态。请检查 API 是否启动。{error}</div>}
<div className="mb-7 grid gap-4 md:grid-cols-3">{[ <div className="mb-7 grid gap-4 md:grid-cols-3">{[
@@ -61,9 +65,8 @@ export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout
{title:"PostgreSQL",subtitle:"业务事实与版本化迁移",icon:Database,value:loading ? "检查中" : health ? label[health.components.postgresql.status] : "未知"}, {title:"PostgreSQL",subtitle:"业务事实与版本化迁移",icon:Database,value:loading ? "检查中" : health ? label[health.components.postgresql.status] : "未知"},
{title:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"}, {title:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"},
].map(({title,subtitle,icon:Icon,value})=><Card key={title}><CardHeader><div className="flex items-center justify-between"><CardDescription>{title}</CardDescription><Icon size={18} className="text-muted-foreground"/></div></CardHeader><CardContent><p className="mb-2 text-2xl font-semibold">{value}</p><p className="text-xs text-muted-foreground">{subtitle}</p></CardContent></Card>)}</div> ].map(({title,subtitle,icon:Icon,value})=><Card key={title}><CardHeader><div className="flex items-center justify-between"><CardDescription>{title}</CardDescription><Icon size={18} className="text-muted-foreground"/></div></CardHeader><CardContent><p className="mb-2 text-2xl font-semibold">{value}</p><p className="text-xs text-muted-foreground">{subtitle}</p></CardContent></Card>)}</div>
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>第一条产品闭环</CardTitle><Badge>模拟流程已接通</Badge></div><CardDescription className="mt-2">先验证完整流程,再扩大策略与数据范围。</CardDescription></CardHeader><CardContent><ol className="space-y-5">{["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=><li key={v} className="flex items-center gap-4"><span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">0{i+1}</span><span className="text-sm">{v}</span><span className="ml-auto text-xs text-muted-foreground">已接通模拟流程</span></li>)}</ol></CardContent></Card><Card><CardHeader><BarChart3 size={24} className="mb-4 text-primary"/><CardTitle>数据接入后,开始观察。</CardTitle><CardDescription className="mt-2 leading-7">候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。</CardDescription></CardHeader><CardContent><div className="rounded-lg bg-muted p-4 text-xs leading-6 text-muted-foreground">当前状态来源:Go API 实时探测<br/>最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}</div><Button variant="ghost" className="mt-4 w-full justify-between" onClick={()=>setPage("数据健康")}>查看数据健康<ArrowUpRight/></Button></CardContent></Card></div> <details className="rounded-xl border bg-card p-5"><summary className="cursor-pointer text-sm font-medium">开发验证:模拟价格回放</summary><SimulationQuotesPanel onSessionExpired={onSessionExpired}/></details></> : page==="总览" ? <TradingOverview onSessionExpired={onSessionExpired} onNavigate={navigate}/> : page==="股票行情" ? <MarketPanel initialSymbol={marketSymbol} onSessionExpired={onSessionExpired}/> : page==="候选审核" ? <CandidatesPanel initialCandidateID={candidateID} onSessionExpired={onSessionExpired}/> : page==="回测研究" ? <BacktestsPanel onSessionExpired={onSessionExpired}/> : page==="策略中心" ? <StrategiesPanel onSessionExpired={onSessionExpired}/> : page==="通知任务" ? <NotificationTasksPanel onSessionExpired={onSessionExpired}/> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : page==="模型配置" ? <ProvidersPanel kind="model" onSessionExpired={onSessionExpired}/> : page==="系统设置" ? <ProvidersPanel kind="market" onSessionExpired={onSessionExpired}/> : page==="审计日志" ? <AuditPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>navigate("总览")}>返回总览</Button></CardContent></Card>}
{page==="数据健康"&&<><MarketPanel onSessionExpired={onSessionExpired}/><SimulationQuotesPanel onSessionExpired={onSessionExpired}/></>}</> : page==="候选审核" ? <CandidatesPanel onSessionExpired={onSessionExpired}/> : page==="回测研究" ? <BacktestsPanel onSessionExpired={onSessionExpired}/> : page==="策略中心" ? <StrategiesPanel onSessionExpired={onSessionExpired}/> : page==="通知任务" ? <NotificationTasksPanel onSessionExpired={onSessionExpired}/> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : page==="模型配置" ? <ProvidersPanel kind="model" onSessionExpired={onSessionExpired}/> : page==="系统设置" ? <ProvidersPanel kind="market" onSessionExpired={onSessionExpired}/> : page==="审计日志" ? <AuditPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>setPage("总览")}>返回总览</Button></CardContent></Card>} <footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>A 股研究工作台 · 开发版</span></footer>
<footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>模拟业务闭环 v0.8</span></footer>
</main> </main>
</div> </div>
</div> </div>
+9 -5
View File
@@ -1,4 +1,6 @@
import {useEffect,useRef,useState,type FormEvent} from "react" import {useEffect,useRef,useState,type FormEvent} from "react"
import {ScreeningRunControl} from "./ScreeningRunControl"
import {StockHistoryChart} from "./StockHistoryChart"
import {Plus,RefreshCw} from "lucide-react" import {Plus,RefreshCw} from "lucide-react"
import {Button} from "./ui/button" import {Button} from "./ui/button"
import {Input} from "./ui/input" import {Input} from "./ui/input"
@@ -10,7 +12,7 @@ const control="w-full rounded-md border bg-background px-3 py-2 text-sm focus:ou
const actions:Record<string,string>={submit:"提交审核",publish:"审核发布",return:"退回修订",withdraw:"撤回发布"} const actions:Record<string,string>={submit:"提交审核",publish:"审核发布",return:"退回修订",withdraw:"撤回发布"}
const localTime=(date:Date)=>new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16) const localTime=(date:Date)=>new Date(date.getTime()-date.getTimezoneOffset()*60000).toISOString().slice(0,16)
function blank():CandidateContent{return {symbol:"",exchange:"SSE",name:"",strategy_code:"trend_following",title:"",thesis:"",evidence:[{source_url:"",locator:"",summary:""}],counter_evidence:"",invalidation:"",reference_plan:{entry_low:"",entry_high:"",stop_loss:"",target_price:""}}} function blank():CandidateContent{return {symbol:"",exchange:"SSE",name:"",strategy_code:"trend_following",title:"",thesis:"",evidence:[{source_url:"",locator:"",summary:""}],counter_evidence:"",invalidation:"",reference_plan:{entry_low:"",entry_high:"",stop_loss:"",target_price:""}}}
export function CandidatesPanel({onSessionExpired}:{onSessionExpired:()=>void}){ export function CandidatesPanel({onSessionExpired,initialCandidateID}:{onSessionExpired:()=>void;initialCandidateID?:string}){
const [list,setList]=useState<Candidate[]>([]),[selected,setSelected]=useState<Candidate|null>(null),[status,setStatus]=useState("") const [list,setList]=useState<Candidate[]>([]),[selected,setSelected]=useState<Candidate|null>(null),[status,setStatus]=useState("")
const [error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[editing,setEditing]=useState(false),[source,setSource]=useState<Candidate|undefined>() const [error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[editing,setEditing]=useState(false),[source,setSource]=useState<Candidate|undefined>()
const [content,setContent]=useState<CandidateContent>(blank),[observed,setObserved]=useState(()=>localTime(new Date())),[until,setUntil]=useState(()=>localTime(new Date(Date.now()+86400000))) const [content,setContent]=useState<CandidateContent>(blank),[observed,setObserved]=useState(()=>localTime(new Date())),[until,setUntil]=useState(()=>localTime(new Date(Date.now()+86400000)))
@@ -19,20 +21,22 @@ export function CandidatesPanel({onSessionExpired}:{onSessionExpired:()=>void}){
function failed(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"操作失败,请重试。")} function failed(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"操作失败,请重试。")}
async function refresh(){controller.current?.abort();const next=new AbortController();controller.current=next;setLoading(true);try{const result=await getCandidates(next.signal,status);if(!next.signal.aborted){setList(result);setError("")}}catch(e){if(!next.signal.aborted)failed(e)}finally{if(!next.signal.aborted)setLoading(false)}} async function refresh(){controller.current?.abort();const next=new AbortController();controller.current=next;setLoading(true);try{const result=await getCandidates(next.signal,status);if(!next.signal.aborted){setList(result);setError("")}}catch(e){if(!next.signal.aborted)failed(e)}finally{if(!next.signal.aborted)setLoading(false)}}
useEffect(()=>{void refresh();return()=>{controller.current?.abort();detailController.current?.abort()}},[status]) useEffect(()=>{void refresh();return()=>{controller.current?.abort();detailController.current?.abort()}},[status])
useEffect(()=>{if(!initialCandidateID)return;const c=new AbortController();getCandidate(initialCandidateID,c.signal).then(result=>{if(!c.signal.aborted)setSelected(result)}).catch(e=>{if(!c.signal.aborted)failed(e)});return()=>c.abort()},[initialCandidateID])
async function select(c:Candidate){detailController.current?.abort();const next=new AbortController();detailController.current=next;setSelected(null);setAction("");setEditing(false);try{const detail=await getCandidate(c.id,next.signal);if(!next.signal.aborted)setSelected(detail)}catch(e){if(!next.signal.aborted)failed(e)}} async function select(c:Candidate){detailController.current?.abort();const next=new AbortController();detailController.current=next;setSelected(null);setAction("");setEditing(false);try{const detail=await getCandidate(c.id,next.signal);if(!next.signal.aborted)setSelected(detail)}catch(e){if(!next.signal.aborted)failed(e)}}
function edit(c?:Candidate){setSource(c);setContent(c?structuredClone(c.content):blank());setObserved(localTime(new Date()));setUntil(localTime(new Date(Date.now()+86400000)));setEditing(true);setAction("");setError("")} function edit(c?:Candidate){setSource(c);setContent(c?structuredClone(c.content):blank());setObserved(localTime(new Date()));setUntil(localTime(new Date(Date.now()+86400000)));setEditing(true);setAction("");setError("")}
async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const result=await saveDraft({content,observed_at:new Date(observed).toISOString(),valid_until:new Date(until).toISOString()},source);setEditing(false);await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}} async function save(e:FormEvent){e.preventDefault();setBusy(true);setError("");try{const result=await saveDraft({content,observed_at:new Date(observed).toISOString(),valid_until:new Date(until).toISOString()},source);setEditing(false);await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}}
async function confirm(e:FormEvent){e.preventDefault();if(!selected)return;setBusy(true);setError("");try{const result=await actOnCandidate(selected,action,reason);setAction("");setReason("");await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}} async function confirm(e:FormEvent){e.preventDefault();if(!selected)return;setBusy(true);setError("");try{const result=await actOnCandidate(selected,action,reason);setAction("");setReason("");await refresh();await select(result)}catch(e){failed(e)}finally{setBusy(false)}}
const set=(key:keyof CandidateContent,value:string)=>setContent(c=>({...c,[key]:value})) const set=(key:keyof CandidateContent,value:string)=>setContent(c=>({...c,[key]:value}))
return <div className="space-y-6"> return <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><Badge variant="outline">模拟候选</Badge><p className="text-sm text-muted-foreground">手工录入来源,保存修订后再提交审核。</p></div><div className="flex gap-2"><Button variant="outline" disabled={busy||loading} onClick={()=>void refresh()}><RefreshCw/>刷新</Button><Button disabled={busy} onClick={()=>edit()}><Plus/>录入候选</Button></div></div> <ScreeningRunControl onSessionExpired={onSessionExpired} onCompleted={()=>void refresh()}/>
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><Badge variant="outline">模拟候选</Badge><p className="text-sm text-muted-foreground">自动筛选形成队列,审核股票依据后发布。</p></div><div className="flex gap-2"><Button variant="outline" disabled={busy||loading} onClick={()=>void refresh()}><RefreshCw/>刷新</Button><Button variant="ghost" disabled={busy} onClick={()=>edit()}><Plus/>手工补录</Button></div></div>
{error&&<p role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">{error}</p>} {error&&<p role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">{error}</p>}
{editing&&<Card><CardHeader><CardTitle>{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}</CardTitle><CardDescription>原修订保留。证据、反证与参考价格需完整填写;当前不会自动获取或核验行情。</CardDescription></CardHeader><CardContent><form onSubmit={e=>void save(e)} className="space-y-5"><div className="grid gap-4 sm:grid-cols-3">{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=><label key={key} className="space-y-2 text-sm"><span>{label}</span><Input required value={content[key]} onChange={e=>set(key,e.target.value)} maxLength={key==="symbol"?6:120}/></label>)}<label className="space-y-2 text-sm">交易所<select className={control} value={content.exchange} onChange={e=>set("exchange",e.target.value)}><option value="SSE">上交所</option><option value="SZSE">深交所</option></select></label><label className="space-y-2 text-sm">研究方法<select className={control} value={content.strategy_code} onChange={e=>set("strategy_code",e.target.value)}><option value="trend_following">趋势观察</option><option value="pullback">回调观察</option><option value="event_driven">事件观察</option></select></label><label className="space-y-2 text-sm">观察时间<Input type="datetime-local" required value={observed} onChange={e=>setObserved(e.target.value)}/></label><label className="space-y-2 text-sm">有效截止<Input type="datetime-local" required value={until} onChange={e=>setUntil(e.target.value)}/></label></div> {editing&&<Card><CardHeader><CardTitle>{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}</CardTitle><CardDescription>原修订保留。证据、反证与参考价格需完整填写;此表单为手工录入;自动筛选结果从策略中心进入待审核。</CardDescription></CardHeader><CardContent><form onSubmit={e=>void save(e)} className="space-y-5"><div className="grid gap-4 sm:grid-cols-3">{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=><label key={key} className="space-y-2 text-sm"><span>{label}</span><Input required value={content[key]} onChange={e=>set(key,e.target.value)} maxLength={key==="symbol"?6:120}/></label>)}<label className="space-y-2 text-sm">交易所<select className={control} value={content.exchange} onChange={e=>set("exchange",e.target.value)}><option value="SSE">上交所</option><option value="SZSE">深交所</option></select></label><label className="space-y-2 text-sm">研究方法<select className={control} value={content.strategy_code} onChange={e=>set("strategy_code",e.target.value)}><option value="trend_following">趋势观察</option><option value="pullback">回调观察</option><option value="event_driven">事件观察</option></select></label><label className="space-y-2 text-sm">观察时间<Input type="datetime-local" required value={observed} onChange={e=>setObserved(e.target.value)}/></label><label className="space-y-2 text-sm">有效截止<Input type="datetime-local" required value={until} onChange={e=>setUntil(e.target.value)}/></label></div>
{([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=><label key={key} className="block space-y-2 text-sm"><span>{label}</span><textarea className={control} rows={3} required maxLength={key==="thesis"?2000:1000} value={content[key]} onChange={e=>set(key,e.target.value)}/></label>)} {([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=><label key={key} className="block space-y-2 text-sm"><span>{label}</span><textarea className={control} rows={3} required maxLength={key==="thesis"?2000:1000} value={content[key]} onChange={e=>set(key,e.target.value)}/></label>)}
<div className="space-y-3"><h3 className="text-sm font-medium">证据来源</h3>{content.evidence.map((evidence,i)=><div key={i} className="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">{([["来源 HTTPS 链接","source_url"],["页码 / 段落 / 标题","locator"],["证据摘要","summary"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required type={key==="source_url"?"url":"text"} value={evidence[key]} onChange={e=>setContent(c=>({...c,evidence:c.evidence.map((item,index)=>index===i?{...item,[key]:e.target.value}:item)}))}/></label>)}{content.evidence.length>1&&<Button type="button" variant="ghost" onClick={()=>setContent(c=>({...c,evidence:c.evidence.filter((_,index)=>index!==i)}))}>移除此证据</Button>}</div>)}<Button type="button" variant="outline" disabled={content.evidence.length>=8} onClick={()=>setContent(c=>({...c,evidence:[...c.evidence,{source_url:"",locator:"",summary:""}]}))}>添加证据</Button></div> <div className="space-y-3"><h3 className="text-sm font-medium">证据来源</h3>{content.evidence.map((evidence,i)=><div key={i} className="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">{([["来源 HTTPS 链接","source_url"],["页码 / 段落 / 标题","locator"],["证据摘要","summary"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required type={key==="source_url"?"url":"text"} value={evidence[key]} onChange={e=>setContent(c=>({...c,evidence:c.evidence.map((item,index)=>index===i?{...item,[key]:e.target.value}:item)}))}/></label>)}{content.evidence.length>1&&<Button type="button" variant="ghost" onClick={()=>setContent(c=>({...c,evidence:c.evidence.filter((_,index)=>index!==i)}))}>移除此证据</Button>}</div>)}<Button type="button" variant="outline" disabled={content.evidence.length>=8} onClick={()=>setContent(c=>({...c,evidence:[...c.evidence,{source_url:"",locator:"",summary:""}]}))}>添加证据</Button></div>
<div className="grid gap-4 sm:grid-cols-4">{([["参考区间下限","entry_low"],["参考区间上限","entry_high"],["风险价格","stop_loss"],["目标观察价格","target_price"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required inputMode="decimal" value={content.reference_plan[key]} onChange={e=>setContent(c=>({...c,reference_plan:{...c.reference_plan,[key]:e.target.value}}))}/></label>)}</div><p className="text-xs text-muted-foreground">金额最多两位小数;风险价格 &lt; 下限 ≤ 上限 &lt; 目标价格。观察时间限最近 24 小时,有效期最长 7 天。</p><div className="flex gap-3"><Button disabled={busy} type="submit">{busy?"保存中":"保存新草稿"}</Button><Button type="button" variant="outline" disabled={busy} onClick={()=>setEditing(false)}>取消</Button></div></form></CardContent></Card>} <div className="grid gap-4 sm:grid-cols-4">{([["参考区间下限","entry_low"],["参考区间上限","entry_high"],["风险价格","stop_loss"],["目标观察价格","target_price"]] as const).map(([label,key])=><label className="space-y-2 text-sm" key={key}><span>{label}</span><Input required inputMode="decimal" value={content.reference_plan[key]} onChange={e=>setContent(c=>({...c,reference_plan:{...c.reference_plan,[key]:e.target.value}}))}/></label>)}</div><p className="text-xs text-muted-foreground">金额最多两位小数;风险价格 &lt; 下限 ≤ 上限 &lt; 目标价格。观察时间限最近 24 小时,有效期最长 7 天。</p><div className="flex gap-3"><Button disabled={busy} type="submit">{busy?"保存中":"保存新草稿"}</Button><Button type="button" variant="outline" disabled={busy} onClick={()=>setEditing(false)}>取消</Button></div></form></CardContent></Card>}
<div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">没有符合此状态的候选。可从录入候选开始。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-sm">{c.content.name} · {c.content.symbol}</strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card> <div className="grid items-start gap-6 xl:grid-cols-[.9fr_1.3fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>候选队列</CardTitle><select aria-label="候选状态" className={`${control} max-w-32`} value={status} onChange={e=>setStatus(e.target.value)}><option value="">全部状态</option>{Object.entries(states).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></div><CardDescription>最近 100 条最新修订及当前发布版本。</CardDescription></CardHeader><CardContent className="space-y-2">{loading&&<p className="text-sm text-muted-foreground">正在读取…</p>}{!loading&&list.length===0&&<p className="py-8 text-center text-sm text-muted-foreground">尚无符合此状态的股票候选。点击上方自动筛选,命中结果会进入待审核;无需手工录入。</p>}{list.map(c=><button disabled={busy} key={c.id} onClick={()=>void select(c)} className={`w-full rounded-lg border p-4 text-left transition hover:bg-muted ${selected?.id===c.id?"border-primary bg-accent/40":""}`}><div className="flex items-center justify-between gap-2"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">{states[c.status]}</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><div className="my-3 flex items-end gap-2"><span className="text-xs text-muted-foreground">参考区间</span><strong className="font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</strong></div><p className="text-xs text-muted-foreground">风险 ¥{c.content.reference_plan.stop_loss} · 目标观察 ¥{c.content.reference_plan.target_price}</p><p className="mt-3 text-sm">{c.content.title}</p><p className="mt-2 text-xs text-muted-foreground">修订 {c.revision} · 版本 {c.version} · {c.is_active_publication?"当前有效发布":c.status==="PUBLISHED"?"已过期或被替代":"模拟研究"}</p></button>)}</CardContent></Card>
<Card><CardHeader><CardTitle>{selected?selected.content.title:"修订详情与审核"}</CardTitle><CardDescription>{selected?`${selected.content.name} ${selected.content.exchange}:${selected.content.symbol} · 修订 ${selected.revision}`:"选择候选查看依据与审核记录。"}</CardDescription></CardHeader><CardContent className="space-y-5">{selected&&<><div className="flex flex-wrap gap-2"><Badge>{states[selected.status]}</Badge><Badge variant="outline">模拟候选</Badge><Badge variant="outline">版本 {selected.version}</Badge></div><p className="text-xs text-muted-foreground">观察 {new Date(selected.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(selected.valid_until).toLocaleString("zh-CN")}</p>{([["观察逻辑",selected.content.thesis],["反证与风险",selected.content.counter_evidence],["失效条件",selected.content.invalidation]]).map(([label,value])=><section key={label}><h3 className="mb-2 text-sm font-semibold">{label}</h3><p className="whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{value}</p></section>)}<section><h3 className="mb-3 text-sm font-semibold">证据</h3>{selected.content.evidence.map((e,i)=><div key={i} className="mb-3 rounded-lg bg-muted p-4 text-sm"><a href={e.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline">{e.locator}</a><p className="mt-2 whitespace-pre-wrap leading-6">{e.summary}</p></div>)}</section><div className="rounded-lg border p-4 text-sm leading-7">参考区间 ¥{selected.content.reference_plan.entry_low}–{selected.content.reference_plan.entry_high}<br/>风险价格 ¥{selected.content.reference_plan.stop_loss} · 目标观察 ¥{selected.content.reference_plan.target_price}</div> <Card><CardHeader><CardTitle>{selected?`${selected.content.name} · ${selected.content.symbol}`:"股票依据与审核"}</CardTitle><CardDescription>{selected?`${selected.content.name} ${selected.content.exchange}:${selected.content.symbol} · 修订 ${selected.revision}`:"选择候选查看依据与审核记录。"}</CardDescription></CardHeader><CardContent className="space-y-5">{selected&&<><StockHistoryChart symbol={selected.content.symbol} exchange={selected.content.exchange} onSessionExpired={onSessionExpired}/><div className="flex flex-wrap gap-2"><Badge>{states[selected.status]}</Badge><Badge variant="outline">模拟候选</Badge><Badge variant="outline">版本 {selected.version}</Badge></div>{selected.screening&&<div className="rounded-lg border p-4 text-sm leading-7"><strong>自动日线筛选</strong><br/>{selected.screening.strategy_name} · 修订 {selected.screening.strategy_revision}<br/>日线 {selected.screening.bar_date} · {selected.screening.source_adapter} 配置版本 {selected.screening.source_version}<p className="break-all text-xs text-muted-foreground">输入摘要 {selected.screening.input_hash}</p></div>}<p className="text-xs text-muted-foreground">观察 {new Date(selected.observed_at).toLocaleString("zh-CN")} · 有效截止 {new Date(selected.valid_until).toLocaleString("zh-CN")}</p>{([["观察逻辑",selected.content.thesis],["反证与风险",selected.content.counter_evidence],["失效条件",selected.content.invalidation]]).map(([label,value])=><section key={label}><h3 className="mb-2 text-sm font-semibold">{label}</h3><p className="whitespace-pre-wrap text-sm leading-7 text-muted-foreground">{value}</p></section>)}<section><h3 className="mb-3 text-sm font-semibold">证据</h3>{selected.content.evidence.map((e,i)=><div key={i} className="mb-3 rounded-lg bg-muted p-4 text-sm"><a href={e.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline">{e.locator}</a><p className="mt-2 whitespace-pre-wrap leading-6">{e.summary}</p></div>)}</section><div className="rounded-lg border p-4 text-sm leading-7">参考区间 ¥{selected.content.reference_plan.entry_low}–{selected.content.reference_plan.entry_high}<br/>风险价格 ¥{selected.content.reference_plan.stop_loss} · 目标观察 ¥{selected.content.reference_plan.target_price}</div>
<div className="flex flex-wrap gap-2">{selected.is_latest&&selected.status!=="PENDING_REVIEW"&&<Button variant="outline" disabled={busy} onClick={()=>edit(selected)}>创建新修订</Button>}{(selected.is_latest&&selected.status==="DRAFT"?["submit"]:selected.is_latest&&selected.status==="PENDING_REVIEW"?["publish","return"]:selected.is_active_publication?["withdraw"]:[]).map(a=><Button key={a} disabled={busy} variant={a==="publish"?"default":"outline"} onClick={()=>{setAction(a);setReason("")}}>{actions[a]}</Button>)}</div> <div className="flex flex-wrap gap-2">{selected.is_latest&&selected.status!=="PENDING_REVIEW"&&<Button variant="outline" disabled={busy} onClick={()=>edit(selected)}>创建新修订</Button>}{(selected.is_latest&&selected.status==="DRAFT"?["submit"]:selected.is_latest&&selected.status==="PENDING_REVIEW"?["publish","return"]:selected.is_active_publication?["withdraw"]:[]).map(a=><Button key={a} disabled={busy} variant={a==="publish"?"default":"outline"} onClick={()=>{setAction(a);setReason("")}}>{actions[a]}</Button>)}</div>
{action&&<form onSubmit={e=>void confirm(e)} className="space-y-3 rounded-lg border border-primary/30 p-4"><p className="text-sm font-semibold">确认{actions[action]}:{selected.content.name},修订 {selected.revision},版本 {selected.version}</p><p className="text-xs text-muted-foreground">{action==="publish"?"发布后 App 可读取此模拟候选;旧发布版本将被替代。":action==="withdraw"?"撤回后此版本从有效候选列表移除,历史链接仍显示失效状态。":"此操作会写入不可覆盖的审核记录。"}</p><textarea required maxLength={1000} aria-label="审核意见" placeholder="填写具体理由或审核意见" rows={3} className={control} value={reason} onChange={e=>setReason(e.target.value)}/><div className="flex gap-2"><Button disabled={busy||!reason.trim()} type="submit">{busy?"提交中":"确认"}</Button><Button disabled={busy} type="button" variant="ghost" onClick={()=>setAction("")}>取消</Button></div></form>} {action&&<form onSubmit={e=>void confirm(e)} className="space-y-3 rounded-lg border border-primary/30 p-4"><p className="text-sm font-semibold">确认{actions[action]}:{selected.content.name},修订 {selected.revision},版本 {selected.version}</p><p className="text-xs text-muted-foreground">{action==="publish"?"发布后 App 可读取此模拟候选;旧发布版本将被替代。":action==="withdraw"?"撤回后此版本从有效候选列表移除,历史链接仍显示失效状态。":"此操作会写入不可覆盖的审核记录。"}</p><textarea required maxLength={1000} aria-label="审核意见" placeholder="填写具体理由或审核意见" rows={3} className={control} value={reason} onChange={e=>setReason(e.target.value)}/><div className="flex gap-2"><Button disabled={busy||!reason.trim()} type="submit">{busy?"提交中":"确认"}</Button><Button disabled={busy} type="button" variant="ghost" onClick={()=>setAction("")}>取消</Button></div></form>}
<section><h3 className="mb-3 text-sm font-semibold">审核记录</h3>{selected.reviews?.length?selected.reviews.map(r=><div key={r.id} className="border-t py-3 text-sm"><strong>{actions[r.decision.toLowerCase()]??r.decision}</strong><span className="ml-3 text-xs text-muted-foreground">{new Date(r.created_at).toLocaleString("zh-CN")} · 输入版本 {r.input_version}</span><p className="mt-2 whitespace-pre-wrap text-muted-foreground">{r.reason}</p></div>):<p className="text-sm text-muted-foreground">尚无审核记录。</p>}</section></>}</CardContent></Card></div> <section><h3 className="mb-3 text-sm font-semibold">审核记录</h3>{selected.reviews?.length?selected.reviews.map(r=><div key={r.id} className="border-t py-3 text-sm"><strong>{actions[r.decision.toLowerCase()]??r.decision}</strong><span className="ml-3 text-xs text-muted-foreground">{new Date(r.created_at).toLocaleString("zh-CN")} · 输入版本 {r.input_version}</span><p className="mt-2 whitespace-pre-wrap text-muted-foreground">{r.reason}</p></div>):<p className="text-sm text-muted-foreground">尚无审核记录。</p>}</section></>}</CardContent></Card></div>
+5 -3
View File
@@ -1,13 +1,15 @@
import {StockHistoryChart} from "./StockHistoryChart"
import {useEffect,useState} from "react" import {useEffect,useState} from "react"
import {ApiError,request} from "@/lib/auth" import {ApiError,request} from "@/lib/auth"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
import {Card,CardContent,CardHeader,CardTitle} from "@/components/ui/card" import {Card,CardContent,CardHeader,CardTitle} from "@/components/ui/card"
type Instrument={id:string;symbol:string;exchange:string;name:string;price:string;observed_at:string;received_at:string} type Instrument={id:string;symbol:string;exchange:string;name:string;price:string;observed_at:string;received_at:string}
type Sync={id:string;status:string;instrument_count:number;message:string;finished_at:string} type Sync={id:string;status:string;instrument_count:number;message:string;finished_at:string}
export function MarketPanel({onSessionExpired}:{onSessionExpired:()=>void}){ export function MarketPanel({onSessionExpired,initialSymbol}:{onSessionExpired:()=>void;initialSymbol?:string}){
const [list,setList]=useState<Instrument[]>([]),[tasks,setTasks]=useState<Sync[]>([]),[query,setQuery]=useState(""),[error,setError]=useState(""),[busy,setBusy]=useState(false) const [selected,setSelected]=useState<Instrument|null>(null)
const [list,setList]=useState<Instrument[]>([]),[tasks,setTasks]=useState<Sync[]>([]),[query,setQuery]=useState(initialSymbol??""),[error,setError]=useState(""),[busy,setBusy]=useState(false)
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"行情读取失败")} function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"行情读取失败")}
async function load(){setBusy(true);setError("");try{const [stocks,runs]=await Promise.all([request<Instrument[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query)}`),request<Sync[]>("/api/admin/v1/market/syncs")]);setList(stocks);setTasks(runs)}catch(e){fail(e)}finally{setBusy(false)}} async function load(){setBusy(true);setError("");try{const [stocks,runs]=await Promise.all([request<Instrument[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query)}`),request<Sync[]>("/api/admin/v1/market/syncs")]);setList(stocks);setTasks(runs)}catch(e){fail(e)}finally{setBusy(false)}}
useEffect(()=>{void load()},[]) useEffect(()=>{void load()},[])
return <Card className="mt-6"><CardHeader><CardTitle>A 股自动行情同步</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-96 overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3">{v.name}<p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td>{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">暂无同步数据,请检查系统设置中的行情源与同步记录。</p>}</div><details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card> return <Card className="mt-6"><CardHeader><CardTitle>沪深股票池 · 行情与日线</CardTitle></CardHeader><CardContent className="space-y-5"><p className="text-sm leading-7 text-muted-foreground">启用公开行情适配器后,后端启动即同步沪深股票,此后每五分钟更新。此接口用于开发研究,价格按供应商时间展示,休市或同步失败时保留上次快照。不会把抓取时间当成实时行情时间。</p><form className="flex gap-3" onSubmit={e=>{e.preventDefault();void load()}}><input className="w-full rounded-lg border px-3 py-2 text-sm" placeholder="按股票代码或名称搜索" value={query} onChange={e=>setQuery(e.target.value)}/><Button disabled={busy}>查询 / 刷新</Button></form>{error&&<p className="text-destructive text-sm">{error}</p>}<div className="max-h-[560px] overflow-auto"><table className="w-full text-left text-sm"><thead><tr>{["股票","价格","行情时间"].map(v=><th key={v} className="p-3">{v}</th>)}</tr></thead><tbody>{list.map(v=><tr className="border-t" key={v.id}><td className="p-3"><button className="text-left text-base font-medium hover:text-primary" onClick={()=>setSelected(v)}>{v.name}</button><p className="text-xs text-muted-foreground">{v.exchange}:{v.symbol}</p></td><td className="font-mono text-lg">{v.price==="0.00"?"无报价":v.price+" 元"}</td><td className="text-xs">{new Date(v.observed_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table>{list.length===0&&<p className="p-3 text-muted-foreground">暂无同步数据,请检查系统设置中的行情源与同步记录。</p>}</div>{selected&&<div><div className="mb-4 flex items-end justify-between"><div><h2 className="text-xl font-semibold">{selected.name}</h2><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange}:{selected.symbol}</p></div><strong className="font-mono text-3xl">¥{selected.price}</strong></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<details><summary className="cursor-pointer text-sm">最近同步记录</summary><div className="mt-3 space-y-2">{tasks.length===0&&<p className="text-sm text-muted-foreground">还没有同步记录。</p>}{tasks.map(t=><div key={t.id} className="rounded-lg bg-muted p-3 text-xs">{new Date(t.finished_at).toLocaleString("zh-CN")} · {t.status==="COMPLETED"?`同步 ${t.instrument_count} 个证券`:t.message}</div>)}</div></details></CardContent></Card>
} }
@@ -0,0 +1,18 @@
import {useEffect,useRef,useState} from "react"
import {ScanLine} from "lucide-react"
import {request,ApiError} from "@/lib/auth"
import {Button} from "./ui/button"
type RunState={state:string;targets:number;completed:number;generated:number;skipped:number;failed:number;started_at:string|null;finished_at:string|null;message:string}
export function ScreeningRunControl({onSessionExpired,onCompleted}:{onSessionExpired:()=>void;onCompleted:()=>void}){
const [run,setRun]=useState<RunState|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState("")
const lastFinished=useRef<string|null>(null),completed=useRef(onCompleted)
completed.current=onCompleted
function fail(e:unknown){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"筛选状态读取失败")}
useEffect(()=>{const controller=new AbortController();let fetching=false
const refresh=async()=>{if(fetching)return;fetching=true;try{const result=await request<RunState>("/api/admin/v1/strategy-screening-status",{signal:controller.signal});if(controller.signal.aborted)return;setRun(result);if(result.finished_at&&lastFinished.current!==result.finished_at){lastFinished.current=result.finished_at;completed.current()}}catch(e){if(!controller.signal.aborted)fail(e)}finally{fetching=false}}
void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>{controller.abort();clearInterval(timer)}
},[])
async function scan(){setBusy(true);setError("");try{setRun(await request<RunState>("/api/admin/v1/strategy-screenings/run",{method:"POST",body:"{}"}))}catch(e){fail(e)}finally{setBusy(false)}}
const running=run?.state==="RUNNING"||run?.state==="QUEUED"
return <div className="rounded-xl border border-primary/20 bg-accent/40 p-5"><div className="flex flex-wrap items-center justify-between gap-4"><div><p className="font-medium">自动筛选 → 人工审核 → App 发布</p><p className="mt-2 text-sm text-muted-foreground">从已启用的策略范围获取真实日线,自动填写依据与价格规则,无需逐条录入股票。</p></div><Button disabled={busy||running} onClick={()=>void scan()}><ScanLine className={running?"animate-pulse":""}/>{running?"正在自动筛选":"立即自动筛选"}</Button></div><p className="mt-4 text-sm" aria-live="polite">{run?.finished_at&&"最近一批:"}{run?.message??"正在读取筛选状态…"}{run?.finished_at&&<span className="ml-3 text-xs text-muted-foreground">{new Date(run.finished_at).toLocaleString("zh-CN")}</span>}</p>{run?.state==="COMPLETED"&&run.targets===0&&<p className="mt-2 text-xs text-muted-foreground">当前没有新的可处理日线:已处理的结果会保留,数据陈旧或策略未启用时会等待下一次更新。</p>}{error&&<p role="alert" className="mt-3 text-sm text-destructive">{error}</p>}</div>
}
@@ -0,0 +1,32 @@
import {useEffect, useState} from "react"
import {request, ApiError} from "@/lib/auth"
import {Button} from "@/components/ui/button"
type Security = {id:string;symbol:string;exchange:string;name:string}
export function ScreeningSecurities({value,onChange,onSessionExpired}:{value:string;onChange:(value:string)=>void;onSessionExpired:()=>void}) {
const [query,setQuery]=useState("")
const [matches,setMatches]=useState<Security[]>([])
const [error,setError]=useState("")
const [loading,setLoading]=useState(false)
const selected=value.trim().split(/\s+/).filter(Boolean)
useEffect(()=>{
const controller=new AbortController()
if(!query.trim()){setMatches([]);setError("");setLoading(false);return()=>controller.abort()}
const timer=setTimeout(()=>{
setLoading(true);setError("")
request<Security[]>(`/api/admin/v1/instruments?q=${encodeURIComponent(query.trim())}`,{signal:controller.signal})
.then(result=>{if(!controller.signal.aborted)setMatches(result.slice(0,8))})
.catch(e=>{if(controller.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"搜索失败")})
.finally(()=>{if(!controller.signal.aborted)setLoading(false)})
},250)
return()=>{clearTimeout(timer);controller.abort()}
},[query])
return <div className="space-y-3">
<label className="block text-sm">从自动同步股票中选择<input value={query} onChange={e=>{setQuery(e.target.value);setMatches([])}} placeholder="搜索股票名称或代码" className="mt-2 w-full rounded-lg border bg-background px-3 py-2"/></label>
{loading&&<p className="text-xs text-muted-foreground">查询中…</p>}
{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
{matches.map(stock=>{const code=`${stock.exchange}:${stock.symbol}`;return <div key={stock.id} className="flex items-center justify-between gap-3 rounded-lg bg-muted px-3 py-2 text-sm"><span>{stock.name} · {code}</span><Button type="button" variant="outline" disabled={selected.includes(code)||selected.length>=100} onClick={()=>onChange([...selected,code].join("\n"))}>{selected.includes(code)?"已选择":"添加"}</Button></div>})}
<label className="block text-sm">股票范围(最多 100 只,每行如 SSE:600000 / SZSE:000001)<textarea required className="mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm" rows={4} value={value} onChange={e=>onChange(e.target.value)}/></label>
</div>
}
@@ -0,0 +1,14 @@
import {useEffect,useState} from "react"
import {request,ApiError} from "@/lib/auth"
type Bar={date:string;open:string;high:string;low:string;close:string}
type History={source:string;adjustment:string;received_at:string;bars:Bar[]}
export function StockHistoryChart({symbol,exchange,onSessionExpired}:{symbol:string;exchange:string;onSessionExpired:()=>void}){
const [data,setData]=useState<History|null>(null),[error,setError]=useState("")
useEffect(()=>{const c=new AbortController();setData(null);setError("");const end=new Date(),start=new Date();start.setDate(start.getDate()-90);request<History>(`/api/admin/v1/market/history?symbol=${symbol}&exchange=${exchange}&start=${start.toISOString().slice(0,10)}&end=${end.toISOString().slice(0,10)}`,{signal:c.signal}).then(result=>{if(!c.signal.aborted)setData(result)}).catch(e=>{if(c.signal.aborted)return;if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"日线读取失败")});return()=>c.abort()},[symbol,exchange])
const bars=data?.bars.slice(-40)??[]
// Integer cents for price parsing; division below only maps values to SVG pixels.
const cents=(price:string)=>{const [whole,fraction=""]=price.split(".");return Number(whole)*100+Number(fraction.padEnd(2,"0"))}
const values=bars.map(b=>cents(b.close)),min=Math.min(...values),max=Math.max(...values),range=max-min||1
const points=values.map((value,i)=>`${20+i*600/Math.max(values.length-1,1)},${150-(value-min)*120/range}`).join(" ")
return <div className="rounded-xl border bg-card p-4"><div className="flex items-center justify-between"><strong className="text-sm">日线收盘走势</strong><span className="text-xs text-muted-foreground">未复权 · 最近 {bars.length} 个交易日</span></div>{error?<p className="mt-5 text-sm text-destructive">{error}</p>:!data?<p className="py-12 text-center text-sm text-muted-foreground">正在获取真实日线…</p>:bars.length===0?<p className="py-12 text-center text-sm text-muted-foreground">此范围没有可用日线。</p>:<><svg viewBox="0 0 640 185" className="mt-4 w-full" role="img" aria-label={`${exchange}:${symbol} 真实日线收盘价`}><path d="M20 30H620 M20 90H620 M20 150H620" fill="none" stroke="var(--color-border)" strokeDasharray="4 4"/><polyline points={points} fill="none" stroke="var(--primary)" strokeWidth="2.5" strokeLinejoin="round"/><text x="20" y="178" fontSize="11" fill="#79736c">{bars[0].date}</text><text x="620" y="178" textAnchor="end" fontSize="11" fill="#79736c">{bars[bars.length-1].date}</text></svg><p className="text-xs text-muted-foreground">末日收盘 ¥{bars[bars.length-1].close} · 来源 {data.source} · 接收 {new Date(data.received_at).toLocaleTimeString("zh-CN")}</p></>}</div>
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
import {useEffect,useRef,useState} from "react"
import {ArrowUpRight,RefreshCw} from "lucide-react"
import {request,ApiError} from "@/lib/auth"
import type {Candidate} from "@/lib/candidates"
import {Button} from "./ui/button"
import {Badge} from "./ui/badge"
import {Card,CardHeader,CardTitle,CardContent} from "./ui/card"
import {StockHistoryChart} from "./StockHistoryChart"
import {ScreeningRunControl} from "./ScreeningRunControl"
type Stock={id:string;name:string;symbol:string;exchange:string;price:string;observed_at:string}
type Sync={id:string;status:string;instrument_count:number;finished_at:string;message:string}
type Strategy={id:string;status:string;name:string}
type Data={stocks:Stock[];candidates:Candidate[];syncs:Sync[];strategies:Strategy[]}
export function TradingOverview({onSessionExpired,onNavigate}:{onSessionExpired:()=>void;onNavigate:(page:string,symbol?:string,candidate?:string)=>void}){
const [data,setData]=useState<Data|null>(null),[error,setError]=useState(""),[loading,setLoading]=useState(false),[selected,setSelected]=useState<Stock|null>(null)
const controller=useRef<AbortController|null>(null)
async function refresh(){controller.current?.abort();const c=new AbortController();controller.current=c;setLoading(true);try{const [stocks,candidates,syncs,strategies]=await Promise.all([request<Stock[]>("/api/admin/v1/instruments",{signal:c.signal}),request<Candidate[]>("/api/admin/v1/candidates",{signal:c.signal}),request<Sync[]>("/api/admin/v1/market/syncs",{signal:c.signal}),request<Strategy[]>("/api/admin/v1/strategies",{signal:c.signal})]);if(c.signal.aborted)return;setData({stocks,candidates,syncs,strategies});setSelected(current=>current??stocks[0]??null);setError("")}catch(e){if(!c.signal.aborted){if(e instanceof ApiError&&e.status===401)onSessionExpired();else setError(e instanceof Error?e.message:"股票数据读取失败")}}finally{if(!c.signal.aborted)setLoading(false)}}
useEffect(()=>{void refresh();return()=>controller.current?.abort()},[])
const success=data?.syncs.find(x=>x.status==="COMPLETED"),pending=data?.candidates.filter(c=>c.status==="PENDING_REVIEW")??[],published=data?.candidates.filter(c=>c.is_active_publication)??[]
return <div className="space-y-6"><div className="flex items-center justify-between"><div className="flex gap-2"><Badge variant="outline">沪深 A 股</Badge><Badge variant="outline">公开行情 · 开发研究</Badge></div><Button variant="outline" disabled={loading} onClick={()=>void refresh()}><RefreshCw className={loading?"animate-spin":""}/>刷新数据</Button></div>{error&&<p role="alert" className="text-sm text-destructive">{error}</p>}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{[{title:"已同步股票",value:success?.instrument_count??"—",note:success?`最近成功同步 ${new Date(success.finished_at).toLocaleTimeString("zh-CN")}`:"等待行情源同步",page:"股票行情"},{title:"待审核股票候选",value:data?pending.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"有效发布候选",value:data?published.length:"—",note:"最近 100 条候选修订",page:"候选审核"},{title:"已启用研究策略",value:data?data.strategies.filter(s=>s.status==="ACTIVE").length:"—",note:"最近 100 个策略 · 非收益指标",page:"策略中心"}].map(x=><button key={x.title} className="rounded-xl border bg-card p-5 text-left transition hover:border-primary/40" onClick={()=>onNavigate(x.page)}><div className="flex items-center justify-between"><span className="text-sm text-muted-foreground">{x.title}</span><ArrowUpRight size={15} className="text-muted-foreground"/></div><p className="my-3 font-mono text-4xl font-semibold tracking-tight">{x.value}</p><p className="text-xs text-muted-foreground">{x.note}</p></button>)}</div>
<ScreeningRunControl onSessionExpired={onSessionExpired} onCompleted={()=>void refresh()}/>
<div className="grid items-start gap-6 xl:grid-cols-[1.35fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>股票观察</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("股票行情")}>查看股票池<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">按证券代码排序的行情样本 · 非涨幅排名或投资推荐</p></CardHeader><CardContent>{selected&&<div className="mb-5"><div className="mb-4 flex items-end justify-between"><div><button className="text-xl font-semibold hover:text-primary" onClick={()=>onNavigate("股票行情",selected.symbol)}>{selected.name}</button><p className="mt-1 font-mono text-xs text-muted-foreground">{selected.exchange} · {selected.symbol}</p></div><div className="text-right"><p className="font-mono text-3xl font-semibold">¥{selected.price}</p><p className="mt-1 text-xs text-muted-foreground">报价 {new Date(selected.observed_at).toLocaleString("zh-CN")}</p></div></div><StockHistoryChart symbol={selected.symbol} exchange={selected.exchange} onSessionExpired={onSessionExpired}/></div>}<div className="overflow-auto"><table className="w-full text-left text-sm"><thead className="text-xs text-muted-foreground"><tr><th className="pb-3">股票 / 代码</th><th className="pb-3 text-right">最新报价</th><th className="pb-3 text-right">行情时间</th></tr></thead><tbody>{data?.stocks.slice(0,8).map(stock=><tr key={stock.id} className="border-t"><td className="py-3"><button className="text-left hover:text-primary" onClick={()=>setSelected(stock)}><strong>{stock.name}</strong><p className="mt-1 font-mono text-xs text-muted-foreground">{stock.exchange}:{stock.symbol}</p></button></td><td className="text-right font-mono">¥{stock.price}</td><td className="text-right text-xs text-muted-foreground">{new Date(stock.observed_at).toLocaleTimeString("zh-CN")}</td></tr>)}</tbody></table>{data?.stocks.length===0&&<p className="py-8 text-sm text-muted-foreground">暂无股票快照,请在行情源设置中检查同步配置。</p>}</div></CardContent></Card>
<Card><CardHeader><div className="flex items-center justify-between"><CardTitle>待审核信号</CardTitle><Button variant="ghost" size="sm" onClick={()=>onNavigate("候选审核")}>进入审核<ArrowUpRight/></Button></div><p className="mt-2 text-xs text-muted-foreground">从股票、参考区间与信号依据开始审核</p></CardHeader><CardContent className="space-y-3">{pending.length===0&&<div className="rounded-xl border border-dashed p-6 text-sm leading-7 text-muted-foreground">当前没有待审核股票。点击“立即自动筛选”后,真实日线命中结果会自动进入这里;不符合规则的股票不会生成候选。</div>}{pending.slice(0,8).map(c=><button key={c.id} className="w-full rounded-xl border p-4 text-left hover:border-primary/40" onClick={()=>onNavigate("候选审核",undefined,c.id)}><div className="flex items-center justify-between"><strong className="text-lg">{c.content.name}</strong><Badge variant="outline">待审核</Badge></div><p className="mt-1 font-mono text-xs text-muted-foreground">{c.content.exchange} · {c.content.symbol}</p><p className="my-3 font-mono text-xl">¥{c.content.reference_plan.entry_low}–{c.content.reference_plan.entry_high}</p><p className="text-xs leading-6 text-muted-foreground">{c.screening?`${c.screening.strategy_name} · 日线 ${c.screening.bar_date}`:"人工补充研究"}<br/>有效至 {new Date(c.valid_until).toLocaleString("zh-CN")}</p></button>)}</CardContent></Card></div>
<p className="text-xs leading-6 text-muted-foreground">价格按供应商观察时间展示,收盘后保留收盘快照。曲线来自实际未复权日线;当前未接入指数、涨跌幅、成交额与行业分类,因此不展示相应统计。</p></div>
}
+1 -1
View File
@@ -1,6 +1,6 @@
import {request} from "./auth" import {request} from "./auth"
export type CandidateContent={symbol:string;exchange:string;name:string;strategy_code:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}} export type CandidateContent={symbol:string;exchange:string;name:string;strategy_code:string;title:string;thesis:string;evidence:{source_url:string;locator:string;summary:string}[];counter_evidence:string;invalidation:string;reference_plan:{entry_low:string;entry_high:string;stop_loss:string;target_price:string}}
export type Candidate={id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";content:CandidateContent;observed_at:string;valid_until:string;published_at:string|null;is_latest:boolean;is_active_publication:boolean;reviews?:{id:string;decision:string;reason:string;input_version:number;created_at:string}[]} export type Candidate={screening?:{candidate_revision_id:string;strategy_version_id:string;strategy_name:string;strategy_revision:number;bar_date:string;source_adapter:string;source_version:number;input_hash:string};id:string;candidate_id:string;revision:number;version:number;status:string;data_mode:"simulation";content:CandidateContent;observed_at:string;valid_until:string;published_at:string|null;is_latest:boolean;is_active_publication:boolean;reviews?:{id:string;decision:string;reason:string;input_version:number;created_at:string}[]}
export type Draft={content:CandidateContent;observed_at:string;valid_until:string} export type Draft={content:CandidateContent;observed_at:string;valid_until:string}
export const states:Record<string,string>={DRAFT:"草稿",PENDING_REVIEW:"待审核",PUBLISHED:"已发布",RETURNED:"已退回",WITHDRAWN:"已撤回"} export const states:Record<string,string>={DRAFT:"草稿",PENDING_REVIEW:"待审核",PUBLISHED:"已发布",RETURNED:"已退回",WITHDRAWN:"已撤回"}
export function getCandidates(signal?:AbortSignal,status=""){return request<Candidate[]>(`/api/admin/v1/candidates${status?`?status=${encodeURIComponent(status)}`:""}`,{signal})} export function getCandidates(signal?:AbortSignal,status=""){return request<Candidate[]>(`/api/admin/v1/candidates${status?`?status=${encodeURIComponent(status)}`:""}`,{signal})}