"股小仙"系列第一篇。分析系统的地基是数据,而个人投资者的数据源只有两处:券商/平台导出的文件和平台接口的分页拉取。这篇讲数据底座的三个实战问题:一份文件两种格式的兼容读取、Excel 导出的
="..."公式陷阱、以及"查历史流水该从哪天开始"这个看似简单实则坑深的问题。
一、一份文件,两种格式
券商导出的"对账单"看起来是个 Excel,实际到手可能是两种东西:
- 真 Excel(xlsx 二进制)——pandas 一行读完;
- 披着 .xls 后缀的 GBK 文本(tab 分隔)——
read_excel直接炸。
兼容读取的写法:
try:df=pd.read_excel(file_path)if'成交日期'notindf.columns:raiseValueError("not excel")exceptException:withopen(file_path,'rb')asf:text=f.read().decode('gbk')cleaned=[re.sub(r'="([^"]*)"',r'\1',line)forlineintext.strip().split('\n')]df=pd.read_csv(io.StringIO('\n'.join(cleaned)),sep='\t',dtype=str)三个细节:
- 按内容判断而不是按后缀:先读、验列名(
成交日期不在就认定不是 Excel)。后缀会说谎,列名不会; - GBK 解码:又是它(A4 篇行情接口、B3 篇流水入库,GBK 三连);
dtype=str先全按字符串读:代码列000001按 Python 默认规则会变成1(整数),前导零没了,股票代码就废了。先 str 后逐列转数值,顺序不能反。
二、="..."公式陷阱
注意清洗正则:
cleaned=[re.sub(r'="([^"]*)"',r'\1',line)forlineinlines]它处理的是 Excel 导出文本时的一个阴招:日期和代码字段会被包成="2026-01-15"这种公式形式(本意是防 Excel 自动转换格式)。不剥掉这层壳,读进来的"日期"是字符串="2026-01-15",解析必炸。
正则="([^"]*)"→\1把公式壳剥掉留下裸值。这个坑的教训是:导出文件里的每个字段都可能穿着"给 Excel 看的衣裳",给程序读之前要先脱。常见的三件衣裳:公式壳、前导零杀手(数字格式)、千分位逗号(金额列1,234.56直接to_numeric会得到 NaN,需要先删逗号——本例金额列没这个问题,但换一家券商就可能有)。
三、datetime 的拼装与排序
df['datetime']=pd.to_datetime(df['成交日期'].astype(str)+' '+df['成交时间'],errors='coerce')df=df.sort_values('datetime').reset_index(drop=True)日期列和时间列拼成 datetime 再全局排序——这步是后面匹配引擎(A2 篇)的前置条件:匹配的时间序约束依赖流水的严格时序。errors='coerce'让坏行变 NaT 而不是炸掉,配合后续筛选把脏数据挡在引擎外面。
(A2 篇讲过秒级时间戳为零的坑,这里补一句它的源头:成交时间只到分,同一分钟内的顺序只能靠导出文件的原始顺序——而上面reset_index后原始顺序就藏在 index 里,这就是"券商导出通常是时序的"这个假设的全部依靠。)
四、接口侧的分页拉取
平台接口的历史流水是分页的,拉全量的循环结构:
page,count=1,30whileTrue:params={"page":str(page),"count":str(count),...,"query_list":json.dumps([{"code":code,"market":market}])}ex_data=api_post(fund_key,"/pc/account/v2/get_money_history",params)records=ex_data.get("list",[])max_page=ex_data.get("max_page",1)ifnotrecords:breakall_records.extend(records)ifpage>=max_page:breakpage+=1all_records.sort(key=lambdax:x.get("entry_date",""))两个防御性设计:双终止条件(空列表提前退 + 到达 max_page 退出)——只信 max_page 的话,服务端分页数算错的瞬间就是死循环;拉完重排序(按entry_date)——不信任接口的分页顺序,时序自己在客户端保证。加sort_type参数接口也支持,但客户端再排一次是免费的保险。
五、最微妙的问题:从哪天开始查?
拉历史流水不能无脑"从开户日查到今天"——接口按时间范围查询,范围越大越慢,而且很多平台对久远历史有限制。看这段决策代码:
defdetermine_start_date(position,cleared_list):code=position["code"]hold_days=int(position.get("hold_days",0))stock_cleared=[cforcincleared_listifc["code"]==code]ifstock_cleared:last_close=max(c["close_date"]forcinstock_cleared)start=last_close+timedelta(days=1)# 有清仓记录:清仓次日开始returnstart,Trueelse:lookback=max(hold_days*3,365)# 无清仓:回溯 max(持有天数×3, 一年)returnnow-timedelta(days=lookback),False逻辑分两支,依据是这只股票有没有清仓记录:
有清仓记录:当前持仓是"清仓后重新建仓"的,流水从最近一次清仓的次日查起——之前的旧周期流水与当前持仓无关,拉了也是噪音;
无清仓记录:当前仓位可能从很远以前一路持有,回溯max(持有天数 × 3, 365)天。为什么是持有天数的三倍?因为"持仓 N 天"是当前这笔的账龄,而这只股票可能经历过多轮买卖——3 倍是对"此前最多再有一两轮完整周期"的经验估计;365 天下兜底保证至少覆盖一年。
这个函数返回的第二个值(bool)标记"是否找到了精确起点",调用方据此决定要不要在日志里提示"流水可能不完整"——启发式规则要把自己的启发式性质暴露给下游,这条原则和 A4 篇行情降级的零值标记一脉相承。
六、两条数据通道,一套数据底座
文件导出(券商对账单)和接口拉取(平台历史)各有所长:文件全而慢(人工导出)、接口快而可能有范围限制。本系统的取舍是接口为主(自动化),文件兜底(校验)——两路数据落到同一张trade_record表(B3 篇的幂等键天然支持双路写入去重),互为对账依据。
数据底座的完成度直接决定上层分析的天花板:A2 的匹配、A3 的健康度、A4 的估值,全部站在这一篇的三个决定上——按内容识别格式、剥掉 Excel 的衣裳、想清楚从哪天开始查。
系列导航
- A1(本篇):数据底座——两种格式、公式陷阱与查询起点
- A2 买卖匹配引擎:两轮匹配、贪心排序与幂等键的坑
- A3 网格层健康检测与波段加仓计划
- A4 免费行情接入:GBK、波浪号与 68 个字段