Skill: tkk0124/retail-investor-radar (version 1.1.0)

--- name: Retail_Investor_Radar description: "A股个股体检报告，输入股票代码或名称，自动拉取公开数据，AI用大白话生成全面体检报告，包含基本面、估值、资金流向、近期新闻和风险提示。触发词：A股分析、个股体检、股票体检、帮我看看XXX这只股票、分析一下股票代码XXXXXX、这只股票怎么样、值不值得买、查一下XXXXXX" version: "1.1.0" author: "" license: MIT requires: env: - DEEPSEEK_API_KEY # 必填：platform.deepseek.com 获取，极低费用 python: ">=3.9" packages: - requests - akshare - pandas - pypinyin - pyyaml --- # Retail Investor Radar · A股体检报告 输入股票代码、名称或拼音缩写，自动拉取 A 股公开数据，AI 用大白话生成个股体检报告。 ## 快速开始 ### 1. 安装依赖 ```bash pip install akshare pandas requests pypinyin pyyaml ``` ### 2. 配置 API Key 在 OpenClaw 环境变量中设置： ```bash DEEPSEEK_API_KEY=你的DeepSeek密钥 # platform.deepseek.com 获取 ``` ### 3. 运行 支持四种输入方式： ```bash python run.py 600519 # 股票代码 python run.py 贵州茅台 # 中文全名 python run.py 茅台 # 中文模糊 python run.py gzmt # 拼音缩写 ``` 输出示例： ``` ================================================== 🏥 个股体检报告 | 贵州茅台（600519.SH） 📅 生成时间：2026-03-25 10:54 ================================================== 📊 【今日行情】 今天茅台股价1407块，微跌0.05%，几乎就是横着走了一天。最近一周和 一个月都在小幅阴跌，价格已经跌破了5日和20日均线，短期走势有点疲软。 💪 【财务健康度】 净利率超过55%，意思是每卖100块钱，能净赚55块多。负债率很低，只有 12%出头，家里没欠啥钱。净资产收益率（ROE）依然强悍。整体评价：非常健康。 🎯 【估值水位】 以茅台通常的估值看，PE长期比行业平均水平高一大截，属于"贵族价"。 当前是付高价买顶级货的逻辑。 🌊 【主力资金动向】 今天主力资金净流出1.52亿，大单在卖，但超大单有小幅买入，大资金内 部有分歧，整体偏谨慎。 📰 【近期热点】 公司副总经理被留置调查，属于突发利空，短期可能影响市场情绪。 ⚡ 【总体体检结论】 公司本身还是那个印钞机，但短期面临技术面走弱、资金流出和突发利空 消息的三重压力。 ⚠️ 【风险提示】 本报告由AI基于公开数据自动生成，仅供参考学习，不构成任何投资建议。 股市有风险，投资需谨慎，请结合自身情况独立决策。 ``` ## 费用说明 | 依赖 | 费用 | |------|------| | AKShare（行情/财务/资金/新闻数据） | **完全免费** | | DeepSeek（AI 报告生成） | 约 ¥0.02 / 次 | 每天查 10 只股票，月费用不超过 **¥6**。 ## 数据源说明 优先使用东方财富接口（数据最全）。若您的网络无法访问东方财富（境外服务器常见）， 行情和K线会自动切换至新浪财经备用接口，并在日志中给出明确提示。 如需强制使用代理，在 `config.yaml` 中设置： ```yaml proxy: "http://127.0.0.1:7890" ``` ## 文件结构 ``` Retail_Investor_Radar/ ├── SKILL.md # 本文件 ├── config.yaml # 配置（风格、模块开关、代理等） ├── run.py # 一键运行入口 ├── fetch.py # AKShare 数据抓取模块 └── report.py # DeepSeek AI 报告生成模块 ``` ## 支持的市场 - **沪市**：600xxx、601xxx、603xxx、688xxx - **深市**：000xxx、001xxx、002xxx、003xxx - **创业板**：300xxx、301xxx - **北交所**：4xxxxx、8xxxxx

path: config.yaml
content: # A股体检报告配置文件

# 代理设置（境外 VPS 必填，境内留空即可）
# 格式示例：http://127.0.0.1:7890 或 socks5://127.0.0.1:1080
proxy: ""

# AI 报告风格
# casual     - 大白话，像朋友聊天（推荐）
# analysis   - 专业分析风格
style: casual

