跳转至

第 6 课 把重复计算整理成函数,并学会从报错里找线索

上一课的相邻收益会被使用很多次。函数把一组步骤保存为有名字的小工具,模块和包负责组织更多工具,报错则告诉我们哪一项约定没有满足。

适合谁能读懂条件和循环的读者
前置知识变量、列表、条件、循环和缩进
建议用时120 分钟,可以分两次完成
本课成果写出可测试的收益率函数,并独立读懂四类常见报错

同一条公式为什么不该复制很多遍

相邻收益率公式会出现在数据检查、指标计算和回测账本中。若每次都重新写 new_price / old_price - 1,某一处可能忘记减 1,另一处又可能把前后价格写反。复制越多,修改和复查越困难。

Python 的函数会把一组步骤保存为一个名字。函数先规定需要哪些输入,内部完成计算,再把结果交回调用它的代码。以后每次使用同一规则,只需提供新的输入,无需复制实现细节。

开始写函数以前,先用自然语言确定约定。本课函数接收旧价格和新价格,两个输入都应该是大于零的数值,输出为小数形式的简单收益率。旧价格为 10 元、新价格为 10.5 元时,答案应该是 0.05。

def simple_return(old_price, new_price):
    result = new_price / old_price - 1
    return result

def 表示开始定义函数,simple_return 是函数名,括号中的 old_pricenew_price 是两个参数。冒号以后进入函数内部,缩进的第一行完成计算,return 把结果交给外部。运行这段定义只会保存步骤,不会自动计算收益。

调用函数时要写名字和括号,并在括号中提供具体输入。下面第一次使用位置参数,Python 按先后顺序把 10 交给旧价格、10.5 交给新价格。第二次使用关键字参数,名字直接说明每个数值扮演什么角色。

rate_a = simple_return(10, 10.5)
rate_b = simple_return(old_price=20, new_price=19)

print(rate_a)
print(rate_b)

输出应为 0.05 和负 0.05。函数只定义一次,却完成了两组独立计算。关键字写法稍长,前后参数容易混淆时更安全;位置写法更紧凑,读者必须熟悉函数约定。

flowchart LR
    accTitle: 一次函数调用怎样经过输入、计算和返回
    accDescr: 调用者把旧价格和新价格交给参数,函数内部计算收益率,return 再把结果交回外部变量

    a[调用函数<br/>simple_return] --> b[输入参数<br/>旧价格与新价格]
    b --> c[函数内部<br/>执行收益率公式]
    c --> d[return<br/>交回计算结果]
    d --> e[外部变量<br/>继续保存或计算]

参数、局部变量和返回值有什么区别

参数是函数开始执行时收到的信息。局部变量只在函数内部暂时保存中间结果,返回值是函数结束时交出的结果。上一个例子中的 old_pricenew_price 是参数,result 是局部变量,return result 交出的数值是返回值。

局部变量帮助函数隔离内部细节。外部代码通常不能直接读取函数里的 result,这样不同函数就不容易误改彼此的临时状态。调用者只需要知道输入约定与输出含义,内部实现以后可以增加费用或检查,只要约定保持清楚,其他代码仍能使用。

若函数只使用 print 显示结果,却没有写 return,外部变量通常会得到 None。打印适合让人临时观察,返回值适合交给后面的程序继续处理。下面函数能够在屏幕上显示 0.05,但 saved 保存的不是这个数字。

def show_return(old_price, new_price):
    print(new_price / old_price - 1)

saved = show_return(10, 10.5)
print(saved)

运行时先显示 0.05,随后显示 None。函数没有明确交回结果,Python 用 None 表示“没有返回值”。研究代码通常让计算函数返回数值或表格,再由报告部分决定怎样打印和画图,计算与展示因此更容易分别测试。

输入约定为什么要在函数内部检查

旧价格为零时,收益率分母无效;旧价格为负数时,普通股票价格也没有合理含义。若函数直接计算,零会产生除零错误,负数则可能安静地得到一个数字。更稳妥的函数会在计算以前检查输入,并在约定不满足时主动停止。

def simple_return(old_price, new_price):
    if old_price <= 0:
        raise ValueError("old_price 必须大于 0")
    if new_price < 0:
        raise ValueError("new_price 不能小于 0")

    return new_price / old_price - 1

raise 会主动产生错误,ValueError 表示数据类型允许当前操作,但具体取值不符合约定。错误文字应告诉读者哪个输入有问题以及合理范围。函数收到旧价格零时,会在除法以前停止,报错位置也更接近原因。

输入检查不能替代数据研究。停牌、退市和期货负价格事件可能需要不同业务规则,函数不能把所有复杂情况都压成“大于零”。教学函数只处理普通正价格,适用范围要写在说明中。真实项目应根据资产类别建立更准确的约定。

文档字符串怎样把约定留在代码旁边

