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:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+218
View File
@@ -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 "============================================================"