C#股票交易模拟系统源码解析:订单撮合与持仓盈亏实现 简介这份资源是一套基于C#实现的股票交易模拟系统源码面向具备C#基础、希望借助完整项目巩固桌面开发与数据库实战的学习者。系统围绕虚拟股票交易场景涵盖行情数据获取与解析、用户信息与交易记录存储、订单撮合与买卖确认等业务逻辑并采用MVC思路分离模型、视图与控制器便于理解真实交易系统的结构。压缩包共63个文件约278KB以21个cs源码文件为核心配合8个resx与9个resources资源文件构建界面8个accdb数据库文件保存用户、行情与交易数据另有csproj、sln等工程配置及exe、pdb运行调试文件可直接在Visual Studio中打开运行。目前已有1765人学习下载。读者可从中获得一套可运行的交易模拟框架参考登录注册、行情管理、用户交易列表等模块的实现方式并借鉴数据库操作、界面设计与业务规则编码的排错思路。1. 从一份 C# 股票交易模拟系统源码说起它到底能跑出什么很多人第一次接触交易系统都是从“下单、撮合、持仓、盈亏”这四个词开始的。但真到动手写才发现难点根本不在界面而在订单状态怎么流转、成交价怎么算、持仓成本怎么更新。这份 C# 实现的股票交易模拟系统核心价值就在于把这条链路完整跑通它不接真实行情也不碰资金通道而是用内存数据结构模拟一个撮合环境让你能反复压测、反复调试不用担心真金白银。适合谁适合想理解交易引擎内部逻辑的后端开发者、想给策略做回放验证的量化新手以及需要一套可改可扩的教学 Demo 的讲师。它解决的是“知道概念但没写过完整链路”的断层而不是教你炒股。2. 拆开引擎订单、撮合、持仓三块怎么咬合2.1 订单对象与状态机设计交易系统的第一道坎是订单状态。市价单、限价单、撤单、部分成交这些状态如果只用几个 bool 变量标记后面必然乱。常见做法是定义一个枚举OrderStatus把Pending、PartiallyFilled、Filled、Cancelled、Rejected全部列进去再用一个Order类承载价格、数量、方向、时间戳。public enum OrderStatus { Pending, // 已提交等待撮合 PartiallyFilled, // 部分成交 Filled, // 全部成交 Cancelled, // 已撤单 Rejected // 被拒绝如价格非法 } public class Order { public string OrderId { get; set; } public string Symbol { get; set; } // 标的代码 public decimal Price { get; set; } // 限价市价单可置 0 public int Quantity { get; set; } // 原始数量 public int FilledQty { get; set; } // 已成交数量 public OrderSide Side { get; set; } // Buy / Sell public OrderStatus Status { get; set; } public DateTime CreatedAt { get; set; } // 剩余可成交量撮合循环里反复用到 public int RemainingQty Quantity - FilledQty; }这里的关键参数是FilledQty和RemainingQty。很多新手会直接用Quantity去减结果部分成交后再次撮合时数量对不上。把剩余量做成只读属性能避免外部误改。另一个容易翻车的地方是Price用decimal而不是double交易金额对精度敏感double的二进制浮点误差在累计成交额时会放大。状态流转必须单向Pending只能到PartiallyFilled或Filled或Cancelled不能从Filled回退。我一般会在Order里加一个TransitionTo方法内部校验合法迁移非法迁移直接抛异常这样调试时能第一时间定位到是谁乱改状态。2.2 撮合循环价格优先、时间优先怎么落地撮合是模拟系统的心脏。真实交易所用订单簿这里简化成两个列表买单列表和卖单列表。每次新订单进来就尝试和对手方列表撮合。价格优先、时间优先的规则用排序加遍历就能实现。public class MatchingEngine { private readonly ListOrder _buyOrders new(); private readonly ListOrder _sellOrders new(); public ListTrade Submit(Order order) { var trades new ListTrade(); if (order.Side OrderSide.Buy) { // 对手方卖单按价格升序、时间升序排 var candidates _sellOrders .Where(o o.Status OrderStatus.Pending || o.Status OrderStatus.PartiallyFilled) .OrderBy(o o.Price) .ThenBy(o o.CreatedAt) .ToList(); foreach (var sell in candidates) { if (order.RemainingQty 0) break; // 限价买单只有卖价 买价才成交 if (order.Price 0 sell.Price order.Price) break; int dealQty Math.Min(order.RemainingQty, sell.RemainingQty); decimal dealPrice sell.Price; // 以被动方价格成交 order.FilledQty dealQty; sell.FilledQty dealQty; UpdateStatus(order); UpdateStatus(sell); trades.Add(new Trade { Symbol order.Symbol, Price dealPrice, Quantity dealQty, BuyOrderId order.OrderId, SellOrderId sell.OrderId, TradedAt DateTime.UtcNow }); } if (order.RemainingQty 0) _buyOrders.Add(order); } // 卖单逻辑对称省略 return trades; } private void UpdateStatus(Order o) { if (o.RemainingQty 0) o.Status OrderStatus.Filled; else if (o.FilledQty 0) o.Status OrderStatus.PartiallyFilled; } }逻辑说明遍历对手方订单时先按价格排序再按时间排序保证“价格优先、时间优先”。成交价取被动方价格这是常见做法避免主动方用离谱限价占便宜。参数上order.Price 0用来区分市价单和限价单市价单不参与价格比较直接吃对手方。dealQty取双方剩余量的较小值保证不会超卖。每次成交后立刻更新双方状态防止同一订单在循环里被重复匹配。失败时看什么如果发现成交数量对不上先检查RemainingQty是否在每次成交后正确递减如果订单状态卡在Pending看UpdateStatus是否被调用。这个循环是单线程的真实场景要加锁但模拟系统里单线程反而更容易复现问题。2.3 持仓与盈亏成本价用移动加权算成交之后持仓模块要更新。持仓成本不能用简单平均因为每次买入价格不同。移动加权平均是常见做法新成本 (原持仓成本 × 原数量 本次成交价 × 本次数量) / (原数量 本次数量)。卖出时成本价不变只减数量盈亏按 (卖出价 - 成本价) × 数量累计。public class Position { public string Symbol { get; set; } public int Quantity { get; set; } public decimal AvgCost { get; set; } public decimal RealizedPnl { get; set; } public void ApplyTrade(Trade trade, OrderSide side) { if (side OrderSide.Buy) { decimal totalCost AvgCost * Quantity trade.Price * trade.Quantity; Quantity trade.Quantity; AvgCost Quantity 0 ? 0 : totalCost / Quantity; } else { // 卖出实现盈亏成本价不变 RealizedPnl (trade.Price - AvgCost) * trade.Quantity; Quantity - trade.Quantity; if (Quantity 0) AvgCost 0; // 清仓后重置 } } }参数说明AvgCost是移动加权成本RealizedPnl是已实现盈亏。注意卖出时如果Quantity减到 0要把AvgCost重置为 0否则下次买入时成本会带着历史残留。另一个坑是卖出数量超过持仓模拟系统里应该直接拒绝而不是允许负持仓。我一般会在ApplyTrade前加一个校验卖出数量不能大于当前Quantity否则抛异常。这样能防止策略代码写出“裸卖空”却不自知。3. 把系统跑起来从零搭建到第一笔成交3.1 环境准备与项目结构这套源码是 C# 写的跑起来需要 .NET SDK。常见做法是装 .NET 6 或 .NET 8 的 LTS 版本用dotnet --version确认。项目结构一般分三层Models放订单、成交、持仓Engine放撮合引擎Simulation放主循环和测试用例。如果你拿到的是单个.cs文件也可以直接dotnet new console建一个控制台项目把文件拖进去。# 创建控制台项目 dotnet new console -n StockSim cd StockSim # 把源码文件按目录放好然后编译 dotnet build # 运行 dotnet run编译报错最常见的是缺少using System.Linq;因为撮合循环里用了Where、OrderBy。另一个是decimal和int混算时的隐式转换C# 不会自动把int转decimal参与除法需要显式写(decimal)。如果dotnet run后没有任何输出检查主函数里是否真的调用了Submit并打印了trades列表。3.2 用测试用例验证撮合逻辑光跑起来不够得验证撮合结果对不对。我一般会写几个固定场景限价买单价格高于卖单应该立即成交限价买单价格低于卖单应该挂起部分成交后撤单剩余数量要正确。下面是一个最小验证用例。var engine new MatchingEngine(); // 场景1卖单挂 10.00买单出 10.50应成交 var sell new Order { OrderId S1, Symbol SIM, Price 10.00m, Quantity 100, Side OrderSide.Sell, Status OrderStatus.Pending, CreatedAt DateTime.UtcNow }; engine.Submit(sell); var buy new Order { OrderId B1, Symbol SIM, Price 10.50m, Quantity 100, Side OrderSide.Buy, Status OrderStatus.Pending, CreatedAt DateTime.UtcNow }; var trades engine.Submit(buy); Console.WriteLine($成交笔数: {trades.Count}); // 期望 1 Console.WriteLine($成交价: {trades[0].Price}); // 期望 10.00 Console.WriteLine($买单状态: {buy.Status}); // 期望 Filled Console.WriteLine($卖单状态: {sell.Status}); // 期望 Filled逻辑说明卖单先挂买单后到买单价格高于卖单触发成交。成交价取被动方卖单的 10.00而不是买单的 10.50。如果输出成交笔数为 0检查Submit里对手方列表是否真的把卖单加进去了。如果成交价是 10.50说明成交价取错了方向。这个用例能覆盖最核心的撮合路径建议每次改完引擎都跑一遍。3.3 模拟行情推送与批量下单单笔测试过了下一步是模拟连续行情。常见做法是写一个循环随机生成买卖订单按时间间隔提交观察持仓和盈亏变化。这里要注意随机生成的订单价格不能完全随机否则买卖价差太大永远不成交。我一般会让买价在基准价上下浮动 0.5%卖价在基准价上下浮动 0.5%这样有较大概率撮合。var rand new Random(42); // 固定种子方便复现 decimal basePrice 100.00m; var position new Position { Symbol SIM }; for (int i 0; i 200; i) { bool isBuy rand.Next(2) 0; decimal offset (decimal)(rand.NextDouble() * 0.01 - 0.005); // ±0.5% var order new Order { OrderId $O{i}, Symbol SIM, Price Math.Round(basePrice * (1 offset), 2), Quantity rand.Next(1, 10) * 100, Side isBuy ? OrderSide.Buy : OrderSide.Sell, Status OrderStatus.Pending, CreatedAt DateTime.UtcNow.AddMilliseconds(i) }; var trades engine.Submit(order); foreach (var t in trades) { position.ApplyTrade(t, isBuy ? OrderSide.Buy : OrderSide.Sell); } } Console.WriteLine($持仓: {position.Quantity}, 成本: {position.AvgCost:F2}, 已实现盈亏: {position.RealizedPnl:F2});参数说明Random(42)固定种子保证每次运行结果一致方便对比修改前后的差异。offset控制在 ±0.5%避免价格偏离太大导致不成交。CreatedAt用AddMilliseconds(i)保证时间顺序否则时间优先排序会乱。跑完之后看持仓数量和成本价是否合理如果持仓为负说明卖出校验没加。这个循环能帮你快速发现状态机漏洞。4. 避坑与排查那些让模拟系统“看起来能跑”的暗坑4.1 成交价取错方向盈亏全反现象回测结果显示策略稳定盈利但手动核对几笔交易发现买卖方向对应的盈亏是反的。原因撮合时成交价取了主动方价格而不是被动方。比如买单出 10.50 去吃 10.00 的卖单成交价记成 10.50导致买入成本虚高后续卖出时反而“盈利”。解决成交价统一取被动方挂单价并在Trade对象里记录BuyOrderId和SellOrderId方便追溯。4.2 部分成交后状态没更新订单卡死现象一笔订单成交了一半剩余数量挂在订单簿里但后续新订单无法与之撮合。原因UpdateStatus只在全部成交时调用部分成交后状态仍是Pending而撮合循环里过滤条件可能只认Pending或PartiallyFilled如果状态没更新过滤逻辑就漏了。解决每次成交后立即调用UpdateStatus确保PartiallyFilled状态被正确设置。同时检查撮合循环的过滤条件是否包含PartiallyFilled。4.3 持仓成本在清仓后没重置现象第一次买入 100 股成本 10.00全部卖出后再次买入 100 股成本却显示 10.00 而不是新成交价。原因清仓时Quantity归零但AvgCost没有重置下次买入时移动加权公式里AvgCost * Quantity这一项虽然数量为 0但AvgCost本身还留着旧值如果代码里先加数量再算成本就会把旧成本带进来。解决在ApplyTrade里判断Quantity 0时强制AvgCost 0并且买入时先算总成本再更新数量。4.4 时间戳精度不够时间优先排序失效现象同一毫秒内提交的多笔订单排序结果每次运行都不一样。原因DateTime.UtcNow精度在 Windows 上约 15ms同一批次订单时间戳相同ThenBy(o o.CreatedAt)无法区分先后。解决在订单对象里加一个自增序号Sequence排序时先按价格再按Sequence而不是依赖时间戳。或者用DateTime.UtcNow.Ticks配合Interlocked.Increment生成唯一序号。4.5 浮点误差累积对账对不上现象跑了几千笔交易后持仓数量乘以成本价与累计投入资金差了几毛钱。原因decimal虽然精度高但如果中间步骤用了double做除法再转回decimal误差会引入。解决全链路统一用decimal除法用decimal.Divide或直接/避免和double混用。对账时用Math.Round保留两位小数再比较不要直接比。5. 进阶把模拟系统改成策略验证台跑通基础撮合后这套系统最大的价值是当策略验证台。我一般会加一个Strategy抽象类定义OnTick方法把行情快照传进去策略返回订单列表。这样均线、网格、突破策略都能插进来跑。关键技巧是行情快照要包含最新成交价和盘口一档策略才能判断买卖条件。public abstract class Strategy { public abstract ListOrder OnTick(MarketSnapshot snapshot, Position position); } public class MarketSnapshot { public string Symbol { get; set; } public decimal LastPrice { get; set; } public decimal BestBid { get; set; } public decimal BestAsk { get; set; } public DateTime Timestamp { get; set; } } // 简单均线策略示例 public class MaStrategy : Strategy { private readonly Queuedecimal _prices new(); private const int ShortWindow 5; private const int LongWindow 20; public override ListOrder OnTick(MarketSnapshot snapshot, Position position) { _prices.Enqueue(snapshot.LastPrice); if (_prices.Count LongWindow) _prices.Dequeue(); if (_prices.Count LongWindow) return new ListOrder(); decimal shortMa _prices.TakeLast(ShortWindow).Average(); decimal longMa _prices.Average(); var orders new ListOrder(); if (shortMa longMa position.Quantity 0) { orders.Add(new Order { OrderId Guid.NewGuid().ToString(N), Symbol snapshot.Symbol, Price snapshot.BestAsk, // 用卖一价买入 Quantity 100, Side OrderSide.Buy, Status OrderStatus.Pending, CreatedAt snapshot.Timestamp }); } else if (shortMa longMa position.Quantity 0) { orders.Add(new Order { OrderId Guid.NewGuid().ToString(N), Symbol snapshot.Symbol, Price snapshot.BestBid, // 用买一价卖出 Quantity position.Quantity, Side OrderSide.Sell, Status OrderStatus.Pending, CreatedAt snapshot.Timestamp }); } return orders; } }逻辑说明_prices队列维护最近 20 个价格短均线取最后 5 个长均线取全部 20 个。金叉且空仓时买入死叉且持仓时卖出。参数上ShortWindow和LongWindow可以调但注意短窗口不能大于长窗口。买入价用BestAsk卖出价用BestBid模拟真实成交。这个策略跑在模拟系统里能快速验证逻辑但别直接拿去实盘因为没考虑滑点和手续费。验证方法跑完一轮后把每笔成交和持仓变化打印成 CSV用 Excel 或 Python 画资金曲线。如果资金曲线在某个时间点突然跳变检查那附近是否有订单状态异常。我习惯在每次修改策略后先用固定种子跑 1000 笔对比修改前后的成交笔数和最终盈亏差异超过 5% 就说明改动影响了核心逻辑。从那以后我每次改完撮合或持仓代码都强制走一遍固定种子的批量测试确认成交笔数、持仓数量、已实现盈亏三个指标和基线一致再去看策略表现。希望帮到你。本文还有配套的精品资源点击获取