你多半用过 Excel:数据一行行排、字段一列列摆,想看某天的收盘价就顺着列名找,想算一列的平均就框住那一列拉个函数。这套「行 + 列名」的组织方式之所以流行,是因为它和人类整理信息的直觉天然合拍。量化里的数据——每天的开盘价、最高价、最低价、收盘价、成交量——恰恰就是一张典型的表格。上一篇我们认识了 NumPy 的 ndarray 和「向量化」思维:一整列数字同时开方、同时求和,一行代码搞定。但 ndarray 有个不方便的地方:它只认位置、不认名字。第 0 列是什么字段?第 100 行是哪一天?全得靠你自己记在心里。真实行情是「带名字」的:这一列叫收盘价、那一行是 2024 年 6 月 5 日。我们需要一个既保留 NumPy 的速度、又能给行和列贴上标签的工具——它就是 pandas,而 pandas 的两块基石,就是本篇的主角:Series(序列)与 DataFrame(数据框)。
本篇我们先弄懂这两个结构的「长相」和脾气,再用它们承载一份真正的 OHLCV 行情数据,练熟摸数据的四板斧、按行列取数的几种姿势和新增列的向量化写法。学完本篇,你就有了后面所有篇章装数据的「那只碗」——从第 5 篇的时间序列开始,我们天天都要用它。
本篇目标
学完本篇,你能够做到:
- 用列表或字典创建 Series 和 DataFrame,说清两者分别对应表格里的「一列」和「一张表」。
- 用
head、tail、info、describe四板斧,在 30 秒内摸清一份陌生行情数据的结构与分布。 - 用列名取列,用
loc按标签取行、用iloc按位置取行,说清两者的区别。 - 用布尔条件筛出满足要求的交易日(比如「收阳线」或「放量上涨」的日子)。
- 用一行向量化代码给 DataFrame 新增一列(如当日均价、涨跌标记),而不是写 for 循环。
核心概念
Series:带门牌号的一列数据
Series(序列)可以理解为「一维数组 + 索引(index)」:数据本身是值,每个值前面挂着一个标签,就像酒店房间的门牌号。用列表就能造一个:
import pandas as pd
prices = pd.Series([10.2, 10.5, 10.3, 10.8, 11.0],
index=["周一", "周二", "周三", "周四", "周五"],
name="收盘价")
print(prices["周三"]) # 按标签取值:10.3
print(prices.mean()) # 照样享受 NumPy 式的向量化计算
白话版:NumPy 数组只记得「第几个」,Series 还记得「叫什么」。prices["周三"] 这种按标签取数是 ndarray 做不到的;但它骨子里仍是数组,所以 mean()、max()、std() 这些向量化运算一个不少。
DataFrame:多列 Series 拼成的一张表
DataFrame(数据框)是一张二维表:每一列是一个 Series,所有列共享同一套行索引。最常见的创建方式是用字典——键当列名,值当列数据。行索引可以直接放日期,这在行情数据里是标配,本篇的完整代码就会这么干。
OHLCV:行情数据的标准长相
股票日线行情通常长成 OHLCV 这副模样:O 是 Open(开盘价),H 是 High(最高价),L 是 Low(最低价),C 是 Close(收盘价),V 是 Volume(成交量)。一天一行、五个字段,天然就是一张 DataFrame:行索引放交易日,列名放这五个字段。后面所有篇章——收益率、均线、MACD、回测——吃的都是这张表。
划重点:拿到任何一份新数据,先
df.head()看长相,再df.info()看结构和缺失,最后df.describe()看分布。这三步花不了 30 秒,却能避开后面一大半「数不对」的惨案。
四板斧:head、tail、info、describe
df.head(n) 看前 n 行(默认 5),df.tail(n) 看后 n 行;df.info() 报家底:索引是什么类型、有哪些列、每列多少非空值、各列什么数据类型;df.describe() 给数值列一份统计摘要:计数、均值、标准差、最小值、四分位数、最大值。价格列是 float64、成交量列是 int64,如果本该是数字的列显示成 object(字符串),说明数据有问题,越早发现越好。
取数的姿势:列选取、loc 与 iloc
- 取列:
df["close"]拿到一列,类型是 Series;df[["open", "close"]]用双中括号包一个列表,拿到的是 DataFrame。 - 取行:
df.loc[标签]按索引标签取,df.iloc[位置]按整数位置取。df.iloc[0, 3]是「第 0 行第 3 列」,而df.loc["2024-06-05", "close"]是「那天那列」——人类可读得多。 - 布尔筛选(Boolean Indexing):把一列比较的结果(一串 True/False)塞进中括号,就能留下 True 的行。多个条件用
&(且)、|(或)连接,每个条件必须各自加括号。
划重点:
df["close"] > 10 & df["volume"] > 100会报错或给出诡异结果——因为在 Python 里&的优先级比>高,10 & df[...]会被先算。规矩记死:每个条件自己一套括号,写成(df["close"] > 10) & (df["volume"] > 100)。
新增列:整列一起算
给 DataFrame 加一列新数据,只需要「算出一个整列结果,赋给一个新列名」,例如 df["avg_price"] = (df["high"] + df["low"]) / 2。这就是 NumPy 的 broadcasting(广播)思想在表格上的延续:第 3 篇里一列数字同时加减乘除,这里换成整列整列地算,同样不需要 for 循环。用 for 循环逐行改表格,是新手最常见也最亏的性能陷阱,从今天起戒掉。
完整代码
本篇只依赖 pandas 和 NumPy(第 1 篇装过环境的话就已就绪),缺哪个补哪个:uv pip install pandas numpy。真实数据尝试用 akshare 拉取(第 7 篇专讲),拉不到会自动降级为本地模拟数据,保证复制就能跑。下面两块代码连起来依次运行。
import numpy as np
import pandas as pd
def make_fake_ohlcv(n_days: int = 60, seed: int = 42) -> pd.DataFrame:
"""本地造一份模拟 OHLCV 日行情,让示例离线也能跑。"""
rng = np.random.default_rng(seed) # 固定随机种子,结果可复现
dates = pd.bdate_range("2024-06-03", periods=n_days) # 交易日历(自动跳过周末)
close = 10 + np.cumsum(rng.normal(0, 0.15, n_days)) # 收盘价:随机游走
open_ = close + rng.normal(0, 0.05, n_days) # 开盘价在收盘价附近小幅抖动
high = np.maximum(open_, close) + rng.uniform(0, 0.10, n_days) # 最高价 ≥ 开收较大者
low = np.minimum(open_, close) - rng.uniform(0, 0.10, n_days) # 最低价 ≤ 开收较小者
volume = rng.integers(80_000, 200_000, n_days) # 成交量在 8 万到 20 万之间
df = pd.DataFrame(
{
"open": open_.round(2),
"high": high.round(2),
"low": low.round(2),
"close": close.round(2),
"volume": volume,
},
index=dates,
)
df.index.name = "date"
return df
def load_ohlcv() -> pd.DataFrame:
"""优先尝试 akshare 拉真实行情,失败则降级为本地模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20240603", end_date="20240830")
raw = raw.rename(columns={"日期": "date", "开盘": "open", "最高": "high",
"最低": "low", "收盘": "close", "成交量": "volume"})
raw["date"] = pd.to_datetime(raw["date"]) # 统一成时间类型
return raw[["date", "open", "high", "low", "close", "volume"]].set_index("date")
except Exception:
print("真实数据不可用,改用本地模拟数据演示。")
return make_fake_ohlcv()
df = load_ohlcv()
print(df.shape)
print(df.index[:3]) # 看一眼索引前三个交易日
print(df.dtypes) # 各列类型:价格 float64、成交量 int64
print(df.columns.tolist()) # ['open', 'high', 'low', 'close', 'volume']
数据到手后是本篇的正戏:四板斧摸数据、三种姿势取数据、向量化加新列。
import numpy as np
import pandas as pd
df = load_ohlcv() # 接上文:拿到 OHLCV 数据
# ---- 四板斧:head / tail / info / describe ----
print(df.head()) # 前 5 行
print(df.tail(3)) # 后 3 行
df.info() # 结构:索引、列类型、缺失情况
print(df.describe()) # 每列的计数、均值、标准差、分位数
# ---- 列选取:单括号是 Series,双括号是 DataFrame ----
close = df["close"] # Series
subset = df[["open", "close"]] # DataFrame
print(subset.head(2)) # 双括号取出的仍是表格
# ---- 行选取:loc 按标签,iloc 按位置 ----
print(df.loc["2024-06-05", "close"]) # 标签定位:那天的收盘价
print(df.iloc[0, 3]) # 位置定位:第 0 行第 3 列(close)
print(type(close)) # 验证:单列取出确实是 Series
print(df[["open", "close"]].mean()) # 两列同时聚合,一次返回两列均值
# ---- 布尔筛选:把比较结果当「行掩码」用 ----
yang = df[df["close"] > df["open"]] # 阳线:收盘 > 开盘
big = df[(df["close"] > 10.5) & (df["volume"] > 150_000)] # 组合条件各自加括号
print(f"样本共 {len(df)} 天,其中阳线 {len(yang)} 天")
print(big[["close", "volume"]].head()) # 看看中选的日子
# ---- 新增列:整列一起算,不写 for 循环 ----
df["avg_price"] = ((df["high"] + df["low"]) / 2).round(2) # 当日中间价
df["is_yang"] = df["close"] > df["open"] # 是否阳线
print(df["is_yang"].value_counts()) # 阴阳天数统计
yang_ratio = df["is_yang"].mean() # 布尔列取均值 = 阳线占比
print(f"阳线占比 {yang_ratio:.1%}")
print(df[["close", "avg_price", "is_yang"]].head())
# ---- 顺手一招:按成交量从大到小排序 ----
print(df.sort_values("volume", ascending=False).head(3))
print(df["volume"].sum()) # 区间总成交量:int 列的常用汇总
# ---- 再练两招:标签区间切片与随机抽样 ----
print(df.loc["2024-06-03":"2024-06-14", ["close", "volume"]]) # 标签切片,两端都包含
print(df.sample(3, random_state=1)) # 随机抽 3 天,检查有无异常行
print(df.loc[df["volume"].idxmax()]) # 成交量最大的一天,整行取出
代码逐段解释
第 1 块:造数据与取数据。 make_fake_ohlcv 用 NumPy 造模拟 OHLCV:np.random.default_rng(seed) 固定随机种子保证每次运行结果一致;pd.bdate_range 生成跳过周末的交易日历;收盘价用 np.cumsum 把每日随机涨跌累加成随机游走(Random Walk);最高价用 np.maximum(open_, close) 保证不低于开收盘中的较大者,最低价同理对称。load_ohlcv 先尝试 akshare 拉平安银行 2024 年 6 月到 8 月的日线,把中文列名改成英文、日期统一成时间类型并设为索引;一旦网络或接口异常,就打印提示并退回模拟数据。运行首先看到形状(示意,以模拟数据为例):
真实数据不可用,改用本地模拟数据演示。
(60, 5)
60 行 5 列,正好是 60 个交易日、五个字段。
第 2 块:四板斧。 df.head() 打出前 5 行(示意):
open high low close volume
date
2024-06-03 9.98 10.12 9.95 10.05 123456
2024-06-04 10.04 10.18 10.01 10.12 98765
2024-06-05 10.15 10.22 10.03 10.08 152340
2024-06-06 10.06 10.15 9.98 10.01 101234
2024-06-07 10.03 10.20 10.02 10.17 173256
df.info() 报家底(示意):
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 60 entries, 2024-06-03 to 2024-08-23
Data columns (total 5 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 open 60 non-null float64
1 high 60 non-null float64
2 low 60 non-null float64
3 close 60 non-null float64
4 volume 60 non-null int64
索引是 DatetimeIndex(时间索引,第 5 篇的主角),五列全部无缺失。df.describe() 给出分布摘要(示意,数值随随机种子与数据源不同而不同):
open high low close volume
count 60.000000 60.000000 60.000000 60.000000 60.000000
mean 10.051234 10.128760 9.979810 10.053700 139582.000000
std 0.470000 0.478000 0.465000 0.472000 34217.000000
min 9.190000 9.260000 9.110000 9.190000 80100.000000
25% 9.760000 9.830000 9.690000 9.770000 112325.000000
50% 10.050000 10.120000 9.980000 10.050000 139100.000000
75% 10.340000 10.410000 10.280000 10.330000 165950.000000
max 10.920000 11.010000 10.880000 10.900000 199870.000000
重点不是背数字,而是学会读:均值和四分位数告诉你价格大概在什么区间活动,最大最小值帮你发现异常值。
第 3 块:列选取与行选取。 df["close"] 得到 Series,df[["open", "close"]] 得到两列的 DataFrame;df.loc["2024-06-05", "close"] 按标签取出那天的收盘(示意:10.08),df.iloc[0, 3] 按位置取出第 0 行第 3 列——数一下列顺序 open(0)、high(1)、low(2)、close(3),所以它就是第一天的收盘价(示意:10.05),和 head 里第一行正好对得上。df.loc["2024-06-03":"2024-06-14"] 这种标签区间切片会把两端日期都包含进来,正好呼应后面的坑 3;df.sample(3, random_state=1) 随机抽三行,random_state 固定随机结果,别人复现时抽到的是同样三行。type(close) 打印验证单列取出确实是 Series;df[["open", "close"]].mean() 演示两列同时聚合,一次返回两列的均值。
第 4 块:布尔筛选与新增列。 yang 筛出收盘高于开盘的阳线日;big 用带括号的组合条件筛「收盘价高于 10.5 且成交量大于 15 万」的日子(示意):
close volume
date
2024-07-15 10.62 168450
2024-07-22 10.71 182330
value_counts() 统计布尔列 True/False 各多少(示意):
is_yang
False 31
True 29
布尔列取 mean() 会把 True 当 1、False 当 0,所以 is_yang.mean() 直接给出阳线占比(示意:48.3%)。最后新增两列:avg_price 是最高最低价的中间值,is_yang 是比较结果本身。打印验证(示意,注意 avg_price 正好等于同行 high 与 low 的平均、is_yang 正好是 close 与 open 的比较结果):
close avg_price is_yang
date
2024-06-03 10.05 10.04 True
2024-06-04 10.12 10.10 True
2024-06-05 10.08 10.12 False
2024-06-06 10.01 10.07 False
2024-06-07 10.17 10.11 True
末尾的 sort_values 按成交量从大到小排序,看看「最热闹的三天」是哪几天;volume 求和得到区间总量(示意:约 837 万,正好等于 describe 里均值 139582 × 60 天);df.loc[df["volume"].idxmax()] 用「最大值所在的索引」直接取出量最大的一整行——这些都是摸数据的常用小动作。
常见坑与小练习
坑 1:布尔条件忘加括号。 现象:组合条件一跑就报 TypeError,或结果明显不对。原因:& 的优先级高于比较运算符,a > 10 & b > 5 实际算的是 a > (10 & b) > 5。避免:每个条件各自套括号,别嫌啰嗦。
坑 2:链式赋值改不动数据。 df[df["close"] > 10]["close"] = 1 这种「先筛选再赋值」的链式写法,可能只在临时副本上生效,pandas 还会弹 SettingWithCopyWarning。正确姿势是一步到位的 df.loc[条件, "close"] = 1。
坑 3:loc 与 iloc 的「包不包含末端」记混。 iloc[0:3] 按位置切片,前闭后开,取第 0、1、2 行;loc["A":"C"] 按标签切片,两端都包含。记不住时就打印 len() 验证,别靠感觉。
坑 4:单括号与双括号傻傻分不清。 df["close"] 是 Series(一列),df[["close"]] 是只有一列的 DataFrame(一张表)。两者都能 mean(),但后续能接的方法不同;想保持「表格」形态或取多列时,用双括号。
坑 5:把第一列序号当成了索引。 从 CSV 读入后常见一列多余的 Unnamed: 0,那是保存时把索引也写了进去。读的时候用 pd.read_csv(path, index_col=0),或者保存时 to_csv(path, index=False),二选一想清楚。
小练习 1: 给 DataFrame 新增一列 pct,存当日涨跌幅 (close - open) / open * 100(单位 %),再筛出涨幅超过 1% 的交易日,数一数有多少天。
思路提示:先算新列,再用布尔筛选
df[df["pct"] > 1],最后取len()。
小练习 2: 把 make_fake_ohlcv 的 n_days 改成 250、起始日改成你的生日,重跑四板斧,观察 info() 里的日期范围和 describe() 里的最大最小值怎么变。
思路提示:只改函数参数,其他代码不动;重点看数据量变大后分位数是否更稳定。
小练习 3: 用 between 重写一个筛选:df[df["close"].between(10, 10.5)],找出收盘价在 10 到 10.5 之间的日子,并与两个布尔条件的写法对比,哪种更不容易写错?
思路提示:
between默认两端都包含,等价于(df["close"] >= 10) & (df["close"] <= 10.5)。
本文数据均为教学演示用途,不构成投资建议;涉及策略的篇章在实盘前请充分测试。