第3周跟练:分组、合并与SQL窗口函数
接着第2周的10条清洗结果,每次约2小时、共5个单元。你会把同一份价格表交给pandas和SQLite计算,并学会解释行数、键、缺失和收益为什么应该这样。
本周交付:能说明结果,也能发现错误
清洗后的CSV → 分组统计 → 行业合并 → SQL窗口 → 逐键核对 → 独立复测
这对应实习里常见的小任务:接到一份价格表,统计覆盖情况、接上分类、计算相邻记录收益,再把核对证据交给同事。这里用虚构数据练流程,不将输出当成投资结论。
延续首月安排:q-309 分组、q-310 合并、q-316 缺失;再到面试练习搜索I08、I09,练习解释你的处理依据。
准备:沿用Python环境,增加一个内存数据库
解压完整文件夹,接上第2周结果
在VS Code打开直接含week3.py的文件夹。把自己第2周outputs/cleaned.csv复制到这里,保留第2周原件。输入列必须是date、symbol、close,共10行;sectors.csv已附在包内。
如果暂未完成第2周,可在本目录没有cleaned.csv时,手动复制cleaned_reference.csv并将副本命名为cleaned.csv,在learning-log.md注明使用了参考。脚本不会自动替你采用参考数据。
用同一个解释器安装与运行
在本目录打开PowerShell。若已经有本目录.venv,跳过第一行;若只有py可用,第一行将python换成py。
powershellpython -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt .\.venv\Scripts\python.exe -c "import sys,pandas,sqlite3; print(sys.executable); print(pandas.__version__); print(sqlite3.sqlite_version)" .\.venv\Scripts\python.exe -m pip freeze > requirements-lock.txt使用Python 3.10+、pandas 2.2至4以下、SQLite 3.25+。sqlite3由Python自带,无需pip安装,也不需要注册数据库服务或配置密码。每次运行新建内存库,结果另存CSV。
检查点:解释器路径指向本目录.venv,两个版本号成功显示。命令输入终端,不粘进.py或.sql文件。
macOS/Linux用python3创建环境,之后解释器换成./.venv/bin/python,脚本参数去掉.\前缀。无需激活环境或修改PowerShell策略。
认识文件分工
01–04是每步入口;week3.py提供共用函数;returns.sql是实际执行的窗口查询;test_week3.py检查错误边界;transfer开头的两份CSV留到隔日验收。生成结果写入outputs,重跑更新同名输出,输入只读。
powershellGet-Location Get-ChildItem -Name .\.venv\Scripts\python.exe .\01_group.py查看输入检查:为什么不让坏数据进入窗口计算
pythondef validate_prices(frame): """约定:每个日期+标的一条记录;价格完整、有限且为正。返回排序后的副本。""" if list(frame.columns) != ["date", "symbol", "close"] or frame.empty: raise ValueError("价格表需要非空的date,symbol,close三列,且顺序一致") work = frame.copy() for column in KEYS: work[column] = work[column].astype("string").str.strip() if work[column].isna().any() or work[column].eq("").any(): raise ValueError(f"{column}键不能缺失") # ISO格式使SQLite文字排序等同本教程的日期排序;不处理时区或时间戳。 if not work["date"].str.fullmatch(r"\d{4}-\d{2}-\d{2}").all(): raise ValueError("日期必须为YYYY-MM-DD") dates = pd.to_datetime(work["date"], format="%Y-%m-%d", errors="raise") if dates.isna().any(): raise ValueError("日期不能为NaT") work["date"] = dates.dt.strftime("%Y-%m-%d") work["close"] = pd.to_numeric(work["close"], errors="raise").astype(float) if not work["close"].map(lambda v: math.isfinite(v) and v > 0).all(): raise ValueError("价格必须完整、有限且大于0;回第2周核验,不能填0") if work.duplicated(KEYS).any(): raise ValueError("date+symbol重复,不能决定上一条是哪条") return work.sort_values(["symbol", "date"]).reset_index(drop=True) def read_prices(name="cleaned.csv"): filename = ROOT / name if not filename.exists(): raise FileNotFoundError(f"找不到{filename};请复制第2周outputs/cleaned.csv到本目录") frame = pd.read_csv(filename, dtype="string", encoding="utf-8-sig", keep_default_na=False) if not isinstance(frame.index, pd.RangeIndex): raise ValueError("CSV列数异常,检查逗号和表头") return validate_prices(frame)本教程要求完整价格;遇到缺失或冲突,回第2周核验。日期只是教学标签,代码没有自动检查交易日历、停牌、复权或数据可得时间。
先认清这6个词
| 术语 | 直白解释 | 本周例子 |
|---|---|---|
| 粒度 | 一行代表什么 | 价格表:一个日期的一只标的 |
| 键 | 用哪些字段确定一条记录 | 价格表date+symbol;行业表symbol |
| 聚合 agg / GROUP BY | 把多条记录归并成统计结果 | 10条价格变成2个标的汇总 |
| transform / 窗口函数 | 保留逐行记录,添加组内计算结果 | 每条价格旁放组行数或上一条价格 |
| CTE / WITH | 给一段中间查询结果起名字 | lagged先算prev_close,外层再算收益 |
| NULL / None / NaN | 不同环境表示缺失的方式 | 每组首条没有上一条,收益缺失 |
单元1 · groupby:每一行究竟代表什么
跟着做
读q-309。先在纸上写出按symbol与按date分组各有几组,再运行脚本。分别打开summary.csv、group-detail.csv、daily-count.csv,对照行数。
.\.venv\Scripts\python.exe .\01_group.pyagg把每组汇总成一行;transform把组统计放回每条原记录。first和last依赖排序,脚本先检查输入并按symbol、date排序。本例价格完整,所以size与count相同;有缺失时size仍数行,count只数该列非缺失值。
查看本步骤的注释函数
片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。
def summarize(frame):
ordered = validate_prices(frame) # first/last取决于顺序,先排序再分组
grouped = ordered.groupby("symbol", sort=True)
# agg把每个标的压缩成一行。size数行,count只数该列非缺失值。
summary = grouped.agg(
rows=("close", "size"),
first_close=("close", "first"),
last_close=("close", "last"),
).reset_index()
summary["period_return"] = summary["last_close"] / summary["first_close"] - 1
# transform把组统计广播回原来的每一行,所以仍有10行。
detail = ordered.copy()
detail["rows_in_symbol"] = grouped["close"].transform("size")
# 日期分组回答“这一天有几条记录”,与按标的统计是不同的问题。
daily = ordered.groupby("date", as_index=False).agg(rows=("symbol", "size"))
return summary, detail, daily查看运行入口:01_group.py
from week3 import read_prices, summarize, save
prices = read_prices()
summary, detail, daily = summarize(prices)
print(summary.to_string(index=False))
print(detail.to_string(index=False))
print(daily.to_string(index=False))
save(summary, "summary.csv")
save(detail, "group-detail.csv")
save(daily, "daily-count.csv")
应看到什么
summary有2行,A/B各5条,首价100/50、末价121/60.5,首末收益均21%;detail有10行,rows_in_symbol全为5;daily有5行,每天2条。
停下来检查:用自己的话解释“粒度”:summary一行是一个标的,detail一行是一个日期+标的。不要把平均每日收益说成累计收益。组内全期统计也不能直接当作每个历史时点可用的特征。
留下的证据:3个输出CSV;在learning-log.md写出每张表的键、行粒度和统计含义。
单元2 · merge:合并后不能悄悄多行或丢行
跟着做
读q-310与面试I08。打开sectors.csv,确认每个symbol只有一条映射。运行脚本,依次观察正常合并、重复右键被拒绝、只保留A行业时的结果。
.\.venv\Scripts\python.exe .\02_merge.pyleft保留全部价格记录;on='symbol'明确匹配字段;many_to_one要求右表的symbol唯一;indicator增加_merge状态列。both表示找到映射,left_only表示未找到。缺少映射与右表重复是不同问题。
查看本步骤的注释函数
片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。
def join_sectors(frame, sectors):
ordered = validate_prices(frame)
if list(sectors.columns) != ["symbol", "sector"]:
raise ValueError("行业表需要symbol,sector两列")
right = sectors.copy()
for column in ["symbol", "sector"]:
right[column] = right[column].astype("string").str.strip()
if right[column].isna().any() or right[column].eq("").any():
raise ValueError(f"行业表{column}不能缺失;没有映射时保留左表待查")
# 多条价格 -> 一条行业映射。右表重复时主动报MergeError,不静默放大样本。
result = ordered.merge(
right, how="left", on="symbol", validate="many_to_one", indicator=True
)
if len(result) != len(ordered) or result.duplicated(KEYS).any():
raise ValueError("合并改变了行数或唯一键")
# left_only不是删除理由。保留记录,并让学习者看见未匹配状态。
return result查看运行入口:02_merge.py
import pandas as pd
from week3 import ROOT, read_prices, join_sectors, save
prices = read_prices()
sectors = pd.read_csv(ROOT / "sectors.csv", dtype="string", encoding="utf-8-sig")
joined = join_sectors(prices, sectors)
print(joined.to_string(index=False))
print("匹配状态:", joined["_merge"].value_counts().to_dict())
save(joined, "joined.csv")
# 缺失行业也作为一组显示,避免groupby默认丢掉缺失键让记录消失。
coverage = joined.groupby(["date", "sector"], dropna=False, as_index=False).agg(
rows=("symbol", "size")
)
print(coverage.to_string(index=False))
save(coverage, "sector-coverage.csv")
# 故意把B的行业复制一次:这里捕获预期错误,所以整个演示仍正常退出。
bad = pd.concat([sectors, sectors.iloc[[-1]]], ignore_index=True)
try:
join_sectors(prices, bad)
except pd.errors.MergeError:
print("预期拒绝:右表symbol重复,many_to_one检查生效")
else:
raise AssertionError("重复右键竟然未被拒绝")
# 演示未匹配:只保留A映射,B的5条价格应继续保留,sector缺失。
missing = join_sectors(prices, sectors[sectors["symbol"].eq("A")])
print("只保留A映射后的行数/未匹配数:", len(missing), missing["_merge"].eq("left_only").sum())
应看到什么
正常joined有10行,10条both;日期+行业覆盖表有10行。故意复制右表一行时打印“预期拒绝”;只保留A映射时仍10行,B的5条sector缺失、状态left_only。
停下来检查:说出为什么不能用inner合并让未匹配记录消失,也不能删除validate后接受重复。按日期+行业统计时dropna=False保留未知行业;检查总覆盖数量仍等于输入行数。
留下的证据:joined.csv、sector-coverage.csv和两个失败场景的预测/实际记录。现实行业会变更,本例静态映射不能直接拿来回填真实历史。
单元3 · SQL:先聚合,再逐行找上一条价格
跟着做
先看下方SQL入门,再运行03_sql.py。普通GROUP BY返回2行;打开returns.sql看LAG查询,再与10行窗口结果对照。读面试I09,解释PARTITION BY与两个ORDER BY。
第一次写SQL,先只看这条
SELECT symbol, COUNT(*) AS rows
FROM prices
GROUP BY symbol
ORDER BY symbol;FROM指定表,GROUP BY指定按谁汇总,SELECT写要输出的列与统计,AS为统计列起名,ORDER BY控制显示顺序。星号在COUNT(*)里表示数行;本例COUNT(close)因为价格完整也为5。这里的SQL由03_sql.py中的连接执行,不能当作PowerShell命令。
再看窗口查询:returns.sql
-- WITH定义一张临时查询结果(CTE),不是另建磁盘表。
WITH lagged AS (
SELECT date, symbol, close,
-- 每个标的独立按日期找上一条;第一条没有上一条,得到NULL。
LAG(close) OVER (PARTITION BY symbol ORDER BY date) AS prev_close
FROM prices
)
SELECT date, symbol,
-- 1.0保证浮点除法;NULLIF避免零分母,但不能替代上游价格检查。
1.0 * close / NULLIF(prev_close, 0) - 1 AS "return"
FROM lagged
-- 窗口内ORDER BY决定“上一条”;这里决定最终显示顺序,两者不同。
ORDER BY symbol, date;
逐句读:先按标的分开 → 组内按日期排好 → 取上一条价格 → 当前除以上一条减1。不要在同一个SELECT里直接引用刚定义的prev_close;本例用WITH分成两层,便于检查。
.\.venv\Scripts\python.exe .\03_sql.pySQLite是嵌入式数据库,本周由Python在内存中创建prices表。LAG(close)取分区内上一条价格;PARTITION BY symbol分开标的,ORDER BY date确定前后。WITH先生成prev_close,外层SELECT再使用它计算收益。
查看本步骤的注释函数
片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。
def pandas_returns(frame):
result = validate_prices(frame)
result["return"] = result.groupby("symbol")["close"].pct_change(fill_method=None)
return result[KEYS + ["return"]]
def sql_returns(frame, query=None):
ordered = validate_prices(frame)
if sqlite3.sqlite_version_info < (3, 25, 0):
raise RuntimeError("需要SQLite 3.25+以支持窗口函数;请更新Python")
if query is None:
query = (ROOT / "returns.sql").read_text(encoding="utf-8")
with closing(sqlite3.connect(":memory:")) as connection:
# closing确保退出时关闭连接,丢弃内存库;无需用户名或数据库服务。
connection.execute("""CREATE TABLE prices (
date TEXT NOT NULL, symbol TEXT NOT NULL,
close REAL NOT NULL CHECK(close > 0),
PRIMARY KEY(date, symbol)
)""")
# ?是参数占位符;数据通过参数传入,不拼接到SQL语句中。
connection.executemany(
"INSERT INTO prices(date,symbol,close) VALUES (?,?,?)",
ordered[KEYS + ["close"]].itertuples(index=False, name=None),
)
return pd.read_sql_query(query, connection)查看运行入口:03_sql.py
import sqlite3
from week3 import read_prices, sql_returns, save
print("SQLite版本:", sqlite3.sqlite_version)
prices = read_prices()
# 先运行普通聚合:按标的归并成2行,COUNT(*)包括所有行。
counts = sql_returns(prices, """
SELECT symbol, COUNT(*) AS rows, COUNT(close) AS non_missing_prices
FROM prices GROUP BY symbol ORDER BY symbol
""")
print("GROUP BY聚合:\n", counts.to_string(index=False))
# 再读取并运行returns.sql。只修改SQL文件即可练习窗口,不用重写连接代码。
returns = sql_returns(prices)
print("窗口函数保留逐条记录:\n", returns.to_string(index=False))
save(returns, "sql-returns.csv")
应看到什么
聚合表共2行,A/B的rows计数各为5;窗口结果10行,A/B首条各一个缺失。B在01-07为55/52.5-1≈4.761905%,01-08为10%。收益以小数保存,0.1才是10%。
停下来检查:SQL的NULL读入pandas数值列后显示NaN,不是0。窗口内排序负责计算,查询最后的排序负责展示。LAG的“上一条”不保证“上一交易日”。
留下的证据:自己注释过的returns.sql、sql-returns.csv,以及对分区、排序、CTE的口头解释。
单元4 · 逐键核对:两个答案怎样才算一致
跟着做
读q-316。运行04_compare.py,打开compare.csv。先核对date+symbol键集合,再比缺失位置,最后看非缺失数值差异。完成下面的故意出错实验。
.\.venv\Scripts\python.exe .\04_compare.pyouter合并把两边所有键保留下来;one_to_one拒绝重复。缺失位置单独比较,数值用相对容差1e-9、绝对容差1e-12。先inner合并或fillna(0)可能掩盖丢行和首期缺失错误。
查看本步骤的注释函数
片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。
def compare_returns(left, right):
"""先比键,再比缺失位置,最后比数值;不能用inner join或fillna(0)掩盖错误。"""
for frame in [left, right]:
if not set(KEYS + ["return"]).issubset(frame.columns) or frame.empty:
raise ValueError("核对表缺少必要列或没有记录")
if frame[KEYS].isna().any().any() or frame.duplicated(KEYS).any():
raise ValueError("核对表有缺失键或重复键")
result = left[KEYS + ["return"]].merge(
right[KEYS + ["return"]], on=KEYS, how="outer",
validate="one_to_one", indicator=True, suffixes=("_pandas", "_sql"),
)
if not result["_merge"].eq("both").all():
raise ValueError("两边的键不一致:存在丢行或多行")
a, b = result["return_pandas"], result["return_sql"]
if not a.isna().equals(b.isna()):
raise ValueError("两边缺失位置不一致,不能把首条缺失填0")
present = a.notna()
# 浮点数存在微小舍入差异,使用明确容差;无限值始终拒绝。
passed = [math.isfinite(x) and math.isfinite(y) and
math.isclose(x, y, rel_tol=1e-9, abs_tol=1e-12)
for x, y in zip(a[present], b[present])]
if not all(passed):
raise ValueError("收益数值不一致或含无限值")
result["abs_diff"] = (a - b).abs() # 两边都缺失时差值也保持缺失
result["passed"] = True
return result.sort_values(["symbol", "date"]).reset_index(drop=True)查看运行入口:04_compare.py
from week3 import read_prices, pandas_returns, sql_returns, compare_returns, save
prices = read_prices()
comparison = compare_returns(pandas_returns(prices), sql_returns(prices))
print(comparison.to_string(index=False))
print("逐键核对通过:", len(comparison), "行")
print("两边各自缺失收益:", comparison["return_pandas"].isna().sum())
save(comparison, "compare.csv")
# 注意:两种实现一致,不证明数据来源正确,也不证明中间没有漏日期。
应看到什么
compare有10行,_merge全both,passed全True;两边各2个缺失收益,缺失位置相同,其他差值在容差内。两边都缺失时abs_diff仍为空,不要求填成0。
停下来检查:即使比较全部通过,也要核查日期覆盖与数据来源。删除A的01-06后,A的01-07变成跨缺口的21%,pandas和SQL仍可能一起通过,不能因此称它为完整日收益。
留下的证据:compare.csv、故意出错时的报错记录,以及哪些问题无法靠双实现对比发现。
手算与故意出错:检查真的能抓到问题吗
| 标的与日期 | 当前价 | 前价 | 收益 |
|---|---|---|---|
| A / 01-05 | 100 | 缺失 | 缺失 |
| A / 01-06 | 110 | 100 | 10% |
| A / 01-08 | 110 | 121 | 约-9.090909% |
| B / 01-05 | 50 | 缺失 | 缺失 |
| B / 01-07 | 55 | 52.5 | 约4.761905% |
| B / 01-08 | 60.5 | 55 | 10% |
.\.venv\Scripts\python.exe -m unittest -v应看到Ran 11 tests与OK。检查覆盖分组、合并、窗口、乱序、丢行、重复键、错填缺失、错误数值、非法输入与跨缺口边界。测试通过证明这些案例符合预期,不代表你已经独立掌握。
去掉分区,收益就串到另一只标的
备份returns.sql,只删除PARTITION BY symbol,再运行04_compare.py,应报核对失败。相同日期下不同标的的先后也不再有唯一规则,不要求记住一个固定错误数值。恢复查询并重跑。
把缺失填0,或者漏掉一行,检查都应失败
新建fault_lab.py,将下面完整脚本保存到同目录并运行。两次“预期拒绝”才是此实验成功;没有捕获预期错误时脚本会明确失败。
pythonfrom week3 import read_prices, pandas_returns, sql_returns, compare_returns prices = read_prices() left = pandas_returns(prices) right = sql_returns(prices) filled = right.copy() filled["return"] = filled["return"].fillna(0) for name, bad in [("首期填0", filled), ("漏一行", right.iloc[1:])]: try: compare_returns(left, bad) except ValueError as error: print(name, "预期拒绝:", error) else: raise AssertionError(name + "没有被发现")powershell.\.venv\Scripts\python.exe .\fault_lab.py两个答案一样,也可能一起遗漏日期
另存cleaned.csv为cleaned_gap.csv,仅删除A在01-06的记录。新建gap_lab.py运行以下代码。此时01-07的21%跨了缺口;检查通过,只说明两种实现一致。
pythonfrom week3 import read_prices, pandas_returns, sql_returns, compare_returns prices = read_prices("cleaned_gap.csv") result = compare_returns(pandas_returns(prices), sql_returns(prices)) print(result.to_string(index=False))powershell.\.venv\Scripts\python.exe .\gap_lab.py预测应为9行、2个首期缺失;原cleaned.csv仍保留10行。真实工作还要核对预期日期覆盖、停牌与行情源,不能仅看数值核对。
查看完整共用代码:week3.py
"""第3周共用函数。输入文件只读;结果写入outputs。数据全部是虚构教学数据。"""
from pathlib import Path
from contextlib import closing
import math
import sqlite3 # Python自带,无需pip install sqlite3,也无需安装数据库服务器
import pandas as pd
ROOT = Path(__file__).resolve().parent
KEYS = ["date", "symbol"]
# region read
def validate_prices(frame):
"""约定:每个日期+标的一条记录;价格完整、有限且为正。返回排序后的副本。"""
if list(frame.columns) != ["date", "symbol", "close"] or frame.empty:
raise ValueError("价格表需要非空的date,symbol,close三列,且顺序一致")
work = frame.copy()
for column in KEYS:
work[column] = work[column].astype("string").str.strip()
if work[column].isna().any() or work[column].eq("").any():
raise ValueError(f"{column}键不能缺失")
# ISO格式使SQLite文字排序等同本教程的日期排序;不处理时区或时间戳。
if not work["date"].str.fullmatch(r"\d{4}-\d{2}-\d{2}").all():
raise ValueError("日期必须为YYYY-MM-DD")
dates = pd.to_datetime(work["date"], format="%Y-%m-%d", errors="raise")
if dates.isna().any():
raise ValueError("日期不能为NaT")
work["date"] = dates.dt.strftime("%Y-%m-%d")
work["close"] = pd.to_numeric(work["close"], errors="raise").astype(float)
if not work["close"].map(lambda v: math.isfinite(v) and v > 0).all():
raise ValueError("价格必须完整、有限且大于0;回第2周核验,不能填0")
if work.duplicated(KEYS).any():
raise ValueError("date+symbol重复,不能决定上一条是哪条")
return work.sort_values(["symbol", "date"]).reset_index(drop=True)
def read_prices(name="cleaned.csv"):
filename = ROOT / name
if not filename.exists():
raise FileNotFoundError(f"找不到{filename};请复制第2周outputs/cleaned.csv到本目录")
frame = pd.read_csv(filename, dtype="string", encoding="utf-8-sig", keep_default_na=False)
if not isinstance(frame.index, pd.RangeIndex):
raise ValueError("CSV列数异常,检查逗号和表头")
return validate_prices(frame)
# endregion read
# region group
def summarize(frame):
ordered = validate_prices(frame) # first/last取决于顺序,先排序再分组
grouped = ordered.groupby("symbol", sort=True)
# agg把每个标的压缩成一行。size数行,count只数该列非缺失值。
summary = grouped.agg(
rows=("close", "size"),
first_close=("close", "first"),
last_close=("close", "last"),
).reset_index()
summary["period_return"] = summary["last_close"] / summary["first_close"] - 1
# transform把组统计广播回原来的每一行,所以仍有10行。
detail = ordered.copy()
detail["rows_in_symbol"] = grouped["close"].transform("size")
# 日期分组回答“这一天有几条记录”,与按标的统计是不同的问题。
daily = ordered.groupby("date", as_index=False).agg(rows=("symbol", "size"))
return summary, detail, daily
# endregion group
# region merge
def join_sectors(frame, sectors):
ordered = validate_prices(frame)
if list(sectors.columns) != ["symbol", "sector"]:
raise ValueError("行业表需要symbol,sector两列")
right = sectors.copy()
for column in ["symbol", "sector"]:
right[column] = right[column].astype("string").str.strip()
if right[column].isna().any() or right[column].eq("").any():
raise ValueError(f"行业表{column}不能缺失;没有映射时保留左表待查")
# 多条价格 -> 一条行业映射。右表重复时主动报MergeError,不静默放大样本。
result = ordered.merge(
right, how="left", on="symbol", validate="many_to_one", indicator=True
)
if len(result) != len(ordered) or result.duplicated(KEYS).any():
raise ValueError("合并改变了行数或唯一键")
# left_only不是删除理由。保留记录,并让学习者看见未匹配状态。
return result
# endregion merge
# region sql
def pandas_returns(frame):
result = validate_prices(frame)
result["return"] = result.groupby("symbol")["close"].pct_change(fill_method=None)
return result[KEYS + ["return"]]
def sql_returns(frame, query=None):
ordered = validate_prices(frame)
if sqlite3.sqlite_version_info < (3, 25, 0):
raise RuntimeError("需要SQLite 3.25+以支持窗口函数;请更新Python")
if query is None:
query = (ROOT / "returns.sql").read_text(encoding="utf-8")
with closing(sqlite3.connect(":memory:")) as connection:
# closing确保退出时关闭连接,丢弃内存库;无需用户名或数据库服务。
connection.execute("""CREATE TABLE prices (
date TEXT NOT NULL, symbol TEXT NOT NULL,
close REAL NOT NULL CHECK(close > 0),
PRIMARY KEY(date, symbol)
)""")
# ?是参数占位符;数据通过参数传入,不拼接到SQL语句中。
connection.executemany(
"INSERT INTO prices(date,symbol,close) VALUES (?,?,?)",
ordered[KEYS + ["close"]].itertuples(index=False, name=None),
)
return pd.read_sql_query(query, connection)
# endregion sql
# region compare
def compare_returns(left, right):
"""先比键,再比缺失位置,最后比数值;不能用inner join或fillna(0)掩盖错误。"""
for frame in [left, right]:
if not set(KEYS + ["return"]).issubset(frame.columns) or frame.empty:
raise ValueError("核对表缺少必要列或没有记录")
if frame[KEYS].isna().any().any() or frame.duplicated(KEYS).any():
raise ValueError("核对表有缺失键或重复键")
result = left[KEYS + ["return"]].merge(
right[KEYS + ["return"]], on=KEYS, how="outer",
validate="one_to_one", indicator=True, suffixes=("_pandas", "_sql"),
)
if not result["_merge"].eq("both").all():
raise ValueError("两边的键不一致:存在丢行或多行")
a, b = result["return_pandas"], result["return_sql"]
if not a.isna().equals(b.isna()):
raise ValueError("两边缺失位置不一致,不能把首条缺失填0")
present = a.notna()
# 浮点数存在微小舍入差异,使用明确容差;无限值始终拒绝。
passed = [math.isfinite(x) and math.isfinite(y) and
math.isclose(x, y, rel_tol=1e-9, abs_tol=1e-12)
for x, y in zip(a[present], b[present])]
if not all(passed):
raise ValueError("收益数值不一致或含无限值")
result["abs_diff"] = (a - b).abs() # 两边都缺失时差值也保持缺失
result["passed"] = True
return result.sort_values(["symbol", "date"]).reset_index(drop=True)
# endregion compare
def save(frame, name):
folder = ROOT / "outputs"
folder.mkdir(exist_ok=True)
frame.to_csv(folder / name, index=False, encoding="utf-8-sig")
print(f"已写入 {folder / name}({len(frame)}行)")
常见报错与误判
找不到cleaned.csv / FileNotFoundError
先定位:未复制第2周的结果,或只打开压缩包而未解压。
怎么修:把第2周outputs/cleaned.csv复制到week3.py同目录。在VS Code打开这个文件夹;路径由脚本所在目录决定。未做第2周时,可按准备区明确使用参考副本。
修后检查:输入为10行、date/symbol/close三列;01_group.py输出2行summary。
No module named pandas / week3
先定位:解释器不一致,或只复制了单个脚本。
怎么修:完整解压,用同一个.venv的python执行pip安装和运行;week3.py要与01_group.py同目录。不要pip install week3。
修后检查:版本命令成功,随后01_group.py正常执行。
KeyError / 列名不符合约定
先定位:保存了索引列、读错CSV、列名或分隔符不同。
怎么修:查看CSV第一行和DataFrame.columns。价格表是date,symbol,close;行业表是symbol,sector。不要删除检查来适应未知结构。
修后检查:保存CSV时index=False;读回没有Unnamed: 0。
价格必须完整、有限且大于0 / date+symbol重复
先定位:本周需要第2周已核验的结果,输入仍有异常。
怎么修:回第2周追查清洗依据。不要补0或随意保留重复的最后一条;同一天同一标的多条记录会让窗口次序含糊。
修后检查:恢复经核验的10行输入;同样的异常输入应再次被拒绝。
MergeError: not a many-to-one merge
先定位:行业表symbol重复。02_merge.py中故意复制一行时出现的“预期拒绝”是成功的检查演示。
怎么修:真实数据先列出重复symbol,查明是误重复还是行业生效日期变化。不要关掉validate、改成many_to_many或随意去重。
修后检查:教学原始sectors.csv恢复为每标的一行后正常合并10行;反例仍会被拒绝。
合并后sector为空 / _merge为left_only
先定位:价格表中的标的没有对应行业,或代码存在格式差异。
怎么修:列出未匹配symbol,检查字符串、空格与代码体系并查映射来源。保留记录并标记待查,不默认填入另一只股票的行业。
修后检查:只保留A映射的实验仍10行,B的5条未匹配;不能变成5行。
no such table: prices / SQL写进终端报错
先定位:在独立SQL工具执行,但没有创建prices表;或把SELECT当成PowerShell命令。
怎么修:本教程修改returns.sql,然后运行03_sql.py。脚本会建立内存表并导入CSV;不要直接在PowerShell粘贴SQL。
修后检查:03_sql.py先显示SQLite版本,再显示2行聚合和10行窗口结果。
near OVER: syntax error / no such column: prev_close
先定位:SQL拼写或括号有误、SQLite过旧,或在同一个SELECT里引用刚起的别名。
怎么修:先对照returns.sql。保留WITH中的LAG表达式,再在外层SELECT引用prev_close;查询SQLite版本需3.25+。不要pip安装sqlite3。
修后检查:先恢复原查询成功,再一次只改一处;每组首条为缺失。
收益全为0 / 跨标的收益 / 顺序乱
先定位:可能发生整数除法、遗漏PARTITION BY,或者只在查询末尾排序。
怎么修:使用1.0*close;窗口里写PARTITION BY symbol ORDER BY date;最后另写ORDER BY symbol,date方便阅读。日期使用YYYY-MM-DD。
修后检查:B在01-07约4.761905%,01-08为10%;两组首条各缺失,不能出现跨标的计算。
核对表缺失位置不一致 / 键不一致 / 数值不一致
先定位:发生了填0、丢行、重复键或计算偏差。
怎么修:先检查两个表的键集合,再看首条缺失,最后用两三行手算。不要放大容差、填0或改成inner来让检查通过。
修后检查:恢复原SQL后compare有10行且全部通过,故意破坏时必须失败。
PermissionError / 旧输出看起来仍正常
先定位:Excel占用输出文件,或本次失败后误用了旧outputs。
怎么修:关闭占用文件的程序;确认本次命令成功结束再查看输出。重跑会更新生成文件,但不会删除上次失败前已存在的输出。
修后检查:看本次终端成功信息、输出内容与行数,不能只看文件存在。
FAILED / Ran 0 tests / 浏览器加载失败
先定位:本地测试目录不对、代码有误,或浏览器Python环境下载失败。
怎么修:本地在含test_week3.py目录运行-m unittest -v,按失败名称定位;浏览器首次需要联网,可重试或运行本地browser_demo.py。不要把下载超时当作SQL计算失败。
修后检查:本地应Ran 11 tests、OK;浏览器初始样例4行、2个缺失收益。
浏览器小练习:四条记录练LAG
不需要本地文件。用Python的sqlite3建立内存表,在三引号内编辑SQL。先预测:X从10到12,Y从40到44;再运行,确认4行、2个缺失。然后把Y的44改成36,预测Y的收益如何变化。
首次需要联网下载Python环境;刷新或离开前复制保存代码。此练习不代替本地CSV、合并与多文件测试。SQL不能直接粘到Python编辑区最外层,要放在query字符串内。
在你的浏览器内运行。首次需联网下载 Python;支持标准库和 Pyodide 提供的 NumPy、pandas 等包。行情抓取、系统文件访问和部分第三方库请在本地环境运行。输出自检仅比对结果关键词,不代替逻辑审查。
单元5 · 45分钟独立验收
关闭参考代码。可以保留CSV读取、数据库连接与导入脚手架,自己重写分组统计、安全合并、LAG查询和逐键核对。保存自己的代码、SQL与输出,再花15分钟评分、40分钟补错、20分钟写记录。
- 按标的汇总、按日期计数,并解释agg与transform结果的粒度。
- 合并后10行,右表重复主动拒绝;缺少B行业时仍10行、5条未匹配。
- SQL保留10行,按标的及日期取上一条,两个首期缺失,手算核对B的关键收益。
- pandas与SQL先核对键,再检查缺失位置,最后比较数值;人为漏行或填0时能拒绝。
- 用2分钟说明跨缺口收益、静态行业映射和数据可得时点的限制。
怎样才算掌握
五项各0–2分:分组键与粒度、合并边界、SQL分区排序、双实现核对、缺口与失败解释。0分是无法完成,1分是需要提示或只能照抄,2分是可独立完成并解释。至少8/10,且合并边界与SQL分区排序必须各2分;现成测试通过不自动给分。
隔24–48小时,换数据再做
只拿transfer_prices.csv与transfer_sectors.csv,20分钟内独立完成。四条价格乱序,行业只有A;先写预期行数、未匹配数量和首期缺失数,再分别用pandas和SQL求收益。禁止删除未匹配价格或随意补行业。
完成复测后再看参考口径
A为100→105,收益首条缺失、第二条5%;B为20→18,首条缺失、第二条-10%。合并保留4行,B的2条行业缺失且left_only;总共2个首期收益缺失。键与缺失位置一致,数值在容差内。
不能解释行数:回单元1画分组表;不能定位合并错误:回单元2只留两只标的;窗口错位:先单独SELECT出prev_close;核对“总通过”:重做故意填0与漏行实验。把补练点写进learning-log.md,复测后再记录掌握。
官方资料:只查本周用到的部分
核验于2026-10-20。先找groupby、transform、merge的validate/indicator,以及SQLite的LAG示例。下载包的README可离线查看操作顺序。实际行业映射应考虑生效区间与当时已知信息,不能把今天的分类直接回填所有历史日期。