VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > 编程开发 > python数据分析 >
  • 实战:开发基本面量化选股工具

第27章 基本面量化实战(续)
27.5 实战:开发基本面量化选股工具
27.5.1 先讲个真实需求:老板要我做一个普通人也能用的选股工具
之前的选股、回测、可视化都是分开的脚本,每次选股要改一堆参数,同事想用也不会用。老板说你把这些功能整合起来,做一个傻瓜式的工具,选行业、点个按钮就能出选股结果,还要带回测报告和财务分析图。我花了两周时间做了一个桌面版的选股工具,现在全公司的投vb.net教程C#教程python教程SQL教程access 2010教程研都在用,不用再找我要数据了。今天我就把这个工具的完整开发流程讲清楚,你也能做一个自己用的专属选股工具。

27.5.2 核心功能设计:一个工具搞定所有基本面需求
工具要包含4个核心模块,覆盖从选股到分析的全流程:
1.数据更新模块:一键更新全市场最新的财务和行情数据,不用手动跑脚本
2.选股模块:内置低PE高ROE、高成长、价值陷阱规避等多个选股策略,支持自定义参数
3.回测模块:一键回测策略的历史表现,生成净值曲线和回测报告
4.分析模块:选中股票一键生成杜邦分析、营收趋势图和财务评分

