├── qtf_mcp ├── __init__.py ├── symbols.py ├── mcp_app.py ├── datafeed.py └── research.py ├── docs ├── deepchat.jpg ├── cherrystudio.jpg ├── deepchat-use.jpg ├── cherrystudio-use.jpg └── let-your-deepseek-analyze-stock-by-mcp.md ├── start.sh ├── .vscode ├── settings.json └── launch.json ├── tests └── test.sh ├── .gitignore ├── pyproject.toml ├── data.py ├── main.py └── README.md /qtf_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | from .mcp_app import mcp_app 2 | -------------------------------------------------------------------------------- /docs/deepchat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elsejj/mcp-cn-a-stock/HEAD/docs/deepchat.jpg -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/bash 2 | 3 | 4 | nohup python3 main.py --transport=http > log.txt 2>&1 & -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "FUNDFLOW", 4 | "streamable" 5 | ] 6 | } -------------------------------------------------------------------------------- /docs/cherrystudio.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elsejj/mcp-cn-a-stock/HEAD/docs/cherrystudio.jpg -------------------------------------------------------------------------------- /docs/deepchat-use.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elsejj/mcp-cn-a-stock/HEAD/docs/deepchat-use.jpg -------------------------------------------------------------------------------- /docs/cherrystudio-use.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elsejj/mcp-cn-a-stock/HEAD/docs/cherrystudio-use.jpg -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # `gpt` 是一个大模型命令行工具,支持多种模型和接口,参见 https://github.com/elsejj/gpt 5 | 6 | gpt -M "http://82.156.17.205/cnstock/mcp" "请详细分析一下浦发银行的最近走势" 7 | 8 | gpt -M "http://82.156.17.205/cnstock/mcp" "请从技术分析角度分析一下贵州茅台的后市走势" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .python-version 12 | 13 | .env 14 | uv.lock 15 | 16 | log.txt 17 | 18 | libs -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "qtf-mcp" 3 | version = "0.1.3" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = ["mcp[cli]>=1.22", "qtf", "ta-lib>=0.6.8"] 8 | 9 | [tool.uv.sources] 10 | qtf = { path = "libs/qtf-0.2.0-cp310-abi3-manylinux_2_28_x86_64.whl" } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Server", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "main.py", 12 | "args": [ 13 | "--transport=http" 14 | ], 15 | "justMyCode": false, 16 | "console": "integratedTerminal", 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /data.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | load_dotenv(override=True) 3 | 4 | from io import StringIO 5 | 6 | 7 | from qtf_mcp import research 8 | 9 | import logging 10 | 11 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 12 | 13 | 14 | async def load_data(symbol: str, start_date: str, end_date: str) -> str: 15 | raw_data = await research.load_raw_data(symbol) 16 | buf = StringIO() 17 | if len(raw_data) == 0: 18 | return "No data found for symbol: " + symbol 19 | research.build_basic_data(buf, symbol, raw_data) 20 | research.build_trading_data(buf, symbol, raw_data) 21 | research.build_financial_data(buf, symbol, raw_data) 22 | research.build_technical_data(buf, symbol, raw_data) 23 | return buf.getvalue() 24 | 25 | 26 | if __name__ == "__main__": 27 | import asyncio 28 | 29 | symbol = "SZ001335" 30 | start_date = "2025-01-01" 31 | end_date = "2026-01-01" 32 | result = asyncio.run(load_data(symbol, start_date, end_date)) 33 | print(result) 34 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | load_dotenv(override=True) 4 | import logging 5 | 6 | logging.basicConfig(level=logging.WARN, format="%(asctime)s %(levelname)s %(message)s") 7 | 8 | logger = logging.getLogger("qtf_mcp") 9 | logger.setLevel(logging.DEBUG) 10 | 11 | import click 12 | 13 | from qtf_mcp import mcp_app 14 | from qtf_mcp.symbols import load_symbols 15 | 16 | 17 | @click.command() 18 | @click.option("--port", default=8000, help="Port to listen on for SSE") 19 | @click.option( 20 | "--transport", 21 | type=click.Choice(["stdio", "sse", "http"], case_sensitive=False), 22 | default="http", 23 | help="Transport type", 24 | ) 25 | def main(port: int, transport: str) -> int: 26 | load_symbols() 27 | if transport == "http": 28 | transport = "streamable-http" 29 | mcp_app.settings.port = port 30 | mcp_app.settings.log_level = "WARNING" 31 | logger.info(f"Starting MCP app on port {port} with transport {transport}") 32 | mcp_app.run(transport) # type: ignore 33 | return 0 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /qtf_mcp/symbols.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Dict, Iterable, Tuple 4 | 5 | SYMBOLS_SHSZ: Dict[str, Tuple[str, int, int]] = {} 6 | 7 | logger = logging.getLogger("qtf_mcp") 8 | 9 | 10 | def load_markets(fname: str): 11 | try: 12 | with open(fname) as fp: 13 | m = json.load(fp) 14 | for o in m["items"]: 15 | SYMBOLS_SHSZ[o["code"]] = (o["name"], 2, 2) 16 | except: 17 | logger.warning("load markets failed", exc_info=True) 18 | 19 | 20 | def load_symbols(): 21 | """ 22 | load symbols from confs/markets.json 23 | """ 24 | load_markets("confs/markets.json") 25 | 26 | 27 | def symbol_with_name(symbols: Iterable[str]) -> Iterable[Tuple[str, str]]: 28 | """ 29 | return symbol with name 30 | """ 31 | for s in symbols: 32 | if s in SYMBOLS_SHSZ: 33 | yield (s, SYMBOLS_SHSZ[s][0]) 34 | else: 35 | yield (s, "") 36 | 37 | 38 | def get_symbol_name(symbol: str) -> str: 39 | """ 40 | get symbol name 41 | """ 42 | if symbol in SYMBOLS_SHSZ: 43 | return SYMBOLS_SHSZ[symbol][0] 44 | else: 45 | return "" 46 | -------------------------------------------------------------------------------- /docs/let-your-deepseek-analyze-stock-by-mcp.md: -------------------------------------------------------------------------------- 1 | # 通过 MCP 让你的 DeepSeek 分析最新的股票走势 2 | 3 | DeepSeek 具备强大的分析能力,但由于缺少最新的股票数据,它并不能为你提供最新的股票走势分析。为了让 DeepSeek 能够分析最新的股票走势,有必要将最新的股票数据提供给 DeepSeek。为此,有以下的一些方法: 4 | 5 | - 手动的将最新的股票数据编辑在提词中,然后让 DeepSeek 进行分析。但这种方法非常麻烦,操作很繁琐。 6 | - 让 DeepSeek 联网搜索最新的股票数据,然后进行分析。由于搜索引擎的滞后性,DeepSeek 可能无法获取最新的股票数据,同时是否能搜索到准确的股票数据也是一个问题。 7 | 8 | 现在,我们可以通过 MCP(Model Context Protocol)来实现这一目标,MCP 是一种让大模型与外部交互的协议,通过 MCP,我们可以将最新的股票数据提供给 DeepSeek,让 DeepSeek 能够分析最新的股票走势。 9 | 10 | 下面,以 [DeepChat](https://github.com/ThinkInAIXYZ/deepchat) 这个支持 MCP 的客户端为例,介绍如何使用 MCP 来让 DeepSeek 分析最新的股票走势。其他的支持 MCP 包括 [CherryStudio](https://github.com/CherryHQ/cherry-studio), [Claude Desktop](https://claude.ai/download) 等,使用方法类似。 11 | 12 | 在安装了 `DeepChat` 之后, 我们需要配置 MCP 服务器,配置路径为: 13 | 14 | > 左侧栏 -> 设置 -> MCP 服务器 -> 添加服务器 15 | 16 | MCP 目前有两种和 DeepSeek 交互的方式: 17 | 18 | - stdio: 标准输入输出, 这种模式, 需要 MCP 服务运行在本地. 19 | - sse: 服务器推送事件, 这种模式, MCP 服务运行在远程服务器上. 20 | 21 | 由于股票的数据比较庞大,在本地运行不太现实, 通过[mcp-cn-a-stock](https://github.com/elsejj/mcp-cn-a-stock) 这个开源项目, 我们可以使用一个现成的 MCP 服务,`http://82.156.17.205/cnstock/sse`. 这个服务提供了最新的 A 股数据. 22 | 23 | 参考以下的截图来配置 MCP 服务器: 24 | 25 | ![MCP 服务器配置](./deepchat.jpg) 26 | 27 | 配置完成后,启用它即可. 28 | 29 | 在启用 MCP 服务器后,当让 DeepSeek 分析股票走势时, 它会自动从 MCP 服务器获取最新的股票数据, 例如 30 | 31 | ![DeepSeek 股票分析](./deepchat-use.jpg) 32 | 33 | 这样, 我们就可以借助 DeepSeek 强大的分析能力来解读股票的走势. 34 | -------------------------------------------------------------------------------- /qtf_mcp/mcp_app.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from mcp.server.fastmcp import Context, FastMCP 4 | from starlette.applications import Starlette 5 | from starlette.middleware.cors import CORSMiddleware 6 | from . import research 7 | 8 | 9 | class QtfMCP(FastMCP): 10 | 11 | def streamable_http_app(self) -> Starlette: 12 | super_app = super().streamable_http_app() 13 | super_app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) 14 | return super_app 15 | 16 | # Create an MCP server 17 | mcp_app = QtfMCP( 18 | "CnStock", 19 | sse_path="/cnstock/sse", 20 | message_path="/cnstock/messages/", 21 | streamable_http_path="/cnstock/mcp", 22 | stateless_http=True, 23 | ) 24 | 25 | 26 | @mcp_app.tool() 27 | async def brief(symbol: str, ctx: Context) -> str: 28 | """Get brief information for a given stock symbol, including 29 | - basic data 30 | - trading data 31 | Args: 32 | symbol (str): Stock symbol, must be in the format of "SH000001" or "SZ000001", you should infer user inputs like stock name to stock symbol 33 | """ 34 | who = ctx.request_context.request.client.host # type: ignore 35 | raw_data = await research.load_raw_data(symbol, None, who) 36 | buf = StringIO() 37 | if len(raw_data) == 0: 38 | return "No data found for symbol: " + symbol 39 | research.build_basic_data(buf, symbol, raw_data) 40 | research.build_trading_data(buf, symbol, raw_data) 41 | """Get brief information for a given stock symbol""" 42 | return buf.getvalue() 43 | 44 | 45 | @mcp_app.tool() 46 | async def medium(symbol: str, ctx: Context) -> str: 47 | """Get medium information for a given stock symbol, including 48 | - basic data 49 | - trading data 50 | - financial data 51 | Args: 52 | symbol (str): Stock symbol, must be in the format of "SH000001" or "SZ000001", you infer convert user inputs like stock name to stock symbol 53 | """ 54 | who = ctx.request_context.request.client.host # type: ignore 55 | raw_data = await research.load_raw_data(symbol, None, who) 56 | buf = StringIO() 57 | if len(raw_data) == 0: 58 | return "No data found for symbol: " + symbol 59 | research.build_basic_data(buf, symbol, raw_data) 60 | research.build_trading_data(buf, symbol, raw_data) 61 | research.build_financial_data(buf, symbol, raw_data) 62 | return buf.getvalue() 63 | 64 | 65 | @mcp_app.tool() 66 | async def full(symbol: str, ctx: Context) -> str: 67 | """Get full information for a given stock symbol, including 68 | - basic data 69 | - trading data 70 | - financial data 71 | - technical analysis data 72 | Args: 73 | symbol (str): Stock symbol, must be in the format of "SH000001" or "SZ000001", you should infer user inputs like stock name to stock symbol 74 | """ 75 | who = ctx.request_context.request.client.host # type: ignore 76 | raw_data = await research.load_raw_data(symbol, None, who) 77 | buf = StringIO() 78 | if len(raw_data) == 0: 79 | return "No data found for symbol: " + symbol 80 | research.build_basic_data(buf, symbol, raw_data) 81 | research.build_trading_data(buf, symbol, raw_data) 82 | research.build_financial_data(buf, symbol, raw_data) 83 | research.build_technical_data(buf, symbol, raw_data) 84 | return buf.getvalue() 85 | -------------------------------------------------------------------------------- /qtf_mcp/datafeed.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import time 5 | from typing import Dict, List 6 | 7 | import numpy as np 8 | from qtf import msd_fetch_once, pre_adjustment 9 | 10 | logger = logging.getLogger("qtf_mcp") 11 | 12 | msd_host = os.environ.get("MSD_HOST", "") 13 | stock_sector_data = os.environ.get("STOCK_TO_SECTOR_DATA", "confs/stock_sector.json") 14 | 15 | if msd_host == "": 16 | logger.error("MSD_HOST is not set") 17 | raise ValueError("MSD_HOST is not set") 18 | 19 | 20 | STOCK_SECTOR: Dict[str, List[str]] | None = None 21 | 22 | 23 | def get_stock_sector() -> Dict[str, List[str]]: 24 | global STOCK_SECTOR 25 | if STOCK_SECTOR is None: 26 | with open(stock_sector_data, "r", encoding="utf-8") as f: 27 | STOCK_SECTOR = json.load(f) 28 | return STOCK_SECTOR if STOCK_SECTOR is not None else {} 29 | 30 | 31 | async def load_data_msd( 32 | symbol: str, start_date: str, end_date: str, n: int = 0, who: str = "" 33 | ) -> Dict[str, np.ndarray]: 34 | # logger.info(f"align data {symbol} cost {t3 - t2} seconds") 35 | datas = load_data_msd_batch([symbol], start_date, end_date, n, who) 36 | 37 | return datas.get(symbol, {}) 38 | 39 | 40 | def align_date_fill(base: np.ndarray, target: np.ndarray) -> np.ndarray: 41 | """ 42 | return index of target that target[index] >= base[i] 43 | """ 44 | index = np.searchsorted(target, base, side="right") 45 | index = index - 1 46 | index[index < 0] = 0 47 | return index 48 | 49 | 50 | def symbol_sqls(sqls: Dict[str, str], symbol: str, start_date: str, end_date: str): 51 | sql1 = f"SELECT * FROM kline1d.{symbol} WHERE __date__ BETWEEN '{start_date}' AND '{end_date}'" 52 | sql2 = f"SELECT * FROM finance.{symbol}" 53 | sql3 = f"SELECT DATE,BS,DS,SD FROM divid.{symbol}" 54 | sql4 = f"SELECT * FROM fundflow.{symbol} WHERE __date__ BETWEEN '{start_date}' AND '{end_date}'" 55 | 56 | if symbol.startswith("SH6") or symbol.startswith("SZ00") or symbol.startswith("SZ30"): 57 | sqls[f"{symbol}.KLINE"] = sql1 58 | sqls[f"{symbol}.FINANCE"] = sql2 59 | sqls[f"{symbol}.DIVID"] = sql3 60 | sqls[f"{symbol}.FUNDFLOW"] = sql4 61 | else: 62 | sqls[f"{symbol}.KLINE"] = sql1 63 | 64 | 65 | def load_data_msd_batch( 66 | symbols: List[str], start_date: str, end_date: str, n: int = 0, who: str = "" 67 | ) -> Dict[str, Dict[str, np.ndarray]]: 68 | sqls = {} 69 | for symbol in symbols: 70 | symbol_sqls(sqls, symbol, start_date, end_date) 71 | 72 | # fetch all data 73 | t1 = time.time() 74 | raw_datas = msd_fetch_once("msd://" + msd_host, sqls) 75 | t2 = time.time() 76 | logger.info(f"{who} fetch data cost {t2 - t1} seconds, symbols: {','.join(symbols)}") 77 | 78 | # group by symbol -> kind -> field 79 | grouped = {} 80 | for k, v in raw_datas.items(): 81 | symbol, kind, field = k.split(".") 82 | if symbol not in grouped: 83 | grouped[symbol] = {} 84 | if kind not in grouped[symbol]: 85 | grouped[symbol][kind] = {} 86 | grouped[symbol][kind][field] = v 87 | 88 | datas = {} 89 | 90 | for k, g in grouped.items(): 91 | symbol_data = {} 92 | 93 | kline = g.get("KLINE", None) 94 | if kline is None: 95 | continue 96 | 97 | date_base = kline.get("DATE", None) 98 | if date_base is None: 99 | continue 100 | 101 | # fill kline data 102 | for field, arr in kline.items(): 103 | symbol_data[field] = arr # NDArrayWithDate(arr, date_base) 104 | symbol_data["_DS_KLINE"] = (kline, "1d") 105 | 106 | # fill finance data 107 | finance = g.get("FINANCE", None) 108 | if finance is not None: 109 | dates2 = finance["DATE"] 110 | for field, arr in finance.items(): 111 | if field == "DATE": 112 | continue 113 | arr = np.nan_to_num(arr) 114 | if field in ["TCAP", "AS", "BS", "GOS", "FIS", "FCS"]: 115 | arr = arr * 10000.0 116 | symbol_data[field] = arr # NDArrayWithDate(arr, dates2) 117 | symbol_data["_DS_FINANCE"] = (finance, "1q") 118 | 119 | # fill divid data 120 | divid = g.get("DIVID", None) 121 | if divid is not None: 122 | dates3 = divid["DATE"] 123 | aligned = align_date_fill(date_base, dates3) 124 | c = np.intersect1d(date_base, dates3) 125 | ai = np.nonzero(np.isin(date_base, c)) 126 | bi = np.nonzero(np.isin(dates3, c)) 127 | if len(aligned) > 0: 128 | BS = np.nan_to_num(divid["BS"]) 129 | DS = np.nan_to_num(divid["DS"]) 130 | SD = np.nan_to_num(divid["SD"]) 131 | 132 | GIVEN_SHARE = np.zeros_like(date_base, dtype=np.float64) 133 | GIVEN_CASH = np.zeros_like(date_base, dtype=np.float64) 134 | GIVEN_SHARE[ai] = (BS[bi] + DS[bi]) / 10.0 135 | GIVEN_CASH[ai] = SD[bi] / 10.0 136 | GIVEN_CASH = np.nan_to_num(GIVEN_CASH) 137 | GIVEN_SHARE = np.nan_to_num(GIVEN_SHARE) 138 | symbol_data["GCASH"] = GIVEN_CASH # NDArrayWithDate(GIVEN_CASH, date_base) 139 | symbol_data["GSHARE"] = np.nan_to_num(GIVEN_SHARE) 140 | divid["GCASH"] = GIVEN_CASH 141 | divid["GSHARE"] = GIVEN_SHARE 142 | else: 143 | GIVEN_CASH = np.zeros_like(date_base, dtype=np.float64) 144 | GIVEN_SHARE = np.zeros_like(date_base, dtype=np.float64) 145 | symbol_data["GCASH"] = np.zeros_like(date_base, dtype=np.float64) 146 | symbol_data["GSHARE"] = np.zeros_like(date_base, dtype=np.float64) 147 | divid["GCASH"] = GIVEN_CASH 148 | divid["GSHARE"] = GIVEN_SHARE 149 | 150 | CLOSE = np.array(symbol_data["CLOSE"], copy=True) 151 | CLOSE2 = pre_adjustment(CLOSE, symbol_data["GCASH"], symbol_data["GSHARE"]) 152 | 153 | divid["DATE"] = date_base 154 | 155 | ratio = CLOSE2 / CLOSE 156 | 157 | symbol_data["OPEN"] *= ratio 158 | symbol_data["HIGH"] *= ratio 159 | symbol_data["LOW"] *= ratio 160 | symbol_data["CLOSE"] *= ratio 161 | symbol_data["CLOSE2"] = CLOSE 162 | symbol_data["PRICE"] = CLOSE 163 | symbol_data["_DS_DIVID"] = (divid, "1d") 164 | else: 165 | symbol_data["GCASH"] = np.zeros_like(date_base, dtype=np.float64) 166 | symbol_data["GSHARE"] = np.zeros_like(date_base, dtype=np.float64) 167 | symbol_data["CLOSE2"] = symbol_data["CLOSE"] 168 | symbol_data["PRICE"] = symbol_data["CLOSE"] 169 | 170 | symbol_data["SECTOR"] = get_stock_sector().get(k, []) 171 | 172 | fund_flow = g.get("FUNDFLOW", None) 173 | if fund_flow is not None: 174 | for field, arr in fund_flow.items(): 175 | if field == "DATE": 176 | continue 177 | symbol_data[field] = arr 178 | symbol_data["_DS_FUNDFLOW"] = (fund_flow, "1d") 179 | 180 | datas[k] = symbol_data 181 | 182 | return datas 183 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | 这是一个为大模型提供 A 股数据的的 MCP(Model Content Protocol) 服务。 4 | 5 | 目前股票数据为大模型提供以下的数据 6 | 7 | - 基本信息:包括股票名称,所属板块等 8 | - 行情数据:当前的行情,以及相关可供参考的历史统计 9 | - 财务数据:近年的公司主要财务数据 10 | - 技术指标:若干常用技术指标 11 | 12 | 这些数据,被划分成三个工具 13 | 14 | - brief: 给定股票的基本信息,行情数据 15 | - medium: 提供所有基本数据和一些财务数据 16 | - full: 提供所有中等数据和技术指标 17 | 由大模型根据用户的问题需要,自行选择调用。 18 | 19 | ## 免责申明 20 | 21 | 本项目会尽可能的保障准确可用, 但不对因此产生的任何后果负任何责任, 使用本 MCP 即代表接受此免责申明. 22 | 23 | # 使用方法 24 | 25 | 由于股票的数据比较庞大,所以我们提供了一个公开的服务地址, 查看 [tests/test.sh](tests/test.sh) 文件,里面有一些测试用例以及相关的使用方法。 26 | 27 | 然后需要一个 MCP 客户端来连接这个服务,下面是一些常用的 MCP 客户端的配置方法。 28 | 29 | ## 重要更新 2025-11-22 30 | 31 | 更新 MCP 协议为 `streamable-http`, 测试服务地址变更为 32 | 33 | `http://82.156.17.205/cnstock/mcp` 34 | 35 | ## CherryStudio 36 | 37 | [CherryStudio](https://github.com/CherryHQ/cherry-studio) 是一个支持 MCP 的客户端,提供了用户友好的界面和丰富的功能。 38 | 39 | ### 配置 40 | 41 | 配置路径为 42 | 43 | > 左侧栏 -> 设置 -> MCP 设置 -> 添加服务器 44 | 45 | 在弹出的对话框中, 参考下图填入相关的信息 46 | 47 | 然后启用即可 48 | 49 | ![cherrystudio](docs/cherrystudio.jpg) 50 | 51 | ### 使用 52 | 53 | ![cherrystudio-use](docs/cherrystudio-use.jpg) 54 | 55 | ## DeepChat 56 | 57 | [DeepChat](https://github.com/ThinkInAIXYZ/deepchat) 是一个支持 MCP 的客户端,提供了用户友好的界面和丰富的功能。 58 | 59 | ### 配置 60 | 61 | 配置路径为 62 | 63 | > 左侧栏 -> 设置 -> MCP 服务器 -> 添加服务器 64 | 65 | 在弹出的对话框中, 参考下图填入相关的信息 66 | 67 | 然后启用即可 68 | 69 | ![deepchat](docs/deepchat.jpg) 70 | 71 | ### 使用 72 | 73 | ![](docs/deepchat-use.jpg) 74 | 75 | # 使用案例 76 | 77 | ## 盘面分析 78 | 79 | ### 系统题词 80 | 81 | ``` 82 | 你是一个专业的投资顾问,请根据用户的输入,推断出股票代码,然后调用相关的工具,进行走势分析。 83 | 请在分析时标明当前的数据日期 84 | 你应该应用你的金融学知识,着重对数据进行专业解读。 85 | ``` 86 | 87 | ### 用户输入 88 | 89 | ``` 90 | 浦发银行 91 | ``` 92 | 93 | ### 模型回复 94 | 95 | ```markdown 96 | 好的,我将为您分析浦发银行的股票走势。浦发银行的股票代码为 **SH600000**(上海证券交易所上市)。我将调用相关工具获取最新的数据并进行分析。 97 | 98 | # 浦发银行 (SH600000) 股票走势分析 99 | 100 | **数据日期:2025 年 5 月 6 日** 101 | 102 | 以下是对浦发银行 (SH600000) 的详细分析,基于提供的最新数据,结合金融学知识,从基本面、交易数据、技术指标等多个角度进行解读。 103 | 104 | --- 105 | 106 | ## 1. 基本面分析 107 | 108 | - **行业概念**:浦发银行属于信托概念、证金持股、跨境支付(CIPS) 和高股息精选等概念板块。这些标签表明其在金融领域具有多元化业务布局,尤其是在高股息领域,可能对追求稳定收益的投资者具有吸引力。 109 | - **市盈率 (PE)**:4.90,处于较低水平,表明股票估值相对较低,可能被市场低估,但也可能反映市场对其未来增长预期的悲观。 110 | - **市净率 (PB)**:0.33,远低于 1,表明股价低于每股净资产,可能是价值投资的机会,但也可能反映资产质量或盈利能力问题。 111 | - **净资产收益率 (ROE)**:2.65%,相对较低,显示公司资本利用效率不高,盈利能力有待提升。 112 | 113 | ### 财务数据趋势 114 | 115 | 从过去五年的财务数据来看: 116 | 117 | - **主营收入**:2020 年至 2022 年呈下降趋势,2023 年略有回升,但 2024 年再次小幅下降至 1707.48 亿元,显示业务增长乏力。 118 | - **净利润**:2020 年至 2022 年持续下降,2023 年有所回升至 367.02 亿元,2024 年进一步增长至 452.57 亿元,表明盈利能力正在逐步恢复。 119 | - **每股收益 (EPS)** 和 **每股净资产 (NAV)**:EPS 从 2020 年的 1.88 元下降至 2023 年的 1.07 元,2024 年回升至 1.36 元;NAV 持续增长,2024 年达到 22.36 元,显示公司资产积累能力较强。 120 | - **净资产收益率 (ROE)**:从 2020 年的 10.46%下降至 2023 年的 5.10%,2024 年小幅回升至 6.08%,反映出盈利效率仍未恢复到前期水平。 121 | 122 | **基本面总结**:浦发银行目前估值较低,具有一定的价值投资潜力,但盈利能力和业务增长面临挑战。净利润的回升是一个积极信号,但主营收入的波动和较低的 ROE 表明其长期增长仍需观察。 123 | 124 | --- 125 | 126 | ## 2. 交易数据分析 127 | 128 | ### 价格趋势 129 | 130 | - **短期 (5 日)**:均价 10.858 元,最高 11.190 元,最低 10.570 元,累计涨跌幅+5.48%,显示短期内股价有较强的上涨动能。 131 | - **中期 (20 日)**:均价 10.510 元,累计涨跌幅+6.58%,价格波动范围较大(最高 11.190 元,最低 9.460 元),表明中期趋势向上,但波动性较高。 132 | - **长期 (240 日)**:均价 9.468 元,累计涨跌幅+43.21%,显示长期趋势非常强劲,股价从低点 7.760 元上涨至当前水平,反映市场对该股的信心逐步增强。 133 | 134 | ### 成交量与换手率 135 | 136 | - **成交量**:当日成交量 97.26 万手,显著高于 5 日均量(67.21 万手)和 20 日均量(69.60 万手),表明近期市场参与度增加,资金流入明显。 137 | - **换手率**:当日换手率 0.33%,5 日总换手率 1.14%,20 日总换手率 4.74%,显示短期交易活跃度较高,但长期来看换手率较低(240 日均换手 0.17%),投资者多以持有为主。 138 | 139 | ### 振幅 140 | 141 | - **短期 (5 日)**:振幅 5.87%,显示短期波动较为温和。 142 | - **长期 (240 日)**:振幅 44.20%,表明长期内价格波动较大,投资者需关注风险。 143 | 144 | **交易数据总结**:浦发银行近期价格呈上涨趋势,尤其是短期和中期动能较强,成交量放大显示资金关注度提升。然而,长期振幅较大,投资者需警惕价格回调风险。 145 | 146 | --- 147 | 148 | ## 3. 技术指标分析 149 | 150 | ### KDJ 指标 151 | 152 | - 最新数据 (2025-05-06):K=86.38,D=81.30,J=96.54,处于高位,显示股价短期内处于超买状态,可能面临回调压力。 153 | - 趋势:过去 30 日 KDJ 指标整体呈上升趋势,J 值多次超过 100,反映市场情绪较为乐观,但需警惕超买风险。 154 | 155 | ### MACD 指标 156 | 157 | - 最新数据:DIF=0.16,DEA=0.10,DIFF>DEA,且 DIFF 持续扩大,显示多头动能增强,短期趋势仍为看涨。 158 | - 趋势:过去 30 日 MACD 指标从负值转为正值,表明股价从弱势转为强势,目前仍处于多头排列。 159 | 160 | ### RSI 指标 161 | 162 | - 最新数据:RSI(6)=77.85,RSI(12)=67.60,RSI(24)=61.28,其中 RSI(6)处于超买区域(>70),短期可能有调整需求。 163 | - 趋势:RSI 指标整体处于 50 以上,显示市场处于强势,但短期超买信号需关注。 164 | 165 | ### 布林带 (BBands) 166 | 167 | - 最新数据:上轨 11.26 元,中轨 10.86 元,下轨 10.47 元,当前股价 11.17 元接近上轨,显示价格处于高位,短期可能面临阻力。 168 | - 趋势:布林带近期持续走宽,表明波动性增加,股价突破中轨后持续靠近上轨,显示多头力量占优。 169 | 170 | **技术指标总结**:多项指标显示浦发银行短期处于超买状态,KDJ 和 RSI 均提示回调风险,但 MACD 和布林带仍显示多头趋势,短期内上涨动能可能延续,建议投资者关注回调后的买入机会。 171 | 172 | --- 173 | 174 | ## 4. 综合分析与投资建议 175 | 176 | ### 优点 177 | 178 | 1. **低估值**:市盈率和市净率均处于较低水平,具有一定的价值投资吸引力。 179 | 2. **长期趋势向上**:240 日涨跌幅高达 43.21%,显示长期市场信心较强。 180 | 3. **短期动能强劲**:5 日和 20 日涨跌幅分别为 5.48%和 6.58%,成交量放大,资金流入明显。 181 | 4. **财务改善**:净利润和每股收益在 2024 年有所回升,显示盈利能力逐步恢复。 182 | 183 | ### 风险 184 | 185 | 1. **盈利效率低**:净资产收益率较低,业务增长乏力,主营收入波动较大。 186 | 2. **短期超买**:技术指标显示股价处于超买状态,短期可能面临回调压力。 187 | 3. **波动性高**:长期振幅较大,投资者需关注市场波动风险。 188 | 189 | ### 投资建议 190 | 191 | - **短期投资者**:当前股价处于高位,技术指标显示超买,建议观望,等待回调至布林带中轨(10.86 元)附近或更低水平再考虑介入。 192 | - **中长期投资者**:考虑到低估值和长期上涨趋势,可在回调时分批建仓,关注 10.50-10.80 元支撑位,同时设置止损位以控制风险。 193 | - **风险控制**:建议关注后续成交量是否持续放大,若成交量萎缩且股价跌破 10.47 元(布林带下轨),需警惕进一步下行风险。 194 | 195 | **总结**:浦发银行目前具有一定的投资价值,但短期超买风险需警惕。投资者应结合自身风险承受能力和投资周期,合理制定操作策略。 196 | ``` 197 | 198 | ## 数据解读 199 | 200 | ### 系统题词 201 | 202 | 无需 203 | 204 | ### 用户输入 205 | 206 | ``` 207 | 浦发银行最近波动大吗? 208 | ``` 209 | 210 | ### 模型回复 211 | 212 | ```markdown 213 | 为了分析浦发银行的近期波动情况,我将推断其股票代码并调用相关工具获取数据。浦发银行的股票代码为 **SH600000**(上海证券交易所上市)。我将使用 `brief` 函数来获取基本的交易数据,以便初步判断其波动情况。如果需要更详细的分析,我会进一步调用其他工具。 214 | 215 | # 浦发银行 (SH600000) 近期波动分析 216 | 217 | **数据日期:2025 年 5 月 6 日** 218 | 219 | ## 1. 价格波动分析 220 | 221 | - **当日波动**:浦发银行当日价格波动幅度为 **5.57%**,价格在 **10.80 元至 11.19 元** 之间波动,收盘价为 **11.17 元**,较前一日上涨 **1.92%**。 222 | - **短期波动(5 日)**:过去 5 日的价格波动幅度为 **5.87%**,价格区间为 **10.57 元至 11.19 元**,均价为 **10.858 元**,累计涨幅为 **5.48%**。短期内波动相对温和,但有一定的上涨动能。 223 | - **中期波动(20 日)**:过去 20 日的价格波动幅度较大,为 **18.29%**,价格区间为 **9.46 元至 11.19 元**,均价为 **10.51 元**,累计涨幅为 **6.58%**。中期波动显示出较强的价格回升趋势。 224 | - **长期波动(240 日)**:过去 240 日的价格波动幅度非常显著,为 **44.20%**,价格区间为 **7.76 元至 11.19 元**,均价为 **9.468 元**,累计涨幅高达 **43.21%**。长期来看,浦发银行股价呈现出较强的上涨趋势。 225 | 226 | ## 2. 成交量与换手率分析 227 | 228 | - **当日成交量**:当日成交量为 **97.26 万手**,显著高于 5 日均量(**67.21 万手**)和 20 日均量(**69.60 万手**),显示出市场参与度在当日有所提升。 229 | - **换手率**:当日换手率为 **0.33%**,高于 5 日均换手率(**0.23%**)和 20 日均换手率(**0.24%**)。虽然整体换手率不高,但短期内市场活跃度有所增加。 230 | 231 | ## 3. 估值指标分析 232 | 233 | - **市盈率 (PE)**:当前为 **4.90**,处于较低水平,表明股票估值相对较低,可能存在低估空间。 234 | - **市净率 (PB)**:当前为 **0.33**,远低于 1,显示市场对公司净资产的估值较低,可能是价值投资的潜在标的。 235 | - **净资产收益率 (ROE)**:当前为 **2.65%**,表明公司盈利能力一般,需关注其未来的盈利改善情况。 236 | 237 | ## 4. 综合解读与投资建议 238 | 239 | 从价格波动来看,浦发银行近期(尤其是过去 5 日和 20 日)股价呈现出一定的上涨趋势,但波动幅度在短期内相对可控(5 日振幅 5.87%),并未表现出剧烈波动。中长期来看(240 日振幅 44.20%),股价经历了较大的上涨,显示出较强的趋势性行情。 240 | 241 | 成交量和换手率的提升表明市场对浦发银行的关注度有所增加,尤其是当日成交量的显著放大,可能预示着短期内仍有上涨动能。然而,换手率整体偏低,市场流动性一般,需警惕可能的成交量萎缩风险。 242 | 243 | 从估值角度看,浦发银行的市盈率和市净率均处于较低水平,显示出一定的价值投资吸引力,但较低的净资产收益率提示其盈利能力有待提升,投资者需关注其后续财报数据及行业环境变化。 244 | 245 | **总结**:浦发银行近期波动幅度在短期内并不算大,但中长期趋势显示出较强的上涨动能。结合低估值特性,短期内可适当关注,但需警惕成交量变化及盈利能力的潜在风险。建议投资者密切关注行业政策(如高股息政策、跨境支付概念等)对股价的进一步催化作用。 246 | 247 | 如果您需要更详细的财务数据或技术分析指标,可以进一步调用“medium”或“full”工具进行深入分析。 248 | ``` 249 | 250 | # QA 常见问题 251 | 252 | ## 使用股票 MCP 服务是否需要付费 253 | 254 | 该服务目前限时免费体验,未来请关注项目主页 https://github.com/elsejj/mcp-cn-a-stock 的更新。 255 | 256 | ## 如何私有部署 257 | 258 | 私有部署需替换本项目使用的数据源为你自有的数据, 参见[相关的讨论](https://github.com/elsejj/mcp-cn-a-stock/issues/1) 259 | 260 | ## 缺少某些标的数据 261 | 262 | 目前主要覆盖沪深主板 A 股, 其他的暂无计划. 263 | 264 | ## 调用频率, 限流等 265 | 266 | 正常使用不会有限制, 因为模型调取工具分析需要的时间远大于 MCP 返回时间, 批量轮询数据则可能会被限流. 267 | 268 | ## 准确性, 可用性等 269 | 270 | 会尽可能保障准确可用, 但不对因此导致的任何结果负任何责任, 同时由于大模型幻觉, 知识面, 理解力的因素, 请使用者仔细甄别它的回复结果是否可靠. 271 | 272 | ## 联系方式 273 | 274 | 请在 https://github.com/elsejj/mcp-cn-a-stock/issues 发起问题或向 cs@kanhq.com 发送邮件,谢谢。 275 | -------------------------------------------------------------------------------- /qtf_mcp/research.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from io import StringIO 3 | from typing import Dict, TextIO 4 | 5 | import talib 6 | from numpy import ndarray 7 | from qtf.indicators import KDJ, MACD 8 | 9 | from .datafeed import load_data_msd 10 | from .symbols import symbol_with_name 11 | 12 | 13 | async def load_raw_data( 14 | symbol: str, end_date=None, who: str = "" 15 | ) -> Dict[str, ndarray]: 16 | if end_date is None: 17 | end_date = datetime.datetime.now() + datetime.timedelta(days=1) 18 | if type(end_date) == str: 19 | end_date = datetime.datetime.strptime(end_date, "%Y-%m-%d") 20 | 21 | start_date = end_date - datetime.timedelta(days=365 * 2) 22 | 23 | return await load_data_msd( 24 | symbol, start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d"), 0, who 25 | ) 26 | 27 | 28 | def is_stock(symbol: str) -> bool: 29 | if symbol.startswith("SH6") or symbol.startswith("SZ00") or symbol.startswith("SZ30"): 30 | return True 31 | return False 32 | 33 | 34 | def build_stock_data(symbol: str, raw_data: Dict[str, ndarray]) -> str: 35 | md = StringIO() 36 | build_basic_data(md, symbol, raw_data) 37 | build_trading_data(md, symbol, raw_data) 38 | build_technical_data(md, symbol, raw_data) 39 | build_financial_data(md, symbol, raw_data) 40 | 41 | return md.getvalue() 42 | 43 | 44 | def filter_sector(sectors: list[str]) -> list[str]: 45 | keywords = ["MSCI", "标普", "同花顺", "融资融券", "沪股通"] 46 | # return sectors not including keywords 47 | return [s for s in sectors if not any(k in s for k in keywords)] 48 | 49 | 50 | def est_fin_ratio(last_fin_date: datetime.datetime) -> float: 51 | if last_fin_date.month == 12: 52 | return 1 53 | elif last_fin_date.month == 9: 54 | return 0.75 55 | elif last_fin_date.month == 6: 56 | return 0.5 57 | elif last_fin_date.month == 3: 58 | return 0.25 59 | else: 60 | return 0 61 | 62 | 63 | def yearly_fin_index(dates: ndarray) -> int: 64 | """ 65 | Returns the index of the last December in the dates array. 66 | If no December is found, returns -1. 67 | """ 68 | for i in range(len(dates) - 1, -1, -1): 69 | date = datetime.datetime.fromtimestamp(dates[i] / 1e9) 70 | if date.month == 12: 71 | return i 72 | return -1 73 | 74 | 75 | def build_basic_data(fp: TextIO, symbol: str, data: Dict[str, ndarray]) -> None: 76 | print("# 基本数据", file=fp) 77 | print("", file=fp) 78 | symbol, name = list(symbol_with_name([symbol]))[0] 79 | sector = " ".join(filter_sector(data["SECTOR"])) # type: ignore 80 | data_date = datetime.datetime.fromtimestamp(data["DATE"][-1] / 1e9) 81 | if is_stock(symbol): 82 | fin, _ = data["_DS_FINANCE"] 83 | last_year_index = yearly_fin_index(fin["DATE"]) 84 | else: 85 | last_year_index = -1 86 | 87 | print(f"- 股票代码: {symbol}", file=fp) 88 | print(f"- 股票名称: {name}", file=fp) 89 | print(f"- 数据日期: {data_date.strftime('%Y-%m-%d')}", file=fp) 90 | print(f"- 行业概念: {sector}", file=fp) 91 | if is_stock(symbol): 92 | total_shares = data["TCAP"][-1] # Convert to shares 93 | total_amount = total_shares * data["CLOSE2"][-1] 94 | net_profit = data["NP"][last_year_index] * 10000 95 | pe_static = total_amount / net_profit if net_profit != 0 else float("inf") 96 | print( 97 | f"- 市盈率(静): {pe_static:.2f}", 98 | file=fp, 99 | ) 100 | print( 101 | f"- 市净率: {data['CLOSE2'][-1] / data['NAVPS'][-1]:.2f}", 102 | file=fp, 103 | ) 104 | print(f"- 净资产收益率: {data['ROE'][-1]:.2f}", file=fp) 105 | print("", file=fp) 106 | 107 | 108 | def today_volume_est_ratio(data: Dict[str, ndarray], now: int = 0) -> float: 109 | data_dt = datetime.datetime.fromtimestamp(data["DATE"][-1] / 1e9) 110 | now_dt = ( 111 | datetime.datetime.now() if now == 0 else datetime.datetime.fromtimestamp(now / 1e9) 112 | ) 113 | 114 | data_date = data_dt.strftime("%Y-%m-%d") 115 | now_date = now_dt.strftime("%Y-%m-%d") 116 | if data_date != now_date: 117 | return 1 118 | now_time = now_dt.strftime("%H:%M:%S") 119 | if now_time >= "09:30:00" and now_time < "11:30:00": 120 | start_dt = now_dt.replace(hour=9, minute=30, second=0) 121 | minutes = (now_dt - start_dt).seconds / 60 122 | return 240 / (minutes + 1) 123 | elif now_time >= "11:30:00" and now_time < "13:00:00": 124 | return 2 125 | elif now_time >= "13:00:00" and now_time < "15:00:00": 126 | start_dt = now_dt.replace(hour=13, minute=0, second=0) 127 | minutes = (now_dt - start_dt).seconds / 60 128 | return 240 / (120 + minutes + 1) 129 | else: 130 | return 1 131 | 132 | 133 | FUND_FLOW_FIELDS = [ 134 | ("主力", "A"), 135 | ("超大单", "XL"), 136 | ("大单", "L"), 137 | ("中单", "M"), 138 | ("小单", "S"), 139 | ] 140 | 141 | 142 | def build_fund_flow(field: tuple[str, str], data: Dict[str, ndarray]) -> str: 143 | field_amount = field[1] + "_A" 144 | field_ratio = field[1] + "_R" 145 | value_amount = data.get(field_amount, None) 146 | value_ratio = data.get(field_ratio, None) 147 | if value_amount is None or value_ratio is None: 148 | return "" 149 | 150 | kind = field[0] 151 | amount = value_amount[-1] / 1e8 # Convert to billions 152 | ratio = abs(value_ratio[-1]) 153 | in_out = "流入" if amount > 0 else "流出" 154 | amount = abs(amount) # Use absolute value for display 155 | return f"- {kind} {in_out}: {amount:.2f}亿, 占比: {ratio:.2%}" 156 | 157 | 158 | def build_trading_data(fp: TextIO, symbol: str, data: Dict[str, ndarray]) -> None: 159 | today_vol_est_ratio = today_volume_est_ratio(data) 160 | close = data["CLOSE"] 161 | volume = data["VOLUME"] 162 | volume[-1] = volume[-1] * today_vol_est_ratio # Adjust today's volume 163 | amount = data["AMOUNT"] / 1e8 164 | amount[-1] = amount[-1] * today_vol_est_ratio # Adjust today's amount 165 | high = data["HIGH"] 166 | low = data["LOW"] 167 | 168 | periods = list(filter(lambda n: n <= len(close), [5, 20, 60, 120, 240])) 169 | 170 | print("# 交易数据", file=fp) 171 | print("", file=fp) 172 | 173 | print("## 价格", file=fp) 174 | print(f"- 当日: {close[-1]:.3f} 最高: {high[-1]:.3f} 最低: {low[-1]:.3f}", file=fp) 175 | for p in periods: 176 | print( 177 | f"- {p}日均价: {close[-p:].mean():.3f} 最高: {high[-p:].max():.3f} 最低: {low[-p:].min():.3f}", 178 | file=fp, 179 | ) 180 | print("", file=fp) 181 | 182 | print("## 振幅", file=fp) 183 | print(f"- 当日: {(high[-1] / low[-1] - 1):.2%}", file=fp) 184 | for p in periods: 185 | print(f"- {p}日振幅: {(high[-p:].max() / low[-p:].min() - 1):.2%}", file=fp) 186 | print("", file=fp) 187 | 188 | print("## 涨跌幅", file=fp) 189 | print(f"- 当日: {(close[-1] / close[-2] - 1):.2%}", file=fp) 190 | for p in periods: 191 | print(f"- {p}日累计: {(close[-1] / close[-p] - 1) * 100:.2f}%", file=fp) 192 | print("", file=fp) 193 | 194 | print("## 成交量(万手)", file=fp) 195 | print(f"- 当日: {volume[-1] / 1e6:.2f}", file=fp) 196 | for p in periods: 197 | print(f"- {p}日均量(万手): {volume[-p:].mean() / 1e6:.2f}", file=fp) 198 | print("", file=fp) 199 | 200 | print("## 成交额(亿)", file=fp) 201 | print(f"- 当日: {amount[-1]:.2f}", file=fp) 202 | for p in periods: 203 | print(f"- {p}日均额(亿): {amount[-p:].mean():.2f}", file=fp) 204 | print("", file=fp) 205 | 206 | print("## 资金流向", file=fp) 207 | for field in FUND_FLOW_FIELDS: 208 | value = build_fund_flow(field, data) 209 | if value: 210 | print(value, file=fp) 211 | print("", file=fp) 212 | 213 | if is_stock(symbol): 214 | tcap = data["TCAP"] 215 | print("## 换手率", file=fp) 216 | print(f"- 当日: {volume[-1] / tcap[-1]:.2%}", file=fp) 217 | for p in periods: 218 | print(f"- {p}日均换手: {volume[-p:].mean() / tcap[-1]:.2%}", file=fp) 219 | print(f"- {p}日总换手: {volume[-p:].sum() / tcap[-1]:.2%}", file=fp) 220 | print("", file=fp) 221 | 222 | 223 | def build_technical_data(fp: TextIO, symbol: str, data: Dict[str, ndarray]) -> None: 224 | close = data["CLOSE"] 225 | high = data["HIGH"] 226 | low = data["LOW"] 227 | volume = data["VOLUME"] 228 | 229 | if len(close) < 30: 230 | return 231 | 232 | print("# 技术指标(最近30日)", file=fp) 233 | print("", file=fp) 234 | 235 | kdj_k, kdj_d, kdj_j = KDJ(close, high, low, 9, 3) 236 | 237 | macd_diff, macd_dea = MACD(close, 12, 26, 9) 238 | 239 | rsi_6 = talib.RSI(close, timeperiod=6) 240 | rsi_12 = talib.RSI(close, timeperiod=12) 241 | rsi_24 = talib.RSI(close, timeperiod=24) 242 | 243 | bb_upper, bb_middle, bb_lower = talib.BBANDS(close, matype=talib.MA_Type.T3) # type: ignore 244 | 245 | obv = talib.OBV(close, volume) 246 | 247 | atr = talib.ATR(high, low, close, timeperiod=14) 248 | 249 | date = [ 250 | datetime.datetime.fromtimestamp(d / 1e9).strftime("%Y-%m-%d") for d in data["DATE"] 251 | ] 252 | columns = [ 253 | ("日期", date), 254 | ("MA(5)", talib.MA(close, timeperiod=5)), 255 | ("MA(10)", talib.MA(close, timeperiod=10)), 256 | ("MA(30)", talib.MA(close, timeperiod=30)), 257 | ("MA(60)", talib.MA(close, timeperiod=60)), 258 | ("MA(120)", talib.MA(close, timeperiod=120)), 259 | ("KDJ.K", kdj_k), 260 | ("KDJ.D", kdj_d), 261 | ("KDJ.J", kdj_j), 262 | ("MACD DIF", macd_diff), 263 | ("MACD DEA", macd_dea), 264 | ("RSI(6)", rsi_6), 265 | ("RSI(12)", rsi_12), 266 | ("RSI(24)", rsi_24), 267 | ("BBands Upper", bb_upper), 268 | ("BBands Middle", bb_middle), 269 | ("BBands Lower", bb_lower), 270 | ("OBV", obv), 271 | ("ATR", atr), 272 | ] 273 | print("| " + " | ".join([c[0] for c in columns]) + " |", file=fp) 274 | print("| --- " * len(columns) + "|", file=fp) 275 | for i in range(-1, max(-len(date), -31), -1): 276 | print( 277 | "| " + date[i] + "|" + " | ".join([f"{c[1][i]:.2f}" for c in columns[1:]]) + " |", 278 | file=fp, 279 | ) 280 | print("", file=fp) 281 | 282 | 283 | def build_financial_data(fp: TextIO, symbol: str, data: Dict[str, ndarray]) -> None: 284 | if not is_stock(symbol): 285 | return 286 | fin, _ = data["_DS_FINANCE"] 287 | dates = fin["DATE"] 288 | max_years = 5 289 | print("# 财务数据", file=fp) 290 | print("", file=fp) 291 | years = 0 292 | fields = [ 293 | # name, id, div, show 294 | ("主营收入(亿元)", "MR", 10000, True), 295 | ("净利润(亿元)", "NP", 10000, True), 296 | ("每股收益", "EPS", 1, True), 297 | ("每股净资产", "NAVPS", 1, True), 298 | ("净资产收益率", "ROE", 1, True), 299 | ] 300 | 301 | rows = [] 302 | for i in range(len(dates) - 1, 0, -1): 303 | date = datetime.datetime.fromtimestamp(dates[i] / 1e9) 304 | if date.month != 12 or years >= max_years: 305 | continue 306 | row = [date.strftime("%Y年度")] 307 | for _, field, div, show in fields: 308 | if show: 309 | row.append(fin[field][i] / div) 310 | rows.append(row) 311 | years += 1 312 | 313 | print("| 指标 | " + " ".join([f"{r[0]} |" for r in rows]), file=fp) 314 | print("| --- " * (len(rows) + 1) + "|", file=fp) 315 | for i in range(1, len(rows[0])): 316 | print( 317 | f"| {fields[i - 1][0]} | " + " ".join([f"{r[i]:.2f} |" for r in rows]), 318 | file=fp, 319 | ) 320 | 321 | print("", file=fp) 322 | --------------------------------------------------------------------------------