资讯动态

C#股票交易模拟系统:从撮合日志到策略验证框架

发布时间:2026/10/25 21:52:59 来源:尧图企业网站定制
简介这份资源是一套基于C#实现的股票交易模拟系统源码面向具备C#基础、希望以完整项目实战巩固桌面开发与数据库技能的开发者。系统提供图形化界面与数据库支持可进行虚拟股票买卖、行情查看与交易记录管理帮助理解真实市场环境下的业务逻辑。压缩包共63个文件约278KB以21个cs源码文件为核心配合8个resx与9个resources资源文件构建界面8个accdb数据库文件存储用户、行情与交易数据另有csproj、sln等工程配置及少量编译产物结构完整可直接在Visual Studio中打开运行。目前已有1765人学习下载。项目覆盖网络请求获取行情、ADO.NET数据库操作、Windows Forms界面设计、MVC分层与订单撮合规则等知识点适合作为课程设计或毕业设计的参考蓝本也可用于练习单元测试与调试排错。1. 从一份撮合日志说起C# 股票交易模拟系统到底在模拟什么很多人第一次听到「C# 实现的股票交易模拟系统」脑子里浮现的是行情软件那种红绿跳动的界面。但真正做过的人知道界面只是壳核心是一份撮合日志谁在什么时间、以什么价格、下了多少量、成交了多少、还剩多少挂在盘口。这份日志决定了你的模拟系统是玩具还是能用来验证策略的工具。这个方向解决的是「不拿真金白银也能验证交易逻辑」的问题。适合三类人想练 C# 面向对象和并发编程的开发者、想回测自己交易想法的量化爱好者、以及需要给内部做交易培训或策略演示的团队。它不承诺帮你赚钱只承诺把「下单—撮合—成交—持仓—盈亏」这条链路用代码讲清楚。下面按「先立模型、再搭撮合、后接行情、最后排坑」的顺序推下去每一步都能落地复现。2. 先把领域模型立住账户、订单、持仓三张表怎么设计写交易模拟系统翻车最多的地方不是算法是模型没设计好写到一半发现持仓成本算不对、订单状态对不上。所以第一步不是敲代码是把账户、订单、持仓这三个核心对象的关系想清楚。2.1 为什么先定订单状态机而不是先写撮合订单是有生命周期的新建、已报、部分成交、全部成交、已撤、废单。如果一开始不把状态机定死后面撮合逻辑里到处是 if-else改一处崩三处。常见做法是用枚举加状态转移校验非法转移直接抛异常让 bug 在测试阶段就暴露而不是等到对账时才发现。public enum OrderStatus { New, // 刚创建未提交 Submitted, // 已提交到撮合 PartiallyFilled, // 部分成交 Filled, // 全部成交 Cancelled, // 已撤单 Rejected // 废单资金/持仓不足等 } public enum OrderSide { Buy, Sell } public class Order { public string OrderId { get; init; } Guid.NewGuid().ToString(N); public string Symbol { get; init; } string.Empty; public OrderSide Side { get; init; } public decimal Price { get; set; } // 限价 public int Quantity { get; init; } // 委托总量 public int FilledQty { get; set; } // 已成交 public OrderStatus Status { get; set; } OrderStatus.New; public DateTime CreatedAt { get; init; } DateTime.UtcNow; public int Remaining Quantity - FilledQty; // 状态转移白名单非法转移直接抛别让它悄悄过去 private static readonly DictionaryOrderStatus, OrderStatus[] Allowed new() { [OrderStatus.New] new[] { OrderStatus.Submitted, OrderStatus.Rejected }, [OrderStatus.Submitted] new[] { OrderStatus.PartiallyFilled, OrderStatus.Filled, OrderStatus.Cancelled }, [OrderStatus.PartiallyFilled] new[] { OrderStatus.Filled, OrderStatus.Cancelled }, }; public void TransitionTo(OrderStatus next) { if (!Allowed.TryGetValue(Status, out var targets) || !targets.Contains(next)) throw new InvalidOperationException($非法状态转移: {Status} - {next}); Status next; } }这段代码的关键点有三个。第一Remaining用计算属性而不是单独字段避免成交量和剩余量不同步这种经典 bug。第二状态转移用白名单字典新增状态时只改一处。第三OrderId用 Guid模拟环境不需要考虑交易所的订单号规则简单可靠。参数上要注意Price和Quantity用decimal和int。金额千万别用double浮点误差在累计成交额上会滚成雪球这是血泪经验。数量用整数股符合 A 股一手 100 股的现实约束如果你想模拟美股碎股把int换成decimal即可。2.2 持仓成本用移动加权别用简单平均持仓表最容易写错的是成本价。有人图省事用「所有买入价简单平均」一遇到分批建仓、部分卖出就全乱。正确做法是移动加权平均每次买入更新成本卖出时成本价不变只减数量。public class Position { public string Symbol { get; init; } string.Empty; public int Quantity { get; private set; } public decimal AvgCost { get; private set; } // 移动加权成本 public decimal RealizedPnl { get; private set; } // 已实现盈亏 // 买入更新加权成本 public void ApplyBuy(int qty, decimal price) { if (qty 0) throw new ArgumentException(买入数量必须为正); var newQty Quantity qty; AvgCost (AvgCost * Quantity price * qty) / newQty; Quantity newQty; } // 卖出成本价不动结算已实现盈亏 public void ApplySell(int qty, decimal price) { if (qty 0 || qty Quantity) throw new InvalidOperationException(卖出数量非法或持仓不足); RealizedPnl (price - AvgCost) * qty; Quantity - qty; if (Quantity 0) AvgCost 0m; // 清仓后成本归零避免残留脏数据 } }逻辑说明买入时用「原市值 新买入市值」除以「新总量」得到新成本这是移动加权的标准公式。卖出时成本价保持不变只把价差计入已实现盈亏这样持仓成本才符合券商对账单的口径。参数上RealizedPnl用decimal累加清仓时把AvgCost归零否则下次再买入会带着旧成本算这是很多人踩过的坑。2.3 账户层做资金校验把废单挡在撮合之前账户负责冻结资金和持仓。下单时先冻结成交时扣减撤单时解冻。这一步做扎实撮合层就不用反复校验余额职责清晰。public class Account { public decimal Cash { get; private set; } // 可用资金 public decimal FrozenCash { get; private set; } // 冻结资金 private readonly Dictionarystring, Position _positions new(); public Account(decimal initialCash) Cash initialCash; public Position GetPosition(string symbol) _positions.TryGetValue(symbol, out var p) ? p : (_positions[symbol] new Position { Symbol symbol }); // 买入冻结按委托价 * 数量冻结含手续费可另加 public bool TryFreezeForBuy(decimal price, int qty) { var need price * qty; if (Cash need) return false; Cash - need; FrozenCash need; return true; } // 成交扣款实际成交价可能优于委托价差额退回 public void SettleBuy(decimal dealPrice, int qty, decimal frozenPrice) { var frozen frozenPrice * qty; var actual dealPrice * qty; FrozenCash - frozen; Cash frozen - actual; // 价差退回 GetPosition(/*symbol*/).ApplyBuy(qty, dealPrice); } }这里有个细节值得说限价单成交价可能低于委托价冻结时按委托价冻成交后要把差额退回可用资金否则你的账户资金会莫名其妙变少。SettleBuy里frozen - actual就是退回的价差。参数上手续费可以在这里扣也可以单独一个FeeCalculator建议后者方便按不同市场切换费率。3. 撮合引擎怎么写价格优先、时间优先的落地实现模型立住之后核心就是撮合。撮合的本质是维护买卖两个有序队列新订单进来就和对手盘最优价撮撮不动就挂进队列。听起来简单坑都在数据结构和边界条件上。3.1 用 SortedDictionary 维护盘口别用 List 排序新手常犯的错是用ListOrder存订单每次撮合前Sort一遍。订单量一大性能直接崩。正确做法是用SortedDictionarydecimal, QueueOrder键是价格值是同价位订单队列天然满足价格优先队列满足时间优先。public class OrderBook { public string Symbol { get; init; } string.Empty; // 买单价格从高到低用 Reverse 比较器 private readonly SortedDictionarydecimal, QueueOrder _bids new(Comparerdecimal.Create((a, b) b.CompareTo(a))); // 卖单价格从低到高 private readonly SortedDictionarydecimal, QueueOrder _asks new(); public void Add(Order order) { var book order.Side OrderSide.Buy ? _bids : _asks; if (!book.TryGetValue(order.Price, out var queue)) book[order.Price] queue new QueueOrder(); queue.Enqueue(order); } public (decimal price, QueueOrder queue)? BestBid _bids.Count 0 ? (_bids.First().Key, _bids.First().Value) : null; public (decimal price, QueueOrder queue)? BestAsk _asks.Count 0 ? (_asks.First().Key, _asks.First().Value) : null; }逻辑说明买单用降序比较器First()拿到的就是最高买价卖单用默认升序First()是最低卖价。同价位用Queue保证先到先撮。参数上decimal作为字典键没问题但要注意价格精度统一建议所有价格在入口处Math.Round(price, 2)否则 10.10 和 10.1000001 会被当成两个价位盘口就散了。3.2 撮合主循环一次撮到底还是撮一笔撮合循环有两种写法新订单进来后循环和对手盘撮直到不能撮为止或者每次只撮一笔由外部驱动。前者适合回测一次调用完成后者适合实时模拟能插入行情更新。我一般用前者逻辑集中好调试。public class MatchingEngine { private readonly OrderBook _book; private readonly Account _account; private readonly ListTrade _trades new(); public MatchingEngine(OrderBook book, Account account) { _book book; _account account; } public IReadOnlyListTrade Submit(Order order) { var newTrades new ListTrade(); order.TransitionTo(OrderStatus.Submitted); while (order.Remaining 0) { var opp order.Side OrderSide.Buy ? _book.BestAsk : _book.BestBid; if (opp is null) break; var (oppPrice, oppQueue) opp.Value; // 价格不交叉停止撮合 if (order.Side OrderSide.Buy order.Price oppPrice) break; if (order.Side OrderSide.Sell order.Price oppPrice) break; var counter oppQueue.Peek(); var dealQty Math.Min(order.Remaining, counter.Remaining); var dealPrice counter.Price; // 被动方价格成交符合价格优先 ExecuteTrade(order, counter, dealPrice, dealQty); newTrades.Add(_trades[^1]); if (counter.Remaining 0) { oppQueue.Dequeue(); counter.TransitionTo(OrderStatus.Filled); if (oppQueue.Count 0) (order.Side OrderSide.Buy ? _book.RemoveAsk(oppPrice) : _book.RemoveBid(oppPrice)); } } if (order.Remaining 0) { order.TransitionTo(order.FilledQty 0 ? OrderStatus.PartiallyFilled : OrderStatus.Submitted); _book.Add(order); // 挂进盘口 } else { order.TransitionTo(OrderStatus.Filled); } return newTrades; } private void ExecuteTrade(Order taker, Order maker, decimal price, int qty) { taker.FilledQty qty; maker.FilledQty qty; // 账户结算taker 和 maker 方向相反 _trades.Add(new Trade(taker.Symbol, price, qty, taker.OrderId, maker.OrderId, DateTime.UtcNow)); } }逻辑说明成交价取被动方挂在盘口那方的价格这是交易所的通行规则保证挂单方不吃亏。dealQty取双方剩余量的较小值处理部分成交。撮完后如果新订单还有剩余就挂进盘口状态置为部分成交或已报。参数上要注意ExecuteTrade里账户结算要区分买卖方向上面为了聚焦撮合省略了实际要调用Account的SettleBuy/SettleSell。3.3 撤单和废单把边界条件写全撤单要处理三种情况订单还在盘口、订单已部分成交、订单已全部成交。已成交的不能撤部分成交的撤剩余部分。废单在下单时校验资金或持仓不足直接拒绝不进撮合。public bool Cancel(string orderId) { // 从盘口找订单找到则移除并解冻 foreach (var book in new[] { _book.Bids, _book.Asks }) { foreach (var (price, queue) in book) { var target queue.FirstOrDefault(o o.OrderId orderId); if (target is null) continue; if (target.Status OrderStatus.Filled) return false; // 已成交不可撤 target.TransitionTo(OrderStatus.Cancelled); // 解冻剩余资金/持仓这里按方向调用 Account 对应方法 return true; } } return false; }撤单的坑在于部分成交的订单冻结资金要按剩余量解冻不是按原始委托量。上面代码省略了解冻细节实际要传target.Remaining给账户层。另外撤单后订单从队列移除如果该价位队列空了记得把整个价位从SortedDictionary删掉否则BestBid会拿到空队列撮合时Peek()直接抛异常。4. 行情接入与回测驱动让模拟系统跑起来撮合引擎是心脏行情是血液。没有行情驱动撮合引擎就是一堆不会动的代码。这一章讲怎么把行情喂进去以及怎么用历史数据做回测。4.1 行情数据结构与推送节奏行情最小字段是时间戳、代码、最新价、买卖盘口。模拟系统不需要 Level-2 那么细但至少要有一档盘口否则撮合没有对手价。常见做法是定义一个Tick结构用事件或队列推给撮合引擎。public readonly record struct Tick( string Symbol, DateTime Timestamp, decimal LastPrice, decimal BidPrice, int BidSize, decimal AskPrice, int AskSize); // 行情源抽象实盘和回测都实现这个接口 public interface IMarketDataSource { event ActionTick OnTick; void Start(); void Stop(); } // 回测用历史数据驱动按时间顺序回放 public class HistoricalDataSource : IMarketDataSource { private readonly IEnumerableTick _ticks; public event ActionTick? OnTick; public HistoricalDataSource(IEnumerableTick ticks) _ticks ticks; public void Start() { foreach (var tick in _ticks.OrderBy(t t.Timestamp)) OnTick?.Invoke(tick); // 回测同步回放简单可控 } public void Stop() { } }逻辑说明Tick用record struct避免频繁分配回测百万级 tick 时 GC 压力小很多。IMarketDataSource抽象让实盘和回测共用撮合逻辑切换数据源即可。参数上回测用同步回放简单但慢如果要模拟真实延迟可以改成定时器驱动但会引入不确定性回测结果不可复现我一般不用。4.2 用行情驱动撮合限价单挂盘口市价单吃对手有了行情撮合逻辑要调整限价单如果价格没交叉挂在盘口等行情来市价单直接吃对手盘。行情更新时要检查新价格是否触发盘口挂单成交。public class TradingSimulator { private readonly MatchingEngine _engine; private readonly IMarketDataSource _source; public TradingSimulator(MatchingEngine engine, IMarketDataSource source) { _engine engine; _source source; _source.OnTick HandleTick; } private void HandleTick(Tick tick) { // 行情更新后检查盘口挂单是否可撮合 // 简化处理把行情盘口作为虚拟对手单注入撮合 _engine.OnMarketTick(tick); } public void PlaceLimitOrder(string symbol, OrderSide side, decimal price, int qty) { var order new Order { Symbol symbol, Side side, Price price, Quantity qty }; _engine.Submit(order); } public void PlaceMarketOrder(string symbol, OrderSide side, int qty) { // 市价单取对手盘最优价吃不到就废单 var opp side OrderSide.Buy ? _engine.BestAsk : _engine.BestBid; if (opp is null) { /* 废单处理 */ return; } var order new Order { Symbol symbol, Side side, Price opp.Value, Quantity qty }; _engine.Submit(order); } }逻辑说明HandleTick里把行情盘口注入撮合引擎让挂单有机会成交。市价单直接取对手最优价简单直接。参数上要注意市价单的滑点模拟真实市场市价单会吃穿多个价位模拟系统可以按盘口深度逐档成交也可以简化为单一价格看你的精度要求。4.3 回测指标别只看总收益回测跑完很多人只看总收益率这是不够的。至少要算最大回撤、夏普比率、胜率、盈亏比。这些指标能告诉你策略是运气还是真有 edge。指标计算方式关注点总收益率(期末权益 - 期初权益) / 期初权益直观但易被单笔大赚误导最大回撤权益曲线峰值到谷底的最大跌幅决定你能不能扛住夏普比率超额收益 / 收益标准差衡量风险调整后收益胜率盈利交易数 / 总交易数配合盈亏比看盈亏比平均盈利 / 平均亏损胜率低但盈亏比高也能赚这些指标在回测结束时从成交记录和权益曲线算出来。权益曲线要每个 tick 或每笔成交后记录一次否则回撤算不准。参数上夏普比率一般用日频收益年化模拟系统如果 tick 级回测要先聚合成日频再算。5. 避坑与排查那些让模拟系统失真的细节这一章是我踩过的坑合集每条都按「现象 → 原因 → 解决」写希望能帮你少走弯路。5.1 现象回测收益高得离谱实盘逻辑一跑就亏原因回测用了未来数据。最常见的是用当前 tick 的收盘价决定下单又用同一个 tick 的价格成交等于开了上帝视角。另一个隐蔽来源是行情数据本身带未来信息比如用了复权后的价格却没对齐时间。解决严格区分「决策时点」和「成交时点」。决策只能用当前 tick 及之前的数据成交用下一个 tick 的价格。代码里把HandleTick拆成OnDecision和OnFill两个阶段中间不允许访问未来数据。回测框架最好加个断言检测是否访问了超出当前时间的 tick。5.2 现象持仓成本越算越偏对不上手工账原因卖出时错误地更新了成本价或者清仓后没把成本归零。还有人用double存价格累计误差导致成本价小数点后一堆 9。解决卖出时成本价不动只结算已实现盈亏清仓时AvgCost归零。金额一律用decimal。每次成交后可以加一个自检持仓数量 * 成本价 已实现盈亏应该等于累计买入金额减累计卖出金额不等就抛异常早发现早修。5.3 现象撮合时Peek()抛 InvalidOperationException原因SortedDictionary里某个价位的队列已经空了但价位键没删。BestBid拿到空队列Peek()直接炸。解决队列Dequeue后立即判断Count 0是则从字典移除该价位。封装一个RemovePriceLevel方法统一处理别在多个地方手写删除逻辑。另外BestBid/BestAsk返回前也校验一下队列非空双保险。5.4 现象并发下单时账户资金出现负数原因多线程同时调用TryFreezeForBuy两个线程都读到足够的Cash都通过校验结果冻结超额。这是典型的 check-then-act 竞态。解决账户操作加锁用lock或Interlocked。简单做法是给Account的每个资金操作方法加lock (_syncRoot)。如果追求性能可以用Interlocked.CompareExchange做无锁更新但复杂度高模拟系统用锁足够。注意锁的粒度别把整个撮合循环锁住只锁账户操作。5.5 现象回测跑得越来越慢内存一直涨原因成交记录、tick 数据全存在List里不释放百万级 tick 直接把内存吃满。或者事件订阅没取消数据源停了但回调还在。解决回测数据用流式读取别一次性ToList()。成交记录如果只是统计用边算边聚合不保留原始明细。事件订阅在Stop()时取消IDisposable实现好。如果确实要保留明细用Channel或分批落盘别全堆内存里。6. 进阶把模拟系统做成可复用的策略验证框架走到这里一个能跑通「下单—撮合—成交—持仓—盈亏」的模拟系统已经成型。但如果你想让它的价值再上一层就得把它从「一次性脚本」变成「可复用框架」。我自己的习惯是抽三层数据层、撮合层、策略层层间用接口通信策略只依赖接口不依赖实现。具体做法是定义一个IStrategy接口只暴露OnTick(Tick tick)和OnTrade(Trade trade)两个回调策略内部自己决定什么时候下单。这样换策略不用动撮合代码换数据源不用动策略代码。下面是一个最小策略示例用双均线做多空判断。public interface IStrategy { void OnTick(Tick tick); void OnTrade(Trade trade); } public class DualMovingAverageStrategy : IStrategy { private readonly int _fast, _slow; private readonly Queuedecimal _prices new(); private readonly ITradeGateway _gateway; // 下单网关由框架注入 private bool _isLong; public DualMovingAverageStrategy(int fast, int slow, ITradeGateway gateway) { _fast fast; _slow slow; _gateway gateway; } public void OnTick(Tick tick) { _prices.Enqueue(tick.LastPrice); if (_prices.Count _slow) return; var fastMa _prices.TakeLast(_fast).Average(); var slowMa _prices.TakeLast(_slow).Average(); // 金叉做多死叉平多简单演示用 if (fastMa slowMa !_isLong) { _gateway.Buy(tick.Symbol, tick.AskPrice, 100); _isLong true; } else if (fastMa slowMa _isLong) { _gateway.Sell(tick.Symbol, tick.BidPrice, 100); _isLong false; } } public void OnTrade(Trade trade) { /* 可记录成交用于分析 */ } }逻辑说明策略只依赖ITradeGateway接口下单不关心背后是模拟撮合还是实盘通道。OnTick里维护价格队列算均线金叉买入死叉卖出。参数上_fast和_slow是均线周期常见 5 和 20但别迷信固定值不同标的要调。TakeLast每次遍历队列tick 多了会慢生产级要换成环形缓冲区或增量计算均线。验证框架是否可复用有个简单办法写两个完全不同的策略比如均线和一个简单的突破策略如果切换策略只需要改一行注入代码说明抽象到位了。如果改策略要动撮合或数据层说明耦合还在。最后说个我自己的习惯每次改完撮合逻辑先跑一组固定的历史数据把成交明细和上次对比diff 不为空就查原因。这个「回归对账」的习惯帮我挡掉了无数次隐性 bug。模拟系统最怕的不是跑不通是跑通了但结果是错的你还拿它当依据。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