diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b247b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.gitea +**/node_modules +**/dist +**/.expo +**/.env +**/.env.* +**/*.log +bin +logs +.cache +.idea +.DS_Store +**/__pycache__ diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..54737ee --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -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'" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d7300ce..cfb7c64 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,5 +1,7 @@ name: Checks -on: [push, pull_request] +on: + push: + branches: [master] jobs: verify: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 83899e6..7285e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ internal/adminui/dist/* expo-env.d.ts /logs/ + +# CI 临时镜像归档和 Python 缓存 +/image.tar.gz* +**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index f6e06c9..b8d2935 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,5 @@ - 后端环境文件与模板位于 configs/;管理端和 App 分别维护自己的 npm 配置、锁文件与依赖,不在根目录建立 npm workspace。 - 后端日志使用 zap:控制台易读,当前运行目录 logs/ 下自动写文件与轮转;日志不提交,不记录密钥、口令、Cookie 或请求正文。 + +- 开发在 dev 分支进行,禁止直接提交或推送 master(管理员也须遵守);通过合并请求进入 master。CI/CD 仅监听 master 的 push,包含合并提交;dev 提交不触发部署。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ab4f00 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index ea46711..65e1745 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ npm --prefix apps/mobile run start ### 后台供应商配置 -「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,当前尚未实现供应商调用与连通性测试。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。 +「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,模型调用与连通性测试尚未实现;行情适配器已接通,实际状态见数据健康页面。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。 `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)。 @@ -154,6 +154,22 @@ go.mod / go.sum 根目录唯一 Go module 后端使用 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_*` 配置。尚未完成真机界面验证。 + + +## 自动候选筛选与提醒 + +管理端「策略中心」修订研究版本,勾选自动候选筛选,从自动同步股票搜索选择沪深范围(最多 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)。 diff --git a/api/openapi.yaml b/api/openapi.yaml index 5916b55..3dafb58 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Veyra API - version: 0.12.0 + version: 0.15.0 description: 开发身份、账户、供应商配置、候选、计划、成交账本、站内通知、研究策略、Python 回测与公开 A 股开发行情。模拟工作流仅限 local/test;正式行情授权、完整交易规则及远程推送未完成。 servers: - url: http://127.0.0.1:8080 @@ -1538,6 +1538,91 @@ paths: $ref: "#/components/responses/Error" "503": $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: securitySchemes: adminCookie: @@ -2219,7 +2304,7 @@ components: code: *a2 name: *a2 description: *a2 - expected_version: &a3 + expected_version: &a6 type: integer parameters: type: object @@ -2230,11 +2315,13 @@ components: - slippage_bps - lot_size properties: - short_window: *a3 - long_window: *a3 - fee_bps: *a3 - slippage_bps: *a3 - lot_size: *a3 + short_window: *a6 + long_window: *a6 + fee_bps: *a6 + slippage_bps: *a6 + lot_size: *a6 + screening: + $ref: "#/components/schemas/ScreeningParameters" StrategyDeployment: type: object required: @@ -2248,7 +2335,7 @@ components: enum: - ACTIVE - PAUSED - expected_version: *a3 + expected_version: *a6 reason: *a2 HistoricalBar: type: object @@ -2318,3 +2405,51 @@ components: code: *a2 message: *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 diff --git a/apps/mobile/app/(tabs)/opportunities.tsx b/apps/mobile/app/(tabs)/opportunities.tsx index 3e36061..50f021d 100644 --- a/apps/mobile/app/(tabs)/opportunities.tsx +++ b/apps/mobile/app/(tabs)/opportunities.tsx @@ -7,5 +7,5 @@ import type {Candidate} from "../../src/lib/candidates" export default function Opportunities(){ const {session,client}=useSession(),router=useRouter() const query=useQuery({queryKey:["candidates",session?.user.id],enabled:!!session,queryFn:({signal})=>client("/api/v1/candidates",{signal}),refetchInterval:30000}) - return 机会从证据、反证与失效条件理解一个候选。模拟研究候选 · 由管理端手工录入并审核发布,当前未接入自动行情筛选。)} -
开发环境
模拟业务流程已接通
+ navigate("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline">V
见势 · Veyra

A-SHARE RESEARCH

+

股票研究与审核

+ +
开发环境
公开行情 · 模拟业务
自动筛选与人工审核
工作空间 {page}
{user.display_name}LOCAL · 开发环境
-
+
{logoutError &&

{logoutError}

} -

VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}

{page === "总览" ? "把每一步,建立在依据之上。" : page}