# 报告包含的模块（按需开关）
modules:
  quote: true          # 今日行情
  valuation: true      # 估值分析（PE/PB）
  financials: true     # 财务健康度
  fund_flow: true      # 主力资金流向
  news: true           # 近期热点新闻
  risk: true           # 风险提示

# 新闻条数
news_count: 5

# 输出格式
output:
  console: true        # 打印到终端
  save_file: true      # 同时保存到文件
  file_dir: ./reports  # 报告保存目录

# DeepSeek 模型
deepseek_model: deepseek-chat
max_tokens: 2000
temperature: 0.7


path: fetch.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
fetch.py
基于 AKShare 抓取 A 股公开数据

数据源策略：
  优先使用东方财富接口（数据最全）
  若连接被拒（常见于境外 VPS），自动切换新浪财经接口并给出明确提示
  如两者均失败，对应模块返回空数据，不影响其他模块运行
"""

import logging
import os
from datetime import datetime, timedelta
from pathlib import Path

import yaml

try:
    from pypinyin import lazy_pinyin, Style
    _PINYIN_AVAILABLE = True
except ImportError:
    _PINYIN_AVAILABLE = False

try:
    import akshare as ak
    import pandas as pd
except ImportError:
    raise ImportError("请先安装依赖：pip install akshare pandas")


# ============================================================
# 代理配置（从 config.yaml 读取）
# ============================================================
def _setup_proxy():
    config_path = Path(__file__).parent / "config.yaml"
    if not config_path.exists():
        return
    try:
        with open(config_path, "r", encoding="utf-8") as f:
            cfg = yaml.safe_load(f) or {}
        proxy = cfg.get("proxy", "")
        if proxy:
            os.environ["HTTP_PROXY"] = proxy
            os.environ["HTTPS_PROXY"] = proxy
            logging.info(f"🌐 已启用代理：{proxy}")
    except Exception:
        pass

_setup_proxy()


# ============================================================
# 工具函数
# ============================================================
def _is_connection_error(e: Exception) -> bool:
    msg = str(e).lower()
    return any(k in msg for k in [
        "connection aborted", "remote end closed", "connection refused",
        "connectionerror", "timeout", "remotedisconnected"
    ])


def _warn_em_blocked(module_name: str):
    logging.warning(
        f"⚠️  [{module_name}] 东方财富接口连接失败。\n"
        f"   可能原因：您的 IP 被东方财富限制访问（境外服务器常见）。\n"
        f"   解决方案：在 config.yaml 中配置 proxy 代理，或在境内机器运行。\n"
        f"   当前已自动切换至新浪财经备用接口，部分字段可能缺失。"
    )


# ============================================================
# 股票代码标准化
# ============================================================
def normalize_code(raw: str) -> dict:
    raw = raw.strip()
    code = raw.upper().replace(".SH", "").replace(".SZ", "").replace(".BJ", "")
    code = code.replace("SH", "").replace("SZ", "").replace("BJ", "")
    code = "".join(filter(str.isdigit, code))

    if len(code) != 6:
        return search_by_name(raw)

    market = _detect_market(code)
    full = f"{code}.{market.upper()}"
    name = _get_stock_name(code)
    return {"code": code, "market": market, "full": full, "name": name}


def _detect_market(code: str) -> str:
    if code.startswith(("6", "500", "510", "511", "512", "513", "515", "518")):
        return "sh"
    elif code.startswith(("4", "8", "9")):
        return "bj"
    else:
        return "sz"


def _get_stock_name(code: str) -> str:
    try:
        info = ak.stock_individual_info_em(symbol=code)
        row = info[info["item"] == "股票简称"]
        if not row.empty:
            return row["value"].values[0]
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("股票名称查询")
        try:
            df = ak.stock_info_a_code_name()
            row = df[df["code"] == code]
            if not row.empty:
                return row["name"].values[0]
        except Exception:
            pass
    return code


def _name_to_pinyin_abbr(name: str) -> str:
    """贵州茅台 → gzmt"""
    if not _PINYIN_AVAILABLE:
        return ""
    return "".join(lazy_pinyin(name, style=Style.FIRST_LETTER))


def _name_to_full_pinyin(name: str) -> str:
    """贵州茅台 → guizhoumaotai"""
    if not _PINYIN_AVAILABLE:
        return ""
    return "".join(lazy_pinyin(name))


def search_by_name(name: str) -> dict:
    """
    支持三种输入：
      - 中文全名或部分名：茅台、贵州茅台
      - 拼音缩写：gzmt
      - 全拼：guizhoumaotai
    """
    try:
        df = ak.stock_info_a_code_name()
        query = name.strip().lower()

        # 1. 优先中文匹配
        matched = df[df["name"].str.contains(query, na=False)]

        # 2. 中文未命中，尝试拼音匹配
        if matched.empty and _PINYIN_AVAILABLE:
            abbr_col = df["name"].apply(_name_to_pinyin_abbr)
            full_col = df["name"].apply(_name_to_full_pinyin)
            matched = df[abbr_col.str.lower() == query]          # 缩写精确匹配
            if matched.empty:
                matched = df[full_col.str.lower() == query]      # 全拼精确匹配
            if matched.empty:
                matched = df[abbr_col.str.lower().str.startswith(query)]  # 缩写前缀
            if matched.empty:
                matched = df[full_col.str.lower().str.contains(query)]    # 全拼模糊

        if matched.empty:
            raise ValueError(
                f"找不到股票：{name}\n"
                f"支持：中文名（茅台）、拼音缩写（gzmt）、全拼（guizhoumaotai）、6位代码（600519）"
            )

        row = matched.iloc[0]
        code = row["code"]
        market = _detect_market(code)
        logging.info(f"  → 搜索「{name}」匹配到：{row['name']}（{code}）")
        return {"code": code, "market": market,
                "full": f"{code}.{market.upper()}", "name": row["name"]}
    except ValueError:
        raise
    except Exception as e:
        raise ValueError(f"搜索股票失败：{e}")


# ============================================================
# 行情：东财 → 新浪K线取最新一日（避免全量快照超时）
# ============================================================
def fetch_quote(code: str) -> dict:
    # 优先东方财富
    try:
        df = ak.stock_zh_a_spot_em()
        row = df[df["代码"] == code]
        if not row.empty:
            return _parse_quote_em(row.iloc[0])
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("实时行情")
        else:
            logging.warning(f"东财行情异常：{e}")

    # 备用：从新浪K线取最近2日数据推算当日行情
    # 比全量快照(stock_zh_a_spot)快得多，且境外可访问
    try:
        logging.info("  → [行情] 尝试新浪K线备用接口...")
        market = _detect_market(code)
        end = datetime.now().strftime("%Y%m%d")
        start = (datetime.now() - timedelta(days=5)).strftime("%Y%m%d")
        df = ak.stock_zh_a_daily(
            symbol=f"{market}{code}", adjust="",   # 不复权，取真实价格
            start_date=start, end_date=end
        )
        if df is not None and not df.empty:
            latest = df.iloc[-1]
            prev = df.iloc[-2] if len(df) >= 2 else latest
            price = float(latest.get("close", 0) or 0)
            pre_close = float(prev.get("close", 0) or 0)
            change_amt = round(price - pre_close, 2)
            change_pct = round(change_amt / pre_close * 100, 2) if pre_close else 0
            return {
                "price": price,
                "change_pct": change_pct,
                "change_amt": change_amt,
                "volume": float(latest.get("volume", 0) or 0),
                "amount": float(latest.get("amount", 0) or 0),
                "turnover": 0,        # 新浪K线无换手率
                "high": float(latest.get("high", 0) or 0),
                "low": float(latest.get("low", 0) or 0),
                "open": float(latest.get("open", 0) or 0),
                "pre_close": pre_close,
                "market_cap": 0,      # 新浪K线无总市值
                "pe_dynamic": 0,      # 新浪K线无PE
                "pb": 0,
            }
    except Exception as e:
        logging.warning(f"新浪K线备用行情也失败：{e}")

    return {}


def _parse_quote_em(r) -> dict:
    def f(k): return float(r.get(k, 0) or 0)
    return {
        "price": f("最新价"), "change_pct": f("涨跌幅"),
        "change_amt": f("涨跌额"), "volume": f("成交量"),
        "amount": f("成交额"), "turnover": f("换手率"),
        "high": f("最高"), "low": f("最低"),
        "open": f("今开"), "pre_close": f("昨收"),
        "market_cap": f("总市值"),
        "pe_dynamic": f("市盈率-动态"), "pb": f("市净率"),
    }


# ============================================================
# 历史K线：东财 → 新浪
# ============================================================
def fetch_history(code: str, days: int = 60) -> dict:
    end = datetime.now().strftime("%Y%m%d")
    start = (datetime.now() - timedelta(days=days + 20)).strftime("%Y%m%d")
    df = pd.DataFrame()

    try:
        df = ak.stock_zh_a_hist(
            symbol=code, period="daily",
            start_date=start, end_date=end, adjust="qfq"
        )
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("历史K线")
        else:
            logging.warning(f"东财K线异常：{e}")

    if df is None or df.empty:
        try:
            logging.info("  → [K线] 尝试新浪财经备用接口...")
            market = _detect_market(code)
            df = ak.stock_zh_a_daily(symbol=f"{market}{code}", adjust="qfq")
            if df is not None and not df.empty:
                df = df.rename(columns={"close": "收盘", "date": "日期"})
                start_date = f"{start[:4]}-{start[4:6]}-{start[6:]}"
                df = df[df["日期"].astype(str) >= start_date]
        except Exception as e:
            logging.warning(f"新浪K线也失败：{e}")

    if df is None or df.empty:
        return {}

    df = df.tail(days)
    close_col = "收盘" if "收盘" in df.columns else "close"
    close = df[close_col].tolist()
    if not close:
        return {}

    week_chg = round((close[-1] / close[-5] - 1) * 100, 2) if len(close) >= 5 else None
    month_chg = round((close[-1] / close[-20] - 1) * 100, 2) if len(close) >= 20 else None
    ma5 = round(sum(close[-5:]) / 5, 2) if len(close) >= 5 else None
    ma20 = round(sum(close[-20:]) / 20, 2) if len(close) >= 20 else None
    ma60 = round(sum(close[-60:]) / 60, 2) if len(close) >= 60 else None

    return {
        "week_change": week_chg, "month_change": month_chg,
        "ma5": ma5, "ma20": ma20, "ma60": ma60,
        "current_price": close[-1],
        "recent_high": max(close[-20:]) if len(close) >= 20 else None,
        "recent_low": min(close[-20:]) if len(close) >= 20 else None,
    }


# ============================================================
# 财务指标（修正列名，带单位后缀）
# ============================================================
def fetch_financials(code: str) -> dict:
    try:
        df = ak.stock_financial_analysis_indicator(symbol=code, start_year="2023")
        if df is None or df.empty:
            return {}
        latest = df.iloc[0]

        def s(k):
            v = latest.get(k)
            try:
                return float(v) if v not in [None, "--", ""] and str(v) != "nan" else None
            except Exception:
                return None

        return {
            "report_date": str(latest.get("日期", "")),
            "roe": s("净资产收益率(%)"),           # 修正
            "gross_margin": s("销售毛利率(%)"),    # 修正
            "net_margin": s("销售净利率(%)"),      # 修正（原字段名是"净利率"，实为"销售净利率(%)"）
            "debt_ratio": s("资产负债率(%)"),      # 修正
            "revenue_growth": s("主营业务收入增长率(%)"),  # 修正
            "profit_growth": s("净利润增长率(%)"),         # 修正
            "eps": s("摊薄每股收益(元)"),          # 修正（原"每股收益"实为"摊薄每股收益(元)"）
        }
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("财务指标")
            logging.warning("   财务指标模块暂无备用数据源，此项将在报告中显示为空。")
        else:
            logging.warning(f"获取财务指标失败：{e}")
        return {}


# ============================================================
# 主力资金流向（修正参数名：stock=，非 symbol=）
# ============================================================
def fetch_fund_flow(code: str, market: str) -> dict:
    try:
        df = ak.stock_individual_fund_flow(stock=code, market=market)  # 修正
        if df is None or df.empty:
            return {}
        latest = df.iloc[-1]

        def s(k):
            v = latest.get(k)
            try:
                return float(v) if v not in [None, "--", ""] and str(v) != "nan" else None
            except Exception:
                return None

        return {
            "date": str(latest.get("日期", "")),
            "main_net": s("主力净流入-净额"),
            "main_net_pct": s("主力净流入-净占比"),
            "super_net": s("超大单净流入-净额"),
            "big_net": s("大单净流入-净额"),
            "small_net": s("小单净流入-净额"),
        }
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("主力资金流向")
            logging.warning("   资金流向模块暂无备用数据源，此项将在报告中显示为空。")
        else:
            logging.warning(f"获取资金流向失败：{e}")
        return {}


# ============================================================
# 近期新闻（东财唯一，无备用接口）
# ============================================================
def fetch_news(code: str, count: int = 5) -> list:
    try:
        df = ak.stock_news_em(symbol=code)
        if df is None or df.empty:
            return []
        news = []
        for _, row in df.head(count).iterrows():
            news.append({
                "title": str(row.get("新闻标题", "")),
                "date": str(row.get("发布时间", "")),
                "source": str(row.get("文章来源", "")),
            })
        return news
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("个股新闻")
            logging.warning("   新闻模块暂无备用数据源，此项将在报告中显示为空。")
        else:
            logging.warning(f"获取新闻失败：{e}")
        return []


# ============================================================
# 股票基本信息
# ============================================================
def fetch_basic_info(code: str) -> dict:
    try:
        df = ak.stock_individual_info_em(symbol=code)
        info = dict(zip(df["item"], df["value"]))
        return {
            "name": info.get("股票简称", ""),
            "industry": info.get("行业", ""),
            "list_date": info.get("上市时间", ""),
            "total_shares": info.get("总股本", ""),
            "float_shares": info.get("流通股本", ""),
            "total_assets": info.get("总资产", ""),
        }
    except Exception as e:
        if _is_connection_error(e):
            _warn_em_blocked("基本信息")
        else:
            logging.warning(f"获取基本信息失败：{e}")
        return {}


# ============================================================
# 主入口
# ============================================================
def fetch_all(raw_input: str, news_count: int = 5) -> dict:
    stock = normalize_code(raw_input)
    code = stock["code"]
    market = stock["market"]
    logging.info(f"📡 抓取数据：{stock['name']}（{stock['full']}）")

    data = {"stock": stock}

    logging.info("  → 基本信息...")
    data["basic"] = fetch_basic_info(code)
    if not data["basic"].get("name"):
        data["basic"]["name"] = stock["name"]

    logging.info("  → 今日行情...")
    data["quote"] = fetch_quote(code)

    logging.info("  → 历史K线...")
    data["history"] = fetch_history(code)

    logging.info("  → 财务指标...")
    data["financials"] = fetch_financials(code)

    logging.info("  → 主力资金...")
    data["fund_flow"] = fetch_fund_flow(code, market)

    logging.info("  → 近期新闻...")
    data["news"] = fetch_news(code, news_count)

    logging.info("✅ 数据抓取完成")
    return data


path: report.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
report.py
调用 DeepSeek API，基于抓取的数据生成大白话体检报告
"""

