简介:这是一份面向C#初学者与.NET开发者的股票交易模拟系统源码,适合希望通过完整项目实战掌握桌面应用开发、数据库操作与业务逻辑实现的学习者。系统提供图形化界面与数据库支持,可进行虚拟股票买卖,模拟真实市场环境。压缩包共63个文件,约278KB,以21个cs源码文件为核心,配合8个resx与9个resources资源文件构建界面,8个accdb数据库文件存储用户与交易数据,另含csproj、sln等工程配置及exe可执行文件,结构完整可直接运行。目前已有1765人学习下载。内容涵盖登录注册、行情展示、用户信息管理、交易记录查询等模块,涉及Windows Forms界面设计、ADO.NET数据库访问、订单撮合与买卖确认等业务规则,并体现MVC分层与安全性处理思路,是理解C#项目架构与交易系统实现的实用参考。
1. 用 C# 搭一套股票交易模拟系统,到底能练到什么程度
很多人学 C# 卡在“语法都会、项目不会写”这一步。语法书翻了三遍,LINQ 写得飞起,真让搭一个带撮合、持仓、盈亏计算的完整系统,立刻不知道从哪下手。股票交易模拟系统就是填这个坑的:它把订单、撮合、账户、行情、风控串成一条完整链路,逼着你处理并发、精度、状态一致性这些真实工程问题。这份资源适合两类人——刚学完 C# 基础想找个有业务厚度的项目练手的新手,以及想看看交易类系统怎么分层、怎么解耦的熟手。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆开讲。
2. 系统架构与目录结构:订单、撮合、账户怎么分层
2.1 整体分层设计
这套系统采用经典的分层架构,从上到下依次是表现层、业务服务层、领域模型层和数据访问层。表现层负责接收用户下单、撤单、查询持仓等操作;业务服务层承载订单校验、撮合调度、账户结算等核心逻辑;领域模型层定义订单、成交、持仓、账户这些实体;数据访问层负责持久化,常见做法是用 Entity Framework Core 配合 SQLite 或 SQL Server。
分层的好处是撮合逻辑不依赖 UI,你可以把控制台换成 WinForms、WPF 甚至 Web API,业务代码一行不用动。我一般会把撮合引擎单独抽成一个服务类,通过接口暴露给上层,这样单元测试也好写。
2.2 目录结构说明
一个典型的目录结构长这样:
StockTradingSim/ ├── StockTradingSim.Core/ # 领域模型与接口 │ ├── Models/ │ │ ├── Order.cs │ │ ├── Trade.cs │ │ ├── Position.cs │ │ └── Account.cs │ ├── Enums/ │ │ ├── OrderSide.cs │ │ ├── OrderStatus.cs │ │ └── OrderType.cs │ └── Interfaces/ │ ├── IOrderService.cs │ ├── IMatchingEngine.cs │ └── IAccountService.cs ├── StockTradingSim.Services/ # 业务逻辑实现 │ ├── OrderService.cs │ ├── MatchingEngine.cs │ ├── AccountService.cs │ └── MarketDataService.cs ├── StockTradingSim.Data/ # 数据访问 │ ├── TradingDbContext.cs │ └── Repositories/ ├── StockTradingSim.Console/ # 控制台入口 │ └── Program.cs └── StockTradingSim.Tests/ # 单元测试Core 层不引用任何外部依赖,Services 层引用 Core,Data 层引用 Core,Console 层引用 Services 和 Data。依赖方向单向,不会出现循环引用。
2.3 核心实体定义
订单是整个系统的起点,先看订单模型怎么定义:
// Models/Order.cs public class Order { public Guid Id { get; set; } = Guid.NewGuid(); public string StockCode { get; set; } // 股票代码,如 "600000" public OrderSide Side { get; set; } // Buy / Sell public OrderType Type { get; set; } // Limit / Market public decimal Price { get; set; } // 委托价格,市价单为 0 public int Quantity { get; set; } // 委托数量 public int FilledQuantity { get; set; } // 已成交数量 public OrderStatus Status { get; set; } // Pending / PartialFilled / Filled / Cancelled public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public int RemainingQuantity => Quantity - FilledQuantity; public bool IsActive => Status == OrderStatus.Pending || Status == OrderStatus.PartialFilled; }这里有几个关键点。decimal而不是double,因为金额计算用浮点数会出现精度丢失,这是血泪经验。RemainingQuantity和IsActive用只读属性暴露,避免外部直接改状态导致数据不一致。Guid做主键而不是自增 int,方便后续分布式扩展。
2.4 撮合引擎的接口设计
撮合引擎是系统的心脏,接口定义决定了后续能不能替换实现:
// Interfaces/IMatchingEngine.cs public interface IMatchingEngine { // 提交订单,返回产生的成交列表 IReadOnlyList<Trade> Submit(Order order); // 撤销订单 bool Cancel(Guid orderId); // 获取某只股票的当前盘口 OrderBookSnapshot GetSnapshot(string stockCode); }Submit返回成交列表而不是单个成交,因为一笔订单可能吃掉对手盘多个价位,产生多笔成交。GetSnapshot用于展示买卖五档,是行情展示的基础。
3. 从零跑起来:环境准备与启动流程
3.1 环境要求与依赖安装
跑这个项目需要 .NET 6 或更高版本的 SDK,数据库默认用 SQLite(零配置,适合本地跑),也可以切到 SQL Server。先确认环境:
# 检查 .NET SDK 版本 dotnet --version # 应输出 6.0.x 或更高 # 克隆项目后进入根目录 cd StockTradingSim # 还原 NuGet 包 dotnet restore如果dotnet restore报找不到包,常见原因是 NuGet 源配置问题。检查NuGet.config里的源地址,或者临时用dotnet restore --source https://api.nuget.org/v3/index.json指定官方源。
3.2 数据库初始化
项目用 EF Core 的 Code First 模式,首次运行需要创建数据库:
# 安装 EF Core 工具(如果还没装) dotnet tool install --global dotnet-ef # 在 Data 项目目录下执行迁移 cd StockTradingSim.Data dotnet ef migrations add InitialCreate dotnet ef database update执行完会在项目目录下生成trading.db文件。如果想重新来过,直接删掉这个文件再跑一次database update即可。注意迁移文件要提交到版本控制,否则别人拉下来没法建库。
3.3 启动与基本操作
回到 Console 项目启动:
cd ../StockTradingSim.Console dotnet run启动后会看到交互菜单,支持以下操作:
| 操作 | 命令示例 | 说明 |
|---|---|---|
| 买入下单 | buy 600000 10.50 100 | 代码 价格 数量 |
| 卖出下单 | sell 600000 10.60 200 | 代码 价格 数量 |
| 查看持仓 | position | 列出所有持仓及盈亏 |
| 查看订单 | orders | 列出当日所有委托 |
| 撤单 | cancel <orderId> | 按订单 ID 撤销 |
| 查看盘口 | book 600000 | 显示买卖五档 |
下单后如果价格能匹配上已有对手单,会立即成交并打印成交流水。市价单用buy 600000 market 100这种写法,价格填market。
3.4 验证系统是否正常
跑通后做几个验证:先挂一笔卖单sell 600000 10.50 100,再挂一笔买单buy 600000 10.50 100,应该立即看到成交回报,持仓里出现 600000 的 100 股多头。然后试试部分成交:挂sell 600000 10.50 200,再buy 600000 10.50 100,卖单状态应变成PartialFilled,剩余 100 股还在盘口挂着。
4. 核心模块拆解:撮合、结算、行情三条链路
4.1 订单簿与价格优先撮合
撮合引擎的核心是订单簿。买单按价格从高到低排,卖单按价格从低到高排,同价位按时间先后排。常见做法是用SortedDictionary<decimal, Queue<Order>>来组织:
// Services/MatchingEngine.cs private readonly Dictionary<string, OrderBook> _books = new(); public IReadOnlyList<Trade> Submit(Order order) { var book = GetOrCreateBook(order.StockCode); var trades = new List<Trade>(); if (order.Side == OrderSide.Buy) { // 买单去匹配卖盘,卖盘价格从低到高 while (order.RemainingQuantity > 0 && book.Asks.Count > 0) { var bestAsk = book.Asks.First(); // 限价单价格不够,停止撮合 if (order.Type == OrderType.Limit && order.Price < bestAsk.Key) break; MatchAtPrice(order, bestAsk.Value, trades); if (bestAsk.Value.Count == 0) book.Asks.Remove(bestAsk.Key); } } else { // 卖单去匹配买盘,买盘价格从高到低 while (order.RemainingQuantity > 0 && book.Bids.Count > 0) { var bestBid = book.Bids.Last(); if (order.Type == OrderType.Limit && order.Price > bestBid.Key) break; MatchAtPrice(order, bestBid.Value, trades); if (bestBid.Value.Count == 0) book.Bids.Remove(bestBid.Key); } } // 未成交部分挂入订单簿 if (order.RemainingQuantity > 0 && order.Type == OrderType.Limit) book.AddOrder(order); return trades; }MatchAtPrice负责逐笔撮合,成交价取被动方(先挂单方)的价格,这是交易所的通用规则。每笔成交同时更新买卖双方的FilledQuantity和Status。注意while循环里每次都要重新取First()或Last(),因为队列可能被清空。
4.2 账户结算与持仓盈亏
成交之后要更新账户资金和持仓。持仓成本用移动加权平均法计算:
// Services/AccountService.cs public void ApplyTrade(Trade trade, Order buyOrder, Order sellOrder) { var buyer = GetAccount(buyOrder.AccountId); var seller = GetAccount(sellOrder.AccountId); decimal amount = trade.Price * trade.Quantity; // 买方:扣资金,加持仓 buyer.Cash -= amount; buyer.AddPosition(trade.StockCode, trade.Quantity, trade.Price); // 卖方:加资金,减持仓,计算已实现盈亏 seller.Cash += amount; seller.ReducePosition(trade.StockCode, trade.Quantity, trade.Price); } // Position 里的成本更新逻辑 public void AddPosition(string code, int qty, decimal price) { if (!Positions.TryGetValue(code, out var pos)) { Positions[code] = new Position { Code = code, Quantity = qty, AvgCost = price }; return; } // 移动加权平均 decimal totalCost = pos.AvgCost * pos.Quantity + price * qty; pos.Quantity += qty; pos.AvgCost = totalCost / pos.Quantity; }AvgCost是移动加权平均成本,卖出时用(卖出价 - AvgCost) * 数量算已实现盈亏。这里要注意除零问题:如果pos.Quantity + qty为 0 要单独处理。另外资金扣减要检查是否足够,否则会出现负余额。
4.3 行情推送与盘口快照
行情模块负责生成盘口快照和模拟价格波动。盘口快照直接从订单簿取前五档:
// Services/MarketDataService.cs public OrderBookSnapshot GetSnapshot(string stockCode) { var book = _engine.GetBook(stockCode); return new OrderBookSnapshot { StockCode = stockCode, Bids = book.Bids .OrderByDescending(kv => kv.Key) .Take(5) .Select(kv => new PriceLevel { Price = kv.Key, Volume = kv.Value.Sum(o => o.RemainingQuantity) }) .ToList(), Asks = book.Asks .OrderBy(kv => kv.Key) .Take(5) .Select(kv => new PriceLevel { Price = kv.Key, Volume = kv.Value.Sum(o => o.RemainingQuantity) }) .ToList() }; }买单要降序取前五(价格最高的五档),卖单升序取前五(价格最低的五档)。Volume是该价位所有订单剩余量之和。如果要做价格模拟,可以起一个后台定时器,每隔几秒随机生成一些买卖单注入订单簿,让盘口看起来是活的。
5. 避坑与常见问题:精度、并发、状态这三座山
5.1 金额用 double 导致对不上账
现象:几笔成交后账户余额出现0.00000001这种尾差,累计多了差几毛钱。原因:double是二进制浮点,无法精确表示0.1这类十进制小数。解决:所有金额、价格、数量字段一律用decimal,数据库列类型用decimal(18,4)。EF Core 里用HasPrecision(18, 4)显式配置。
5.2 多线程下单导致订单簿错乱
现象:并发下单时偶尔出现订单丢失,或者同一笔订单被成交两次。原因:Dictionary和SortedDictionary不是线程安全的,多个线程同时读写会破坏内部结构。解决:在撮合引擎入口加锁,用lock (_bookLock)包住整个Submit方法。如果追求性能,可以按股票代码分片加锁,不同股票的撮合互不阻塞。我一般先用全局锁保证正确,压测发现瓶颈再优化。
5.3 订单状态更新遗漏
现象:订单明明全部成交了,状态还显示PartialFilled,撤单时提示“订单仍活跃”。原因:撮合循环里只更新了FilledQuantity,忘了同步Status。解决:把状态更新封装成Order.UpdateFill(int qty)方法,内部根据FilledQuantity和Quantity自动判断状态,避免散落在各处手动改。
5.4 数据库迁移后字段类型不对
现象:本地跑得好好的,换台机器建库后金额变成整数,小数部分被截断。原因:decimal属性没配精度,不同数据库默认精度不一样,SQLite 宽松但 SQL Server 默认decimal(18,2)。解决:在OnModelCreating里统一配置:
modelBuilder.Entity<Order>() .Property(o => o.Price) .HasPrecision(18, 4);所有金额字段都走一遍,别偷懒。
5.5 撤单后订单还在盘口
现象:撤单返回成功,但盘口里还能看到那笔单。原因:只改了订单状态,没从订单簿的队列里移除。解决:Cancel方法里除了改状态,还要从对应价位的队列中Remove掉该订单。如果队列空了,把整个价位从SortedDictionary里删掉,否则盘口会显示空档。
6. 二次开发:加策略回测和风控的改造思路
这套系统跑通之后,最有价值的改造方向是加策略回测。思路是把MatchingEngine的时间源抽象成IClock接口,回测时用历史数据驱动,实盘时用系统时钟。具体做法是定义:
public interface IClock { DateTime Now { get; } } // 实盘实现 public class SystemClock : IClock { public DateTime Now => DateTime.UtcNow; } // 回测实现:时间可手动推进 public class BacktestClock : IClock { public DateTime Now { get; private set; } public void Advance(TimeSpan span) => Now += span; }然后把撮合引擎和订单服务里所有DateTime.UtcNow替换成_clock.Now。回测时读 CSV 历史行情,按时间戳逐条推进BacktestClock,注入订单,跑完统计收益率、最大回撤、胜率。这样一套代码既能实盘模拟又能回测,不用维护两份逻辑。
风控模块可以挂在OrderService的PlaceOrder入口,下单前做检查:单笔委托金额是否超过限额、当日累计亏损是否触发熔断、持仓集中度是否超标。风控规则用策略模式组织,每条规则实现IRiskRule接口,方便增删。
改造时有个习惯我每次都强制走一遍:先写单元测试再动代码。撮合引擎的测试尤其重要,构造几个典型场景——完全成交、部分成交、跨价位成交、撤单后重挂——确保改造没破坏原有逻辑。回测结果和手工计算对不上时,先查时钟推进顺序,再查成交价取值规则,这两处最容易翻车。希望帮到你。
本文还有配套的精品资源,点击获取