{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "录入证据、审核修订并发布模拟候选。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}

{overview && }
+

VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}

{page === "总览" ? "A 股研究总览" : page}

{page === "总览" ? "从真实股票行情,到策略信号与候选审核。" : page === "股票行情" ? "搜索沪深股票,查看报价、数据时间与真实日线走势。" : overview ? "检查行情链路、存储与任务依赖。" : page === "模型配置" || page === "系统设置" ? "配置供应商连接信息,按需启用。" : page === "审计日志" ? "查看后台配置的操作记录。" : page === "通知任务" ? "查看持久化事件与站内通知处理状态。" : page === "候选审核" ? "审核自动生成的股票信号、证据与参考价格,发布到 App。" : page === "策略中心" ? "管理研究参数与版本历史。" : page === "回测研究" ? "提交历史数据,查看可追溯的 Python 回测结果。" : "该工作区的业务功能正在建设中。"}

{overview && }
{overview ? <> {error &&
无法读取当前服务状态。请检查 API 是否启动。{error}
}
{[ @@ -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:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"}, ].map(({title,subtitle,icon:Icon,value})=>
{title}

{value}

{subtitle}

)}
-
第一条产品闭环模拟流程已接通
先验证完整流程,再扩大策略与数据范围。
    {["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=>
  1. 0{i+1}{v}已接通模拟流程
  2. )}
数据接入后,开始观察。候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。
当前状态来源:Go API 实时探测
最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}
- {page==="数据健康"&&<>} : page==="候选审核" ? : page==="回测研究" ? : page==="策略中心" ? : page==="通知任务" ? : page==="用户管理" ? : page==="模型配置" ? : page==="系统设置" ? : page==="审计日志" ? :

{page}尚未开放

该模块完成业务接口、权限与验证后开放。

} -
见势 · Veyra / 可追溯的投资决策模拟业务闭环 v0.8
+
开发验证:模拟价格回放
: page==="总览" ? : page==="股票行情" ? : page==="候选审核" ? : page==="回测研究" ? : page==="策略中心" ? : page==="通知任务" ? : page==="用户管理" ? : page==="模型配置" ? : page==="系统设置" ? : page==="审计日志" ? :

{page}尚未开放

该模块完成业务接口、权限与验证后开放。

} +
见势 · Veyra / 可追溯的投资决策A 股研究工作台 · 开发版
diff --git a/web/admin/src/components/CandidatesPanel.tsx b/web/admin/src/components/CandidatesPanel.tsx index d119925..c27aa26 100644 --- a/web/admin/src/components/CandidatesPanel.tsx +++ b/web/admin/src/components/CandidatesPanel.tsx @@ -1,4 +1,6 @@ import {useEffect,useRef,useState,type FormEvent} from "react" +import {ScreeningRunControl} from "./ScreeningRunControl" +import {StockHistoryChart} from "./StockHistoryChart" import {Plus,RefreshCw} from "lucide-react" import {Button} from "./ui/button" 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={submit:"提交审核",publish:"审核发布",return:"退回修订",withdraw:"撤回发布"} 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:""}}} -export function CandidatesPanel({onSessionExpired}:{onSessionExpired:()=>void}){ +export function CandidatesPanel({onSessionExpired,initialCandidateID}:{onSessionExpired:()=>void;initialCandidateID?:string}){ const [list,setList]=useState([]),[selected,setSelected]=useState(null),[status,setStatus]=useState("") const [error,setError]=useState(""),[busy,setBusy]=useState(false),[loading,setLoading]=useState(false),[editing,setEditing]=useState(false),[source,setSource]=useState() const [content,setContent]=useState(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:"操作失败,请重试。")} 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(()=>{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)}} 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 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})) return
-
模拟候选

手工录入来源,保存修订后再提交审核。

+ void refresh()}/> +
模拟候选

自动筛选形成队列,审核股票依据后发布。

{error&&

{error}

} - {editing&&{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}原修订保留。证据、反证与参考价格需完整填写;当前不会自动获取或核验行情。
void save(e)} className="space-y-5">
{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=>)}
+ {editing&&{source?`创建第 ${source.revision+1} 次修订` : "录入观察候选"}原修订保留。证据、反证与参考价格需完整填写;此表单为手工录入;自动筛选结果从策略中心进入待审核。void save(e)} className="space-y-5">
{([["证券代码","symbol"],["证券名称","name"],["候选标题","title"]] as const).map(([label,key])=>)}
{([["观察逻辑","thesis"],["反证与风险","counter_evidence"],["失效条件","invalidation"]] as const).map(([label,key])=>