import json
import logging
import os
import requests
from datetime import datetime


DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"

STYLE_PROMPTS = {
    "casual": """你是一个接地气的股票分析博主，专门帮普通散户看懂股票。
风格要求：
- 用大白话，把数据翻译成人话，不要堆砌专业术语
- 带一点幽默感，但不要哗众取宠
- 直接说结论，不绕弯子
- 对风险要直白提示，不要美化
""",
    "analysis": """你是专业的股票分析师，风格严谨理性。
风格要求：
- 客观陈述数据，给出逻辑清晰的分析
- 结合行业背景解读指标
- 明确列出多空两方观点
"""
}


def _format_money(amount: float, unit: str = "元") -> str:
    """格式化金额：亿/万"""
    if amount is None:
        return "N/A"
    if abs(amount) >= 1e8:
        return f"{amount/1e8:.2f}亿{unit}"
    elif abs(amount) >= 1e4:
        return f"{amount/1e4:.2f}万{unit}"
    else:
        return f"{amount:.2f}{unit}"


def _pct(val, suffix="%") -> str:
    if val is None:
        return "N/A"
    sign = "▲" if val > 0 else ("▼" if val < 0 else "")
    return f"{sign}{abs(val):.2f}{suffix}"


def build_data_summary(data: dict) -> str:
    """把抓取的数据整理成结构化文本，供 LLM 分析"""
    stock = data.get("stock", {})
    basic = data.get("basic", {})
    quote = data.get("quote", {})
    history = data.get("history", {})
    fin = data.get("financials", {})
    ff = data.get("fund_flow", {})
    news = data.get("news", [])

    lines = [
        f"【股票基本信息】",
        f"名称：{basic.get('name') or stock.get('name')}",
        f"代码：{stock.get('full')}",
        f"行业：{basic.get('industry', 'N/A')}",
        f"上市时间：{basic.get('list_date', 'N/A')}",
        f"总市值：{_format_money(quote.get('market_cap'))}",
        "",
        f"【今日行情】",
        f"当前价：{quote.get('price', 'N/A')} 元",
        f"涨跌幅：{_pct(quote.get('change_pct'))}",
        f"成交额：{_format_money(quote.get('amount'))}",
        f"换手率：{quote.get('turnover', 'N/A')}%",
        f"今日最高：{quote.get('high', 'N/A')}，最低：{quote.get('low', 'N/A')}",
        "",
        f"【技术面】",
        f"近1周涨跌：{_pct(history.get('week_change'))}",
        f"近1月涨跌：{_pct(history.get('month_change'))}",
        f"MA5（5日均线）：{history.get('ma5', 'N/A')}",
        f"MA20（20日均线）：{history.get('ma20', 'N/A')}",
        f"MA60（60日均线）：{history.get('ma60', 'N/A')}",
        f"近20日最高：{history.get('recent_high', 'N/A')}，最低：{history.get('recent_low', 'N/A')}",
        "",
        f"【估值】",
        f"动态PE：{quote.get('pe_dynamic', 'N/A')} 倍",
        f"市净率PB：{quote.get('pb', 'N/A')} 倍",
        "",
    ]

    if fin:
        lines += [
            f"【财务健康（{fin.get('report_date', '最新')}）】",
            f"ROE净资产收益率：{fin.get('roe', 'N/A')}%",
            f"销售毛利率：{fin.get('gross_margin', 'N/A')}%",
            f"净利率：{fin.get('net_margin', 'N/A')}%",
            f"资产负债率：{fin.get('debt_ratio', 'N/A')}%",
            f"营收增长率：{fin.get('revenue_growth', 'N/A')}%",
            f"净利润增长率：{fin.get('profit_growth', 'N/A')}%",
            f"每股收益EPS：{fin.get('eps', 'N/A')} 元",
            "",
        ]

    if ff:
        lines += [
            f"【主力资金（{ff.get('date', '今日')}）】",
            f"主力净流入：{_format_money(ff.get('main_net'))}（占比{ff.get('main_net_pct', 'N/A')}%）",
            f"超大单净流入：{_format_money(ff.get('super_net'))}",
            f"大单净流入：{_format_money(ff.get('big_net'))}",
            f"小单净流入：{_format_money(ff.get('small_net'))}",
            "",
        ]

    if news:
        lines.append("【近期新闻】")
        for i, n in enumerate(news, 1):
            lines.append(f"{i}. [{n.get('date','')}] {n.get('title','')}（来源：{n.get('source','')}）")
        lines.append("")

    return "\n".join(lines)