函数定义下面可以加入三引号文字,这段内容叫文档字符串。它应简要说明函数作用、输入单位、返回值和重要限制。编辑器与 help() 可以读取它,几个月以后重新打开项目时也不必到别处猜测。

def simple_return(old_price, new_price):
    """计算两个正价格之间的简单收益率,返回小数形式。"""
    if old_price <= 0:
        raise ValueError("old_price 必须大于 0")
    return new_price / old_price - 1

这一句没有描述公式的每个符号,因为代码已经清楚展示了计算。它补充的是函数用途、价格条件和输出形式。复杂函数还可以逐项写参数和异常,入门阶段先养成留下约定的习惯。

小测试怎样证明几个已知场景

函数写完以后,应先使用答案已知的小例子。价格上涨、不变和下跌分别覆盖三种基本结果。assert 会检查左边是否与预期相等,条件失败时立即停止。

assert abs(simple_return(10, 11) - 0.1) < 1e-9
assert simple_return(10, 10) == 0
assert abs(simple_return(10, 9) + 0.1) < 1e-9

第一条与第三条使用容许误差,避免浮点尾数影响严格相等判断。第二条的结果恰好为零,可以直接比较。三条测试分别证明函数在这三个输入上符合预期,没有证明所有市场数据都正确。还应检查非法输入是否真的报错,未来课程会使用更完整的测试工具。

测试的价值来自先写预期。只运行函数再观察一个看起来合理的数字,很容易接受错误结果。手算 10 到 11 的收益率以后,程序必须接近 0.1;若得到 10%,显示格式可能正确,内部值仍要确认是否为 0.1。

一个文件为什么叫模块

函数增加以后,把所有内容放在同一个脚本会越来越长。Python 把一个 .py 文件视为一个模块。可以把稳定的收益计算保存在 calculations.py,其他文件再通过 import 使用它。

from calculations import simple_return

rate = simple_return(20, 21)

第一行让当前文件从 calculations 模块中取出 simple_return 这个名字,第二行才真正调用函数。导入不会把函数来源变得可靠,研究者仍要知道模块版本和函数约定。它只负责让不同文件共享同一份实现。

模块可以按照职责分开。读取数据、计算指标、生成信号、维护账本和画报告承担不同任务,分别组织以后更容易单独检查。文件数量也不能盲目增加,一个模块最好围绕一类稳定职责。

project/
  data.py
  indicators.py
  strategy.py
  backtest.py
  report.py

data.py 只处理数据读取与清洗,indicators.py 保存指标计算,strategy.py 描述信号,backtest.py 维护成交与账本,report.py 读取结果并画图。策略函数不应偷偷下载数据,画图函数也不应在内部修改持仓,这些边界能减少难以追踪的副作用。

包、安装和导入分别发生了什么

多个相关模块可以组成。NumPy、pandas 和 Matplotlib 都是别人维护的第三方包。它们包含许多文件、版本和依赖,安装以后才出现在某个 Python 环境中。

安装把包文件放入当前环境,导入让当前代码使用包里的名字,调用让某个函数或方法真正执行。三个动作常被初学者混在一起,所以程序提示“找不到 pandas”时,要先判断当前环境是否安装,再判断代码是否正确导入。

动作 发生的事情 常见检查
安装 把指定版本的包放进 Python 环境 当前使用的是哪个 Python
导入 在当前文件中取得包或函数名称 名称与拼写是否正确
调用 给函数输入并执行步骤 参数和返回值是否符合约定

import pandas as pd 会导入 pandas,并在当前文件中用 pd 作为简称。简称来自社区长期习惯,大量教程都会采用,保留它更容易阅读资料。简称只在当前代码中有效,不会改变包的正式名称。

浏览器练习场已经提供本书示例所需工具,Python 入门阶段无需安装本地环境。完成第 8 课以后,想保存完整项目的读者再按安装页创建独立环境。延后安装可以先把注意力放在代码含义,无法联网时仍能继续阅读手算示例。

报错为什么要从最下面读起

Python 报错通常包含一段调用轨迹,也叫 traceback。上方记录程序经过哪些文件和函数,最末行写错误类型与简短原因。初学时先读最后一行,再向上找到第一处属于自己文件的代码,通常能最快接近问题。

File "study.py", line 4, in <module>
    rate = new_price / old_prcie - 1
NameError: name 'old_prcie' is not defined

最末行告诉我们错误类型为 NameError,并指出名字 old_prcie 没有定义。向上一行看,代码把 old_price 拼成了 old_prcie。修正拼写以后应重新运行原例子,并确认输出与手算一致。报错消失只能说明这次执行没有在同一位置停止,逻辑结果仍要检查。

错误类型 常见原因 第一项检查
NameError 名字未定义、拼错或执行顺序不对 对照变量定义与拼写
TypeError 当前类型不支持某项操作 打印 type(...)
IndexError 列表位置超出有效范围 比较索引与 len(...)
KeyError 字典或表格中没有这个键 打印真实字段名称
ValueError 类型允许操作,具体取值不符合要求 检查输入内容和范围

