ARTICLE DETAIL

资讯详情

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

Java课程设计实战:Swing+JDBC+JFreeChart实现股票交易模拟系统

Java课程设计实战:Swing+JDBC+JFreeChart实现股票交易模拟系统 简介一份完整的股票行情与交易模拟系统课程设计资料包基于Java Swing开发图形界面并整合数据库、JFreechart图表库及新浪股票数据接口重点解决实时行情揭示、模拟交易、技术指标与策略分析等实验需求适合高校学生或Java初学者用于课程设计、毕业设计或项目练手。压缩包共187个文件总大小约10.79MB核心为54个Java源文件与87个编译后的class文件另有20个Jar依赖库提供图表和数据库驱动支持9张PNG/JPG图片展示界面效果2个txt及1个doc包含说明信息1个ppt可用于答辩展示整体结构清晰便于按需查阅。已有758人学习下载。内容覆盖随机数模拟行情、K线/走势图绘制、账户资金管理、模拟买卖委托等技术模块可对照源码体会链表、队列等数据结构在数据缓存与交易排队中的应用同时学习文件读写、数据库访问、程序测试用例设计等工程实践。运行后可快速查看图形化界面与交易流程适合直接作为课程设计参考或在此基础上扩展实时行情接入、策略回测等进阶功能。1. 为什么一个课程设计要同时对付数据库、链表和 K 线图如果你只把“股票交易模拟系统”当成一个 Java Swing 作业你会低估它。真正动手拆这个项目时你会发现它同时踩中了四个最容易被面试官追问的坑Swing 线程模型怎么跟数据刷新共存、随机数模拟的行情如何让技术指标看起来不假、买卖委托用队列还是链表排队、以及 JFreeChart 的数据集为什么不能直接塞进一个ArrayList。这篇博文基于实际完成的课程设计包源码 数据库 PPT展开把每一处“看起来能跑就行”的代码拆开讲清楚包括参数怎么调、内存有什么隐患、界面卡顿的根因在哪。适合正在做数据结构或 Java 课程设计的人也适合想快速把 Swing JDBC 图表组件串起来做原型的一线开发。2. 行情数据层随机数模拟与新浪接口的取舍以及 K 线图绘制2.1 随机数模拟器的实现别让股价像白噪声课程设计的实验思路里明确写了“用随机数来代替传统股票系统从服务器得到的数据”。这是最稳妥的起步方案但直接用Math.random()会产生完全独立的随机值画出来的股价走势会像心电图一样上下乱跳没有任何趋势性。常见做法是为行情源抽象一个接口内部维护上一笔收盘价然后围绕它做带漂移的扰动。public class RandomQuoteProvider implements QuoteProvider { private double lastPrice; private final double drift 0.0002; // 微小趋势项 private final double volatility 0.015; // 每日波动率 public RandomQuoteProvider(double initialPrice) { this.lastPrice initialPrice; } Override public Tick nextTick() { double noise (Math.random() - 0.5) * 2 * volatility; double change drift noise; lastPrice Math.max(0.01, lastPrice * (1 change)); long now System.currentTimeMillis(); return new Tick(now, lastPrice, lastPrice * (1 (Math.random() - 0.5) * 0.002)); } }代码里drift是趋势项控制价格长期是上涨还是下跌volatility控制单次波动的幅度。改用这两个参数时要注意模拟日内分时行情可以把volatility调大到 0.03模拟日 K 线则保持在 0.010.02 之间。这个类同时负责生成Tick内部持有lastPrice这就是一个典型的状态类应用。如果你把lastPrice设计成static多个线程各自调用时会互相污染建议每个交易品种单独 new 一个 provider。2.2 用队列与链表维护历史行情序列在数据结构层面本项目最大的价值不是写了二叉树或哈希表而是用对了最朴素的两种结构队列管理实时 tick 流链表维护 K 线历史。实时行情是一个典型的“生产者消费者”模型行情线程不断向尾部追加新 tick图表面板从头部读取最近 N 条。如果只取最近 500 条数组也能做但扩容和头部移除的成本是 O(n)。这里用ArrayDeque更合适因为它底层是循环数组头尾操作都是 O(1)。DequeTick tickBuffer new ArrayDeque(); final int MAX_BUFFER_SIZE 500; public synchronized void appendTick(Tick tick) { if (tickBuffer.size() MAX_BUFFER_SIZE) { tickBuffer.pollFirst(); // 淘汰最旧 } tickBuffer.addLast(tick); }注意synchronized在这里不是可选项。Swing 的Timer回调线程和行情生成线程如果同时操作Deque轻则ConcurrentModificationException重则导致图表数据错位。另一种做法是LinkedBlockingQueue它可以天然支持offer和poll但你需要自己在消费端聚合 K 线。课程设计里强调链表应该理解为对“顶点覆盖”的灵活操作——补丁就是让你看到当数据源是流式时队列比链表更好用。如果想在作业里同时展示链表可以把历史委托记录设计成LinkedListOrder按时间正序插入头部这样最新委托在getFirst()遍历时就是倒序。2.3 JFreeChart 渲染 K 线图的正确数据集姿势JFreeChart 画 K 线不能直接用DefaultCategoryDataset必须用OHLCDataset实现。项目里KChartsGragh类处理的正是这件事。常见做法是维护一个DefaultHighLowDataset或者自己实现OHLCDataset接口但课程设计阶段直接使用DefaultHighLowDataset最省事。// 构造 OHLC 数据 Date[] dates new Date[klineList.size()]; double[] highs new double[klineList.size()]; double[] lows new double[klineList.size()]; double[] opens new double[klineList.size()]; double[] closes new double[klineList.size()]; double[] volumes new double[klineList.size()]; for (int i 0; i klineList.size(); i) { KLine k klineList.get(i); dates[i] new Date(k.getTimestamp()); highs[i] k.getHigh(); lows[i] k.getLow(); opens[i] k.getOpen(); closes[i] k.getClose(); volumes[i] k.getVolume(); } DefaultHighLowDataset dataset new DefaultHighLowDataset( stock, dates, highs, lows, opens, closes, volumes); JFreeChart chart ChartFactory.createCandlestickChart( 分时行情, 时间, 价格, dataset, false); XYPlot plot (XYPlot) chart.getPlot(); plot.setDomainPannable(true);关键点是Date[]和double[]必须等长且按下标对齐千万不要在循环里对一个ArrayList排序后再填数组否则日期和价格就错位了。JFreeChart的createCandlestickChart默认会把周末空缺也画成空白如果你希望只显示交易日要给DateAxis设置setLowerMargin和setUpperMargin并考虑隐藏非交易时间段。这部分我在实际调KChartsGragh时踩过坑数据长度不足 2 根 K 线时图形区域会直接空白所以至少要预填充 2 根初始 K 线。3. 交易引擎与账户管理数据库表设计、JDBC 操作与委托队列3.1 账户与持仓表的 ER 设计系统里ConectToDataBase和ConectSQL这两个类承担了所有 JDBC 逻辑但表结构才是最值得先讲的部分。模拟交易系统不需要完整券商级的数据模型但至少要覆盖用户、资金、持仓、委托、成交五张表。课程设计唇枪舌战后我发现把“委托”和“成交”合并成一张表会出问题撤单时状态不好维护成交均价计算也麻烦。推荐的最小表结构如下表名关键字段说明userid, username, password, balance账户与可用资金stockcode, name, current_price股票基本信息positionuser_id, stock_code, quantity, avg_cost持仓联合主键orderid, user_id, stock_code, direction, price, quantity, status, time买卖委托status 区分 pending/filled/cancelledtradeid, order_id, stock_code, price, quantity, time成交记录用于对账用order表区分买卖方向时建议用TINYINT而不是字符串0表示买入1表示卖出。原因一是查询快二是避免上层到处比较BUY.equals(direction)。课程设计的 PPT 里如果能画出这个 ER 图会比贴一堆代码更容易拿高分。3.2 JDBC 连接与 PreparedStatement 的防注入写法ConectSQL.class从命名上看是负责 SQL 操作的但容易出现一种坏味道每个按钮事件里都DriverManager.getConnection一次然后Statement拼字符串。这在单机课程设计里看不出问题但会让你在答辩时被问到“为什么不用连接池”时哑口无言。public class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/stock_sim; private static final String USER root; private static final String PASSWORD 123456; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static int executeUpdate(String sql, Object... args) throws SQLException { try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i args.length; i) { ps.setObject(i 1, args[i]); } return ps.executeUpdate(); } } }买入操作的 SQL 可以写成String sql UPDATE user SET balance balance - ? WHERE id ? AND balance ?; int rows JdbcUtil.executeUpdate(sql, cost, userId, cost); if (rows 0) { throw new IllegalStateException(余额不足); }这里的核心技巧是把“扣余额”和“判断余额是否足够”合并到一条UPDATE的WHERE条件里。rows 0时既可能是余额不足也可能是用户 id 不存在需要在业务层再查一次区分。PreparedStatement比Statement更安全也避免了行情价格里出现单引号的极端情况。要注意executeUpdate返回的是影响行数不是自增主键想拿主键要用RETURN_GENERATED_KEYS。3.3 买卖委托的队列模型与撮合时机真实交易所撮合用的是价格优先、时间优先但课程设计不必做到那个深度用队列模拟逐个撮合更能体现数据结构课的内容。买入委托和卖出委托各维护一个DequeOrder新委托进入队列尾部系统每隔一定时间比如 300ms尝试撮合队首的买单和队首的卖单。撮合条件为买单价格卖单价格成交价取两者中间价或先进入队列的委托价。QueueOrder buyQueue new LinkedList(); QueueOrder sellQueue new LinkedList(); public void match() { while (!buyQueue.isEmpty() !sellQueue.isEmpty()) { Order buy buyQueue.peek(); Order sell sellQueue.peek(); if (buy.getPrice() sell.getPrice()) { // 成交数量为两者较小值 int tradeQty Math.min(buy.getRemainingQty(), sell.getRemainingQty()); double tradePrice sell.getPrice(); // 简单起见以卖价成交 // 更新订单和持仓写入 trade 表 buy.setRemainingQty(buy.getRemainingQty() - tradeQty); sell.setRemainingQty(sell.getRemainingQty() - tradeQty); if (buy.getRemainingQty() 0) buyQueue.poll(); if (sell.getRemainingQty() 0) sellQueue.poll(); } else { break; // 价格不满足后续也撮合不了 } } }用LinkedList是因为Deque接口下它具备明确的“先进先出”语义而且课时里重点讲过链表实现队列的操作代价。撮合循环里有一个隐蔽的问题如果买价始终低于卖价while 会一直卡在peek不推进所以在else里必须break否则定时器调用会进入死循环。更精细的逻辑是同时维护价格桶比如每个价位一个队列那已经是市场深度图HandicapJPanel的范畴了后面第四节会提到。4. 界面与交互Swing 组件树、MVC 模式与自动补全的数据结构4.1 拆解界面类从 MainFrame 到每个 JPanel从项目代码清单能看出界面部分不是一个巨无霸JFrame而是拆成了HandicapJPanel盘口、SpecificStockJPanel个股详情、CountMessageJPanel统计信息、BuyPanel买入面板等多个面板。这种拆分方式本身就是 Swing 开发的标准实践每个面板只负责自己的模型和渲染主窗口只做布局装配。我通常会在启动类里写这样的骨架public static void createAndShowGUI() { StockMarketModel model new StockMarketModel(); MainFrame frame new MainFrame(model); frame.addTab(行情, new HandicapJPanel(model)); frame.addTab(个股, new SpecificStockJPanel(model)); frame.addTab(买入, new BuyPanel(model)); frame.setVisible(true); }这里的一个设计要点是把StockMarketModel共享给所有面板面板之间不直接相互引用避免出现“买入面板成功后去通知行情面板刷新”这种强耦合。具体的刷新通过 Swing 的PropertyChangeSupport或者自定义Listener完成。BuyPanel里点击“买入”后只需要调用model.buy(stockCode, price, qty)模型内部处理数据库和撮合然后firePropertyChange(position, ...)其他人各自刷新。4.2 用 MVC 解耦股票列表与行情展示SpecificStockJPanel通常左边是一个JList或JTable右边是 JFreeChart。如果直接在ListSelectionListener里 new 一个ChartPanel然后removeAll/add会频繁触发重绘界面会很卡。正确的做法是让图表只有一份实例监听选中项变化后只更新数据集不重建ChartPanel。JListString stockList new JList(new DefaultListModel()); stockList.getSelectionModel().addListSelectionListener(e - { if (!e.getValueIsAdjusting()) { String code stockList.getSelectedValue(); if (code ! null) { model.updateCurrentStock(code); chartUpdateListener.onStockChanged(code); } } });e.getValueIsAdjusting()这个判断很重要。鼠标拖动选中时ListSelectionEvent会触发多次如果不判isAdjusting一次拖动可能触发五六次数据库查询。项目里的AutoCompletionField其实也是这个思路在DocumentListener里做过滤但每次输入一个字符就全量查库是完全不可接受的所以要用 4.3 节的补丁方案。4.3 AutoCompletionField为什么这里用 Trie 比 ArrayList.contains 快AutoCompletionField是搜索框输入股票代码或拼音首字母时拉出候选列表的组件。最直接的实现是ArrayList里contains但股票数量一多每敲一个字符都遍历一次开销是 O(n)。更贴合课程设计的技术方案是前缀树也就是 Trie。class TrieNode { MapCharacter, TrieNode children new HashMap(); ListString codes new ArrayList(); // 存储该节点对应的股票代码 } public ListString suggest(String prefix) { TrieNode node root; for (char c : prefix.toCharArray()) { node node.children.get(c); if (node null) { return Collections.emptyList(); } } return node.codes; }每次插入股票代码时从根节点逐字符走codes存的是“以当前路径为前缀”的所有股票代码。这样查询开销只与前缀长度有关而不是股票总数。代码里用Map而不是数组桶是为了兼容字母和数字混合的代码如果你确定只处理A-Z0-9用固定 36 长度数组更快。这块是答辩时最容易被追问的——因为它是纯数据结构题和界面并无直接关系。4.4 盘口面板与统计信息面板的刷新策略HandicapJPanel展示的是五档买卖盘在模拟系统里就是买一卖一价格和数量。刷新频率不需要跟行情 tick 保持一致一般 500ms 刷新一次即可。不要直接用Thread.sleep然后调repaint()正确做法是javax.swing.TimerTimer refreshTimer new Timer(500, e - { model.refreshMarketData(); updateHandicapLabels(); }); refreshTimer.start();javax.swing.Timer的回调执行在 EDT事件分发线程上所以updateHandicapLabels可以直接操作组件不需要再包一层SwingUtilities.invokeLater。这一点比java.util.Timer安全得多后者回调在非 EDT 线程一旦直接操作 Swing 组件会随机出现InterruptedException或界面不刷新。CountMessageJPanel里的统计信息比如涨跌家数、总成交量建议用单独的长周期 Timer比如 2 秒避免高频统计导致 CPU 飙高。5. 数据持久化与报表输出文件读写、图表导出与技术指标计算5.1 交易记录的两种持久化方式比较课程设计要求“文件的读写操作”这里有两种理解一是把交易记录导出为文本或 CSV二是程序启动时从文件恢复上次的持仓状态。我在这个项目里两种都做了但更推荐把“数据库”作为主存储把“文件”作为报表导出渠道这样两个知识点都能展示。导出 CSV 的核心代码非常简单try (BufferedWriter writer Files.newBufferedWriter(Paths.get(trades.csv), StandardCharsets.UTF_8)) { writer.write(时间,股票,方向,价格,数量); writer.newLine(); for (Trade t : tradeList) { writer.write(String.format(%s,%s,%s,%.2f,%d%n, t.getTime(), t.getCode(), t.getDirection(), t.getPrice(), t.getQty())); } }用BufferedWriter包裹字符流是为了减少单条写入时刷盘的次数。这里有个细节写入中文时一定要指定UTF_8否则 Excel 打开 CSV 会乱码。如果不想写 BOM就在 Excel 里用“数据-自文本”导入时选择 UTF-8。这个导出函数适合放在“统计输出”按钮里一次性把所有成交记录落盘。5.2 技术指标计算MA 与 RSI 的滑动窗口实现CountMessageJPanel里通常展示均线或涨跌幅。计算 MA 最容易犯的错误是每次都重新求和导致时间复杂度 O(n*m)。用移动窗口维护一个累计和只需要 O(1) 更新public double[] calcMA(double[] closes, int window) { int len closes.length; double[] ma new double[len]; double sum 0; for (int i 0; i len; i) { sum closes[i]; if (i window) { sum - closes[i - window]; } ma[i] sum / Math.min(i 1, window); } return ma; }注意i window时ma[i]是前面所有数的平均值不是完整窗口平均值。如果你只在图上画ma[window-1]之后的点前面的点可以填Double.NaNJFreeChart 会自动跳过空值。RSI 的计算要保存前一个涨跌幅计算量不大但 EMA 平滑因子alpha 2 / (period 1)这个公式最好写进注释不然过两周自己看也会忘。5.3 将图表导出为 PNGJFreeChart 的编码细节项目的 PPT 里会展示生成的 K 线图但如果你想把图存成文件例如写实验报告时使用核心代码是BufferedImage image chart.createBufferedImage(800, 450); ImageIO.write(image, png, new File(kline.png));这里有一个与会话方案冲突的坑createBufferedImage在绘制大量数据点时如果监听的系列数量多可能会出现文字乱码或者中文变方块。这时候需要设置chart.getTitle().setFont(new Font(宋体, Font.BOLD, 16))并且SwatchColor不要用默认那套太深的背景导出后的图片在 PPT 白底上会看不清。另一个实用技巧是导出前调用chart.setAntiAlias(true)否则图片边缘会有锯齿在答辩投影上很难看。6. 排错与进阶优化从界面卡顿到实时数据接口的接入6.1 三个最常见的运行时错误与排查方向第一是java.sql.SQLException: No suitable driver这通常是忘记写Class.forName(com.mysql.cj.jdbc.Driver)或者 mysql 驱动版本与 Java 版本不匹配。MySQL 8 对应com.mysql.cj.jdbc.DriverMySQL 5.x 用com.mysql.jdbc.Driver。第二是NullPointerException出现在HandicapJPanel里多半是model.getBuyOne()返回了 null原因可能是撮合队列还没有产生盘口数据。第三是ArrayIndexOutOfBoundsException出现在calcMA常见于closes长度小于window我做参数检查时会直接返回一个所有元素为Double.NaN的数组而不是让调用方崩溃。调试时建议先把日志级别打开用java.util.logging打印关键节点Logger.getLogger(stock).info(委托撮合成功: 单号 orderId 数量 tradeQty);不要用System.out.println因为 EDT 线程里的大量输出会抢占 UI 刷新时间。6.2 多线程刷新 Swing 界面的正确姿势模拟行情时最自然的想法是new Thread(() - { while(true) { updateUI(); } }).start()。这会带来两个问题非 EDT 线程直接操作 Swing 组件是未定义行为死循环导致 UI 无响应。正确做法是生产者线程只更新模型数据然后通过SwingUtilities.invokeLater安排 UI 更新new Thread(() - { while (running) { Tick t provider.nextTick(); model.appendTick(t); SwingUtilities.invokeLater(() - chartPanel.refreshChart(model.getRecentTicks())); Thread.sleep(200); } }).start();注意Thread.sleep(200)控制的是生产速度而 UI 更新如果耗时超过 200msinvokeLater会堆积一堆Runnable造成事件队列膨胀。如果发现这种情况要把 UI 刷新频率降到 500ms并在refreshChart里判断数据版本号只刷新最新版本。这是本项目最容易考到的“线程安全”知识点。6.3 从纯随机到几何布朗运动让模拟行情更像真的课程设计里需要展示“按照一定的分析模型给出数、形、文”。纯随机模型生成的曲线会让技术指标失去意义。一个进阶做法是给RandomQuoteProvider增加一个“波动率聚集”效果即大跌之后波动率变大大涨之后波动率变小。最轻量级的实现就是几何布朗运动模型public Tick nextTick() { double dt 1.0 / 240.0; // 假设一天240分钟 double shock (Math.random() - 0.5) * 2 * volatility; double driftTerm (drift - 0.5 * volatility * volatility) * dt; double shockTerm volatility * Math.sqrt(dt) * shock; lastPrice * Math.exp(driftTerm shockTerm); return new Tick(...); }Math.sqrt(dt)是几何布朗运动和普通随机游走的本质区别。把volatility从固定值改成类似volatility * (1 Math.abs(shock))的递归关系就能模拟出“波动率聚集”。这段代码放在实验报告里会是很明显的加分项因为它表明你不是只会调Math.random()。6.4 把新浪股票接口封装为可替换 Provider课程设计的文字里提到“实时数据接口”在实际源码中可能只是预留了位置。如果要接真实行情新浪股票接口是一个典型的 HTTP GET 接口返回格式类似var hq_str_sh600000浦发银行,10.50,10.55,10.60...。你可以把它封装成另一个QuoteProvider与RandomQuoteProvider实现相同接口public class SinaQuoteProvider implements QuoteProvider { private final HttpClient client HttpClient.newHttpClient(); Override public Tick nextTick() { // 实际项目中需要解析编码和字段 String url https://hq.sinajs.cn/listsh600000; // 拼接请求头 Referer 否则会被拒绝这里省略 // 解析返回字符串得到最新价构造 Tick } }这个类不需要真的上线只要在程序里留下Provider接口和参数切换口子答辩时就能说“本项目支持模拟源与实时源无缝切换”。要注意的是真实接口有网络延迟和限流所以nextTick不应该在线程里死循环轮询而要用定时任务拉取比如 3 秒一次再在本地按 1 秒插值。最后还有一个隐藏的验收技巧把模拟数据的时间戳对齐到交易日历跳过周末和时间戳为空的区间这样生成的 K 线图不会出现长时间横盘的空窗段看起来更接近真实券商软件。这一点不需要改多少代码只要在模拟器里维护一个“下一交易时间”的变量即可。本文还有配套的精品资源点击获取
返回列表