27.5.3 实战:完整开发基本面选股工具
我们用PyQt做GUI界面,把之前写的所有功能整合进去,代码结构清晰,容易扩展。

  1. 实战代码:完整选股工具实现
    python
	# 1. 导入需要的库
	import sys
	import os
	import pandas as pd
	import numpy as np
	from datetime import datetime, timedelta
	import tushare as ts
	from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
	QPushButton, QComboBox, QLabel, QSpinBox, QTableWidget,
	QTableWidgetItem, QProgressBar, QMessageBox, QTabWidget,
	QCheckBox, QFileDialog)
	from PyQt5.QtCore import QThread, pyqtSignal
	import matplotlib.pyplot as plt
	from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
	
	# 导入之前写好的模块
	from 核心财务指标计算 import FinancialIndicatorCalculator
	from 基本面选股模型 import FundamentalStrategyTester
	from 财务数据可视化 import FinancialVisualizer
	
	# 初始化Tushare
	pro = ts.pro_api()
	
	# 2. 数据更新线程(避免界面卡住)
	class DataUpdateThread(QThread):
	progress_updated = pyqtSignal(int)
	finished = pyqtSignal(bool, str)
	
	def __init__(self, start_year, end_year):
	super().__init__()
	self.start_year = start_year
	self.end_year = end_year
	
	def run(self):
	try:
	# 获取全市场上市满1年的非ST股票
	self.progress_updated.emit(10)
	stock_list = pro.stock_basic(
	exchange='', list_status='L', fields='ts_code,name,industry,list_date'
	)
	stock_list = stock_list[~stock_list['name'].str.contains('ST|\*ST')]
	stock_list = stock_list[stock_list['list_date'] <= (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')]
	stock_codes = stock_list['ts_code'].tolist()
	total = len(stock_codes)
	
	# 批量计算财务指标
	calculator = FinancialIndicatorCalculator()
	all_data = []
	for idx, ts_code in enumerate(stock_codes):
	try:
	df = calculator.calculate_all_indicators(ts_code, self.start_year, self.end_year, save_result=False)
	df['ts_code'] = ts_code
	df['name'] = stock_list[stock_list['ts_code'] == ts_code]['name'].iloc[0]
	df['industry'] = stock_list[stock_list['ts_code'] == ts_code]['industry'].iloc[0]
	all_data.append(df)
	# 更新进度
	progress = int(10 + 80 * (idx + 1) / total)
	self.progress_updated.emit(progress)
	except Exception as e:
	continue
	
	# 保存数据
	self.progress_updated.emit(90)
	all_data = pd.concat(all_data, ignore_index=True)
	os.makedirs('data', exist_ok=True)
	all_data.to_csv('data/全市场财务指标.csv', index=False, encoding='utf-8-sig')
	self.progress_updated.emit(100)
	self.finished.emit(True, f'数据更新完成,共{len(all_data)}条记录')
	except Exception as e:
	self.finished.emit(False, f'数据更新失败:{str(e)}')
	
	# 3. 选股回测线程
	class StockSelectThread(QThread):
	progress_updated = pyqtSignal(int)
	finished = pyqtSignal(bool, object, str)
	
	def __init__(self, strategy, industry, hold_num, start_date, end_date):
	super().__init__()
	self.strategy = strategy
	self.industry = industry
	self.hold_num = hold_num
	self.start_date = start_date
	self.end_date = end_date
	
	def run(self):
	try:
	self.progress_updated.emit(20)
	# 读取数据
	if not os.path.exists('data/全市场财务指标.csv'):
	self.finished.emit(False, None, '请先更新数据!')
	return
	all_data = pd.read_csv('data/全市场财务指标.csv', parse_dates=['交易日'])
	self.progress_updated.emit(40)
	
	# 行业筛选
	if self.industry != '全市场':
	all_data = all_data[all_data['industry'] == self.industry]
	if len(all_data) == 0:
	self.finished.emit(False, None, '该行业无有效数据!')
	return
	
	# 初始化回测器
	tester = FundamentalStrategyTester()
	tester.fin_data = all_data
	tester.start_date = self.start_date
	tester.end_date = self.end_date
	tester.hold_num = self.hold_num
	self.progress_updated.emit(60)
	
	# 运行回测
	net_value, metrics = tester.backtest(self.strategy)
	self.progress_updated.emit(80)
	
	# 获取最新一期选股结果
	latest_quarter = all_data['交易日'].max().to_period('Q')
	latest_data = all_data[all_data['交易日'].dt.to_period('Q') == latest_quarter]
	if self.strategy == 'low_pe_high_roe':
	selected_stocks, _ = tester.select_stocks_low_pe_high_roe(latest_data)
	else:
	selected_stocks, _ = tester.select_stocks_high_growth(latest_data)
	selected_detail = latest_data[latest_data['ts_code'].isin(selected_stocks)][
	['ts_code', 'name', 'industry', '滚动PE(TTM)', '扣非ROE(TTM)(%)', '盈利现金比率(%)']
	].drop_duplicates(subset=['ts_code'])
	self.progress_updated.emit(100)
	
	result = {
	'selected_stocks': selected_detail,
	'net_value': net_value,
	'metrics': metrics
	}
	self.finished.emit(True, result, '选股回测完成')
	except Exception as e:
	self.finished.emit(False, None, f'选股失败:{str(e)}')
	
	# 4. 主界面类
	class FundamentalStockPicker(QMainWindow):
	def __init__(self):
	super().__init__()
	self.setWindowTitle('基本面量化选股工具')
	self.setGeometry(100, 100, 1200, 800)
	self.init_ui()
	self.load_industry_list()
	
	def init_ui(self):
	# 主部件和布局
	main_widget = QWidget()
	main_layout = QVBoxLayout()
	main_widget.setLayout(main_layout)
	self.setCentralWidget(main_widget)
	
	# 选项卡
	self.tabs = QTabWidget()
	self.tabs.addTab(self.create_data_tab(), '数据更新')
	self.tabs.addTab(self.create_stock_pick_tab(), '选股回测')
	self.tabs.addTab(self.create_analysis_tab(), '个股分析')
	main_layout.addWidget(self.tabs)
	
	# 创建数据更新选项卡
	def create_data_tab(self):
	widget = QWidget()
	layout = QVBoxLayout()
	
	# 参数选择区
	param_layout = QHBoxLayout()
	param_layout.addWidget(QLabel('起始年份:'))
	self.start_year_spin = QSpinBox()
	self.start_year_spin.setRange(2010, datetime.now().year - 1)
	self.start_year_spin.setValue(2018)
	param_layout.addWidget(self.start_year_spin)
	param_layout.addWidget(QLabel('结束年份:'))
	self.end_year_spin = QSpinBox()
	self.end_year_spin.setRange(2010, datetime.now().year - 1)
	self.end_year_spin.setValue(datetime.now().year - 1)
	param_layout.addWidget(self.end_year_spin)
	self.update_btn = QPushButton('开始更新数据')
	self.update_btn.clicked.connect(self.start_update_data)
	param_layout.addWidget(self.update_btn)
	param_layout.addStretch()
	layout.addLayout(param_layout)
	
	# 进度条
	self.update_progress = QProgressBar()
	layout.addWidget(self.update_progress)
	
	# 日志区
	self.update_log = QLabel('等待数据更新...')
	layout.addWidget(self.update_log)
	
	layout.addStretch()
	widget.setLayout(layout)
	return widget
	
	# 创建选股回测选项卡
	def create_stock_pick_tab(self):
	widget = QWidget()
	layout = QVBoxLayout()
	
	# 参数选择区
	param_layout = QHBoxLayout()
	param_layout.addWidget(QLabel('选股策略:'))
	self.strategy_combo = QComboBox()
	self.strategy_combo.addItems(['low_pe_high_roe(低PE高ROE)', 'high_growth(高成长)'])
	param_layout.addWidget(self.strategy_combo)
	param_layout.addWidget(QLabel('行业筛选:'))
	self.industry_combo = QComboBox()
	self.industry_combo.addItem('全市场')
	param_layout.addWidget(self.industry_combo)
	param_layout.addWidget(QLabel('持仓数量:'))
	self.hold_num_spin = QSpinBox()
	self.hold_num_spin.setRange(5, 100)
	self.hold_num_spin.setValue(20)
	param_layout.addWidget(self.hold_num_spin)
	param_layout.addWidget(QLabel('回测起始日期:'))
	self.backtest_start = QComboBox()
	self.backtest_start.addItems(['20180101', '20190101', '20200101', '20210101'])
	param_layout.addWidget(self.backtest_start)
	self.pick_btn = QPushButton('开始选股回测')
	self.pick_btn.clicked.connect(self.start_stock_pick)
	param_layout.addWidget(self.pick_btn)
	param_layout.addStretch()
	layout.addLayout(param_layout)
	
	# 进度条
	self.pick_progress = QProgressBar()
	layout.addWidget(self.pick_progress)
	
	# 选股结果表格
	layout.addWidget(QLabel('最新选股结果:'))
	self.stock_table = QTableWidget()
	self.stock_table.setColumnCount(6)
	self.stock_table.setHorizontalHeaderLabels(['股票代码', '股票名称', '所属行业', 'PE(TTM)', 'ROE(TTM)(%)', '盈利现金比率(%)'])
	layout.addWidget(self.stock_table)
	
	# 回测指标表格
	layout.addWidget(QLabel('回测核心指标:'))
	self.metrics_table = QTableWidget()
	self.metrics_table.setColumnCount(2)
	self.metrics_table.setHorizontalHeaderLabels(['指标', '数值'])
	layout.addWidget(self.metrics_table)
	
	# 净值曲线图
	layout.addWidget(QLabel('净值曲线:'))
	self.fig, self.ax = plt.subplots(figsize=(10, 4))
	self.canvas = FigureCanvas(self.fig)
	layout.addWidget(self.canvas)
	
	# 导出按钮
	self.export_btn = QPushButton('导出选股结果')
	self.export_btn.clicked.connect(self.export_stock_result)
	layout.addWidget(self.export_btn)
	
	widget.setLayout(layout)
	return widget
	
	# 创建个股分析选项卡
	def create_analysis_tab(self):
	widget = QWidget()
	layout = QVBoxLayout()
	
	# 参数区
	param_layout = QHBoxLayout()
	param_layout.addWidget(QLabel('股票代码:'))
	self.stock_code_input = QComboBox()
	self.stock_code_input.setEditable(True)
	param_layout.addWidget(self.stock_code_input)
	self.analyze_btn = QPushButton('生成分析报告')
	self.analyze_btn.clicked.connect(self.generate_analysis)
	param_layout.addWidget(self.analyze_btn)
	param_layout.addStretch()
	layout.addLayout(param_layout)
	
	# 财务评分显示
	self.score_label = QLabel('财务健康度评分:-')
	layout.addWidget(self.score_label)
	
	# 图表区
	self.analysis_fig, (self.ax1, self.ax2) = plt.subplots(2, 1, figsize=(10, 8))
	self.analysis_canvas = FigureCanvas(self.analysis_fig)
	layout.addWidget(self.analysis_canvas)
	
	widget.setLayout(layout)
	return widget
	
	# 加载行业列表
	def load_industry_list(self):
	try:
	industries = pro.stock_basic(fields='industry').drop_duplicates()['industry'].tolist()
	self.industry_combo.addItems(industries)
	# 加载股票代码列表
	stocks = pro.stock_basic(fields='ts_code,name')
	stock_list = [f"{row['ts_code']} {row['name']}" for _, row in stocks.iterrows()]
	self.stock_code_input.addItems(stock_list)
	except Exception as e:
	QMessageBox.warning(self, '警告', f'加载行业列表失败:{str(e)}')
	
	# 开始更新数据
	def start_update_data(self):
	start_year = self.start_year_spin.value()
	end_year = self.end_year_spin.value()
	if start_year > end_year:
	QMessageBox.warning(self, '警告', '起始年份不能大于结束年份!')
	return
	self.update_btn.setEnabled(False)
	self.update_thread = DataUpdateThread(start_year, end_year)
	self.update_thread.progress_updated.connect(self.update_progress.setValue)
	self.update_thread.finished.connect(self.on_update_finished)
	self.update_thread.start()
	
	# 数据更新完成回调
	def on_update_finished(self, success, msg):
	self.update_btn.setEnabled(True)
	self.update_log.setText(msg)
	if success:
	QMessageBox.information(self, '成功', msg)
	else:
	QMessageBox.critical(self, '失败', msg)
	
	# 开始选股回测
	def start_stock_pick(self):
	strategy = self.strategy_combo.currentText().split('(')[0]
	industry = self.industry_combo.currentText()
	hold_num = self.hold_num_spin.value()
	start_date = self.backtest_start.currentText()
	end_date = datetime.now().strftime('%Y%m%d')
	
	self.pick_btn.setEnabled(False)
	self.pick_thread = StockSelectThread(strategy, industry, hold_num, start_date, end_date)
	self.pick_thread.progress_updated.connect(self.pick_progress.setValue)
	self.pick_thread.finished.connect(self.on_pick_finished)
	self.pick_thread.start()
	
	# 选股完成回调
	def on_pick_finished(self, success, result, msg):
	self.pick_btn.setEnabled(True)
	if not success:
	QMessageBox.critical(self, '失败', msg)
	return
	
	# 填充选股结果表格
	selected = result['selected_stocks']
	self.stock_table.setRowCount(len(selected))
	for i, (_, row) in enumerate(selected.iterrows()):
	self.stock_table.setItem(i, 0, QTableWidgetItem(row['ts_code']))
	self.stock_table.setItem(i, 1, QTableWidgetItem(row['name']))
	self.stock_table.setItem(i, 2, QTableWidgetItem(row['industry']))
	self.stock_table.setItem(i, 3, QTableWidgetItem(f"{row['滚动PE(TTM)']:.2f}"))
	self.stock_table.setItem(i, 4, QTableWidgetItem(f"{row['扣非ROE(TTM)(%)']:.2f}"))
	self.stock_table.setItem(i, 5, QTableWidgetItem(f"{row['盈利现金比率(%)']:.2f}"))
	
	# 填充回测指标表格
	metrics = result['metrics']
	self.metrics_table.setRowCount(len(metrics))
	for i, (_, row) in enumerate(metrics.iterrows()):
	self.metrics_table.setItem(i, 0, QTableWidgetItem(row['指标']))
	self.metrics_table.setItem(i, 1, QTableWidgetItem(row.iloc[1]))
	
	# 绘制净值曲线
	self.ax.clear()
	net_value = result['net_value']
	self.ax.plot(net_value['trade_date'], net_value['strategy_net'], label='策略净值', color='red')
	self.ax.plot(net_value['trade_date'], net_value['benchmark_net'], label='沪深300', color='blue')
	self.ax.legend()
	self.ax.set_title('策略净值vs基准净值')
	self.ax.grid(alpha=0.3)
	self.canvas.draw()
	
	# 保存结果用于导出
	self.last_pick_result = selected
	QMessageBox.information(self, '成功', msg)
	
	# 导出选股结果
	def export_stock_result(self):
	if not hasattr(self, 'last_pick_result'):
	QMessageBox.warning(self, '警告', '请先进行选股!')
	return
	path, _ = QFileDialog.getSaveFileName(self, '保存文件', '', 'CSV文件(*.csv)')
	if path:
	self.last_pick_result.to_csv(path, index=False, encoding='utf-8-sig')
	QMessageBox.information(self, '成功', '导出完成!')
	
	# 生成个股分析报告
	def generate_analysis(self):
	stock_code = self.stock_code_input.currentText().split(' ')[0]
	if not stock_code:
	QMessageBox.warning(self, '警告', '请输入股票代码!')
	return
	try:
	visualizer = FinancialVisualizer()
	df = visualizer.get_financial_data_for_vis(stock_code, 5)
	score, rating = visualizer.calculate_financial_score(df)
	self.score_label.setText(f'财务健康度评分:{score:.1f}分,评级:{rating}')
	
	# 绘制杜邦分析
	self.ax1.clear()
	latest = df.iloc[-1]
	factors = ['销售净利率', '总资产周转率', '权益乘数']
	values = [latest['net_profit_margin'], latest['turnover_rate'], latest['权益乘数']*10]
	self.ax1.bar(factors, values, color=['#FF4B4B', '#3498DB', '#9B59B6'])
	self.ax1.set_title(f'{latest.year}年杜邦三因子')
	self.ax1.grid(alpha=0.3, axis='y')
	
	# 绘制营收利润趋势
	self.ax2.clear()
	self.ax2.plot(df['year'], df['revenue_yoy'], label='营收增速', marker='o')
	self.ax2.plot(df['year'], df['profit_yoy'], label='净利润增速', marker='o')
	self.ax2.plot(df['year'], df['cashflow_yoy'], label='现金流增速', marker='o')
	self.ax2.axhline(y=0, color='gray', linestyle='--')
	self.ax2.legend()
	self.ax2.set_title('近5年增速趋势(%)')
	self.ax2.grid(alpha=0.3)
	
	self.analysis_canvas.draw()
	QMessageBox.information(self, '成功', '分析报告生成完成!')
	except Exception as e:
	QMessageBox.critical(self, '失败', f'分析失败:{str(e)}')
	
	# 5. 运行程序
	if __name__ == '__main__':
	app = QApplication(sys.argv)
	window = FundamentalStockPicker()
	window.show()
	sys.exit(app.exec_())

逐行讲解:
DataUpdateThread:数据更新线程,把耗时的数据更新操作放到后台线程,避免界面卡住,实时更新进度条。
StockSelectThread:选股回测线程,同样放到后台运行,选股完成后自动填充表格和绘制净值曲线。
FundamentalStockPicker:主界面类,分三个选项卡:
数据更新页:选择年份范围,一键更新全市场财务数据,不用再手动运行脚本。
选股回测页:选择策略、行业、持仓数量,一键选股回测,结果直接展示在表格里,还能导出CSV。
个股分析页:输入股票代码,一键生成财务评分和两张核心图表,快速判断公司质地。
所有之前写的模块都直接导入复用,不用重复写代码,结构清晰,要加新功能直接加个选项卡就行。
运行结果:
生成一个桌面应用程序,界面直观,点按钮就能用,不懂代码的同事也能操作。更新一次全市场数据大概1-2小时,选股回测只要几十秒,比之前手动跑脚本快多了。

27.5.4 基础知识拓展:工具开发的注意事项

  1. 性能优化
    数据缓存:已经更新过的数据不用重复爬取,增量更新每个季度的新财报,不用每次都重新跑5年的数据,更新时间从2小时缩短到10分钟。
    多线程加速:爬取数据的时候用多线程,同时请求多个股票的数据,速度能提升3-5倍,注意控制请求频率避免被API限流。
    分页加载:全市场股票有几千只,表格不要一次性加载所有数据,分页显示,避免界面卡顿。
  2. 错误处理
    网络异常重试:API请求失败的时候自动重试3次,避免因为网络波动导致更新失败。
    异常数据过滤:遇到退市股、数据缺失的股票自动跳过,不要因为一只股票的数据问题导致整个更新失败。
    操作提示:用户点错的时候弹出友好的提示,比如没更新数据就选股,提示先更新数据,不要直接崩溃。
  3. 功能扩展建议
    加自定义策略:可以加一个自定义选股页,让用户自己设置PE、ROE、增速的阈值,灵活选股。
    加预警功能:监控持仓股的基本面变化,比如ROE下滑、现金流恶化的时候自动弹出预警。
    加导出报告功能:把选股结果、回测报告、个股分析一键导出成PDF,直接用来做汇报。

27.5.5 实战优化:添加策略对比功能

  1. 实战代码:添加多策略对比
    python
	# 在选股回测页添加策略对比按钮和画布
	def create_stock_pick_tab(self):
	# ... 原有代码 ...
	self.compare_btn = QPushButton('对比两个策略')
	self.compare_btn.clicked.connect(self.compare_strategies)
	param_layout.addWidget(self.compare_btn)
	
	# 新增对比曲线画布
	layout.addWidget(QLabel('策略对比净值曲线:'))
	self.compare_fig, self.compare_ax = plt.subplots(figsize=(10, 4))
	self.compare_canvas = FigureCanvas(self.compare_fig)
	layout.addWidget(self.compare_canvas)
	# ... 原有代码 ...
	
	# 添加策略对比方法
	def compare_strategies(self):
	industry = self.industry_combo.currentText()
	hold_num = self.hold_num_spin.value()
	start_date = self.backtest_start.currentText()
	end_date = datetime.now().strftime('%Y%m%d')
	
	try:
	# 回测两个策略
	tester = FundamentalStrategyTester()
	all_data = pd.read_csv('data/全市场财务指标.csv', parse_dates=['交易日'])
	if industry != '全市场':
	all_data = all_data[all_data['industry'] == industry]
	tester.fin_data = all_data
	tester.start_date = start_date
	tester.end_date = end_date
	tester.hold_num = hold_num
	
	# 回测低PE高ROE
	nv1, _ = tester.backtest('low_pe_high_roe')
	# 回测高成长
	nv2, _ = tester.backtest('high_growth')
	
	# 绘制对比曲线
	self.compare_ax.clear()
	self.compare_ax.plot(nv1['trade_date'], nv1['strategy_net'], label='低PE高ROE', color='red')
	self.compare_ax.plot(nv2['trade_date'], nv2['strategy_net'], label='高成长', color='green')
	self.compare_ax.plot(nv1['trade_date'], nv1['benchmark_net'], label='沪深300', color='blue')
	self.compare_ax.legend()
	self.compare_ax.set_title('策略对比净值曲线')
	self.compare_ax.grid(alpha=0.3)
	self.compare_canvas.draw()
	QMessageBox.information(self, '成功', '策略对比完成!')
	except Exception as e:
	QMessageBox.critical(self, '失败', f'对比失败:{str(e)}')

优化效果:
可以直接对比两个策略在同一个行业、同一个时间段的表现,比如在消费行业是低PE策略好还是高成长策略好,一目了然。

27.5.6 总结:选股工具的实盘应用
1.日常选股:每个季度末更新一次数据,跑一下选股策略,选出来的股票再手动看一下基本面,挑几只放进股票池。
2.策略研究:想试新的选股逻辑,直接加个选股方法就行,不用改整个框架,快速验证策略有效性。
3.投研效率提升:之前做一个行业的选股要花一天,现在点几下按钮10分钟就出结果,省下来的时间可以做更深入的个股研究。
4.团队协作:工具打包成exe文件,发给团队所有人用,不用每个人都搭Python环境,统一选股标准,避免各算各的。
到这里,整个基本面量化的实战流程就全部讲完了,从数据获取、指标计算、选股回测、可视化到工具开发,你已经掌握了基本面量化的全部核心技能。接下来可以根据自己的需求扩展工具功能,打造专属的投研利器。
下一章我们会讲量化策略的实盘交易与风险控制,教你怎么把回测好的策略真正用到实盘里赚钱。

 本站原创,转载请注明出处:https://www.xin3721.com/ArticlePrograme/csharp49727.html


相关教程