风险提示:本文仅用于学习编程实战,不构成任何投资建议。
接下来,我们将从零起步,一步步搭建一个功能完整的A股K线图网站。所有代码均已实测通过,复制即可运行。 完整源代码:https://github.com/ICodeWR/kline

pip install -r requirements.txt本软件支持两种数据源:(二选一即可)
| akshare | ||
| tushare |
TUSHARE_TOKEN 配置kline/├── app.py # Flask 主应用├── stock_data.py # 股票数据获取模块├── requirements.txt # 项目依赖├── test_app.py # Flask 应用单元测试├── test_stock_data.py # 数据模块单元测试├── .gitignore # Git 忽略规则├── LICENSE # MIT 开源许可证├── README.md # 项目说明文档├── CONTRIBUTING.md # 贡献指南├── CHANGELOG.md # 更新日志├── templates/│ ├── base.html # 基础模板(导航栏、页脚)│ ├── index.html # 首页(搜索表单 + K线图)│ └── 404.html # 404/500错误页面├── static/│ └── css/│ └── style.css # 自定义样式└── 教程.md # 本教程stock_data.py 是数据获取核心模块,封装了所有与股票数据源的交互。主要特性:
import tushare as tsimport pandas as pdimport akshare as akfrom datetime import datetime, timedeltafrom typing import Optional, Tuple, ListclassStockDataFetcher:"""A股股票数据获取类,支持tushare和akshare双数据源"""def__init__(self, token: Optional[str] = None): self.pro = Noneif token:try: ts.set_token(token) self.pro = ts.pro_api()except Exception as e: print(f"tushare初始化失败,将使用akshare: {e}") self.pro = None self._stock_cache = Nonedef_is_shanghai(self, code: str) -> bool:"""判断是否为沪市股票(6或9开头)"""return code.startswith(('6', '9'))def_get_ts_code(self, code: str) -> str:"""生成tushare格式的股票代码""" suffix = '.SH'if self._is_shanghai(code) else'.SZ'returnf"{code}{suffix}"defget_stock_list(self) -> pd.DataFrame:"""获取A股股票列表,带缓存"""if self._stock_cache isnotNone:return self._stock_cachetry: df = ak.stock_info_a_code_name() df = df.rename(columns={'code': 'code', 'name': 'name'}) self._stock_cache = df[['code', 'name']]return self._stock_cacheexcept Exception:pass# 网络失败时返回常用股票备选列表 fallback = pd.DataFrame({'code': ['000001', '600000', '000002', '600036', '000858','600519', '000333', '601318', '600276', '002415'],'name': ['平安银行', '浦发银行', '万科A', '招商银行', '五粮液','贵州茅台', '美的集团', '中国平安', '恒瑞医药', '海康威视'] }) self._stock_cache = fallbackreturn fallbackdefget_historical_data(self, stock_code: str, days: int = 250) -> Tuple[List[str], List[List[float]]]:"""获取股票历史OHLC数据"""try: end_date = datetime.now().strftime('%Y%m%d') start_date = (datetime.now() - timedelta(days=days * 2)).strftime('%Y%m%d') df = Noneif self.pro:try: symbol = self._get_ts_code(stock_code) df = self.pro.daily(ts_code=symbol, start_date=start_date, end_date=end_date, fields='trade_date,open,high,low,close')except Exception:passif df isNoneor df.empty: df = ak.stock_zh_a_hist(symbol=stock_code, period="daily", start_date=start_date, end_date=end_date, adjust="qfq") df = df.rename(columns={'日期': 'trade_date', '开盘': 'open','最高': 'high', '最低': 'low', '收盘': 'close' }) df['trade_date'] = df['trade_date'].astype(str).str.replace('-', '')if df.empty:return [], [] df = df.sort_values('trade_date', ascending=True) df = df.tail(days) dates = df['trade_date'].tolist() ohlc_data = df[['open', 'high', 'low', 'close']].values.tolist()return dates, ohlc_dataexcept Exception as e: print(f"获取股票数据失败: {e}")return [], []defsearch_stock(self, keyword: str) -> Tuple[int, Optional[list]]:"""搜索股票,返回 (状态码, [代码, 名称])""" stock_df = self.get_stock_list()for code, name in stock_df.values.tolist():if keyword in str(code) or keyword in str(name):return1, [str(code), str(name)]return0, Noneapp.py 是Web服务的主入口,包含路由定义和K线图生成逻辑。
import osimport jsonfrom flask import Flask, render_template, request, jsonifyfrom flask_bootstrap import Bootstrapfrom pyecharts import options as optsfrom pyecharts.charts import Klinefrom stock_data import StockDataFetcherapp = Flask(__name__)bootstrap = Bootstrap(app)# 通过环境变量配置tushare token(可选),不配置则使用akshareTUSHARE_TOKEN = os.environ.get('TUSHARE_TOKEN', '')stock_fetcher = StockDataFetcher(token=TUSHARE_TOKEN if TUSHARE_TOKEN elseNone)defcreate_kline_chart(dates, data, stock_name):"""生成K线图的pyecharts配置"""ifnot dates ornot data:returnNone kline = ( Kline(init_opts=opts.InitOpts(width="100%", height="600px")) .add_xaxis(dates) .add_yaxis( series_name=stock_name, y_axis=data, itemstyle_opts=opts.ItemStyleOpts( color="#ef232a", # 阳线(涨)红色 color0="#14b143", # 阴线(跌)绿色 border_color="#ef232a", border_color0="#14b143", ), markline_opts=opts.MarkLineOpts( data=[ opts.MarkLineItem(type_="max", name="最高价"), opts.MarkLineItem(type_="min", name="最低价"), ] ), ) .set_global_opts( title_opts=opts.TitleOpts( title=f"{stock_name} K线图", subtitle="数据来源: akshare / tushare.pro", pos_left="center", ), xaxis_opts=opts.AxisOpts( type_="category", is_scale=True, axislabel_opts=opts.LabelOpts(rotate=45, interval=10), ), yaxis_opts=opts.AxisOpts( is_scale=True, splitarea_opts=opts.SplitAreaOpts( is_show=True, areastyle_opts=opts.AreaStyleOpts(opacity=0.3) ), axislabel_opts=opts.LabelOpts(formatter="{value}元"), ), datazoom_opts=[ opts.DataZoomOpts(type_="inside", range_start=0, range_end=100), opts.DataZoomOpts(type_="slider", range_start=0, range_end=100), ], tooltip_opts=opts.TooltipOpts(trigger="axis", axis_pointer_type="cross"), legend_opts=opts.LegendOpts(is_show=False), ) )return kline@app.errorhandler(404)defpage_not_found(e):return render_template("404.html"), 404@app.errorhandler(500)definternal_server_error(e):return render_template("404.html", error="服务器内部错误"), 500@app.route("/")defindex():return render_template("index.html")@app.route("/api/kline", methods=["POST"])defget_kline():"""API:获取K线图数据"""try: stock_keyword = request.form.get("stockName", "").strip() query_days = request.form.get("queryTime", "250").strip()ifnot stock_keyword: stock_keyword = "平安银行"try: query_days = int(query_days) query_days = max(10, min(query_days, 1000))except ValueError: query_days = 250 status, stock_info = stock_fetcher.search_stock(stock_keyword)if status == 0:return jsonify({"error": f"未找到股票: {stock_keyword}"}), 404 stock_code, stock_name = stock_info dates, ohlc_data = stock_fetcher.get_historical_data(stock_code, query_days)ifnot dates:return jsonify({"error": f"获取 {stock_name}({stock_code}) 数据失败"}), 404 kline = create_kline_chart(dates, ohlc_data, stock_name)if kline isNone:return jsonify({"error": "生成K线图失败"}), 500return jsonify(json.loads(kline.dump_options()))except Exception as e: print(f"请求处理失败: {e}")return jsonify({"error": str(e)}), 500@app.route("/api/suggest", methods=["GET"])defget_suggestions():"""API:股票名称自动补全""" keyword = request.args.get("keyword", "").strip()ifnot keyword:return jsonify([])try: stock_df = stock_fetcher.get_stock_list() suggestions = []for _, row in stock_df.iterrows(): code = str(row['code']) name = str(row['name'])if keyword in code or keyword in name: suggestions.append({"code": code, "name": name, "display": f"{code} - {name}"})if len(suggestions) >= 10:breakreturn jsonify(suggestions)except Exception as e: print(f"获取建议失败: {e}")return jsonify([])if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)基础模板包含导航栏、CDN资源引入和页脚,所有页面继承此模板。
<!DOCTYPE html><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>{% block title %}股票K线图分析系统{% endblock %}</title><!-- Bootstrap 5 CSS --><linkhref="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"rel="stylesheet"><!-- Font Awesome 图标 --><linkhref="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"rel="stylesheet"><!-- jQuery --><scriptsrc="https://code.jquery.com/jquery-3.6.0.min.js"></script><!-- ECharts --><scriptsrc="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script><!-- 自定义样式 --><linkhref="{{ url_for('static', filename='css/style.css') }}"rel="stylesheet"> {% block extra_css %}{% endblock %}</head><body><navclass="navbar navbar-expand-lg"><divclass="container"><aclass="navbar-brand"href="/"><iclass="fas fa-chart-line"></i> 股票K线图分析系统</a><buttonclass="navbar-toggler"type="button"data-bs-toggle="collapse"data-bs-target="#navbarNav"><spanclass="navbar-toggler-icon"></span></button><divclass="collapse navbar-collapse"id="navbarNav"><ulclass="navbar-nav ms-auto"><liclass="nav-item"><aclass="nav-link"href="/"><iclass="fas fa-home"></i> 首页</a></li><liclass="nav-item"><aclass="nav-link"href="#"onclick="refreshChart()"><iclass="fas fa-sync"></i> 刷新</a></li></ul></div></div></nav><divclass="container main-container"> {% block content %}{% endblock %}</div><divclass="footer"><p>数据来源: akshare / tushare.pro | 仅供学习参考,不构成投资建议</p></div><scriptsrc="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> {% block extra_js %}{% endblock %}</body></html>首页包含搜索表单和K线图展示区域,集成了自动补全和图表渲染功能。
{% extends "base.html" %}{% block title %}股票K线图查询{% endblock %}{% block content %}<divclass="row"><divclass="col-12"><divclass="card"><divclass="card-header"><h5><iclass="fas fa-search"></i> 查询股票</h5></div><divclass="card-body"><formid="searchForm"onsubmit="return false;"><divclass="row g-3 align-items-end"><divclass="col-md-5"><labelfor="stockName"class="form-label">股票名称或代码</label><divclass="position-relative"><inputtype="text"class="form-control"id="stockName"placeholder="请输入股票名称或代码,如:平安银行、000001"value="平安银行"autocomplete="off"><divid="suggestions"class="list-group"style="display:none; position:absolute; z-index:1000; width:100%; max-height:200px; overflow-y:auto;"></div></div></div><divclass="col-md-4"><labelfor="queryTime"class="form-label">查询天数</label><selectclass="form-select"id="queryTime"><optionvalue="30">近30天</option><optionvalue="60">近60天</option><optionvalue="120">近120天</option><optionvalue="250"selected>近250天</option><optionvalue="500">近500天</option><optionvalue="1000">近1000天</option></select></div><divclass="col-md-3"><buttontype="button"class="btn btn-primary w-100"onclick="loadKline()"><iclass="fas fa-chart-bar"></i> 查看K线图</button></div></div></form><divid="errorMessage"class="error-message"></div></div></div></div></div><divclass="row mt-4"><divclass="col-12"><divclass="card"><divclass="card-header d-flex justify-content-between align-items-center"><h5><iclass="fas fa-candlestick-chart"></i> K线图</h5><spanid="stockInfo"class="badge bg-light text-dark">加载中...</span></div><divclass="card-body"><divid="kline-container"><divclass="loading-text"><iclass="fas fa-spinner fa-spin fa-2x"></i><pclass="mt-2">加载中,请稍候...</p></div></div></div></div></div></div>{% endblock %}{% block extra_js %}<script>var chartInstance = null;$(document).ready(function() {var suggestionTimeout; $('#stockName').on('input', function() { clearTimeout(suggestionTimeout);var keyword = $(this).val().trim();if (keyword.length < 1) { $('#suggestions').hide();return; } suggestionTimeout = setTimeout(function() { $.ajax({url: '/api/suggest',method: 'GET',data: { keyword: keyword },success: function(data) {if (data.length > 0) {var html = ''; data.forEach(function(item) { html += '<a href="#" class="list-group-item list-group-item-action suggestion-item"' + ' data-code="' + item.code + '" data-name="' + item.name + '">' + '<strong>' + item.code + '</strong> - ' + item.name + '</a>'; }); $('#suggestions').html(html).show(); } else { $('#suggestions').hide(); } } }); }, 300); }); $('#suggestions').on('click', '.suggestion-item', function(e) { e.preventDefault(); $('#stockName').val($(this).data('name')); $('#suggestions').hide(); loadKline(); }); $(document).click(function(e) {if (!$(e.target).closest('#stockName').length && !$(e.target).closest('#suggestions').length) { $('#suggestions').hide(); } }); $('#stockName, #queryTime').on('keypress', function(e) {if (e.which === 13) loadKline(); }); loadKline();});functionloadKline() {var stockName = $('#stockName').val().trim() || '平安银行';var queryTime = $('#queryTime').val(); $('#kline-container').html('<div class="loading-text">' + '<i class="fas fa-spinner fa-spin fa-2x"></i>' + '<p class="mt-2">加载中,请稍候...</p></div>'); $('#errorMessage').hide(); $('#stockInfo').text('加载中...');if (chartInstance) { chartInstance.dispose(); chartInstance = null; } $.ajax({url: '/api/kline',method: 'POST',data: { stockName: stockName, queryTime: queryTime },success: function(data) {if (data.error) { showError(data.error); return; } chartInstance = echarts.init(document.getElementById('kline-container')); chartInstance.setOption(data); $('#stockInfo').text( (data.title && data.title.text) ? data.title.text : stockName );window.addEventListener('resize', function() {if (chartInstance) chartInstance.resize(); }); },error: function(xhr) {var msg = (xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : '查询失败,请稍后重试'; showError(msg); } });}functionshowError(message) { $('#errorMessage').text(message).show(); $('#kline-container').html('<div class="loading-text" style="color:#dc3545;">' + '<i class="fas fa-exclamation-circle fa-2x"></i>' + '<p class="mt-2">' + message + '</p></div>'); $('#stockInfo').text('查询失败');}functionrefreshChart() { loadKline(); }</script>{% endblock %}{% extends "base.html" %}{% block title %}页面未找到{% endblock %}{% block content %}<divclass="row"><divclass="col-12 text-center"style="padding: 100px 0;"><iclass="fas fa-exclamation-triangle"style="font-size: 80px; color: #f39c12;"></i><h1class="display-1"style="font-weight: 700;">404</h1><h3class="text-muted">抱歉!页面找不到了</h3><pclass="text-muted">您访问的页面不存在或已移动</p><ahref="/"class="btn btn-primary mt-3"><iclass="fas fa-home"></i> 返回首页</a></div></div>{% endblock %}独立样式文件使代码更清晰,便于维护:
body {background-color: #f5f7fa;font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,"Helvetica Neue", Arial, sans-serif;}.navbar {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);box-shadow: 02px10pxrgba(0, 0, 0, 0.1);}.navbar-brand {font-weight: bold;color: white !important;}.navbar-nav.nav-link {color: rgba(255, 255, 255, 0.9) !important;transition: transform 0.2s;}.navbar-nav.nav-link:hover {color: white !important;transform: translateY(-1px);}.main-container {margin-top: 30px;margin-bottom: 30px;}.card {border: none;border-radius: 15px;box-shadow: 04px20pxrgba(0, 0, 0, 0.08);overflow: hidden;}.card-header {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);color: white;padding: 15px25px;border-bottom: none;}.card-headerh5 {margin: 0;font-weight: 600;}.btn-primary {background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);border: none;padding: 8px30px;border-radius: 25px;font-weight: 500;transition: transform 0.2s, box-shadow 0.2s;}.btn-primary:hover {transform: translateY(-2px);box-shadow: 05px15pxrgba(102, 126, 234, 0.4);}.form-control {border-radius: 10px;border: 2px solid #e8ecf1;padding: 10px15px;transition: border-color 0.2s, box-shadow 0.2s;}.form-control:focus {border-color: #667eea;box-shadow: 0000.2remrgba(102, 126, 234, 0.25);}#kline-container {width: 100%;height: 600px;background: white;border-radius: 10px;}.loading-text {text-align: center;padding: 50px;color: #999;}.error-message {color: #dc3545;padding: 10px;margin: 10px0;border-radius: 5px;background: #f8d7da;display: none;}.footer {text-align: center;padding: 20px;color: #999;font-size: 14px;}.suggestion-item {cursor: pointer;}.suggestion-item:hover {background-color: #f0f0ff;}Flask>=2.2.0flask-bootstrap>=3.3.7.0pyecharts>=2.0.0pandas>=1.5.0tushare>=1.2.60akshare>=1.12.0requests>=2.28.0pip install -r requirements.txtpython app.py打开浏览器访问:http://localhost:5000
# Windows PowerShell$env:TUSHARE_TOKEN="你的token"# Linux / macOSexport TUSHARE_TOKEN="你的token"如果不配置,系统将自动使用 akshare 作为数据源。
| 股票搜索 | |
| 自动补全 | |
| K线图展示 | |
| 数据缩放 | |
| 交互式分析 | |
| 最高/最低价标记 | |
| 响应式布局 |
# 修改 app.py 最后一行端口号app.run(debug=True, host="0.0.0.0", port=5001)# 使用国内镜像加速pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple在 stock_data.py 中添加:
defadd_ma(self, df, days=[5, 10, 20, 60]):for d in days: df[f'MA{d}'] = df['close'].rolling(d).mean()return df在 K线图下方叠加成交量柱状图,使用 ECharts 的 grid 多图布局。
MACD、KDJ、RSI、BOLL等常用技术指标。
使用 Redis 或文件缓存减少API调用频率,提升响应速度。
本项目包含完整的单元测试,覆盖数据模块和 Flask 应用。
test_stock_data.py | StockDataFetcher | |
test_app.py |
# 运行全部测试python -m pytest test_stock_data.py test_app.py -v# 仅运行数据模块测试python -m pytest test_stock_data.py -v# 仅运行 Flask 应用测试python -m pytest test_app.py -vtest_stock_data.py(5 个测试类)
TestStockDataFetcherInit | ||
TestStockDataFetcherHelpers | _is_shanghai_get_ts_code 代码转换 | |
TestStockDataFetcherStockList | ||
TestStockDataFetcherSearch | ||
TestStockDataFetcherHistorical |
test_app.py(2 个测试类)
TestFlaskApp | ||
TestCreateKlineChart |
在编写测试时发现 search_stock 方法中空字符串 "" 会匹配到所有股票(因为 "" in any_string 始终为 True),已在方法开头添加空关键词守卫:
defsearch_stock(self, keyword: str) -> Tuple[int, Optional[list]]:ifnot keyword:return0, None# ... 搜索逻辑项目已初始化 Git 仓库,.gitignore 配置了如下忽略规则:
venv/ # 虚拟环境__pycache__/ # Python 字节码缓存*.pyc # 编译文件.pytest_cache/ # 测试缓存*.egg-info/ # 打包信息dist/ build/ # 构建产物.env .venv # 环境变量*.log # 日志文件git initgit add -Agit commit -m "feat: 初始化项目,A股K线图网站"LICENSE | |
README.md | |
CONTRIBUTING.md | |
CHANGELOG.md |
本项目代码遵循 MIT 开源协议,依赖的第三方库遵循各自的许可协议:
本文从零开始构建了一个完整的A股K线图网站,包含:
所有代码已在本项目中实现,可直接运行。欢迎基于此框架继续扩展更多功能!
作者简介:码上工坊,探索用编程为己赋能,定期分享编程知识和项目实战经验。持续学习、适应变化、记录点滴、复盘反思、成长进步。
重要提示:本文主要是记录自己的学习与实践过程,所提内容或者观点仅代表个人意见,只是我以为的,不代表完全正确,欢迎交流讨论。