
简介这份从策略为王论坛 SVN 仓库导出的开源交易软件源代码面向希望拆解证券行情客户端架构的 C 开发者适合已具备 MFC 基础、关注行情推送、K 线绘制与实时走势图实现的中高级读者。代码覆盖行情、K 线与实时走势等常见交易软件功能界面、绘图与数据接收均有对应实现并已在 VS2008 下编译通过、可正常运行。压缩包共 1299 个文件、约 10.54MB以 219 个 .h 头文件与 215 个 .cpp 源文件为主体辅以 bmp、ico 界面素材与 vcproj、sln 工程配置目录层次较完整便于按模块梳理结构。目前已有 1143 人学习下载。读者可从中获得一套可直接编译运行的行情客户端范本理解数据接收、K 线渲染与界面组织的落地写法行情服务器端并未包含恰好留作推送服务与二次开发的练手空间。1. 拿到一套开源交易软件源代码先别急着编译三个判断决定它值不值得跑一个做私募的朋友把「策略为王」这套开源交易软件源代码丢给我时原话是“股票行情接了两天没接上策略跑出来的曲线漂亮得不像真的”。这句话基本覆盖了这类开源交易软件最常见的两个问题行情链路没有打通以及回测撮合过于理想。它并不是一个能开箱即用的看盘工具而是一套把行情接入、策略调度、回测撮合、下单执行、风控和日志拆成独立模块的源码工程股票行情的订阅、落库、复权都在里面。适合谁想自己搭一套可改可调的策略框架、不想从零写撮合和风控的从业者不适合想装个软件就自动赚钱的人。下面按我实际拆包的顺序从目录骨架、行情接入、策略实战、踩坑一路写到参数实验怎么做成可复现。2. 拆骨架行情、策略、交易、风控四层怎么分改动边界在哪拿到源码我先不看策略样例先看目录。原因很直白策略写得再花只要行情层和撮合层是糊在一起的后面所有回测数字都不可信。这类交易软件源码的价值八成在骨架的分层契约上两成在自带的那几个策略示例里。2.1 目录结构与四个核心模块的职责边界一套结构正常的开源交易软件目录大致长这样模块名各家不同但职责跑不出这张表目录职责我通常的改动频率core/market行情订阅、断线重连、tick 合成 bar、落库低除非换行情源core/strategy策略基类、参数注入、生命周期调度中加钩子时才动core/broker下单通道、订单状态机、成交回报对账低动了要重测core/risk事前风控仓位、单笔上限、事后校验中跟着规则走core/backtest撮合、滑点、手续费、绩效统计低strategies/自己写的策略唯一高频改动区高config/环境与账户配置高tools/自检、补数据、对账脚本中看这张表要抓的重点是改动边界strategies/ 是你可以随便改的core/ 里的东西每动一次都要重新验证。我见过太多人为了图省事直接在 broker 模块里加了一段“信号不对就撤单重发”的逻辑结果回测跑了三个月才发现实盘里这段逻辑会重复下单。判断一个包是否成熟就看它有没有把“策略想要的”和“账户真正发生的”分成两套数据结构——策略侧只有目标仓位账户侧才有订单、成交、持仓明细。读代码的顺序建议是core/strategy/base.py→core/broker/order.py→core/backtest/matcher.py。先看策略能拿到什么上下文再看订单状态怎么流转最后看回测怎么把订单撮合成成交。这三个文件读完整个框架的能力边界就清楚了剩下的都是外围。2.2 行情数据的入口从行情源到本地缓存的链路股票行情这一层多数开源实现的链路是行情源适配器 → 消息队列/内存队列 → bar 合成器 → 落库 分发给策略。适配器负责把不同来源的字段名、时间戳格式、价格精度统一成内部结构这一步没做好后面全是脏数据。# core/market/adapter.py示意按包里实际字段名改 from dataclasses import dataclass dataclass class Tick: symbol: str ts: int # 统一成毫秒时间戳别用字符串 last: float # 最新价 volume: float # 累计成交量 turnover: float # 累计成交额 bid1: float 0.0 ask1: float 0.0 def normalize(raw: dict, tick_size: float 0.01) - Tick: # 1) 价格归到最小变动价位避免 10.000000001 这种脏值进策略 last round(float(raw[price]) / tick_size) * tick_size # 2) 时间戳统一有的源给秒有的给毫秒靠位数判断 ts int(raw[time]) if ts 10**11: ts * 1000 # 3) 成交量有的源给“手”有的给“股”这里统一成股 vol float(raw[vol]) * (100 if raw.get(vol_unit) lot else 1) return Tick(symbolraw[code], tsts, lastlast, volumevol, turnoverfloat(raw.get(amount, 0.0)))这段代码有三个参数值得盯tick_size决定价格精度A 股股票一般是 0.01基金和债券不一样写死会在别的品种上出错时间戳的位数判断属于不得已的做法更稳的方式是在配置里显式声明单位成交量单位是最容易出错的一处差 100 倍会让所有量价策略的信号全废。落库那张表至少要建(symbol, ts)的联合主键重连补数据时才不会插出重复 K 线。bar 合成器要处理的是“这根 K 线算不算完整”。我的做法是每个周期只认时间戳落在区间内、且区间结束才推送中途来的 tick 只更新不推送这样策略里拿到的 bar 一定是终态不会出现同一根 K 线被回调两次、策略重复下单的情况。2.3 策略接口的最小契约init / on_bar / on_tick / on_order策略基类是这个框架里最值得抄的部分。一个够用的契约大概四个钩子初始化、行情回调、订单回报回调、收盘回调。重点是回测和实盘必须共用同一套钩子否则你回测跑通的东西搬到实盘就是另一个程序。# core/strategy/base.py示意 class Strategy: params: dict {} # 参数默认值实盘/回测都从这里读 def on_start(self, ctx): 启动时调一次订阅、预热指标缓冲都在这里 def on_bar(self, ctx, bar): 每根 K 线收盘调一次策略主体 def on_order(self, ctx, order): 订单状态变化已报、部成、全成、废单 def on_stop(self, ctx): 收盘或手动停止平掉不该留的仓位、落盘状态ctx这个上下文对象是设计的关键。它给策略暴露的东西应该克制当前时间、持仓查询、目标仓位下单、日志。不该暴露“直接改账户余额”或者“拿到整个 DataFrame”这类口子一旦开了回测和实盘的差异就从这个口子漏进来。我给 ctx 加过的一个最有用的方法是ctx.order_target_pct(symbol, pct)——策略只说“我要这只票占总资产的 10%”至于买多少股、按什么价、要不要拆单全部交给 broker 层算。这样策略代码在两个环境下完全一致差异被收敛到撮合器里。反过来如果包里的策略是直接调用broker.buy(symbol, price, qty)的那就要多留个心眼这种写法在实盘里没问题在回测里很容易变成“用当根收盘价成交”也就是典型的未来函数。3. 把行情接进门环境、配置、自检与第一条 K 线落地骨架看完接下来是最枯燥也最劝退的一步让它真的收到行情。这一步 90% 的失败不在代码在环境和配置。3.1 依赖与运行环境的确定这类工程通常会带requirements.txt或者pom.xml、CMakeLists.txt主语言是 Python 的居多也有 C 写撮合核心、Python 写策略的混合结构。不管哪种第一原则是隔离不要往系统 Python 里装包冲突是这类项目最常见的翻车点尤其是带 C 扩展的指标库和数据库驱动。# 1) 独立虚拟环境 python3 -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate # 2) 先看依赖清单别闭眼装 head -40 requirements.txt # 3) 带 C 扩展的包指标库、数据库驱动先装系统编译依赖 # Debian/Ubuntu 系sudo apt-get install -y build-essential python3-dev pip install -r requirements.txt # 4) 锁版本把当前可用的组合固定下来 pip freeze requirements.lock第 4 步很多人省掉代价是两周后同事拉下来装了一遍指标库升了个小版本均线算出来的小数位变了回测结果对不上然后开始怀疑人生。requirements.lock这东西就是后悔药出事能一键回到当时那套环境。如果包里主语言是 Java 或 C思路一样JDK/编译器版本写进 READMEC 侧把编译选项里的-O2和浮点相关的 flag 记下来别用默认值跑回测。3.2 配置文件逐项拆解配置文件是这套交易软件源代码里信息密度最高的文件读懂了它基本等于读懂了框架的能力边界。多数实现用 YAML 或 TOML字段名不同但结构相似# config/live.yaml —— 回测/模拟/实盘共用一份靠 mode 切换 mode: sim # backtest | sim | live market: source: gateway_a # 对应 core/market/adapters/gateway_a.py symbols: [600000.SH, 000001.SZ] bar_interval: 1m # 1m / 5m / 1d reconnect_interval: 5 # 断线重连间隔秒 time_zone: UTC8 # 与行情源的时区对齐方式 account: init_cash: 1000000 commission_rate: 0.00025 # 佣金双边 min_commission: 5 # 单笔最低佣金 stamp_tax: 0.001 # 印花税仅卖出 slippage_ticks: 1 # 滑点按最小变动价位的倍数 risk: max_position_pct: 0.2 # 单票最大仓位占比 max_daily_loss_pct: 0.03 # 当日亏损熔断线 storage: db_url: sqlite:///data/market.db log_dir: logs/参数怎么改我的习惯是按影响面排序mode和symbols每次都可能动bar_interval动了等于换策略要重跑回测slippage_ticks和手续费三项是回测可信度的根宁可设得悲观一点也别设成 0 图好看max_daily_loss_pct是保命参数实盘前必须设设成 0 或者注释掉等于没有风控。一个容易忽略的点time_zone。行情源给的时间戳有的带时区有的不带有的给的是 UTC 秒。配置里必须显式声明对齐方式别指望代码猜。3.3 启动前的自检脚本我在任何交易系统上都会写一个自检脚本五分钟能省掉半天排查。检查四件事配置合法性、数据库可写、时钟偏差、行情源连通性。# tools/selfcheck.py —— 启动前跑一遍任何一项不过就别启动 import os, sys, time, sqlite3, yaml def load_cfg(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) def check_config(cfg): assert cfg[mode] in (backtest, sim, live), mode 取值非法 assert cfg[market][symbols], symbols 为空订阅会直接退出 assert 0 cfg[risk][max_position_pct] 1, 仓位上限必须在 (0,1] assert cfg[account][init_cash] 0, 初始资金必须为正 return True def check_storage(cfg): path cfg[storage][db_url].replace(sqlite:///, ) os.makedirs(os.path.dirname(path) or ., exist_okTrue) con sqlite3.connect(path) con.execute(CREATE TABLE IF NOT EXISTS bars( symbol TEXT, ts INTEGER, open REAL, high REAL, low REAL, close REAL, volume REAL, PRIMARY KEY(symbol, ts))) con.commit(); con.close() return True def check_clock(source): # 行情源握手包里一般带服务器时间偏差超过 2 秒K 线排序会乱 drift abs(time.time() - source.server_time()) assert drift 2, f时钟偏差 {drift:.2f}s先校时再跑 return True这三个函数的顺序不是随便排的配置错是纯本地问题最快暴露存储错没权限、路径不存在次之时钟偏差最隐蔽放在最后但一定要有因为时间戳乱序导致的 K 线错误不会报错只会让策略莫名其妙地在错误的时间点开仓。自检不过就直接sys.exit(1)不要写“警告但继续”这类系统带病运行的成本远高于停机。3.4 用一段最小策略验证链路通畅环境、配置、自检都过了别急着上你的策略。先跑一段只打印不交易的探针策略确认三件事K 线来了、周期对了、落库了。# strategies/probe.py —— 只观察不下单 from core.strategy.base import Strategy class Probe(Strategy): params {} def on_start(self, ctx): ctx.log.info(probe start, symbols%s interval%s, ctx.symbols, ctx.cfg[market][bar_interval]) def on_bar(self, ctx, bar): # 每根 K 线打印一次确认时间戳是升序且间隔均匀 ctx.log.info(%s %s close%.2f vol%.0f, bar.symbol, bar.dt, bar.close, bar.volume) if ctx.bar_count % 100 0: ctx.log.info(已收 %d 根最新落库 %s, ctx.bar_count, ctx.last_db_ts)跑起来之后看日志重点不是“有输出”而是三件事对不对同一标的的bar.dt必须严格递增间隔必须等于配置的周期中间缺 K 线说明行情源有丢包或订阅晚了last_db_ts要跟日志里最后一根 K 线对得上对不上说明落库线程卡了。我一般会让这个探针跑满一个完整的交易时段再切正式策略尤其是 1 分钟周期跑十分钟看不出任何问题跑四小时才会暴露午休时段和开盘集合竞价的边界处理。4. 策略层动手一个双均线策略从回测到模拟盘的完整链路骨架通了接下来才是策略。这里用一个双均线做样例不是为了证明这个策略能赚钱而是因为它足够简单能把“信号怎么来、订单怎么下、成交怎么撮合、绩效怎么读”这四件事全流程走一遍。4.1 策略骨架与信号判定写策略时最需要定下来的是用哪根 K 线的信息做决策用哪根 K 线的价格成交。这两件事一旦含糊回测就是自欺欺人。# strategies/dual_ma.py from core.strategy.base import Strategy class DualMA(Strategy): params {fast: 5, slow: 20, pos_pct: 0.1} def on_start(self, ctx): self.buf {} # symbol - 收盘价序列 def on_bar(self, ctx, bar): buf self.buf.setdefault(bar.symbol, []) buf.append(bar.close) n self.params[slow] if len(buf) n 1: return f, s self.params[fast], self.params[slow] fast_now sum(buf[-f:]) / f slow_now sum(buf[-s:]) / s # 用上一根算出的均线做比较避免同根 K 线内既决策又成交 fast_prev sum(buf[-f-1:-1]) / f slow_prev sum(buf[-s-1:-1]) / s pos ctx.position(bar.symbol) golden fast_prev slow_prev and fast_now slow_now dead fast_prev slow_prev and fast_now slow_now if golden and pos.qty 0: ctx.order_target_pct(bar.symbol, self.params[pos_pct]) elif dead and pos.qty 0: ctx.order_target_pct(bar.symbol, 0.0)fast_prev、slow_prev这两行是整个策略里最关键的也是最容易被写漏的。很多人直接拿fast_now slow_now判断金叉然后在同一根 K 线的收盘价成交回测曲线会漂亮得离谱——因为你用当根收盘价的信息又用当根收盘价成交等于零延迟。正常做法是信号在当根 K 线收盘确认成交在下一根 K 线的开盘价或撮合器给的下一笔可成交价。这个约定必须写死在撮合器里不能靠策略自觉。pos_pct用目标仓位而不是股数好处是复权、拆股、资金变动都不用改策略代码。4.2 回测撮合的四个细节成交价、滑点、手续费、不可成交场景回测和实盘的差距八成在这四处。看一套源码值不值得用直接去翻撮合器。# core/backtest/matcher.py示意 def fill_price(order, bar, tick_size, slippage_ticks): direction 1 if order.side buy else -1 # 市价单按下一根 K 线开盘价成交限价单按挂单价 px bar.open if order.type market else order.price px px direction * slippage_ticks * tick_size # 滑点永远对自己不利 return round(px / tick_size) * tick_size # 归到最小变动价位 def calc_fee(side, amount, cfg): commission max(amount * cfg[commission_rate], cfg[min_commission]) tax amount * cfg[stamp_tax] if side sell else 0.0 return round(commission tax, 2)四个细节逐个说。成交价市价单必须取下一根 K 线的开盘价取当根收盘价就是未来函数限价单要判断当根 K 线的最高最低是否穿过挂单价穿过了才算成交不能无条件成交。滑点方向永远对自己不利买单往上加、卖单往下减slippage_ticks设 1 到 3 个 tick 是常见做法小盘股可以更悲观。手续费佣金双边收且有最低 5 元卖出加印花税这两项对小资金高频策略的侵蚀非常明显min_commission不设会让小单回测严重虚高。不可成交场景一字板、停牌、涨跌停封单撮合器里必须有对应分支否则回测会在根本买不进去的价位上成交。这四项里我认为最该多花时间的是“不可成交”。开源包默认常常不处理涨跌停你回测里买到的那些一字板实盘一次都买不到而这部分成交往往恰好贡献了最多收益。4.3 绩效指标怎么读别只看年化框架一般自带绩效统计输出一堆数字。我的读法固定成顺序最大回撤 → 交易次数 → 胜率与盈亏比 → 年化 → 夏普。顺序换了容易误判。def metrics(equity, periods_per_year252): rets [equity[i] / equity[i-1] - 1 for i in range(1, len(equity))] total equity[-1] / equity[0] - 1 ann (1 total) ** (periods_per_year / len(equity)) - 1 peak, mdd equity[0], 0.0 for v in equity: peak max(peak, v) mdd min(mdd, v / peak - 1) mean sum(rets) / len(rets) var sum((r - mean) ** 2 for r in rets) / max(len(rets) - 1, 1) sharpe mean / (var ** 0.5) * (periods_per_year ** 0.5) if var 0 else 0.0 return {total_return: total, annual: ann, max_drawdown: mdd, sharpe: sharpe, bars: len(equity)}先看回撤回撤 40% 的策略年化再高也拿不住。再看交易次数一年不到 20 次的策略统计上说明不了什么别信它的夏普。再看胜率与盈亏比胜率 30% 但盈亏比 3:1 是正常的趋势策略形态胜率 90% 反而是危险信号通常意味着有没平掉的仓位或者成交假设有问题。最后看年化和夏普且一定要跟“买入持有”对比跑不赢基准的策略没有存在意义。4.4 从回测切到模拟盘的三个开关回测数字能看之后别直接上实盘先跑模拟盘。切换的动作应该只有配置层面的一行改动代码零改动开关backtestsimlivemodebacktestsimlive行情源本地历史库回放实时行情实时行情撮合matcher 模拟matcher 模拟真实通道账户虚拟虚拟真实账户切之前必须确认两件事策略代码里没有if mode backtest这种分支有就说明它在作弊模拟盘的成交回报走的是和实盘同一条订单状态机只有这样on_order里的逻辑才是被验证过的。我一般让模拟盘跑满两周重点不是收益是对账每天收盘把策略侧记录的持仓和通道侧的持仓明细逐笔对一遍差一笔就查到底。5. 避坑排查五条让回测和实盘对不上的血泪记录这一章全是具体故障每条按现象、原因、解决写。这几条我都在不同项目上真实遇到过也见过别人反复踩。5.1 K 线时间戳错位信号总在收盘后一分钟才出现现象日志里策略的信号时间比行情软件晚一分钟回测里看不出来模拟盘时成交价总是差一截。原因行情源给的是 UTC 秒级时间戳适配器没做时区换算或者 bar 合成器用的是“区间开始时间”打标而策略以为是“区间结束时间”。两种错法都会让整条链路平移一个周期。解决在适配器里统一转成本地时间并显式打印前 5 根 K 线的起止时间人工核对一次bar 的时间戳一律用区间结束时间写进基类注释里。这个核对做一次管很久值。5.2 复权处理不当除权日凭空多出一根大阴线现象某只票在回测里出现一根跌幅 30% 的 K 线触发止损清仓之后一路上涨策略完美错过。原因本地库里存的是不复权价格跨除权日时价格跳空。有些实现只对日线做前复权分钟线不做混用就出问题。解决落库时同时存原始价和复权因子策略层统一用后复权价计算信号下单前再换算回真实价格。换算这一步放在 broker 层别让策略自己算。另外回测区间内如果有除权绩效统计要用复权净值否则收益率会失真。5.3 重复 K 线与重复下单断线重连后的隐形炸弹现象模拟盘跑一天某只票的持仓是最初计划的两倍日志里同一个时间点有两条开仓记录。原因断线重连后行情源补发了最近一段 K 线落库那步没做去重或者主键没建对策略的on_bar被重复调用更狠的是订单侧重连时把未确认的订单重发了一遍。解决数据库层用(symbol, ts)联合主键加INSERT OR IGNORE从根上去重策略侧记一个last_bar_ts遇到不大于它的直接 return订单侧必须有客户端订单号重发前先查通道侧的订单状态只有“未报/已报未成”才允许重发。5.4 浮点与取整回测能买 1234.56 股现象回测成交明细里出现小数股实盘下单直接报错。原因目标仓位换算成股数时没取整到 100 股一手价格没归到最小变动价位浮点累计误差还会让“刚好满仓”变成超额。解决broker 层统一做两道取整——股数向下取整到 100 的倍数价格按tick_size四舍五入资金校验用Decimal或者整数分做运算别用 float 直接比大小。风控里加一条“下单金额不得超过可用资金”的硬校验兜底。5.5 回测里从没排过队流动性假设过于乐观现象回测年化 60%模拟盘同一策略一个月只成交了三笔滑点吃掉全部利润。原因撮合器无条件按开盘价全额成交没考虑成交量约束。策略在小盘股上一次性下单几十万实盘里这笔单子本身就会把价格推走。解决在撮合器里加成交量上限单笔成交不超过当根 K 线成交量的一定比例我一般用 5%超出的部分顺延到下一根同时把滑点按下单量分档量越大滑点越高。改完回测收益大概率会掉一大截但那才是真的。6. 参数实验做成快照把“这次跑的是哪一版”钉死双均线这种策略参数一改结果全变。跑到第三轮的时候你一定记不清上周那组 5/20 的曲线是在哪份配置、哪个数据版本下跑出来的。我的做法是每次回测都生成一份参数快照落到同一个目录里谁跑的都一样。# tools/snapshot.py —— 每次回测前调用把这次实验钉死 import json, hashlib, time, random def snapshot(params, data_range, seed42): payload json.dumps(params, sort_keysTrue, ensure_asciiFalse) return { params: params, data_range: data_range, seed: seed, hash: hashlib.md5(payload.encode()).hexdigest()[:12], created: time.strftime(%Y-%m-%d %H:%M:%S), } if __name__ __main__: random.seed(42) # 有随机成分的策略打乱、抽样必须固定 snap snapshot({fast: 5, slow: 20, pos_pct: 0.1}, data_range(2021-01-04, 2024-12-31)) print(json.dumps(snap, ensure_asciiFalse, indent2)) # 输出里的 hash 就是这次实验的身份证写进回测报告标题hash那一行是重点参数排序后算摘要顺序不同但内容相同的参数会得到同一个 hash比文件名靠谱。seed容易被忽略只要策略里有一点点随机比如按概率抽样建仓、多标的随机排序不固定种子两次跑出来的结果就不一样你会在“策略不稳定”和“代码有 bug”之间反复横跳。有了快照参数对比就该用表格而不是靠记忆组号fastslow仓位年化最大回撤交易次数结论A52010%12.4%-18.2%146基准B103010%9.8%-12.6%71回撤更小收益够用C52020%21.1%-34.7%148杠杆放大不可接受D106010%6.2%-9.1%24样本太少不采信看这张表的方式先排除交易次数过少的D再排除回撤超出承受范围的C剩下的按“收益/回撤”比排序而不是按年化排序。B 比 A 年化低两个点但回撤少了将近六个点实盘拿得住这种选择在纸面上不显眼在账户曲线上差别很大。还有一个技巧把每组参数的净值曲线一起画出来看形态而不是只看末端数字。两条曲线如果在前半段高度重合、后半段才分开说明参数差异只影响了特定行情如果从头到尾都在分叉说明参数敏感度高实盘换个市场环境就未必成立。这种敏感的参数组宁可不用。踩过的坑是我曾经在没固定种子的情况下对比了七组参数挑了一组“最优”的上模拟盘两周后想复现怎么跑都跑不出那个数字翻了两天才发现是抽样顺序的问题。从那以后我每次回测都强制先跑一遍快照、把 hash 写进报告文件名参数和数据区间对不上的结果一律不认。希望帮到你。本文还有配套的精品资源点击获取