A-股 一家 私募 的 数据 团队 每天 下午 15:00 收盘 后,要 把 沪深300 ETF(510300)的 tick 文件 从 staging 服务器 同步 到 本地 研究机,解压、做 一次 行数 校验、调 一个 Python loader 写 进 数据库;如果 任何 一步 失败,调度器 必须 拿到 非 0 退出 码,方便 把 这一晚 标 红。这套 流程 不能 让 你 每天 上来 手敲——你 要 写 一个 bash 脚本,让 它 在 cron 或 systemd --user timer 下 跑、出 错 立刻 退、清 掉 临时 文件、留 一行 grep-friendly 的 结构化 日志。这一课 把 前三 课 的 substrate / pipeline / 进程 控制 收 起来,变成 一个 「同事 敢 把 自己 的 名字 写 在 cron 行 里」 的 脚本。
严格 模式 前奏
每一个 生产 脚本 都 以 三 行 开始。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
第一 行 #!/usr/bin/env bash 是 portable shebang——它 在 把 bash 装 在 /bin/bash 还是 /usr/local/bin/bash 的 系统 之间 都 能 工作。第二 行 是 「strict mode」(严格 模式),逐 字 解释:
-e— 任意 命令 返回 非 0 立刻 退出(if条件 与||短路 是 文档 中 的 例外,不触发-e)。-u— 引用 未 设置 的 变量 报错。echo $TYPO不 再 静默 打印 空 串、然后 让 下游 命令 收 一个 空 参数。-o pipefail— 管道 中 任意 一 段 失败 整个 pipeline 失败。没有 它 的 话,cmd1 | cmd2只 返回cmd2的 退出 码——cmd1死了 但 你 看 不见。
第三 行 IFS=$'\n\t' 把 内部 字段 分隔符(internal field separator)重置 为 「换行 + tab」,不 再 在 空格 上 分词,于是 文件名 带 空格 的 边界 情形 不 把 你 的 for 循环 拆 烂。
引号:bash 最 常 杀人 的 地方
三 条 引号 规则 必须 刻 下来。
- 单 引号
'...'字面 不 解析。'$HOME'就 是 五 个 字符。 - 双 引号
"..."解析$var与$(cmd),但 把 结果 当作 一 个 参数 保留(空格 留住)。 - 不 加 引号 的
$var被 按$IFS分 词 再 glob 展开——这 几乎 总 是 bug。
最 经典 的 灾难 是 rm -rf $DIR/ 而 DIR=''——展开 成 rm -rf /,递归 删 根目录。
DIR=''; rm -rf $DIR/ # DANGER: expands to 'rm -rf /'
DIR=''; rm -rf "${DIR:?DIR is required}"/ # aborts: bash: DIR: DIR is required
修法 是 默认 错误 展开 ${VAR:?msg}——变量 未 设置 或 为 空 时 整个 脚本 立刻 退出,并 把 msg 打 到 stderr。配套 的 还有 ${VAR:-default}(未设/为空 时 用 default)与 ${VAR:=default}(顺手 也 把 默认 值 赋 回 去)。命令 替换 用 $(cmd),不要 用 反 引号 `cmd`——反 引号 不 嵌 套。
控制 流:每 种 一个 例子
if [[ -f "$file" ]]; then
echo "file exists"
elif [[ -d "$file" ]]; then
echo "directory"
else
echo "neither" >&2
exit 1
fi
for x in *.csv; do
echo "$x"
done
while read -r line; do
echo "got: $line"
done < input.txt
case "$arg" in
--date) shift; DATE="$1" ;;
*) echo "unknown arg: $arg" >&2; exit 2 ;;
esac
[[ ... ]] 是 bash 推荐 的 条件 形式(比 [ ... ] 更 安全 地 处理 未 设置 变量、支持 =~ 正则)。函数 这样 写:
log() { printf '%s level=%s msg=%s\n' "$(date -u +%FT%TZ)" "$1" "$2"; }
log INFO 'starting'
log INFO 'ingest complete'
log ERROR 'rsync failed' >&2
函数 是 「带 名字 的 命令」;用 local x=1 声明 局部 变量;return N 设 退出 状态——不是 返回 值。退出 码:0 成功,非 0 失败;exit 1 通用 失败,exit 2 用法 错(参数 非法),exit 78(configerr)配置 错。$? 拿 上 一条 命令 的 退出 码。
trap:清 掉 你 留下 的 临时 文件
TMP=$(mktemp -d)
trap 'log INFO "cleanup" tmp="$TMP"; rm -rf "$TMP"' EXIT INT TERM
trap 在 脚本 正常 退出(EXIT)、被 Ctrl-C 打断(INT)、被 kill 发 SIGTERM 时 都 跑 一遍 你 给 的 命令。两 个 顺序 极其 重要:先 TMP=$(mktemp -d) 再 trap,再 跑 任何 可能 失败 的 命令——否则 早 早 失败 的 路径 没 被 注册 进 清理 钩子,临时 目录 留 在 /tmp 里 不退场。这是 量化 脚本 「悄悄 把 磁盘 填 满」 的 最 常 见 原因。
getopts:短 标志 解析
usage() { echo "Usage: $0 [-d YYYYMMDD] [-v]" >&2; exit 2; }
DATE=$(date -d 'yesterday' +%Y%m%d) # GNU date; BSD/macOS 写 `date -v -1d +%Y%m%d`
while getopts ':d:v' opt; do
case $opt in
d) DATE="$OPTARG" ;;
v) VERBOSE=1 ;;
\?) usage ;;
esac
done
':d:v' 这 个 optstring:开头 的 : 让 getopts 在 缺 参数 时 静默 返回 ? 而不是 打印 错误,再 跟 d: 表示 -d 必须 带 参数、v 表示 单 flag 不带 参数。$OPTARG 拿 到 实际 值。比 getopts 更 复杂 的 参数 模型(带 默认 值、子命令、--long-flag)—— pointer 到 Python 的 argparse,bash 在 这里 是 错 的 工具。
调度:cron 与 systemd --user
crontab -e 进 编辑器。一 行 cron 的 五 字段 是 m h dom mon dow cmd——分钟、小时、日、月、周。沪深300 收盘 在 北京 时间 15:00(China Standard Time, CST),数据 落地 等 15 分钟 后 进 流 程:
crontab -e
# 加 一 行:
30 15 * * 1-5 /home/quant/bin/ingest.sh >> /var/log/ingest.log 2>&1
cron 的 两 条 canonical pitfall:
- cron's PATH is minimal — use absolute paths(cron 的
$PATH非常 精简,通常 只有/usr/bin:/bin、没有LANG、没有 conda 别名。脚本 顶部 显式export TZ=Asia/Shanghai、export PATH=/opt/conda/bin:$PATH、所有 命令 用 绝对 路径。) - cron has no terminal — any command that prompts for input hangs forever(cron 没有 终端。任何 等待 stdin 的 命令——
git pull走 passphrase、read -p——会 永远 挂 在 那里。)
现代 替代 是 用户 级 systemd timer:写 一个 ~/.config/systemd/user/ingest.timer(OnCalendar=Mon..Fri 15:30)+ 一个 同名 .service 跑 脚本,systemctl --user enable --now ingest.timer 启用。可以 看 单元 状态、可以 拿 journald 收 stdout——比 cron 信号 强 得 多。
结构化 日志
printf '%s level=INFO msg=%s key=%s\n' "$(date -u +%FT%TZ)" "$1" "$2"
一 行 一 个 事件,key=value 形式——grep level=ERROR /var/log/ingest.log 一秒 就 把 错误 行 捞 出来。stdout 是 cron mail / journald 收 的 流。这 是 通往 第 3.6.6 节(Observability)的 入 口。
调 Python:bash 是 编排,Python 是 业务
FNAME="tick_510300_$DATE.csv"
python -c "import pandas as pd; print(pd.read_csv('$TMP/'+'$FNAME').shape)"
python -m quant.ingest.tick --market cn --date "$DATE" --file "$TMP/$FNAME"
显式 通过 命令行 参数 传值,不要 让 Python 直接 读 脚本 的 环境变量(TZ、PATH、HOME 是 唯三 的 例外——它们 本来 就 是 环境 变量)。
完整 capstone:ingest.sh
脚本 由 五 步 组 成,按 顺序:
- 严格 模式 前奏 + 时区 +
getopts解析 默认DATE=yesterday。 mktemp -d建 临时 目录、紧 跟trap注册 三 路 退出 清理。rsync拉tick_510300_*.csv.gz(课 内 mock 成echo)+gunzip -k解压。awk 'END { if (NR < 2) ... exit 1 }'守 卫 行 数 ≥ 2。python -m quant.ingest.tick调 业务 loader、最后 一 行 结构化 success 日志。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
export TZ=Asia/Shanghai
usage() { echo "Usage: $0 [-d YYYYMMDD] [-v]" >&2; exit 2; }
log() { printf '%s level=%s msg=%s\n' "$(date -u +%FT%TZ)" "$1" "$2"; }
DATE=$(date -d 'yesterday' +%Y%m%d)
VERBOSE=0
while getopts ':d:v' opt; do
case $opt in
d) DATE="$OPTARG" ;;
v) VERBOSE=1 ;;
\?) usage ;;
esac
done
: "${DATE:?DATE is required}"
TMP=$(mktemp -d)
trap 'log INFO "cleanup" tmp="$TMP"; rm -rf "$TMP"' EXIT INT TERM
log INFO 'starting' date="$DATE"
FNAME_GZ="tick_510300_$DATE.csv.gz"
FNAME="tick_510300_$DATE.csv"
SRC="data-staging:/staging/cn/equity/tick/$DATE/$FNAME_GZ"
# 1) 从 staging 拉 tick 文件
echo rsync -avz --progress "$SRC" "$TMP/"
# 2) 解压
echo gunzip -k "$TMP/$FNAME_GZ"
# 3) 行数 校验
awk 'END { if (NR < 2) { print "empty" > "/dev/stderr"; exit 1 } }' "$TMP/$FNAME" \
|| { log ERROR 'empty file' date="$DATE" >&2; exit 1; }
ROWS=$(wc -l < "$TMP/$FNAME")
# 4) 调 Python loader
echo python -m quant.ingest.tick --market cn --date "$DATE" --file "$TMP/$FNAME"
log INFO 'ingest complete' rows="$ROWS" date="$DATE"
整 块 解读:第 1-3 行 严格 模式 前奏;export TZ 让 cron 与 systemd 都 看 到 上海 时区;usage 与 log 两 个 帮 手 函数;默认 DATE = 昨天;getopts 允许 手动 覆盖;${DATE:?...} 兜底;mktemp -d + trap 在 任何 退出 路径 上 都 清 临时 目录;rsync / gunzip / Python 调用 在 这 里 都 用 echo mocked,让 课 上 跑 不 起 真实 网络 也 能 走 一 遍 流程;awk 守 卫 行 数 ≥ 2(表头 + 至少 一 行 数据),否则 退 1;最后 一 行 结构化 日志 收 尾。任何 一步 失败 都 因 set -e 立 即 退 出,trap 把 临时 目录 清 掉,调度 器 拿 到 非 0 状态——这 才 是 「同事 敢 部 署」 的 脚本。
shellcheck 通 一遍 再 提交:它 抓 引号 / 未引用 变量 / 不 兼 容 写法,是 写 bash 的 标配 lint。
练习
Exercise
拿 capstone 的 ingest.sh 骨架 做 三 处 修改。(a) 通过 getopts 加 一 个 -n(dry-run)flag,仅 打印 出 rsync 与 python 命令 而 不 实际 执行。(b) 加 一 个 行 数 阈 值:校验 出 的 CSV 行 数 不 足 1000 行,记 一 条 WARN 级 日志 但 不 退 出;如果 表头 之外 一 行 也 没有,仍按 之前 exit 1。(c) 把 cron 行 替换 成 systemd --user timer 对:~/.config/systemd/user/ingest.timer 用 OnCalendar=Mon..Fri 15:30,配 一个 ingest.service 跑 /home/quant/bin/ingest.sh。给 出 三 件 产物:修改 后 的 ingest.sh、ingest.service 单元、ingest.timer 单元。
提示
-n 这种 不 带 参数 的 flag 在 optstring 写 n(不 加 冒号);case 分支 里 DRYRUN=1。脚本 里 把 真 跑 行 改 成 if [[ "$DRYRUN" == 1 ]]; then echo rsync ...; else rsync ...; fi。提示
.timer 配 [Timer] OnCalendar=Mon..Fri 15:30;.service 配 [Service] Type=oneshot ExecStart=/home/quant/bin/ingest.sh。启用 systemctl --user enable --now ingest.timer。模块 收 尾
至此 你 把 substrate(L1)、pipeline(L2)、进程 控制(L3)、bash 脚本 与 调度(L4)合 起来 跑 了 一 遍。再 复 述 一 句 纪律:bash 负责 编排;Python 负责 解析 与 业务 逻辑。当 你 在 awk 里 开始 写 嵌 套 if、当 你 在 bash 里 开始 想 用 数组,那 就 是 该 切 到 Python 的 时候。
下 一 个 模块(3.6.2 Git & Code Quality)会 教 你 把 这套 脚本 与 Python 代码 进 版本 控制、加 pre-commit hook 跑 shellcheck / ruff、做 PR review;3.6.3 教 你 把 ingest 写 进 数据库;3.6.5 把 这套 ingest 装 进 容器、由 Kubernetes CronJob 调度。
阅读清单
- 《鸟哥的 Linux 私房菜》第四版,第 12–15 章(bash 脚本、awk、sort、grep 与 正则、备份 与 服务)。
man bash中文版 关于set/trap/getopts的 小节。- Greg's Bash Wiki / mywiki.wooledge.org 的 中文 翻译 片段(
Bash Pitfalls)。 - systemd 中文 用户手册 关于
systemd --usertimer 的 章节。 - 一篇 被 A-股 量化社区 广泛 转载 的 「cron 与 收盘后 数据落地」 经典 短文。
bash 负责 编排;Python 负责 解析 与 业务 逻辑——把 这一 行 抄 在 你 工作 笔记 的 第 一 页。
参考卡
本课 给 你 留 下 七 块 Fenced ```bash 模板,覆盖 一个 生产 落地 脚本 的 所有 必要 部件:
- 前奏:
#!/usr/bin/env bash+set -euo pipefail+IFS=$'\n\t',三行 不变。 - 引号 防卫:危险 的 裸
$DIR与 安全 的"${DIR:?DIR is required}"。 - 日志 助手:
log()一行 输出level=INFO msg=... key=value形式 的 结构化 日志。 - **
getopts**:短 标志 解析 +usage+ 默认DATE=昨天。 - **
trap**:mktemp -d紧跟trap,覆盖 EXIT INT TERM 三 条 退出 路径。 - **capstone
ingest.sh**:把 上面 五 块 拼 成 一 个 端 到 端 的 落地 脚本。 - cron 行 + 两 条 pitfall:
PATH极小、没有 终端。
把 capstone 整 块 抄进 你 自己 的 ~/bin/ 模板 库 当 「新 项目 起手」 用——之后 改 ticker、改 时区、改 Python loader 模块 名 即可。Inline 引用 ${VAR:?msg}、${VAR:-default} 与 $(cmd) 这 几 个 默认 展开 形式 也 会 跟 你 一辈子。
收 尾 再 重复 一遍 这 个 模块 的 四 个 关键 词:substrate(L1 文件 与 权限)、composition(L2 管道)、operation(L3 进程 与 远程)、integration(L4 脚本 与 调度)。把 这 四 层 都 拿 稳,你 就 能 在 任何 A-股 量化 团队 的 研究机 上 把 自己 的 工作 立住。