← 返回第3周学习计划
第3周 · 同一份数据,两种实现

第3周跟练:分组、合并与SQL窗口函数

接着第2周的10条清洗结果,每次约2小时、共5个单元。你会把同一份价格表交给pandas和SQLite计算,并学会解释行数、键、缺失和收益为什么应该这样。

本周交付:能说明结果,也能发现错误

清洗后的CSV → 分组统计 → 行业合并 → SQL窗口 → 逐键核对 → 独立复测

这对应实习里常见的小任务:接到一份价格表,统计覆盖情况、接上分类、计算相邻记录收益,再把核对证据交给同事。这里用虚构数据练流程,不将输出当成投资结论。

延续首月安排:q-309 分组、q-310 合并、q-316 缺失;再到面试练习搜索I08、I09,练习解释你的处理依据。

准备:沿用Python环境,增加一个内存数据库

  1. 解压完整文件夹,接上第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注明使用了参考。脚本不会自动替你采用参考数据。

    回第2周核对清洗结果 · 回第1周安装Python与VS Code

  2. 用同一个解释器安装与运行

    在本目录打开PowerShell。若已经有本目录.venv,跳过第一行;若只有py可用,第一行将python换成py。

    powershell
    python -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策略。

  3. 认识文件分工

    01–04是每步入口;week3.py提供共用函数;returns.sql是实际执行的窗口查询;test_week3.py检查错误边界;transfer开头的两份CSV留到隔日验收。生成结果写入outputs,重跑更新同名输出,输入只读。

    powershell
    Get-Location
    Get-ChildItem -Name
    .\.venv\Scripts\python.exe .\01_group.py
    查看输入检查:为什么不让坏数据进入窗口计算
    python
    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)

    本教程要求完整价格;遇到缺失或冲突,回第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,对照行数。

powershell
.\.venv\Scripts\python.exe .\01_group.py

agg把每组汇总成一行;transform把组统计放回每条原记录。first和last依赖排序,脚本先检查输入并按symbol、date排序。本例价格完整,所以size与count相同;有缺失时size仍数行,count只数该列非缺失值。

查看本步骤的注释函数

片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。

python
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
python
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行业时的结果。

powershell
.\.venv\Scripts\python.exe .\02_merge.py

left保留全部价格记录;on='symbol'明确匹配字段;many_to_one要求右表的symbol唯一;indicator增加_merge状态列。both表示找到映射,left_only表示未找到。缺少映射与右表重复是不同问题。

查看本步骤的注释函数

片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。

python
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
python
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,先只看这条

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

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分成两层,便于检查。

powershell
.\.venv\Scripts\python.exe .\03_sql.py

SQLite是嵌入式数据库,本周由Python在内存中创建prices表。LAG(close)取分区内上一条价格;PARTITION BY symbol分开标的,ORDER BY date确定前后。WITH先生成prev_close,外层SELECT再使用它计算收益。

查看本步骤的注释函数

片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。

python
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
python
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键集合,再比缺失位置,最后看非缺失数值差异。完成下面的故意出错实验。

powershell
.\.venv\Scripts\python.exe .\04_compare.py

outer合并把两边所有键保留下来;one_to_one拒绝重复。缺失位置单独比较,数值用相对容差1e-9、绝对容差1e-12。先inner合并或fillna(0)可能掩盖丢行和首期缺失错误。

查看本步骤的注释函数

片段来自下载包的week3.py,导入与共用常量由完整文件提供;不要只复制函数片段运行。每个入口都重新读取输入,无需依赖前一步输出。

python
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
python
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-05100缺失缺失
A / 01-0611010010%
A / 01-08110121约-9.090909%
B / 01-0550缺失缺失
B / 01-075552.5约4.761905%
B / 01-0860.55510%
powershell
.\.venv\Scripts\python.exe -m unittest -v

应看到Ran 11 tests与OK。检查覆盖分组、合并、窗口、乱序、丢行、重复键、错填缺失、错误数值、非法输入与跨缺口边界。测试通过证明这些案例符合预期,不代表你已经独立掌握。

  1. 去掉分区,收益就串到另一只标的

    备份returns.sql,只删除PARTITION BY symbol,再运行04_compare.py,应报核对失败。相同日期下不同标的的先后也不再有唯一规则,不要求记住一个固定错误数值。恢复查询并重跑。

  2. 把缺失填0,或者漏掉一行,检查都应失败

    新建fault_lab.py,将下面完整脚本保存到同目录并运行。两次“预期拒绝”才是此实验成功;没有捕获预期错误时脚本会明确失败。

    python
    from 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
  3. 两个答案一样,也可能一起遗漏日期

    另存cleaned.csv为cleaned_gap.csv,仅删除A在01-06的记录。新建gap_lab.py运行以下代码。此时01-07的21%跨了缺口;检查通过,只说明两种实现一致。

    python
    from 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
python
"""第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 练习区

在你的浏览器内运行。首次需联网下载 Python;支持标准库和 Pyodide 提供的 NumPy、pandas 等包。行情抓取、系统文件访问和部分第三方库请在本地环境运行。输出自检仅比对结果关键词,不代替逻辑审查。

Python · 独立工作线程 · 执行限时10秒

单元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可离线查看操作顺序。实际行业映射应考虑生效区间与当时已知信息,不能把今天的分类直接回填所有历史日期。