简介:C#实现的股票交易模拟系统面向希望练习桌面应用开发与数据库整合的 C# 学习者,也适合作为课程设计或小型交易仿真项目的参考。项目围绕行情展示、用户登录注册、账户资料维护、买入卖出与交易记录查询等流程展开,借助 Windows Forms 构建交互界面,并用 Access 数据库保存用户、行情和成交数据,帮助读者理解界面层、业务逻辑与数据访问之间的组织方式。压缩包共 63 个文件、约 278KB,以 21 个 cs 源码、8 个 accdb 数据库、8 个 resx 资源文件及 csproj/sln 工程文件为主,同时包含少量 exe、pdb 等编译与调试产物,结构适合在 Visual Studio 中直接打开、阅读和改造。已有 1766 人学习,可作为梳理桌面交易系统模块划分、订单处理思路和基础数据持久化实现的实战样例。
1. 有人问我 C# 股票交易模拟系统除了练手还能干什么
那个问题问得很实在:一个不能接真实柜台、不能真的赚钱的模拟盘,价值在哪。我的回答是,它把交易系统里最容易出问题的那条链路——下单、冻结资金、撮合、成交回报、持仓更新、盈亏重算、K 线重绘——完整地摆在一台机器上,每一步都能打断点看变量。这份用 C# 写的股票交易模拟系统走的就是这个路子:内置行情发生器造数据,内存订单簿做撮合,SQLite 落盘,WinForms 界面把盘口、委托、成交、持仓、K 线并排摆开。适合三类人:刚学完 C# 想找个有业务厚度的项目练手的人;要验证仓位和加减仓规则、又不想动真账户的人;以及需要在离线环境里演示完整交易流程的测试同学。它不追求微秒级延迟,追求的是链路完整、状态可查。
2. 技术栈与目录结构:.NET 桌面端加 SQLite 的四层骨架
拿到压缩包,先别双击 exe。用文本编辑器打开.csproj,看一眼TargetFramework和UseWindowsForms,基本就能判断这套代码在谁的机器上跑得起来。我拆的时候第一件事是把工程之间的引用关系画出来,因为模拟盘这种东西,出问题十有八九不是算法错,而是某一层偷偷跨层拿了数据。
2.1 技术选型:为什么是 WinForms 而不是 WPF 或 Web
界面层用 WinForms,理由很朴素:拖控件快,自定义 K 线控件直接用 GDI+ 在Panel上画线和矩形就够,不用去啃 WPF 的依赖属性和数据模板。对于一个人维护的模拟盘,能省下的都是调试时间。
数据层用 SQLite 单文件库,好处是拷给别人不用装数据库服务,坏处是并发写入弱,这一点后面避坑章节会专门讲。访问方式我一般选 Dapper 而不是 EF Core,因为撮合相关的查询就那么几条,手写 SQL 反而更容易看清执行计划,也不用为了改一个字段去生成迁移。
运行时版本建议 .NET 8 的 Windows 桌面运行时,如果你机器上只有 .NET Framework 4.8,改TargetFramework到net48大部分代码也能编过,但要留意record和init访问器在老框架下需要额外处理。这是我踩过的第一个坑:源码用了record,老框架直接编译失败。
| 组件 | 选型 | 理由 | 可替换方案 |
|---|---|---|---|
| 界面 | WinForms | 上手快,自绘 K 线方便 | WPF、Avalonia |
| 运行时 | .NET 8 | 长期支持,异步 API 完整 | .NET Framework 4.8 |
| 存储 | SQLite + Dapper | 免安装、SQL 可控 | SQL Server LocalDB、LiteDB |
| 日志 | 文件滚动日志 | 排查撮合问题靠它 | 控制台输出 |
| 并发 | 撮合单线程 + 生产者消费者队列 | 避免订单簿加锁竞争 | Channel、BlockingCollection |
行情线程、撮合线程、UI 线程必须是三个东西。行情线程按固定频率产生快照丢进队列,撮合线程从队列取委托做匹配并把成交事件推出去,UI 线程只负责订阅事件重绘。这个边界一旦糊掉,界面就会假死。
2.2 目录结构:从 sln 到每一个工程
解压后的结构大致是这样,我用tree命令看了一遍:
StockSim/ ├── StockSim.sln ├── src/ │ ├── StockSim.UI/ # WinForms 窗体、自定义 K 线控件 │ │ ├── Forms/ │ │ │ ├── MainForm.cs # 主界面,行情/委托/持仓三个区 │ │ │ └── OrderEntryForm.cs# 下单弹窗,含价格数量校验 │ │ └── Controls/ │ │ └── CandleChart.cs # GDI+ 自绘 K 线 │ ├── StockSim.Application/ # 用例层:OrderService、AccountService │ ├── StockSim.Domain/ # 实体与规则:Order、Trade、Position、OrderBook │ └── StockSim.Infrastructure/ # 仓储、行情发生器、日志、配置 └── data/ └── simulation.db # 首次运行自动创建分层依赖是单向的:UI 依赖 Application,Application 依赖 Domain,Infrastructure 被 Application 引用但不反向依赖。Domain里不应该出现System.Data.SQLite或者System.Windows.Forms的 using,这是判断一份代码分层是否干净的快捷方式——如果Order.cs里出现了MessageBox,那这层就是假的。
常见的耦合点在于OrderService既要做业务又要发事件给界面。我的做法是让 Application 层只抛领域事件(OrderFilled、PositionChanged),UI 层订阅,这样以后换成 Web 端或者控制台回放,业务代码一行不用改。
2.3 数据库:五张表撑起整个业务
建表脚本在Infrastructure/Scripts/schema.sql,首次启动时执行。核心是账户、委托、成交、持仓、K 线五张表:
-- 账户:现金与冻结资金分开记,避免下单时反复扣减 CREATE TABLE Account ( AccountId TEXT PRIMARY KEY, Cash REAL NOT NULL DEFAULT 0, -- 可用资金 FrozenCash REAL NOT NULL DEFAULT 0, -- 冻结资金(挂单占用) CreatedAt TEXT NOT NULL ); -- 委托:Seq 用于同价位的时间优先排序 CREATE TABLE Orders ( OrderId INTEGER PRIMARY KEY AUTOINCREMENT, AccountId TEXT NOT NULL, Symbol TEXT NOT NULL, Side INTEGER NOT NULL, -- 0 买 1 卖 OrderType INTEGER NOT NULL, -- 0 限价 1 市价 Price REAL NOT NULL, Quantity INTEGER NOT NULL, FilledQty INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL, -- 0 挂单 1 部分成交 2 全成 3 撤单 4 废单 Seq INTEGER NOT NULL, CreateTime TEXT NOT NULL ); -- 成交回报:一条委托可能对应多条成交,靠 OrderId 关联 CREATE TABLE Trades ( TradeId INTEGER PRIMARY KEY AUTOINCREMENT, OrderId INTEGER NOT NULL, Symbol TEXT NOT NULL, Price REAL NOT NULL, Quantity INTEGER NOT NULL, Fee REAL NOT NULL DEFAULT 0, TradeTime TEXT NOT NULL ); CREATE INDEX idx_orders_acc_time ON Orders(AccountId, CreateTime DESC); CREATE INDEX idx_trades_order ON Trades(OrderId); CREATE INDEX idx_trades_symbol_time ON Trades(Symbol, TradeTime);Orders表上的(AccountId, CreateTime DESC)索引别省,委托列表按时间倒序分页查询,没索引时几百条就开始明显卡顿。Trades表按(Symbol, TradeTime)建索引是为了画分时和 K 线时按标的取成交明细。
注意:SQLite 没有 decimal 类型,
REAL存金额会有浮点误差,累计几百笔之后对账能差几分钱。稳妥做法是金额统一按「分」存成 INTEGER,读出时再除 100;或者干脆存 TEXT 在应用层用decimal解析。这份源码用的是 REAL,属于够用但不够严谨。
2.4 第一次启动:三条命令加一次初始化
命令行跑起来只需要三步:
# 1. 确认 SDK 版本,低于 8.0 需要改 TargetFramework dotnet --list-sdks # 2. 还原依赖,第一次会拉 Dapper 和 SQLite 原生包 cd StockSim && dotnet restore # 3. 启动 UI 工程 dotnet run --project src/StockSim.UI首次启动会自动在data/下创建simulation.db,并插入一个初始资金 100 万的虚拟账户和三支模拟标的(代码形如SIM001),不需要手工执行 SQL。连接字符串在appsettings.json的ConnectionStrings:Default里,想换个库文件路径改这一处就行。
跑不起来的情况我见过几种:一是只装了 .NET 运行时没装 SDK,dotnet run直接报找不到项目;二是 SQLite 原生库位数对不上,报Unable to load DLL 'e_sqlite3',把平台目标从 AnyCPU 改成 x64 即可;三是界面出来了但行情不动,多半是行情线程被异常吞掉了,去日志文件里找第一行ERROR,通常是初始价格种子为空导致除零。
3. 撮合引擎与订单生命周期:一次下单从进入到生成成交的完整链路
模拟盘好不好用,八成看撮合。撮合写歪了,持仓、盈亏、K 线全跟着歪。这一章按「数据长什么样 → 队列怎么排 → 循环怎么走 → 资金怎么冻」的顺序拆。
3.1 订单模型:一个委托至少要带哪些字段
public enum OrderSide { Buy, Sell } public enum OrderType { Limit, Market } public enum OrderStatus { Pending, PartiallyFilled, Filled, Cancelled, Rejected } public sealed class Order { public long OrderId { get; init; } public string Symbol { get; init; } = string.Empty; // 标的代码,如 SIM001 public OrderSide Side { get; init; } public OrderType Type { get; init; } public decimal Price { get; init; } // 市价单忽略此字段 public int Quantity { get; init; } // 委托数量 public int FilledQty { get; set; } // 已成交数量 public decimal FrozenAmount { get; set; } // 买单已冻结资金 public OrderStatus Status { get; set; } = OrderStatus.Pending; public long Seq { get; init; } // 全局自增序号,同价位排队用 public DateTime CreateTime { get; init; } = DateTime.Now; public int Remaining => Quantity - FilledQty; // 剩余未成交数量 }Seq是这套代码里最容易被忽略的字段。价格优先、时间优先中的「时间」不能直接用CreateTime比较,因为同一毫秒内下的两笔单时间戳会相同,排序结果不稳定。用一个Interlocked.Increment生成的全局递增序号,顺序才是确定的。Remaining做成只读计算属性,避免出现「FilledQty 改了但 Quantity 没同步」这类脏状态。
FrozenAmount只对买单有意义,卖单冻结的是持仓不是钱。这个字段放在 Order 上而不是单独一张冻结表,是因为模拟盘一般不做复杂的多账户保证金计算,够用。
3.2 订单簿:价格优先、时间优先怎么落到数据结构上
public sealed class OrderBook { private readonly object _gate = new(); // 买盘价格从高到低;卖盘价格从低到高。取 First() 就是最优价 private readonly SortedDictionary<decimal, LinkedList<Order>> _bids = new(Comparer<decimal>.Create((a, b) => b.CompareTo(a))); private readonly SortedDictionary<decimal, LinkedList<Order>> _asks = new(); public void Add(Order order) { lock (_gate) { var book = order.Side == OrderSide.Buy ? _bids : _asks; if (!book.TryGetValue(order.Price, out var queue)) { queue = new LinkedList<Order>(); book[order.Price] = queue; } queue.AddLast(order); // 同价位追加到队尾 = 时间优先 } } public (decimal Bid, decimal Ask) Best() { lock (_gate) { var bid = _bids.Count > 0 ? _bids.First().Key : 0m; var ask = _asks.Count > 0 ? _asks.First().Key : 0m; return (bid, ask); } } }选SortedDictionary而不是Dictionary+ 每次排序,是因为最优价要高频读取(盘口刷新一秒好几次),排序一次 O(n log n) 撑不住。SortedDictionary底层是红黑树,取First()是 O(log n),插入也是 O(log n),正好匹配需求。
同一价位用LinkedList<Order>而不是List<Order>,因为撮合过程中要频繁从队头删除已成交的委托,链表的RemoveFirst是 O(1),List 删头部要挪整个数组。
买盘用反转比较器是这套代码里比较巧的一处:让_bids.First()天然就是最高买价,撮合循环里买卖两侧可以共用同一段取最优价的代码,少写一个分支就少一处出错机会。
3.3 撮合主循环:吃单方与挂单方的匹配
public List<Trade> Match(Order taker) { var trades = new List<Trade>(); lock (_gate) { // 买单来吃卖盘,卖单来吃买盘 var book = taker.Side == OrderSide.Buy ? _asks : _bids; while (taker.Remaining > 0 && book.Count > 0) { var best = book.First(); // 对手方最优价 if (taker.Type == OrderType.Limit) { // 限价单价格不满足就停手,剩下的挂着 bool crossed = taker.Side == OrderSide.Buy ? taker.Price >= best.Key : taker.Price <= best.Key; if (!crossed) break; } var queue = best.Value; while (queue.Count > 0 && taker.Remaining > 0) { var maker = queue.First!.Value; int qty = Math.Min(maker.Remaining, taker.Remaining); trades.Add(new Trade { OrderId = maker.OrderId, Symbol = maker.Symbol, Price = maker.Price, // 成交价取挂单方价格 Quantity = qty, TradeTime = DateTime.Now }); maker.FilledQty += qty; taker.FilledQty += qty; maker.Status = maker.Remaining == 0 ? OrderStatus.Filled : OrderStatus.PartiallyFilled; if (maker.Remaining == 0) queue.RemoveFirst(); } if (queue.Count == 0) book.Remove(best.Key); // 该价位吃空了 } taker.Status = taker.Remaining == 0 ? OrderStatus.Filled : (taker.FilledQty > 0 ? OrderStatus.PartiallyFilled : OrderStatus.Pending); } return trades; }几个关键点值得单独说:
成交价取挂单方(maker)的价格而不是吃单方(taker)的价格,这是价格优先原则的直接体现——先挂的人先得到价格保护。如果你把Price = maker.Price改成taker.Price,回测出来的收益率会明显偏高,这是个很隐蔽的偏差。
限价单在价格不满足时break跳出,剩余部分由调用方重新Add回订单簿,状态置为Pending或PartiallyFilled。市价单则一直吃到Remaining == 0或者对手盘吃空,吃空后剩余部分通常直接废单而不是挂着,因为市价单本身不承诺价格。
锁的粒度是整本订单簿而不是单个价位。模拟盘单账户、单标的,撮合频率低,粗锁完全够;真要做多标的并行撮合,应该按Symbol分片,每个标的一把锁,否则多线程抢同一把锁反而更慢。
3.4 资金冻结与成交回报:别让同一笔钱下两次单
public OrderResult PlaceOrder(OrderRequest req) { // 1. 买单先冻结资金:价格 * 数量 * (1 + 预估手续费率) if (req.Side == OrderSide.Buy) { decimal need = req.Price * req.Quantity * (1 + FeeRate); if (account.Cash - account.FrozenCash < need) return OrderResult.Reject("可用资金不足"); account.FrozenCash += need; order.FrozenAmount = need; } else { // 2. 卖单校验可卖持仓,T+1 场景下还要扣掉当日买入的部分 var pos = positionRepo.Get(req.AccountId, req.Symbol); if (pos == null || pos.Available < req.Quantity) return OrderResult.Reject("可卖数量不足"); pos.Available -= req.Quantity; } // 3. 先落库再撮合,保证崩溃后能靠委托表恢复状态 orderRepo.Insert(order); var trades = _engine.Match(order); foreach (var t in trades) _settlement.Apply(order, t); return OrderResult.Ok(order, trades); }冻结资金用FrozenCash单独记账,而不是直接从Cash里扣。原因很直接:挂单只是占用,不是真花钱,撤单时要原路退回。如果下单就扣Cash、撤单再加回来,中间任何一次异常退出都会导致对账对不上。判断可用资金时用Cash - FrozenCash,这个表达式在两个地方出现,最好抽成一个属性,别手写两遍。
「先落库再撮合」这个顺序是有意为之。反过来先撮合再落库,如果写库失败,内存里已经成交了,重启后这笔成交凭空消失,持仓和资金全歪。落库先写委托,成交明细在Apply里逐条写,即使中途崩溃,靠Orders.FilledQty和Trades的汇总也能对出来。
Apply里做的事按顺序是:更新委托已成交数量、扣减冻结资金、扣手续费、更新持仓均价和数量、写成交记录、抛OrderFilled事件。这六步里任何一步抛异常,都会让状态半新半旧,所以整段建议包在一个数据库事务里,内存对象在事务提交之后再改。
4. 行情模拟与账户资金:K 线、盘口与持仓盈亏的计算链路
撮合解决「怎么成交」,这一章解决「成交之后账面长什么样」。模拟盘的行情是造出来的,但造得像不像,直接决定这套东西能不能拿来验证策略。
4.1 行情发生器:没有真实行情也能跑出像样的 K 线
public sealed class RandomWalkTicker { private readonly Random _rng; private readonly double _sigma; // 单步波动率,日波动 2% 折算到每 tick 大约 0.0002 private readonly decimal _tick; // 最小变动价位,如 0.01 private decimal _last; public RandomWalkTicker(decimal seed, decimal tick, double sigma, int randomSeed = 42) { _last = seed; _tick = tick; _sigma = sigma; _rng = new Random(randomSeed); // 固定种子,保证回放可复现 } public decimal Next() { // Box-Muller 把两个均匀分布转成一个标准正态 var u1 = 1.0 - _rng.NextDouble(); var u2 = 1.0 - _rng.NextDouble(); var z = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2 * Math.PI * u2); var ret = (decimal)(z * _sigma); _last = Math.Max(_tick, _last * (1 + ret)); // 价格不能为负 return Math.Round(_last / _tick) * _tick; // 归整到最小变动价位 } }用几何随机游走而不是简单的_last + random(-1, 1),是因为前者天然保证价格为正、涨跌幅呈对数正态分布,看起来更像真实盘面;后者跑一段时间容易出现负价格或者长期横盘。
randomSeed固定成常量是个很实用的设计:同一次运行的行情序列可以完全复现,调策略时不会因为行情不同而得出相反结论。要换一组行情,改这个种子就行。
Math.Round(_last / _tick) * _tick这一步不能省。不归整的话价格会出现10.0237这种带四位小数的值,界面上显示着难受,撮合时买单限价10.02永远吃不到卖盘10.0237,看着盘口明明有量却成交不了,能查半天。
4.2 K 线聚合:从逐笔快照到 1 分钟、5 分钟
public sealed class KLineBuilder { private readonly int _periodSeconds; private DateTime _bucketStart; private decimal _open, _high, _low, _close; private long _volume; public KLineBuilder(int periodSeconds) => _periodSeconds = periodSeconds; public KLine? Push(DateTime time, decimal price, long volume) { var bucket = new DateTime(time.Year, time.Month, time.Day, time.Hour, time.Minute, 0, DateTimeKind.Local) .AddSeconds(-(time.Minute * 60 + time.Second) % _periodSeconds); KLine? closed = null; if (_bucketStart != default && bucket != _bucketStart) { closed = Snapshot(); // 上一根收线 _bucketStart = bucket; _open = _high = _low = _close = price; _volume = volume; return closed; } if (_bucketStart == default) { _bucketStart = bucket; _open = _high = _low = _close = price; } _high = Math.Max(_high, price); _low = Math.Min(_low, price); _close = price; _volume += volume; return null; } private KLine Snapshot() => new(_bucketStart, _open, _high, _low, _close, _volume, _periodSeconds); }分桶的算法是AddSeconds(-(分钟*60 + 秒) % period),把时间往回推到周期起点,这样 1 分钟、5 分钟、15 分钟共用同一段代码,只要改_periodSeconds。比手写if (period == 60) ... else if (period == 300) ...好维护得多。
Push返回KLine?,只有收线那一刻才返回非空,调用方拿到就落库并通知界面重绘最后一根。当前还没走完的那根不落库,界面每次刷新时用内存里的_open/_high/_low/_close现画,这样能画出实时跳动的最后一根蜡烛。
DateTimeKind.Local这个参数别漏。默认new DateTime(...)是Unspecified,存进 SQLite 再读出来跟本地时间比较,会出现八小时偏差,夜盘的 K 线全跑到前一天去了。
4.3 持仓与盈亏:浮动盈亏、可用资金、手续费的算法
public sealed class Position { public string Symbol { get; init; } = ""; public int Quantity { get; set; } // 持仓总量 public int Available { get; set; } // 可卖数量(T+1 下当日买入不可卖) public decimal AvgCost { get; set; } // 移动加权成本价 // 买入:重算加权成本 public void OnBuy(int qty, decimal price, decimal fee) { decimal totalCost = AvgCost * Quantity + price * qty + fee; Quantity += qty; AvgCost = Quantity == 0 ? 0 : totalCost / Quantity; } // 卖出:数量减少,成本价不变,卖出盈亏计入已实现 public void OnSell(int qty, decimal price, decimal fee) { decimal realized = (price - AvgCost) * qty - fee; Quantity -= qty; if (Quantity == 0) AvgCost = 0; // 清仓后成本归零,避免下次买入带错基数 RealizedPnl += realized; } public decimal FloatPnl(decimal last) => (last - AvgCost) * Quantity; public decimal RealizedPnl { get; private set; } }买入用移动加权平均,把手续费摊进成本,这样FloatPnl算出来的浮盈就是净的,不用在展示层再减一次。卖出时成本价保持不变,这是会计上的通行做法——卖出的盈亏单独记为已实现,不摊回到剩余持仓上。
if (Quantity == 0) AvgCost = 0;这行看着多余,实际上是血泪经验:不清零的话,下次同一标的再买入,加权公式里用了一个陈旧的AvgCost * 0,虽然结果碰巧是对的,但如果中间清仓后手动改过库里的成本价,就会出现成本价跳变的怪现象。显式归零,逻辑干净。
手续费按成交金额乘费率,费率一般设成万分之二点五,最低五元。模拟盘里要不要模拟最低五元收费,取决于你要验证的策略频率——高频小额策略不模拟最低收费,回测收益会虚高。
4.4 界面刷新:跨线程更新控件不卡死
private void OnTick(object? sender, TickEventArgs e) { // 行情线程回调,直接改控件会抛跨线程异常 if (InvokeRequired) { BeginInvoke(new Action(() => OnTick(sender, e))); // 异步投递,不阻塞行情线程 return; } lblLast.Text = e.Last.ToString("F2"); lblChange.Text = $"{e.ChangePercent:P2}"; chart.Append(e.Time, e.Last); // 自绘控件内部维护环形缓冲 } private void OnOrderFilled(object? sender, TradeEventArgs e) { if (InvokeRequired) { BeginInvoke(new Action(() => OnOrderFilled(sender, e))); return; } RefreshPositions(); RefreshOrders(); }用BeginInvoke而不是Invoke是有意的:Invoke是同步等待,行情推送频率一高,行情线程就会卡在等 UI 线程处理完,连带撮合队列堆积。BeginInvoke把委托丢进消息队列立即返回,行情线程该干嘛干嘛。
但BeginInvoke也有代价——如果 UI 处理不过来,消息队列会越堆越长,界面看起来在「慢放」,几秒后才追上当前价。判断方法是看任务管理器里进程内存是否持续上涨。真正的解法是在 UI 侧做节流:行情更新按 100 毫秒合并一次,只渲染最后一笔,中间的直接丢弃,反正用户也看不清。
chart.Append里用的是环形缓冲而不是List.Add,因为 K 线图最多显示 200 根,超过就从头覆盖,避免持有几万个点后 GDI+ 绘制越来越慢。
5. 避坑与排查:从「成交了但持仓没变」到数据库锁表
模拟盘最容易让人怀疑人生的是「界面显示的和数据库里的对不上」。下面四条是我在拆这套代码时真实撞到过的问题,按现象、原因、解决写,遇到类似的可以直接对号入座。
5.1 现象:委托状态变成已成交,持仓列表还是空的
现象:下单后委托区立刻显示「已成交」,成交明细也有一条,切到持仓页签却空空如也,或者数量少了一半。
原因:成交事件OrderFilled是在撮合线程上抛的,RefreshPositions()里重新查库,但此时Apply的事务还没提交。SQLite 的默认隔离级别下,另一个连接读不到未提交的数据,查出来自然是旧的。等事务提交了,界面已经刷新完,不会自己再刷一次。
解决:把事件抛出时机挪到事务提交之后,而不是在Apply中间抛。写法上是让Apply只负责改状态和写库,返回一个待发布的事件列表,由PlaceOrder在transaction.Commit()之后再统一Publish。另外一个保底措施是持仓查询走内存里的PositionRepository缓存而不是每次都查库,模拟盘数据量小,全量放内存完全可行。
5.2 现象:撮合线程 CPU 一路飙到 100%,界面假死
现象:跑了几分钟,风扇开始响,任务管理器里进程占用稳定在单核满载,界面点一下卡三秒,但日志里没有任何异常。
原因:撮合循环里的while (taker.Remaining > 0 && book.Count > 0),如果qty = Math.Min(maker.Remaining, taker.Remaining)算出来是 0,taker.FilledQty不增加,taker.Remaining不变,循环永远不会退出。什么情况下 qty 会是 0?——挂单被部分撤销后Quantity和FilledQty都改了,但Remaining用的是旧值;或者撤单时只改了状态没从LinkedList里摘掉。
解决:两处一起改。一是撮合循环里加防御:if (qty <= 0) { queue.RemoveFirst(); continue; },把非法挂单直接踢出队列;二是撤单逻辑必须从订单簿里真删,用LinkedListNode<Order>保存引用,撤单时queue.Remove(node),O(1) 摘除。我在Order上直接挂了一个internal LinkedListNode<Order>? Node字段,虽然破坏了点纯洁性,但换来的是撤单不会漏。
提示:任何
while循环里只要有一个「靠对象状态变化来推进」的条件,就要在循环体里显式检查推进量是否为正。这类死循环不会抛异常,只会让你的 CPU 温度告诉你。
5.3 现象:跑几分钟后开始抛 database is locked
现象:刚启动一切正常,十几分钟后日志里零星出现SQLite Error 5: 'database is locked',接着成交记录开始丢,重启之后数据对不上。
原因:SQLite 默认的日志模式是 DELETE,写操作会锁住整个库文件。这套代码里行情线程每秒落一次 K 线,撮合线程每笔成交写三张表,UI 线程又在按秒轮询查委托列表,三个连接抢一把写锁,只要有一个事务超过默认的 5 秒忙等待,就抛 locked。
解决:三个方向同时下手。数据库连接串里加Cache=Shared;Journal Mode=WAL;BusyTimeout=5000,WAL 模式允许读写并发,写不阻塞读;行情 K 线不要每秒落库,改成内存里攒着,收线时才写一次;最后把仓储层改成单例连接 +lock,模拟盘并发量小,串行化写入反而最简单可靠。改完之后 locked 基本绝迹。
5.4 现象:浮动盈亏每次刷新数字都不一样,还不收敛
现象:持仓不动、行情也不动的情况下,连续点几次刷新,浮盈数字在小范围内来回跳,差几毛到几块。
原因:两个来源。一是REAL存金额带来的浮点误差,每次从库里读出来再参与计算,误差会累积;二是AvgCost每次买入都重新除一遍,decimal除法产生的尾数被截断方式不一致,重算次数越多偏差越大。
解决:金额字段统一按「分」用 INTEGER 存,应用层读出后/ 100m转成decimal;成本价保留 4 位小数并用Math.Round(AvgCost, 4, MidpointRounding.AwayFromZero)显式指定舍入方式,不要依赖默认的银行家舍入。改完再对同一份数据反复刷新,数字就稳定了。这类问题不影响功能但极其影响信任感——数字老在跳,用户第一反应是这软件算错了。
6. 二次开发改造:把模拟盘接上自己的策略与风控
这套代码最适合改造的地方是策略层。原始版本只有手工下单,加一个策略接口就能变成半自动的交易验证台:
public interface IStrategy { string Name { get; } // 每来一笔行情调用一次,策略内部自己判断要不要下单 void OnTick(MarketSnapshot snapshot, ITradeApi api); } // 均线交叉示例:短均线上穿长均线买入,下穿卖出 public sealed class MaCrossStrategy : IStrategy { public string Name => "MA5/MA20"; private readonly Queue<decimal> _prices = new(); private int _position; public void OnTick(MarketSnapshot s, ITradeApi api) { _prices.Enqueue(s.Last); if (_prices.Count > 20) _prices.Dequeue(); if (_prices.Count < 20) return; var arr = _prices.ToArray(); decimal ma5 = arr.TakeLast(5).Average(); decimal ma20 = arr.Average(); if (ma5 > ma20 && _position == 0) { api.Buy(s.Symbol, price: s.Last, quantity: 1000, type: OrderType.Limit); _position = 1000; } else if (ma5 < ma20 && _position > 0) { api.Sell(s.Symbol, price: s.Last, quantity: _position, type: OrderType.Limit); _position = 0; } } }ITradeApi是喂给策略的唯一入口,策略拿不到OrderService也拿不到数据库连接,这样策略代码天然无法绕过风控。风控做成责任链挂在ITradeApi实现里,下单前依次过规则:
| 扩展点 | 位置 | 改造方式 | 验证方法 |
|---|---|---|---|
| 策略 | 新增IStrategy实现 | 定时器驱动OnTick | 固定随机种子跑一周数据,看委托笔数 |
| 风控 | ITradeApi装饰器 | 实现IRiskRule并注册 | 故意下超量单,应被拒 |
| 行情源 | ITickSource | 换成文件回放或 CSV 导入 | 同一份 CSV 两次回放结果一致 |
| 撮合规则 | OrderBook.Match | 加涨跌停限制、最小成交量 | 构造边界价,看是否正确挂起 |
| 存储 | IOrderRepository | 换 LiteDB 或内存实现 | 断库跑一遍,确认不崩 |
改造时我习惯先做「回放」再做「策略」:把行情源切成 CSV 回放,因为只有可复现的输入,策略表现才有可比性。回放模式下把撮合线程的速度控制成按时间戳推进,不要用Thread.Sleep硬等,而是让撮合器一次处理一个 tick,处理完再取下一个,跑一年的数据也就几秒钟。
风险控制这块,最容易被跳过的是「单标的持仓上限」和「单日最大回撤」两条。策略写得再漂亮,没有这两条兜底,一次极端行情就能把模拟账户打成负数。我一般把这两条写成硬规则,触发后直接锁定该账户当日交易权限,而不是只弹个提示框——提示框点一下就过去了,锁权限才拦得住。
从那以后我每次拿到一个新的模拟盘代码,都强迫自己先做一件事:把撮合线程、行情线程、UI 线程的边界在纸上画一遍,标出每一处跨线程的数据传递和每一处数据库写入,画完再动手改。因为这类项目里 90% 的玄学问题都出在这两条边上,而不是算法本身。希望帮到你。
本文还有配套的精品资源,点击获取