def build_prompt(data: dict, style: str = "casual") -> str:
    style_text = STYLE_PROMPTS.get(style, STYLE_PROMPTS["casual"])
    data_text = build_data_summary(data)
    stock_name = data.get("basic", {}).get("name") or data.get("stock", {}).get("name")

    return f"""{style_text}

请根据以下数据，为 {stock_name} 写一份个股体检报告。

{data_text}

报告格式要求（严格按照以下结构输出，不要加任何额外说明）：

📊 【今日行情】
（2~3句话说明今天的价格和涨跌情况，结合近期走势给出判断）

💪 【财务健康度】
（用大白话解读毛利率、ROE、负债率等核心财务数据，给出"健康/一般/需注意"的整体评价）

🎯 【估值水位】
（解读PE和PB，判断当前估值是高还是低，对比行业给出参考）

🌊 【主力资金动向】
（解读主力净流入数据，判断主力是在买入、卖出还是观望）

📰 【近期热点】
（总结近期新闻，分析对股价可能的影响）

⚡ 【总体体检结论】
（综合各维度，用1~2句话给出整体判断，态度要明确，不要模棱两可）

⚠️ 【风险提示】
以下内容由AI根据公开数据自动生成，仅供参考学习，不构成任何投资建议。股市有风险，投资需谨慎，请结合自身情况独立决策。"""


