国内某头部私募 quant 桌的初级开发者隔夜上线了一段 P&L 归因脚本。第二天清晨的 trade-cap 报表显示一笔头寸的名义金额是 -21,474,836.48 元。那笔头寸是 510300.SH(沪深 300 ETF)的 4,500,000 股多头。bug 花了六个小时才定位:把累计股数加总的辅助函数被声明成了 int total_shares;,而值在累加过程中越过了 21 亿——一个 32 位有符号整数在大约 2.147e9 处溢出。风控团队不得不在开盘前手工对账头寸系统。裸 int 几乎不是任何 quant 开发该写的类型。本课就是 C++17 类型系统,但只讲到你日常会用到的那一层——从 <cstdint> 取定宽整数表示数量与 ID,IEEE-754 double 表示价格与波动率,const 正确性让编译器替你执行你的意图,enum class 让 Buy 不会被悄悄当成 1——再加上你在第 5 课之前会敲上百遍的控制流与函数定义机制。整堂课串在一个例子上:一个对 510300.SH 欧式看涨期权按 Black-Scholes 闭式公式定价的自由函数 black_scholes_call。
定宽整数与价格的规则
任何 C++ 文件里第一个决策是哪些数值类型在这里。<cstdint> 声明了定宽整数别名:int8_t 与 uint8_t(1 字节,有符号与无符号),int16_t / uint16_t(2 字节),int32_t / uint32_t(4 字节),int64_t / uint64_t(8 字节)。还有两个平台相关的:size_t 用于大小与数组下标(无符号,至少 32 位——在所有现代 x86-64 机器上是 64 位),ptrdiff_t 用于同一数组内两个指针之差(有符号,与 size_t 等宽)。
规则:永远不要用裸 int 表示价格、数量、或任何可能增长的累计值。裸 int 按标准至少 16 位,2026 年的实际几乎一定是 32 位,而一个 32 位有符号整数在 21M 元(以「分」为最小单位计)就溢出了。用 int64_t 表示以最小单位(分)记的金额——64 位下你有大约 ±9200 万亿的余量,够用了。用 double 表示以主单位(元)记的价格(510300.SH 的行情口径)。每个工程选一种约定,不要混用。
浮点:IEEE-754 double 8 字节,大约 15–17 位有效十进制数字,是价格、PnL、波动率、利率以及任何连续量的默认。float 4 字节,大约 6–9 位——只有在你已经度量了内存压力并接受了精度损失之后才用。long double 精度依赖平台(x86-64 Linux 上 80 位、Windows MSVC 上 64 位、某些 PowerPC 构建上 128 位);除非你既懂你的平台又懂你的问题,否则别用。
其余内建类型是 bool(1 字节,在你读过 std::vector<bool> 的特化之前别假设它在向量里被压成位),以及 char(1 字节,但有无符号是实现相关的——ARM 与 x86 的默认就不一样)。如果你要表达一个小整数,用 int8_t / uint8_t;只有真的想表达字符串里的字符时才用 char。
enum class 与 const 正确性
两个看上去不大、实际很重的语言特性。enum class Side { Buy, Sell }; 是 C++11 的作用域枚举——Side::Buy 与 Side::Sell 不会隐式转成整数,也不会污染外层命名空间。C 风格的无作用域形式 enum Side { Buy, Sell }; 会让 Buy 与文件里别处任何 Buy 撞名,而且稍有点动静就会自动退化成 int。永远优先 enum class。
const 正确性是另一个。const double r = 0.024; 声明一个不可变的局部;之后任何 r = 0.025; 都被编译器拒掉。void price(const Order& o) 以引用接收 Order 而不拷贝,且不给该函数修改它的权限;调用方可以传入任何 Order——包括临时对象——并知道它返回时不变。让整套机制运转的规则是:const 正确性会传递。一个接收 const Order& 的函数,只能在 o 上调用 const 限定的成员函数;第 4 课会示范怎么给访问器加 const,把这条链子拉住。
还有一组你会在编译错误信息里看到的术语:值类别。lvalue(左值)是有名字、有地址的东西——int x = 1; x 是 lvalue,你可以取 &x。rvalue(右值)是一个临时——x + 1 是 rvalue,你不能 &(x + 1)。C++ 用这个区分挑选函数重载(const T& 参数两者都能接,T& 只能接 lvalue)。围绕右值引用与 std::move 的产生式规则属于模块 3.4.2。
控制流:C++17 的形式
if、for、while 的形状与 Python 直观相通,有三个 C++17 加项值得拎出来。
if (init; cond) 形式把临时量限定在分支里:
if (const double intrinsic = S - K; intrinsic > 0.0) { /* in the money */ }
intrinsic 只在 if 的大括号里(以及后续 else if / else)可见。C++17 之前的写法会把名字泄漏到外层作用域。
范围 for:
for (const auto& x : prices) { sum += x; }
const auto& 只读且不拷贝;auto& 读且写;裸 auto 拷贝。刻意选。带计数器的 C 风格 for (int i = 0; i < n; ++i) 在你需要下标时仍合适,但优先伸手抓范围形式。
switch 配上 [[fallthrough]],对那些真想穿透的 case:
enum class Side { Buy, Sell };
switch (s) {
case Side::Buy:
record_buy();
[[fallthrough]];
case Side::Sell:
record_audit();
break;
default:
throw std::runtime_error("unknown side");
}
没有 [[fallthrough]] 注释时,编译器会对 Buy 与 Sell 之间缺失的 break 报警告——这正是它的意义所在,因为几乎所有缺失的 break 都是 bug。这个注释是你告诉编译器「我故意的」的方式。[[nodiscard]] 是它的配套:[[nodiscard]] double price(...) 让编译器在调用方丢弃返回值时报警告。用在那些「调用就是为了返回值」的函数上。
函数:声明、定义、参数
C++ 函数有两部分。声明给出返回类型、函数名与参数类型——double black_scholes_call(double S, double K, double r, double sigma, double T);——告诉程序的其余部分有这么一个函数存在。定义给出函数体。声明放头文件(pricing.hpp),定义放源文件(pricing.cpp);第 5 课会把这个拆分的整套论证讲清楚,但约定从现在开始。
参数传递在本模块有四种常用形式。按值——double x——拷贝实参;用于内建类型与小 struct。按 const &——const Order& o——不拷贝、不许修改;读非平凡类型的默认。按 &——Order& o——不拷贝、被调方可改;少用、并把修改写在文档里。按裸指针——Order* o——第 3 课讲,用于 null 是有意义值的情形。
默认参数只能在最右侧那几个参数上:double price(double S, double K = 100.0)。函数重载在调用处按参数列表挑选:同名而参数不同的两个函数,对编译器而言是两个不同的函数。inline 关键字加在自由函数定义上,允许该定义放在头文件里而不违反单一定义原则——第 5 课讲为什么;现在,记住关键字就够。
工作例:对 510300.SH 看涨期权的 Black-Scholes 定价
把零件拼起来。欧式看涨期权 Black-Scholes 闭式公式是
其中 是标准正态 CDF。推导属于量化技能轨(1.4.3);这里你只是把它翻译成代码。
需要的 include:
#include <iostream>
#include <iomanip>
#include <cmath>
#include <cstdint>
函数本体,写一次、调用三次:
double black_scholes_call(double S, double K, double r, double sigma, double T) { const double d1 = (std::log(S / K) + (r + 0.5 * sigma * sigma) * T) / (sigma * std::sqrt(T)); const double d2 = d1 - sigma * std::sqrt(T); const double Nd1 = 0.5 * std::erfc(-d1 / std::sqrt(2.0)); const double Nd2 = 0.5 * std::erfc(-d2 / std::sqrt(2.0)); return S * Nd1 - K * std::exp(-r * T) * Nd2; }
关于 CDF 一句话:C++17 标准库没有现成的正态 CDF,所以你用 std::erfc(<cmath> 提供的互补误差函数)按恒等式 计算——标准库给你的是 erfc,这条恒等式是精确的。
驱动 main:
int main() {
const double S = 4.20, r = 0.024, sigma = 0.18, T = 0.5;
std::cout << std::fixed << std::setprecision(4) << black_scholes_call(S, 3.80, r, sigma, T) << '\n';
std::cout << std::fixed << std::setprecision(4) << black_scholes_call(S, 4.20, r, sigma, T) << '\n';
std::cout << std::fixed << std::setprecision(4) << black_scholes_call(S, 4.80, r, sigma, T) << '\n';
return 0;
}
三个调用点:深度实值(K = 3.80)、平值(K = 4.20)、深度虚值(K = 4.80)。参数来自 510300.SH 的真实口径:S = 4.20 元 / 份(ETF 报价单位)、r = 0.024(一年期 SHIBOR 约略值)、sigma = 0.18(近一年实际波动率水平)、T = 0.5 年。std::fixed << std::setprecision(4) 把输出锁到四位小数。'\n' 行尾符优于 std::endl;std::endl 会刷流,在紧凑的数值循环里是浪费动作。用第 1 课的 CMake 构建一遍,你应当看到三个价格按降序打印——深度实值最贵,深度虚值最便宜,这是你每次都做一遍的健全性检查。
行业一句:国内私募 / 自营 quant 桌的 C++ 代码里以 int64_t 表示「分」为最小单位的金额是常见做法(避免浮点累加误差);以 double 表示「元」为单位的价格也很常见——两种约定都见过,关键是同一工程内不混用。本课的 black_scholes_call 采用「元」为单位 + double,与 510300.SH 的实际行情口径一致。
阅读清单:《C++ Primer》第 5 版 中译 第 2 章(变量和基本类型)、第 6 章(函数);zh.cppreference.com 上 <cstdint> / enum class / [[nodiscard]] / [[fallthrough]] 的页面;《Effective Modern C++》中译 条款 1(理解模板类型推导)作为承前启后阅读——本课不讲模板,但 auto 的工作方式与此相关。
Formula Explorer
C = S * Phi(d1) - K * exp(-r*T) * Phi(d2)通往下一课
你现在能在现代 C++17 里写一个自由函数、为任务挑对数值类型、用 const 正确性传达意图、用现代形式驱动控制流。上面 Black-Scholes 函数里每一个值都活在栈上——函数入口创建、出口销毁,没有任何分配器参与。第 3 课会把这一模型的盖子掀开:栈与堆、裸指针、作为「不可为空别名」的引用,以及当你用 new 分配后忘了 delete 时 AddressSanitizer 会说什么。定价器会变成一组价格的数组,而数组就是麻烦开始的地方。
练习
Exercise
拿本课的 black_scholes_call 函数。(a) 给声明加上 [[nodiscard]],观察当调用方写出 black_scholes_call(100.0, 100.0, 0.03, 0.20, 0.5); 作为一条独立语句、丢弃返回值时,编译器会给出什么警告。(b) 加一个重载 double black_scholes_call(double S, double K, double r, double sigma, double T, bool is_call),当 is_call == false 时返回看跌价(用 put-call parity:P = C - S + K * exp(-r * T))。(c) 在 main 里以 S = 100.0, K = 100.0, r = 0.03, sigma = 0.20, T = 0.5 调用两个重载,验证 call_price + (K * exp(-r * T) - S) 在 1e-12 容差内等于 put_price。
提示
-Wunused-result 风格的警告,信息里点名函数与该属性。重载的函数体应当先调 5 参形式拿到 C,再按 is_call 分支。提示
P = C - S + K * exp(-r * T)。用 std::abs(lhs - rhs) < 1e-12 做 double 比较;由于两侧用的是同一组基元,结果应当是确定的。