496 lines
16 KiB
Python
496 lines
16 KiB
Python
import os
|
||
from langchain_classic.agents import AgentExecutor, create_tool_calling_agent
|
||
from langchain_core.prompts import ChatPromptTemplate
|
||
from langchain_core.output_parsers import StrOutputParser
|
||
from langchain_openai import ChatOpenAI
|
||
from dotenv import load_dotenv
|
||
from adata import stock
|
||
from langchain_core.tools import tool
|
||
from telegram import Update
|
||
from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler, MessageHandler, filters
|
||
from langchain_community.embeddings import HuggingFaceEmbeddings
|
||
from langchain_community.vectorstores import Chroma
|
||
|
||
load_dotenv()
|
||
|
||
# VectorDB 存在 ./rules_data 目录中
|
||
# ======================
|
||
# Embedding模型
|
||
# ======================
|
||
|
||
embeddings = HuggingFaceEmbeddings(
|
||
model_name="BAAI/bge-small-zh-v1.5",
|
||
model_kwargs={
|
||
"device":"cpu"
|
||
}
|
||
)
|
||
|
||
# ======================
|
||
# 加载向量库
|
||
# ======================
|
||
|
||
vector_store = Chroma(
|
||
persist_directory="./rag_db",
|
||
collection_name="investment_knowledge",
|
||
embedding_function=embeddings
|
||
)
|
||
|
||
# 公司事实 Retriever
|
||
company_retriever = vector_store.as_retriever(
|
||
search_kwargs={
|
||
"k":5,
|
||
"filter":{
|
||
"category":"company_case"
|
||
}
|
||
}
|
||
)
|
||
|
||
# 行业 Retriever
|
||
industry_retriever = vector_store.as_retriever(
|
||
search_kwargs={
|
||
"k":3,
|
||
"filter":{
|
||
"category":"industry"
|
||
}
|
||
}
|
||
)
|
||
|
||
# 规则 Retriever
|
||
rule_retriever = vector_store.as_retriever(
|
||
search_kwargs={
|
||
"k":5,
|
||
"filter":{
|
||
"category":"rule"
|
||
}
|
||
}
|
||
)
|
||
|
||
def research_retrieve(question):
|
||
|
||
result = {}
|
||
# 公司事实
|
||
company_docs = company_retriever.invoke(question)
|
||
result["company"] = "\n\n".join(
|
||
[
|
||
doc.page_content
|
||
for doc in company_docs
|
||
]
|
||
)
|
||
# 行业知识
|
||
industry_docs = industry_retriever.invoke(question)
|
||
result["industry"] = "\n\n".join(
|
||
[
|
||
doc.page_content
|
||
for doc in industry_docs
|
||
]
|
||
)
|
||
# 分析规则
|
||
rule_docs = rule_retriever.invoke(question)
|
||
result["rules"] = "\n\n".join(
|
||
[
|
||
doc.page_content
|
||
for doc in rule_docs
|
||
]
|
||
)
|
||
return result
|
||
|
||
@tool
|
||
def get_fina_info(stock_code: str) -> str:
|
||
"""获取指定股票代码的财务数据。当需要获取财务数据的时使用此工具"""
|
||
|
||
# 字典定义:英文指标名 -> 中文财务指标名
|
||
column_mapping = {
|
||
# 基础与披露信息
|
||
'stock_code': '股票代码',
|
||
'short_name': '股票简称',
|
||
'report_date': '报告期',
|
||
'report_type': '报表类型',
|
||
'notice_date': '公告日期',
|
||
|
||
# 每股指标
|
||
'basic_eps': '基本每股收益',
|
||
'diluted_eps': '稀释每股收益',
|
||
'non_gaap_eps': '扣非每股收益',
|
||
'net_asset_ps': '每股净资产',
|
||
'cap_reserve_ps': '每股公积金',
|
||
'undist_profit_ps': '每股未分配利润',
|
||
'oper_cf_ps': '每股经营现金流',
|
||
|
||
# 规模与利润指标 (元)
|
||
'total_rev': '营业总收入',
|
||
'gross_profit': '毛利润',
|
||
'net_profit_attr_sh': '归母净利润',
|
||
'non_gaap_net_profit': '扣非归母净利润',
|
||
|
||
# 增长率指标 (同比 YoY / 环比 QoQ)
|
||
'total_rev_yoy_gr': '营业总收入同比增长率',
|
||
'net_profit_yoy_gr': '归母净利润同比增长率',
|
||
'non_gaap_net_profit_yoy_gr': '扣非归母净利润同比增长率',
|
||
'total_rev_qoq_gr': '营业总收入环比增长率',
|
||
'net_profit_qoq_gr': '归母净利润环比增长率',
|
||
'non_gaap_net_profit_qoq_gr': '扣非归母净利润环比增长率',
|
||
|
||
# 盈利能力与收益率
|
||
'roe_wtd': '加权净资产收益率(ROE)',
|
||
'roe_non_gaap_wtd': '扣非加权净资产收益率(ROE)',
|
||
'roa_wtd': '加权总资产收益率(ROA)',
|
||
'gross_margin': '销售毛利率',
|
||
'net_margin': '销售净利率',
|
||
|
||
# 现金流与收入质量比率
|
||
'adv_receipts_to_rev': '预收款及合同负债占营收比',
|
||
'net_cf_sales_to_rev': '销售收现率(销售现金流/营收)',
|
||
'oper_cf_to_rev': '经营现金净流量占营收比',
|
||
'eff_tax_rate': '实际有效税率',
|
||
|
||
# 偿债能力与财务杠杆
|
||
'curr_ratio': '流动比率',
|
||
'quick_ratio': '速动比率',
|
||
'cash_flow_ratio': '现金流量比率',
|
||
'asset_liab_ratio': '资产负债率',
|
||
'equity_multiplier': '权益乘数',
|
||
'equity_ratio': '产权比率',
|
||
|
||
# 运营效率与周转率/天数
|
||
'total_asset_turn_days': '总资产周转天数',
|
||
'inv_turn_days': '存货周转天数',
|
||
'acct_recv_turn_days': '应收账款周转天数',
|
||
'total_asset_turn_rate': '总资产周转率',
|
||
'inv_turn_rate': '存货周转率',
|
||
'acct_recv_turn_rate': '应收账款周转率'
|
||
}
|
||
|
||
df = stock.finance.get_core_index(stock_code=stock_code)
|
||
|
||
year_data = df[df.report_type == "年报"].iloc[:5, :] # 取5年的年报数据
|
||
year_data.rename(columns=column_mapping, inplace=True)
|
||
|
||
year_formatted_lines = []
|
||
for _, row in year_data.iterrows():
|
||
line_items = []
|
||
for col, val in zip(year_data.columns, row):
|
||
# 判断是否为数字类型
|
||
if isinstance(val, (int, float)):
|
||
# 如果列名包含"率",则格式化为保留两位小数并加上 %
|
||
if "率" in col:
|
||
# 如果原始数值本身就是小数(如 0.35 表示 35%),需要乘以 100
|
||
val_pct = val * 100 if abs(val) <= 1.0 else val
|
||
formatted_val = f"{val_pct:.2f}%"
|
||
else:
|
||
formatted_val = f"{val:,.2f}"
|
||
else:
|
||
# 非数字类型(如日期、文本)直接转为字符串
|
||
formatted_val = str(val)
|
||
|
||
line_items.append(f"{col}:{formatted_val}")
|
||
|
||
# 将这一期的所有字段用换行符拼接
|
||
year_formatted_lines.append("\n".join(line_items))
|
||
|
||
final_report = f"近五期年报主要财务数据如下:\n" + "\n\n".join(year_formatted_lines)
|
||
|
||
last_type = df['report_type'].to_list()[0]
|
||
|
||
if last_type != "年报":
|
||
|
||
last_data = df[df.report_type == last_type].iloc[:2,:]
|
||
|
||
# 假设你的原始数据存储在 df 中,使用 rename 修改列名
|
||
# inplace=True 表示直接在原 DataFrame 上修改
|
||
last_data.rename(columns=column_mapping, inplace=True)
|
||
|
||
formatted_lines = []
|
||
for _, row in last_data.iterrows():
|
||
line_items = []
|
||
for col, val in zip(last_data.columns, row):
|
||
# 判断是否为数字类型
|
||
if isinstance(val, (int, float)):
|
||
# 如果列名包含"率",则格式化为保留两位小数并加上 %
|
||
if "率" in col:
|
||
# 如果原始数值本身就是小数(如 0.35 表示 35%),需要乘以 100
|
||
val_pct = val * 100 if abs(val) <= 1.0 else val
|
||
formatted_val = f"{val_pct:.2f}%"
|
||
else:
|
||
formatted_val = f"{val:,.2f}"
|
||
else:
|
||
# 非数字类型(如日期、文本)直接转为字符串
|
||
formatted_val = str(val)
|
||
|
||
line_items.append(f"{col}:{formatted_val}")
|
||
|
||
# 将这一期的所有字段用换行符拼接
|
||
formatted_lines.append("\n".join(line_items))
|
||
|
||
# 3. 组装最终报告文本
|
||
final_report = final_report + "\n\n" + f"近两期{last_type}主要财务数据如下:\n" + "\n\n".join(formatted_lines)
|
||
|
||
return final_report
|
||
|
||
class MyOutputParser(StrOutputParser):
|
||
def parse(self, text) -> str:
|
||
return text.strip()
|
||
|
||
|
||
llm = ChatOpenAI(model="deepseek-v4-flash")
|
||
tools = [get_fina_info]
|
||
template = """
|
||
你是一位专业的股票基本面分析与投资研究专家。
|
||
你的任务是结合:
|
||
1. 实时获取的数据
|
||
2. 公司历史资料
|
||
3. 行业商业逻辑
|
||
4. 专业投资分析规则
|
||
对上市公司进行客观、严谨、深度的基本面诊断。
|
||
你的目标不是预测股价,而是分析公司的商业质量、竞争优势、成长逻辑以及潜在风险。
|
||
=============================
|
||
【信息来源优先级】
|
||
==============================
|
||
进行分析时,请遵循以下信息优先级:
|
||
第一优先级:
|
||
用户提供的数据、工具返回的数据、最新财务数据。
|
||
第二优先级:
|
||
公司事实资料(company_facts)。
|
||
第三优先级:
|
||
行业知识与商业逻辑(industry_knowledge)。
|
||
第四优先级:
|
||
通用分析规则(analysis_rules)。
|
||
如果不同来源之间存在冲突:
|
||
- 以最新、可验证的数据为准。
|
||
- 明确指出信息冲突。
|
||
- 不得自行编造缺失数据。
|
||
==============================
|
||
【决策与工具使用原则】
|
||
==============================
|
||
1. 按需调用工具:
|
||
根据用户问题类型,自主选择:
|
||
- 财务数据查询工具
|
||
|
||
不要为了调用工具而调用工具。
|
||
2. 数据驱动:
|
||
所有涉及以下内容:
|
||
- 收入
|
||
- 利润
|
||
- 毛利率
|
||
- 现金流
|
||
- 估值
|
||
- 市占率
|
||
- 产品规模
|
||
必须基于真实数据。
|
||
禁止:
|
||
- 编造财务数字
|
||
- 推测不存在的订单
|
||
- 将行业趋势当作公司事实
|
||
==============================
|
||
【知识库参考信息】
|
||
==============================
|
||
以下为公司事实资料:
|
||
{company_facts}
|
||
说明:
|
||
- 这些内容代表已经整理的公司历史信息。
|
||
- 可以用于理解公司的业务结构、产品、技术和历史事件。
|
||
- 不得擅自修改其中的数据。
|
||
------------------------------
|
||
以下为行业背景与商业逻辑:
|
||
{industry_knowledge}
|
||
说明:
|
||
- 用于判断行业空间、竞争格局、商业模式。
|
||
- 不代表当前公司的实际情况。
|
||
- 必须结合公司数据进行分析。
|
||
------------------------------
|
||
以下为基本面分析规则:
|
||
{analysis_rules}
|
||
说明:
|
||
- 这些规则用于辅助判断公司质量。
|
||
- 不应机械套用,需要结合行业特点分析。
|
||
==============================
|
||
【分析方法要求】
|
||
==============================
|
||
请按照以下逻辑进行分析:
|
||
一、数据与规则交叉验证
|
||
将公司的实际数据与分析规则进行匹配:
|
||
例如:
|
||
- 收入增长是否符合成长标准?
|
||
- 利润增速是否超过收入增速?
|
||
- 毛利率变化是否体现产品升级?
|
||
- 现金流是否支持利润真实性?
|
||
- 资产负债结构是否存在风险?
|
||
明确指出:
|
||
- 触发的积极信号
|
||
- 触发的风险信号
|
||
- 尚未验证的关键因素
|
||
------------------------------
|
||
二、商业模式分析
|
||
分析:
|
||
- 公司主要产品和收入来源
|
||
- 产品竞争壁垒
|
||
- 客户粘性
|
||
- 行业地位
|
||
- 上下游关系
|
||
重点回答:
|
||
“公司为什么能够赚钱?”
|
||
以及:
|
||
“这种赚钱能力是否可持续?”
|
||
------------------------------
|
||
三、成长逻辑分析
|
||
不要只关注收入增长。
|
||
需要分析:
|
||
- 新产品放量
|
||
- 市场空间
|
||
- 技术升级
|
||
- 国产替代
|
||
- 市占率变化
|
||
- 客户拓展
|
||
区分:
|
||
- 已经兑现的成长
|
||
- 预期中的成长
|
||
- 尚未验证的成长
|
||
------------------------------
|
||
四、风险评估
|
||
重点关注:
|
||
- 盈利质量下降
|
||
- 毛利率持续下滑
|
||
- 现金流恶化
|
||
- 资本开支压力
|
||
- 行业竞争加剧
|
||
- 技术路线变化
|
||
- 客户集中风险
|
||
- 安全生产风险
|
||
- 政策与国际环境风险
|
||
|
||
================================================================================
|
||
【 Telegram 消息专属输出排版规范 (CRITICAL FORMATTING RULES) 】
|
||
================================================================================
|
||
|
||
由于本诊断报告最终将在 Telegram 移动端客户端展示,为保证最佳的阅读体验与视觉质感,你必须严格遵循以下 Telegram 专属排版规则:
|
||
|
||
1. 禁用标准 Markdown 表格 (STRICTLY FORBIDDEN):
|
||
- 严禁使用 | 列分隔符 | 的 Markdown 表格语法!Telegram 移动端屏幕狭窄,多列表格会导致严重的强行折行与对齐错乱。
|
||
- 所有多维财务数据、年度对比数据,必须拆解为【卡片式结构】或【树状分支结构】。
|
||
|
||
2. 树状分支与数据对齐 (Tree Structure):
|
||
- 涉及多项财务指标(如营收、归母、扣非)的平行对比时,使用 ├ 和 └ 字符构建清晰的树状层级:
|
||
* 2025年
|
||
├ 营收:`25.85亿`(+9.93%) │ 归母:`3.20亿`(+18.00%)
|
||
└ 扣非:`2.54亿`(+31.49%)
|
||
- 单指标多年趋势,采用带箭头的平铺演进线表示:
|
||
* 毛利率走势:`43.42%` (21) ➔ `41.16%` (24) ➔ `39.62%` (25)
|
||
|
||
3. 视觉高亮与数据卡片 (Inline Code Highlighting):
|
||
- 所有具体的【财务数值】、【百分比】、【比率】以及【核心定性结论】,必须用单反引号包围(即 Inline Code 格式,例如 `22.97%`、`中性偏正面`)。
|
||
- 这会在 Telegram 界面中形成高亮灰色背景卡片,提升视觉重点的提炼效率。
|
||
|
||
4. 标题与符号锚点 (Visual Anchors):
|
||
- 每一个大章节标题前必须配有明确的 Emoji 视觉锚点(如 📊、💡、🔍、⚠️、🎯)。
|
||
- 结论与诊断部分,优先采用带色彩倾向的标识符:
|
||
* 正向/优质:✅ 或 🚀 或 `[良好]`
|
||
* 中性/提示:📌 或 💡
|
||
* 隐忧/警报:⚠️ 或 🔴 或 🟠
|
||
|
||
5. 控制行宽与分段 (Mobile Readability):
|
||
- 单段分析文字切忌过长,移动端每段尽量保持在 2-4 行以内。
|
||
- 章节之间使用全划线 `---` 或空行进行视觉隔离,避免形成“文本大山”。
|
||
|
||
6. Telegram 模版输出参照:
|
||
大纲结构示例:
|
||
# 📊 [股票名称(代码)] 基本面深度诊断
|
||
> **综合评级**:`[评级结果]`
|
||
|
||
---
|
||
### 💡 一、核心结论
|
||
* **优质面**
|
||
├ **[维度]**:[简短分析]
|
||
└ **[维度]**:[简短分析]
|
||
|
||
### 🔍 二、基本面深度诊断
|
||
#### 1️⃣ 成长性:[一句话小结]
|
||
* **[年份]**
|
||
├ 营收:`[数据]`([增速]) │ 归母:`[数据]`([增速])
|
||
└ 扣非:`[数据]`([增速])
|
||
📌 **关键判断**:[分析文字]
|
||
"""
|
||
chat_prompt = ChatPromptTemplate.from_messages([
|
||
("system", template),
|
||
("human", "{input}"),
|
||
("placeholder", "{agent_scratchpad}"),
|
||
])
|
||
|
||
agent = create_tool_calling_agent(llm, tools, chat_prompt)
|
||
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
|
||
|
||
def run_fundamental_agent(user_input: str):
|
||
|
||
# ======================
|
||
# 多知识源检索
|
||
# ======================
|
||
rag_context = research_retrieve(
|
||
user_input
|
||
)
|
||
|
||
# ======================
|
||
# 注入Agent
|
||
# ======================
|
||
|
||
result = agent_executor.invoke(
|
||
{
|
||
"input": user_input,
|
||
"company_facts":
|
||
rag_context["company"],
|
||
"industry_knowledge":
|
||
rag_context["industry"],
|
||
"analysis_rules":
|
||
rag_context["rules"]
|
||
}
|
||
)
|
||
|
||
|
||
return result["output"]
|
||
|
||
# ================= 新增:Telegram 机器人交互逻辑 =================
|
||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""处理 /start 命令"""
|
||
await update.message.reply_text("你好!我是你的股票分析助手,请直接发送类似“分析一下600699的基本面”的消息。")
|
||
|
||
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""处理普通文本消息,调用你的 Agent"""
|
||
user_input = update.message.text
|
||
# 提示用户正在处理,因为 Agent 调用工具可能需要几秒钟
|
||
await update.message.reply_text("🤔 正在分析,请稍候...")
|
||
|
||
try:
|
||
# 调用你原有的 Agent 逻辑
|
||
output = run_fundamental_agent(user_input)
|
||
except Exception as e:
|
||
output = f"分析出错:{str(e)}"
|
||
|
||
await update.message.reply_text(output)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 从环境变量读取 Token
|
||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||
if not token:
|
||
raise ValueError("未在 .env 文件中找到 TELEGRAM_BOT_TOKEN")
|
||
|
||
proxy_url = os.getenv(
|
||
"TELEGRAM_PROXY"
|
||
)
|
||
|
||
builder = (
|
||
ApplicationBuilder()
|
||
.token(token)
|
||
)
|
||
|
||
if proxy_url:
|
||
builder = (
|
||
builder
|
||
.proxy(proxy_url)
|
||
.get_updates_proxy(proxy_url)
|
||
)
|
||
|
||
application = builder.build()
|
||
|
||
application.add_handler(CommandHandler("start", start))
|
||
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
||
|
||
print("Telegram Bot 正在运行...")
|
||
application.run_polling() |