def call_deepseek(prompt: str, model: str, max_tokens: int, temperature: float) -> str:
    [REDACTED_SECRET]"DEEPSEEK_API_KEY", "")
    if not api_key:
        raise ValueError("DEEPSEEK_API_KEY 未设置，请在环境变量中配置")

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json"
    }
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": max_tokens,
        "temperature": temperature,
    }
    resp = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60)
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"].strip()


def generate_report(data: dict, config: dict) -> str:
    """主函数：生成完整报告"""
    stock = data.get("stock", {})
    basic = data.get("basic", {})
    name = basic.get("name") or stock.get("name")
    full_code = stock.get("full", "")
    now = datetime.now().strftime("%Y-%m-%d %H:%M")

    # 构建报告头
    header = f"{'='*50}\n🏥 个股体检报告 | {name}（{full_code}）\n📅 生成时间：{now}\n{'='*50}\n"

    logging.info("🤖 调用 DeepSeek 生成大白话报告...")
    prompt = build_prompt(data, style=config.get("style", "casual"))

    report_body = call_deepseek(
        prompt=prompt,
        model=config.get("deepseek_model", "deepseek-chat"),
        max_tokens=config.get("max_tokens", 2000),
        temperature=config.get("temperature", 0.7),
    )

    full_report = header + "\n" + report_body + "\n"
    logging.info(f"✅ 报告生成完成，共 {len(full_report)} 字")
    return full_report


