资讯详情

C#股票交易模拟系统:订单簿撮合与持仓盈亏链路拆解

📅 2026/10/29 15:31:12 | 华诺云谱 👁 阅读
C#股票交易模拟系统:订单簿撮合与持仓盈亏链路拆解
简介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 依赖 ApplicationApplication 依赖 DomainInfrastructure 被 Application 引用但不反向依赖。Domain里不应该出现System.Data.SQLite或者System.Windows.Forms的 using这是判断一份代码分层是否干净的快捷方式——如果Order.cs里出现了MessageBox那这层就是假的。常见的耦合点在于OrderService既要做业务又要发事件给界面。我的做法是让 Application 层只抛领域事件OrderFilled、PositionChangedUI 层订阅这样以后换成 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 运行时没装 SDKdotnet 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 SortedDictionarydecimal, LinkedListOrder _bids new(Comparerdecimal.Create((a, b) b.CompareTo(a))); private readonly SortedDictionarydecimal, LinkedListOrder _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 LinkedListOrder(); 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)正好匹配需求。同一价位用LinkedListOrder而不是ListOrder因为撮合过程中要频繁从队头删除已成交的委托链表的RemoveFirst是 O(1)List 删头部要挪整个数组。买盘用反转比较器是这套代码里比较巧的一处让_bids.First()天然就是最高买价撮合循环里买卖两侧可以共用同一段取最优价的代码少写一个分支就少一处出错机会。3.3 撮合主循环吃单方与挂单方的匹配public ListTrade Match(Order taker) { var trades new ListTrade(); 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. 卖单校验可卖持仓T1 场景下还要扣掉当日买入的部分 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; } // 可卖数量T1 下当日买入不可卖 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)算出来是 0taker.FilledQty不增加taker.Remaining不变循环永远不会退出。什么情况下 qty 会是 0——挂单被部分撤销后Quantity和FilledQty都改了但Remaining用的是旧值或者撤单时只改了状态没从LinkedList里摘掉。解决两处一起改。一是撮合循环里加防御if (qty 0) { queue.RemoveFirst(); continue; }把非法挂单直接踢出队列二是撤单逻辑必须从订单簿里真删用LinkedListNodeOrder保存引用撤单时queue.Remove(node)O(1) 摘除。我在Order上直接挂了一个internal LinkedListNodeOrder? Node字段虽然破坏了点纯洁性但换来的是撤单不会漏。提示任何while循环里只要有一个「靠对象状态变化来推进」的条件就要在循环体里显式检查推进量是否为正。这类死循环不会抛异常只会让你的 CPU 温度告诉你。5.3 现象跑几分钟后开始抛 database is locked现象刚启动一切正常十几分钟后日志里零星出现SQLite Error 5: database is locked接着成交记录开始丢重启之后数据对不上。原因SQLite 默认的日志模式是 DELETE写操作会锁住整个库文件。这套代码里行情线程每秒落一次 K 线撮合线程每笔成交写三张表UI 线程又在按秒轮询查委托列表三个连接抢一把写锁只要有一个事务超过默认的 5 秒忙等待就抛 locked。解决三个方向同时下手。数据库连接串里加CacheShared;Journal ModeWAL;BusyTimeout5000WAL 模式允许读写并发写不阻塞读行情 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 Queuedecimal _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% 的玄学问题都出在这两条边上而不是算法本身。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