ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java Swing股票交易模拟系统:从数据结构选型到撮合引擎设计

Java Swing股票交易模拟系统:从数据结构选型到撮合引擎设计 简介这是基于Java Swing开发的股票交易模拟系统课程设计资源包面向需要完成数据结构课程设计、Java实训或毕业设计的本科/高职学生。系统采用面向对象与MVC分层思想综合运用数据库存储技术、链表与队列数据结构、JFreechart开源图表库以及新浪股票行情接口实现行情揭示、账户管理、模拟买卖、走势图绘制、技术指标与策略分析等完整功能同时以随机数模拟实时数据便于脱离真实行情环境独立运行调试。资源包共187个文件约10.79MB包含54个Java源码、20个依赖jar包、87个编译后的class文件以及PPT答辩演示文稿、数据库配置脚本、界面截图和设计说明文档代码分层清晰可直接导入主流IDE学习与二次开发。已有758人学习下载适合用来掌握Swing图形界面设计、数据库连接、随机数仿真、事件驱动编程等核心技能也可作为课程设计报告与答辩PPT的参考素材。1. Java Swing 股票交易模拟系统这个课程设计包真正值得花时间的部分每年数据结构课设都有人拿到类似「Java Swing 股票交易模拟系统源码数据库ppt」的压缩包第一反应是装上跑通、截图交差。但这类 java 课程设计案例源码遍地都是真正拉开分数差距的不是界面多好看而是你能不能把订单排队、行情缓存、持仓映射、账户并发扣款这些业务场景和数据结构课上的堆、队列、哈希表、平衡树逐一对上号。题目里带着「数据结构」三个字评分点基本落在选型和复杂度上。这篇文章我会从整体设计拆到数据库建表再讲清楚撮合、行情刷新和线程安全怎么落地最后给你一套答辩前能直接用的验证方法和避坑顺序。2. 先搭框架再看代码四层架构与每层该选什么数据结构2.1 依赖方向决定改代码的位置UI、服务、数据三层为什么要物理拆开很多课设包的原始代码是「一个 MainFrame 包天下」JTable 的渲染、行情生成循环、下单逻辑全塞在一个类里甚至监听器里直接写 SQL。这种写法不是不能跑而是你一旦被问到「某个功能改了会不会影响另一个功能」根本答不清楚依赖关系。我一般会先把代码按四层拆开按下面的包结构落com.course.stock ├── view # JFrame、JTable、监听器只做展示和输入收集 ├── service # 账户、撮合引擎、行情生成业务规则都在这里 ├── dao # JDBC 数据访问负责落库和查询 └── domain # User、TradeOrder、DealRecord、Position 等实体依赖方向是单向的view 调 serviceservice 调 daodomain 是公共数据载体。service 层绝对不 import javax.swingdao 层也绝对不出现业务判断。这样分完之后你在答辩时可以说「UI 层的登录按钮只负责把用户名密码交给 service 去校验数据库连接方式换成连接池UI 层不需要改动」。这句话比功能演示更能证明你理解了分层。一个额外收益是调试成本下降。比如行情价格异常先看 service 层的生成逻辑不用在监听器里翻几千行找数据到底从哪来的。对于课设这种「交付时可能被抽查代码」的场景类之间职责清晰比代码写得花哨重要得多。2.2 订单簿用优先队列而不是普通 List撮合复杂度差了一个量级股票模拟系统里最核心的数据结构是订单簿。买盘按价格从高到低排队卖盘按价格从低到高排队同价按时间先后排队。这个「按优先级出队」的语义天然是堆的活。用 ArrayList 存订单每次撮合都要线性扫描找最优价用 PriorityQueueJava 的优先队列底层是二叉堆插入和取最值都是 O(log n)。一次撮合要取买家最高价和卖家最低价各一次n 笔订单下整体复杂度从 O(n²) 降到 O(n log n)。这个对比放在答辩里就是实打实的加分点。// 买盘价格高者优先同价先到先得 QueueOrder buyOrders new PriorityQueue( (o1, o2) - o2.getPrice().compareTo(o1.getPrice()) ! 0 ? o2.getPrice().compareTo(o1.getPrice()) : o1.getTime().compareTo(o2.getTime()) ); // 卖盘价格低者优先同价先到先得 QueueOrder sellOrders new PriorityQueue( (o1, o2) - o1.getPrice().compareTo(o2.getPrice()) ! 0 ? o1.getPrice().compareTo(o2.getPrice()) : o1.getTime().compareTo(o2.getTime()) );这里有两个细节值得在代码注释里写清楚。第一价格比较用 BigDecimal 的 compareTo 而不是 equals原因是 equals 还比较精度1.0 和 1.00 会被判为不同但它们在订单簿里必须是一个价位。第二时间戳字段建议直接存 long 型的 System.currentTimeMillis()不需要在队列里存 Date 对象做比较排序更快落库时再转 DATETIME。队列初始化后撮合循环就很简洁同时看两个队头能成交就处理剩余量不能成交就退出。这个逻辑放到第 4 章展开这里先记住一个原则——PriorityQueue 只在内存里维护订单顺序订单本身的持久化还在数据库两者不冲突。2.3 行情环存、持仓映射与自选列表三种典型数据结构的使用位置订单簿之外还有三处数据结构选型容易在答辩时被追问。第一是行情快照。实时价格要被表格、K 线图、持仓盈亏同时读取用 ConcurrentHashMapString, Stock 存代码到行情对象的映射读多写少比用 Hashtable 锁整个表要合理。更新行情时只 put 单只股票其他股票的读操作不会受阻塞。第二是 K 线滑动窗口。只保留最近 N 根分钟 K 线新数据进来把最老的挤掉用数组实现的环形缓冲最合适。public class RingBuffer { private final double[] data; private int head; private int size; public RingBuffer(int capacity) { this.data new double[capacity]; } public void add(double value) { data[(head size) % data.length] value; if (size data.length) { head (head 1) % data.length; // 满了就覆盖最老的一格 } else { size; } } public double avg() { double sum 0; for (int i 0; i size; i) { sum data[(head i) % data.length]; } return size 0 ? 0 : sum / size; } }用它算最近 N 分钟的均价复杂度是 O(1)不需要每次重新遍历整个历史表。如果把「均价高于实时价提示风险」做成一个小标签放在界面里这一处设计足够支撑一个追问「你这里为什么不用 LinkedList」答案就是数组索引定位 O(1)、GC 压力小且容量固定。第三是持仓映射。默认用 HashMapString, Position 以股票代码为键取持仓 O(1)。但如果自选股面板需要按涨跌幅排序展示不要在 HashMap 上强行排序而是排序时把 entrySet 拉出来丢进临时列表用 Collections.sort。原因是持仓数量通常是个位数到几十个数量级排序成本可忽略没必要为了排序引入一个始终维护有序性的 TreeMap 把写操作拖慢。3. 数据库表设计与 JDBC 落地把行情、订单、持仓变成可对账的账3.1 建表脚本六张表和它们的字段语义模拟交易系统再怎么简化「用户、账户、股票、委托、成交、K 线」这六类数据一样都不能少。账户和用户拆开是为了以后扩展「一个用户多个模拟账户」留余地。委托表和成交表拆开是因为一笔委托可能被拆成多笔成交这是真实撮合语义。CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, available_balance DECIMAL(18,2) DEFAULT 1000000.00, frozen_balance DECIMAL(18,2) DEFAULT 0.00, CONSTRAINT fk_account_user FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_stock ( code VARCHAR(10) PRIMARY KEY, name VARCHAR(20) NOT NULL, last_price DECIMAL(10,2) NOT NULL, updated_at DATETIME ); CREATE TABLE t_trade_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, stock_code VARCHAR(10) NOT NULL, side TINYINT NOT NULL COMMENT 1买 2卖, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, status TINYINT NOT NULL COMMENT 0未成交 1部分成交 2全部成交 3已撤单, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_deal_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, stock_code VARCHAR(10) NOT NULL, deal_price DECIMAL(10,2) NOT NULL, deal_quantity INT NOT NULL, deal_amount DECIMAL(18,2) NOT NULL, deal_time DATETIME DEFAULT CURRENT_TIMESTAMP );DECIMAL(18,2) 是故意选的。模拟盘初始资金 100 万单股价格按 10200 元算买一万股刚好 200 万DECIMAL(18,2) 足够覆盖且不会出现 double 那种 0.10.2 不等于 0.3 的精度问题。t_trade_order 要建索引在 (user_id, status) 上因为「查我的未成交委托」是最频繁的查询。t_deal_record 的 order_id 保留是为了演示时点开一笔委托能看到它被拆成了几次成交。K 线表按分钟落库就够了字段是 stock_code、kline_time、open、high、low、close、volume联合主键 (stock_code, kline_time)。这笔数据不用实时写每 N 秒由内存里的环形缓冲聚合一次再批插入避免高频行情把数据库拖垮。3.2 JDBC 工具类、事务提交与连接参数先能跑再谈优化课设阶段不需要上数据库连接池一个简单的 JDBC 工具类足够。但连接参数里有两个坑必须提前避开MySQL 8 的驱动类名是 com.mysql.cj.jdbc.Driver不再是 com.mysql.jdbc.DriverserverTimezone 必须显式指定否则会抛时区异常。public class DbUtil { private static final String URL jdbc:mysql://localhost:3306/stock_sim ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement st, ResultSet rs) { if (rs ! null) try { rs.close(); } catch (SQLException ignored) {} if (st ! null) try { st.close(); } catch (SQLException ignored) {} if (conn ! null) try { conn.close(); } catch (SQLException ignored) {} } }写入操作一定要手动控制事务。最典型的场景是「买入成交」涉及三张表成交记录插入、账户余额扣减、订单状态更新。任何一个失败账就对不上了。Connection conn DbUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 扣减账户余额 // 2. 更新订单状态为全部成交 // 3. 插入成交记录 conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DbUtil.close(conn, null, null); }连接池的话题可以在答辩时提一句「生产环境会用连接池管理连接课设里为了让你看清链路所以用 DriverManager」。但前提是你在配置文件里留了 db.properties 而不是把用户名密码写死在常量类里。常见的做法是 Properties.load 读取这里就不再贴完整代码知道这个方向即可。3.3 拿到 zip 后从导入到第一条记录落库的完整路径压缩包解压后先用一小时做环境核对而不是直接双击运行脚本。我见过的翻车大半发生在「IDEA 里 Run 起来报 ClassNotFoundException」或者「表结构对不上」。按下面顺序操作用 Navicat 或命令行执行建库脚本库名用 stock_sim核对 SQL 脚本里的 CREATE DATABASE 语句是否包含字符集设置打开 db.properties把 jdbc.url、username、password 改为本机实际值在 IDEA 中打开项目确认 Project SDK 是 1.8 或更高Language level 与编译版本一致在 Project Structure 的 Libraries 里把 mysql-connector-java 的 jar 包加进来确认它出现在最终 artifact 里找到带 main 方法的入口类通常是 LoginFrame 或 MainFrame先跑一次跑通后做一次冒烟验证注册一个用户、查看初始资金是否为 100 万、下买入单、去数据库查 t_trade_order 是否多了一行。这条链路通了后面所有演示环节才谈得上。4. 核心交易逻辑实现撮合、行情刷新与线程安全4.1 撮合引擎价格优先、时间优先的完整成交流程撮合引擎是整个系统里最有讲头的一段代码。它的输入是两个优先队列——买盘队列和卖盘队列循环逻辑一句话概括买一价格 卖一价格就能成交成交量取两者剩余量的较小值成交价取卖一价成交后把剩余量放回原队列继续比对。public ListDeal match(QueueOrder buys, QueueOrder sells) { ListDeal deals new ArrayList(); while (!buys.isEmpty() !sells.isEmpty()) { Order buy buys.peek(); Order sell sells.peek(); if (buy.getPrice().compareTo(sell.getPrice()) 0) { break; // 买方最高价够不到卖方最低价 } int quantity Math.min(buy.getRemaining(), sell.getRemaining()); Deal deal new Deal(); deal.setBuyOrderId(buy.getId()); deal.setSellOrderId(sell.getId()); deal.setStockCode(buy.getStockCode()); deal.setPrice(sell.getPrice()); // 成交价按卖一价 deal.setQuantity(quantity); deals.add(deal); buy.setRemaining(buy.getRemaining() - quantity); sell.setRemaining(sell.getRemaining() - quantity); if (buy.getRemaining() 0) buys.poll(); if (sell.getRemaining() 0) sells.poll(); // 没成交完的订单留在队里下一轮继续比较 } return deals; }成交价按卖一价而不是买一价这是模拟盘常用的简化规则真实交易所的撮合中间价规则更复杂。你在答辩时主动说出「我这个模型做了简化真实场景要考虑中间价和最小变动价位」比等评委来问要主动得多。要注意的是这里的 peek 不会移除订单只有剩余量归零才 poll。如果买单一笔很大可能连续吃掉好几档卖单每吃一档就产生一条 Deal 记录正好对应数据库里 t_deal_record 的一行。将这次循环产生的 List 批量入库不要一条条 commit。4.2 行情生成与界面回显SwingWorker 把价格刷新的结果送回 EDT行情数据在模拟系统里通常是一个后台线程循环更新。这里最容易犯的错误是直接在守护线程里调用 table.setValueAt——Swing 组件不是线程安全的任何 UI 操作必须在事件分发线程EDT上执行。直接别处改 UI 的后果是界面偶发闪烁、半刷新甚至直接抛异常。class PriceFeedWorker extends SwingWorkerVoid, PriceTick { private final Random random new Random(); Override protected Void doInBackground() throws Exception { while (!isCancelled()) { Thread.sleep(1000); ListStock stocks stockDao.findAll(); ListPriceTick ticks new ArrayList(); for (Stock s : stocks) { double change (random.nextDouble() - 0.5) * 2 * 0.02; BigDecimal newPrice s.getLastPrice() .multiply(BigDecimal.valueOf(1 change)) .setScale(2, RoundingMode.HALF_UP); PriceTick tick new PriceTick(s.getCode(), newPrice); ticks.add(tick); latestPrices.put(s.getCode(), newPrice); } publish(ticks.toArray(new PriceTick[0])); } return null; } Override protected void process(ListPriceTick chunks) { // 此方法一定运行在 EDT 上可以安全更新表格 for (PriceTick tick : chunks) { stockTableModel.updatePrice(tick.getCode(), tick.getPrice()); } } }SwingWorker 的 publish/process 机制就是设计出来解决这个问题的。doInBackground 在后台线程跑循环process 汇总后切回 EDT 批量更新界面。注意 setScale(2, RoundingMode.HALF_UP) 保证价格只保留两位小数。还有一点我踩过坑后台线程里每秒查一次全表股票虽然不是大问题但明显浪费。更合理的做法是启动时把股票列表加载进内存的 ConcurrentHashMap后台线程只更新内存对象定时任务再批量把快照写回数据库。数据库是模拟系统的「存档」不是行情数据的实时通道。4.3 账户与持仓的并发控制synchronized 锁在哪个对象上模拟盘允许同时开启多个窗口下单或者一个窗口里连续快速点击买入。如果账户扣款和持仓变更不清理并发「余额扣了但持仓没到账」「卖出数量超过持仓」这些问题就会冒出来。这其实是 java 面试八股文里经典的并发问题换了个皮肤。public synchronized TradeResult buy(Order order, Account account) { BigDecimal cost order.getPrice().multiply(BigDecimal.valueOf(order.getQuantity())); if (account.getAvailableBalance().compareTo(cost) 0) { return TradeResult.fail(可用余额不足需要 cost 实际 account.getAvailableBalance()); } account.setAvailableBalance(account.getAvailableBalance().subtract(cost)); Position position positions.get(order.getStockCode()); if (position null) { position new Position(order.getStockCode(), BigDecimal.ZERO, BigDecimal.ZERO); positions.put(order.getStockCode(), position); } position.setQuantity(position.getQuantity() order.getQuantity()); position.setCostAmount(position.getCostAmount().add(cost)); return TradeResult.ok(); }把整个买入动作锁在方法上听着粗暴但在单机模拟盘这个场景下是正确的取舍。课设系统没有微服务没有分布式业务都在一个 JVM 里方法级锁足够保证账户余额和持仓的一致性。如果你用锁对象注意别锁字符串常量两个不同的 key 如果值相同会互相同步造成无意义的等待。余额不足的校验一定要放在扣款之前并且校验和扣款必须处于同一把锁下。先校验再放锁再扣款中间另一个线程插进来余额就被透支了。这个「检查和动作之间要有原子性」的结论值得在答辩论证时反复强调。5. 翻车点避坑与排查五个常见问题的现象、原因和解决顺序5.1 界面卡死登录按钮点下去整个窗口白屏现象点击登录后 JFrame 无响应转圈或者白屏过几秒恢复严重时直接 NotResponding。原因登录按钮的监听器里直接执行了数据库查询而鼠标事件回调运行在 EDT 上一条慢 SQL 或网络抖动就把界面线程堵住了。数据量一大这个卡顿就越明显。解决所有可能耗时超过几十毫秒的操作丢进 SwingWorker 或普通线程结果回来后再用 SwingUtilities.invokeLater 或 worker 的 process 刷新界面。登录按钮里只做「启动登录任务」这一个动作不要在里面查库。把这条写好评委的「你的界面为什么流畅」就有答案了。5.2 连不上数据库ClassNotFoundException 或 CommunicationsException现象点登录立刻报错控制台出现 java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver或者 Communications link failure。原因前者是驱动 jar 没被正确打进运行环境后者八成是 MySQL 版本和 URL 参数不匹配。MySQL 5.7 用 com.mysql.jdbc.Driver 还能过MySQL 8 必须用 com.mysql.cj.jdbc.Driver 且 URL 里加 serverTimezone。解决先确认用的驱动 jar 版本。IDE 里添加完 Library 后去 Artifacts 里勾选 include in project build很多 IDEA 项目里 Library 存在但打包时没带上。连接参数统一走 db.properties排查时只需要改一个文件不要在代码里到处找硬编码的 URL。5.3 卖出金额和买入成本对不上账持仓成本越算越乱现象演示买入 100 股、卖出 100 股后账户总资产不等于初始资金差额正好是买入卖出的某个百分比。原因用 double 存价格和金额累加多次后精度丢失。比如 19.99 元的价格在 double 里实际是 19.989999999999998乘上股数放大后误差变成几毛钱再经过几笔交易就完全对不上。解决价格、金额、余额全部用 BigDecimal构造时用字符串构造器 new BigDecimal(19.99)不要用 new BigDecimal(19.99)。JDBC 读取 DECIMAL 字段时用 getBigDecimal 拿到就是 BigDecimal不要转成 double 再转回来。这是所有对账问题的根源。5.4 注册成功后列表不刷新偶尔空指针现象新用户注册完成页面上的用户列表没有新数据点击某些行还会报 NullPointerException。原因注册成功后的数据加载仍然执行在旧逻辑里拿的是注册前的列表引用或者界面刷新和后台数据加载的时序不对窗口还没完成初始化数据触达 UI 时表格模型还是 null。解决数据变更后统一走「重新查询→构建新的 TableModel→setModel 刷新」这一条路径。空指针排查时先打印堆栈看是哪个对象为 null八成是领域对象没有初始化例如 new ArrayList() 没赋值就 add。不要在 getter 里返回 null集合属性在声明处直接初始化。5.5 快速双击下单持仓出现负数现象快速点击买入按钮明明余额足够买 100 股最终持仓变成 -20 股或者余额变负数。原因两次点击事件都在余额校验完成前进入了扣款逻辑或者卖出时没有检查当前持仓量。数据库层面没有约束程序层面也没有加锁负库存就出现了。解决下单入口加 synchronized 见 4.3 的写法卖出在下单前先查 position.getQuantity()不足直接拒绝。另外可以在 t_account 表加 CHECK (available_balance 0)MySQL 8.0.16 以后会强制生效作为最后一层防线。程序锁加数据库约束双层保护演示时再怎么点都不怕。6. 答辩前只练这三件事数据验证、演示节奏与加分说辞答辩演示前我会先跑一遍对账 SQL确认账是平的。账不平的模拟盘演示出去功能再多也圆不回来。对账思路不复杂用户的「当前可用余额 各持仓市值 所有成交扣费」应该等于初始资金。SQL 写成这样SELECT u.username, a.available_balance, SUM(p.quantity * s.last_price) AS market_value, a.available_balance SUM(p.quantity * s.last_price) AS total_assets FROM t_user u JOIN t_account a ON u.id a.user_id LEFT JOIN t_position p ON p.user_id u.id LEFT JOIN t_stock s ON s.code p.stock_code GROUP BY u.id;如果每个用户的 total_assets 都等于初始资金说明撮合、扣款、持仓更新是一致的。这条 SQL 跑通你演示时心里有底。演示节奏我习惯控制在十分钟第一分钟界面行情刷新动起来让评委看到价格在每秒跳动中间五分钟做一笔完整买入和一笔卖出强调撮合队列的价格优先和时间优先最后两分钟打开数据库对着 t_trade_order 和 t_deal_record 展示买卖数据是怎么落库的。这比反复切窗口讲功能更能体现数据结构课程的训练痕迹。加分说辞记得准备两个。一是撮合队列为什么用堆而不用数组线性扫描答「ArrayDeque 和 List 得 O(n) 扫描PriorityQueue 插入和取最值 O(log n)」二是 ConcurrentHashMap 和 Hashtable 的差别答「锁粒度不同前者锁桶后者锁整表」。这类问题就是常见的 java 基础面试题变体提前背熟被追问不慌。我自己的习惯是正式演示前至少完整过三遍流程中间故意制造一次「余额不足」的失败弹窗证明异常路径也处理了。这一手比单纯展示顺利路径更让评委相信系统是你自己写的。希望帮到你。本文还有配套的精品资源点击获取
返回列表