feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
This commit is contained in:
+29
-10
@@ -56,14 +56,31 @@ port_in_use() { [[ -n "$(listener_pids "$1")" ]]; }
|
||||
|
||||
pidfile_alive() { [[ -f "$1" ]] && kill -0 "$(cat "$1")" 2>/dev/null; }
|
||||
|
||||
# 后台启动命令:Linux 用 setsid 脱离会话;macOS 无 setsid(BSD)则退回 nohup。
|
||||
# 两种方式都写 pidfile + 按端口定位,stop 逻辑不依赖进程组。
|
||||
spawn_bg() { # $1 日志文件;其余为命令
|
||||
local logfile="$1"; shift
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid nohup "$@" >>"$logfile" 2>&1 &
|
||||
else
|
||||
nohup "$@" >>"$logfile" 2>&1 &
|
||||
fi
|
||||
}
|
||||
|
||||
# 杀掉指定 pid 及其整进程组(uvicorn --reload / next dev 的子进程一起清理)
|
||||
# 注意:macOS 无 setsid 时子进程与当前脚本同进程组,此时禁止按 pgid 杀,
|
||||
# 否则会连带杀掉调用方自身;改为逐 pid + 子进程清理。
|
||||
kill_tree() { # $1 pid
|
||||
local pid pgid
|
||||
local pid pgid self_pgid
|
||||
pid="$1"
|
||||
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
||||
[[ -n "${pgid:-}" ]] && kill -- "-$pgid" 2>/dev/null || true
|
||||
kill "$pid" 2>/dev/null || true
|
||||
self_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ' || true)"
|
||||
if [[ -n "${pgid:-}" && "$pgid" != "${self_pgid:-}" ]]; then
|
||||
kill -- "-$pgid" 2>/dev/null || true
|
||||
fi
|
||||
# 先清理子进程再杀父进程:父进程先退出会让其子进程被 reparent,pkill -P 不再匹配
|
||||
pkill -P "$pid" 2>/dev/null || true
|
||||
kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ---------- 启动 ----------
|
||||
@@ -74,10 +91,9 @@ start_api() {
|
||||
listener_pids "$API_PORT" | head -1 > "$API_PID_FILE"
|
||||
return 0
|
||||
fi
|
||||
log "启动后端 :$API_PORT(日志 $API_LOG)"
|
||||
log "启动后端 :${API_PORT}(日志 ${API_LOG})"
|
||||
( cd "$ROOT/backend"
|
||||
setsid nohup uv run uvicorn app.main:app --host 0.0.0.0 --port "$API_PORT" --reload \
|
||||
>>"$API_LOG" 2>&1 &
|
||||
spawn_bg "$API_LOG" uv run uvicorn app.main:app --host 0.0.0.0 --port "$API_PORT" --reload
|
||||
) || true
|
||||
for _ in $(seq 1 40); do
|
||||
if port_in_use "$API_PORT"; then
|
||||
@@ -95,11 +111,12 @@ start_web() {
|
||||
listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE"
|
||||
return 0
|
||||
fi
|
||||
log "启动前端 :$WEB_PORT(日志 $WEB_LOG)"
|
||||
log "启动前端 :${WEB_PORT}(日志 ${WEB_LOG})"
|
||||
( cd "$ROOT/frontend/web"
|
||||
export BACKEND_API_URL
|
||||
# pnpm exec next 直接运行,避免 package.json dev 脚本自带 -p 3000 与端口参数重复
|
||||
setsid nohup pnpm exec next dev -p "$WEB_PORT" >>"$WEB_LOG" 2>&1 &
|
||||
# -H 0.0.0.0:显式监听所有网卡(局域网可用 http://<本机IP>:3000 访问)
|
||||
spawn_bg "$WEB_LOG" pnpm exec next dev -H 0.0.0.0 -p "$WEB_PORT"
|
||||
) || true
|
||||
for _ in $(seq 1 80); do
|
||||
if port_in_use "$WEB_PORT"; then
|
||||
@@ -145,8 +162,10 @@ status() {
|
||||
local api_pid web_pid
|
||||
api_pid="$(listener_pids "$API_PORT" | head -1 | tr -d '\n ' || true)"
|
||||
web_pid="$(listener_pids "$WEB_PORT" | head -1 | tr -d '\n ' || true)"
|
||||
log "后端: ${api_pid:+运行中 pid $api_pid}${api_pid:-未运行}"
|
||||
log "前端: ${web_pid:+运行中 pid $web_pid}${web_pid:-未运行}"
|
||||
# 注意:不用 "${pid:+运行中 pid $pid}${pid:-未运行}" —— bash 3.2(macOS 自带)
|
||||
# 对 :+ 词内再引用同一变量会重复展开,导致 PID 显示为拼接值。
|
||||
if [[ -n "$api_pid" ]]; then log "后端: 运行中 pid $api_pid"; else log "后端: 未运行"; fi
|
||||
if [[ -n "$web_pid" ]]; then log "前端: 运行中 pid $web_pid"; else log "前端: 未运行"; fi
|
||||
log "访问: 前端 http://127.0.0.1:$WEB_PORT | API http://127.0.0.1:$API_PORT/api/health"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- ============================================================
|
||||
-- 本地 MariaDB:创建与项目配置一致的 qlib 账号
|
||||
-- 凭据来源:根目录 .env 的 MYSQL_PASSWORD + config.yaml database.mysql
|
||||
-- host/port/db/user = 127.0.0.1 / 3306 / qlib / qlib
|
||||
-- 用途:让后端用本机 MariaDB 代替远端 192.168.1.10。
|
||||
--
|
||||
-- 本文件不含任何口令(AGENT.md §33:密钥只放 .env,禁止提交)。
|
||||
-- 占位符 __MYSQL_PASSWORD__ 须在运行时用根目录 .env 的 MYSQL_PASSWORD 替换后再执行
|
||||
-- (root 走 unix_socket,须以 _mysql 用户运行):
|
||||
--
|
||||
-- cd <项目根>
|
||||
-- PW="$(sed -n 's/^MYSQL_PASSWORD=//p' .env | head -1)"
|
||||
-- sed "s/__MYSQL_PASSWORD__/${PW}/g" scripts/grant_local_mysql_user.sql \
|
||||
-- | sudo -u _mysql /opt/local/bin/mysql \
|
||||
-- --socket=/opt/local/var/run/mariadb-10.11/mysqld.sock -u root
|
||||
--
|
||||
-- 提示:若口令含 sed 特殊字符(| & \ 或单引号),请改用 mysql_recover_and_grant.sh
|
||||
-- (该脚本直接读 .env 并自行拼接 SQL,不经 shell 替换)。
|
||||
--
|
||||
-- 幂等:可重复执行。
|
||||
-- ============================================================
|
||||
|
||||
-- 本机 TCP 与 socket 两种连接方式都授权
|
||||
CREATE USER IF NOT EXISTS 'qlib'@'localhost' IDENTIFIED BY '__MYSQL_PASSWORD__';
|
||||
CREATE USER IF NOT EXISTS 'qlib'@'127.0.0.1' IDENTIFIED BY '__MYSQL_PASSWORD__';
|
||||
|
||||
-- 确保密码与项目 .env 完全一致(若账号已存在但密码不同)
|
||||
ALTER USER 'qlib'@'localhost' IDENTIFIED BY '__MYSQL_PASSWORD__';
|
||||
ALTER USER 'qlib'@'127.0.0.1' IDENTIFIED BY '__MYSQL_PASSWORD__';
|
||||
|
||||
-- 业务库权限:仅限 qlib.*,不给全局权限
|
||||
GRANT ALL PRIVILEGES ON `qlib`.* TO 'qlib'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON `qlib`.* TO 'qlib'@'127.0.0.1';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 落库清单自检
|
||||
SELECT user, host, plugin FROM mysql.user WHERE user = 'qlib';
|
||||
SELECT COUNT(*) AS qlib_tables FROM information_schema.tables WHERE table_schema = 'qlib';
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 本地 MariaDB:恢复 root 访问 + 创建与项目配置一致的 qlib 账号
|
||||
#
|
||||
# 背景
|
||||
# - 本机 MacPorts mariadb-10.11 的 root@localhost 密码未知,
|
||||
# 且 unix_socket 认证也不可用(以 _mysql 运行同样被拒),
|
||||
# 因此无法创建项目所需的 qlib 账号。
|
||||
# - 服务由 launchd 托管(KeepAlive=true),直接 kill 会自动重启。
|
||||
#
|
||||
# 做法(标准 root 密码找回流程)
|
||||
# 1) 停掉 launchd 托管的正式实例(释放 datadir 咨询锁)
|
||||
# 2) 以 _mysql 用户启动临时实例:--skip-grant-tables --skip-networking
|
||||
# (不监听 TCP,仅本机私密 socket)
|
||||
# 3) 在临时实例上重置 root@localhost 密码,并创建 qlib@localhost /
|
||||
# qlib@127.0.0.1(密码取自项目根 .env 的 MYSQL_PASSWORD)
|
||||
# 4) 关闭临时实例,重新启动 launchd 正式实例
|
||||
# 5) 用正式实例验证 qlib 账号 TCP 登录
|
||||
#
|
||||
# 用法(需要 sudo,会短暂中断本机数据库服务)
|
||||
# sudo bash scripts/mysql_recover_and_grant.sh
|
||||
#
|
||||
# 幂等:可重复执行;若 qlib 账号已可用则直接跳过,不做任何改动。
|
||||
# ============================================================
|
||||
set -uo pipefail
|
||||
|
||||
MDB=/opt/local/lib/mariadb-10.11/bin
|
||||
DATADIR=/opt/local/var/db/mariadb-10.11
|
||||
MYSQLUSER=_mysql
|
||||
RUN=/tmp/qlib-mariadb-recover
|
||||
TMP_SOCK=${RUN}/recover.sock
|
||||
TMP_PID=${RUN}/recover.pid
|
||||
MAIN_PID_FILE=${DATADIR}/summer.pid
|
||||
MAIN_SOCK=/opt/local/var/run/mariadb-10.11/mysqld.sock
|
||||
LAUNCHD_LABEL=system/org.macports.mariadb-10.11-server
|
||||
PLIST=/opt/local/etc/LaunchDaemons/org.macports.mariadb-10.11-server/org.macports.mariadb-10.11-server.plist
|
||||
SERVICE_STOPPED=0
|
||||
# 口令一律不硬编码(AGENT.md §33/§34:密钥只放 .env,禁止提交):
|
||||
# RECOVER_PW —— root 复位后的口令:取环境变量 MARIADB_ROOT_PW,未设则随机生成(见下)
|
||||
# QLIB_PW —— 业务账号口令:运行时从根目录 .env 的 MYSQL_PASSWORD 读取(见下)
|
||||
RECOVER_PW="${MARIADB_ROOT_PW:-}"
|
||||
QLIB_PW=""
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
warn() { echo "!!! $*" >&2; }
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
warn "需要 root 权限:sudo bash $0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 凭据读取:不落盘、不硬编码 ----------
|
||||
ENV_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.env"
|
||||
if [ -f "${ENV_FILE}" ]; then
|
||||
QLIB_PW="$(sed -n 's/^MYSQL_PASSWORD=//p' "${ENV_FILE}" \
|
||||
| head -1 | tr -d '\r' \
|
||||
| sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")"
|
||||
fi
|
||||
if [ -z "${QLIB_PW}" ]; then
|
||||
warn "未从 ${ENV_FILE} 读到 MYSQL_PASSWORD(见 config.yaml database.mysql.password_env)"
|
||||
exit 1
|
||||
fi
|
||||
# SQL 里口令以单引号字面量拼接:含单引号会破坏语句,直接拒绝而不是生成坏 SQL
|
||||
case "${QLIB_PW}" in
|
||||
*"'"*)
|
||||
warn "MYSQL_PASSWORD 含单引号,本脚本的 SQL 拼接不安全;请用 mysql 客户端手工授权"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# root 复位口令:不设弱口令,未显式提供则随机生成并由脚本末尾打印一次
|
||||
if [ -z "${RECOVER_PW}" ]; then
|
||||
RECOVER_PW="$(openssl rand -base64 24 2>/dev/null | tr -dc 'A-Za-z0-9' | cut -c1-24)"
|
||||
fi
|
||||
if [ -z "${RECOVER_PW}" ]; then
|
||||
warn "无法生成 root 口令(openssl 不可用):请显式设置 MARIADB_ROOT_PW 后重试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 步骤 0:若 qlib 账号已可用,直接结束(幂等、零改动) ----------
|
||||
if "${MDB}/mariadb" -h 127.0.0.1 -P 3306 -u qlib -p"${QLIB_PW}" \
|
||||
-N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='qlib';" \
|
||||
>/tmp/qlib-pregrant.out 2>/dev/null; then
|
||||
log "qlib 账号已可登录本机 MariaDB,表数:$(cat /tmp/qlib-pregrant.out)"
|
||||
log "无需任何改动,退出。"
|
||||
rm -f /tmp/qlib-pregrant.out
|
||||
exit 0
|
||||
fi
|
||||
|
||||
stop_service() {
|
||||
log "停止 launchd 托管的 MariaDB"
|
||||
launchctl bootout "${LAUNCHD_LABEL}" 2>/dev/null || \
|
||||
launchctl unload -w "${PLIST}" 2>/dev/null || \
|
||||
warn "launchctl 停止未成功,继续尝试"
|
||||
for _ in $(seq 1 60); do
|
||||
pgrep -x mariadbd >/dev/null 2>&1 || pgrep -x mysqld >/dev/null 2>&1 || break
|
||||
sleep 0.5
|
||||
done
|
||||
if pgrep -x mariadbd >/dev/null 2>&1 || pgrep -x mysqld >/dev/null 2>&1; then
|
||||
warn "实例未在 30s 内退出,强制结束"
|
||||
pkill -x mariadbd 2>/dev/null || true
|
||||
pkill -x mysqld 2>/dev/null || true
|
||||
sleep 3
|
||||
fi
|
||||
# 清掉残留 socket/pid,避免临时实例或重启冲突
|
||||
rm -f "${MAIN_SOCK}" 2>/dev/null || true
|
||||
[ -f "${MAIN_PID_FILE}" ] && mv -f "${MAIN_PID_FILE}" "${MAIN_PID_FILE}.pre-recover" 2>/dev/null || true
|
||||
SERVICE_STOPPED=1
|
||||
log "服务已停止"
|
||||
}
|
||||
|
||||
start_service() {
|
||||
log "重新启动 launchd 托管的 MariaDB"
|
||||
launchctl bootstrap system "${PLIST}" 2>/dev/null || \
|
||||
launchctl load -w "${PLIST}" 2>/dev/null || \
|
||||
warn "launchctl 启动未成功"
|
||||
for _ in $(seq 1 120); do
|
||||
if "${MDB}/mariadb" -h 127.0.0.1 -P 3306 -u qlib -p"${QLIB_PW}" -N -e "SELECT 1;" >/dev/null 2>&1; then
|
||||
SERVICE_STOPPED=0
|
||||
log "服务已恢复,qlib 账号可登录"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
warn "服务未在 120s 内就绪,请检查:sudo launchctl print ${LAUNCHD_LABEL}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 任何情况下退出都要把正式服务拉起来
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
[ -f "${TMP_PID}" ] && kill "$(cat "${TMP_PID}")" 2>/dev/null || true
|
||||
sleep 2
|
||||
if [ "${SERVICE_STOPPED}" -eq 1 ]; then
|
||||
warn "脚本异常中断,正在恢复正式服务…"
|
||||
start_service || true
|
||||
fi
|
||||
rm -rf "${RUN}"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------- 步骤 1:停正式实例 ----------
|
||||
stop_service
|
||||
|
||||
# ---------- 步骤 2:起临时实例(skip-grant-tables) ----------
|
||||
log "启动临时实例(--skip-grant-tables,不监听 TCP)"
|
||||
rm -rf "${RUN}"
|
||||
install -d -o "${MYSQLUSER}" -g "${MYSQLUSER}" -m 0770 "${RUN}"
|
||||
sudo -u "${MYSQLUSER}" "${MDB}/mariadbd" \
|
||||
--datadir="${DATADIR}" \
|
||||
--socket="${TMP_SOCK}" \
|
||||
--pid-file="${TMP_PID}" \
|
||||
--skip-grant-tables \
|
||||
--skip-networking \
|
||||
>"${RUN}/server.log" 2>&1 &
|
||||
for _ in $(seq 1 60); do
|
||||
[ -S "${TMP_SOCK}" ] && break
|
||||
sleep 0.5
|
||||
done
|
||||
if [ ! -S "${TMP_SOCK}" ]; then
|
||||
warn "临时实例启动失败,日志:"
|
||||
tail -30 "${RUN}/server.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
log "临时实例就绪:${TMP_SOCK}"
|
||||
|
||||
# ---------- 步骤 3:重置 root 密码 + 创建 qlib 账号 ----------
|
||||
log "重置 root@localhost 密码并创建 qlib 账号"
|
||||
"${MDB}/mariadb" --socket="${TMP_SOCK}" -u root <<SQL
|
||||
FLUSH PRIVILEGES;
|
||||
ALTER USER 'root'@'localhost' IDENTIFIED BY '${RECOVER_PW}';
|
||||
CREATE USER IF NOT EXISTS 'qlib'@'localhost' IDENTIFIED BY '${QLIB_PW}';
|
||||
CREATE USER IF NOT EXISTS 'qlib'@'127.0.0.1' IDENTIFIED BY '${QLIB_PW}';
|
||||
ALTER USER 'qlib'@'localhost' IDENTIFIED BY '${QLIB_PW}';
|
||||
ALTER USER 'qlib'@'127.0.0.1' IDENTIFIED BY '${QLIB_PW}';
|
||||
GRANT ALL PRIVILEGES ON \`qlib\`.* TO 'qlib'@'localhost';
|
||||
GRANT ALL PRIVILEGES ON \`qlib\`.* TO 'qlib'@'127.0.0.1';
|
||||
SELECT user, host, plugin FROM mysql.user WHERE user IN ('root','qlib');
|
||||
SELECT COUNT(*) AS qlib_tables FROM information_schema.tables WHERE table_schema='qlib';
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
SQL_RC=$?
|
||||
if [ "${SQL_RC}" -ne 0 ]; then
|
||||
warn "授权 SQL 执行失败(rc=${SQL_RC}),将恢复正式服务"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 步骤 4:关临时实例 ----------
|
||||
log "关闭临时实例"
|
||||
if [ -f "${TMP_PID}" ]; then
|
||||
kill "$(cat "${TMP_PID}")" 2>/dev/null || true
|
||||
for _ in $(seq 1 40); do
|
||||
pgrep -x mariadbd >/dev/null 2>&1 || break
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
pkill -x mariadbd 2>/dev/null || true
|
||||
sleep 2
|
||||
rm -f "${TMP_SOCK}" "${TMP_PID}" 2>/dev/null || true
|
||||
|
||||
# ---------- 步骤 5:重启正式实例并验证 ----------
|
||||
start_service || exit 1
|
||||
|
||||
log "验证 qlib 账号(TCP 127.0.0.1:3306)"
|
||||
"${MDB}/mariadb" -h 127.0.0.1 -P 3306 -u qlib -p"${QLIB_PW}" \
|
||||
-e "SELECT CURRENT_USER() AS whoami; SELECT COUNT(*) AS qlib_tables FROM information_schema.tables WHERE table_schema='qlib';"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " 完成"
|
||||
echo " root@localhost 密码已重置为:${RECOVER_PW}"
|
||||
echo " qlib 账号:user=qlib db=qlib(密码与项目 .env 一致)"
|
||||
echo ""
|
||||
echo " 建议尽快改掉 root 密码:"
|
||||
echo " ${MDB}/mariadb -h 127.0.0.1 -u root -p"
|
||||
echo " MariaDB> ALTER USER 'root'@'localhost' IDENTIFIED BY '<新密码>';"
|
||||
echo "============================================================"
|
||||
@@ -0,0 +1,133 @@
|
||||
"""高股息选股回测案例执行器(走真实 Job 链路:submit → 状态机 → Experiment 归档)。
|
||||
|
||||
案例口径(全部可改,见 --help):
|
||||
- 全市场股息率最高的 n 只(n=20),自 2020-01-01 起每 m 个月(m=6)择股一次
|
||||
- 附加条件:dv_ratio ≤ 30%(剔除特殊分红导致的股息率尖峰)
|
||||
- 实际持仓前 x 只(x=20,等权);每 y 个月(y=6)调仓,买卖点为收盘价
|
||||
- 起始资金 100 万;佣金 0.03% / 印花税 0.05% / 滑点 0.1% / 最低佣金 5 元
|
||||
- 行情口径 hfq(后复权):现金分红不再被计为除权亏损
|
||||
- 买不进(涨停/停牌)→ 顺延到之后首个可成交交易日按收盘价买入
|
||||
|
||||
用法(在 backend/ 下执行,与 Job 子进程同一环境):
|
||||
PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py
|
||||
PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py --n 30 --x 10 --end 2025-12-31
|
||||
|
||||
执行成功后打印关键指标,并把完整结果(含净值曲线 / 个股曲线 / 买卖点)落盘为 JSON:
|
||||
data/backtest_dividend_case.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from app.application.services.job_executor import submit_and_run
|
||||
from app.domain.entities.research import (
|
||||
ConditionSpec,
|
||||
CostSpec,
|
||||
FactorSpec,
|
||||
ResearchSpec,
|
||||
SelectionSpec,
|
||||
UniverseSpec,
|
||||
)
|
||||
|
||||
|
||||
def build_spec(args: argparse.Namespace) -> ResearchSpec:
|
||||
return ResearchSpec(
|
||||
type="backtest",
|
||||
universe=UniverseSpec(exclude_st=not args.include_st, min_listing_days=args.min_listing_days),
|
||||
price_adjustment=args.adjust,
|
||||
factors=[FactorSpec(name=args.factor, weight=1.0)],
|
||||
conditions=[ConditionSpec(field="dv_ratio", op="lte", value=args.dv_cap)],
|
||||
selection=SelectionSpec(
|
||||
top_n=args.n,
|
||||
hold_top_x=args.x,
|
||||
allow_substitute=False,
|
||||
defer_buy=not args.no_defer,
|
||||
),
|
||||
rebalance="monthly",
|
||||
selection_interval_months=args.m,
|
||||
rebalance_interval_months=args.y if args.y is not None else args.m,
|
||||
period=(args.start, args.end),
|
||||
costs=CostSpec(
|
||||
commission_rate=args.commission,
|
||||
stamp_tax_rate=args.stamp,
|
||||
slippage_rate=args.slippage,
|
||||
min_commission=args.min_commission,
|
||||
),
|
||||
initial_capital=args.capital,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="高股息选股回测案例(真实 Job 链路)")
|
||||
p.add_argument("--n", type=int, default=20, help="候选池大小 n(股息率最高的 n 只)")
|
||||
p.add_argument("--x", type=int, default=20, help="实际持仓数 x(必须 ≤ n)")
|
||||
p.add_argument("--m", type=int, default=6, help="择股间隔(月)")
|
||||
p.add_argument("--y", type=int, default=None, help="调仓间隔(月);缺省 = m")
|
||||
p.add_argument("--start", type=date.fromisoformat, default=date(2020, 1, 1))
|
||||
p.add_argument("--end", type=date.fromisoformat, default=date.today())
|
||||
p.add_argument("--factor", default="dividend_yield", help="因子名(默认股息率 dv_ratio)")
|
||||
p.add_argument("--dv-cap", type=float, default=30.0, help="股息率上限(%%),剔除尖峰")
|
||||
p.add_argument(
|
||||
"--adjust", choices=["none", "qfq", "hfq"], default="hfq", help="复权口径"
|
||||
)
|
||||
p.add_argument("--capital", type=float, default=1_000_000.0)
|
||||
p.add_argument("--commission", type=float, default=0.0003)
|
||||
p.add_argument("--stamp", type=float, default=0.0005)
|
||||
p.add_argument("--slippage", type=float, default=0.001)
|
||||
p.add_argument("--min-commission", type=float, default=5.0, help="单笔最低佣金(元)")
|
||||
p.add_argument("--min-listing-days", type=int, default=250)
|
||||
p.add_argument("--include-st", action="store_true", help="不剔除 ST(默认剔除)")
|
||||
p.add_argument("--no-defer", action="store_true", help="买不进时不顺延(直接放弃)")
|
||||
p.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path("../data/backtest_dividend_case.json"),
|
||||
help="结果 JSON 落盘路径",
|
||||
)
|
||||
args = p.parse_args()
|
||||
if args.x > args.n:
|
||||
p.error(f"--x({args.x}) 不能大于 --n({args.n})")
|
||||
|
||||
spec = build_spec(args)
|
||||
print("[case] 已提交 Job(真实状态机 + Experiment 归档)…", flush=True)
|
||||
job = submit_and_run(spec)
|
||||
print(f"[case] job={job.id} status={job.status} mode/kind={job.kind}")
|
||||
if job.status != "success":
|
||||
print(f"[case] 失败:{job.error}")
|
||||
return 1
|
||||
|
||||
result = json.loads(job.result_json or "{}")
|
||||
s = result.get("summary", {})
|
||||
print("\n=== 回测结果(整体收益趋势)===")
|
||||
for key in (
|
||||
"start", "end", "initial_capital", "final_equity", "total_return_pct",
|
||||
"annual_return_pct", "sharpe", "max_drawdown_pct", "volatility_pct",
|
||||
"win_rate_pct", "total_trades", "avg_turnover_pct",
|
||||
):
|
||||
print(f" {key:22s} {s.get(key)}")
|
||||
curves = result.get("symbol_curves", [])
|
||||
print(f"\n=== 个股收益率趋势(共 {len(curves)} 只,按期末收益绝对值降序,前 10)===")
|
||||
for c in curves[:10]:
|
||||
buys = sum(1 for m in c.get("marks", []) if m.get("signal") == "BUY")
|
||||
sells = sum(1 for m in c.get("marks", []) if m.get("signal") == "SELL")
|
||||
print(f" {c['symbol']} 期末 {c['final_return_pct']:8.2f}% 买点 {buys} 卖点 {sells}")
|
||||
print("\n=== 未建模 / 口径说明(如实标注)===")
|
||||
for note in result.get("unimplemented", []):
|
||||
print(f" - {note}")
|
||||
print("\n=== 买卖点(signal_history 中的成交,前 10 条)===")
|
||||
for f in [a for a in result.get("fills", [])][:10]:
|
||||
print(f" {f['date']} {f['symbol']} {f['signal']} @ {f.get('price')}")
|
||||
|
||||
out = args.out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n[case] 完整结果已写入 {out.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,153 @@
|
||||
"""前端页面契约验证:按回测页实际下发的请求体与读取路径校验后端字段,防结构漂移。
|
||||
|
||||
基准是页面参数模型的「高股息案例预设」(`frontend/web/components/StrategyParamsForm.tsx`
|
||||
的 `CASE_PRESET`)+ 页面 `run()` 组装出的 spec 形状;结果侧的断言对应
|
||||
`app/backtest/page.tsx` 的 ResultView(净值/回撤/个股曲线、买卖点、config_snapshot 口径)。
|
||||
|
||||
与 `verify_strategy_workspace.py` 的分工:
|
||||
- 本脚本 = 回测结果**结构契约**(跑一次完整 2020→ 区间,约 5 分钟);
|
||||
- 另一个 = 策略库/说明/名称/选股直通**接口契约**(含一次 1 年回测,约 4 分钟)。
|
||||
|
||||
用法:PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py [--end 2024-12-31]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
API = "http://127.0.0.1:8000"
|
||||
|
||||
|
||||
def _post(path: str, payload: dict) -> dict:
|
||||
req = urllib.request.Request(
|
||||
API + path,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=120) as r: # noqa: S310
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def _get(path: str) -> dict:
|
||||
with urllib.request.urlopen(API + path, timeout=120) as r: # noqa: S310
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--end", default="2024-12-31")
|
||||
args = p.parse_args()
|
||||
|
||||
# —— 与 CASE_PRESET(高股息案例预设)逐字段一致;min_listing_days 用 250 ——
|
||||
spec = {
|
||||
"type": "backtest",
|
||||
"universe": {"exclude_st": True, "min_listing_days": 250},
|
||||
"price_adjustment": "hfq",
|
||||
"factors": [{"name": "dividend_yield", "weight": 1}],
|
||||
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
|
||||
"selection": {
|
||||
"top_n": 20,
|
||||
"hold_top_x": 20,
|
||||
"allow_substitute": False,
|
||||
"defer_buy": True,
|
||||
},
|
||||
"rebalance": "monthly",
|
||||
"selection_interval_months": 6,
|
||||
"rebalance_interval_months": 6,
|
||||
"costs": {
|
||||
"commission_rate": 0.0003,
|
||||
"stamp_tax_rate": 0.0005,
|
||||
"slippage_rate": 0.001,
|
||||
"min_commission": 5,
|
||||
},
|
||||
"initial_capital": 1_000_000,
|
||||
"period": ["2020-01-01", args.end],
|
||||
}
|
||||
print("[contract] POST /api/jobs(页面 submitJob 的请求体)…", flush=True)
|
||||
job = _post("/api/jobs", spec)
|
||||
job_id = job["job_id"]
|
||||
print(f"[contract] job_id={job_id} status={job['status']}")
|
||||
|
||||
t0 = time.monotonic()
|
||||
while True:
|
||||
out = _get(f"/api/jobs/{job_id}")
|
||||
if out["status"] in ("success", "failed", "cancelled"):
|
||||
break
|
||||
if time.monotonic() - t0 > 1800:
|
||||
print("[contract] 超时")
|
||||
return 1
|
||||
time.sleep(3)
|
||||
print(f"[contract] 终态 {out['status']},耗时 {time.monotonic() - t0:.0f}s")
|
||||
if out["status"] != "success":
|
||||
print(f"[contract] 失败:{out.get('error')}")
|
||||
return 1
|
||||
|
||||
res = out["result"]
|
||||
s = res["summary"]
|
||||
|
||||
# —— 页面 ResultView / BacktestMetrics 读取的字段 ——
|
||||
required = [
|
||||
"summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns",
|
||||
"positions", "trades", "selection_history", "signal_history", "fills",
|
||||
"symbol_curves", "turnover_pct", "unimplemented", "config_snapshot",
|
||||
]
|
||||
missing = [k for k in required if k not in res]
|
||||
assert not missing, f"结果缺少页面读取的字段:{missing}"
|
||||
for k in (
|
||||
"start", "end", "initial_capital", "final_equity", "total_return_pct",
|
||||
"annual_return_pct", "sharpe", "max_drawdown_pct", "volatility_pct",
|
||||
"win_rate_pct", "total_trades", "avg_turnover_pct",
|
||||
):
|
||||
assert k in s, f"summary 缺字段 {k}"
|
||||
print(f"[contract] 指标:总收益 {s['total_return_pct']}% · 年化 {s['annual_return_pct']}%"
|
||||
f" · 回撤 {s['max_drawdown_pct']}% · 成交 {s['total_trades']}")
|
||||
|
||||
# —— 整体收益趋势图 + 买卖点标注(page.tsx equityMarks 的数据源)——
|
||||
equity_dates = {p["date"] for p in res["equity_curve"]}
|
||||
assert equity_dates, "净值曲线为空"
|
||||
buys = [a for a in res["fills"] if a["signal"] == "BUY"]
|
||||
sells = [a for a in res["fills"] if a["signal"] == "SELL"]
|
||||
assert buys and sells, f"买卖点为空:BUY={len(buys)} SELL={len(sells)}"
|
||||
on_curve = sum(1 for a in buys + sells if a["date"] in equity_dates)
|
||||
assert on_curve == len(buys) + len(sells), "存在落在净值曲线日期之外的买卖点(图上会丢失标注)"
|
||||
print(f"[contract] 净值曲线 {len(equity_dates)} 点;买卖点 BUY={len(buys)} SELL={len(sells)}"
|
||||
f"(全部可落到曲线日期上)")
|
||||
|
||||
# —— 个股收益率趋势图 + 买卖点(page.tsx SymbolCurveChart 的数据源)——
|
||||
curves = res["symbol_curves"]
|
||||
assert curves, "个股曲线为空"
|
||||
curve = curves[0]
|
||||
pt_dates = {p["date"] for p in curve["points"]}
|
||||
assert curve["points"], f"{curve['symbol']} 曲线无数据点"
|
||||
assert any(m["signal"] == "BUY" for m in curve["marks"]), "个股曲线缺 BUY 标注"
|
||||
mark_dates = {m["date"] for m in curve["marks"]}
|
||||
assert mark_dates <= pt_dates, f"个股买卖点日期不在曲线点上:{sorted(mark_dates - pt_dates)[:5]}"
|
||||
assert all(m["filled"] for m in curve["marks"]), "个股 marks 含未成交记录"
|
||||
print(f"[contract] 个股曲线 {len(curves)} 只;首只 {curve['symbol']} "
|
||||
f"{len(curve['points'])} 点 / {len(curve['marks'])} 个买卖点,全部落点成功"
|
||||
f"(期末 {curve['final_return_pct']}%)")
|
||||
|
||||
# —— 页面顶部 Pill 读取口径与 n/x ——
|
||||
sel = res["config_snapshot"]["selection"]
|
||||
basis = res["config_snapshot"]["price_basis"]
|
||||
assert sel["top_n"] == 20 and sel["hold_top_x"] == 20, sel
|
||||
assert basis["adjust_mode"] == "hfq", basis
|
||||
print(f"[contract] config_snapshot.selection={sel}")
|
||||
print(f"[contract] config_snapshot.price_basis={basis}")
|
||||
|
||||
# —— 未成交意图卡片(signal_history 中 filled=False 且带原因)——
|
||||
rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]]
|
||||
print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"
|
||||
f"{rejects[0]['date'] if rejects else '—'} {rejects[0]['reject_reason'] if rejects else ''}")
|
||||
|
||||
print(f"[contract] unimplemented {len(res['unimplemented'])} 条(页面如实展示)")
|
||||
print("[contract] ✅ 页面契约验证通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python
|
||||
"""策略研究工作台契约自检(本轮新增能力的端到端验证)。
|
||||
|
||||
覆盖「创建策略 → 展开 → 回测 → 选股 → 对比」全链路,以及三处前端契约:
|
||||
- `GET /api/stocks/names`:前端全站股票名称缓存的唯一数据源(形状必须是 {symbol: name});
|
||||
- `POST /api/strategies`:说明为空时必须自动补全(需求:策略必须有说明);
|
||||
- `PUT /api/strategies/{id}`:原地更新且 id/created_at 不变(策略库「编辑」依赖);
|
||||
- `GET /api/strategies/{id}/describe`、`POST /api/strategies/describe`:说明 + 计算公式;
|
||||
- 回测结果里 `symbol_curves/positions/trades` 必须带 `name`(前端「代码必须配名称」依赖);
|
||||
- 选股结果 `candidates[].name` 与 `config_snapshot`(选股 → 回测直通依赖它取回当时的规则);
|
||||
- 页面 SSR:/strategies、/backtest、/experiments 必须 200 且含关键区块;
|
||||
- **归档链路**:`GET /api/experiments` 的 `X-Total-Count` 与 kind/q 过滤、归档详情含
|
||||
`data_version`/`job_id`、`/experiments/{id}` 归档页 SSR 能渲染、`DELETE` 语义正确。
|
||||
|
||||
用法:
|
||||
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py
|
||||
# 跳过长回测(只验接口与页面):
|
||||
... --skip-job
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
API = "http://127.0.0.1:8000"
|
||||
WEB = "http://127.0.0.1:3000"
|
||||
|
||||
_ok = 0
|
||||
_bad = 0
|
||||
|
||||
|
||||
def check(cond: bool, label: str, detail: str = "") -> None:
|
||||
global _ok, _bad
|
||||
if cond:
|
||||
_ok += 1
|
||||
print(f" ✅ {label}" + (f" — {detail}" if detail else ""), flush=True)
|
||||
else:
|
||||
_bad += 1
|
||||
print(f" ❌ {label}" + (f" — {detail}" if detail else ""), flush=True)
|
||||
|
||||
|
||||
def call_raw(method: str, path: str, body: object | None = None, timeout: float = 60.0):
|
||||
"""返回 (status, headers, payload):需要读响应头(X-Total-Count 等)时用。"""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
API + path, data=data, method=method, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, {k.lower(): v for k, v in r.headers.items()}, json.loads(
|
||||
r.read().decode() or "null"
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, {}, e.read().decode()[:300]
|
||||
|
||||
|
||||
def call(method: str, path: str, body: object | None = None, timeout: float = 60.0):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
API + path, data=data, method=method, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, json.loads(r.read().decode() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:300]
|
||||
|
||||
|
||||
def get_text(url: str, timeout: float = 120.0) -> tuple[int, str]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.status, r.read().decode("utf-8", "ignore")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "ignore")[:200]
|
||||
|
||||
|
||||
STRATEGY = {
|
||||
"name": f"契约自检-高股息-{int(time.time())}",
|
||||
"description": "", # 故意留空:验证后端自动补全
|
||||
"spec_type": "backtest",
|
||||
"universe": {"exclude_st": True, "min_listing_days": 250},
|
||||
"price_adjustment": "hfq",
|
||||
"factors": [{"name": "dividend_yield", "weight": 1}],
|
||||
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
|
||||
"selection": {"top_n": 20, "hold_top_x": 20, "allow_substitute": False, "defer_buy": True},
|
||||
"rebalance": "monthly",
|
||||
"selection_interval_months": 6,
|
||||
"rebalance_interval_months": 6,
|
||||
"costs": {
|
||||
"commission_rate": 0.0003,
|
||||
"stamp_tax_rate": 0.0005,
|
||||
"slippage_rate": 0.001,
|
||||
"min_commission": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--end", default="2024-12-31", help="回测结束日(默认 1 年,控制在 ~2 分钟)")
|
||||
p.add_argument("--skip-job", action="store_true", help="跳过真实回测 Job(只验接口与页面)")
|
||||
args = p.parse_args()
|
||||
|
||||
created_id = ""
|
||||
try:
|
||||
# ---------- 1. 名称接口 ----------
|
||||
print("[1] 股票名称接口(全站名称缓存的唯一数据源)", flush=True)
|
||||
st, names = call("GET", "/api/stocks/names")
|
||||
check(st == 200 and isinstance(names, dict), "GET /api/stocks/names 返回 dict", f"HTTP {st}")
|
||||
if isinstance(names, dict):
|
||||
check(len(names) > 5000, "名称条数 > 5000", f"{len(names)} 条")
|
||||
check(names.get("600519.SH") == "贵州茅台", "含 600519.SH 贵州茅台", str(names.get("600519.SH")))
|
||||
st2, one = call("GET", "/api/stocks/600519.SH")
|
||||
check(st2 == 200, "GET /api/stocks/{symbol} 未被 /names 抢占(路由顺序)", f"HTTP {st2}")
|
||||
|
||||
# ---------- 2. 创建策略(说明自动补全) ----------
|
||||
print("[2] 创建策略 + 说明自动补全", flush=True)
|
||||
st, saved = call("POST", "/api/strategies", STRATEGY)
|
||||
check(st == 200 and isinstance(saved, dict), "POST /api/strategies", f"HTTP {st}")
|
||||
if not isinstance(saved, dict):
|
||||
return 1
|
||||
created_id = saved.get("id") or ""
|
||||
check(bool(created_id), "返回策略 id", created_id)
|
||||
check(
|
||||
bool((saved.get("description") or "").strip()),
|
||||
"说明为空时被自动补全(策略必须有说明)",
|
||||
(saved.get("description") or "")[:80],
|
||||
)
|
||||
check(
|
||||
saved.get("selection_interval_months") == 6 and saved.get("rebalance_interval_months") == 6,
|
||||
"m/y 字段持久化(策略 JSON blob,无需迁移)",
|
||||
f"m={saved.get('selection_interval_months')} y={saved.get('rebalance_interval_months')}",
|
||||
)
|
||||
created_at = str(saved.get("created_at") or "")
|
||||
|
||||
# ---------- 3. 原地更新 ----------
|
||||
print("[3] 原地更新(策略库「编辑」依赖)", flush=True)
|
||||
upd = dict(STRATEGY)
|
||||
upd["selection"] = {**STRATEGY["selection"], "top_n": 15, "hold_top_x": 10}
|
||||
st, after = call("PUT", f"/api/strategies/{created_id}", upd)
|
||||
check(st == 200, "PUT /api/strategies/{id}", f"HTTP {st}")
|
||||
if isinstance(after, dict):
|
||||
check(after.get("id") == created_id, "id 不变", str(after.get("id")))
|
||||
check(
|
||||
(after.get("selection") or {}).get("top_n") == 15,
|
||||
"参数已更新(n=15)",
|
||||
str((after.get("selection") or {}).get("top_n")),
|
||||
)
|
||||
check(
|
||||
str(after.get("created_at") or "") == created_at,
|
||||
"created_at 未被刷新(避免「改一下就排最前」)",
|
||||
f"{created_at} → {after.get('created_at')}",
|
||||
)
|
||||
st, notfound = call("PUT", "/api/strategies/STG-NOT-EXIST", upd)
|
||||
check(st == 404, "更新不存在的策略 → 404", f"HTTP {st}")
|
||||
# 改名目标必须**每次运行都不同**:曾经用固定名字,脚本被中断(未走到 cleanup)时
|
||||
# 会留下同名策略,导致下次运行在这里收到正确的 400 重名拒绝、却被误判为失败。
|
||||
rename_to = f"契约自检-改名-{int(time.time())}"
|
||||
st, dup = call("PUT", f"/api/strategies/{created_id}", {**upd, "name": rename_to})
|
||||
check(st == 200, "改名成功(未撞车)", f"HTTP {st} → {rename_to}")
|
||||
# 重名应当被拒(400),这是产品行为,必须验到
|
||||
st, conflict = call("PUT", f"/api/strategies/{created_id}", {**upd, "name": "高股息 Top20(案例口径)"})
|
||||
check(st == 400, "改成已存在的策略名 → 400(重名保护)", f"HTTP {st}")
|
||||
|
||||
# ---------- 4. 说明与公式 ----------
|
||||
print("[4] 说明 / 计算公式(describe_strategy)", flush=True)
|
||||
st, doc = call("GET", f"/api/strategies/{created_id}/describe")
|
||||
check(st == 200 and isinstance(doc, dict), "GET /strategies/{id}/describe", f"HTTP {st}")
|
||||
if isinstance(doc, dict):
|
||||
summary = doc.get("summary") or ""
|
||||
formula = doc.get("formula") or ""
|
||||
check(bool(summary), "summary 非空(一句话说明)", summary[:90])
|
||||
check("dividend_yield" in formula, "公式含因子名", "dividend_yield")
|
||||
check("dv_ratio" in formula, "公式含过滤条件字段", "dv_ratio")
|
||||
check("30" in formula, "公式含条件阈值", "30")
|
||||
check(
|
||||
any(k in formula for k in ("佣金", "印花", "滑点", "commission")),
|
||||
"公式含成本项",
|
||||
)
|
||||
check(bool(doc.get("steps")), "steps 非空(执行步骤)", f"{len(doc.get('steps') or [])} 步")
|
||||
spec_probe = {
|
||||
"type": "backtest",
|
||||
"universe": {"exclude_st": True, "min_listing_days": 250},
|
||||
"price_adjustment": "hfq",
|
||||
"factors": [{"name": "dividend_yield", "weight": 1}],
|
||||
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
|
||||
# 注意:allow_substitute 与 defer_buy 互斥;只给 defer_buy 会因后端默认
|
||||
# allow_substitute=True 触发 422(这正是在前端表单里用三态单选表达的原因)
|
||||
"selection": {"top_n": 20, "hold_top_x": 20, "allow_substitute": False, "defer_buy": True},
|
||||
"rebalance": "monthly",
|
||||
"selection_interval_months": 6,
|
||||
"rebalance_interval_months": 6,
|
||||
"costs": {"commission_rate": 0.0003, "min_commission": 5},
|
||||
"initial_capital": 1000000,
|
||||
"period": ["2020-01-01", args.end],
|
||||
}
|
||||
st, doc2 = call("POST", "/api/strategies/describe", spec_probe)
|
||||
check(st == 200 and bool((doc2 or {}).get("formula")), "POST /strategies/describe(未保存参数也可预览)", f"HTTP {st}")
|
||||
# 互斥校验必须仍然生效(前端三态单选正是为避免踩到它)
|
||||
bad = dict(spec_probe)
|
||||
bad["selection"] = {"top_n": 20, "allow_substitute": True, "defer_buy": True}
|
||||
st, _ = call("POST", "/api/strategies/describe", bad)
|
||||
check(st == 422, "allow_substitute 与 defer_buy 同真被拒(前端用三态单选规避)", f"HTTP {st}")
|
||||
|
||||
# ---------- 5. 展开 + 回测 ----------
|
||||
print("[5] 展开为 Spec" + ("" if args.skip_job else " + 真实回测 Job"), flush=True)
|
||||
st, spec = call(
|
||||
"POST",
|
||||
f"/api/strategies/{created_id}/expand",
|
||||
{"period": ["2024-01-02", args.end], "initial_capital": 1000000},
|
||||
)
|
||||
check(st == 200 and isinstance(spec, dict), "POST /strategies/{id}/expand", f"HTTP {st}")
|
||||
if isinstance(spec, dict):
|
||||
check(spec.get("selection", {}).get("top_n") == 15, "展开沿用了更新后的参数", str(spec.get("selection")))
|
||||
|
||||
if not args.skip_job and isinstance(spec, dict):
|
||||
st, job = call("POST", "/api/jobs", spec)
|
||||
check(st == 200, "POST /api/jobs(策略 → 回测)", f"HTTP {st}")
|
||||
job_id = (job or {}).get("job_id") if isinstance(job, dict) else None
|
||||
if job_id:
|
||||
t0 = time.monotonic()
|
||||
out = None
|
||||
while time.monotonic() - t0 < 900:
|
||||
_, out = call("GET", f"/api/jobs/{job_id}")
|
||||
if (out or {}).get("status") in ("success", "failed", "cancelled"):
|
||||
break
|
||||
time.sleep(6)
|
||||
status = (out or {}).get("status")
|
||||
check(status == "success", f"回测 Job 终态 success(耗时 {time.monotonic()-t0:.0f}s)", str(status))
|
||||
res = ((out or {}).get("result") or {}) if isinstance(out, dict) else {}
|
||||
curves = res.get("symbol_curves") or []
|
||||
pos = res.get("positions") or []
|
||||
trades = res.get("trades") or []
|
||||
check(bool(curves), "结果含个股曲线", f"{len(curves)} 条")
|
||||
check(
|
||||
all(c.get("name") for c in curves[:5]),
|
||||
"symbol_curves[].name 已填充(前端代码必须配名称)",
|
||||
str([c.get("name") for c in curves[:3]]),
|
||||
)
|
||||
check(
|
||||
bool(pos) and all(p.get("name") for p in pos[:5]),
|
||||
"positions[].name 已填充",
|
||||
str([p.get("name") for p in pos[:3]]),
|
||||
)
|
||||
check(
|
||||
all(t.get("name") for t in trades[:5]),
|
||||
"trades[].name 已填充",
|
||||
str([t.get("name") for t in trades[:3]]),
|
||||
)
|
||||
|
||||
# ---------- 6. 选股 + 直通契约 ----------
|
||||
print("[6] 选股结果的 name 与「选股 → 回测」直通契约", flush=True)
|
||||
st, sel = call(
|
||||
"POST",
|
||||
"/api/selections",
|
||||
{
|
||||
"universe": {"exclude_st": True, "min_listing_days": 250},
|
||||
"as_of": "2024-07-01",
|
||||
"method": "score",
|
||||
"factors": [{"name": "dividend_yield", "weight": 1}],
|
||||
"conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}],
|
||||
"top_n": 20,
|
||||
},
|
||||
timeout=300,
|
||||
)
|
||||
check(st == 200 and isinstance(sel, dict), "POST /api/selections", f"HTTP {st}")
|
||||
if isinstance(sel, dict):
|
||||
run = sel.get("result", sel)
|
||||
cands = run.get("candidates") or []
|
||||
check(bool(cands), "选股返回候选", f"{len(cands)} 只")
|
||||
check(
|
||||
all(c.get("name") for c in cands[:5]),
|
||||
"candidates[].name 已填充",
|
||||
str([c.get("name") for c in cands[:3]]),
|
||||
)
|
||||
snap = run.get("config_snapshot") or {}
|
||||
check(
|
||||
bool(snap.get("factors")) and "top_n" in snap and "conditions" in snap,
|
||||
"config_snapshot 含规则(回测页据此预填参数)",
|
||||
f"keys={sorted(snap.keys())[:6]}",
|
||||
)
|
||||
sid = sel.get("selection_id")
|
||||
if sid:
|
||||
st, again = call("GET", f"/api/selections/{sid}")
|
||||
check(st == 200, "GET /selections/{id} 可读回(直通按钮依赖)", f"HTTP {st}")
|
||||
|
||||
# ---------- 6.5 归档(存档)链路 ----------
|
||||
print("[6.5] 回测存档:列表过滤 / 总数 / 详情元数据 / 归档页 / 删除", flush=True)
|
||||
st, exps = call("GET", "/api/experiments?limit=200")
|
||||
check(st == 200 and isinstance(exps, list), "GET /api/experiments 列表", f"HTTP {st}")
|
||||
check(bool(exps), "已有归档记录", f"{len(exps) if isinstance(exps, list) else 0} 条")
|
||||
exp_id = ""
|
||||
if isinstance(exps, list) and exps:
|
||||
exp_id = exps[0].get("id") or ""
|
||||
st, headers, _ = call_raw("GET", "/api/experiments?limit=1")
|
||||
total = headers.get("x-total-count")
|
||||
check(
|
||||
total is not None and total.isdigit() and int(total) >= 1,
|
||||
"列表通过 X-Total-Count 暴露总数(不再静默截断在 50)",
|
||||
f"X-Total-Count={total}",
|
||||
)
|
||||
st, only_bt = call("GET", "/api/experiments?kind=backtest&limit=200")
|
||||
check(
|
||||
st == 200 and isinstance(only_bt, list) and all(e.get("kind") == "backtest" for e in only_bt),
|
||||
"kind=backtest 过滤生效",
|
||||
f"{len(only_bt) if isinstance(only_bt, list) else '?'} 条",
|
||||
)
|
||||
st, none_hit = call("GET", "/api/experiments?q=zzz-no-such-experiment")
|
||||
check(
|
||||
st == 200 and isinstance(none_hit, list) and len(none_hit) == 0,
|
||||
"q= 过滤生效(不存在的关键词 → 0 条)",
|
||||
f"{len(none_hit) if isinstance(none_hit, list) else '?'} 条",
|
||||
)
|
||||
if exp_id:
|
||||
st, det = call("GET", f"/api/experiments/{exp_id}")
|
||||
check(st == 200 and isinstance(det, dict), f"GET /api/experiments/{exp_id} 详情", f"HTTP {st}")
|
||||
if isinstance(det, dict):
|
||||
check("job_id" in det, "详情含来源作业 id", str(det.get("job_id")))
|
||||
check("data_version" in det, "详情含数据快照指纹字段", str(det.get("data_version")))
|
||||
check(bool(det.get("spec")), "详情含归档 spec(复现依据)")
|
||||
res = det.get("result") or {}
|
||||
meta = res.get("archive_meta") or {}
|
||||
if meta:
|
||||
check(
|
||||
meta.get("curves_total") is not None,
|
||||
"结果含 archive_meta(曲线存储完整度)",
|
||||
f"stored={meta.get('curves_stored')} total={meta.get('curves_total')} "
|
||||
f"truncated={meta.get('truncated')}",
|
||||
)
|
||||
check(
|
||||
not meta.get("truncated") or bool(meta.get("curves_stored")),
|
||||
"若被裁剪则如实标注(不静默丢曲线)",
|
||||
str(meta.get("truncated")),
|
||||
)
|
||||
else:
|
||||
check(False, "结果含 archive_meta(曲线存储完整度)", "缺失:归档未带完整度元数据")
|
||||
|
||||
# ---------- 7. 页面 SSR ----------
|
||||
print("[7] 页面可访问性与关键区块", flush=True)
|
||||
pages = [
|
||||
("/strategies", ["策略库", "计算公式", "一句话说明"]),
|
||||
("/backtest", ["选股回测", "保存为策略", "计算公式"]),
|
||||
("/experiments", ["实验对比", "参数"]),
|
||||
]
|
||||
if exp_id:
|
||||
# 归档页必须能回答「选股条件」与「交易执行依据」——这是本页存在的理由
|
||||
pages.append((f"/experiments/{exp_id}", ["选股条件", "交易执行依据", "归档"]))
|
||||
for path, keywords in pages:
|
||||
code, html = get_text(WEB + path)
|
||||
check(code == 200, f"{path} HTTP 200", str(code))
|
||||
missing = [k for k in keywords if k not in html]
|
||||
check(not missing, f"{path} 含关键区块", f"缺失 {missing}" if missing else "全部命中")
|
||||
|
||||
# ---------- 7.1 归档页按类型逐类验证 ----------
|
||||
# 为什么单列一节:归档结果的**结构随 kind 变化**(backtest / factor_test / selection),
|
||||
# 只验回测归档会漏掉「非回测归档按回测字段渲染 → 整页 500」这类问题(真实踩过:
|
||||
# 4 条 factor_test + 1 条 selection 归档从列表点进去全部 500)。这里对库里
|
||||
# **每一种**出现的归档类型各取一条真实归档验证:必须 200,且非回测类型不得
|
||||
# 出现回测专属区块(净值曲线 / 交易执行依据),必须出现该类型自己的区块。
|
||||
print("[7.1] 归档页按 kind 逐类验证(防结构错配 500)", flush=True)
|
||||
_, listed = call("GET", "/api/experiments?limit=200")
|
||||
by_kind: dict[str, str] = {}
|
||||
for row in listed if isinstance(listed, list) else []:
|
||||
by_kind.setdefault(str(row.get("kind")), str(row.get("id")))
|
||||
if not by_kind:
|
||||
check(False, "归档列表可用于逐类验证", "列表为空(先跑一次回测/因子测试)")
|
||||
for kind, aid in sorted(by_kind.items()):
|
||||
code, html = get_text(f"{WEB}/experiments/{aid}")
|
||||
check(code == 200, f"归档页 {kind}({aid})HTTP 200", str(code))
|
||||
if code != 200:
|
||||
continue
|
||||
if kind == "backtest":
|
||||
need = ["交易执行依据", "整体收益趋势"]
|
||||
forbid: list[str] = []
|
||||
elif kind == "factor_test":
|
||||
need = ["因子测试配置", "IC"]
|
||||
forbid = ["交易执行依据(撮合价", "整体收益趋势"]
|
||||
elif kind == "selection":
|
||||
need = ["选股条件", "选出"]
|
||||
forbid = ["交易执行依据(撮合价"]
|
||||
else:
|
||||
need, forbid = ["归档"], []
|
||||
missing = [k for k in need if k not in html]
|
||||
check(not missing, f"归档页 {kind} 含该类型专属区块", f"缺失 {missing}" if missing else "全部命中")
|
||||
leaked = [k for k in forbid if k in html]
|
||||
check(
|
||||
not leaked,
|
||||
f"归档页 {kind} 不出现回测专属口径",
|
||||
f"误出现 {leaked}(会让非回测归档看起来像跑过调仓成交)" if leaked else "未出现",
|
||||
)
|
||||
finally:
|
||||
if created_id:
|
||||
st, _ = call("DELETE", f"/api/strategies/{created_id}")
|
||||
print(f"[cleanup] 删除自检策略 {created_id} → HTTP {st}", flush=True)
|
||||
|
||||
print(f"\n结果:{_ok} 项通过 / {_bad} 项失败", flush=True)
|
||||
return 1 if _bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user