
Python 3.15.0 已于 2026 年 10 月 9 日正式发布。这个版本包含 5643 次提交、来自 1012 位贡献者,改动很多,但大多数开发者用不着关心其中的大部分。
真正值得关注的,是那些会改变我们日常写代码方式的变化:一个回测命令行工具怎么组织导入、一套策略参数怎么防止被意外修改、一个修改挂单的接口怎么区分“没传”和“传了 None”、一个慢吞吞的回测怎么找到瓶颈。
这篇文章挑出七个变化,全部换成量化场景的例子,并在 Python 3.15 上实际运行过。有些很方便,有一个用不好会让程序更难调试,还有一个的基准测试数字很亮眼,值得细看,但还不到庆祝的时候。
核心要点
惰性导入(Lazy Imports):lazy import pandas as pd 让查版本号这样的轻量命令不再为 pandas 的导入买单,代价是缺失依赖的报错从启动时推迟到第一次使用时。
frozendict 和 sentinel 成为内置:前者让策略参数不可修改、还能直接做缓存的键;后者让接口能区分“没传参数”和“传了 None”。
默认 UTF-8:在中文 Windows 上,以前不写 encoding 也能读的 GBK 文件,到了 3.15 会直接报 UnicodeDecodeError,升级前一定要检查文件读写。
新的采样分析器和 JIT:分析器可以附加到正在运行的进程;JIT 在官方基准上快了 7%~12%,但仍是实验特性,你的代码要自己测。
一、惰性导入:启动更快,但要想清楚导入发生在什么时候
1. 问题
设想一个回测命令行工具:report 命令要用 pandas 生成报告,version 命令只打印版本号。按传统写法,把 import pandas 放在文件开头,每个命令都要先等 pandas 导入完;把导入挪进函数里倒是可以,但文件一长,导入语句就散落在各个分支里,很难找。
2. 新写法
Python 3.15 通过 PEP 810 引入了显式的惰性导入:
import sys
# 只有生成报告时才需要 pandas,查看版本号用不到它
lazy import pandas as pd
def report():
"""生成回测报告:第一次用到 pd 时,pandas 才真正被导入"""
nav = pd.Series([1.00, 1.02, 0.99, 1.05], name="净值")
return nav.pct_change().describe()
def version():
return "backtest-cli 1.0.0"
print(version())
print(f"查版本号之后,pandas 已导入:{'pandas' in sys.modules}")
print(report().round(4))
print(f"生成报告之后,pandas 已导入:{'pandas' in sys.modules}")
运行结果:调用 version() 之后 pandas 不在 sys.modules 里,调用 report() 之后才出现。
lazy import 只是先绑定一个“惰性引用”,名字第一次被用到时才真正加载模块。普通的 import 语句行为不变。这样的好处是改动是显式的、局部的:代码评审时一眼就能看到哪个导入被推迟了,并追问为什么,而不需要改变整个程序的导入行为。
3. 代价:报错时间变了
# 一个没有安装的技术指标库:程序照样能启动
lazy import talib_not_installed as ta
print("程序正常启动")
def rsi_signal(prices):
return ta.RSI(prices)
try:
rsi_signal([1, 2, 3])
except ImportError as e:
print(f"直到调用时才报错:{type(e).__name__}: {e}")
运行结果:先打印“程序正常启动”,调用 rsi_signal 时才报 ModuleNotFoundError。
以前启动时就会崩溃的缺失依赖,现在可能要等到盘中某个信号第一次触发时才暴露出来。对于实盘程序,这显然不是你想要的。同理,那些在导入时注册插件、配置日志、给其他库打补丁的模块,在真正被加载之前,这些事情都不会发生。
惰性导入不会让昂贵的依赖变得免费,它只是让你决定由哪个操作来承担这笔开销。
4. 使用限制
lazy import 只能写在模块顶层,不能写在函数、类定义或 try 块里,否则直接报 SyntaxError;
需要兼容 3.14 及更早版本的代码不能用新关键字,可以用文档里的 __lazy_modules__ 机制;
还有一个全局开关,让所有导入都变成惰性的:
python3.15 -X lazy_imports=all app.py
全局开关更适合用来做兼容性测试,而不是直接上生产。有针对性的局部改动,比在整个进程里悄悄推迟所有导入容易理解得多。
补充:实盘或定时任务这类程序,建议在启动阶段加一个依赖自检,比如把关键模块的某个属性访问一遍,主动触发真正的导入,让缺失依赖在开盘前就暴露出来。
二、frozendict:不可变的字典终于内置了
Python 早就有 tuple 和 frozenset,但想要一个不可变的字典,以前只能用第三方包、MappingProxyType,或者跟同事约定“谁都别改它”。3.15 新增了内置的 frozendict:
from collections.abc import Mapping
from functools import lru_cache
# 策略参数:回测过程中谁都不应该改它
PARAMS = frozendict(fast=10, slow=60, fee=0.0003)
try:
PARAMS["fee"] = 0
except TypeError as e:
print(f"TypeError: {e}")
print(f"是 dict 吗:{isinstance(PARAMS, dict)},是 Mapping 吗:{isinstance(PARAMS, Mapping)}")
# 想换参数就生成一个新对象,原对象不变
print(PARAMS | {"fast": 20})
@lru_cache(maxsize=None)
def backtest(params):
"""同一组参数只回测一次:frozendict 可哈希,能直接作为缓存的键"""
print(f" 正在回测 {dict(params)}")
return params["slow"] - params["fast"]
backtest(PARAMS)
# 键的顺序不同,但内容相同,命中缓存
backtest(frozendict(fee=0.0003, slow=60, fast=10))
print(backtest.cache_info())
运行结果:赋值时报 TypeError: 'frozendict' object does not support item assignment;PARAMS | {"fast": 20} 返回一个新的 frozendict;第二次调用 backtest 没有打印“正在回测”,缓存信息显示命中 1 次、未命中 1 次。
参数网格搜索时,这个特性很实用:以前 dict 不能直接作为 lru_cache 的参数,只能先转成排序后的元组。
使用时有三个要注意的地方:
它不是 dict 的子类:代码里写死的 isinstance(x, dict) 检查会失败,需要改成检查 collections.abc.Mapping。
不可变不是递归的:里面存的列表照样能改。
只有键和值都可哈希时才能哈希:
# 外层冻结了,里面的列表照样能改
cfg = frozendict(symbols=["000300", "000905"])
cfg["symbols"].append("000852")
print(cfg)
try:
hash(cfg)
except TypeError as e:
print(f"TypeError: {e}")
# 换成元组才能真正冻结,也才能做缓存的键
print(hash(frozendict(symbols=("000300", "000905"))) is not None)
运行结果:列表被成功追加了 000852;hash(cfg) 报 TypeError: unhashable type: 'list';改成元组后可以哈希。
这些都不意味着 frozendict 不好用,只说明它是一个有用的类型,而不是一个万能的安全开关。
三、sentinel:“没传”和“传了 None”不是一回事
写过修改类接口的人都会遇到这个问题。比如修改一张挂单:调用方没传 stop_loss,就保留原来的止损;明确传了 None,就撤掉止损。常见的 stop_loss=None 默认值无法区分这两种情况。
3.15 新增了内置的 sentinel:
import copy
MISSING = sentinel("MISSING")
def update_order(order, price=MISSING, stop_loss=MISSING):
"""修改挂单:没传的字段保持不变;stop_loss=None 表示撤掉止损"""
new = dict(order)
if price is not MISSING:
new["price"] = price
if stop_loss is not MISSING:
new["stop_loss"] = stop_loss
return new
order = {"code": "600519", "price": 1500.0, "stop_loss": 1450.0}
# 只改价格,止损保持不变
print(update_order(order, price=1510.0))
# 明确传 None:撤掉止损
print(update_order(order, stop_loss=None))
print(f"{MISSING!r},深拷贝后还是同一个对象:{copy.deepcopy(MISSING) is MISSING}")
运行结果:第一次调用止损保持 1450.0,第二次止损变成 None;MISSING 显示为 MISSING,深拷贝后仍是同一个对象。
当然,以前也可以写 MISSING = object()。但内置的 sentinel 有可读的显示形式(调试时不会看到 <object object at 0x...>),拷贝后保持同一身份,可以用在类型标注里,定义在可导入的模块中时还能被 pickle。
这类不起眼的变化往往最终会无处不在:序列化工具、Web 框架、库的默认参数、配置处理。不是因为它有多惊艳,而是因为它给一个常见的做法提供了统一的写法。
四、推导式里的解包:展平数据少写一层
把嵌套列表展平,以前要写两层 for:
groups = [[1, 2], [3, 4], [5]]
flat = [x for group in groups for x in group]
3.15 通过 PEP 798,允许在推导式里直接解包:
# 每天的成交记录是一个列表,合并成一张总表
daily_fills = [[("600519", 100), ("000001", 500)], [("300750", 200)], []]
fills = [*day for day in daily_fills]
print(fills)
# 逐层覆盖的回测配置:默认值 → 品种设置 → 本次实验,后面的覆盖前面的
layers = [{"fee": 0.0003, "slippage": 0.0005}, {"fee": 0.0001}, {"slippage": 0.0}]
print({**layer for layer in layers})
# 几个股票池合并去重
pools = [["600519", "000001"], ["000001", "300750"]]
print(sorted({*pool for pool in pools}))
运行结果:
[('600519', 100), ('000001', 500), ('300750', 200)]
{'fee': 0.0001, 'slippage': 0.0}
['000001', '300750', '600519']
列表、集合、字典推导式都支持这种写法,生成器表达式也支持。字典合并时仍然遵循“后出现的值覆盖前面的”,写法变短了,冲突规则并没有变。
这不是一个值得为之升级生产服务的特性,但如果你经常合并分块解析的数据、展平嵌套结构,它能让代码少一层需要解释的嵌套。
五、默认 UTF-8:对中文 Windows 用户影响最大的变化
在 3.15 中,没有指定 encoding 时,文本读写默认使用 UTF-8,而不再沿用系统的区域编码。
这对 Linux 和 macOS 用户几乎没有感觉,但中文 Windows 的系统区域编码是 GBK(cp936)。很多券商软件、老系统导出的 CSV 都是 GBK 编码,以前不写 encoding 也能正常读,升级后就会出错:
import sys
import tempfile
from pathlib import Path
# 在临时目录模拟一个 GBK 编码的成交记录文件:中文 Windows 上的老软件常这样导出
path = Path(tempfile.gettempdir()) / "trades_gbk.csv"
with open(path, "w", encoding="gbk") as f:
f.write("日期,代码,名称,方向,数量\n2026-10-09,600519,贵州茅台,买入,100\n")
print(f"UTF-8 模式:{sys.flags.utf8_mode}")
try:
# 不写 encoding:3.15 默认按 UTF-8 解码
with open(path) as f:
print(f.readline().strip())
except UnicodeDecodeError as e:
print(f"UnicodeDecodeError: {e.reason}")
# 明确写出文件的真实编码,任何版本、任何系统上结果都一样
with open(path, encoding="gbk") as f:
print(f.readline().strip())
# 确实想用系统区域编码时,写 encoding="locale"
with open(path, encoding="locale") as f:
print(f.readline().strip())
在中文 Windows 上用 Python 3.15 运行:UTF-8 模式为 1,不写 encoding 的那次读取报 UnicodeDecodeError: invalid continuation byte,后两次都能正确读出表头。同一段代码用 3.13 运行(不开 UTF-8 模式)时,三次读取都不会报错。
几点建议:
共享的脚本里,始终明确写出 encoding。它说明了文件的格式,在旧版本的解释器上也一样可靠。
改变默认值不会转换旧文件:GBK 编码的导出文件依然是 GBK 编码。
需要兼容旧行为时,可以用 encoding="locale",或者设置环境变量 PYTHONUTF8=0。
升级测试清单里,一定要加上文件读写这一项。
补充:pandas.read_csv 的 encoding 参数一直默认是 UTF-8,不受这次变化影响;读 GBK 文件本来就要写 encoding="gbk"。受影响的主要是内置的 open()、pathlib.Path.read_text() 等标准库的文本读写。
六、Tachyon:可以中途“附加”的采样分析器
性能问题从不挑合适的时间出现。一个回测或行情服务已经跑了半天,突然变慢了,你的第一个问题往往不是“哪个函数慢”,而是“这个进程到底在干什么”。
3.15 内置了一个采样分析器 Tachyon,位于新的 profiling.sampling 模块。它可以在观察下启动一个程序、附加到一个正在运行的 Python 进程,或者对进程的调用栈做一次快照:
# 运行脚本并生成交互式火焰图
python3.15 -m profiling.sampling run --flamegraph -o profile.html slow_bt.py
# 附加到一个已经在运行的 Python 进程(12345 是进程号)
python3.15 -m profiling.sampling attach 12345
# 获取进程当前的调用栈快照
python3.15 -m profiling.sampling dump 12345
拿一个典型的“慢回测”做实验:逐行循环计算均线,和向量化写法对比。
import numpy as np
import pandas as pd
def ma_cross_loop(close, fast=10, slow=60):
"""逐行循环计算均线交叉持仓:写法直观,但很慢"""
pos = []
for i in range(len(close)):
f = close.iloc[max(0, i - fast + 1): i + 1].mean()
s = close.iloc[max(0, i - slow + 1): i + 1].mean()
pos.append(1 if f > s else 0)
return pd.Series(pos, index=close.index)
def ma_cross_vec(close, fast=10, slow=60):
"""向量化写法:rolling 一次算完"""
f = close.rolling(fast, min_periods=1).mean()
s = close.rolling(slow, min_periods=1).mean()
return (f > s).astype(int)
rng = np.random.default_rng(27)
close = pd.Series(100 * np.exp(np.cumsum(rng.normal(0, 0.01, 20000))))
print(f"两种写法结果一致:{(ma_cross_loop(close) == ma_cross_vec(close)).all()}")
把这段代码存成 slow_bt.py,用上面的 run 命令生成火焰图。采样结果里,除了 ma_cross_loop 这个循环,导入 pandas 本身也占了相当一部分时间。这正好呼应了第一节:对于运行时间很短的命令,导入开销可能比计算本身还大。
Tachyon 支持按墙钟时间、CPU 时间、GIL 占用和异常几种模式采样,也支持异步感知的分析。对于在 Python 代码、等待接口响应和原生扩展库之间来回切换的服务,这种组合很有用。
官方宣传的最高采样频率可达每秒 100 万次,听起来很惊人,但这不是建议你对每个程序都用这么高的频率。频率高不等于结果完全准确:采样可能漏掉很短暂的操作,实际表现也取决于运行环境。
更重要的是工作方式的改变:不用为了“看看时间花在哪了”而去修改程序代码,就能从一个已经在运行的进程里收集证据。
补充:以前常用的 cProfile 是确定性分析器,会记录每一次函数调用,开销大,而且必须从程序启动时就开启。采样分析器每隔一段时间看一眼调用栈,开销小,适合在生产环境里临时使用;第三方工具 py-spy 也是这个思路。
七、JIT:基准测试更快了,但你的代码说了算
3.15 对 CPython 的实验性即时编译器(Just-In-Time Compiler,JIT)做了大幅改进,包括新的追踪机制和更好的代码生成。官方公布的数字:
|
平台 |
对比基准 |
几何平均提升 |
|
x86-64 Linux |
优化后的标准解释器 |
7%~8% |
|
AArch64 macOS |
优化后的尾调用解释器 |
11%~12% |
这些对比的条件很具体,并不是说每个 3.15 安装都会让每个脚本快 8%。JIT 仍然是实验特性,不同基准之间的结果差异很大,有些甚至变慢了。可以这样检查当前解释器的 JIT 状态:
import sys
# sys._jit 是带下划线的内部接口,只用来查看状态
print(f"JIT 可用:{sys._jit.is_available()},已启用:{sys._jit.is_enabled()}")
在官方的 Windows 安装包上运行,两项都是 False:默认的构建并没有启用 JIT。
还有几项平台变化要和 JIT 区分开:
官方 64 位 Windows 安装包现在使用尾调用解释器;
官方 macOS 安装包默认附带自由线程(free-threading)支持,但这不等于每个程序都会自动在没有 GIL 的情况下运行。
对量化代码来说还有一点格外重要:大部分 CPU 时间可能花在 NumPy、pandas 这些原生扩展里,解释器的 JIT 对它们几乎没有影响。 想评估 3.15 对自己程序的影响,应当分别测试启动时间和预热后的运行时间,保持依赖版本一致,并记录测试用的是哪个解释器构建。
一个性能数字,只有在知道构建方式、负载类型和对比对象时才有意义;否则它只是标题里的一个数字。
八、其他值得一提的变化
更安全的启动钩子:新的 .start 文件提供了一种显式描述 Python 启动时运行代码的方式,不用再把可执行的 import 语句藏在 .pth 文件里。维护打包基础设施的人会关心这个。
自由线程的扩展支持:新的 abi3t 稳定 ABI 让 C 扩展的维护者可以面向自由线程构建的 CPython。这不是一键切换,很多扩展仍需要修改代码和构建工具。
更友好的报错:从其他语言带过来的写法会得到提示,比如对列表调用 JavaScript 风格的 .push(),报错会附带 Did you mean '.append'?。
macOS 的已知问题:macOS 27.0 可能导致基于 Tk 的图形程序卡死,包括 IDLE 和 tkinter 对话框。这是操作系统行为变化影响了 Tk,而不是 3.15 的新特性造成的;依赖这些工具的话,升级系统前先测试。
九、怎样试用 3.15,又不打扰正在运行的项目
新的练手项目,直接安装正式版体验新语法就行。已经在跑的项目,建议保留现有的 Python 版本,先单独建一个虚拟环境:
# 从官网安装 Python 3.15 后
python3.15 --version
python3.15 -m venv .venv315
# macOS 或 Linux
source .venv315/bin/activate
python -m pip install -r requirements.txt
python -m pytest
Windows 上可执行文件和激活虚拟环境的命令不同,重点是隔离,而不是具体的命令。
还有一条原则:不要在同一次部署里同时升级解释器、打开全局惰性导入、试验 JIT。 出了问题时,你希望只有一个变量需要排查。
补充:量化项目升级前,最容易出问题的往往是依赖:NumPy、pandas、SciPy 以及各种 C 扩展的指标库需要发布对应 3.15 的安装包(wheel),否则就要从源码编译。先确认依赖都支持新版本,再谈升级。
局限与适用条件
代码的运行环境:文中代码在 Windows 上的 Python 3.15 候选版(3.15.0rc1)上实际运行,lazy import、frozendict、sentinel、推导式解包等新语法在 3.14 及更早版本上无法运行;个别细节可能与正式版略有差异。
编码相关的结论取决于系统:第五节的报错只出现在系统区域编码不是 UTF-8 的环境,比如中文 Windows;Linux 和 macOS 上通常没有区别。
性能数字来自官方基准:JIT 的 7%~8%、11%~12% 和采样分析器的每秒 100 万次采样都是官方公布的数字,本文没有独立复现;具体到自己的程序,提升可能更大、更小,甚至变慢。
生产环境要看依赖:能不能升级,更多取决于依赖库是否已经支持 3.15,而不是发布公告本身。
总结
惰性导入让轻量命令跳过不需要的导入,但要提防缺失依赖的报错被推迟。
frozendict让参数不可修改、可以直接做缓存的键;记住它不是 dict 的子类,也不会递归冻结。
sentinel让“没传”和“传了 None”各有清晰的含义。
推导式解包让展平和合并数据少一层嵌套。
默认 UTF-8对中文 Windows 用户影响最大:读 GBK 文件的老代码会报错,明确写出 encoding 最稳妥。
Tachyon 能附加到运行中的进程找瓶颈;JIT 在进步,但基准数字不等于你的程序。
对学习和新项目来说,现在就可以装上 3.15 开始尝试;对生产环境,答案取决于依赖能不能跟上。最有用的变化不一定是最响亮的那些,新版本也不要求我们马上用上每一个新特性:选择哪些工具的责任,一如既往地落在最了解代码的开发者身上。
以上就是“Python 新版本正式发布:量化开发者最该关注的七个变化”的详细内容,想要了解更多Python教程欢迎持续关注编程学习网。
扫码二维码 获取免费视频学习资料

- 本文固定链接: http://www.phpxs.com/post/14634/
- 转载请注明:转载必须在正文中标注并保留原文链接
- 扫码: 扫上方二维码获取免费视频资料