Files
qlib/scripts/dev.sh
T
Simon 23972e7063 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 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

204 lines
6.5 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================
# qlib-platform 前后端一键管理脚本
#
# 用法:
# scripts/dev.sh start 启动后端(8000) + 前端(3000)
# scripts/dev.sh stop 停止两者
# scripts/dev.sh restart 重启两者
# scripts/dev.sh status 查看状态
# scripts/dev.sh logs [api|web] 实时查看日志
#
# 环境变量(可选):
# API_PORT 后端端口(默认 8000)
# WEB_PORT 前端端口(默认 3000)
# BACKEND_API_URL 前端同源代理指向的后端地址(默认 http://127.0.0.1:8000,
# 局域网访问前端时改为 http://<本机IP>:8000)
#
# 进程管理说明:pidfile 记录启动进程;停止时同时按「监听端口」定位真实服务进程
# (pnpm/uv 包装进程可能提前退出),并对整进程组停止,避免子进程残留。
# ============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="$ROOT/.run"
LOG_DIR="$ROOT/logs"
mkdir -p "$RUN_DIR" "$LOG_DIR"
API_PORT="${API_PORT:-8000}"
WEB_PORT="${WEB_PORT:-3000}"
BACKEND_API_URL="${BACKEND_API_URL:-http://127.0.0.1:8000}"
API_PID_FILE="$RUN_DIR/backend.pid"
WEB_PID_FILE="$RUN_DIR/frontend.pid"
API_LOG="$LOG_DIR/backend.log"
WEB_LOG="$LOG_DIR/frontend.log"
log() { echo "[dev.sh] $*"; }
die() { echo "[dev.sh] 错误: $*" >&2; exit 1; }
# ---------- 进程探测 ----------
# 监听指定端口的 pid(无则空)
listener_pids() { # $1 port —— ss 优先(lsof 在部分环境对 socket 不可靠)
if command -v ss >/dev/null 2>&1; then
ss -tlnp 2>/dev/null | awk -v p=":$1 " '
$0 ~ p && /LISTEN/ {
if (match($0, /pid=[0-9]+/)) print substr($0, RSTART+4, RLENGTH-4)
}'
elif command -v lsof >/dev/null 2>&1; then
lsof -t -iTCP:"$1" -sTCP:LISTEN 2>/dev/null || true
fi
}
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 self_pgid
pid="$1"
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || 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
}
# ---------- 启动 ----------
start_api() {
if port_in_use "$API_PORT"; then
log "后端已在 :$API_PORT 运行(pid $(listener_pids "$API_PORT" | head -1))"
listener_pids "$API_PORT" | head -1 > "$API_PID_FILE"
return 0
fi
log "启动后端 :${API_PORT}(日志 ${API_LOG})"
( cd "$ROOT/backend"
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
listener_pids "$API_PORT" | head -1 > "$API_PID_FILE"
return 0
fi
sleep 0.5
done
die "后端启动超时,请查看 $API_LOG"
}
start_web() {
if port_in_use "$WEB_PORT"; then
log "前端已在 :$WEB_PORT 运行(pid $(listener_pids "$WEB_PORT" | head -1))"
listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE"
return 0
fi
log "启动前端 :${WEB_PORT}(日志 ${WEB_LOG})"
( cd "$ROOT/frontend/web"
export BACKEND_API_URL
# pnpm exec next 直接运行,避免 package.json dev 脚本自带 -p 3000 与端口参数重复
# -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
listener_pids "$WEB_PORT" | head -1 > "$WEB_PID_FILE"
return 0
fi
sleep 0.5
done
die "前端启动超时,请查看 $WEB_LOG"
}
# ---------- 停止 ----------
stop_service() { # $1 pidfile $2 port $3 名称
local killed=0
if pidfile_alive "$1"; then
local pid
pid="$(cat "$1")"
log "停止 $3 (pid $pid)"
kill_tree "$pid"
killed=1
fi
local lp
for lp in $(listener_pids "$2"); do
log "停止 $3 监听进程 (pid $lp)"
kill_tree "$lp"
killed=1
done
if [[ "$killed" == 0 ]]; then
log "$3 未在运行"
fi
for _ in $(seq 1 20); do
port_in_use "$2" || break
sleep 0.3
done
if pidfile_alive "$1"; then kill -9 "$(cat "$1")" 2>/dev/null || true; fi
for lp in $(listener_pids "$2"); do kill -9 "$lp" 2>/dev/null || true; done
rm -f "$1"
log "$3 已停止"
}
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)"
# 注意:不用 "${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"
}
# ---------- actions ----------
case "${1:-start}" in
start)
start_api
start_web
status
;;
stop)
stop_service "$API_PID_FILE" "$API_PORT" "后端"
stop_service "$WEB_PID_FILE" "$WEB_PORT" "前端"
;;
restart)
"$0" stop
sleep 1
"$0" start
;;
status)
status
;;
logs)
case "${2:-}" in
api) tail -f "$API_LOG" ;;
web) tail -f "$WEB_LOG" ;;
*) tail -f "$API_LOG" "$WEB_LOG" ;;
esac
;;
*)
echo "用法: $0 {start|stop|restart|status|logs [api|web]}" >&2
exit 1
;;
esac