path: run.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
run.py
A股体检报告 - 一键运行入口

用法：
    python run.py 600519        # 输入股票代码
    python run.py 贵州茅台       # 输入股票名称
"""

import logging
import os
import sys
from datetime import datetime
from pathlib import Path

import yaml

BASE_DIR = Path(__file__).parent


def setup_logging():
    log_dir = BASE_DIR / "logs"
    log_dir.mkdir(exist_ok=True)
    log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d')}.log"
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        handlers=[
            logging.FileHandler(str(log_file), encoding="utf-8"),
            logging.StreamHandler()
        ]
    )


def load_config() -> dict:
    config_path = BASE_DIR / "config.yaml"
    if config_path.exists():
        with open(config_path, "r", encoding="utf-8") as f:
            return yaml.safe_load(f) or {}
    return {}


def check_env():
    if not os.environ.get("DEEPSEEK_API_KEY"):
        print("❌ 错误：未设置 DEEPSEEK_API_KEY 环境变量")
        print("请在 OpenClaw 中设置环境变量，或执行：")
        print("  export DEEPSEEK_API_KEY=你的密钥")
        sys.exit(1)


def main():
    setup_logging()
    check_env()

    # 获取股票输入
    if len(sys.argv) < 2:
        print("用法：python run.py <股票代码或名称>")
        print("示例：python run.py 600519")
        print("      python run.py 贵州茅台")
        sys.exit(1)

    raw_input = " ".join(sys.argv[1:]).strip()
    config = load_config()

    # 导入模块
    from fetch import fetch_all
    from report import generate_report

    # 抓取数据
    logging.info(f"🚀 开始为「{raw_input}」生成体检报告...")
    try:
        news_count = config.get("modules", {}) and config.get("news_count", 5)
        data = fetch_all(raw_input, news_count=news_count)
    except ValueError as e:
        print(f"❌ {e}")
        sys.exit(1)
    except Exception as e:
        logging.error(f"数据抓取失败: {e}")
        print(f"❌ 数据抓取失败：{e}")
        sys.exit(1)

    # 生成报告
    try:
        report = generate_report(data, config)
    except Exception as e:
        logging.error(f"报告生成失败: {e}")
        print(f"❌ 报告生成失败：{e}")
        sys.exit(1)

    # 输出到控制台
    if config.get("output", {}).get("console", True):
        print("\n" + report)

    # 保存到文件
    output_cfg = config.get("output", {})
    if output_cfg.get("save_file", True):
        stock_name = data.get("basic", {}).get("name") or data.get("stock", {}).get("name", "stock")
        code = data.get("stock", {}).get("code", "")
        file_dir = BASE_DIR / output_cfg.get("file_dir", "reports")
        file_dir.mkdir(exist_ok=True)

        filename = f"{datetime.now().strftime('%Y%m%d_%H%M')}_{code}_{stock_name}.txt"
        filepath = file_dir / filename
        with open(filepath, "w", encoding="utf-8") as f:
            f.write(report)
        logging.info(f"💾 报告已保存：{filepath}")
        print(f"💾 报告已保存：{filepath}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)