同一种错误也可能有不同原因。KeyError: 'close' 可能来自大小写、前后空格、读取失败或数据供应商改名。先打印 columnskeys(),再依据数据说明判断,不能只把代码中的名称改成碰巧存在的列。

最小复现怎样缩小问题

一百行程序报错时,同时修改十处会失去因果。更可靠的做法是保留能够重现错误的最少数据和代码,这个小例子叫最小复现。下面两行已经足以说明索引 2 超出两项列表范围。

prices = [10, 11]
print(prices[2])

问题缩小以后,可以明确列出有效索引为 0 和 1,修正方案也容易验证。若最小例子没有复现原错误,说明问题可能来自前面的数据处理、环境或函数调用关系,应该逐步加入必要部分,直到错误重新出现。

向 AI 求助时,最小复现同样重要。提供相关代码、完整报错、一个脱敏输入样例和预期结果,请 AI 先解释错误类型与候选原因,再给出一处最小修改。直接要求重写整个文件会带来很多新变化,原原因反而更难确认。

一套可以反复使用的排错顺序

遇到错误时先保留完整报错,不要只截最后几个字。随后确认当前运行的是哪个文件和哪套数据,再从末行读错误类型,向上找到自己的最近一行。打印关键变量的类型、长度、列名与前几项,最后缩小成最小例子。

修改时一次只处理一个明确原因,并重新运行原测试。若代码不再报错,还要比较手算结果、输入行数和关键中间值。报错属于显性失败,时间错位、单位错误和未来数据泄漏往往不会报错,因此结果检查比消灭红字更重要。

容易弄错的地方

现象 原因 下一步
函数打印出数字,外部变量却是 None 函数没有用 return 交回结果 明确返回值并再次调用
参数前后顺序写反 位置参数依赖函数定义顺序 使用清楚名字或关键字参数
包已安装,程序仍找不到 安装和运行可能使用不同 Python 环境 确认当前解释器与安装位置
报错很长,找不到重点 调用轨迹记录了多层经过 先读最末行,再向上找自己文件
改很多处以后报错消失 无法判断哪项修改有效 回到最小复现,一次只改一个原因
函数测试通过便相信全部数据 小测试只覆盖已写场景 再检查时间、单位、缺失与边界

一分钟自测

函数定义以后会立刻执行吗?printreturn 对外部代码有什么不同?

查看答案 定义只把步骤保存下来,需要写函数名和括号才会执行。`print` 把内容显示给人,`return` 把结果交回调用者,外部变量才能继续保存和计算。

本课术语

词语 现在应该怎样理解
函数 有名字、可重复调用的一组步骤
参数 函数开始执行时收到的输入
局部变量 只在函数内部暂时使用的名字
返回值 函数完成后通过 return 交回的结果
模块 可以被其他文件导入的 Python 文件
组织多个模块并带有版本的工具集合
异常 程序无法按当前约定继续时产生的错误对象
调用轨迹 报错中记录文件与函数经过的路径
最小复现 能够稳定重现问题的最少代码和输入

课后练习

1 辨认输入输出

调用 simple_return(20, 21) 时,两个参数和返回值分别是什么?

提示把数字按函数定义顺序放入旧价格和新价格。
答案旧价格参数为 20,新价格参数为 21,返回值为 `21 / 20 - 1 = 0.05`。

2 判断返回值

函数内部只写 print(result),没有 return,外部变量通常保存什么?

提示屏幕显示和函数交回结果是两项动作。
答案外部变量通常保存 `None`。函数虽然显示了内容,却没有通过 `return` 交回数值。

3 阅读报错

三项列表读取 prices[3],最可能出现哪类错误,为什么?

提示三项列表的有效位置是 0、1、2。
答案会出现 `IndexError`,因为索引 3 已经超出列表范围。先打印长度与索引可以确认。

4 动手修改

写一个 price_change 函数,接收旧价格和新价格,检查两者都不小于零,再返回差值。

提示先用 `if` 和 `raise ValueError` 检查,最后写 `return new_price - old_price`。
答案一种写法是 `def price_change(old_price, new_price):`,函数内部检查 `old_price < 0 or new_price < 0` 时抛出错误,随后返回 `new_price - old_price`。注意缩进。

5 开放思考

一个函数同时下载数据、计算指标、生成信号、修改持仓和画图,会给研究检查带来什么困难?

评价要点回答应讨论职责混合、输入输出不清、难以最小复现、修改一个环节影响其他环节,以及无法分别测试数据、策略和展示。

下一课会认识量化研究最常用的三个第三方包,并把列表套字典转换成带行列名称的表格。继续阅读第 7 课 NumPy、pandas 和 Matplotlib