☰
股小仙(二):买卖匹配引擎——两轮匹配、贪心排序与幂等键的坑
2026/10/8 8:25:48 网站建设 项目流程

这是"股小仙"系列第二篇。系列目标:从零搭建一套交易流水驱动的个人持仓分析系统。上一篇定了数据底座(券商流水入库),本篇进入系统的心脏——买卖匹配引擎:给每一笔卖出找到它对应的买入,算出每一层仓位的真实盈亏。全文基于 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'])

匹配策略是贪心:价差最小者优先,价差相同时间最近者优先。

为什么价差最小优先而不是最大优先?两个理由:

  1. 策略语义:网格的哲学是"刚达标就兑现"。把卖单配给"刚好达到兑现线"的买入,让浮盈更多的仓位继续留在场上——这正是网格操盘者的实际意图;
  2. 统计诚实:如果按价差最大优先,每笔卖出都会去认领成本最低的买入,策略收益会被系统性高估。最小优先是保守口径。

第二排序键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里的层分布判断"网格还活着吗"。

系列导航

  • 篇一:数据底座——券商流水的字段清洗与入库
  • 篇二(本篇):买卖匹配引擎
  • 篇三:网格层健康检测与波段买入计划
  • 篇四:用真实流水回测网格参数

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询