简介:这是一套基于C#开发的股票交易模拟系统实战项目,面向C#初学者及.NET平台进阶学习者,帮助其综合掌握Windows桌面应用开发、数据库交互、实时数据处理与MVC架构设计等核心能力。资源包含63个文件,涵盖21个C#源码文件(如LoginForm.cs、MarketForm.cs、UserInfoForm.cs等)、8个Access数据库文件(.accdb,用于存储用户信息、交易记录与市场数据)、8个资源文件(.resx)及UI设计文件(.Designer.cs),另有可执行程序(.exe)、项目配置(.csproj/.sln)和类图(.cd)等,完整呈现从界面构建、业务逻辑到数据持久化的全流程实现,压缩包仅278KB,轻量易学。已有1766人学习下载,提供开箱即用的可视化交易环境,支持虚拟开户、行情查看、委托下单、成交确认与交易历史查询等功能,并内置SQLite/SQL Server兼容的数据访问层与模块化分层结构,便于理解订单撮合逻辑、UI与数据模型解耦实践及安全加密等工程细节。
1. C#股票交易模拟系统:不是玩具,是能跑通完整下单-成交-风控链路的实战组合沙盒
你有没有试过写了个策略逻辑,却卡在“怎么把订单发出去”这一步?或者调用真实接口时被风控拦截、资金冻结、成交延迟搞到怀疑人生?这个C#实现的股票交易模拟系统,就是专治这类焦虑的——它不依赖任何外部行情或柜台服务,本地启动后自带行情生成器、订单撮合引擎、账户资金/持仓双账本、实时盯盘预警模块,甚至支持按A股T+1规则做交收结算。我拿它给某高校金融工程课做实训项目时,学生第三天就能跑通“均线金叉买入→触发止盈单→自动平仓→生成交易报告”的闭环。它适合三类人:想验证量化策略逻辑但不敢碰真金白银的新手;需要快速搭建可演示、可调试、可单元测试的交易流程原型的开发者;以及正在为实盘系统做压力预演、异常路径覆盖的中台工程师。核心价值不在“像不像”,而在“能不能稳稳走完每一笔委托的生命周期”。
2. 环境准备与一键运行:从解压到看到K线图只要3分钟
2.1 开发环境确认:.NET 6 SDK 是硬门槛,别踩低版本坑
这个项目基于 .NET 6 构建,不是 .NET Framework,也不是 .NET Core 3.1。如果你本地装的是 Visual Studio 2019(默认带 .NET 5)或更早版本,直接双击.sln文件会报错:“The project file cannot be opened because the required version of the .NET SDK is not installed.”。必须安装 .NET 6 SDK(Runtime 可选,但 SDK 必须)。下载地址是官方dotnet.microsoft.com/download/dotnet/6.0,选 “SDK” 版本(Windows x64 Installer)。装完后在命令行执行:
dotnet --list-sdks你应该看到类似输出:
6.0.401 [C:\Program Files\dotnet\sdk]提示:不要试图用
dotnet publish -r win-x64打包成独立exe再运行——项目里用了System.Drawing.Common做K线渲染,该组件在 Windows 以外平台需额外配置,而本项目默认只适配 Windows 桌面环境。强行跨平台会触发PlatformNotSupportedException。
2.2 项目结构速览:五个核心工程,各司其职不越界
解压后你会看到一个标准的 Visual Studio 解决方案目录,共包含 5 个工程(.csproj):
| 工程名 | 类型 | 职责说明 |
|---|---|---|
StockSim.Core | Class Library | 核心领域模型:Order,Trade,Position,Account,MarketData;所有业务规则(如T+1交收、涨跌停限制、最小变动单位)都在这里定义和校验 |
StockSim.Engine | Class Library | 交易中枢:含OrderBook(限价订单簿)、MatchingEngine(价格优先+时间优先撮合)、MarketSimulator(生成带噪声的模拟行情) |
StockSim.UI | Windows Forms App | 主界面:K线图(使用 ScottPlot 渲染)、委托列表、成交明细、资金/持仓面板、策略脚本加载区 |
StockSim.Strategy | Class Library | 策略基类与示例:BaseStrategy抽象类定义OnBarUpdate()、OnOrderFilled()等钩子;附带MovingAverageCrossStrategy示例 |
StockSim.Test | xUnit Test Project | 关键路径全覆盖:订单提交校验、撮合结果断言、T+1交收逻辑验证、极端行情下风控触发 |
注意:没有数据库工程。所有状态都驻留在内存中(
ConcurrentDictionary+ConcurrentBag),重启即清空。这不是缺陷,而是设计选择——你要测策略逻辑,不是测ORM性能。
2.3 启动与首屏验证:三步确认系统活了
- 用 Visual Studio 2022(推荐)或 VS Code + C# Dev Kit 打开
StockSim.sln - 设
StockSim.UI为启动项目(右键 → Set as Startup Project) - 按
Ctrl+F5运行(不调试)
首次运行会弹出初始化窗口,点击【生成模拟行情】按钮(默认生成 1000 条 1分钟K线,代码在MarketSimulator.GenerateMockData()中可调)。几秒后主界面出现,你会看到:
- 左上角 K线图开始滚动(X轴时间,Y轴价格,红绿柱体)
- “委托列表”面板为空(正常,还没下单)
- “成交明细”面板为空(同上)
- “资金/持仓”显示初始资金
1,000,000.00元,可用资金相同,持仓为 0
此时系统已就绪。你可以手动点【买入】按钮下单,或下一步加载策略自动交易。
3. 核心模块拆解:订单从提交到成交的完整链路
3.1 订单提交入口:OrderService.SubmitOrder()是唯一出口
所有下单动作最终都会走到StockSim.Core.Services.OrderService.SubmitOrder()方法。它不是简单转发,而是承担第一道风控闸门。我们来看关键校验逻辑:
public Result<Order> SubmitOrder(Order order) { // 1. 基础字段非空校验 if (string.IsNullOrWhiteSpace(order.Symbol)) return Result<Order>.Failure("股票代码不能为空"); // 2. 价格有效性:必须是0.01的整数倍(A股最小变动单位) if (Math.Round(order.Price * 100) % 1 != 0) return Result<Order>.Failure($"价格 {order.Price} 不符合最小变动单位0.01元要求"); // 3. 数量有效性:必须是100的整数倍(A股一手=100股) if (order.Quantity % 100 != 0) return Result<Order>.Failure($"数量 {order.Quantity} 不符合A股一手100股要求"); // 4. 资金/持仓校验(T+0买入,T+1卖出) var account = _accountService.GetAccount(); if (order.Side == OrderSide.Buy) { var requiredCash = order.Price * order.Quantity; if (account.AvailableCash < requiredCash) return Result<Order>.Failure($"可用资金不足,需 {requiredCash:F2} 元,当前仅 {account.AvailableCash:F2} 元"); } else // Sell { var position = account.Positions.FirstOrDefault(p => p.Symbol == order.Symbol); if (position == null || position.Quantity < order.Quantity) return Result<Order>.Failure($"持仓不足,需 {order.Quantity} 股,当前仅 {position?.Quantity ?? 0} 股"); } // 5. 涨跌停校验(基于最新行情快照) var latestBar = _marketDataService.GetLatestBar(order.Symbol); var upperLimit = Math.Round(latestBar.Close * 1.1, 2); // 涨停价 var lowerLimit = Math.Round(latestBar.Close * 0.9, 2); // 跌停价 if (order.Side == OrderSide.Buy && order.Price > upperLimit) return Result<Order>.Failure($"买入价 {order.Price} 超过涨停价 {upperLimit}"); if (order.Side == OrderSide.Sell && order.Price < lowerLimit) return Result<Order>.Failure($"卖出价 {order.Price} 低于跌停价 {lowerLimit}"); // 校验通过,生成唯一OrderID并入队 order.Id = Guid.NewGuid().ToString("N"); _orderQueue.Enqueue(order); _logger.LogInformation("订单提交成功: {OrderId}", order.Id); return Result<Order>.Success(order); }这段代码的价值在于:它把业务规则显式编码,而不是藏在配置文件或数据库里。比如“T+1卖出限制”体现在Sell分支对Positions的检查;“价格精度”用Math.Round(x*100)%1判断比字符串截取更可靠;“涨跌停”计算直接复用MarketDataService提供的最新K线,保证实时性。
3.2 订单撮合引擎:MatchingEngine.Match()如何实现价格优先+时间优先
撮合不是魔法,本质是两个有序队列的归并。MatchingEngine维护两个SortedSet<Order>:BuyOrders(按价格降序,同价按时间升序)、SellOrders(按价格升序,同价按时间升序)。Match()方法每毫秒被MarketSimulator调用一次,执行如下逻辑:
public List<Trade> Match() { var trades = new List<Trade>(); var processedBuys = new HashSet<string>(); var processedSells = new HashSet<string>(); // 遍历买单(从最高价开始) foreach (var buy in BuyOrders.ToList()) { if (processedBuys.Contains(buy.Id)) continue; // 找到第一个满足价格条件的卖单(卖价 <= 买价) var sell = SellOrders.FirstOrDefault(s => s.Price <= buy.Price); if (sell == null) continue; // 计算可成交数量:取双方剩余数量最小值 var tradeQty = Math.Min(buy.RemainingQuantity, sell.RemainingQuantity); var tradePrice = sell.Price; // 成交价取卖方报价(更贴近实际) // 生成成交记录 var trade = new Trade { Id = Guid.NewGuid().ToString("N"), Symbol = buy.Symbol, Price = tradePrice, Quantity = tradeQty, BuyOrderId = buy.Id, SellOrderId = sell.Id, Timestamp = DateTime.Now }; trades.Add(trade); // 更新订单剩余数量 buy.RemainingQuantity -= tradeQty; sell.RemainingQuantity -= tradeQty; // 若订单已全部成交,从集合中移除 if (buy.RemainingQuantity == 0) { BuyOrders.Remove(buy); processedBuys.Add(buy.Id); } if (sell.RemainingQuantity == 0) { SellOrders.Remove(sell); processedSells.Add(sell.Id); } } return trades; }关键点解析:
SortedSet<Order>的排序器(IComparer<Order>)决定了“价格优先+时间优先”:买单按Price descending, Time ascending,卖单按Price ascending, Time ascending。- 成交价取卖方报价,这是 A 股连续竞价的惯例(买方主动吃单)。
RemainingQuantity字段是订单状态的核心,它让部分成交、撤单等复杂场景成为可能。ToList()避免遍历时修改集合引发异常,是线程安全的常见做法。
3.3 实时盯盘与策略触发:StrategyRunner如何驱动你的策略
UI 界面底部有个【加载策略】按钮,点它会从StockSim.Strategy工程中反射加载继承自BaseStrategy的类。策略生效后,StrategyRunner会在每个新K线生成时调用其OnBarUpdate()方法:
// 在 StockSim.UI 中,MarketSimulator 每生成一根新K线,就广播事件 private void OnNewBarReceived(Bar bar) { // 通知所有已加载策略 foreach (var strategy in _loadedStrategies) { try { strategy.OnBarUpdate(bar); // 你的策略逻辑在这里执行 } catch (Exception ex) { _logger.LogError(ex, "策略 {StrategyName} 执行异常", strategy.GetType().Name); } } }BaseStrategy定义了标准接口:
public abstract class BaseStrategy { protected IOrderService OrderService { get; } // 注入订单服务 protected IAccountService AccountService { get; } // 注入账户服务 protected IMarketDataService MarketDataService { get; } // 注入行情服务 public abstract void OnBarUpdate(Bar bar); // K线更新时调用 public virtual void OnOrderFilled(Trade trade) { } // 订单成交时回调 public virtual void OnOrderCanceled(string orderId) { } // 订单撤单时回调 }这意味着,你的策略代码可以完全脱离UI,专注逻辑。例如MovingAverageCrossStrategy的核心:
public override void OnBarUpdate(Bar bar) { var symbol = bar.Symbol; var bars = MarketDataService.GetBars(symbol, 200); // 获取200根历史K线 if (bars.Count < 200) return; // 计算MA5和MA20 var ma5 = bars.TakeLast(5).Average(b => b.Close); var ma20 = bars.TakeLast(20).Average(b => b.Close); var position = AccountService.GetAccount().Positions .FirstOrDefault(p => p.Symbol == symbol); if (ma5 > ma20 && (position == null || position.Quantity == 0)) { // 金叉且无持仓,买入 var order = new Order { Symbol = symbol, Side = OrderSide.Buy, Price = bar.Close, // 市价单 Quantity = 100, // 一手 Type = OrderType.Market }; OrderService.SubmitOrder(order); } else if (ma5 < ma20 && position != null && position.Quantity > 0) { // 死叉且有持仓,卖出 var order = new Order { Symbol = symbol, Side = OrderSide.Sell, Price = bar.Close, Quantity = position.Quantity, Type = OrderType.Market }; OrderService.SubmitOrder(order); } }这里没有花哨的异步等待,没有复杂的事件总线,就是最朴素的“数据进来→算指标→下指令”。策略的可测试性极强:你完全可以写一个单元测试,传入1000根模拟K线,断言它是否在第501根触发了买入。
4. 避坑指南:那些让我重启三次才定位到的玄学问题
4.1 现象:K线图不刷新,始终停在初始画面
原因:StockSim.UI工程的App.config中MarketSimulator.IntervalMs被误改为0或负数,导致Timer无法启动。该配置项控制行情生成频率,默认60000(1分钟)。若设为0,System.Timers.Timer会静默失败,不抛异常。
解决:打开StockSim.UI\App.config,确认<add key="MarketSimulator.IntervalMs" value="60000" />存在且值为正整数。改完需重启应用。
4.2 现象:下单成功返回OrderId,但“成交明细”面板始终为空
原因:MatchingEngine的Match()方法未被定时调用。检查StockSim.Engine\MarketSimulator.cs中Start()方法,确认timer.Elapsed += (s, e) => { _matchingEngine.Match(); };这行注册代码未被注释,且timer.Start()在Start()方法末尾被调用。常见翻车点是开发者为了调试注释了这行,忘了恢复。
解决:在MarketSimulator.Start()方法内搜索Match(),确保它在timer.Start()之前被注册为事件处理器。
4.3 现象:策略中MarketDataService.GetBars(symbol, n)返回空集合
原因:MarketSimulator尚未生成足够历史数据。GetBars()默认只返回内存中缓存的最近n根,而MarketSimulator初始化时默认只生成1000根。若你在策略中请求GetBars("SH600000", 2000),而当前只缓存了1000根,则返回空。
解决:两种方式任选其一:① 在MarketSimulator.GenerateMockData()中将生成数量从1000改为3000;② 在策略中加判空逻辑:if (bars.Count < n) return;,避免后续计算崩溃。
4.4 现象:多策略同时加载时,OnOrderFilled()回调被错误地分发给非下单策略
原因:StrategyRunner使用List<BaseStrategy>存储策略,但OnOrderFilled()回调时未校验Trade.BuyOrderId/Trade.SellOrderId是否属于当前策略创建的订单。所有策略都会收到所有成交事件。
解决:在BaseStrategy中添加OrderIdPrefix属性(如"MA5_"),下单时order.Id = $"{OrderIdPrefix}{Guid.NewGuid()}";在OnOrderFilled()中先检查trade.BuyOrderId.StartsWith(OrderIdPrefix)或trade.SellOrderId.StartsWith(OrderIdPrefix)。这是策略隔离的最小成本方案。
4.5 现象:程序退出后再次启动,资金/持仓数据丢失
原因:这是设计使然,不是Bug。项目明确采用内存态(In-Memory)设计,所有状态在AppDomain卸载时销毁。若你需要持久化,必须自行扩展。
解决:在StockSim.Core\Services\AccountService.cs的SaveState()和LoadState()方法中,添加 JSON 序列化逻辑(用System.Text.Json),将Account对象存到AppData\Local\StockSim\state.json。注意:SaveState()应在Application.ApplicationExit事件中调用,LoadState()在AccountService构造函数中调用。别忘了加try/catch处理文件读写异常。
5. 二次开发实战:给系统加上「动态止损」功能
5.1 需求分析:为什么原生止损不够用?
原系统支持两种止损:① 下单时指定StopPrice的 Stop Order(触发后转市价单);② UI 上手动设置的“全局止盈止损比例”。但这两者都是静态的——一旦设定,除非手动修改,否则永不变化。真实交易中,我们需要“移动止损”(Trailing Stop):比如买入后股价上涨2%,止损位自动上移至成本价+1%;若继续涨到5%,止损位再上移到成本价+3%。这要求系统能监听持仓变化,并动态调整挂单。
5.2 改造路径:三处关键注入点
要实现移动止损,需在三个位置打补丁:
| 位置 | 文件 | 修改内容 | 目的 |
|---|---|---|---|
| 1. 持仓变更监听 | StockSim.Core\Services\AccountService.cs | 在UpdatePosition()方法末尾添加OnPositionChanged?.Invoke(position);事件 | 让外部模块能感知“我刚买了100股”或“刚卖了50股” |
| 2. 止损单管理器 | 新建StockSim.Core\Services\StopLossManager.cs | 实现StartTracking(Symbol, initialStopPrice)、UpdateStopPrice(Symbol, newStopPrice)、CancelAllForSymbol(Symbol) | 集中管理所有动态止损单,避免重复挂单 |
| 3. 策略基类增强 | StockSim.Strategy\BaseStrategy.cs | 添加protected void SetTrailingStop(string symbol, double trailPercent)方法 | 向策略暴露简洁API,隐藏底层细节 |
5.3 核心代码:StopLossManager的防重入设计
这是最容易翻车的模块。如果股价快速波动,同一秒内多次触发UpdateStopPrice(),可能产生多张止损单。我们用ConcurrentDictionary+ 时间戳锁来解决:
public class StopLossManager { private readonly IOrderService _orderService; private readonly ConcurrentDictionary<string, (double StopPrice, DateTime LastUpdated)> _activeStops = new(); public StopLossManager(IOrderService orderService) => _orderService = orderService; public void StartTracking(string symbol, double initialStopPrice) { // 1. 先取消该标的原有止损单 CancelAllForSymbol(symbol); // 2. 设置初始止损位 _activeStops[symbol] = (initialStopPrice, DateTime.Now); // 3. 立即挂一张限价止损单(假设做多) var order = new Order { Symbol = symbol, Side = OrderSide.Sell, Price = initialStopPrice, Quantity = GetHeldQuantity(symbol), // 从AccountService获取 Type = OrderType.Limit }; _orderService.SubmitOrder(order); } public void UpdateStopPrice(string symbol, double newStopPrice) { // 防重入:1秒内只允许一次更新 if (_activeStops.TryGetValue(symbol, out var existing) && (DateTime.Now - existing.LastUpdated).TotalSeconds < 1) return; // 只有新止损价高于旧价(做多时),才更新 if (newStopPrice <= existing.StopPrice) return; // 更新内存状态 _activeStops[symbol] = (newStopPrice, DateTime.Now); // 撤掉旧单,挂新单 CancelAllForSymbol(symbol); var order = new Order { Symbol = symbol, Side = OrderSide.Sell, Price = newStopPrice, Quantity = GetHeldQuantity(symbol), Type = OrderType.Limit }; _orderService.SubmitOrder(order); } public void CancelAllForSymbol(string symbol) { // 实际应调用 OrderService.CancelOrderBySymbol(symbol) // 此处简化为清除内存状态 _activeStops.TryRemove(symbol, out _); } private int GetHeldQuantity(string symbol) => // 从 AccountService 获取当前持仓,此处省略具体实现 0; }关键设计点:
ConcurrentDictionary保证多线程安全;TotalSeconds < 1是硬性冷却,防止高频抖动;newStopPrice <= existing.StopPrice检查确保只向上移动(做多逻辑),做空时需反向判断;CancelAllForSymbol()是原子操作,避免新旧单共存。
5.4 策略中调用:一行代码启用移动止损
改造完后,在你的策略中只需:
public override void OnBarUpdate(Bar bar) { var position = AccountService.GetAccount().Positions .FirstOrDefault(p => p.Symbol == bar.Symbol); if (position != null && position.Quantity > 0) // 有持仓 { // 成本价 = 总成本 / 持仓量,此处简化为 position.AvgCost var cost = position.AvgCost; var currentPrice = bar.Close; var trailPercent = 0.01; // 1% 移动距离 if (currentPrice > cost * 1.02) // 股价已涨2% { var newStop = cost * (1 + trailPercent); // 止损位设为成本+1% StopLossManager.UpdateStopPrice(bar.Symbol, newStop); } } }从那以后我每次给策略加风控逻辑,都强制走一遍“内存状态变更 → 事件广播 → 异步处理 → 状态持久化(如需)”四步链路,哪怕只是加个日志。因为漏掉任意一环,都会在实盘压力下变成黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取