├── tests ├── __init__.py └── test_stock.py ├── src ├── __init__.py ├── main.py ├── modern_main.py ├── stock.py └── modern_stock.py ├── example_modern.png ├── example_traditional.png ├── charts └── README.md ├── requirements.txt ├── pyproject.toml ├── .gitignore ├── stock_terminal1.py ├── README.md └── stock_terminal.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 测试包 3 | """ -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 实时股票查询工具 3 | """ 4 | 5 | __version__ = "1.0.0" -------------------------------------------------------------------------------- /example_modern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixglow/Stock/HEAD/example_modern.png -------------------------------------------------------------------------------- /example_traditional.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felixglow/Stock/HEAD/example_traditional.png -------------------------------------------------------------------------------- /charts/README.md: -------------------------------------------------------------------------------- 1 | # 图表目录 2 | 3 | 此目录用于存储程序生成的K线图和其他图表文件。 4 | 5 | ## 文件命名规则 6 | 7 | 生成的图表文件遵循以下命名规则: 8 | 9 | 1. 传统界面生成的K线图:`股票名称_日K线图.png` 10 | 2. 现代界面生成的K线图:`股票名称_日K线图_现代界面.png` 11 | 12 | ## 注意事项 13 | 14 | - 图表文件会自动保存到此目录 15 | - 旧的图表文件不会自动删除,建议定期清理 16 | - 如果希望保存某些图表,请将其复制到其他目录 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 主要依赖 2 | requests>=2.31.0 3 | rich>=13.7.0 4 | python-dotenv>=1.0.0 5 | matplotlib>=3.8.0 6 | pandas>=2.2.0 7 | 8 | # 测试依赖 9 | pytest>=8.0.0 10 | pytest-cov>=4.1.0 11 | 12 | # 代码质量和类型检查 13 | ruff>=0.2.0 14 | mypy>=1.8.0 15 | types-requests>=2.31.0 16 | 17 | # 文档生成 18 | sphinx>=7.2.0 19 | sphinx-rtd-theme>=2.0.0 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "stock-query" 3 | version = "0.1.0" 4 | description = "实时股票查询工具" 5 | authors = [ 6 | {name = "Kyo", email = "your.email@example.com"}, 7 | ] 8 | dependencies = [ 9 | "requests>=2.31.0", 10 | "rich>=13.7.0", 11 | "python-dotenv>=1.0.0", 12 | "matplotlib>=3.8.0", 13 | "pandas>=2.2.0", 14 | ] 15 | requires-python = ">=3.8" 16 | 17 | [build-system] 18 | requires = ["hatchling"] 19 | build-backend = "hatchling.build" 20 | 21 | [tool.ruff] 22 | line-length = 100 23 | target-version = "py38" 24 | select = ["E", "F", "B", "I", "N", "UP", "PL", "RUF"] 25 | 26 | [tool.ruff.per-file-ignores] 27 | "__init__.py" = ["F401"] 28 | 29 | [tool.pytest.ini_options] 30 | addopts = "-v --cov=src --cov-report=term-missing" 31 | testpaths = ["tests"] 32 | 33 | [tool.mypy] 34 | python_version = "3.8" 35 | warn_return_any = true 36 | warn_unused_configs = true 37 | disallow_untyped_defs = true 38 | check_untyped_defs = true 39 | 40 | [[tool.mypy.overrides]] 41 | module = ["matplotlib.*", "pandas.*"] 42 | ignore_missing_imports = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .nox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | .pytest_cache/ 48 | 49 | # Environments 50 | .env 51 | .venv 52 | env/ 53 | venv/ 54 | ENV/ 55 | env.bak/ 56 | venv.bak/ 57 | 58 | # IDE project settings 59 | .idea/ 60 | .vscode/ 61 | *.swp 62 | *.swo 63 | .cursor/ 64 | 65 | # Generated charts and images 66 | # 排除大多数生成的图表文件,但保留示例图片 67 | *.png 68 | !example_traditional.png 69 | !example_modern.png 70 | charts/* 71 | !charts/README.md 72 | 73 | # Cache directories 74 | .ruff_cache/ 75 | __pycache__/ 76 | 77 | # Local configuration 78 | .config.json 79 | config.local.json -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | 主程序入口 3 | """ 4 | import argparse 5 | import sys 6 | 7 | from .stock import Stock 8 | 9 | 10 | def parse_args() -> argparse.Namespace: 11 | """解析命令行参数""" 12 | parser = argparse.ArgumentParser(description="股票数据查询程序") 13 | parser.add_argument( 14 | "-c", "--codes", 15 | help="股票代码列表,使用逗号分隔", 16 | type=str, 17 | required=True 18 | ) 19 | parser.add_argument( 20 | "-i", "--interval", 21 | help="数据更新间隔(秒)[已废弃,保留兼容性]", 22 | type=float, 23 | default=6.0 24 | ) 25 | parser.add_argument( 26 | "-t", "--threads", 27 | help="线程数量", 28 | type=int, 29 | default=3 30 | ) 31 | return parser.parse_args() 32 | 33 | def check_code_format(code: str) -> bool: 34 | """检查股票代码格式是否正确""" 35 | if not code.startswith(('sh', 'sz')): 36 | return False 37 | 38 | # 检查数字部分 39 | num_part = code[2:] 40 | if not num_part.isdigit() or len(num_part) != 6: 41 | return False 42 | 43 | return True 44 | 45 | def main(): 46 | """主函数""" 47 | args = parse_args() 48 | codes = args.codes.split(",") 49 | 50 | # 检查股票代码格式 51 | invalid_codes = [code for code in codes if not check_code_format(code)] 52 | if invalid_codes: 53 | print(f"错误: 以下股票代码格式不正确: {', '.join(invalid_codes)}") 54 | print("格式要求: sh开头表示上海股票,sz开头表示深圳股票,后接6位数字") 55 | sys.exit(1) 56 | 57 | print(f"正在查询股票: {', '.join(codes)}") 58 | 59 | # 初始化Stock对象并显示股票数据 60 | try: 61 | stock = Stock(codes[0], args.threads) 62 | stock.display_stocks(codes) 63 | except KeyboardInterrupt: 64 | print("\n程序已被用户中断") 65 | except Exception as e: 66 | print(f"\n程序运行出错: {e}") 67 | sys.exit(1) 68 | 69 | if __name__ == "__main__": 70 | main() -------------------------------------------------------------------------------- /src/modern_main.py: -------------------------------------------------------------------------------- 1 | """ 2 | 现代界面主程序入口 3 | """ 4 | import argparse 5 | import sys 6 | 7 | from .modern_stock import ModernStock 8 | 9 | 10 | def parse_args() -> argparse.Namespace: 11 | """解析命令行参数""" 12 | parser = argparse.ArgumentParser(description="现代界面股票数据查询程序") 13 | parser.add_argument( 14 | "-c", "--codes", 15 | help="股票代码列表,使用逗号分隔", 16 | type=str, 17 | required=True 18 | ) 19 | parser.add_argument( 20 | "-i", "--interval", 21 | help="数据更新间隔(秒)[已废弃,保留兼容性]", 22 | type=float, 23 | default=6.0 24 | ) 25 | parser.add_argument( 26 | "-t", "--threads", 27 | help="线程数量", 28 | type=int, 29 | default=3 30 | ) 31 | return parser.parse_args() 32 | 33 | def check_code_format(code: str) -> bool: 34 | """检查股票代码格式是否正确""" 35 | if not code.startswith(('sh', 'sz')): 36 | return False 37 | 38 | # 检查数字部分 39 | num_part = code[2:] 40 | if not num_part.isdigit() or len(num_part) != 6: 41 | return False 42 | 43 | return True 44 | 45 | def main(): 46 | """主函数""" 47 | args = parse_args() 48 | codes = args.codes.split(",") 49 | 50 | # 检查股票代码格式 51 | invalid_codes = [code for code in codes if not check_code_format(code)] 52 | if invalid_codes: 53 | print(f"错误: 以下股票代码格式不正确: {', '.join(invalid_codes)}") 54 | print("格式要求: sh开头表示上海股票,sz开头表示深圳股票,后接6位数字") 55 | sys.exit(1) 56 | 57 | print(f"正在查询股票: {', '.join(codes)}") 58 | 59 | # 初始化ModernStock对象并显示股票数据 60 | try: 61 | stock = ModernStock(codes[0], args.threads) 62 | stock.display_stocks(codes) 63 | except KeyboardInterrupt: 64 | print("\n程序已被用户中断") 65 | except Exception as e: 66 | print(f"\n程序运行出错: {e}") 67 | sys.exit(1) 68 | 69 | if __name__ == "__main__": 70 | main() -------------------------------------------------------------------------------- /tests/test_stock.py: -------------------------------------------------------------------------------- 1 | """ 2 | 股票数据模块测试 3 | """ 4 | from src.stock import Stock 5 | 6 | # 测试数据常量 7 | TEST_STOCK_CODE = "sz002230" 8 | TEST_STOCK_NAME = "科大讯飞" 9 | TEST_CURRENT_PRICE = 43.39 10 | TEST_CHANGE_PERCENT = -10.00 11 | TEST_HIGH_PRICE = 45.80 12 | TEST_LOW_PRICE = 43.39 13 | TEST_VOLUME = 48001952 14 | TEST_AMOUNT = 2122000000.0 15 | TEST_THREAD_COUNT = 3 16 | 17 | def test_stock_initialization(): 18 | """测试股票对象初始化""" 19 | stock = Stock(TEST_STOCK_CODE, TEST_THREAD_COUNT) 20 | assert stock.code == TEST_STOCK_CODE 21 | assert len(stock.threads) == TEST_THREAD_COUNT 22 | 23 | def test_stock_data_fetch(): 24 | """测试股票数据获取""" 25 | stock = Stock(TEST_STOCK_CODE, 1) 26 | result = stock.value_get(TEST_STOCK_CODE, 0) 27 | 28 | assert result is not None 29 | code_index, data = result 30 | assert code_index == 0 31 | assert isinstance(data, tuple) 32 | name, price, change = data 33 | assert name == TEST_STOCK_NAME 34 | assert float(price) > 0 35 | 36 | def test_stock_data_creation(): 37 | """测试股票数据对象创建""" 38 | stock = Stock(TEST_STOCK_CODE, TEST_THREAD_COUNT) 39 | result = stock.value_get(TEST_STOCK_CODE, 0) 40 | assert result is not None 41 | code_index, data = result 42 | assert code_index == 0 43 | name, price, change = data 44 | assert name == TEST_STOCK_NAME 45 | assert float(price) > 0 46 | 47 | def test_stock_data_from_sina_api(): 48 | """测试从新浪API获取数据""" 49 | code = TEST_STOCK_CODE 50 | data = f"{TEST_STOCK_NAME},{TEST_HIGH_PRICE},48.21,{TEST_CURRENT_PRICE}," \ 51 | f"{TEST_HIGH_PRICE},{TEST_LOW_PRICE},{TEST_CURRENT_PRICE}," \ 52 | f"{TEST_CURRENT_PRICE},{TEST_VOLUME},{TEST_AMOUNT},..." 53 | 54 | stock = Stock(code) 55 | result = stock.value_get(code, 0) 56 | assert result is not None 57 | code_index, data = result 58 | assert code_index == 0 59 | name, price, _ = data 60 | assert name == TEST_STOCK_NAME 61 | assert float(price) > 0 62 | 63 | def test_stock_api_get_data(): 64 | """测试股票API数据获取""" 65 | stock = Stock(TEST_STOCK_CODE) 66 | result = stock.value_get(TEST_STOCK_CODE, 0) 67 | assert result is not None 68 | code_index, data = result 69 | assert code_index == 0 70 | name, price, _ = data 71 | assert name == TEST_STOCK_NAME 72 | assert float(price) > 0 -------------------------------------------------------------------------------- /stock_terminal1.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8 -*- 2 | # 3 | # Created on 2016-03-04, by felix 4 | # 5 | 6 | __author__ = 'felix' 7 | 8 | import requests 9 | import time 10 | import sys 11 | import threading 12 | 13 | import queue 14 | from optparse import OptionParser 15 | 16 | 17 | class Worker(threading.Thread): 18 | """多线程获取""" 19 | def __init__(self, work_queue, result_queue): 20 | threading.Thread.__init__(self) 21 | self.work_queue = work_queue 22 | self.result_queue = result_queue 23 | self.start() 24 | 25 | def run(self): 26 | while True: 27 | func, arg, code_index = self.work_queue.get() 28 | res = func(arg, code_index) 29 | self.result_queue.put(res) 30 | if self.result_queue.full(): 31 | res = sorted([self.result_queue.get() for i in range(self.result_queue.qsize())], key=lambda s: s[0], reverse=True) 32 | res.insert(0, ('0', u'名称 股价 实时涨幅 昨日价格')) 33 | print ('***** start *****') 34 | for obj in res: 35 | print (obj[1]) 36 | print ('***** end *****\n') 37 | self.work_queue.task_done() 38 | 39 | 40 | class Stock(object): 41 | """股票实时价格获取""" 42 | 43 | def __init__(self, code, thread_num): 44 | self.code = code 45 | self.work_queue = queue.Queue() 46 | self.threads = [] 47 | self.__init_thread_poll(thread_num) 48 | 49 | def __init_thread_poll(self, thread_num): 50 | self.params = self.code.split(',') 51 | self.params.extend(['s_sh000001', 's_sz399001']) # 默认获取沪指、深指 52 | self.result_queue = queue.Queue(maxsize=len(self.params[::-1])) 53 | for i in range(thread_num): 54 | self.threads.append(Worker(self.work_queue, self.result_queue)) 55 | 56 | def __add_work(self, stock_code, code_index): 57 | self.work_queue.put((self.value_get, stock_code, code_index)) 58 | 59 | def del_params(self): 60 | for obj in self.params: 61 | self.__add_work(obj, self.params.index(obj)) 62 | 63 | def wait_all_complete(self): 64 | for thread in self.threads: 65 | if thread.isAlive(): 66 | thread.join() 67 | 68 | @classmethod 69 | def value_get(cls, code, code_index): 70 | slice_num, value_num,begin_num = 21, 3 ,2 71 | name, now = u'——无——', u' ——无——' 72 | if code in ['s_sh000001', 's_sz399001']: 73 | slice_num = 23 74 | value_num = 1 75 | begin_num = 3 76 | r = requests.get("http://hq.sinajs.cn/list=%s" % (code,)) 77 | res = r.text.split(',') 78 | if len(res) > 1: 79 | name, now, begin = r.text.split(',')[0][slice_num:], r.text.split(',')[value_num],float(r.text.split(',')[begin_num]) 80 | if code in ['s_sh000001', 's_sz399001']: 81 | rate = begin 82 | begin = float(now)/(1 + (begin)/100) 83 | else: 84 | rate = (float(now) - (begin))/(begin) *100 85 | if(rate >1 or rate < -1): 86 | print("*******" + name + "**********") 87 | return code_index, name + ' ' + now + ' ' + str(round(rate,3)) + ' ' + str(round(begin,3)) 88 | 89 | 90 | if __name__ == '__main__': 91 | parser = OptionParser(description="Query the stock's value.", usage="%prog [-c] [-s] [-t]", version="%prog 1.0") 92 | parser.add_option('-c', '--stock-code', dest='codes', 93 | help="the stock's code that you want to query.") 94 | parser.add_option('-s', '--sleep-time', dest='sleep_time', default=6, type="int", 95 | help='How long does it take to check one more time.') 96 | parser.add_option('-t', '--thread-num', dest='thread_num', default=3, type='int', 97 | help="thread num.") 98 | options, args = parser.parse_args(args=sys.argv[1:]) 99 | 100 | assert options.codes, "Please enter the stock code!" # 是否输入股票代码 101 | aa =filter(lambda s: s[:-6] not in ('sh','f_' ,'sz', 's_sh', 's_sz'), options.codes.split(',')) 102 | if aa: 103 | print(aa) 104 | if list(aa): 105 | print(list(aa)) 106 | print(aa) 107 | for a in aa: 108 | if a[:-6] not in ('sh', 'sz','s_sh'): 109 | print(a[:-6]) 110 | if list(filter(lambda s: s[:-6] not in ('sh','f_' ,'sz', 's_sh', 's_sz'), options.codes.split(','))): # 股票代码输入是否正确 111 | raise ValueError 112 | 113 | stock = Stock(options.codes, options.thread_num) 114 | 115 | while True: 116 | stock.del_params() 117 | time.sleep(options.sleep_time) 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 实时股票查询工具 2 | 3 | ## 项目简介 4 | 这是一个基于命令行的实时股票价格查询工具,支持同时查询多支股票,数据来源为新浪股票API。本工具包括传统K线图和现代化界面两种显示方式。 5 | 6 | ## 功能特点 7 | - 实时查询股票价格 8 | - 默认包含沪指、深指查询 9 | - 支持多股票同时查询 10 | - 可配置查询间隔和线程数 11 | - K线图形象直观展示股票走势 12 | - 现代化界面提供更美观的数据展示 13 | 14 | ## 安装说明 15 | 1. 克隆仓库: 16 | ```bash 17 | # 从Gitee克隆 18 | git clone https://gitee.com/your-username/stock-query.git 19 | cd stock-query 20 | ``` 21 | 22 | 2. 安装依赖: 23 | ```bash 24 | # 使用 Python3 安装依赖 25 | python3 -m pip install -r requirements.txt 26 | 27 | # 可选:更新 pip 到最新版本 28 | python3 -m pip install --upgrade pip 29 | ``` 30 | 31 | ## 使用方法 32 | ```bash 33 | # 基本使用 - 传统界面 34 | python3 -m src.main -c sz000816 35 | 36 | # 使用现代化界面 37 | python3 -m src.modern_main -c sz000816 38 | 39 | # 查询多支股票 40 | python3 -m src.main -c sh601003,sz000816,sz000778 41 | 42 | # 自定义查询间隔和线程数 43 | python3 -m src.main -c sz000816 -t 4 -s 3 44 | ``` 45 | 46 | ### 命令行参数 47 | - `-c, --codes`: 股票代码(必需),多个代码用逗号分隔 48 | - `-s, --interval`: 查询间隔(秒),默认6秒 49 | - `-t, --threads`: 线程数,默认3个线程 50 | - `-h, --help`: 显示帮助信息 51 | 52 | ## 项目结构 53 | ``` 54 | stock-query/ 55 | ├── src/ 56 | │ ├── __init__.py 57 | │ ├── main.py # 传统界面入口 58 | │ ├── modern_main.py # 现代化界面入口 59 | │ ├── stock.py # 传统股票数据处理 60 | │ └── modern_stock.py # 现代化股票数据处理 61 | ├── tests/ # 单元测试 62 | ├── charts/ # 生成的图表保存目录 63 | ├── requirements.txt # 依赖包列表 64 | ├── pyproject.toml # 项目配置 65 | └── README.md # 项目说明 66 | ``` 67 | 68 | ## 技术实现 69 | - 使用新浪股票API获取实时数据 70 | - 多线程并发查询 71 | - Matplotlib生成可视化图表 72 | - Pandas处理和分析股票数据 73 | - 线程池实现高效数据获取 74 | - 优雅的命令行参数处理 75 | 76 | ## Gitee代码管理 77 | 将代码提交到公司Gitee服务器的步骤: 78 | 79 | 1. 在Gitee上创建一个新仓库 80 | 81 | 2. 配置本地仓库的远程地址: 82 | ```bash 83 | # 添加Gitee远程仓库 84 | git remote add gitee https://gitee.com/your-username/stock-query.git 85 | ``` 86 | 87 | 3. 初次提交代码: 88 | ```bash 89 | # 初始化本地仓库(如果尚未初始化) 90 | git init 91 | 92 | # 添加所有文件 93 | git add . 94 | 95 | # 提交更改 96 | git commit -m "初始提交:实时股票查询工具" 97 | 98 | # 推送到Gitee 99 | git push -u gitee master 100 | ``` 101 | 102 | 4. 后续更新: 103 | ```bash 104 | git add . 105 | git commit -m "更新说明" 106 | git push gitee master 107 | ``` 108 | 109 | ## 注意事项 110 | - 投资需谨慎,股市有风险。本工具仅供参考,不构成任何投资建议。 111 | - 本工具需要 Python 3.6 或更高版本。 112 | - 如果遇到权限问题,可能需要使用 `sudo python3 -m pip install -r requirements.txt` 进行安装。 113 | - 图表保存在当前目录或charts目录中,可通过查看对应的PNG文件查看。 114 | 115 | --- 116 | 117 | # 实时股票查询工具(中文说明) 118 | 119 | ## 项目简介 120 | 本项目是一款基于命令行的实时股票价格查询工具,可以同时查询多支股票的实时价格和K线走势,数据来源为新浪财经API。工具提供传统和现代化两种界面风格,满足不同用户的使用需求。 121 | 122 | ## 主要功能 123 | - 实时获取股票价格数据 124 | - 以K线图形式直观展示股票走势 125 | - 支持多股票并行查询 126 | - 默认包含沪深指数信息 127 | - 可自定义查询间隔和线程数 128 | - 提供传统与现代化两种界面风格 129 | 130 | ## 安装步骤 131 | 1. 从Gitee克隆代码: 132 | ```bash 133 | git clone https://gitee.com/your-username/stock-query.git 134 | cd stock-query 135 | ``` 136 | 137 | 2. 安装依赖包: 138 | ```bash 139 | python3 -m pip install -r requirements.txt 140 | ``` 141 | 142 | ## 使用指南 143 | ```bash 144 | # 使用传统界面查询单只股票 145 | python3 -m src.main -c sz000858 146 | 147 | # 使用现代化界面查询股票 148 | python3 -m src.modern_main -c sz000858 149 | 150 | # 同时查询多只股票 151 | python3 -m src.main -c sh601003,sz000858,sz002230 152 | 153 | # 自定义刷新间隔(3秒)和线程数(4个) 154 | python3 -m src.main -c sz000858 -t 4 -s 3 155 | ``` 156 | 157 | ### 参数说明 158 | - `-c, --codes`: 股票代码,必填参数,多个代码用逗号分隔 159 | - `-s, --interval`: 数据刷新间隔(秒),默认6秒 160 | - `-t, --threads`: 线程数量,默认3个线程 161 | - `-h, --help`: 显示帮助信息 162 | 163 | ## 代码结构 164 | 本项目采用模块化设计,主要包含以下组件: 165 | ``` 166 | ├── src/ # 源代码目录 167 | │ ├── main.py # 传统界面入口 168 | │ ├── modern_main.py # 现代界面入口 169 | │ ├── stock.py # 传统股票数据处理 170 | │ └── modern_stock.py # 现代股票数据处理 171 | ├── tests/ # 测试代码 172 | ├── charts/ # 图表保存目录 173 | └── example_*.png # 示例图片 174 | ``` 175 | 176 | ## Gitee代码管理 177 | 将代码推送到公司Gitee服务器的操作步骤: 178 | 179 | 1. 在公司Gitee上创建新仓库 180 | 181 | 2. 添加Gitee远程地址: 182 | ```bash 183 | git remote add gitee http://gitee.company.com/your-name/stock-tool.git 184 | ``` 185 | 186 | 3. 推送代码到Gitee服务器: 187 | ```bash 188 | git push -u gitee master 189 | ``` 190 | 191 | ## 使用须知 192 | - 本工具仅供学习和参考,投资有风险,决策需谨慎 193 | - 需要Python 3.6或更高版本 194 | - 生成的图表文件默认保存在当前目录或charts目录中 195 | - 如遇显示问题,可查看生成的PNG图片文件 196 | ======= 197 | # Stock 198 | 终端实时获取股票价格 199 | ==================== 200 | 给有需要的朋友,投资需谨慎。 201 | 202 | 用途: 203 | ---- 204 | 实时查询股票价格,默认查询了沪指、深指 205 | 结果输出到终端 206 | stock_terminal1.py 增加了实时涨幅和昨日收盘价 207 | 208 | 使用: 209 | ---- 210 | 需要安装requests库 211 | 支持命令行多参数,如果需要帮助: 212 | python stock_terminal.py -h 213 | 设置查询代码(必传) -c 214 | 设置查询时间间隔(默认6秒) -s 215 | 设置线程数(默认3)(如果有需要) -t 216 | 217 | 查询 智慧农业 sz000816 218 | 例如: 219 | python stock_terminal.py -c sz000816 -t 4 -s 3 220 | 221 | 支持查询多个股票 222 | 例如: 223 | python stock_terminal.py -c sh601003,sz000816,sz000778 224 | 225 | 实现: 226 | ---- 227 | 通过调用新浪股票API,实时查询股票价格 228 | 支持查询多支股票,通过threading多线程同时查询结果 229 | 通过Queue实现线程池 230 | requests请求接口 231 | optparse实现命令行参数处理 232 | -------------------------------------------------------------------------------- /stock_terminal.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8 -*- 2 | # 3 | # Created on 2016-03-04, by Kyo 4 | # 5 | 6 | __author__ = 'Kyo' 7 | 8 | from datetime import datetime, timedelta 9 | from optparse import OptionParser 10 | from queue import Queue 11 | import matplotlib.pyplot as plt 12 | from matplotlib.animation import FuncAnimation 13 | import pandas as pd 14 | import requests 15 | import sys 16 | import threading 17 | import time 18 | 19 | # 添加中文字体支持 20 | plt.rcParams['font.sans-serif'] = ['Arial Unicode MS'] # Mac系统 21 | # plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows系统 22 | plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 23 | 24 | class Worker(threading.Thread): 25 | """多线程获取""" 26 | def __init__(self, work_queue, result_queue): 27 | threading.Thread.__init__(self) 28 | self.work_queue = work_queue 29 | self.result_queue = result_queue 30 | self.start() 31 | 32 | def run(self): 33 | while True: 34 | func, arg, code_index = self.work_queue.get() 35 | res = func(arg, code_index) 36 | self.result_queue.put(res) 37 | if self.result_queue.full(): 38 | res = sorted([self.result_queue.get() for i in range(self.result_queue.qsize())], key=lambda s: s[0], reverse=True) 39 | res.insert(0, ('0', u'名称 股价')) 40 | print('***** start *****') 41 | for obj in res: 42 | print(obj[1]) 43 | print('***** end *****\n') 44 | self.work_queue.task_done() 45 | 46 | 47 | class Stock(object): 48 | """股票实时价格获取""" 49 | 50 | def __init__(self, code, thread_num): 51 | self.code = code 52 | self.work_queue = Queue() 53 | self.threads = [] 54 | self.__init_thread_poll(thread_num) 55 | self.price_history = [] 56 | self.time_history = [] 57 | self.fig, (self.ax1, self.ax2) = plt.subplots(2, 1, figsize=(12, 8)) 58 | self.current_price = None 59 | # 初始化图表设置 60 | self.ax1.set_title('实时价格走势') 61 | self.ax1.set_xlabel('时间') 62 | self.ax1.set_ylabel('价格(元)') 63 | self.ax1.grid(True) 64 | self.ax2.set_title('分时K线图') 65 | self.ax2.set_xlabel('时间') 66 | self.ax2.set_ylabel('价格(元)') 67 | self.ax2.grid(True) 68 | 69 | def __init_thread_poll(self, thread_num): 70 | self.params = self.code.split(',') 71 | self.params.extend(['s_sh000001', 's_sz399001']) # 默认获取沪指、深指 72 | self.result_queue = Queue(maxsize=len(self.params[::-1])) 73 | for i in range(thread_num): 74 | self.threads.append(Worker(self.work_queue, self.result_queue)) 75 | 76 | def __add_work(self, stock_code, code_index): 77 | self.work_queue.put((self.value_get, stock_code, code_index)) 78 | 79 | def del_params(self): 80 | for obj in self.params: 81 | self.__add_work(obj, self.params.index(obj)) 82 | 83 | def wait_all_complete(self): 84 | for thread in self.threads: 85 | if thread.isAlive(): 86 | thread.join() 87 | 88 | def update_plot(self, frame): 89 | if not self.price_history: 90 | print("Debug - No price history data") # 添加调试信息 91 | return 92 | 93 | print(f"Debug - Updating plot with {len(self.price_history)} data points") # 添加调试信息 94 | 95 | # 更新实时价格走势图 96 | self.ax1.clear() 97 | self.ax1.grid(True) 98 | 99 | # 确保有数据再绘制 100 | if len(self.price_history) > 0: 101 | time_labels = [t.strftime('%H:%M:%S') for t in self.time_history] 102 | self.ax1.plot(range(len(time_labels)), self.price_history, 'b-', marker='o') 103 | self.ax1.set_xticks(range(len(time_labels))) 104 | self.ax1.set_xticklabels(time_labels, rotation=45, ha='right') 105 | self.ax1.set_title(f'实时价格走势 - 当前价格: {self.current_price:.2f}') 106 | 107 | # 设置合适的Y轴范围 108 | min_price = min(self.price_history) 109 | max_price = max(self.price_history) 110 | price_range = max_price - min_price 111 | if price_range == 0: 112 | price_range = 1 # 避免价格相同时范围为0 113 | self.ax1.set_ylim([min_price - price_range * 0.1, max_price + price_range * 0.1]) 114 | 115 | # 更新K线图 116 | if len(self.price_history) >= 20: 117 | self.plot_candlestick(self.ax2) 118 | 119 | plt.tight_layout() 120 | 121 | def plot_candlestick(self, ax): 122 | # 生成K线图数据 123 | df = pd.DataFrame({ 124 | 'time': self.time_history, 125 | 'price': self.price_history 126 | }) 127 | df = df.set_index('time') 128 | df = df.resample('1min').ohlc() # 1分钟K线 129 | 130 | # 绘制K线图 131 | ax.clear() 132 | ax.grid(True) 133 | 134 | # 格式化时间轴 135 | time_labels = [t.strftime('%H:%M:%S') for t in df.index] 136 | x = range(len(time_labels)) 137 | 138 | width = 0.6 139 | width2 = 0.1 140 | 141 | up = df[df.close >= df.open] 142 | down = df[df.close < df.open] 143 | 144 | # 绘制上涨K线(红色) 145 | for i, (idx, row) in enumerate(up.iterrows()): 146 | ax.bar(i, row.close-row.open, width, bottom=row.open, color='red') 147 | ax.bar(i, row.high-row.close, width2, bottom=row.close, color='red') 148 | ax.bar(i, row.low-row.open, width2, bottom=row.open, color='red') 149 | 150 | # 绘制下跌K线(绿色) 151 | for i, (idx, row) in enumerate(down.iterrows()): 152 | ax.bar(i, row.close-row.open, width, bottom=row.open, color='green') 153 | ax.bar(i, row.high-row.open, width2, bottom=row.open, color='green') 154 | ax.bar(i, row.low-row.close, width2, bottom=row.close, color='green') 155 | 156 | ax.set_title('分时K线图') 157 | ax.set_xlabel('时间') 158 | ax.set_ylabel('价格(元)') 159 | 160 | # 设置时间轴标签 161 | ax.set_xticks(x) 162 | ax.set_xticklabels(time_labels, rotation=45, ha='right') 163 | 164 | # 自动调整Y轴范围 165 | if not df.empty: 166 | mean_price = df.close.mean() 167 | price_range = df.high.max() - df.low.min() 168 | ax.set_ylim([mean_price - price_range * 0.6, mean_price + price_range * 0.6]) 169 | 170 | def value_get(self, code, code_index): 171 | slice_num, value_num = 21, 3 172 | name, now = u'——无——', u' ——无——' 173 | if code in ['s_sh000001', 's_sz399001']: 174 | slice_num = 23 175 | value_num = 1 176 | try: 177 | url = f"http://hq.sinajs.cn/list={code}" 178 | headers = { 179 | 'Referer': 'http://finance.sina.com.cn', 180 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 181 | } 182 | r = requests.get(url, headers=headers) 183 | r.encoding = 'gbk' 184 | res = r.text.split(',') 185 | print(f"Debug - Raw data: {r.text}") # 添加调试信息 186 | if len(res) > 1: 187 | name, now = r.text.split(',')[0][slice_num:], r.text.split(',')[value_num] 188 | try: 189 | price = float(now) 190 | print(f"Debug - Price: {price}") # 添加调试信息 191 | self.price_history.append(price) 192 | self.time_history.append(datetime.now()) 193 | self.current_price = price 194 | # 保持固定长度的历史数据 195 | if len(self.price_history) > 100: 196 | self.price_history.pop(0) 197 | self.time_history.pop(0) 198 | except ValueError: 199 | print(f"无法转换价格: {now}") 200 | except Exception as e: 201 | print(f"获取数据错误: {str(e)}") 202 | return code_index, f"{name} {now}" 203 | 204 | 205 | if __name__ == '__main__': 206 | parser = OptionParser(description="Query the stock's value.", usage="%prog [-c] [-s] [-t]", version="%prog 1.0") 207 | parser.add_option('-c', '--stock-code', dest='codes', 208 | help="the stock's code that you want to query.") 209 | parser.add_option('-s', '--sleep-time', dest='sleep_time', default=6, type="int", 210 | help='How long does it take to check one more time.') 211 | parser.add_option('-t', '--thread-num', dest='thread_num', default=3, type='int', 212 | help="thread num.") 213 | options, args = parser.parse_args(args=sys.argv[1:]) 214 | 215 | assert options.codes, "Please enter the stock code!" # 是否输入股票代码 216 | codes = options.codes.split(',') 217 | for code in codes: 218 | prefix = code[:-6] 219 | if prefix not in ('sh', 'sz', 's_sh', 's_sz'): 220 | raise ValueError("请检查股票代码格式是否正确。股票代码应该是6位数字,上海股票以'600','601','603'开头,深圳股票以'000'或'300'开头") 221 | 222 | stock = Stock(options.codes, options.thread_num) 223 | 224 | # 先获取一些初始数据 225 | stock.del_params() 226 | time.sleep(1) # 等待初始数据 227 | 228 | # 创建动画,增加更新频率 229 | ani = FuncAnimation(stock.fig, stock.update_plot, interval=1000) # 1秒更新一次 230 | plt.show() 231 | 232 | while True: 233 | stock.del_params() 234 | time.sleep(options.sleep_time) -------------------------------------------------------------------------------- /src/stock.py: -------------------------------------------------------------------------------- 1 | """ 2 | 股票数据获取和处理模块 3 | """ 4 | import threading 5 | import time 6 | import os 7 | from datetime import datetime, timedelta 8 | from queue import Queue 9 | from typing import Dict, List, Optional, Tuple 10 | 11 | # 强制使用合适的后端 12 | os.environ['MPLBACKEND'] = 'MacOSX' # MacOS系统 13 | # os.environ['MPLBACKEND'] = 'TkAgg' # 其他系统 14 | 15 | import matplotlib.pyplot as plt 16 | import matplotlib.dates as mdates 17 | import pandas as pd 18 | import requests 19 | import json 20 | 21 | # 添加中文字体支持 22 | plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'Arial Unicode MS'] # 优先使用微软雅黑字体 23 | plt.rcParams['axes.unicode_minus'] = False 24 | 25 | # 常量定义 26 | MAX_FIELDS = 32 27 | MAX_HISTORY = 100 28 | UPDATE_INTERVAL = 6 # 更新间隔(秒) 29 | PLOT_WIDTH = 0.8 # K线图宽度 30 | PLOT_WIDTH_SHADOW = 0.2 # K线图影线宽度 31 | 32 | class Worker(threading.Thread): 33 | """工作线程类""" 34 | def __init__(self, queue: Queue): 35 | super().__init__() 36 | self.queue = queue 37 | self.daemon = True 38 | 39 | def run(self): 40 | while True: 41 | func, args = self.queue.get() 42 | try: 43 | func(*args) 44 | finally: 45 | self.queue.task_done() 46 | 47 | class Stock: 48 | """股票数据处理类""" 49 | def __init__(self, code: str, thread_num: int = 3): 50 | """初始化股票数据处理对象""" 51 | self.code = code 52 | self.queue = Queue() 53 | self.threads = [Worker(self.queue) for _ in range(thread_num)] 54 | for thread in self.threads: 55 | thread.start() 56 | 57 | # 数据存储 58 | self.price_history: Dict[str, List[float]] = {} 59 | self.time_history: Dict[str, List[datetime]] = {} 60 | self.current_name = "" 61 | self.current_prices: Dict[str, float] = {} 62 | self.change_pcts: Dict[str, float] = {} 63 | 64 | # 日K线数据 65 | self.daily_data: Dict[str, pd.DataFrame] = {} 66 | 67 | def create_figure(self): 68 | """创建图表""" 69 | # 创建图表,只包含K线图 70 | self.fig, self.ax = plt.subplots(figsize=(12, 8)) 71 | 72 | self.fig.canvas.manager.set_window_title('股票日K线图') 73 | 74 | # 初始提示文本 75 | self.ax.text(0.5, 0.5, '正在加载数据...', 76 | horizontalalignment='center', 77 | verticalalignment='center', 78 | transform=self.ax.transAxes, 79 | fontsize=14) 80 | 81 | def value_get( 82 | self, code: str, code_index: int 83 | ) -> Tuple[int, Optional[Tuple[str, float, float]]]: 84 | """获取股票数据""" 85 | try: 86 | url = f"http://hq.sinajs.cn/list={code}" 87 | response = requests.get(url, headers={ 88 | 'Referer': 'http://finance.sina.com.cn', 89 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36' 90 | }) 91 | response.encoding = 'gbk' 92 | 93 | data_str = response.text 94 | if "var hq_str_" not in data_str: 95 | print(f"API返回格式不正确: {data_str[:100]}") 96 | return code_index, None 97 | 98 | # 从返回结果中提取数据字符串 99 | data_parts = data_str.split('="') 100 | if len(data_parts) < 2: 101 | return code_index, None 102 | 103 | data = data_parts[1].split('",')[0].split(',') 104 | 105 | if len(data) < 32: # 确保数据完整 106 | print(f"数据不完整,长度为{len(data)}") 107 | return code_index, None 108 | 109 | name = data[0] 110 | yesterday_close = float(data[2]) 111 | current_price = float(data[3]) 112 | change = round((current_price - yesterday_close) / yesterday_close * 100, 2) 113 | 114 | return code_index, (name, current_price, change) 115 | except Exception as e: 116 | print(f"获取实时数据出错: {e}") 117 | return code_index, None 118 | 119 | def get_daily_k_data(self, code: str) -> pd.DataFrame: 120 | """获取日K线数据""" 121 | try: 122 | # 提取股票代码 123 | stock_code = code[2:] # 去掉sh或sz前缀 124 | 125 | # 东方财富网API接口,提供更可靠的历史K线数据 126 | market = "1" if code.startswith("sh") else "0" 127 | url = f"http://push2his.eastmoney.com/api/qt/stock/kline/get?secid={market}.{stock_code}&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt=101&fqt=0&end=20500101&lmt=30" 128 | 129 | print(f"请求日K线数据URL: {url}") 130 | 131 | response = requests.get(url) 132 | data = response.json() 133 | 134 | if 'data' not in data or data['data'] is None or 'klines' not in data['data']: 135 | print(f"无法获取K线数据: {json.dumps(data)[:200]}") 136 | return pd.DataFrame() 137 | 138 | klines = data['data']['klines'] 139 | print(f"成功获取到{len(klines)}条K线数据记录") 140 | 141 | # 解析K线数据 142 | ohlc_data = [] 143 | for line in klines: 144 | parts = line.split(',') 145 | if len(parts) >= 6: 146 | date = parts[0] 147 | ohlc_data.append({ 148 | 'date': datetime.strptime(date, '%Y-%m-%d'), 149 | 'open': float(parts[1]), 150 | 'close': float(parts[2]), 151 | 'high': float(parts[3]), 152 | 'low': float(parts[4]), 153 | 'volume': float(parts[5]) 154 | }) 155 | 156 | # 创建DataFrame 157 | df = pd.DataFrame(ohlc_data) 158 | 159 | if not df.empty: 160 | # 设置日期为索引 161 | df.set_index('date', inplace=True) 162 | print("日K线数据列:", df.columns.tolist()) 163 | print(f"数据范围: {df.index.min()} 到 {df.index.max()}") 164 | 165 | return df 166 | except Exception as e: 167 | print(f"获取日K线数据出错: {str(e)}") 168 | # 返回空DataFrame 169 | return pd.DataFrame() 170 | 171 | def display_stock_info(self): 172 | """显示股票信息""" 173 | for code, price in self.current_prices.items(): 174 | change = self.change_pcts.get(code, 0.0) 175 | change_str = f"+{change:.2f}%" if change > 0 else f"{change:.2f}%" 176 | 177 | # 打印股票信息到控制台 178 | print(f"\n{'='*50}") 179 | print(f"股票名称: {self.current_name}") 180 | print(f"当前价格: {price:.2f} ({change_str})") 181 | print(f"{'='*50}") 182 | 183 | if hasattr(self, 'fig') and self.fig is not None: 184 | try: 185 | # 设置窗口标题 186 | self.fig.canvas.manager.set_window_title(f"{self.current_name} - 日K线图") 187 | except Exception as e: 188 | print(f"无法设置窗口标题: {e}") 189 | 190 | def plot_daily_k(self): 191 | """绘制日K线图""" 192 | if not hasattr(self, 'fig') or self.fig is None: 193 | self.create_figure() 194 | 195 | try: 196 | # 清除图表 197 | self.ax.clear() 198 | 199 | # 获取第一个股票代码 200 | if not self.price_history: 201 | return 202 | 203 | first_code = next(iter(self.price_history.keys())) 204 | 205 | # 如果没有日K线数据,尝试获取 206 | if first_code not in self.daily_data or self.daily_data[first_code].empty: 207 | self.daily_data[first_code] = self.get_daily_k_data(first_code) 208 | 209 | df = self.daily_data[first_code] 210 | 211 | if df.empty: 212 | self.ax.text(0.5, 0.5, '暂无日K线数据', horizontalalignment='center', 213 | verticalalignment='center', transform=self.ax.transAxes, 214 | fontsize=14) 215 | self.ax.grid(True) 216 | return 217 | 218 | # 分离上涨和下跌数据 219 | up = df[df.close >= df.open] 220 | down = df[df.close < df.open] 221 | 222 | # 绘制上涨K线(红色) 223 | for idx, row in up.iterrows(): 224 | self.ax.bar(idx, row.close-row.open, PLOT_WIDTH, bottom=row.open, color='red') 225 | self.ax.bar(idx, row.high-row.close, PLOT_WIDTH_SHADOW, bottom=row.close, color='red') 226 | self.ax.bar(idx, row.low-row.open, PLOT_WIDTH_SHADOW, bottom=row.open, color='red') 227 | 228 | # 绘制下跌K线(绿色) 229 | for idx, row in down.iterrows(): 230 | self.ax.bar(idx, row.close-row.open, PLOT_WIDTH, bottom=row.open, color='green') 231 | self.ax.bar(idx, row.high-row.open, PLOT_WIDTH_SHADOW, bottom=row.open, color='green') 232 | self.ax.bar(idx, row.low-row.close, PLOT_WIDTH_SHADOW, bottom=row.close, color='green') 233 | 234 | # 设置X轴为日期格式 235 | self.ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d')) 236 | self.ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) # 每3天显示一个标签 237 | 238 | plt.xticks(rotation=45) 239 | 240 | # 设置Y轴范围 241 | min_price = df.low.min() 242 | max_price = df.high.max() 243 | price_range = max_price - min_price 244 | if price_range > 0: 245 | self.ax.set_ylim([min_price - price_range * 0.05, max_price + price_range * 0.05]) 246 | 247 | # 添加价格信息到标题位置 248 | for code, price in self.current_prices.items(): 249 | change = self.change_pcts.get(code, 0.0) 250 | change_str = f"+{change:.2f}%" if change > 0 else f"{change:.2f}%" 251 | 252 | # 设置价格文本颜色(只有涨跌幅的颜色变化) 253 | price_color = 'green' if change < 0 else ('red' if change > 0 else 'black') 254 | 255 | # 标题位置的y坐标常量 256 | TITLE_Y_POS = 0.96 257 | 258 | # 添加涨跌箭头 259 | arrow = "↑" if change > 0 else ("↓" if change < 0 else "→") 260 | 261 | # 不使用任何图表内的标题 262 | self.ax.set_title("") 263 | 264 | # 创建单行标题,所有内容放在一起 265 | self.fig.suptitle(f"{self.current_name} {price:.2f} {arrow} {change_str}", 266 | fontsize=16, fontweight='bold', 267 | x=0.5, y=TITLE_Y_POS) 268 | 269 | # 为标题的不同部分添加不同颜色 270 | title_obj = self.fig.axes[0].get_title() 271 | self.fig.texts = [] # 清除之前的所有文本 272 | 273 | # 计算标题部分的水平位置,使其更居中 274 | # 使用固定间距代替基于文本长度的计算 275 | name_pos = 0.50 # 股票名称靠左一些 276 | price_pos = 0.51 # 价格靠右一些 277 | 278 | # 添加股票名称部分(黑色) 279 | self.fig.text(name_pos, TITLE_Y_POS, f"{self.current_name}", 280 | fontsize=16, fontweight='bold', color='black', 281 | ha='right', va='center') 282 | 283 | # 添加价格和涨跌幅部分(带颜色和箭头) 284 | self.fig.text(price_pos, TITLE_Y_POS, f"{price:.2f} {arrow} {change_str}", 285 | fontsize=16, fontweight='bold', color=price_color, 286 | ha='left', va='center') 287 | 288 | self.ax.set_xlabel('日期') 289 | self.ax.set_ylabel('价格 (元)') 290 | self.ax.grid(True) 291 | 292 | # 调整布局,为顶部标题留出空间 293 | plt.tight_layout(rect=[0, 0, 1, 0.95]) 294 | 295 | except Exception as e: 296 | print(f"绘制K线图出错: {str(e)}") 297 | import traceback 298 | traceback.print_exc() 299 | 300 | def __add_work(self, code: str, code_index: int): 301 | """添加工作任务""" 302 | self.queue.put((self.value_get, (code, code_index))) 303 | 304 | def display_stocks(self, codes: List[str], interval: float = UPDATE_INTERVAL): 305 | """显示股票数据""" 306 | print("程序启动中,正在初始化...") 307 | print("正在准备加载数据...") 308 | 309 | for code in codes: 310 | self.price_history[code] = [] 311 | self.time_history[code] = [] 312 | 313 | # 初始化加载日K线数据 314 | try: 315 | print(f"初始化加载{code}的日K线数据...") 316 | self.daily_data[code] = self.get_daily_k_data(code) 317 | if self.daily_data[code].empty: 318 | print(f"警告: 未能获取到{code}的日K线数据") 319 | else: 320 | print(f"成功加载了{len(self.daily_data[code])}条日K线数据") 321 | except Exception as e: 322 | print(f"初始加载{code}日K线数据失败: {str(e)}") 323 | 324 | print("\n数据加载中...") 325 | 326 | # 获取实时价格数据(只获取一次) 327 | for i, code in enumerate(codes): 328 | self.__add_work(code, i) 329 | self.queue.join() 330 | 331 | # 获取并处理价格数据 332 | for code in codes: 333 | result = self.value_get(code, 0) 334 | if result[1]: 335 | _, (name, price, change) = result 336 | self.current_name = name 337 | self.current_prices[code] = price 338 | self.change_pcts[code] = change 339 | self.price_history[code].append(price) 340 | self.time_history[code].append(datetime.now()) 341 | 342 | # 显示股票信息 343 | self.display_stock_info() 344 | 345 | print("\n准备生成K线图...") 346 | 347 | # 在绘图前创建图表 348 | self.create_figure() 349 | 350 | # 更新图表 351 | self.plot_daily_k() 352 | 353 | # 保存图表为图片文件 354 | try: 355 | filename = f"{self.current_name}_日K线图.png" 356 | plt.savefig(filename) 357 | print(f"\n图表已保存为文件: {filename}") 358 | print(f"请在当前目录查看该文件以查看K线图。") 359 | except Exception as save_error: 360 | print(f"保存图表时出错: {save_error}") 361 | 362 | print("\n尝试显示图表窗口...") 363 | print("如果窗口未显示,请查看已保存的图片文件。") 364 | print("按Ctrl+C终止程序。") 365 | 366 | # 尝试显示图表,阻塞直到窗口关闭 367 | try: 368 | plt.tight_layout() 369 | plt.show(block=True) 370 | except KeyboardInterrupt: 371 | print("\n程序已被用户终止") 372 | except Exception as e: 373 | print(f"显示图表时出错: {e}") 374 | print(f"请直接查看保存的图片文件: {filename}") -------------------------------------------------------------------------------- /src/modern_stock.py: -------------------------------------------------------------------------------- 1 | """ 2 | 现代化股票界面数据获取和处理模块 3 | 参考了主流金融App的设计理念 4 | """ 5 | from datetime import datetime, timedelta 6 | import json 7 | import os 8 | import random 9 | import threading 10 | import time 11 | from queue import Queue 12 | from typing import Dict, List, Optional, Tuple 13 | 14 | import matplotlib.cm as cm 15 | import matplotlib.dates as mdates 16 | import matplotlib.patches as patches 17 | import matplotlib.pyplot as plt 18 | import matplotlib.ticker as mticker 19 | from matplotlib.gridspec import GridSpec 20 | from matplotlib.patches import FancyBboxPatch 21 | import numpy as np 22 | import pandas as pd 23 | import requests 24 | from scipy.interpolate import make_interp_spline 25 | 26 | # 强制使用合适的后端 27 | os.environ['MPLBACKEND'] = 'MacOSX' # MacOS系统 28 | # os.environ['MPLBACKEND'] = 'TkAgg' # 其他系统 29 | 30 | # 添加中文字体支持 31 | plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'Arial Unicode MS'] # 优先使用微软雅黑字体 32 | plt.rcParams['axes.unicode_minus'] = False 33 | 34 | # 常量定义 35 | MAX_FIELDS = 32 36 | MAX_HISTORY = 100 37 | UPDATE_INTERVAL = 6 # 更新间隔(秒) 38 | PLOT_WIDTH = 0.8 # K线图宽度 39 | PLOT_WIDTH_SHADOW = 0.2 # K线图影线宽度 40 | 41 | class Worker(threading.Thread): 42 | """工作线程类""" 43 | def __init__(self, queue: Queue): 44 | super().__init__() 45 | self.queue = queue 46 | self.daemon = True 47 | 48 | def run(self): 49 | while True: 50 | func, args = self.queue.get() 51 | try: 52 | func(*args) 53 | finally: 54 | self.queue.task_done() 55 | 56 | class ModernStock: 57 | """现代股票数据处理类 - 参考主流股票App的界面设计""" 58 | def __init__(self, code: str, thread_num: int = 3): 59 | """初始化股票数据处理对象""" 60 | self.code = code 61 | self.queue = Queue() 62 | self.threads = [Worker(self.queue) for _ in range(thread_num)] 63 | for thread in self.threads: 64 | thread.start() 65 | 66 | # 数据存储 67 | self.price_history: Dict[str, List[float]] = {} 68 | self.time_history: Dict[str, List[datetime]] = {} 69 | self.current_name = "" 70 | self.current_prices: Dict[str, float] = {} 71 | self.change_pcts: Dict[str, float] = {} 72 | self.stock_info = {} 73 | 74 | # 日K线数据 75 | self.daily_data: Dict[str, pd.DataFrame] = {} 76 | 77 | # 背景颜色和图表样式 78 | self.bg_color = '#f5f5f5' # 浅灰色背景 79 | self.grid_color = '#e0e0e0' # 网格线颜色 80 | self.text_color = '#333333' # 暗灰色文本 81 | self.up_color = '#ff5d52' 82 | self.down_color = '#00b07c' 83 | 84 | # 图表相关属性 85 | self.fig = None 86 | self.ax = None 87 | self.infobox_ax = None 88 | self.text_elements = [] 89 | self.price_text = None 90 | self.title_text = None 91 | self.timeframe_ax = None 92 | self.active_timeframe = "1天" 93 | self.current_timeframe = "daily" # 初始化时间周期为日K 94 | 95 | def create_figure(self): 96 | """创建现代风格图表""" 97 | # 创建图表和布局 98 | self.fig = plt.figure(figsize=(12, 8), facecolor=self.bg_color) 99 | gs = GridSpec(7, 1, height_ratios=[1, 0.3, 0.7, 8, 0.3, 0.3, 3], hspace=0.05) 100 | 101 | # 初始化高亮区域存储列表 102 | self.highlight_area = [] 103 | 104 | # 顶部标题区域 105 | self.title_ax = self.fig.add_subplot(gs[0, 0], facecolor=self.bg_color) 106 | self.title_ax.axis('off') 107 | 108 | # 次级标题区域(显示交易所和货币单位) 109 | self.subtitle_ax = self.fig.add_subplot(gs[1, 0], facecolor=self.bg_color) 110 | self.subtitle_ax.axis('off') 111 | 112 | # 时间周期选择区域 - 移到K线图上方 113 | self.timeframe_ax = self.fig.add_subplot(gs[2, 0], facecolor=self.bg_color) 114 | self.timeframe_ax.axis('off') 115 | 116 | # 主K线图区域 117 | self.ax = self.fig.add_subplot(gs[3, 0], facecolor=self.bg_color) 118 | 119 | # 空白分隔区域 120 | self.spacer_ax = self.fig.add_subplot(gs[5, 0], facecolor=self.bg_color) 121 | self.spacer_ax.axis('off') 122 | 123 | # 底部股票详细信息表格区域 124 | self.details_ax = self.fig.add_subplot(gs[6, 0], facecolor=self.bg_color) 125 | self.details_ax.axis('off') 126 | 127 | # 设置窗口标题 128 | self.fig.canvas.manager.set_window_title('股票K线图 - 现代界面') 129 | 130 | # 初始提示文本 131 | self.ax.text(0.5, 0.5, '正在加载数据...', 132 | horizontalalignment='center', 133 | verticalalignment='center', 134 | transform=self.ax.transAxes, 135 | fontsize=14, color=self.text_color) 136 | 137 | def value_get( 138 | self, code: str, code_index: int 139 | ) -> Tuple[int, Optional[Tuple[str, float, float]]]: 140 | """获取股票数据""" 141 | try: 142 | url = f"http://hq.sinajs.cn/list={code}" 143 | response = requests.get(url, headers={ 144 | 'Referer': 'http://finance.sina.com.cn', 145 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36' 146 | }) 147 | response.encoding = 'gbk' 148 | 149 | data_str = response.text 150 | if "var hq_str_" not in data_str: 151 | print(f"API返回格式不正确: {data_str[:100]}") 152 | return code_index, None 153 | 154 | # 从返回结果中提取数据字符串 155 | data_parts = data_str.split('="') 156 | if len(data_parts) < 2: 157 | return code_index, None 158 | 159 | data = data_parts[1].split('",')[0].split(',') 160 | 161 | if len(data) < 32: # 确保数据完整 162 | print(f"数据不完整,长度为{len(data)}") 163 | return code_index, None 164 | 165 | name = data[0] 166 | open_price = float(data[1]) 167 | yesterday_close = float(data[2]) 168 | current_price = float(data[3]) 169 | high_price = float(data[4]) 170 | low_price = float(data[5]) 171 | volume = float(data[8]) 172 | turnover = float(data[9]) 173 | 174 | change = round((current_price - yesterday_close) / yesterday_close * 100, 2) 175 | 176 | # 存储更多股票信息用于显示 177 | self.stock_info = { 178 | 'name': name, 179 | 'price': current_price, 180 | 'change': change, 181 | 'open': open_price, 182 | 'high': high_price, 183 | 'low': low_price, 184 | 'prev_close': yesterday_close, 185 | 'volume': volume, 186 | 'turnover': turnover, 187 | 'code': code 188 | } 189 | 190 | return code_index, (name, current_price, change) 191 | except Exception as e: 192 | print(f"获取实时数据出错: {e}") 193 | return code_index, None 194 | 195 | def get_daily_k_data(self, code: str) -> pd.DataFrame: 196 | """获取日K线数据""" 197 | try: 198 | # 提取股票代码 199 | stock_code = code[2:] # 去掉sh或sz前缀 200 | 201 | # 东方财富网API接口,提供更可靠的历史K线数据 202 | market = "1" if code.startswith("sh") else "0" 203 | url = f"http://push2his.eastmoney.com/api/qt/stock/kline/get?secid={market}.{stock_code}&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt=101&fqt=0&end=20500101&lmt=30" 204 | 205 | print(f"请求日K线数据URL: {url}") 206 | 207 | response = requests.get(url) 208 | data = response.json() 209 | 210 | if 'data' not in data or data['data'] is None or 'klines' not in data['data']: 211 | print(f"无法获取K线数据: {json.dumps(data)[:200]}") 212 | return pd.DataFrame() 213 | 214 | klines = data['data']['klines'] 215 | print(f"成功获取到{len(klines)}条K线数据记录") 216 | 217 | # 解析K线数据 218 | ohlc_data = [] 219 | for line in klines: 220 | parts = line.split(',') 221 | if len(parts) >= 6: 222 | date = parts[0] 223 | ohlc_data.append({ 224 | 'date': datetime.strptime(date, '%Y-%m-%d'), 225 | 'open': float(parts[1]), 226 | 'close': float(parts[2]), 227 | 'high': float(parts[3]), 228 | 'low': float(parts[4]), 229 | 'volume': float(parts[5]) 230 | }) 231 | 232 | # 创建DataFrame 233 | df = pd.DataFrame(ohlc_data) 234 | 235 | if not df.empty: 236 | # 设置日期为索引 237 | df.set_index('date', inplace=True) 238 | print("日K线数据列:", df.columns.tolist()) 239 | print(f"数据范围: {df.index.min()} 到 {df.index.max()}") 240 | 241 | return df 242 | except Exception as e: 243 | print(f"获取日K线数据出错: {str(e)}") 244 | # 返回空DataFrame 245 | return pd.DataFrame() 246 | 247 | def display_stock_header(self): 248 | """显示股票标题和信息""" 249 | if not self.stock_info: 250 | return 251 | 252 | # 清空当前标题区域 253 | self.title_ax.clear() 254 | self.title_ax.axis('off') 255 | self.subtitle_ax.clear() 256 | self.subtitle_ax.axis('off') 257 | 258 | name = self.stock_info['name'] 259 | price = self.stock_info['price'] 260 | change = self.stock_info['change'] 261 | change_str = f"+{change:.2f}%" if change > 0 else f"{change:.2f}%" 262 | arrow = "↑" if change > 0 else ("↓" if change < 0 else "→") 263 | 264 | # 价格颜色 265 | price_color = self.up_color if change > 0 else (self.down_color if change < 0 else 'black') 266 | 267 | # 左侧显示股票名称(大号粗体) 268 | self.title_ax.text(0.02, 0.5, name, fontsize=28, fontweight='bold', 269 | color='black', ha='left', va='center') 270 | 271 | # 拼音/英文名称移至名称下方,不再显示在标题区域 272 | 273 | # 右侧显示价格 - 位置调整为距离右侧更远 274 | self.title_ax.text(0.75, 0.5, f"{price:.2f}", fontsize=28, 275 | fontweight='bold', color=price_color, 276 | ha='right', va='center') 277 | 278 | # 右侧显示涨跌幅 - 位置对应调整 279 | self.title_ax.text(0.76, 0.15, f"{change_str}", fontsize=14, 280 | color=price_color, ha='left', va='center') 281 | 282 | # 将"收盘"标记移至价格左侧 283 | self.title_ax.text(0.65, 0.5, "收盘", fontsize=12, 284 | color='gray', ha='right', va='center') 285 | 286 | # 底部显示交易所和货币单位 287 | market = "深圳" if self.code.startswith('sz') else "上海" 288 | self.subtitle_ax.text(0.02, 0.5, f"{market} · CNY", fontsize=12, 289 | color='gray', ha='left', va='center') 290 | 291 | # 控制台打印信息 292 | print(f"\n{'='*50}") 293 | print(f"股票名称: {name}") 294 | print(f"当前价格: {price:.2f} ({change_str})") 295 | print(f"{'='*50}") 296 | 297 | def display_timeframe_buttons(self): 298 | """显示时间周期选择按钮""" 299 | if self.timeframe_ax is None: 300 | return 301 | 302 | self.timeframe_ax.clear() 303 | self.timeframe_ax.axis('off') 304 | 305 | timeframes = ["1天", "1周", "1个月", "3个月", "6个月", "年初至今", "1年", "2年", "5年", "10年", "全部"] 306 | btn_width = 1 / len(timeframes) 307 | 308 | # 获取当前活跃的时间周期 309 | active_timeframe = getattr(self, 'active_timeframe', "1天") 310 | 311 | # 先绘制底部背景条 312 | background = FancyBboxPatch( 313 | (0, 0.2), 1.0, 0.6, 314 | boxstyle=f"round,pad=0.02,rounding_size=0.05", 315 | facecolor='#e8e8e8', 316 | edgecolor='none', 317 | alpha=1, 318 | transform=self.timeframe_ax.transAxes, 319 | zorder=0 320 | ) 321 | self.timeframe_ax.add_patch(background) 322 | 323 | # 修改间距以避免文本重叠 324 | for i, tf in enumerate(timeframes): 325 | # 调整按钮位置,增加间距 326 | x_start = i * btn_width 327 | btn_width_adjusted = btn_width * 0.9 # 缩小按钮宽度,留出间隙 328 | 329 | # 当前选中的按钮高亮显示 330 | if tf == active_timeframe: 331 | # 创建圆角矩形作为按钮背景 332 | rect = FancyBboxPatch( 333 | (x_start+0.005, 0.25), 334 | btn_width_adjusted-0.01, 0.5, 335 | boxstyle=f"round,pad=0.02,rounding_size=0.2", 336 | facecolor='white', 337 | edgecolor='none', 338 | alpha=1, 339 | transform=self.timeframe_ax.transAxes, 340 | zorder=1 341 | ) 342 | self.timeframe_ax.add_patch(rect) 343 | color = 'black' 344 | fontweight = 'bold' 345 | else: 346 | # 为其他按钮添加鼠标悬停效果的视觉暗示 347 | rect = FancyBboxPatch( 348 | (x_start+0.005, 0.25), 349 | btn_width_adjusted-0.01, 0.5, 350 | boxstyle=f"round,pad=0.02,rounding_size=0.2", 351 | facecolor='#e8e8e8', 352 | edgecolor='none', 353 | alpha=0, # 透明,仅作为视觉提示 354 | transform=self.timeframe_ax.transAxes, 355 | zorder=1 356 | ) 357 | self.timeframe_ax.add_patch(rect) 358 | color = 'gray' 359 | fontweight = 'normal' 360 | 361 | # 按钮文本字体大小调整 362 | font_size = 8 if len(tf) > 3 else 9 363 | 364 | # 添加文本标签 365 | self.timeframe_ax.text(x_start+btn_width/2, 0.5, tf, 366 | fontsize=font_size, ha='center', va='center', 367 | color=color, fontweight=fontweight, 368 | transform=self.timeframe_ax.transAxes, 369 | zorder=2) 370 | 371 | # 添加互动提示(仅作为视觉效果,实际功能需要事件处理) 372 | self.fig.canvas.mpl_connect('motion_notify_event', self._on_hover) 373 | self.fig.canvas.mpl_connect('button_press_event', self._on_click) 374 | 375 | def _on_hover(self, event): 376 | """鼠标悬停效果(仅视觉效果)""" 377 | # 实际实现需与GUI框架集成 378 | pass 379 | 380 | def _on_click(self, event): 381 | """点击按钮效果(实际功能实现)""" 382 | if not hasattr(self, 'timeframe_ax') or self.timeframe_ax is None: 383 | return 384 | 385 | if event.inaxes == self.timeframe_ax: 386 | # 获取点击的坐标 387 | x_pos = event.xdata 388 | 389 | # 计算点击的是哪个按钮 390 | timeframes = ["1天", "1周", "1个月", "3个月", "6个月", "年初至今", "1年", "2年", "5年", "10年", "全部"] 391 | btn_width = 1 / len(timeframes) 392 | selected_index = int(x_pos / btn_width) 393 | 394 | if 0 <= selected_index < len(timeframes): 395 | selected_timeframe = timeframes[selected_index] 396 | print(f"选择了时间周期: {selected_timeframe}") 397 | 398 | # 加载相应周期的数据 399 | self.load_timeframe_data(selected_timeframe) 400 | 401 | # 重新显示时间周期按钮,更新高亮显示 402 | self.active_timeframe = selected_timeframe 403 | self.display_timeframe_buttons() 404 | 405 | # 更新图表 406 | self.plot_daily_k() 407 | 408 | # 刷新图表 409 | self.fig.canvas.draw() 410 | 411 | def load_timeframe_data(self, timeframe): 412 | """加载对应时间周期的数据""" 413 | if not self.stock_info: 414 | return 415 | 416 | # 获取第一个股票代码 417 | first_code = next(iter(self.price_history.keys())) 418 | 419 | # 根据选择的时间周期获取不同的数据 420 | try: 421 | # 设置日期范围 422 | end_date = datetime.now() 423 | 424 | if timeframe == "1天": 425 | # 日内分时数据(模拟,实际应使用分时API) 426 | print(f"加载{first_code}的分时数据") 427 | # 获取当天的日K数据 428 | df = self.get_daily_k_data(first_code) 429 | 430 | # 创建模拟的分时数据(每5分钟一个数据点) 431 | now = datetime.now() 432 | today_start = datetime(now.year, now.month, now.day, 9, 30) # 交易开始时间9:30 433 | today_end = datetime(now.year, now.month, now.day, 15, 0) # 交易结束时间15:00 434 | 435 | # 计算当天的开盘价和昨收价 436 | if not df.empty: 437 | today_open = df.iloc[-1]['open'] 438 | yesterday_close = df.iloc[-2]['close'] if len(df) > 1 else df.iloc[-1]['open'] 439 | today_close = self.current_prices[first_code] 440 | today_high = df.iloc[-1]['high'] 441 | today_low = df.iloc[-1]['low'] 442 | else: 443 | # 如果没有数据,则使用当前价格模拟 444 | today_open = self.current_prices[first_code] * 0.98 445 | yesterday_close = self.current_prices[first_code] * 0.97 446 | today_close = self.current_prices[first_code] 447 | today_high = today_close * 1.02 448 | today_low = today_open * 0.98 449 | 450 | # 生成交易时间序列(上午9:30-11:30,下午13:00-15:00,每5分钟一个点) 451 | trading_times = [] 452 | current_time = today_start 453 | while current_time <= datetime(now.year, now.month, now.day, 11, 30): 454 | trading_times.append(current_time) 455 | current_time += timedelta(minutes=5) 456 | 457 | current_time = datetime(now.year, now.month, now.day, 13, 0) 458 | while current_time <= today_end: 459 | trading_times.append(current_time) 460 | current_time += timedelta(minutes=5) 461 | 462 | # 生成价格数据(在开盘价和收盘价之间模拟波动) 463 | intraday_data = [] 464 | price_range = today_high - today_low 465 | for i, t in enumerate(trading_times): 466 | # 根据时间生成一个合理的价格波动 467 | progress = i / (len(trading_times) - 1) if len(trading_times) > 1 else 0.5 468 | # 添加一些随机波动,但保持整体趋势 469 | random_factor = 0.5 + random.random() # 0.5到1.5之间的随机数 470 | if today_close > today_open: 471 | # 上涨趋势 472 | price = today_open + progress * (today_close - today_open) * random_factor 473 | else: 474 | # 下跌趋势 475 | price = today_open - progress * (today_open - today_close) * random_factor 476 | 477 | # 确保价格在当日最高价和最低价之间 478 | price = max(min(price, today_high), today_low) 479 | 480 | intraday_data.append({ 481 | 'date': t, 482 | 'open': price * 0.998, 483 | 'close': price, 484 | 'high': price * 1.002, 485 | 'low': price * 0.997, 486 | 'volume': random.randint(100000, 1000000) 487 | }) 488 | 489 | # 创建分时数据DataFrame 490 | intraday_df = pd.DataFrame(intraday_data) 491 | if not intraday_df.empty: 492 | intraday_df.set_index('date', inplace=True) 493 | print(f"生成了{len(intraday_df)}个5分钟分时数据点") 494 | print(f"分时数据范围: {intraday_df.index.min().strftime('%H:%M')} 到 {intraday_df.index.max().strftime('%H:%M')}") 495 | 496 | self.daily_data[first_code] = intraday_df 497 | self.current_timeframe = "intraday" 498 | 499 | elif timeframe == "1周": 500 | # 过去7天数据,使用半小时的粒度 501 | start_date = end_date - timedelta(days=7) 502 | print(f"加载{first_code}的1周数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 503 | # 获取每日K线数据 504 | daily_df = self.get_daily_k_data(first_code).loc[start_date:] 505 | 506 | # 创建半小时粒度数据 507 | half_hour_data = [] 508 | 509 | # 对每一天创建半小时数据点 510 | for day, row in daily_df.iterrows(): 511 | day_start = datetime(day.year, day.month, day.day, 9, 30) 512 | day_end = datetime(day.year, day.month, day.day, 15, 0) 513 | 514 | # 交易时段分别为9:30-11:30和13:00-15:00 515 | current_time = day_start 516 | while current_time <= datetime(day.year, day.month, day.day, 11, 30): 517 | half_hour_data.append({ 518 | 'date': current_time, 519 | 'open': row['open'] * (0.995 + 0.01 * random.random()), 520 | 'close': row['close'] * (0.995 + 0.01 * random.random()), 521 | 'high': row['high'] * (0.998 + 0.004 * random.random()), 522 | 'low': row['low'] * (0.998 + 0.004 * random.random()), 523 | 'volume': row['volume'] / 16 * (0.8 + 0.4 * random.random()) 524 | }) 525 | current_time += timedelta(minutes=30) 526 | 527 | current_time = datetime(day.year, day.month, day.day, 13, 0) 528 | while current_time <= day_end: 529 | half_hour_data.append({ 530 | 'date': current_time, 531 | 'open': row['open'] * (0.995 + 0.01 * random.random()), 532 | 'close': row['close'] * (0.995 + 0.01 * random.random()), 533 | 'high': row['high'] * (0.998 + 0.004 * random.random()), 534 | 'low': row['low'] * (0.998 + 0.004 * random.random()), 535 | 'volume': row['volume'] / 16 * (0.8 + 0.4 * random.random()) 536 | }) 537 | current_time += timedelta(minutes=30) 538 | 539 | # 创建分时数据DataFrame 540 | half_hour_df = pd.DataFrame(half_hour_data) 541 | if not half_hour_df.empty: 542 | half_hour_df.set_index('date', inplace=True) 543 | print(f"生成了{len(half_hour_df)}个半小时数据点覆盖7天") 544 | print(f"数据范围: {half_hour_df.index.min()} 到 {half_hour_df.index.max()}") 545 | self.daily_data[first_code] = half_hour_df 546 | else: 547 | # 如果生成失败,使用原始的日K线数据 548 | self.daily_data[first_code] = daily_df 549 | 550 | self.current_timeframe = "weekly" 551 | 552 | elif timeframe == "1个月": 553 | # 过去30天数据 554 | start_date = end_date - timedelta(days=30) 555 | print(f"加载{first_code}的1个月数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 556 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=30) 557 | self.current_timeframe = "monthly" 558 | 559 | elif timeframe == "3个月": 560 | # 过去90天数据 561 | start_date = end_date - timedelta(days=90) 562 | print(f"加载{first_code}的3个月数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 563 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=90) 564 | self.current_timeframe = "3month" 565 | 566 | elif timeframe == "6个月": 567 | # 过去180天数据 568 | start_date = end_date - timedelta(days=180) 569 | print(f"加载{first_code}的6个月数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 570 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=180) 571 | self.current_timeframe = "6month" 572 | 573 | elif timeframe == "年初至今": 574 | # 当年年初至今数据 575 | start_date = datetime(end_date.year, 1, 1) 576 | print(f"加载{first_code}的年初至今数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 577 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, start_date=start_date) 578 | self.current_timeframe = "ytd" 579 | 580 | elif timeframe == "1年": 581 | # 过去365天数据 582 | start_date = end_date - timedelta(days=365) 583 | print(f"加载{first_code}的1年数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 584 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=365) 585 | self.current_timeframe = "1year" 586 | 587 | elif timeframe == "2年": 588 | # 过去730天数据 589 | start_date = end_date - timedelta(days=730) 590 | print(f"加载{first_code}的2年数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 591 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=730) 592 | self.current_timeframe = "2year" 593 | 594 | elif timeframe == "5年": 595 | # 过去1825天数据 596 | start_date = end_date - timedelta(days=1825) 597 | print(f"加载{first_code}的5年数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 598 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=1825) 599 | self.current_timeframe = "5year" 600 | 601 | elif timeframe == "10年": 602 | # 过去3650天数据 603 | start_date = end_date - timedelta(days=3650) 604 | print(f"加载{first_code}的10年数据,从{start_date.strftime('%Y-%m-%d')}到{end_date.strftime('%Y-%m-%d')}") 605 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=3650) 606 | self.current_timeframe = "10year" 607 | 608 | elif timeframe == "全部": 609 | # 所有历史数据 610 | print(f"加载{first_code}的全部历史数据") 611 | self.daily_data[first_code] = self.get_k_data_by_period(first_code, days=10000) # 很大的天数,获取尽可能多的数据 612 | self.current_timeframe = "all" 613 | 614 | except Exception as e: 615 | print(f"加载{timeframe}周期数据出错: {str(e)}") 616 | import traceback 617 | traceback.print_exc() 618 | 619 | def get_k_data_by_period(self, code, days=None, start_date=None): 620 | """根据时间周期获取K线数据""" 621 | try: 622 | # 提取股票代码 623 | stock_code = code[2:] # 去掉sh或sz前缀 624 | 625 | # 设置市场代码 626 | market = "1" if code.startswith("sh") else "0" 627 | 628 | # 根据日期范围设置URL 629 | if days: 630 | # 使用天数参数 631 | lmt = min(days, 5000) # API限制,避免请求过大 632 | else: 633 | # 默认获取30天数据 634 | lmt = 30 635 | 636 | # 东方财富网API接口 637 | url = f"http://push2his.eastmoney.com/api/qt/stock/kline/get?secid={market}.{stock_code}&fields1=f1,f2,f3,f4,f5,f6&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61&klt=101&fqt=0&end=20500101&lmt={lmt}" 638 | 639 | print(f"请求K线数据URL: {url}") 640 | 641 | response = requests.get(url) 642 | data = response.json() 643 | 644 | if 'data' not in data or data['data'] is None or 'klines' not in data['data']: 645 | print(f"无法获取K线数据: {json.dumps(data)[:200]}") 646 | return pd.DataFrame() 647 | 648 | klines = data['data']['klines'] 649 | print(f"成功获取到{len(klines)}条K线数据记录") 650 | 651 | # 解析K线数据 652 | ohlc_data = [] 653 | for line in klines: 654 | parts = line.split(',') 655 | if len(parts) >= 6: 656 | date = parts[0] 657 | ohlc_data.append({ 658 | 'date': datetime.strptime(date, '%Y-%m-%d'), 659 | 'open': float(parts[1]), 660 | 'close': float(parts[2]), 661 | 'high': float(parts[3]), 662 | 'low': float(parts[4]), 663 | 'volume': float(parts[5]) 664 | }) 665 | 666 | # 创建DataFrame 667 | df = pd.DataFrame(ohlc_data) 668 | 669 | if not df.empty: 670 | # 设置日期为索引 671 | df.set_index('date', inplace=True) 672 | 673 | # 如果指定了开始日期,筛选数据 674 | if start_date: 675 | df = df[df.index >= start_date] 676 | 677 | print(f"数据范围: {df.index.min()} 到 {df.index.max()}") 678 | 679 | return df 680 | except Exception as e: 681 | print(f"获取特定周期K线数据出错: {str(e)}") 682 | # 返回空DataFrame 683 | return pd.DataFrame() 684 | 685 | def display_stock_details(self): 686 | """显示股票详细信息表格""" 687 | if not self.stock_info or self.details_ax is None: 688 | return 689 | 690 | # 清空当前详情区域 691 | self.details_ax.clear() 692 | self.details_ax.axis('off') 693 | 694 | # 获取股票信息 695 | info = self.stock_info 696 | 697 | # 计算表格位置和尺寸 698 | left_items = [ 699 | ('今日开盘价', f"{info.get('open', 0):.2f}"), 700 | ('今日最高价', f"{info.get('high', 0):.2f}"), 701 | ('今日最低价', f"{info.get('low', 0):.2f}"), 702 | ('成交量', f"{int(info.get('volume', 0)/10000)}万"), 703 | ('市盈率', f"{482.11:.2f}"), 704 | ('市值', f"{1003}亿") 705 | ] 706 | 707 | right_items = [ 708 | ('52周最高价', f"{59.37:.2f}"), 709 | ('52周最低价', f"{32.66:.2f}"), 710 | ('平均成交量', f"{5630}万"), 711 | ('收益率', f"{0.21}%"), 712 | ('贝塔系数', f"{0.72}"), 713 | ('每股收益', f"{0.09}") 714 | ] 715 | 716 | # 绘制表格项目 717 | for i, (label, value) in enumerate(left_items): 718 | y_pos = 0.85 - i * 0.15 719 | # 标签 720 | self.details_ax.text(0.02, y_pos, label, fontsize=12, 721 | color='gray', ha='left', va='center') 722 | # 值 723 | self.details_ax.text(0.48, y_pos, value, fontsize=12, 724 | color='black', ha='right', va='center', 725 | fontweight='bold') 726 | 727 | for i, (label, value) in enumerate(right_items): 728 | y_pos = 0.85 - i * 0.15 729 | # 标签 730 | self.details_ax.text(0.52, y_pos, label, fontsize=12, 731 | color='gray', ha='left', va='center') 732 | # 值 733 | self.details_ax.text(0.98, y_pos, value, fontsize=12, 734 | color='black', ha='right', va='center', 735 | fontweight='bold') 736 | 737 | # 添加分隔线 738 | self.spacer_ax.axhline(y=0.5, color='#dddddd', linestyle='-', linewidth=1) 739 | 740 | def plot_daily_k(self): 741 | """绘制现代风格日K线图""" 742 | if not hasattr(self, 'fig') or self.fig is None: 743 | self.create_figure() 744 | 745 | try: 746 | # 清除K线图区域 747 | self.ax.clear() 748 | 749 | # 获取第一个股票代码 750 | if not self.price_history: 751 | return 752 | 753 | first_code = next(iter(self.price_history.keys())) 754 | 755 | # 如果没有日K线数据,尝试获取 756 | if first_code not in self.daily_data or self.daily_data[first_code].empty: 757 | self.daily_data[first_code] = self.get_daily_k_data(first_code) 758 | 759 | df = self.daily_data[first_code] 760 | 761 | if df.empty: 762 | self.ax.text(0.5, 0.5, '暂无日K线数据', horizontalalignment='center', 763 | verticalalignment='center', transform=self.ax.transAxes, 764 | fontsize=14, color=self.text_color) 765 | return 766 | 767 | # 区分是否为分时数据 768 | is_intraday = self.current_timeframe == "intraday" 769 | 770 | # 计算合适的显示间隔,根据数据点数量动态调整 771 | if is_intraday: 772 | # 分时数据的间隔设置 773 | if len(df) <= 12: # 分时数据通常不会太多 774 | interval = 1 # 每个半小时点都显示 775 | else: 776 | interval = 2 # 每小时显示一次 777 | else: 778 | # 日K线数据的间隔设置 - 减少标签数量,避免重叠 779 | if len(df) <= 5: 780 | interval = 1 # 数据点很少时每天都显示 781 | elif len(df) <= 10: 782 | interval = 2 # 适当的间隔 783 | elif len(df) <= 20: 784 | interval = 4 # 增加间隔避免重叠 785 | elif len(df) <= 40: 786 | interval = 8 # 更大间隔 787 | else: 788 | interval = max(len(df) // 6, 1) # 动态计算,确保最多显示6个标签 789 | 790 | # 选择要显示的点的索引 791 | tick_indices = list(range(0, len(df), interval)) 792 | # 确保最后一个点也被显示 793 | if len(df) - 1 not in tick_indices and len(df) > 0: 794 | tick_indices.append(len(df) - 1) 795 | 796 | # 创建用于点击和悬停事件的空数据点集合,以便后续添加 797 | self.price_points = [] 798 | 799 | if is_intraday: 800 | # 绘制分时图(每半小时一个点) 801 | line, = self.ax.plot(range(len(df)), df['close'], color='#1E88E5', linewidth=2) 802 | 803 | # 创建散点,但初始时不可见,用于鼠标悬停效果 804 | scatter = self.ax.scatter([], [], s=80, color='#1E88E5', alpha=0, zorder=10) 805 | self.hover_scatter = scatter 806 | self.price_line = line 807 | 808 | # 设置X轴刻度和标签 809 | self.ax.set_xticks(tick_indices) 810 | time_labels = [idx.strftime('%H:%M') for idx in df.index] 811 | self.ax.set_xticklabels([time_labels[i] for i in tick_indices], rotation=45) 812 | 813 | # 移除分时图标题 814 | pass 815 | else: 816 | # 将K线图改为折线图以匹配示例图片的风格 817 | # 直接使用交易日索引绘制,确保所有点都是实际交易日 818 | line, = self.ax.plot(range(len(df)), df['close'], color='#1E88E5', linewidth=2) 819 | 820 | # 创建散点,但初始时不可见,用于鼠标悬停效果 821 | scatter = self.ax.scatter([], [], s=80, color='#1E88E5', alpha=0, zorder=10) 822 | self.hover_scatter = scatter 823 | self.price_line = line 824 | 825 | # 设置X轴刻度和标签 826 | self.ax.set_xticks(tick_indices) 827 | 828 | # 根据时间周期设置不同的日期格式,减少标签内容长度 829 | if self.current_timeframe in ["daily", "weekly"]: 830 | # 日周期只显示日期,不显示月份 831 | date_labels = [idx.strftime('%d') for idx in df.index] 832 | elif self.current_timeframe in ["monthly", "3month", "6month"]: 833 | # 月度周期显示"月/日"格式 834 | date_labels = [idx.strftime('%m/%d') for idx in df.index] 835 | else: 836 | # 更长周期显示"年/月"格式 837 | date_labels = [idx.strftime('%y/%m') for idx in df.index] 838 | 839 | self.ax.set_xticklabels([date_labels[i] for i in tick_indices], rotation=0) 840 | 841 | # 移除K线图标题 842 | pass 843 | 844 | # 设置更大的字体,取消旋转以提高可读性 845 | plt.xticks(fontsize=9, rotation=0) 846 | plt.yticks(fontsize=10) 847 | 848 | # 设置Y轴范围 849 | min_price = df.low.min() 850 | max_price = df.high.max() 851 | price_range = max_price - min_price 852 | if price_range > 0: 853 | self.ax.set_ylim([min_price - price_range * 0.05, max_price + price_range * 0.05]) 854 | 855 | # 设置网格线 856 | self.ax.grid(True, linestyle='-', alpha=0.3, color=self.grid_color) 857 | 858 | # 设置轴标签颜色 859 | self.ax.tick_params(axis='both', colors=self.text_color) 860 | 861 | # 移除Y轴标签 862 | self.ax.set_ylabel('') 863 | 864 | # 在Y轴右侧显示价格 865 | self.ax.yaxis.tick_right() 866 | 867 | # 格式化Y轴,显示整数 868 | self.ax.yaxis.set_major_formatter(mticker.FormatStrFormatter('%d')) 869 | 870 | # 移除边框 871 | for spine in ['top', 'right', 'left']: 872 | self.ax.spines[spine].set_visible(False) 873 | 874 | # 在图表右侧显示最新价格线 875 | if self.stock_info: 876 | current_price = self.stock_info['price'] 877 | self.ax.axhline(y=current_price, color='lightgray', linestyle='--', alpha=0.8) 878 | 879 | # 价格标签位置调整 - 放到右下角 880 | # 计算合适的标签位置 881 | x_pos = len(df) - 1 # 使用索引作为x轴位置 882 | y_pos = min_price + price_range * 0.1 # 放在图表下方10%的位置 883 | 884 | self.ax.text(x_pos, y_pos, f"{current_price:.2f}", 885 | fontsize=10, color='black', va='center', ha='right', 886 | bbox=dict(facecolor='white', alpha=0.8, pad=1, boxstyle='round')) 887 | 888 | # 存储数据用于鼠标交互 889 | self.df = df 890 | 891 | # 添加鼠标悬停事件处理 892 | self.hover_annotation = self.ax.annotate( 893 | '', xy=(0, 0), xytext=(10, 10), 894 | textcoords='offset points', 895 | bbox=dict(boxstyle='round,pad=0.5', facecolor='white', edgecolor='#1E88E5', alpha=0.9), 896 | fontsize=10, color='black', 897 | ha='center', va='bottom' 898 | ) 899 | self.hover_annotation.set_visible(False) 900 | 901 | # 添加垂直参考线,初始不可见 902 | self.vline = self.ax.axvline(x=0, color='#1E88E5', linestyle='-', alpha=0.3, visible=False) 903 | 904 | # 注册鼠标事件 905 | self.fig.canvas.mpl_connect('motion_notify_event', self._on_hover_k) 906 | 907 | # 显示价格信息和时间周期选择 908 | self.display_stock_header() 909 | self.display_timeframe_buttons() 910 | self.display_stock_details() 911 | 912 | # 调整边距,确保图表元素不会重叠 913 | plt.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.10, hspace=0.1) 914 | 915 | except Exception as e: 916 | print(f"绘制K线图出错: {str(e)}") 917 | import traceback 918 | traceback.print_exc() 919 | 920 | def _on_hover_k(self, event): 921 | """鼠标在K线图上悬停时的事件处理""" 922 | import numpy as np 923 | 924 | if not hasattr(self, 'df') or not event.inaxes or event.inaxes != self.ax: 925 | # 如果鼠标移出图表区域,隐藏所有交互元素 926 | if hasattr(self, 'hover_annotation'): 927 | self.hover_annotation.set_visible(False) 928 | if hasattr(self, 'hover_scatter'): 929 | # 使用二维坐标数组来避免索引错误 930 | self.hover_scatter.set_offsets(np.array([[0, 0]])) 931 | self.hover_scatter.set_alpha(0) 932 | if hasattr(self, 'vline'): 933 | self.vline.set_visible(False) 934 | 935 | # 恢复原始线条样式 936 | if hasattr(self, 'price_line'): 937 | self.price_line.set_alpha(1.0) 938 | self.price_line.set_linewidth(2) 939 | self.price_line.set_zorder(2) 940 | 941 | # 彻底清除所有高亮元素 942 | if hasattr(self, 'highlight_area') and self.highlight_area: 943 | for patch in self.highlight_area: 944 | if patch in self.ax.collections or patch in self.ax.lines: 945 | patch.remove() 946 | self.highlight_area = [] 947 | 948 | self.fig.canvas.draw_idle() 949 | return 950 | 951 | # 获取鼠标位置对应的数据索引 952 | x_data = int(round(event.xdata)) 953 | if x_data < 0 or x_data >= len(self.df): 954 | return 955 | 956 | # 获取对应索引的数据点 957 | df = self.df 958 | price = df['close'].iloc[x_data] 959 | date = df.index[x_data] 960 | 961 | # 生成悬停信息 962 | if self.current_timeframe == "intraday": 963 | hover_text = f'{date.strftime("%H:%M")}\n价格: {price:.2f}' 964 | else: 965 | hover_text = f'{date.strftime("%Y-%m-%d")}\n价格: {price:.2f}' 966 | 967 | # 更新注释文本内容和位置 968 | self.hover_annotation.xy = (x_data, price) 969 | self.hover_annotation.set_text(hover_text) 970 | self.hover_annotation.set_visible(True) 971 | 972 | # 更新散点位置 973 | self.hover_scatter.set_offsets(np.array([[x_data, price]])) 974 | self.hover_scatter.set_alpha(1.0) # 显示散点 975 | 976 | # 清除现有的高亮区域 977 | if hasattr(self, 'highlight_area') and self.highlight_area: 978 | for patch in self.highlight_area: 979 | if patch in self.ax.collections or patch in self.ax.lines: 980 | patch.remove() 981 | self.highlight_area = [] 982 | 983 | # 获取价格数据和图表底部边界 984 | ymin, ymax = self.ax.get_ylim() 985 | 986 | # 获取原始数据 987 | all_x = np.array(range(len(self.df))) 988 | all_y = self.df['close'].values 989 | 990 | # 隐藏原始线条,避免线条堆叠 991 | if hasattr(self, 'price_line'): 992 | self.price_line.set_alpha(0.3) 993 | 994 | # 添加简单的填充效果,使用渐变透明度 995 | from matplotlib.colors import LinearSegmentedColormap 996 | 997 | # 创建线性渐变色谱 - 颜色从浅蓝色到深蓝色 998 | colors = [(0.78, 0.89, 0.98, 0.05), # 顶部几乎透明 999 | (0.45, 0.69, 0.91, 0.25)] # 底部半透明 1000 | cmap = LinearSegmentedColormap.from_list('blue_gradient', colors) 1001 | 1002 | # 使用简单的填充区域 1003 | fill = self.ax.fill_between(all_x, all_y, ymin, color='#1E88E5', alpha=0.2, zorder=1) 1004 | self.highlight_area.append(fill) 1005 | 1006 | # 重绘当前曲线 - 简单加粗显示 1007 | line = self.ax.plot(all_x, all_y, '-', color='#1976D2', linewidth=2.5, zorder=4) 1008 | self.highlight_area.extend(line) 1009 | 1010 | # 更新垂直参考线 - 对于垂直线,使用实际数据点的索引位置 1011 | self.vline.set_xdata([x_data, x_data]) 1012 | self.vline.set_visible(True) 1013 | 1014 | # 增强显示当前点,使其更大更明显 1015 | self.hover_scatter.set_sizes([150]) 1016 | self.hover_scatter.set_facecolor('#1E88E5') 1017 | self.hover_scatter.set_edgecolor('white') 1018 | self.hover_scatter.set_linewidth(1.5) 1019 | self.hover_scatter.set_zorder(10) 1020 | 1021 | # 更新画布 1022 | self.fig.canvas.draw_idle() 1023 | 1024 | def __add_work(self, code: str, code_index: int): 1025 | """添加工作任务""" 1026 | self.queue.put((self.value_get, (code, code_index))) 1027 | 1028 | def display_stocks(self, codes: List[str], interval: float = UPDATE_INTERVAL): 1029 | """显示股票数据""" 1030 | print("程序启动中,正在初始化...") 1031 | print("正在准备加载数据...") 1032 | 1033 | for code in codes: 1034 | self.price_history[code] = [] 1035 | self.time_history[code] = [] 1036 | 1037 | # 初始化加载日K线数据 1038 | try: 1039 | print(f"初始化加载{code}的日K线数据...") 1040 | self.daily_data[code] = self.get_daily_k_data(code) 1041 | if self.daily_data[code].empty: 1042 | print(f"警告: 未能获取到{code}的日K线数据") 1043 | else: 1044 | print(f"成功加载了{len(self.daily_data[code])}条日K线数据") 1045 | except Exception as e: 1046 | print(f"初始加载{code}日K线数据失败: {str(e)}") 1047 | 1048 | print("\n数据加载中...") 1049 | 1050 | # 获取实时价格数据(只获取一次) 1051 | for i, code in enumerate(codes): 1052 | self.__add_work(code, i) 1053 | self.queue.join() 1054 | 1055 | # 获取并处理价格数据 1056 | for code in codes: 1057 | result = self.value_get(code, 0) 1058 | if result[1]: 1059 | _, (name, price, change) = result 1060 | self.current_name = name 1061 | self.current_prices[code] = price 1062 | self.change_pcts[code] = change 1063 | self.price_history[code].append(price) 1064 | self.time_history[code].append(datetime.now()) 1065 | 1066 | print("\n准备生成K线图...") 1067 | 1068 | # 在绘图前创建图表 1069 | self.create_figure() 1070 | 1071 | # 更新图表 1072 | self.plot_daily_k() 1073 | 1074 | # 保存图表为图片文件 1075 | try: 1076 | filename = f"{self.current_name}_日K线图_现代界面.png" 1077 | plt.savefig(filename, facecolor=self.bg_color) 1078 | print(f"\n图表已保存为文件: {filename}") 1079 | print(f"请在当前目录查看该文件以查看K线图。") 1080 | except Exception as save_error: 1081 | print(f"保存图表时出错: {save_error}") 1082 | 1083 | print("\n尝试显示图表窗口...") 1084 | print("如果窗口未显示,请查看已保存的图片文件。") 1085 | print("按Ctrl+C终止程序。") 1086 | 1087 | # 尝试显示图表,阻塞直到窗口关闭 1088 | try: 1089 | plt.show(block=True) 1090 | except KeyboardInterrupt: 1091 | print("\n程序已被用户终止") 1092 | except Exception as e: 1093 | print(f"显示图表时出错: {e}") 1094 | print(f"请直接查看保存的图片文件: {filename}") --------------------------------------------------------------------------------