这是"股小仙"系列第二篇。系列目标:从零搭建一套交易流水驱动的个人持仓分析系统。上一篇定了数据底座(券商流水入库),本篇进入系统的心脏——买卖匹配引擎:给每一笔卖出找到它对应的买入,算出每一层仓位的真实盈亏。全文基于 500 行 Python 实现逐段拆解,最后指出代码里两个真实的坑(其中一个是我写的时候埋的,半年后才被真实数据踩中)。
一、为什么"净额持仓"不够,必须做匹配
券商给的持仓是净额:Σ买入 - Σ卖出 = 当前持仓。但做策略分析要回答的问题是过程性的:
- “我这 3000 股里,哪 1000 股是周一买的(T+1 锁定)?”
- “今天卖掉的 500 股,成本是哪笔买入?赚了多少?”
- “有没有那笔卖出,其实卖亏了(卖价低于某笔买价)?”
净额算术回答不了任何一个。必须把每笔卖出拆开,指认它消耗的是哪几笔买入——这就是匹配引擎做的事。
二、总体设计:为什么是两轮而不是一轮
直接拍脑袋的方案是一轮匹配完事,但实战中存在一类交易——违规操作:策略说"价差 ≥ 0.10 元才能卖",而真实流水里就是存在卖价只比买价高 0.03 的卖出(手滑、误操作、或者当时就是想跑)。这些交易真实发生了,持仓必须反映它们,但它们不该混进"策略收益"的统计里。
所以设计成两轮:
第一轮(策略合规匹配):只按策略规则配对 → 产出"策略收益" 第二轮(违规强制匹配):第一轮剩下的卖出,强制配对 → 产出"事实持仓"两轮的输出合起来才是完整持仓,分开看才有"策略执行质量"这个维度。
三、第一轮:合规匹配逐段拆解
3.1 数据结构与最小价差
buy_positions=[{'buy_idx':idx,'datetime':row['datetime'],'price':row['成交价格'],'remaining':row['成交数量'],# 剩余可配数量——匹配的本质是消耗它'matched_records':[]},...]每笔买入建模为一个可消耗的池:remaining从原始数量开始,每被一笔卖出匹配掉一部分就减一部分。减到 0,这笔买入"耗尽"。
卖出侧的关键参数是最小价差:
min_diff=0.0ifsell_qty<500else0.10这行是策略的翻译:小单(<500 股)是网格自动执行的,只要不亏(价差 > 0)就认;大单(≥500 股)是手动决策,必须赚够 0.10 元/股才认。为什么用数量而不是金额区分?因为网格单的特征恰恰是"股数固定、金额小",数量是最稳定的判别特征。
3.2 候选构建:三个硬约束
对每笔卖出,扫描所有买入池,筛出候选:
forbpinbuy_positions:ifbp['remaining']<=0:continue# 约束1:池已耗尽pd_val=round(sell_price-bp['price'],4)ifbp['datetime']<sell_datetimeandpd_val>=min_diff:candidates.append({...})# 约束2:时间序 约束3:最小价差三个约束里最容易被忽略的是时间序(bp['datetime'] < sell_datetime):买入必须严格早于卖出。它看起来显然,但流水数据里同日多笔交易的秒级时间戳经常为 0(券商导出只到分),靠它做严格判断会出幺蛾子——后面讲坑的时候会回到这里。
3.3 贪心排序:(price_diff, time_diff)的双重含义
candidates.sort(key=lambdax:(x['price_diff'],x['time_diff']))best=candidates[0]match_qty=min(remaining,best['remaining'])匹配策略是贪心:价差最小者优先,价差相同时间最近者优先。
为什么价差最小优先而不是最大优先?两个理由:
- 策略语义:网格的哲学是"刚达标就兑现"。把卖单配给"刚好达到兑现线"的买入,让浮盈更多的仓位继续留在场上——这正是网格操盘者的实际意图;
- 统计诚实:如果按价差最大优先,每笔卖出都会去认领成本最低的买入,策略收益会被系统性高估。最小优先是保守口径。
第二排序键time_diff(持有时间最短优先)配合同样逻辑:同等条件下优先"快进快出"的仓位,让长持仓位沉淀。
3.4 部分成交循环
一笔卖出 1000 股,可能要消耗三个买入池(500 + 300 + 200):
whileremaining>0:candidates=[...]# 每轮重建候选(remaining 已变化的池要刷新)ifnotcandidates:break# 没有合规候选了 → 剩余部分流去第二轮best=candidates[0]match_qty=min(remaining,best['remaining'])...remaining-=match_qty注意while内每轮重建候选列表——上一轮匹配后各池的remaining变了,且耗尽的池要退出。个人数据量(几千笔)下这个 O(卖出数 × 买入数) 的全量重扫毫无压力;如果是机构级流水,这里要换成堆或平衡树,但对个人系统,简单正确比精巧快更重要。
四、第二轮:违规匹配与"损害最小"原则
第一轮剩下的卖出(remaining > 0的部分)进第二轮强制匹配:
# 关键差异:价差最大优先(含负价差)candidates.sort(key=lambdax:-x['price_diff'])排序方向反过来了。第一轮最小优先是策略视角(保守统计收益),第二轮最大优先是损害控制视角:这笔违规卖出已经发生了,把它配给"成本最低的买入池",认亏最少的那一对。如果反过来配给高成本池,会把一笔小失误放大成大额账面亏损,扭曲对"这次手滑到底亏了多少"的判断。
第二轮不设min_diff门槛、允许负价差——事实不挑食。
五、两个真实的坑
坑一:幂等键撞车(我埋的)
第二轮开始时要算"这笔卖出已被第一轮匹配了多少":
key=(m['sell_date'],m['sell_time'],m['sell_price'],m['sell_qty'])sell_matched[key]=sell_matched.get(key,0)+m['match_qty']用(日期,时间,价格,数量)四元组当卖出记录的身份证。看起来稳,直到有一天真实流水里出现同一秒、同价格、同数量的两笔卖出——比如一笔大单被交易所拆成两笔完全相同的成交回报。两笔卖出共享同一个 key,第一轮的匹配量被重复计入,第二轮就少配甚至漏配。
修法很直接:流水入库时就给每笔交易分配全局自增 id,用 id 做键。这个坑在测试数据里永远不会出现(模拟数据不会生成"完全相同的两笔交易"),只有真实数据会教会你什么叫身份证。
坑二:秒级时间戳为零
券商导出的流水时间很多只精确到分,秒是00。同一分钟内先买后卖的两笔,datetime相等,严格小于判断bp['datetime'] < sell_datetime为假——同分钟的"买完立刻卖"全部匹配失败,全流去第二轮变成"违规"。
目前的缓解是按"同分钟内按流水原始顺序"补序(券商导出通常是时序的),但这是对数据格式的假设。根本解法还是入库时做时序校验和编号。
六、输出契约
两轮跑完,引擎输出三样东西,契约长这样:
{"matched":[{"buy_date":"...","sell_date":"...","price_diff":0.12,"match_qty":500,"profit":60.0,"is_small":true}],"violations":[{"price_diff":-0.05,"profit":-25.0,...}],"positions":[{"buy_date":"...","remaining":1200,"matched_records":[...]}]}下游一切分析——可卖/不可卖、网格层健康、策略执行质量(违规率、违规亏损占比)——全部建立在这三个结构上。下一篇写网格层健康检测:怎么用positions里的层分布判断"网格还活着吗"。
系列导航
- 篇一:数据底座——券商流水的字段清洗与入库
- 篇二(本篇):买卖匹配引擎
- 篇三:网格层健康检测与波段买入计划
- 篇四:用真实流水回测网格参数