简介:C#实现的股票交易模拟系统是一份面向C#学习者与初、中级开发者的完整工程,覆盖股票行情获取、虚拟下单、用户管理与数据库存储等典型业务,适合课程设计或入门实战。压缩包共63个文件、278KB,含21个.cs源码文件、8个.accdb数据库、多个.resx界面资源及.exe可执行程序与.sln工程配置,目录按登录、注册、行情、交易等窗体模块组织,可直接打开运行与对照学习。已有1765人学习下载。通过该项目可掌握HttpClient调用行情接口、ADO.NET存取Access数据库、Windows窗体界面布局以及订单撮合等业务规则实现,尤其能理解登录校验、交易记录与用户信息在数据库中的关联设计。配合类图与解决方案结构,能快速梳理C#分层开发思路,是练习桌面应用开发的实用参考资料。
1. C#股票交易模拟系统:为什么我劝你别急着写界面
很多人一拿到“C#股票交易模拟系统”这个需求,第一反应是先画K线、先做账户表格、先拖一个漂亮界面。结果真正跑起来才发现,盘口撮合、资金冻结、持仓盈亏这些核心逻辑完全没有想象中那么简单。C#股票交易模拟系统的价值,是让你在不接实盘接口、不碰真实资金的前提下,把订单生命周期、撮合规则、清算交收这些金融业务概念,用代码完整落地一遍。它适合刚转金融开发的C#程序员,也适合给量化团队做内部回测或者给学生做教学沙盘。技术选型没那么玄学,关键是先把引擎和数据模型稳住,界面永远是最后一步。
2. 先搭骨架:模拟系统的数据模型与撮合引擎设计
2.1 从真实交易柜台到内存撮合:边界到底划在哪
真实的证券交易链路很长:客户端 -> 券商柜台 -> 交易所撮合 -> 清算 -> 交收。我们做模拟系统,不需要模拟网络传输、席位权限、结算备付金,只需要关心四个东西:订单入口、订单簿、成交回报、账户资金持仓。所以这个系统的边界是:只做交易撮合和账户记账,不做清算交收;行情要么来自本地历史数据回放,要么来自随机行情生成器,不走外部实时源。
我一般会把整个系统分成三层:界面层只负责显示,业务层负责账户和风控,引擎层负责订单簿和撮合。这三层在逻辑上必须严格分层,哪怕你想在控制台里快速验证,也建议先定好接口。C#在这件事上天生有优势:类型安全、集成的decimal类型适合做金额计算、SortedDictionary天然能做价格优先队列、async/await方便后续扩展异步接口。但注意不要一上来就上Actor模型或者分布式架构,只有一套内存撮合引擎时,使用lock再加上单一撮合线程,数据一致性反而最好维护。
2.2 用C#实现限价订单簿与撮合核心逻辑
先别急着写界面,有一张能跑的订单簿比什么都重要。限价订单簿的核心是维护买盘和卖盘两个价格档位集合,买盘按价格从高到低排序,卖盘从低到高排序。在C#中,SortedDictionary<decimal, PriceLevel>是最直接的载体,买盘传入一个反转比较器。
public sealed class OrderBook { // 买盘:价格降序,所以需要自定义比较器 private readonly SortedDictionary<decimal, PriceLevel> _bids = new SortedDictionary<decimal, PriceLevel>( Comparer<decimal>.Create((x, y) => y.CompareTo(x))); // 卖盘:价格升序,默认即可 private readonly SortedDictionary<decimal, PriceLevel> _asks = new SortedDictionary<decimal, PriceLevel>(); // 这里只展示最核心的逐笔撮合 public bool Match(Order incoming, out List<Trade> trades) { trades = new List<Trade>(); var opposite = incoming.IsBuy ? _asks : _bids; // 对手盘 var ownSide = incoming.IsBuy ? _bids : _asks; // 本方盘 while (incoming.RemainingQuantity > 0 && opposite.Count > 0) { var bestLevel = opposite.First().Value; if (incoming.IsBuy && bestLevel.Price > incoming.Price) break; if (!incoming.IsBuy && bestLevel.Price < incoming.Price) break; int matchQty = Math.Min(incoming.RemainingQuantity, bestLevel.TotalQuantity); // 生成成交记录,这里省略 Trade 的构造细节 trades.Add(new Trade(incoming.OrderId, bestLevel.Orders.Peek().OrderId, incoming.Symbol, bestLevel.Price, matchQty, DateTime.UtcNow)); bestLevel.TotalQuantity -= matchQty; incoming.RemainingQuantity -= matchQty; if (bestLevel.TotalQuantity == 0) opposite.Remove(bestLevel.Price); } // 剩余数量如果大于0,就挂到本方订单簿 if (incoming.RemainingQuantity > 0) AddToLevel(ownSide, incoming); return trades.Count > 0; } }这个撮合流程看似简单,但它已经完成了“价格优先、时间优先”的绝大部分工作:买盘和卖盘的排序由SortedDictionary保证,价格优先自然成立;同一价格档位内用Queue<Order>来维护时间顺序,这一点在PriceLevel里要设计好,虚拟代码里用Orders.Peek()表示取最早那笔订单。matchQty取的是剩余量和档位总量的较小值,这样既能处理部分成交,又不会把一笔订单拆得太碎。需要特别留意的是,SortedDictionary的插入、删除和查询都是O(log n),在大批量回测时比反复遍历List稳定得多。如果以后要支持市价单,可以在Order里加一个OrderType,把市价单的Price设为-1并跳过价格判断分支,同时记录“冲击到第几档”来模拟滑点。
2.3 行情生成器:让K线、盘口和逐笔成交能自洽
撮合引擎需要行情来驱动,但很多人会让“随机行情”和“订单簿”各走各的路,结果K线上涨,盘口却还是老价格,漏洞一目了然。我建议把行情生成器做成一个事件源:每次tick只输出Symbol, Time, Price, Volume,然后由订单簿的做市商模块决定如何改变盘口。最简单的做法是让行情生成器驱动一个“参考价”字段,所有未成交订单的标记价格都跟随参考价,撮合时再按限价单的逻辑判断。
一个确定性足够好的生成器可以写成几何布朗运动,但C#的Random没有现成的高斯函数,很多开发者会直接踩坑用均匀分布,导致行情波动异常集中。我这里给一个“够用但别再升级”的最小版本:
public class TickGenerator { private readonly Random _rng = new Random(); private double _currentPrice; public TickGenerator(double startPrice) { _currentPrice = startPrice; } public double NextTick() { // 实际使用时建议实现经典的 Box-Muller 变换,这里用简单波动演示 double shock = (double)_rng.NextDouble() - 0.5; double drift = 0.0001; double volatility = 0.002; double next = _currentPrice * (1.0 + drift + shock * volatility); _currentPrice = Math.Round(next, 2); return _currentPrice; } }这段代码里,shock的范围是[-0.5, 0.5),乘以volatility后相当于每tick最多±0.1%的波动,适合给模拟盘当玩具行情。如果你想做专业回测,建议直接放弃随机数,改用历史逐笔成交CSV回放,那个逻辑我们放到第6章讲。这里核心要明白的是:行情生成器不能独立于撮合引擎,它的每个tick都应该触发一次“订单簿重估价”,或者至少让风险模块根据最新价检查持仓盈亏。生成器里硬编码0.0001和0.002这种参数,也要放到构造函数中,方便后续调参,而不是散落在代码里。
3. 把系统跑起来:控制台还是GUI?实践一个最小可交易的模拟器
3.1 选型:控制台/WPF/ASP.NET Core 怎么选
很多初学者一上来就问“用WinForms还是WPF”,这其实问错了。模拟器的业务核心是撮合和账户,界面只负责把成交回报画出来。我见过某开发者在WPF里写了大量数据绑定,结果每次撮合触发UI线程卡顿,最后发现罪魁祸首是订单簿查询放在UI线程里,而不是技术上选错了框架。
我的建议是:如果你只做本地回测、策略验证,就用控制台;如果你想给小组做一个带界面的模拟盘Demo,就选WPF,数据展示用ObservableCollection,但撮合引擎必须跑在独立的Task线程或者线程池里;如果你要支撑多个账号并发下单,就优先考虑ASP.NET Core Web API,这样天然适合做多客户端。下表是我的实际选择逻辑:
| 场景 | 推荐框架 | 理由 |
|---|---|---|
| 个人回测、压测 | 控制台 | 启动快,没有UI线程干扰,便于自动化输出 |
| 课堂演示、本地单机 | WPF | 可视化盘口,但要把撮合引擎和UI隔离 |
| 团队多人模拟盘 | ASP.NET Core | 天然支持多客户端,但要引入数据库存储 |
| 极简原型 | 控制台+JSON输出 | 最快验证核心逻辑,后续再包任何壳都不难 |
界面隔离不是口号:业务层只能暴露PlaceOrder、CancelOrder、GetAccountSnapshot这些方法,界面只负责调用。你可以先用控制台把所有方法跑通,再套WPF外壳。这样即使以后换框架,内核一行不用改。
3.2 最小实现:账户、持仓、委托与成交的结构体设计
先设计账户和持仓对象。这里最容易犯的错误是只记录“总现金”和“总持仓”,忽略了“冻结”状态。正确做法是现金拆成TotalCash和FrozenCash,持仓拆成Quantity和FrozenQuantity,一切聚合值都由这两个原始值实时计算出来。
public class Account { public string AccountId { get; set; } public decimal TotalCash { get; set; } // 总资金,含冻结 public decimal FrozenCash { get; set; } // 冻结资金 public Dictionary<string, Position> Positions { get; set; } = new(); public decimal AvailableCash => TotalCash - FrozenCash; } public class Position { public string Symbol { get; set; } public int Quantity { get; set; } // 总持仓 public int FrozenQuantity { get; set; } // 挂单冻结的持仓 public decimal AvgCost { get; set; } // 摊薄成本 public int AvailableQuantity => Quantity - FrozenQuantity; } public class Order { public string OrderId { get; set; } public string Symbol { get; set; } public bool IsBuy { get; set; } public decimal Price { get; set; } // 限价,市价可设为-1 public int Quantity { get; set; } // 原始数量 public int RemainingQuantity { get; set; } // 剩余未成交量 }这里所有价格和金额都用decimal,不用double。int只用来表示股数。Position里的AvgCost用于计算持仓成本,但在极端情况下,比如反复做T,成本算法有很多分支,最稳妥的方式是把每笔成交都追加到成交明细里,AvgCost仅作展示。Order里的RemainingQuantity是撮合时唯一会修改的量,原始Quantity要保留,方便撤单和统计。
3.3 交易循环:从下单到成交回执的完整代码
现在把这些对象串起来,写一个最简交易循环。这一步的目标是让你能在控制台里完成“下单 -> 撮合 -> 成交 -> 更新持仓”的完整流程。
public class TradingSimulator { private readonly OrderBook _orderBook = new OrderBook(); private readonly Account _account = new Account(); // 买入委托的简化入口 public void PlaceBuyOrder(string symbol, decimal price, int quantity) { decimal estimatedAmount = price * quantity; decimal estimatedFee = estimatedAmount * 0.00025m; // 假设万2.5手续费 if (_account.AvailableCash < estimatedAmount + estimatedFee) { Console.WriteLine("资金不足,委托被拒绝"); return; } // 冻结资金:冻结金额 = 估算成交金额 + 估算手续费 _account.FrozenCash += estimatedAmount + estimatedFee; var order = new Order { OrderId = Guid.NewGuid().ToString("N"), Symbol = symbol, IsBuy = true, Price = price, Quantity = quantity, RemainingQuantity = quantity }; if (_orderBook.Match(order, out var trades)) { foreach (var trade in trades) { decimal actualAmount = trade.Price * trade.Quantity; decimal actualFee = actualAmount * 0.00025m; // 已成交部分:从冻结资金中释放并扣除实际金额 _account.FrozenCash -= actualAmount + actualFee; _account.TotalCash -= actualAmount + actualFee; // 更新持仓 if (!_account.Positions.TryGetValue(symbol, out var pos)) { pos = new Position { Symbol = symbol }; _account.Positions[symbol] = pos; } pos.Quantity += trade.Quantity; // 注意:这里没有更新 AvgCost,完整实现需要用加权平均 } } // 剩余未成交部分继续挂单,冻结资金保留 if (order.RemainingQuantity > 0) { Console.WriteLine($"剩余 {order.RemainingQuantity} 股挂单中"); } } }这段代码展示了一个非常常见的流程,但也带着几个故意留下的坑。最大的坑是:如果交易部分成交,FrozenCash只扣减了成交部分对应的金额,剩余挂单的资金还冻着,这是对的。但如果订单被撤单,你需要把对应剩余数量按委托价计算的美金解冻回来。另外,ActualFee按每笔成交重新计算,而预估费率和实际费率可能不一致,导致冻结现金被扣成负数,所以更稳的方式是先按成交价计算资金占用,再统一按实际手续费结算,而不是冻结时用预估费。控制台里跑通这个循环后,你就能看到每次下单后账户资金和持仓的变化,这比任何静态图表都有说服力。
4. 模拟盘与实盘隔着多少坑:资金冻结、手续费与涨跌停规则
4.1 资金冻结与可用余额计算:一个经典的整型陷阱
很多人在第一版模拟器里把“资金”写成double,结果撮合几百笔后,账户资金凭空多出几分钱。原因很简单:double在累加小数值时会产生二进制浮点误差,而金融系统里金额必须精确。这就是经典的“整型陷阱”:方案设计时大家都懂要用decimal,到了写代码的时候,图方便顺手就是double。
正确做法是:任何与“金额”相关的变量,一律decimal;任何“股数”,一律int或long。可用余额公式永远是:
decimal available = account.TotalCash - account.FrozenCash;下单判断时,不能只看available,还要把手续费算进去。否则会出现“资金够买100股,但付不起手续费”的尴尬场景。我踩过一次很惨的坑:当时把手续费在成交后才计算,结果账户里只有1000元,委托了999元的买单,成交后扣了2.5元手续费,总现金直接变负。后来改为“下单时冻结估计金额+估计手续费”,成交后再调整。
4.2 手续费模型和滑点:什么时候该用单精度,什么时候该用decimal
手续费和滑点是模拟盘最影响最终盈亏的两个参数,但不建议一上手就做得太复杂。手续费常见模型是“按成交金额的万分之几,最低5元”。这个“最低5元”必须实现,因为小额交易的实际费率远高于默认费率。下面这段代码是目前很多模拟器的标准实现:
public static decimal CalcFee(decimal tradeAmount, decimal rate, decimal minFee) { decimal rawFee = tradeAmount * rate; return rawFee < minFee ? minFee : rawFee; } // 滑点模拟:市价单在盘口撮合时,假设成交价偏移一定比例 public static decimal ApplySlippage(decimal basePrice, double bps, bool isBuy) { double multiplier = isBuy ? 1.0 + bps / 10000.0 : 1.0 - bps / 10000.0; return decimal.Round(basePrice * (decimal)multiplier, 2); }CalcFee里每个参数都用decimal,如果传入double就会丢失精度。而滑点参数bps属于比率,波动率、夏普比率这类统计量用double计算也没什么问题。所以在这个系统里,decimal和double的边界是:现金、价格、金额用decimal;收益率、波动率、随机因子用double。它们之间转换时,强制decimal要接受double,必须用(decimal)显式转换,避免编译器警告变成隐藏bug。
4.3 涨跌停与停牌:模拟系统最容易忽略的边界条件
如果你只做个股模拟,不设置涨跌停,撮合引擎可能会在一天之内把价格拉到离谱位置。A股市场常见的做法是限制单日涨跌幅。实现起来不复杂,但要在两个地方校验:下单时校验委托价是否在涨跌停范围内,撮合时校验最新价是否因为涨跌停导致无法成交。
public bool ValidateTradable(string symbol, decimal price, decimal prevClose) { // 停牌、退市等情况用单独的状态字段判断 if (!_symbolStatus[symbol].IsNormal) return false; // 涨跌停判断:默认10%,适用于常规股票 decimal upLimit = decimal.Round(prevClose * 1.1m, 2); decimal downLimit = decimal.Round(prevClose * 0.9m, 2); return price >= downLimit && price <= upLimit; }这里有一个关键细节:涨停价和跌停价都要四舍五入到分,不能直接用prevClose * 1.1m取整,否则会和真实市场产生一分钱差异。另外,触发涨停时不代表所有买单都不能成交,只是没有卖单可吃。所以涨跌停的限制应该在订单校验层处理,而不是在撮合层直接禁止挂单。停牌则简单粗暴:所有委托直接拒绝,并推送拒单回报。如果你要模拟T+1,还要在卖出时检查持仓解锁日期,否则模拟盘会成为一个可以无限做T的“世外桃源”,训练出来的策略失真。
5. 避坑避坑:C#模拟交易系统常见的5个血泪问题
5.1 行情刷新用double导致对账不平
现象:系统跑了一段时间后,账户总资产与“现金+持仓市值+冻结资金”对不上,差几分钱。原因:double在累加成千上万笔手续费、成交金额时产生浮点误差,且误差会一直累积。解决:把价格和金额一律换成decimal,手续费计算、冻结资金扣除、持仓成本更新全部基于decimal。压测后看对账表,误差清零。
5.2 SortedDictionary在并发下单时的秩序错乱
现象:多线程调用PlaceOrder后,订单簿里的价格档位偶尔出现重复,或者成交顺序倒挂。原因:SortedDictionary是线性不安全的,多个线程同时修改会破坏内部红黑树结构。解决:最稳妥的方案是所有订单进入一个单线程的撮合队列,外部线程只负责入队。用C#的Channel<T>或BlockingCollection都可以,撮合线程从队列里取订单逐笔撮合。这样既避免了锁竞争,又天然保证了全局顺序。
5.3 撤单后资金消失:冻结余额没有按比例释放
现象:用户挂了一笔大买单,没成交就撤单,结果可用资金没有完全恢复,少了一部分。原因:下单时冻结了“估计成交金额+估计手续费”,但撤单时只按“剩余数量 × 委托价”解冻,没有把预估手续费一并解冻。解决:写一个CancelOrder方法,根据订单剩余数量和委托价计算应解冻金额,同时释放对应持仓的冻结数量。注意手续费预收还是后收,前后端口径要一致,否则会出现“撤单回血”的错觉。
5.4 历史数据回放时跨日数据顺序错乱
现象:回放某只股票的分笔数据,成交顺序与历史顺序不一致,导致撮合结果失真。原因:不同日期的数据按文件顺序读出,但没有统一时间戳排序,或者日期字段用DateOnly导致时间丢失。解决:加载数据时统一转成DateTime,整体按时间排序后再进入撮合,遇到跨日数据还要重置涨跌停参考价。这个阶段用LINQ的OrderBy(t => t.Time)就能解决,但要注意不要对超大文件一次性排序,最好是按天切片加载。
5.5 模拟盘没有最低佣金,回测收益虚高
现象:策略模拟交易很赚钱,但实盘根本不是那么回事。原因:模拟系统只按万分之几的比率计算手续费,忽略了“最低5元”规则。小资金、高频交易时,最低佣金占比极高,直接吃掉利润。解决:在交易引擎里加入minFee参数,并在参数文件中明确标注。如果你的模拟器只做教学演示,可以把最低佣金设0以简化,但要清楚知道这个假设在哪里。
6. 进阶:把模拟器改造成回测引擎的两个关键动作
当你把前面的模拟器跑顺了,下一步自然是想拿历史数据来回测策略。很多人会直接循环读取CSV,然后调PlaceOrder,结果发现回测速度和撮合吞吐量惨不忍睹。这里我分享两个关键动作。
第一个动作是把“行情驱动”从随机生成器切换成“时间戳排序的历史tick数组”。历史逐笔数据必须先按时间全局排序,再按时间推进,每到一个小时间窗就触发策略决策。不要一边读文件一边实时撮合,那样很难定位问题。最小回测循环如下:
var orderedTicks = LoadTicks("history.csv") .OrderBy(t => t.Time) .ToArray(); foreach (var tick in orderedTicks) { market.Update(tick.Price, tick.Volume); strategy.OnTick(tick, account, orderBook); orderBook.SettlePendingOrders(tick.Price); // 处理止损单等 }第二个动作是关注撮合性能。当你把历史数据放大到几十万笔,瓶颈会集中在Order对象的垃圾回收压力和SortedDictionary的节点分配上。最有效的优化是复用Order对象,把已经完成并回报过的订单回收到对象池;或者采用“价格档位聚合 + 队首指针”的结构,避免每个档位频繁创建和销毁。用Stopwatch打点测试,目标至少达到每秒撮合万笔以上,不然策略优化时一次次重新跑全量数据会把你气到摔键盘。
我自己的习惯是:把模拟器当一个长期养着的系统,不是写完就扔。每一版都留出对账脚本、压测脚本和参数文件,这样改一个行情规则或手续费模型时,随时能把旧结果拿出来对比。曾经我也图快跳过历史回放排序,导致两次回测结果对不上,最后花了整晚排查才发现是跨日数据顺序问题。从那以后,任何能自动验证的东西,绝不用肉眼。希望帮到你。
本文还有配套的精品资源,点击获取