← 返回模块
3.6.1.4beta 可读 · 未来付费校验通过内容版本 2026-05-27

面向量化流水线的 Bash 脚本

3.6.1 · 面向量化开发的 Linux 与 Shell · 编程

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 在 这里 是 错 的 工具。

调度:cronsystemd --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/Shanghaiexport 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.timerOnCalendar=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 直接 读 脚本 的 环境变量(TZPATHHOME 是 唯三 的 例外——它们 本来 就 是 环境 变量)。

完整 capstone:ingest.sh

脚本 由 五 步 组 成,按 顺序:

  1. 严格 模式 前奏 + 时区 + getopts 解析 默认 DATE=yesterday
  2. mktemp -d 建 临时 目录、紧 跟 trap 注册 三 路 退出 清理。
  3. rsynctick_510300_*.csv.gz(课 内 mock 成 echo)+ gunzip -k 解压。
  4. awk 'END { if (NR < 2) ... exit 1 }' 守 卫 行 数 ≥ 2。
  5. 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 都 看 到 上海 时区;usagelog 两 个 帮 手 函数;默认 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,仅 打印 出 rsyncpython 命令 而 不 实际 执行。(b) 加 一 个 行 数 阈 值:校验 出 的 CSV 行 数 不 足 1000 行,记 一 条 WARN 级 日志 但 不 退 出;如果 表头 之外 一 行 也 没有,仍按 之前 exit 1。(c) 把 cron 行 替换 成 systemd --user timer 对:~/.config/systemd/user/ingest.timerOnCalendar=Mon..Fri 15:30,配 一个 ingest.service/home/quant/bin/ingest.sh。给 出 三 件 产物:修改 后 的 ingest.shingest.service 单元、ingest.timer 单元。

提示
-n 这种 不 带 参数 的 flag 在 optstring 写 n(不 加 冒号);case 分支 里 DRYRUN=1。脚本 里 把 真 跑 行 改 成 if [[ "$DRYRUN" == 1 ]]; then echo rsync ...; else rsync ...; fi
提示
systemd unit 文件 是 INI:.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 --user timer 的 章节。
  • 一篇 被 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-股 量化 团队 的 研究机 上 把 自己 的 工作 立住。