Skill: gangtisegts/gangtise-data (version 1.4.3)

--- name: gangtise-data description: 通过 Gangtise 金融 Open API 拉取结构化量化数据，包括日K行情、财务三大报表、主营构成与估值分位等。需在 scripts 目录配置授权（.authorization），证券参数须为完整代码（如 600519.SH）。当用户需要可落盘的表格化行情与基本面数据时使用。 version: 1.4.3 metadata: requires: env: - GTS_ACCESS_KEY - GTS_SECRET_KEY config: - path: scripts/.authorization required: false description: 用于配置ak/sk, 内容为{"accessKey": "<ak>", "secretAccessKey": "<sk>"}。与GTS_ACCESS_KEY和GTS_SECRET_KEY使用其一即可。如果同时存在，则以GTS_ACCESS_KEY和GTS_SECRET_KEY为准，建议环境变量配置不成功时使用文件配置。 envVars: - name: GTS_ACCESS_KEY required: true description: 用于和GTS_SECRET_KEY一起获取临时 authorization - name: GTS_SECRET_KEY required: true description: 用于和GTS_ACCESS_KEY一起获取临时 authorization --- # 搜索 ## 概览 本技能在**本机**调用 `scripts/*.py`，请求 **Gangtise Open API**（`open.gangtise.com`），得到**结构化、可保存为 CSV** 的结果（由 `scripts/utils.py` 中的 `format_response` 写入工作区 `workspace/gangtise/` 下对应子目录）。 当前脚本覆盖： - **估值分析**（多指标并发、历史分位）：`scripts/valuation.py` - **A 股日 K**（可选全市场、`--adjust` 不复权/前复权/后复权；前复权默认，复权因子来自独立接口）：`scripts/quote.py` - **主营构成**（按产品/行业/地区，可并发全维度）：`scripts/main_business.py` - **股东结构**（前十大股东/前十大流通股东）：`scripts/shareholder.py` - **财务报表**（利润表/资产负债表/现金流量表）：`scripts/financial.py` - **盈利预测**（券商一致预期多指标）：`scripts/earning_forecast.py` 使用场景： - 需要**精确数值、时间序列或宽表**，并保存为 **CSV** 做后续分析或制图。 - 已具备或可查到 **完整证券代码**（本技能**不做**简称/键盘精灵解析）。 与其他技能的区别： | 项目 | `gangtise-data`（本技能） | `gangtise-file` | `gangtise-kb` | |------|---------------------------|-----------------|---------------| | 数据形态 | **结构化数值表**（行情、财务、估值等），可落盘 CSV | **文件索引**：按类型/证券/日期等筛文档，返回 ID、元数据、摘要，可下载全文 | **语义检索**：返回与查询相关的**内容片段**，偏阅读与推理 | | 典型问题 | 「这只股票某段区间的收盘价、利润表科目是多少」 | 「最近有哪些研报/公告、ID 是什么」 | 「文档里怎么论述某观点、结论是什么」 | | 调用方式 | 本机脚本 + `scripts/.authorization` | 本机脚本 + 授权 | 本机脚本 + 授权 | （中文说明：本技能解决「**是多少、表格化**」；要先**列文件再下载**用 `gangtise-file`；要**读原文片段、问答**用 `gangtise-kb`。） **授权**：在 `scripts/` 下放置 `.authorization`，内容为 `long-term-token`，或 `accessKey` + `secretAccessKey`（将自动换 token）。详见 `scripts/utils.py`。 调用脚本时若使用 `-sd` / `-ed`，请注意**当前真实日期与年份**。 **报告期（财务 / 主营）**：命令行统一使用 **Q1 / Q2 / Q3 / Q4 / Q0**（分别对应一季报、中报、三季报、年报、最新一期）；脚本内再映射为 Open API 所需枚举（详见各 `references`）。 调用各脚本前，请阅读对应 **[references](./references/)** 中的参数与返回说明。 ## 使用说明 ### 1. 估值数据（估值分析） 基于 open **估值分析**接口，并发拉取 peTtm / psTtm / pbMrq / peg / pcfTtm / em，并组装为与后端类似的宽表列（含「在 N 年中所处分位」）。`-sd/-ed` 默认均为当天。 ```bash python3 scripts/valuation.py --securities 600519.SH,000858.SZ ``` ```bash python3 scripts/valuation.py --securities-file ./codes.csv -sd 2023-01-01 -ed 2026-12-31 ``` 详见 [估值数据调用指导](./references/valuation.md)。 ### 2. 行情数据（日 K） 基于 open **日 K** 接口；`-sd/-ed` 默认均为当天；**`--all-market`** 时不传证券列表（数据量可能极大，需配合日期与 `limit`）。 ```bash python3 scripts/quote.py --securities 00700.HK -sd 2026-04-23 -ed 2026-04-23 ``` ```bash python3 scripts/quote.py --securities-file ./codes.csv --limit 8000 ``` 详见 [行情数据调用指导](./references/quote.md)。 ### 3. 主营构成 基于 open **main-business** 接口；不传 `--breakdown` 时并发拉取 **product / industry / region** 三种拆分。`--period` 仅支持 **Q2**（中报）或 **Q4**（年报）。 ```bash python3 scripts/main_business.py --securities 000651.SZ ``` ```bash python3 scripts/main_business.py --securities 600519.SH --breakdown product --period Q4 ``` 详见 [主营构成调用指导](./references/main_business.md)。 ### 4. 股东结构（前十大 / 前十大流通） 基于 open **capital-structure/top-holders** 接口；通过 `--holder-type` 切换 `top10`（前十大股东）与 `top10Float`（前十大流通股东）。支持日期区间筛选（`-sd/-ed`）或按财报年度筛选（`--fiscal-year`），并可叠加报告期筛选（`--period`）。 ```bash python3 scripts/shareholder.py --holder-type top10 --securities 600519.SH ``` ```bash python3 scripts/shareholder.py --holder-type top10Float --securities 600519.SH -sd 2025-01-01 -ed 2025-12-31 --period q3 ``` ```bash python3 scripts/shareholder.py --holder-type top10 --securities-file ./codes.csv --fiscal-year 2024,2025 --period q1,q3 ``` 详见 [股东结构调用指导](./references/shareholder.md)。 ### 5. 财务数据（三大报表） 基于 open **financial-report** 系列接口，支持 `income`/`balance`/`cashflow`；`--period` 使用 `Q1~Q4,Q0`（默认 `Q0`），`--granularity` 支持 `accumulated|quarterly`（默认 `accumulated`，仅利润表/现金流量表生效）；默认 `fieldList=[]` 返回全部科目。 ```bash python3 scripts/financial.py --securities 600519.SH ``` ```bash python3 scripts/financial.py --table-type balance --securities 600519.SH --period Q4 ``` ```bash python3 scripts/financial.py --table-type income --granularity quarterly --securities 600519.SH --fiscal-year 2025 --period Q2 --field-list totalOpRev,totalOpCost,netProfit,basicEPS ``` ```bash python3 scripts/financial.py --securities-file ./codes.csv --field-list netProfit,basicEarningsPerShare ``` 详见 [财务数据调用指导](./references/financial.md)。 ### 6. 盈利预测（券商一致预期） 基于 open **earning_forecast** 接口，支持通过 `--securities`/`--securities-file` 并发查询多只证券在指定日期区间的券商一致预期；支持通过 `--consensus-list` 选择指标，不传则默认返回全部可选字段。`-sd/-ed` 默认均为当天。返回会展开为 `security_code | date | 预测年份 | ...` 的扁平表；若单日查询当日无数据，会仅向前回退一天重试一次。 ```bash python3 scripts/earning_forecast.py --securities 600519.SH,000858.SZ -sd 2026-03-20 -ed 2026-03-25 ``` ```bash python3 scripts/earning_forecast.py --securities-file ./codes.csv -sd 2026-03-20 -ed 2026-03-25 --consensus-list netIncome,eps,pe ``` 详见 [盈利预测调用指导](./references/earning_forecast.md)。

path: scripts/quote.py
content: import os
import re
import sys
from datetime import date, datetime, timedelta
from io import TextIOWrapper
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Optional, Tuple

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import (
    GTS_AUTHORIZATION,
    QUOTE_ADJUST_FACTOR_URL,
    QUOTE_MINUTE_URL,
    QUOTE_HK_URL,
    QUOTE_URL,
    check_version,
    format_response,
)

# 与 open 日 K 文档一致
_FIELD_LIST = [
    "securityCode",
    "tradeDate",
    "open",
    "high",
    "low",
    "close",
    "preClose",
    "change",
    "pctChange",
    "volume",
    "amount",
]

API_FIELD_TO_CN = {
    "securityCode": "证券代码",
    "tradeDate": "日期",
    "open": "开盘价",
    "high": "最高价",
    "low": "最低价",
    "close": "收盘价",
    "preClose": "昨收价",
    "change": "涨跌额",
    "pctChange": "涨跌幅",
    "volume": "成交量",
    "amount": "成交额",
}

MINUTE_FIELD_LIST = [
    "securityCode",
    "tradeTime",
    "open",
    "high",
    "low",
    "close",
    "change",
    "pctChange",
    "volume",
    "amount",
]

MINUTE_API_FIELD_TO_CN = {
    "securityCode": "证券代码",
    "tradeTime": "日期",
    "open": "开盘价",
    "high": "最高价",
    "low": "最低价",
    "close": "收盘价",
    "change": "涨跌额",
    "pctChange": "涨跌幅",
    "volume": "成交量",
    "amount": "成交额",
}

def _last_day_of_month(d: date) -> date:
    if d.month == 12:
        return date(d.year, 12, 31)
    return date(d.year, d.month + 1, 1) - timedelta(days=1)


def _month_date_chunks(start_date: str, end_date: str) -> List[Tuple[str, str]]:
    s = datetime.strptime(start_date[:10], "%Y-%m-%d").date()
    e = datetime.strptime(end_date[:10], "%Y-%m-%d").date()
    out: List[Tuple[str, str]] = []
    cur = s
    while cur <= e:
        month_start = cur.replace(day=1)
        month_end = _last_day_of_month(month_start)
        seg_s = cur
        seg_e = min(e, month_end)
        out.append((seg_s.isoformat(), seg_e.isoformat()))
        cur = seg_e + timedelta(days=1)
    return out


def normalize_adjust_mode(raw: Optional[str]) -> str:
    """返回 none | forward | backward；默认前复权 forward。"""
    if raw is None or not str(raw).strip():
        return "forward"
    s = str(raw).strip()
    sl = s.lower()
    if sl in {"none", "raw", "no", "noadj", "unadjusted"} or s == "不复权":
        return "none"
    if sl in {"forward", "qfq"} or s == "前复权":
        return "forward"
    if sl in {"backward", "hfq"} or s == "后复权":
        return "backward"
    return "forward"


def _adj_price_suffix(mode: str) -> str:
    return "(前复权)" if mode == "forward" else "(后复权)"


def _fetch_adjust_factor_body(
    headers: dict,
    security_list: List[str],
    start_date: str,
    end_date: str,
    limit: int,
) -> Tuple[pd.DataFrame, Optional[str]]:
    payload = {
        "securityList": security_list,
        "startDate": start_date[:10],
        "endDate": end_date[:10],
        "limit": min(int(limit), 10000),
    }
    try:
        r = requests.post(QUOTE_ADJUST_FACTOR_URL, headers=headers, json=payload, timeout=300)
        if r.status_code != 200:
            return pd.DataFrame(), r.text
        body = r.json()
    except Exception as ex:
        return pd.DataFrame(), str(ex)
    return _parse_kline_body(body), None


def fetch_adjust_factors_batched(
    headers: dict,
    security_list: List[str],
    start_date: str,
    end_date: str,
    limit_per_request: int,
) -> Tuple[pd.DataFrame, Optional[str]]:
    """按自然月拆请求，合并去重，降低单次超 10000 行风险。"""
    chunks = _month_date_chunks(start_date, end_date)
    parts: List[pd.DataFrame] = []
    errs: List[str] = []
    for sd, ed in chunks:
        df, err = _fetch_adjust_factor_body(headers, security_list, sd, ed, limit_per_request)
        if err:
            errs.append(f"{sd}~{ed}: {err}")
            continue
        if not df.empty:
            parts.append(df)
    if errs and not parts:
        return pd.DataFrame(), "；".join(errs)
    if not parts:
        return pd.DataFrame(), "复权因子接口无数据" if not errs else "；".join(errs)
    merged = pd.concat(parts, ignore_index=True)
    if "securityCode" in merged.columns and "tradeDate" in merged.columns:
        merged = merged.drop_duplicates(subset=["securityCode", "tradeDate"], keep="last")
    warn = "；".join(errs) if errs else None
    return merged, warn


def _apply_daily_adjust_with_factors(data: pd.DataFrame, factors: pd.DataFrame, mode: str) -> Tuple[pd.DataFrame, Optional[str]]:
    """
    使用独立 adjustFactor 接口返回的因子调整日 K。
    前复权：P'(t) = P(t) × F(t) / F(t_latest)，t_latest 为本结果中该证券最后交易日。
    后复权：P'(t) = P(t) × F(t) / F(t_earliest)，t_earliest 为本结果中该证券首个交易日。
    """
    if factors.empty:
        return data, "未获取到复权因子，无法计算复权行情"
    need = {"证券代码", "日期", "开盘价", "最高价", "最低价", "收盘价"}
    if not need.issubset(data.columns):
        return data, "行情数据缺少 OHLC 列，无法复权"

    fac = factors.rename(columns={"securityCode": "证券代码", "tradeDate": "日期", "adjustFactor": "_f"})
    fac["日期"] = pd.to_datetime(fac["日期"], errors="coerce").dt.strftime("%Y-%m-%d")
    fac["证券代码"] = fac["证券代码"].astype(str).str.upper()
    fac["_f"] = pd.to_numeric(fac["_f"], errors="coerce")

    d = data.copy()
    d["_dt"] = pd.to_datetime(d["日期"], errors="coerce")
    d["日期"] = d["_dt"].dt.strftime("%Y-%m-%d")
    d["证券代码"] = d["证券代码"].astype(str).str.upper()

    d = d.merge(fac[["证券代码", "日期", "_f"]], on=["证券代码", "日期"], how="left")
    d = d.sort_values(by=["证券代码", "_dt"])
    d["_f"] = d.groupby("证券代码", sort=False)["_f"].ffill().bfill()

    missing = d["_f"].isna() | (d["_f"] == 0)
    if missing.any():
        ub = d.loc[missing, "证券代码"].drop_duplicates()
        bad = ub.head(20).tolist()
        hint = ",".join(bad)
        if len(ub) > 20:
            hint += "…"
        return data, f"部分交易日缺少有效复权因子（证券示例：{hint}），无法完成复权"

    d = d.sort_values(by=["证券代码", "_dt"])
    if mode == "forward":
        d["_f_anchor"] = d.groupby("证券代码", sort=False)["_f"].transform("last")
    else:
        d["_f_anchor"] = d.groupby("证券代码", sort=False)["_f"].transform("first")

    mult = d["_f"] / d["_f_anchor"]
    mult = mult.mask(d["_f_anchor"].isna() | (d["_f_anchor"] == 0))

    suffix = _adj_price_suffix(mode)
    for col in ["开盘价", "最高价", "最低价", "收盘价"]:
        if col in d.columns:
            v = pd.to_numeric(d[col], errors="coerce")
            d[f"{col}{suffix}"] = (v * mult).round(2)

    close_adj = f"收盘价{suffix}"
    d = d.sort_values(by=["证券代码", "_dt"])
    d[f"昨收价{suffix}"] = d.groupby("证券代码", sort=False)[close_adj].shift(1)
    ca = pd.to_numeric(d[close_adj], errors="coerce")
    pa = pd.to_numeric(d[f"昨收价{suffix}"], errors="coerce")
    d[f"涨跌额{suffix}"] = (ca - pa).round(4)
    pct = (ca - pa) / pa * 100
    pct = pct.replace([float("inf"), float("-inf")], pd.NA)
    d[f"涨跌幅{suffix}"] = pct.round(4)

    drop_cols = ["开盘价", "最高价", "最低价", "收盘价", "昨收价", "涨跌额", "涨跌幅", "_dt", "_f", "_f_anchor"]
    d = d.drop(columns=[c for c in drop_cols if c in d.columns], errors="ignore")
    return d, None


def _load_security_codes_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _parse_kline_body(body: dict) -> pd.DataFrame:
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return pd.DataFrame()
    block = body.get("data") or {}
    field_list = block.get("fieldList") or []
    rows = block.get("list") or []
    if not field_list or not rows:
        return pd.DataFrame()
    records = []
    for row in rows:
        if not isinstance(row, (list, tuple)):
            continue
        n = min(len(field_list), len(row))
        if n == 0:
            continue
        records.append({field_list[i]: row[i] for i in range(n)})
    if not records:
        return pd.DataFrame()
    return pd.DataFrame(records)


def _fetch_kline_data(url: str, headers: dict, payload: dict) -> Tuple[pd.DataFrame, Optional[str]]:
    try:
        r = requests.post(url, headers=headers, json=payload, timeout=300)
        if r.status_code != 200:
            return pd.DataFrame(), r.text
        body = r.json()
    except Exception as e:
        return pd.DataFrame(), str(e)

    data = _parse_kline_body(body)
    if data.empty:
        return pd.DataFrame(), "未找到行情数据"
    return data, None


def quote_data(
    securities: Optional[List[str]] = None,
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    limit: int = 5000,
    all_market: bool = False,
    data_type: str = "daily",
    adjust_mode: Optional[str] = None,
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "quote",
        )

    headers = {"Authorization": GTS_AUTHORIZATION}
    data_type = (data_type or "daily").strip().lower()
    if data_type not in {"daily", "minute"}:
        return format_response(
            {"state": "error", "message": "type 仅支持 daily 或 minute", "data": [], "usage": usage},
            "quote",
        )
    if data_type == "minute":
        if adjust_mode is not None and normalize_adjust_mode(adjust_mode) != "none":
            return format_response(
                {
                    "state": "error",
                    "message": "分钟 K 不支持复权调整，请使用 type=daily（日 K）",
                    "data": [],
                    "usage": usage,
                },
                "quote",
            )
        adj_mode = "none"
    else:
        adj_mode = normalize_adjust_mode(adjust_mode)

    if not end_date:
        end_date = date.today().strftime("%Y-%m-%d")
    if not start_date:
        start_date = date.today().strftime("%Y-%m-%d")

    security_list: Optional[List[str]] = None
    if not all_market:
        codes: List[str] = []
        if securities:
            for s in securities:
                c = _normalize_security_code(s)
                if c:
                    codes.append(c)
        if not codes:
            return format_response(
                {
                    "state": "error",
                    "message": "未找到有效证券代码，请仅输入完整代码（如 600519.SH），或使用 --all-market",
                    "data": [],
                    "usage": usage,
                },
                "quote",
            )
        security_list = codes
    else:
        security_list = ["all"]

    data_parts: List[pd.DataFrame] = []
    request_errors: List[str] = []
    request_markets: List[str] = []
    capped_limit = min(limit, 10000)
    extra_notes: List[str] = []

    if data_type == "daily":
        payload: dict = {
            "startDate": start_date,
            "endDate": end_date,
            "limit": capped_limit,
            "fieldList": list(_FIELD_LIST),
        }
        if security_list is not None:
            payload["securityList"] = security_list

        # 股票代码包含 .HK 时，按文档走港股接口；其余代码走原有接口
        if all_market:
            data_a, err_a = _fetch_kline_data(QUOTE_URL, headers, payload)
            if err_a:
                request_errors.append(f"A股接口请求失败: {err_a}")
            elif not data_a.empty:
                data_parts.append(data_a)
                request_markets.append("A股")
        else:
            hk_codes = [c for c in (security_list or []) if c.upper().endswith(".HK")]
            other_codes = [c for c in (security_list or []) if not c.upper().endswith(".HK")]

            if other_codes:
                payload_a = dict(payload)
                payload_a["securityList"] = other_codes
                data_a, err_a = _fetch_kline_data(QUOTE_URL, headers, payload_a)
                if err_a:
                    request_errors.append(f"A股接口请求失败: {err_a}")
                elif not data_a.empty:
                    data_parts.append(data_a)
                    request_markets.append("A股")

            if hk_codes:
                payload_hk = dict(payload)
                payload_hk["securityList"] = hk_codes
                data_hk, err_hk = _fetch_kline_data(QUOTE_HK_URL, headers, payload_hk)
                if err_hk:
                    request_errors.append(f"港股接口请求失败: {err_hk}")
                elif not data_hk.empty:
                    data_parts.append(data_hk)
                    request_markets.append("港股")
    else:
        if all_market:
            return format_response(
                {
                    "state": "error",
                    "message": "minute 类型不支持 --all-market，请指定 --securities / --securities-file",
                    "data": [],
                    "usage": usage,
                },
                "quote",
            )
        # 分钟接口仅支持 securityCode 单值；这里对 securities 并发请求再聚合。
        minute_codes = list(dict.fromkeys(security_list or []))
        if not minute_codes:
            return format_response(
                {"state": "error", "message": "minute 类型未找到有效证券代码", "data": [], "usage": usage},
                "quote",
            )

        def _fetch_one_minute(code: str) -> Tuple[str, pd.DataFrame, Optional[str]]:
            payload_one = {
                "securityCode": code,
                "startTime": start_date,
                "endTime": end_date,
                "limit": capped_limit,
                "fieldList": list(MINUTE_FIELD_LIST),
            }
            data_one, err_one = _fetch_kline_data(QUOTE_MINUTE_URL, headers, payload_one)
            return code, data_one, err_one

        max_workers = min(8, max(1, len(minute_codes)))
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = [executor.submit(_fetch_one_minute, code) for code in minute_codes]
            for fut in as_completed(futures):
                code, data_one, err_one = fut.result()
                if err_one:
                    request_errors.append(f"{code} 请求失败: {err_one}")
                elif not data_one.empty:
                    data_parts.append(data_one)
                    request_markets.append(code)

    if not data_parts:
        error_message = "；".join(request_errors) if request_errors else "未找到行情数据"
        return format_response(
            {"state": "error", "message": error_message, "data": [], "usage": usage},
            "quote",
        )

    data = pd.concat(data_parts, ignore_index=True)

    field_map = API_FIELD_TO_CN if data_type == "daily" else MINUTE_API_FIELD_TO_CN
    rename_map = {k: v for k, v in field_map.items() if k in data.columns}
    data = data.rename(columns=rename_map)

    if "证券代码" in data.columns:
        data["证券简称"] = data["证券代码"].astype(str)
    else:
        data["证券简称"] = ""

    if "日期" in data.columns:
        if data_type == "minute":
            data["日期"] = pd.to_datetime(data["日期"], errors="coerce").dt.strftime("%Y-%m-%d %H:%M:%S")
        else:
            data["日期"] = pd.to_datetime(data["日期"], errors="coerce").dt.strftime("%Y-%m-%d")
    if start_date:
        sd = pd.to_datetime(start_date, errors="coerce")
        data = data[pd.to_datetime(data["日期"], errors="coerce") >= sd]
    if end_date:
        ed = pd.to_datetime(end_date, errors="coerce")
        data = data[pd.to_datetime(data["日期"], errors="coerce") <= ed]
    if "日期" in data.columns:
        data = data.dropna(subset=["日期"])

    if data.empty:
        return format_response(
            {"state": "error", "message": "日期范围内未找到行情数据", "data": [], "usage": usage},
            "quote",
        )

    if data_type == "daily" and adj_mode != "none":
        fac_codes = security_list if security_list is not None else ["all"]
        factors, fac_warn = fetch_adjust_factors_batched(
            headers, fac_codes, start_date, end_date, capped_limit
        )
        if fac_warn:
            extra_notes.append(f"复权因子接口：{fac_warn}")
        data, adj_err = _apply_daily_adjust_with_factors(data, factors, adj_mode)
        if adj_err:
            return format_response(
                {"state": "error", "message": adj_err, "data": [], "usage": usage},
                "quote",
            )

    preferred = [
        "证券简称",
        "证券代码",
        "日期",
        "开盘价",
        "最高价",
        "最低价",
        "收盘价",
        "昨收价",
        "涨跌额",
        "涨跌幅",
        "成交量",
        "成交额",
    ]
    if data_type == "daily" and adj_mode != "none":
        sfx = _adj_price_suffix(adj_mode)
        preferred = [
            "证券简称",
            "证券代码",
            "日期",
            f"开盘价{sfx}",
            f"最高价{sfx}",
            f"最低价{sfx}",
            f"收盘价{sfx}",
            f"昨收价{sfx}",
            f"涨跌额{sfx}",
            f"涨跌幅{sfx}",
            "成交量",
            "成交额",
        ]
    if data_type == "minute":
        preferred = [c for c in preferred if c not in {"昨收价"}]
    cols = [c for c in preferred if c in data.columns]
    cols += [c for c in data.columns if c not in cols]
    data = data[cols]

    if "证券代码" in data.columns and "日期" in data.columns:
        data = data.sort_values(
            by=["证券代码", "日期"],
            ascending=[True, False],
        ).reset_index(drop=True)
    elif "日期" in data.columns:
        data = data.sort_values(by=["日期"], ascending=[False]).reset_index(drop=True)

    col_map = {
        "证券简称": "security_abbr",
        "证券代码": "security_code",
        "日期": "date",
    }
    for col in list(data.columns):
        if col in col_map:
            data.rename(columns={col: col_map[col]}, inplace=True)
    front_columns = ["security_abbr", "security_code", "date"]
    columns = [c for c in front_columns if c in data.columns] + [
        c for c in data.columns if c not in front_columns
    ]
    data = data[columns]
    data = data.drop(columns=["security_abbr"])

    k_label = "日K" if data_type == "daily" else "分钟K"
    if all_market:
        title = f"全市场（或未指定证券）{k_label}行情数据"
    elif security_list and len(security_list) > 1:
        title = f"{security_list[0]}等{len(security_list)}只证券{k_label}行情数据"
    elif security_list and len(security_list) == 1:
        title = f"{security_list[0]}{k_label}行情数据"
    else:
        title = f"{k_label}行情数据"
    if request_markets:
        if data_type == "daily":
            title = f"{title}（{'+'.join(request_markets)}）"

    if data_type == "daily":
        adj_title = {"none": "不复权", "forward": "前复权", "backward": "后复权"}[adj_mode]
        title = f"{title}·{adj_title}"

    msg = f"已找到{title}"
    if extra_notes:
        msg += "\n" + "\n".join(extra_notes)

    parts = [
        {
            "data": data.to_dict(orient="records"),
            "module": "quote",
            "type": "data",
        }
    ]
    return format_response(
        {
            "state": "success",
            "message": msg,
            "data": parts,
            "usage": usage,
        },
        "quote",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询 A/港股历史 K 线（type=daily/minute）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    today_str = date.today().strftime("%Y-%m-%d")
    parser.add_argument("-sd", "--start-date", default=today_str, help="开始日期 yyyy-MM-dd")
    parser.add_argument("-ed", "--end-date", default=today_str, help="结束日期 yyyy-MM-dd")
    parser.add_argument(
        "--securities",
        default=None,
        help="证券代码逗号分隔（完整代码如 600519.SH）；与 --all-market 二选一",
    )
    parser.add_argument(
        "--securities-file",
        default=None,
        help="csv 须含 security_code 列",
    )
    parser.add_argument(
        "--all-market",
        action="store_true",
        help="不传 securityList，按接口约定拉全市场（数据量可能极大，请配合日期区间与 limit）",
    )
    parser.add_argument("--limit", type=int, default=5000, help="单次请求最大行数（上限 10000）")
    parser.add_argument(
        "--type",
        dest="data_type",
        choices=["daily", "minute"],
        default="daily",
        help="K线类型：daily（日K）或 minute（分钟K，仅A股且不支持all-market）",
    )
    parser.add_argument(
        "--adjust",
        default=None,
        help="复权方式（仅日 K）：forward/qfq/前复权（默认）、backward/hfq/后复权、none/raw/不复权",
    )
    args = parser.parse_args()

    if args.all_market and (args.securities or args.securities_file):
        parser.error("--all-market 时不要使用 --securities / --securities-file")
    if args.data_type == "minute" and args.all_market:
        parser.error("minute 类型不支持 --all-market，请指定 --securities / --securities-file")

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)

    if not args.all_market and not securities:
        parser.error("请指定 --securities / --securities-file，或使用 --all-market")

    out = quote_data(
        securities=securities,
        start_date=args.start_date,
        end_date=args.end_date,
        limit=args.limit,
        all_market=args.all_market,
        data_type=args.data_type,
        adjust_mode=args.adjust,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: scripts/financial.py
content: import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from io import TextIOWrapper
from typing import List, Optional, Set, Tuple

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import (
    BALANCE_FIELD_CN,
    CASH_FLOW_FIELD_CN,
    GTS_AUTHORIZATION,
    FINANCIAL_REPORT_BALANCE_URL,
    FINANCIAL_REPORT_CASH_FLOW_URL,
    FINANCIAL_REPORT_CASH_FLOW_QUARTERLY_URL,
    FINANCIAL_REPORT_INCOME_URL,
    FINANCIAL_REPORT_INCOME_QUARTERLY_URL,
    INCOME_FIELD_CN,
    check_version,
    format_response,
)

# 命令行使用 Q1~Q4、Q0；发往 open 接口时映射为官方 period 枚举
FINANCIAL_PERIOD_CLI_TO_API = {
    "Q1": "q1",
    "Q2": "interim",
    "Q3": "q3",
    "Q4": "annual",
    "Q0": "latest",
}

# 财务报表共有元数据列（不参与「数值全空」判定中的「数值列」）
META_FIELDS_EN: Set[str] = {
    "securityCode",
    "companyName",
    "endDate",
    "fiscalYear",
    "period",
    "reportType",
    "companyType",
    "currency",
    "unit",
}

FINANCIAL_GRANULARITY_ARG_ALIASES = {
    "accumulated": "accumulated",
    "累计": "accumulated",
    "quarterly": "quarterly",
    "单季度": "quarterly",
}


def _normalize_financial_granularity(s: str) -> Tuple[Optional[str], Optional[str]]:
    if not s or not str(s).strip():
        return "accumulated", None
    key = str(s).strip().lower()
    if key in FINANCIAL_GRANULARITY_ARG_ALIASES:
        return FINANCIAL_GRANULARITY_ARG_ALIASES[key], None
    raw = str(s).strip()
    if raw in FINANCIAL_GRANULARITY_ARG_ALIASES:
        return FINANCIAL_GRANULARITY_ARG_ALIASES[raw], None
    return None, f"granularity 无效: {s}，可选 accumulated/累计、quarterly/单季度"


def _resolve_financial_report_url(
    table_type: str,
    granularity: str,
) -> Tuple[Optional[str], Optional[str]]:
    if table_type == "balance":
        if granularity != "accumulated":
            return None, "granularity=quarterly 仅对利润表和现金流量表有效，资产负债表仅支持 accumulated"
        return FINANCIAL_REPORT_BALANCE_URL, None
    if table_type == "income":
        return (
            FINANCIAL_REPORT_INCOME_QUARTERLY_URL
            if granularity == "quarterly"
            else FINANCIAL_REPORT_INCOME_URL
        ), None
    if table_type == "cashflow":
        return (
            FINANCIAL_REPORT_CASH_FLOW_QUARTERLY_URL
            if granularity == "quarterly"
            else FINANCIAL_REPORT_CASH_FLOW_URL
        ), None
    return None, f"table-type 无效: {table_type}"

TABLE_TYPE_LABEL_CN = {
    "income": "利润表",
    "balance": "资产负债表",
    "cashflow": "现金流量表",
}

TABLE_TYPE_ARG_ALIASES = {
    "income": "income",
    "profit": "income",
    "pl": "income",
    "利润表": "income",
    "balance": "balance",
    "bs": "balance",
    "资产负债表": "balance",
    "cashflow": "cashflow",
    "cf": "cashflow",
    "现金流量表": "cashflow",
}


def _normalize_table_type(s: str) -> Tuple[Optional[str], Optional[str]]:
    if not s or not str(s).strip():
        return "income", None
    key = str(s).strip().lower()
    if key in TABLE_TYPE_ARG_ALIASES:
        return TABLE_TYPE_ARG_ALIASES[key], None
    raw = str(s).strip()
    if raw in TABLE_TYPE_ARG_ALIASES:
        return TABLE_TYPE_ARG_ALIASES[raw], None
    return None, f"table-type 无效: {s}，可选 income/利润表、balance/资产负债表、cashflow/现金流量表"

META_FIELD_CN = {k: INCOME_FIELD_CN[k] for k in META_FIELDS_EN}

TABLE_TYPE_TO_FIELD_MAP = {
    "income": INCOME_FIELD_CN,
    "balance": BALANCE_FIELD_CN,
    "cashflow": CASH_FLOW_FIELD_CN,
}

META_CN_LABELS: Set[str] = {INCOME_FIELD_CN[k] for k in META_FIELDS_EN}


def _load_security_codes_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _fmt_yyyymmdd(val) -> str:
    if val is None or (isinstance(val, float) and pd.isna(val)):
        return ""
    t = str(val).strip()
    if len(t) == 8 and t.isdigit():
        return f"{t[:4]}-{t[4:6]}-{t[6:8]}"
    return t


def _map_financial_periods_cli_to_api(
    periods: Optional[List[str]],
) -> Tuple[List[str], Optional[str]]:
    """将 Q1~Q4、Q0 转为 open 接口 period 列表；默认 Q0→latest。"""
    if not periods:
        return ["latest"], None
    out: List[str] = []
    for p in periods:
        k = p.strip().upper()
        if k not in FINANCIAL_PERIOD_CLI_TO_API:
            return (
                [],
                f"period 仅支持 Q1/Q2/Q3/Q4/Q0（一季报/中报/三季报/年报/最新），无效: {p}",
            )
        out.append(FINANCIAL_PERIOD_CLI_TO_API[k])
    return out, None


def _parse_income_statement_body(body: dict) -> pd.DataFrame:
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return pd.DataFrame()
    block = body.get("data") or {}
    field_list = block.get("fieldList") or []
    rows = block.get("list") or []
    if not field_list or not rows:
        return pd.DataFrame()
    records = []
    for row in rows:
        if not isinstance(row, (list, tuple)):
            continue
        n = min(len(field_list), len(row))
        if n == 0:
            continue
        records.append({field_list[i]: row[i] for i in range(n)})
    if not records:
        return pd.DataFrame()
    return pd.DataFrame(records)


def _drop_empty_value_rows_and_cols(df: pd.DataFrame) -> pd.DataFrame:
    """删除数值列全为空的行，以及数值列全为空的列（元数据列保留至仍有行）。"""
    if df.empty:
        return df
    d = df.copy()
    value_cols = [c for c in d.columns if c not in META_FIELDS_EN]
    if not value_cols:
        return d
    for c in value_cols:
        d[c] = pd.to_numeric(d[c], errors="coerce")
    row_keep = d[value_cols].notna().any(axis=1)
    d = d.loc[row_keep].reset_index(drop=True)
    if d.empty:
        return d
    empty_val_cols = [c for c in value_cols if c in d.columns and d[c].notna().sum() == 0]
    d = d.drop(columns=empty_val_cols, errors="ignore")
    return d


def _rename_columns_for_table(df: pd.DataFrame, table_type: str) -> pd.DataFrame:
    field_map = TABLE_TYPE_TO_FIELD_MAP.get(table_type)
    if field_map:
        return df.rename(columns=lambda c: field_map.get(c, c))
    return df.rename(columns=lambda c: META_FIELD_CN.get(c, c))


def _round_numeric_values(df: pd.DataFrame) -> pd.DataFrame:
    d = df.copy()
    for c in d.columns:
        if c in META_CN_LABELS or c in META_FIELDS_EN:
            continue
        d[c] = pd.to_numeric(d[c], errors="coerce").round(2)
    return d


def _fetch_financial_report(
    report_url: str,
    headers: dict,
    security_code: str,
    start_date: Optional[str],
    end_date: Optional[str],
    fiscal_year: Optional[List[str]],
    period: List[str],
    report_type: List[str],
    field_list: List[str],
) -> pd.DataFrame:
    payload: dict = {
        "securityCode": security_code,
        "period": period,
        "reportType": report_type,
        "fieldList": field_list,
    }
    if start_date:
        payload["startDate"] = start_date
    else:
        payload["startDate"] = None
    if end_date:
        payload["endDate"] = end_date
    else:
        payload["endDate"] = None
    if fiscal_year:
        payload["fiscalYear"] = fiscal_year
    else:
        payload["fiscalYear"] = None
    try:
        r = requests.post(report_url, headers=headers, json=payload, timeout=120)
        if r.status_code != 200:
            return pd.DataFrame()
        body = r.json()
        return _parse_income_statement_body(body)
    except Exception:
        return pd.DataFrame()


def financial_data(
    securities: List[str],
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    fiscal_year: Optional[List[str]] = None,
    period: Optional[List[str]] = None,
    report_type: Optional[List[str]] = None,
    field_list: Optional[List[str]] = None,
    table_type: str = "income",
    granularity: str = "accumulated",
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "financial",
        )

    table_norm, table_err = _normalize_table_type(table_type)
    if table_err:
        return format_response(
            {"state": "error", "message": table_err, "data": [], "usage": usage},
            "financial",
        )
    granularity_norm, granularity_err = _normalize_financial_granularity(granularity)
    if granularity_err:
        return format_response(
            {"state": "error", "message": granularity_err, "data": [], "usage": usage},
            "financial",
        )

    report_url, report_url_err = _resolve_financial_report_url(table_norm, granularity_norm)
    if report_url_err:
        return format_response(
            {"state": "error", "message": report_url_err, "data": [], "usage": usage},
            "financial",
        )

    table_label = TABLE_TYPE_LABEL_CN[table_norm]

    headers = {"Authorization": GTS_AUTHORIZATION}

    period_list, period_err = _map_financial_periods_cli_to_api(period)
    if period_err:
        return format_response(
            {"state": "error", "message": period_err, "data": [], "usage": usage},
            "financial",
        )
    report_list = report_type if report_type is not None else ["consolidated"]
    fields = field_list if field_list is not None else []

    codes: List[str] = []
    for sec in securities:
        c = _normalize_security_code(sec)
        if c:
            codes.append(c)
    if not codes:
        return format_response(
            {
                "state": "error",
                "message": "未找到有效证券代码，请仅输入完整代码（如 600519.SH）",
                "data": [],
                "usage": usage,
            },
            "financial",
        )

    frames: List[pd.DataFrame] = []
    with ThreadPoolExecutor(max_workers=min(len(codes), 8)) as ex:
        futs = [
            ex.submit(
                _fetch_financial_report,
                report_url,
                headers,
                code,
                start_date,
                end_date,
                fiscal_year,
                period_list,
                report_list,
                fields,
            )
            for code in codes
        ]
        for fut in as_completed(futs):
            part = fut.result()
            if not part.empty:
                frames.append(part)

    if not frames:
        return format_response(
            {"state": "error", "message": f"未找到{table_label}数据", "data": [], "usage": usage},
            "financial",
        )

    data = pd.concat(frames, ignore_index=True)

    data = data.drop(columns=["category", "announcementDate"], errors="ignore")

    if "endDate" in data.columns:
        data["endDate"] = data["endDate"].map(_fmt_yyyymmdd)

    data = _drop_empty_value_rows_and_cols(data)
    if data.empty:
        return format_response(
            {"state": "error", "message": f"过滤空值后无{table_label}数据", "data": [], "usage": usage},
            "financial",
        )

    data = _rename_columns_for_table(data, table_norm)
    data = _round_numeric_values(data)
    if "证券代码" in data.columns and "财报截止日期" in data.columns:
        data = data.sort_values(
            by=["证券代码", "财报截止日期"],
            ascending=[True, False],
        ).reset_index(drop=True)

    col_map = {
        "证券简称": "security_abbr",
        "证券代码": "security_code",
        "财报截止日期": "date",
    }
    for col in list(data.columns):
        if col in col_map:
            data.rename(columns={col: col_map[col]}, inplace=True)
    front = ["security_abbr", "security_code", "date"]
    columns = [c for c in front if c in data.columns] + [
        c for c in data.columns if c not in front
    ]
    data = data[columns]

    success_label = "、".join(codes[:3]) + "等" if len(codes) > 3 else "、".join(codes)
    parts = [
        {
            "data": data.to_dict(orient="records"),
            "module": "financial",
            "type": "data",
        }
    ]
    return format_response(
        {
            "state": "success",
            "message": f"已找到{success_label}{table_label}数据",
            "data": parts,
            "usage": usage,
        },
        "financial",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询财务报表：利润表 / 资产负债表 / 现金流量表（open financial-report）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        "-t",
        "--table-type",
        default="income",
        help="报表：income|利润表、balance|资产负债表、cashflow|现金流量表",
    )
    parser.add_argument(
        "-g",
        "--granularity",
        default="accumulated",
        help="口径：accumulated|累计、quarterly|单季度（仅利润表/现金流量表）",
    )
    parser.add_argument("-sd", "--start-date", default=None, help="开始日期 yyyy-MM-dd")
    parser.add_argument("-ed", "--end-date", default=None, help="结束日期 yyyy-MM-dd")
    parser.add_argument(
        "--fiscal-year",
        default=None,
        help="财报年度，逗号分隔，如 2024,2025",
    )
    parser.add_argument(
        "--period",
        default="Q0",
        help="报告期：Q1/Q2/Q3/Q4/Q0（一季报/中报/三季报/年报/最新），逗号分隔；默认 Q0",
    )
    parser.add_argument(
        "--report-type",
        default="consolidated",
        help="报表类型：consolidated/consolidatedRestated/standalone/standaloneRestated，逗号分隔",
    )
    parser.add_argument(
        "--field-list",
        default=None,
        help="指定科目英文字段名，逗号分隔；不传则 fieldList=[] 取全部",
    )
    parser.add_argument("--securities", default=None, help="证券代码逗号分隔")
    parser.add_argument("--securities-file", default=None, help="csv 含 security_code 列")
    args = parser.parse_args()

    fy: Optional[List[str]] = None
    if args.fiscal_year:
        fy = [x.strip() for x in args.fiscal_year.replace("，", ",").split(",") if x.strip()]

    period_list = [
        x.strip() for x in args.period.replace("，", ",").split(",") if x.strip()
    ]
    report_list = [x.strip() for x in args.report_type.replace("，", ",").split(",") if x.strip()]

    fl: Optional[List[str]] = None
    if args.field_list:
        fl = [x.strip() for x in args.field_list.replace("，", ",").split(",") if x.strip()]

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)

    if not securities:
        parser.error("必须至少提供 --securities 或 --securities-file")

    out = financial_data(
        securities=securities,
        start_date=args.start_date,
        end_date=args.end_date,
        fiscal_year=fy,
        period=period_list if period_list else None,
        report_type=report_list if report_list else None,
        field_list=fl,
        table_type=args.table_type,
        granularity=args.granularity,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: scripts/main_business.py
content: import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, timedelta
from io import TextIOWrapper
from typing import List, Optional, Sequence

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import (
    GTS_AUTHORIZATION,
    MAIN_BUSINESS_URL,
    check_version,
    format_response,
)

# open 文档：可选指标，默认全取
_FIELD_LIST_ALL = [
    "opRevenue",
    "opRevenueYoy",
    "opRevenueRatio",
    "opCost",
    "opCostYoy",
    "opCostRatio",
    "grossProfit",
    "grossProfitYoy",
    "grossProfitRatio",
    "grossMargin",
    "grossMarginYoy",
    "grossMarginRatio",
]

# 命令行使用 Q2、Q4；open 主营接口 period 仅支持中报/年报
MAIN_BUSINESS_PERIOD_CLI_TO_API = {
    "Q2": "interim",
    "Q4": "annual",
}

BREAKDOWN_KEYS = ("product", "industry", "region")
BREAKDOWN_LABEL = {
    "product": "分产品",
    "industry": "分行业",
    "region": "分地区",
}

# 响应 fieldList 中字段 -> 中文列名（与 backend 中「分行业/分产品/分地区」等语义对齐）
API_FIELD_TO_CN = {
    "periodName": "报告期",
    "periodEndDate": "日期",
    "categoryName": "主营业务名称",
    "opRevenue": "营业收入",
    "opRevenueYoy": "营业收入同比增速(%)",
    "opRevenueRatio": "营业收入占比(%)",
    "opCost": "营业成本",
    "opCostYoy": "营业成本同比增速(%)",
    "opCostRatio": "营业成本占比(%)",
    "grossProfit": "毛利",
    "grossProfitYoy": "毛利同比增速(%)",
    "grossProfitRatio": "毛利占比(%)",
    "grossMargin": "毛利率(%)",
    "grossMarginYoy": "毛利率同比增速(%)",
    "grossMarginRatio": "毛利率占比(%)",
}

SORT_TYPE_COL = "分行业/分产品/分地区"


def _load_security_codes_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _parse_main_business_body(body: dict, breakdown_key: str) -> pd.DataFrame:
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return pd.DataFrame()
    block = body.get("data") or {}
    rows = block.get("list") or []
    field_list = block.get("fieldList") or []
    if not rows or not field_list:
        return pd.DataFrame()
    security_name = (block.get("securityName") or "").strip()
    security_code = (block.get("securityCode") or "").strip()
    records = []
    for row in rows:
        if not isinstance(row, (list, tuple)):
            continue
        n = min(len(field_list), len(row))
        if n == 0:
            continue
        rec = {field_list[i]: row[i] for i in range(n)}
        records.append(rec)
    if not records:
        return pd.DataFrame()
    df = pd.DataFrame(records)
    df.insert(0, "证券简称", security_name or security_code)
    df.insert(1, "证券代码", security_code)
    df[SORT_TYPE_COL] = BREAKDOWN_LABEL.get(breakdown_key, breakdown_key)
    rename = {k: v for k, v in API_FIELD_TO_CN.items() if k in df.columns}
    df = df.rename(columns=rename)
    return df


def _fetch_main_business(
    headers: dict,
    security_code: str,
    start_date: str,
    end_date: str,
    breakdown_key: str,
    period: Optional[str],
    field_list: Sequence[str],
) -> pd.DataFrame:
    payload: dict = {
        "securityCode": security_code,
        "startDate": start_date,
        "endDate": end_date,
        "fieldList": list(field_list),
        "breakdown": breakdown_key,
    }
    if period:
        payload["period"] = period
    try:
        r = requests.post(MAIN_BUSINESS_URL, headers=headers, json=payload, timeout=120)
        if r.status_code != 200:
            return pd.DataFrame()
        body = r.json()
        return _parse_main_business_body(body, breakdown_key)
    except Exception:
        return pd.DataFrame()


def _main_business_for_security(
    headers: dict,
    security_code: str,
    start_date: str,
    end_date: str,
    breakdown: Optional[str],
    period: Optional[str],
    field_list: Sequence[str],
) -> pd.DataFrame:
    keys: List[str]
    if breakdown is None:
        keys = list(BREAKDOWN_KEYS)
    else:
        if breakdown not in BREAKDOWN_LABEL:
            return pd.DataFrame()
        keys = [breakdown]

    frames: List[pd.DataFrame] = []
    if len(keys) == 1:
        df = _fetch_main_business(
            headers, security_code, start_date, end_date, keys[0], period, field_list
        )
        if not df.empty:
            frames.append(df)
    else:
        with ThreadPoolExecutor(max_workers=len(keys)) as ex:
            futs = [
                ex.submit(
                    _fetch_main_business,
                    headers,
                    security_code,
                    start_date,
                    end_date,
                    k,
                    period,
                    field_list,
                )
                for k in keys
            ]
            for fut in as_completed(futs):
                part = fut.result()
                if not part.empty:
                    frames.append(part)

    if not frames:
        return pd.DataFrame()
    out = pd.concat(frames, ignore_index=True)
    if "日期" in out.columns:
        out["_dt"] = pd.to_datetime(out["日期"], errors="coerce")
        sort_cols = ["证券代码", "_dt", SORT_TYPE_COL]
        if "主营业务名称" in out.columns:
            sort_cols.append("主营业务名称")
        ascending = [True, False, True] + [True] * (len(sort_cols) - 3)
        out = out.sort_values(by=sort_cols, ascending=ascending).reset_index(drop=True)
        out = out.drop(columns=["_dt"], errors="ignore")
    return out


def main_business_data(
    securities: List[str],
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    period: Optional[str] = None,
    breakdown: Optional[str] = None,
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "main_business",
        )

    if period is not None and period not in ("interim", "annual"):
        return format_response(
            {"state": "error", "message": "period 仅支持 interim（中报）或 annual（年报）", "data": [], "usage": usage},
            "main_business",
        )

    if breakdown is not None and breakdown not in BREAKDOWN_LABEL:
        return format_response(
            {
                "state": "error",
                "message": f"breakdown 须为 product / industry / region 之一，当前: {breakdown}",
                "data": [],
                "usage": usage,
            },
            "main_business",
        )

    headers = {"Authorization": GTS_AUTHORIZATION}

    if not end_date:
        end_date = date.today().strftime("%Y-%m-%d")
    if not start_date:
        start_date = (date.today() - timedelta(days=365 * 3)).strftime("%Y-%m-%d")

    securities_codes: List[str] = []
    for sec in securities:
        c = _normalize_security_code(sec)
        if c:
            securities_codes.append(c)

    if not securities_codes:
        return format_response(
            {
                "state": "error",
                "message": "未找到有效证券代码，请仅输入完整代码（如 600519.SH）",
                "data": [],
                "usage": usage,
            },
            "main_business",
        )

    frames: List[pd.DataFrame] = []
    for code in securities_codes:
        df_one = _main_business_for_security(
            headers,
            code,
            start_date,
            end_date,
            breakdown,
            period,
            _FIELD_LIST_ALL,
        )
        if not df_one.empty:
            frames.append(df_one)

    if not frames:
        return format_response(
            {"state": "error", "message": "未找到主营业务数据", "data": [], "usage": usage},
            "main_business",
        )

    data = pd.concat(frames, ignore_index=True)
    data = data.sort_values(
        by=["证券代码", "日期"] if "日期" in data.columns else ["证券代码"],
        ascending=[True, False] if "日期" in data.columns else [True],
    ).reset_index(drop=True)

    skip_numeric = {"证券简称", "证券代码", "报告期", SORT_TYPE_COL, "主营业务名称", "日期"}
    numeric_cols = [c for c in data.columns if c not in skip_numeric]
    for c in numeric_cols:
        data[c] = pd.to_numeric(data[c], errors="coerce")

    col_map = {
        "证券简称": "security_abbr",
        "证券代码": "security_code",
        "日期": "date",
    }
    for col in list(data.columns):
        if col in col_map:
            data.rename(columns={col: col_map[col]}, inplace=True)
    front_columns = ["security_abbr", "security_code", "date"]
    columns = [c for c in front_columns if c in data.columns] + [
        c for c in data.columns if c not in front_columns
    ]
    data = data[columns]

    success_label = "、".join(securities_codes[:3]) + "等" if len(securities_codes) > 3 else "、".join(securities_codes)

    parts = [
        {
            "data": data.to_dict(orient="records"),
            "module": "main_business",
            "type": "data",
        }
    ]
    return format_response(
        {
            "state": "success",
            "message": f"已找到{success_label}主营业务数据",
            "data": parts,
            "usage": usage,
        },
        "main_business",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询主营构成（open main-business）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("-sd", "--start-date", default=None, help="开始日期 yyyy-MM-dd，默认 end 往前三年")
    parser.add_argument("-ed", "--end-date", default=None, help="结束日期 yyyy-MM-dd，默认今天")
    parser.add_argument("--securities", default=None, help="证券代码逗号分隔（完整代码）")
    parser.add_argument("--securities-file", default=None, help="csv 含 security_code 列")
    parser.add_argument(
        "--period",
        default=None,
        help="报告期：仅 Q2=中报 或 Q4=年报；不传则不限定",
    )
    parser.add_argument(
        "--breakdown",
        default=None,
        choices=["product", "industry", "region"],
        help="拆分维度；不传则并发拉取 product / industry / region 三种",
    )
    args = parser.parse_args()

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)

    if not securities:
        parser.error("必须至少提供 --securities 或 --securities-file")

    api_period = None
    if args.period is not None and str(args.period).strip():
        k = str(args.period).strip().upper()
        if k not in MAIN_BUSINESS_PERIOD_CLI_TO_API:
            parser.error(
                "主营构成 --period 仅支持 Q2（中报）或 Q4（年报）"
            )
        api_period = MAIN_BUSINESS_PERIOD_CLI_TO_API[k]

    out = main_business_data(
        securities=securities,
        start_date=args.start_date,
        end_date=args.end_date,
        period=api_period,
        breakdown=args.breakdown,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: scripts/valuation.py
content: import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date
from io import TextIOWrapper
from typing import Dict, List, Optional, Tuple

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import (
    GTS_AUTHORIZATION,
    VALUATION_URL,
    check_version,
    format_response,
)

# 与 open 接口文档一致；并发拉取后按 backend valuation 的列语义组装
VALUATION_INDICATORS: List[str] = ["peTtm", "psTtm", "pbMrq", "peg", "pcfTtm", "em"]

INDICATOR_CN: Dict[str, str] = {
    "peTtm": "市盈率TTM",
    "psTtm": "市销率TTM",
    "pbMrq": "市净率MRQ",
    "peg": "PEG",
    "pcfTtm": "市现率TTM",
    "em": "企业倍数",
}

# 与 backend 中 abs(range_year)=3 时「在N年中所处分位」一致
QUANTILE_YEAR_LABEL = 3

_FIELD_LIST = ["value", "percentileRank"]


def _load_security_codes_from_file(path: str) -> List[str]:
    """仅读取 security_code 列（完整代码），与估值接口仅支持代码输入一致。"""
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    """仅接受完整证券代码（如 600519.SH），不做简称/键盘精灵解析。"""
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _parse_valuation_analysis_body(
    body: dict,
    indicator: str,
    quantile_years: int,
) -> Dict[str, Dict[str, object]]:
    """将单次估值分析接口 data 转为 {日期字符串: {列名: 值}}。"""
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return {}
    block = body.get("data") or {}
    field_list = block.get("fieldList") or []
    rows = block.get("list") or []
    if not field_list or not rows:
        return {}
    idx = {name: i for i, name in enumerate(field_list)}
    i_td = idx.get("tradeDate")
    i_val = idx.get("value")
    i_pct = idx.get("percentileRank")
    if i_td is None or i_val is None or i_pct is None:
        return {}
    cn = INDICATOR_CN.get(indicator, indicator)
    col_v = cn
    col_q = f"{cn}在{quantile_years}年中所处分位"
    out: Dict[str, Dict[str, object]] = {}
    max_i = max(i_td, i_val, i_pct)
    for row in rows:
        if not isinstance(row, (list, tuple)) or len(row) <= max_i:
            continue
        raw_d = row[i_td]
        if raw_d is None:
            continue
        ds = str(raw_d).strip()
        if len(ds) >= 10:
            ds = ds[:10]
        if not ds:
            continue
        if ds not in out:
            out[ds] = {}
        out[ds][col_v] = row[i_val]
        out[ds][col_q] = row[i_pct]
    return out


def _fetch_one_indicator(
    headers: dict,
    security_code: str,
    indicator: str,
    start_date: str,
    end_date: str,
    limit: int,
) -> Tuple[str, Dict[str, Dict[str, object]]]:
    payload = {
        "securityCode": security_code,
        "indicator": indicator,
        "startDate": start_date,
        "endDate": end_date,
        "limit": limit,
        "fieldList": list(_FIELD_LIST),
    }
    try:
        r = requests.post(
            VALUATION_URL,
            headers=headers,
            json=payload,
            timeout=120,
        )
        if r.status_code != 200:
            return indicator, {}
        body = r.json()
        merged = _parse_valuation_analysis_body(body, indicator, QUANTILE_YEAR_LABEL)
        return indicator, merged
    except Exception:
        return indicator, {}


def _valuation_df_for_security(
    headers: dict,
    security_abbr: str,
    security_code: str,
    start_date: str,
    end_date: str,
    limit: int,
) -> pd.DataFrame:
    date_maps: List[Dict[str, Dict[str, object]]] = []
    with ThreadPoolExecutor(max_workers=len(VALUATION_INDICATORS)) as ex:
        futs = [
            ex.submit(
                _fetch_one_indicator,
                headers,
                security_code,
                ind,
                start_date,
                end_date,
                limit,
            )
            for ind in VALUATION_INDICATORS
        ]
        for fut in as_completed(futs):
            _, part = fut.result()
            if part:
                date_maps.append(part)

    merged_by_date: Dict[str, Dict[str, object]] = {}
    for part in date_maps:
        for d, cols in part.items():
            if d not in merged_by_date:
                merged_by_date[d] = {}
            merged_by_date[d].update(cols)

    if not merged_by_date:
        return pd.DataFrame()

    records = []
    for d in sorted(merged_by_date.keys(), reverse=True):
        row = {
            "证券简称": security_abbr,
            "证券代码": security_code,
            "日期": d,
        }
        row.update(merged_by_date[d])
        records.append(row)

    df = pd.DataFrame(records)
    base_cols = ["证券简称", "证券代码", "日期"]
    ordered_metric_cols: List[str] = []
    for ind in VALUATION_INDICATORS:
        cn = INDICATOR_CN[ind]
        q = f"{cn}在{QUANTILE_YEAR_LABEL}年中所处分位"
        if cn in df.columns:
            ordered_metric_cols.append(cn)
        if q in df.columns:
            ordered_metric_cols.append(q)
    extra = [c for c in df.columns if c not in base_cols + ordered_metric_cols]
    df = df[base_cols + [c for c in ordered_metric_cols if c in df.columns] + extra]

    for c in df.columns:
        if c not in base_cols:
            df[c] = pd.to_numeric(df[c], errors="coerce")

    if start_date:
        sd = pd.to_datetime(start_date, errors="coerce")
        df = df[pd.to_datetime(df["日期"], errors="coerce") >= sd]
    if end_date:
        ed = pd.to_datetime(end_date, errors="coerce")
        df = df[pd.to_datetime(df["日期"], errors="coerce") <= ed]
    df = df.reset_index(drop=True)
    if not df.empty:
        df = df.sort_values(by=["日期"], ascending=False).reset_index(drop=True)
    df = df.drop(columns=["证券简称"])
    return df


def valuation_data(
    securities: List[str],
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    limit: int = 2000,
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "valuation",
        )

    headers = {"Authorization": GTS_AUTHORIZATION}

    if not end_date:
        end_date = date.today().strftime("%Y-%m-%d")
    if not start_date:
        start_date = date.today().strftime("%Y-%m-%d")

    securities_abbrs: List[str] = []
    securities_codes: List[str] = []
    for sec in securities:
        code = _normalize_security_code(sec)
        if code:
            securities_codes.append(code)
            securities_abbrs.append(code)
    if not securities_codes:
        return format_response(
            {
                "state": "error",
                "message": "未找到有效证券代码，请仅输入完整代码（如 600519.SH），不支持简称",
                "data": [],
                "usage": usage,
            },
            "valuation",
        )

    frames: List[pd.DataFrame] = []
    for abbr, code in zip(securities_abbrs, securities_codes):
        df_one = _valuation_df_for_security(
            headers, abbr, code, start_date, end_date, limit
        )
        if not df_one.empty:
            frames.append(df_one)

    if not frames:
        return format_response(
            {"state": "error", "message": "未找到估值数据", "data": [], "usage": usage},
            "valuation",
        )

    valuation_data_df = pd.concat(frames, ignore_index=True)
    valuation_data_df = valuation_data_df.sort_values(
        by=["证券代码", "日期"], ascending=[True, False]
    ).reset_index(drop=True)

    col_map = {
        "证券简称": "security_abbr",
        "证券代码": "security_code",
        "日期": "date",
    }
    for col in list(valuation_data_df.columns):
        if col in col_map:
            valuation_data_df.rename(columns={col: col_map[col]}, inplace=True)
    front_columns = ["security_abbr", "security_code", "date"]
    columns = [c for c in front_columns if c in valuation_data_df.columns] + [
        c for c in valuation_data_df.columns if c not in front_columns
    ]
    valuation_data_df = valuation_data_df[columns]

    success_securities_abbr = (
        "、".join(securities_abbrs[:3]) + "等"
        if len(securities_abbrs) > 3
        else "、".join(securities_abbrs)
    )

    parts = [
        {
            "data": valuation_data_df.to_dict(orient="records"),
            "module": "valuation",
            "type": "data",
        }
    ]
    return format_response(
        {
            "state": "success",
            "message": f"已找到{success_securities_abbr}估值数据",
            "data": parts,
            "usage": usage,
        },
        "valuation",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询估值数据（open 估值分析接口）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    today_str = date.today().strftime("%Y-%m-%d")
    parser.add_argument("-sd", "--start-date", default=today_str, help="开始日期，如 2023-01-01")
    parser.add_argument("-ed", "--end-date", default=today_str, help="结束日期，如 2026-12-31")
    parser.add_argument("--securities", default=None, help="证券代码逗号分隔")
    parser.add_argument(
        "--securities-file",
        default=None,
        help="从 csv 读取证券代码列 security_code",
    )
    parser.add_argument("--limit", type=int, default=2000, help="单次指标请求最大行数")
    args = parser.parse_args()

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)

    if not securities:
        parser.error("必须至少提供 --securities 或 --securities-file")

    out = valuation_data(
        securities=securities,
        start_date=args.start_date,
        end_date=args.end_date,
        limit=args.limit,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: scripts/utils.py
content: import os
import re
from typing import Dict, List, Any, Optional
# import logging
# from logging.handlers import TimedRotatingFileHandler
import pandas as pd
import datetime
import requests
import json

GTS_AUTHORIZATION_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), ".authorization")
GTS_ACCESS_KEY = os.getenv("GTS_ACCESS_KEY", None)
GTS_SECRET_KEY = os.getenv("GTS_SECRET_KEY", None)

# 通过 ak/sk 获取 临时 authorization

AUTHORIZATION_URL = f"https://open.gangtise.com/application/auth/oauth/open/loginV2"

def get_authorization(ak: str, sk: str):
    payload = {
        "accessKey": ak,
        "secretAccessKey": sk
    }
    response = requests.post(AUTHORIZATION_URL, json=payload)
    if response.status_code != 200:
        return None
    try:
        return response.json()["data"]["accessToken"]
    except Exception as e:
        return None

GTS_AUTHORIZATION = None
if GTS_ACCESS_KEY and GTS_SECRET_KEY:
    GTS_AUTHORIZATION = get_authorization(GTS_ACCESS_KEY, GTS_SECRET_KEY)
elif os.path.exists(GTS_AUTHORIZATION_PATH):
    with open(GTS_AUTHORIZATION_PATH, "r", encoding="utf-8") as f:
        content = json.load(f)
        if content.get("accessKey", None) and content.get("secretAccessKey", None):
            GTS_AUTHORIZATION = get_authorization(content["accessKey"], content["secretAccessKey"])
        else:
            GTS_AUTHORIZATION = None

GTS_SAVE_FILE = os.getenv("GTS_SAVE_FILE", False)
GTS_SAVE_EXTENSION = os.getenv("GTS_SAVE_EXTENSION", "md")

GANGTISE_QUOTE_DOMAIN = "https://open.gangtise.com/application/open-quote"
GANGTISE_FUNDAMENTAL_DOMAIN = "https://open.gangtise.com/application/open-fundamental"
QUOTE_URL = f"{GANGTISE_QUOTE_DOMAIN}/kline/daily"
QUOTE_HK_URL = f"{GANGTISE_QUOTE_DOMAIN}/kline-hk/daily"
QUOTE_MINUTE_URL = f"{GANGTISE_QUOTE_DOMAIN}/kline/minute"
QUOTE_ADJUST_FACTOR_URL = f"{GANGTISE_QUOTE_DOMAIN}/adjustFactor"
FINANCIAL_REPORT_INCOME_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/financial-report/income-statement/accumulated"
FINANCIAL_REPORT_INCOME_QUARTERLY_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/financial-report/income-statement/quarterly"
FINANCIAL_REPORT_BALANCE_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/financial-report/balance-sheet/accumulated"
FINANCIAL_REPORT_CASH_FLOW_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/financial-report/cash-flow-statement/accumulated"
FINANCIAL_REPORT_CASH_FLOW_QUARTERLY_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/financial-report/cash-flow-statement/quarterly"
FINANCIAL_REPORT_URL = FINANCIAL_REPORT_INCOME_URL
MAIN_BUSINESS_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/main-business"
VALUATION_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/valuation-analysis"

def _find_openclaw_root():
    """向上遍历目录直到找到 .openclaw，返回其上级目录作为执行目录"""
    path = os.path.abspath(os.path.dirname(__file__))
    openclaw_dir_got = False
    while path != os.path.dirname(path):
        dir_name = os.path.basename(path)
        if dir_name in (".openclaw"):
            openclaw_dir_got = True
            return os.path.abspath(path)
        path = os.path.dirname(path)
    path = os.path.abspath(os.path.dirname(__file__))
    if not openclaw_dir_got:
        openclaw_dir_got = False
        while path != os.path.dirname(path):
            dir_name = os.path.basename(path)
            if dir_name in (".agent"):
                openclaw_dir_got = True
                return os.path.abspath(path)
            path = os.path.dirname(path)
    path = os.path.abspath(os.path.dirname(__file__))
    if not openclaw_dir_got:
        openclaw_dir_got = False
        while path != os.path.dirname(path):
            dir_name = os.path.basename(path)
            if dir_name in ("workspace"):
                openclaw_dir_got = True
                return os.path.abspath(path)
            path = os.path.dirname(path)
    path = os.path.abspath(os.path.dirname(__file__))
    if not openclaw_dir_got:
        openclaw_dir_got = False
        while path != os.path.dirname(path):
            dir_name = os.path.basename(path)
            if dir_name in ("skills"):
                openclaw_dir_got = True
                return os.path.abspath(os.path.dirname(path))
            path = os.path.dirname(path)
    return os.path.abspath(os.getcwd())

openclaw_root = _find_openclaw_root()
if openclaw_root.endswith("workspace"):
    gangtise_workspace_path = os.path.join(openclaw_root, "gangtise")
else:
    gangtise_workspace_path = os.path.join(openclaw_root, "workspace", "gangtise")
if not os.path.exists(gangtise_workspace_path):
    os.makedirs(gangtise_workspace_path, exist_ok=True)

usage_dir = os.path.join(gangtise_workspace_path, ".usage")
if not os.path.exists(usage_dir):
    os.makedirs(usage_dir, exist_ok=True)

file_dir = os.path.join(gangtise_workspace_path, "files")
if not os.path.exists(file_dir):
    os.makedirs(file_dir, exist_ok=True)

def remove_html_tags(text):
    clean = re.compile('<.*?>')
    return re.sub(clean, '', text)

def data_to_md(data: pd.DataFrame, range: List[int]=None, max_cell_length: int=None):
    data_copy = data.copy()
    if "metadata" in data_copy.columns:
        data_copy = data_copy.drop(columns=["metadata"])
    content = "| " + " | ".join(data_copy.columns) + " |\n"
    content += "| " + " | ".join(["-" for _ in data_copy.columns]) + " |\n"
    omitted = False
    for i, row in enumerate(data_copy.to_dict(orient="records")):
        if range:
            if i in range:
                if max_cell_length:
                    content += "| " + " | ".join([re.sub(r"\s+", " ", str(row[key]).replace("\n"," ")).replace("|", "")[:max_cell_length]+"..." if len(re.sub(r"\s+", " ", str(row[key])).replace("|", "")) > max_cell_length else re.sub(r"\s+", " ", str(row[key])).replace("|", "") for key in row.keys()]) + " |\n"
                else:
                    content += "| " + " | ".join([re.sub(r"\s+", " ", str(row[key]).replace("\n"," ")).replace("|", "") for key in row.keys()]) + " |\n"
            elif not omitted:
                content += "| ... |\n"
                omitted = True
        else:
            if max_cell_length:
                content += "| " + " | ".join([re.sub(r"\s+", " ", str(row[key]).replace("\n"," ")).replace("|", "")[:max_cell_length]+"..." if len(re.sub(r"\s+", " ", str(row[key])).replace("|", "")) > max_cell_length else re.sub(r"\s+", " ", str(row[key])).replace("|", "") for key in row.keys()]) + " |\n"
            else:
                content += "| " + " | ".join([re.sub(r"\s+", " ", str(row[key]).replace("\n"," ")).replace("|", "") for key in row.keys()]) + " |\n"
    content = content[:-1]
    return content.strip()

def add_usages(usages_list: List[Dict[str, Any]]):
    usages = {}
    for usages_item in usages_list:
        if len(usages_item) == 0:
            continue
        for k,v in usages_item.items():
            if k not in usages:
                usages[k] = v
            else:
                usages[k] = usages[k] + v
    return usages

def format_response(response: dict, method_name: str):
    
    # 保存usage
    today = datetime.datetime.now().strftime("%Y-%m-%d")
    now = datetime.datetime.now().strftime("%H%M%S")
    usage_path = os.path.join(usage_dir, f"{today}.json")
    if response.get("usage", None):
        if os.path.exists(usage_path):
            with open(usage_path, "r", encoding="utf-8") as f:
                usage = json.load(f)
            if now in usage:
                now_usage = add_usages([response["usage"], usage[now]])
            else:
                now_usage = response["usage"]
            usage.update({now: now_usage})
        else:
            usage = {now: response["usage"]}
        with open(usage_path, "w", encoding="utf-8") as f:
            json.dump(usage, f, ensure_ascii=False)

    # 保存结果
    return_message = response.get("message", "") + "\n\n" if response.get("message", "") else ""
    method_name_map = {
        "block_component": "板块成分信息",
        "index": "指数信息",
        "financial": "财务数据",
        "earning_forecast": "盈利预测数据",
        "industry_indicator": "行业指标数据",
        "main_business": "主营业务数据",
        "quote": "行情数据",
        "security": "证券信息",
        "shareholder": "股东数据",
        "valuation": "估值数据",
        "rag": "知识库检索",
    }
    if response["state"] == "success":
        for item in response["data"]:
            module_name = item["module"]
            data = item["data"]
            extension = "csv" if item["type"] == "data" else "md"
            process_dir = os.path.join(gangtise_workspace_path, method_name)
            if not os.path.exists(process_dir):
                os.makedirs(process_dir, exist_ok=True)
            # now = datetime.datetime.now().strftime("%H%M%S")
            now = 1
            process_path = os.path.join(process_dir, f"{module_name}_{now}.{extension}")
            max_retries = 10
            for file in os.listdir(process_dir):
                if file.startswith(f"{module_name}_") and file.endswith(f".{extension}"):
                    max_retries = max(max_retries, int(file.split("_")[-1].split(".")[0])+10)
            while os.path.exists(process_path) and max_retries > 0:
                # now = datetime.datetime.now().strftime("%H%M%S")
                now += 1
                process_path = os.path.join(process_dir, f"{module_name}_{now}.{extension}")
                max_retries -= 1
                # sleep(1)
            if max_retries == 0:
                return_message = "错误信息：文件存储系统繁忙，请稍后再试"
                return return_message
            if item["type"] == "data":
                data = pd.DataFrame(data)
                data.to_csv(process_path, index=False)
                sample_data = data_to_md(data, range=[0, 1, 2, len(data)-3, len(data)-2, len(data)-1])
                return_message += "### " + method_name_map[method_name] + " " + module_name + " 数据已保存到csv：\n`" + os.path.abspath(process_path) + "`\n\n#### 样例数据:\n" + sample_data + "\n\n---\n\n"
            elif item["type"] == "files":
                with open(process_path, "w", encoding="utf-8") as f:
                    for i, file in enumerate(data):
                        f.write(f"### 查询结果 {i+1}\n")
                        f.write(f"标题：{file['标题']}\n")
                        f.write(f"文件时间：{file['文件时间']}\n")
                        f.write(f"类型：{file['类型']}\n")
                        f.write(f"类型中ID：{file['类型中ID']}\n")
                        f.write(f"内容：\"\"\"\n{file['摘要']}\n\"\"\"")
                        if i < len(data) - 1:
                            f.write("\n\n---\n\n")
                _sample_data = [file["标题"] for file in data]
                sample_data = []
                for _sample_data_item in _sample_data:
                    if _sample_data_item not in sample_data:
                        sample_data.append(_sample_data_item)
                sample_data = [f'"{x}"' for x in sample_data]
                sample_data = ", ".join(sample_data)
                return_message += "### " + method_name_map[method_name] + "查询结果具体内容已保存到md：\n`" + os.path.abspath(process_path) + "`\n\n#### 查询结果的文件标题:\n" + sample_data
    else:
        return_message = "调用gangtise服务端失败，错误信息：" + response["message"]
    if return_message.endswith("---\n\n"):
        return_message = return_message[:-6].strip()
    return return_message

def load_securities_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_abbr" in df.columns:
        return [str(x) for x in df["security_abbr"].dropna().tolist()]
    if "security_code" in df.columns:
        return [str(x) for x in df["security_code"].dropna().tolist()]
    raise ValueError("证券文件必须包含 security_abbr 或 security_code 列")

def match_best(item: str, candidates, threshold: float = 0.6):
    """
    candidates: List[str] 时返回匹配的字符串，Dict[str, Any] 时返回匹配 key 的 {k: v}。
    无匹配返回 None。
    """
    from difflib import SequenceMatcher

    if not item or not candidates:
        return None
    if item in candidates:
        return item

    is_dict = isinstance(candidates, dict)
    keys = list(candidates.keys()) if is_dict else candidates

    if item in keys:
        return {item: candidates[item]} if is_dict else item

    best_score = 0.0
    best_key = None

    for key in keys:
        if item in key or key in item:
            overlap = min(len(item), len(key))
            score = overlap / max(len(item), len(key))
            score = max(score, 0.8)
        else:
            score = SequenceMatcher(None, item, key).ratio()

        if score > best_score:
            best_score = score
            best_key = key

    if best_score >= threshold and best_key is not None:
        return {best_key: candidates[best_key]} if is_dict else best_key
    return None

INCOME_FIELD_CN = {
    "securityCode": "证券代码",
    "companyName": "证券简称",
    "endDate": "财报截止日期",
    "fiscalYear": "财报年",
    "period": "报告期",
    "reportType": "报表类型",
    "companyType": "企业报表格式",
    "currency": "币种",
    "unit": "单位",
    "totalOperatingRevenue": "一、营业总收入",
    "operatingRevenue": "营业收入",
    "ofWhichSalesRevenue": "其中:主营业务收入",
    "ofWhichOtherOperatingIncome": "其中:其他业务收入",
    "netInterestIncome": "利息净收入",
    "ofWhichInterestIncome": "其中:利息收入",
    "ofWhichInterestExpense": "其中:利息支出",
    "premiumsEarned": "已赚保费",
    "ofWhichPremiumsIncome": "其中:保险业务收入",
    "ofWhichReinsuranceIncome": "其中:分保费收入",
    "ofWhichLessReinsurance": "其中:减:分出保费",
    "ofWhichLessUnearnedPremiumReserve": "其中:减:提取未到期责任准备金",
    "guaranteeIncome": "担保业务收入",
    "netCommissionIncome": "手续费及佣金净收入",
    "ofWhichCommissionIncome": "其中:手续费及佣金收入",
    "ofWhichBrokerageIncome": "其中:经纪业务手续费收入",
    "ofWhichInvestBankIncome": "其中:投资银行业务手续费收入",
    "ofWhichAssetManageIncome": "其中:资产管理业务手续费收入",
    "ofWhichfundManageIncome": "其中:基金管理业务手续费收入",
    "ofWhichInvestConsultIncome": "其中:投资咨询业务收入",
    "ofWhichRiskManageIncome": "其中:风险管理业务收入",
    "ofWhichInvestManageIncome": "其中:投资管理业务收入",
    "ofWhichOtherAgencyIncome": "其中:其他代理业务收入",
    "ofWhichCommissionExpense": "其中:手续费及佣金支出",
    "ofWhichBrokerageExpense": "其中:经纪业务手续费支出",
    "ofWhichInvestBankExpense": "其中:投资银行业务手续费支出",
    "ofWhichAssetManageExpense": "其中:资产管理业务手续费支出",
    "ofWhichfundManageExpense": "其中:基金管理业务手续费支出",
    "ofWhichInvestConsultExpense": "其中:投资咨询业务支出",
    "ofWhichRiskManageExpense": "其中:风险管理业务支出",
    "ofWhichInvestManageExpense": "其中:投资管理业务支出",
    "ofWhichOtherAgencyExpense": "其中:其他代理业务支出",
    "ofWhichNetProxySecuritiesIncome": "其中:代理买卖证券业务净收入",
    "ofWhichNetUnderwritingSecuritiesIncome": "其中:证券承销业务净收入",
    "ofWhichNetTrustIncome": "其中:受托客户资产管理业务净收入",
    "ofWhichNetfundManagementIncome": "其中:基金管理业务手续费净收入",
    "otherOperatingRevenue": "其他收入",
    "specialItemsEffectingOperatingIncome": "营业收入特殊项目",
    "adjustmentItemsEffectingOperatingIncome": "营业收入调整项目",
    "totalOperatingCost": "二、营业总成本",
    "operatingPayout": "营业支出",
    "insuranceCommissionExpense": "保险手续费及佣金支出",
    "refundedPremiums": "退保金",
    "provisionForInsuranceReserve": "提取保费准备金",
    "provisionForFuturesRiskReserve": "提取期货风险准备金",
    "provisionForGuaranteeBusinessReserve": "提取担保业务准备金",
    "provisionForGuaranteeCompensationReserve": "提取担保赔偿准备金",
    "netClaimIncurred": "赔付支出净额",
    "ofWhichGrossClaimsPaid": "其中:赔付支出",
    "ofWhichLessReinsuranceRecoveriesOnClaims": "其中:减:摊回赔付支出",
    "netProvisionForInsuranceLiabilities": "提取保险责任准备金净额",
    "ofWhichProvisionForInsuranceLiabilities": "其中:提取保险责任准备金",
    "ofWhichLessReinsuranceRecoveriesOnReserves": "其中:减:摊回保险责任准备金",
    "policyDividendPayout": "保单红利支出",
    "insuranceServiceExpense": "保险服务费用",
    "reinsuranceCostAllocation": "分出保费的分摊",
    "lessReinsuranceRecoveriesOnServiceExpense": "减:摊回保险服务费用",
    "underwritingFinancialLoss": "承保财务损失",
    "lessCededReinsuranceFinancialIncome": "减:分出再保险财务收益",
    "reinsuranceCommissionExpense": "分保费用",
    "otherOperatingCost": "其他成本",
    "operatingCost": "营业成本",
    "ofWhichSalesCost": "其中:主营业务成本",
    "ofWhichOtherOperationalCost": "其中:其他业务成本",
    "operatingTaxAndSurcharges": "营业税金及附加",
    "salesExpense": "销售费用",
    "totalAdministrativeExpense": "管理费用合计",
    "ofWhichAdministrativeExpense": "其中:管理费用",
    "ofWhichOperatingAndAdminExpense": "其中:业务及管理费",
    "ofWhichLessAmortizedReinsuranceCost": "其中:减:摊回分保费用",
    "researchAndDevelopmentExpense": "研发费用",
    "financialExpense": "财务费用",
    "ofWhichFinancialInterestExpense": "其中:利息费用(财务费用)",
    "ofWhichFinancialInterestIncome": "其中:利息收入(财务费用)",
    "explorationCost": "勘探费用",
    "creditImpairmentLossCost": "信用减值损失(成本)",
    "assetImpairmentLossCost": "资产减值损失(成本)",
    "specialItemsEffectingOperatingCost": "营业成本特殊项目",
    "adjustmentItemsEffectingOperatingCost": "营业成本调整项目",
    "nonOperatingNetIncome": "三、非经营性净收益",
    "otherNonOperatingIncome": "其他收益",
    "investmentIncome": "投资净收益",
    "ofWhichInvestmentIncomeAssociates": "其中:对联营合营企业的投资收益",
    "ofWhichGainOnDerecognitionOfAmortizedCostAssets": "其中:以摊余成本计量的金融资产终止确认收益",
    "exchangeGain": "汇兑收益",
    "netOpenHedgeGain": "净敞口套期收益",
    "fairValueChangeGain": "公允价值变动净收益",
    "creditImpairmentLossProfit": "信用减值损失(利润)",
    "assetImpairmentLossProfit": "资产减值损失(利润)",
    "gainOnAssetDisposal": "资产处置收益",
    "specialItemsEffectingOperatingProfit": "营业利润特殊项目",
    "adjustedItemsEffectingOperatingProfit": "营业利润调整项目",
    "operatingProfit": "四、营业利润",
    "addNonoperatingIncome": "加:营业外收入",
    "lessNonoperatingExpense": "减:营业外支出",
    "ofWhichNetLossOnDisposalOfNonCurrentAssets": "其中:非流动资产处置净损失",
    "specialItemsEffectingTotalProfit": "利润总额特殊项目",
    "adjustedItemsEffectingTotalProfit": "利润总额调整项目",
    "totalProfit": "五、利润总额",
    "lessIncomeTaxExpense": "减:所得税费用",
    "addUnrecognizedInvestmentLoss": "加:未确认的投资损失",
    "specialItemsEffectingNetProfit": "净利润特殊项目",
    "adjustedItemsEffectingNetProfit": "净利润调整项目",
    "netProfit": "六、净利润",
    "byContinuingOperations": "(一)按经营持续性分类",
    "profitFromContinuingOperations": "持续经营净利润",
    "profitFromDiscontinuedOperations": "终止经营净利润",
    "byOwnership": "(二)按所有权归属分类",
    "netProfitAttributableToOwnersOfParent": "归属于母公司所有者的净利润",
    "ofWhichNetProfitAttributableToOrdinaryShareholders": "其中:归属于母公司普通股股东的净利润",
    "ofWhichNetProfitAttributableToOtherEquityInstrumentHolders": "其中:归属于母公司其他权益工具持有者的净利润",
    "netProfitAttributableToNoncontrollingInterests": "少数股东损益",
    "specialItemsEffectingNetProfitParent": "母公司净利润特殊项目",
    "adjustedItemsEffectingNetProfitParent": "母公司净利润调整项目",
    "otherComprehensiveIncome": "七、其他综合收益的税后净额",
    "ociParentCompanyOwners": "归属于母公司所有者的其他综合收益的税后净额",
    "ociNotReclassifiableToProfitOrLoss": "(一)以后不能重分类进损益的其他综合收益",
    [REDACTED_SECRET]: "1.1重新计量设定受益计划净负债或净资产的变动",
    [REDACTED_SECRET]: "1.2权益法下在被投资单位不能重分类进损益的其他综合收益",
    [REDACTED_SECRET]: "1.3其他权益工具投资公允价值变动",
    "ownCreditRiskFairValueChange": "1.4企业自身信用风险公允价值变动",
    [REDACTED_SECRET]: "1.5不能转损益的保险合同金融变动",
    "ociReclassifiableToProfitOrLoss": "(二)以后将重分类进损益的其他综合收益",
    [REDACTED_SECRET]: "2.1权益法下在被投资单位以后将重分类进损益的其他综合收益",
    [REDACTED_SECRET]: "2.2其他债权投资公允价值变动",
    [REDACTED_SECRET]: "2.3金融资产重分类计入其他综合收益的金额",
    [REDACTED_SECRET]: "2.4其他债权投资信用减值准备",
    [REDACTED_SECRET]: "2.5现金流量套期损益的有效部分",
    [REDACTED_SECRET]: "2.6外币财务报表折算差额",
    [REDACTED_SECRET]: "2.7可供出售金融资产公允价值变动损益",
    [REDACTED_SECRET]: "2.8持有至到期投资重分类为可供出售金融资产损益",
    [REDACTED_SECRET]: "2.9可转损益的保险合同金融变动",
    [REDACTED_SECRET]: "3.0可转损益的分出再保险合同金融变动",
    "otherReclassifiableOCI": "3.1其他(以后能重分类进损益表的其他综合收益)",
    "ociAttributableToNoncontrollingInterests": "归属于少数股东的其他综合收益的税后净额",
    "adjustedItemsEffectingOtherComprehensiveIncome": "综合收益总额调整项目",
    "specialItemsEffectingOtherComprehensiveIncome": "综合收益总额特殊项目",
    "totalComprehensiveIncome": "八、综合收益总额",
    "comprehensiveIncomeAttributableToOwnersOfParent": "归属于母公司所有者的综合收益总额",
    "ofWhichComprehensiveIncomeAttributableToOrdinaryShareholders": "其中:归属于母公司普通股股东的综合收益",
    "ofWhichComprehensiveIncomeAttributableToOtherEquityInstrumentHolders": "其中:归属于母公司其他权益工具持有者的综合收益",
    "comprehensiveIncomeAttributableToNoncontrollingInterests": "归属于少数股东的综合收益总额",
    "adjustedItemsEffectingComprehensiveIncomeParent": "母公司综合收益总额调整项目",
    "basicEarningsPerShare": "基本每股收益",
    "dilutedEarningsPerShare": "稀释每股收益",
    "totalOpRev": "一、营业总收入",
    "opRev": "营业收入",
    "salesRev": "其中：主营业务收入",
    "otherOpRev": "其中：其他业务收入",
    "netIntIncome": "利息净收入",
    "intIncome": "其中：利息收入",
    "intExp": "其中：利息支出",
    "premEarned": "已赚保费",
    "premIncome": "其中：保险业务收入",
    "reinsIncome": "其中：保险业务收入：分保费收入",
    "lessReins": "其中：减：分出保费",
    "lessUnearnedPremRes": "其中：减：提取未到期责任准备金",
    "guaranteeIncome": "担保业务收入",
    "netCommIncome": "手续费及佣金净收入",
    "commIncome": "其中：手续费及佣金收入",
    "brokerageIncome": "其中：经纪业务手续费收入",
    "invBankIncome": "其中：投资银行业务手续费收入",
    "assetMgmtIncome": "其中：资产管理业务手续费收入",
    "fundMgmtIncome": "其中：基金管理业务手续费收入",
    "invConsultIncome": "其中：投资咨询业务收入",
    "riskMgmtIncome": "其中：风险管理业务收入",
    "invMgmtIncome": "其中：投资管理业务收入",
    "otherAgencyIncome": "其中：其他代理业务收入",
    "commExp": "其中：手续费及佣金支出",
    "brokerageExp": "其中：经纪业务手续费支出",
    "invBankExp": "其中：投资银行业务手续费支出",
    "assetMgmtExp": "其中：资产管理业务手续费支出",
    "fundMgmtExp": "其中：基金管理业务手续费支出",
    "invConsultExp": "其中：投资咨询业务支出",
    "riskMgmtExp": "其中：风险管理业务支出",
    "invMgmtExp": "其中：投资管理业务支出",
    "otherAgencyExp": "其中：其他代理业务支出",
    "netProxySecIncome": "其中：代理买卖证券业务净收入",
    "netUnderSecIncome": "其中：证券承销业务净收入",
    "netTrustIncome": "其中：受托客户资产管理业务净收入",
    "netFundMgmtIncome": "其中：基金管理业务手续费净收入",
    "otherOpRev2": "其他收入",
    "specialItemsOpRev": "营业收入特殊项目",
    "adjItemsOpRev": "营业收入调整项目",
    "totalOpCost": "二、营业总成本",
    "opPayout": "营业支出",
    "insCommExp": "保险手续费及佣金支出",
    "refundPrem": "退保金",
    "provInsRes": "提取保费准备金",
    "provFutRiskRes": "提取期货风险准备金",
    "provGuaranteeBizRes": "提取担保业务准备金",
    "provGuaranteeCompRes": "提取担保赔偿准备金",
    "netClaims": "赔付支出净额",
    "grossClaimsPaid": "其中：赔付支出",
    "lessReinsRecovClaims": "其中：减：摊回赔付支出",
    "netProvInsLiab": "提取保险责任准备金净额",
    "provInsLiab": "其中：提取保险责任准备金",
    "lessReinsRecovRes": "其中：减：摊回保险责任准备金",
    "policyDivPayout": "保单红利支出",
    "insServiceExp": "保险服务费用",
    "reinsCostAlloc": "分出保费的分摊",
    "lessReinsRecovServiceExp": "减：摊回保险服务费用",
    "underwritingFinLoss": "承保财务损失",
    "lessCededReinsFinInc": "减：分出再保险财务收益",
    "reinsCommExp": "分保费用",
    "otherOpCost": "其他成本",
    "opCost": "营业成本",
    "salesCost": "其中：主营业务成本",
    "otherOpCost2": "其中：其他业务成本",
    "opTaxSurcharges": "营业税金及附加",
    "salesExp": "销售费用",
    "totalAdminExp": "管理费用合计",
    "adminExp": "其中：管理费用",
    "opAdminExp": "其中：业务及管理费",
    "lessAmortReinsCost": "其中：减：摊回分保费用",
    "rdExp": "研发费用",
    "finExp": "财务费用",
    "finIntExp": "其中：利息费用（财务费用）",
    "finIntIncome": "其中：利息收入（财务费用）",
    "explorationCost": "勘探费用",
    "creditImpairLossCost": "信用减值损失（成本）",
    "assetImpairLossCost": "资产减值损失（成本）",
    "specialItemsOpCost": "营业成本特殊项目",
    "adjItemsOpCost": "营业成本调整项目",
    "nonOpNetIncome": "三、非经营性净收益",
    "otherNonOpIncome": "其他收益",
    "invIncome": "投资净收益",
    "invIncomeAssoc": "其中：对联营合营企业的投资收益",
    "gainDerecogAmortCost": "其中：以摊余成本计量的金融资产终止确认收益",
    "fxGain": "汇兑收益",
    "netOpenHedgeGain": "净敞口套期收益",
    "fvChangeGain": "公允价值变动净收益",
    "creditImpairLossProfit": "信用减值损失（利润）",
    "assetImpairLossProfit": "资产减值损失（利润）",
    "gainAssetDisposal": "资产处置收益",
    "specialItemsOpProfit": "营业利润特殊项目",
    "adjItemsOpProfit": "营业利润调整项目",
    "opProfit": "四、营业利润",
    "addNonopIncome": "加：营业外收入",
    "lessNonopExp": "减：营业外支出",
    "netLossDispNonCurrAssets": "其中：非流动资产处置净损失",
    "specialItemsTotalProfit": "利润总额特殊项目",
    "adjItemsTotalProfit": "利润总额调整项目",
    "totalProfit": "五、利润总额",
    "lessIncTaxExp": "减：所得税费用",
    "addUnrecogInvLoss": "加：未确认的投资损失",
    "specialItemsNetProfit": "净利润特殊项目",
    "adjItemsNP": "净利润调整项目",
    "netProfit": "六、净利润",
    "byContOps": "（一）按经营持续性分类",
    "profitContOps": "持续经营净利润",
    "profitDiscOps": "终止经营净利润",
    "byOwnership": "（二）按所有权归属分类",
    "netProfitAttrParent": "归属于母公司所有者的净利润",
    "netProfitAttrOrdShare": "其中：归属于母公司普通股股东的净利润",
    "netProfitAttrOtherEq": "其中：归属于母公司其他权益工具持有者的净利润",
    "netProfitAttrNoncontrol": "少数股东损益",
    "specialItemsNetProfitParent": "母公司净利润特殊项目",
    "adjItemsNetProfitParent": "母公司净利润调整项目",
    "otherCompIncome": "七、其他综合收益的税后净额",
    "OCIParentOwners": "归属于母公司所有者的其他综合收益的税后净额",
    "OCINotReclassToPL": "（一）以后不能重分类进损益的其他综合收益",
    "remeasureDefBenefit": "1.1 重新计量设定受益计划净负债或净资产的变动",
    "shareOCINotReclass": "1.2 权益法下在被投资单位不能重分类进损益的其他综合收益",
    "fvChangeOtherEqInstr": "1.3 其他权益工具投资公允价值变动",
    "ownCreditRiskFvChange": "1.4 企业自身信用风险公允价值变动",
    "finVarInsContrNotReclass": "1.5 不能转损益的保险合同金融变动",
    "OCIReclassToPL": "（二）以后将重分类进损益的其他综合收益",
    "shareOCIReclass": "2.1 权益法下在被投资单位以后将重分类进损益的其他综合收益",
    "fvChangeOtherDebtInv": "2.2 其他债权投资公允价值变动",
    "reclassFinAssetsToOCI": "2.3 金融资产重分类计入其他综合收益的金额",
    "creditImpairOtherDebtInv": "2.4 其他债权投资信用减值准备",
    "effectivePartCashFlowHedge": "2.5 现金流量套期损益的有效部分",
    "fxTransDiff": "2.6 外币财务报表折算差额",
    "fvGainLossAFS": "2.7 可供出售金融资产公允价值变动损益",
    "reclassHTMtoAFS": "2.8 持有至到期投资重分类为可供出售金融资产损益",
    "finVarInsContrReclass": "2.9 可转损益的保险合同金融变动",
    "finVarReinsContrReclass": "3.0 可转损益的分出再保险合同金融变动",
    "otherReclassOCI": "3.1 其他（以后能重分类进损益表的其他综合收益）",
    "OCIAttrNoncontrol": "归属于少数股东的其他综合收益的税后净额",
    "adjItemsOtherCompIncome": "综合收益总额调整项目",
    "specialItemsOtherCompIncome": "综合收益总额特殊项目",
    "totalCompIncome": "八、综合收益总额",
    "compIncomeAttrParent": "归属于母公司所有者的综合收益总额",
    "compIncomeAttrOrdShare": "其中：归属于母公司普通股股东的综合收益",
    "compIncomeAttrOtherEq": "其中：归属于母公司其他权益工具持有者的综合收益",
    "compIncomeAttrNoncontrol": "归属于少数股东的综合收益总额",
    "adjItemsCompIncParent": "母公司综合收益总额调整项目",
    "basicEPS": "基本每股收益",
    "dilutedEPS": "稀释每股收益",
}

BALANCE_FIELD_CN = {
    "securityCode": "证券代码",
    "companyName": "证券简称",
    "endDate": "财报截止日期",
    "fiscalYear": "财报年",
    "period": "报告期",
    "reportType": "报表类型",
    "companyType": "企业报表格式",
    "currency": "币种",
    "unit": "单位",
    "currAssets": "流动资产",
    "monetaryAssets": "货币资金/现金及存放中央银行款项",
    "cash": "其中：货币资金",
    "clientDeposit": "其中：客户资金存款",
    "depositCentralBank": "其中：现金及存放中央银行款项",
    "settleReserve": "结算备付金",
    "clientFundReserve": "其中：客户备付金",
    "fundsLent": "拆出资金",
    "fundsForFinancing": "融出资金",
    "depositInterbank": "存放同业款项",
    "depositAssoc": "存放联行款项",
    "preciousMetals": "贵金属",
    "tradingAssets": "交易性金融资产合计",
    "tradingFinAssets": "其中：交易性金融资产",
    "finAssetsFVTPL": "其中：以公允价值计量且其变动计入当期损益的金融资产",
    "derivAssets": "衍生金融资产",
    "marginDeposited": "存出保证金",
    "notesAcctsRecv": "应收票据及应收账款",
    "notesReceivable": "其中：应收票据",
    "acctsReceivable": "其中：应收账款",
    "recvFinancing": "应收款项融资",
    "advPay": "预付款项",
    "insReceivables": "应收保费",
    "recvSubrogation": "应收代位追偿款",
    "reinsReceivables": "应收分保账款",
    "recvReinsReserves": "应收分保合同准备金",
    "recvReinsUnearnedRes": "其中：应收分保未到期责任准备金",
    "recvReinsClaimsRes": "其中：应收分保未决赔款准备金",
    "recvReinsLifeRes": "其中：应收分保寿险责任准备金",
    "recvReinsHealthRes": "其中：应收分保长期健康险责任准备金",
    "otherRecvIncIntDiv": "其他应收款（含利息和股利）",
    "otherReceivable": "其中：其他应收款",
    "dividendReceivable": "其中：应收股利",
    "interestReceivable": "其中：应收利息",
    "finLeaseRecv": "应收融资租赁款",
    "receivables": "应收款项",
    "cashMarginRecv": "应收货币保证金",
    "pledgeMarginRecv": "应收质押保证金",
    "settleGuaranteeRecv": "应收结算担保金",
    "riskLossRecv": "应收风险损失款",
    "feesCommRecv": "应收手续费及佣金",
    "reverseRepoAssets": "买入返售金融资产",
    "inventory": "存货",
    "consumableBioAssets": "其中：消耗性生物资产",
    "dataResourceInv": "其中：数据资源（存货）",
    "contractAssets": "合同资产",
    "insContractAssets": "保险合同资产",
    "reinsContractAssets": "分出再保险合同资产",
    "assetsHeldForSale": "持有待售资产",
    "agencyAssets": "代理业务资产",
    "prepayDeferredExp": "待摊费用",
    "policyholderPledgeLoan": "保户质押贷款",
    "nonCurrAssetsDue1Yr": "一年内到期的非流动资产",
    "otherCurrAssets": "其他流动资产",
    "specItemsCurrAssets": "流动资产特殊项目",
    "adjItemsCurrAssets": "流动资产调整项目",
    "totalCurrAssets": "流动资产合计",
    "nonCurrAssets": "非流动资产",
    "loansAdvances": "发放贷款和垫款",
    "totalDebtInv": "债权投资合计",
    "debtInvestments": "其中：债权投资",
    "finAssetsAmortCost": "其中：以摊余成本计量的金融资产",
    "totalOtherDebtInv": "其他债权投资合计",
    "otherDebtInvestments": "其中：其他债权投资",
    "finInvFVOCI": "其中：以公允价值计量且其变动计入其他综合收益的金融投资",
    "invLoansRecv": "投资-贷款及应收款项（应收款项类投资）",
    "timeDep": "定期存款",
    "totalOtherEquityInv": "其他权益工具投资合计",
    "otherEquityInv": "其中：其他权益工具投资",
    "nonTradeEquityFVOCI": "其中：以公允价值计量且其变动计入其他综合收益的非交易性权益工具投资",
    "finAssetsFVOCI": "以公允价值计量且其变动计入其他综合收益的金融资产",
    "htmInvestments": "持有至到期投资",
    "afsFinAssets": "可供出售金融资产",
    "otherNonCurrFinAssets": "其他非流动金融资产",
    "entrustedLoans": "委托贷款",
    "ltReceivables": "长期应收款",
    "ltEquityInvest": "长期股权投资",
    "capitalMarginDep": "存出资本保证金",
    "investmentProp": "投资性房地产",
    "totalPPE": "固定资产合计",
    "ppe": "其中：固定资产",
    "ppeDisposal": "其中：固定资产清理",
    "totalCIP": "在建工程合计",
    "cip": "其中：在建工程",
    "constrMaterials": "其中：工程物资",
    "prodBioAssets": "生产性生物资产",
    "pubWelfareBioAssets": "公益性生物资产",
    "oilGasAssets": "油气资产",
    "rouAssets": "使用权资产",
    "intangAssets": "无形资产",
    "tradingSeatFee": "其中：交易席位费",
    "dataResourceIntang": "其中：数据资源（无形资产）",
    "devExp": "开发支出",
    "dataResourceDevExp": "其中：数据资源（开发支出）",
    "goodwill": "商誉",
    "ltDeferredExp": "长期待摊费用",
    "sepAcctAssets": "独立账户资产",
    "deferredTaxAssets": "递延所得税资产",
    "assetsInLieu": "抵债资产",
    "futuresMembershipInv": "期货会员资格投资",
    "otherNonCurrAssets": "其他非流动资产",
    "specItemsNonCurrAssets": "非流动资产特殊项目",
    "adjItemsNonCurrAssets": "非流动资产调整项目",
    "totalNonCurrAssets": "非流动资产合计",
    "otherAssets": "其他资产",
    "finInvestments": "金融投资",
    "otherAssetsMisc": "其他资产",
    "specItemsAssets": "资产特殊项目",
    "adjItemsAssets": "资产调整项目",
    "totalAssets": "资产总计",
    "currLiab": "流动负债",
    "stBorrowings": "短期借款",
    "pledgedBorrowings": "其中：质押借款",
    "stFinancingPay": "应付短期融资款",
    "stBondsPay": "应付短期债券",
    "borrowCentralBank": "向中央银行借款",
    "fundsBorrowed": "拆入资金",
    "totalTradingFinLiab": "交易性金融负债合计",
    "tradingFinLiab": "其中：交易性金融负债",
    "finLiabFVTPL": "其中：以公允价值计量且其变动计入当期损益的金融负债",
    "derivFinLiab": "衍生金融负债",
    "notesAcctsPay": "应付票据及应付账款",
    "notesPayable": "其中：应付票据",
    "acctsPayable": "其中：应付账款",
    "advFromCust": "预收款项",
    "unearnedPremRes": "预收保费",
    "contractLiab": "合同负债",
    "insContractLiab": "保险合同负债",
    "reinsCededLiab": "分出再保险合同负债",
    "repoLiab": "卖出回购金融资产款",
    "depInterbankDep": "吸收存款及同业存款",
    "custDeposits": "其中：吸收存款",
    "interbankDep": "其中：同业及其他金融机构存放款项",
    "dueToAffiliates": "联行存放款项",
    "clientBrokeragePay": "代理买卖证券款",
    "underwritingSecPay": "代理承销证券款",
    "empBenefitsPay": "应付职工薪酬",
    "taxPayable": "应交税费",
    "otherPayIncIntDiv": "其他应付款（含利息和股利）",
    "otherPayable": "其中：其他应付款",
    "dividendPayable": "其中：应付股利",
    "interestPayable": "其中：应付利息",
    "payables": "应付款项",
    "feesCommPay": "应付手续费及佣金",
    "cashMarginPay": "应付货币保证金",
    "pledgeMarginPay": "应付质押保证金",
    "futuresInvestorFundPay": "应付期货投资者保障基金",
    "reinsPayable": "应付分保账款",
    "agencyLiab": "代理业务负债",
    "liabHeldForSale": "持有待售负债",
    "claimsPay": "应付赔付款",
    "policyholderDivPay": "应付保单红利",
    "policyholderDepInvFund": "保户储金及投资款",
    "insContractReserves": "保险合同准备金",
    "marginReceived": "存入保证金",
    "accruedExp": "预提费用",
    "deferredIncome": "递延收益",
    "guarCompensReserve": "担保赔偿准备金",
    "guarBusinessReserve": "担保业务准备金",
    "futuresRiskReserve": "期货风险准备金",
    "nonCurrLiabDue1Yr": "一年内到期的非流动负债",
    "otherCurrLiab": "其他流动负债",
    "specItemsCurrLiab": "流动负债特殊项目",
    "adjItemsCurrLiab": "流动负债调整项目",
    "totalCurrLiab": "流动负债合计",
    "nonCurrLiab": "非流动负债",
    "ltInsContractReserves": "长期保险合同准备金",
    "unearnedPremResLt": "其中：未到期责任准备金",
    "outstandingClaimsRes": "其中：未决赔款准备金",
    "lifeInsLiabRes": "其中：寿险责任准备金",
    "ltHealthRes": "其中：长期健康险责任准备金",
    "ltBorrowings": "长期借款",
    "bondsPay": "应付债券",
    "prefSharesBonds": "其中：优先股（应付债券）",
    "perpetualBonds": "其中：永续债（应付债券）",
    "leaseLiab": "租赁负债",
    "totalLtPayables": "长期应付款合计",
    "ltPayables": "其中：长期应付款",
    "specificPayables": "其中：专项应付款",
    "finLeasePay": "应付融资租赁款",
    "ltEmpBenefitsPay": "长期应付职工薪酬",
    "provisions": "预计负债",
    "ltDeferredIncome": "长期递延收益",
    "sepAcctLiab": "独立账户负债",
    "deferredTaxLiab": "递延所得税负债",
    "otherNonCurrLiab": "其他非流动负债",
    "specItemsNonCurrLiab": "非流动负债特殊项目",
    "adjItemsNonCurrLiab": "非流动负债调整项目",
    "totalNonCurrLiab": "非流动负债合计",
    "otherLiab": "其他负债",
    "otherLiabMisc": "其他负债",
    "specItemsLiab": "负债特殊项目",
    "adjItemsLiab": "负债调整项目",
    "totalLiab": "负债合计",
    "equity": "所有者权益（或股东权益）",
    "shareCapital": "实收资本（或股本）",
    "otherEquityInstr": "其他权益工具",
    "prefSharesEquity": "其中：优先股（其他权益工具）",
    "perpetualBondsEquity": "其中：永续债（其他权益工具）",
    "capReserve": "资本公积",
    "lessTreasuryShares": "减：库存股",
    "specReserve": "专项储备",
    "oci": "其他综合收益",
    "surplusReserve": "盈余公积",
    "genRiskProv": "一般风险准备",
    "tradingRiskProv": "交易风险准备",
    "otherReserves": "其他储备（公允价值变动储备）",
    "retainedEarn": "未分配利润",
    "fxTransDiff": "外币报表折算差额",
    "unrecogInvLoss": "未确认投资损失",
    "specItemsParentEq": "归属母公司所有者权益特殊项目",
    "adjItemsParentEq": "归属母公司所有者权益调整项目",
    "totalParentEq": "归属母公司所有者权益合计",
    "parentOrdinaryEq": "其中：归属于母公司普通股股东权益",
    "nonControllingInterests": "少数股东权益",
    "specItemsTotalEq": "所有者权益特殊项目",
    "adjItemsTotalEq": "所有者权益调整项目",
    "totalEquity": "所有者权益（或股东权益）合计",
    "liabAndEquity": "负债和所有者权益",
    "specItemsLAndE": "负债和权益特殊项目",
    "adjItemsLAndE": "负债和权益调整项目",
    "totalLAndE": "负债和所有者权益（或股东权益）总计",
}

CASH_FLOW_FIELD_CN = {
    "securityCode": "证券代码",
    "companyName": "证券简称",
    "endDate": "财报截止日期",
    "fiscalYear": "财报年",
    "period": "报告期",
    "reportType": "报表类型",
    "companyType": "企业报表格式",
    "currency": "币种",
    "unit": "单位",
    "cashFromSales": "销售商品、提供劳务收到的现金",
    "netIncCustDeposits": "客户存款和同业存放款项净增加额",
    "netIncBorrowFromCentralBank": "向中央银行借款净增加额",
    "netIncBorrowFromOtherFinInst": "向其他金融机构拆入资金净增加额",
    "netDecCustomerLoans": "客户贷款及垫款净减少额",
    "netDecDepositsCentralBank": "存放中央银行和同业款项净减少额",
    "cashFromInsurancePremiums": "收到原保险合同保费取得的现金",
    "netCashFromReinsurance": "收到再保业务现金净额",
    "netIncPolicyholderDeposits": "保户储金及投资款净增加额",
    "netIncBorrowedFunds": "拆入资金净增加额",
    "netDecLoanedFunds": "拆出资金净减少额",
    "netDecReverseRepoFundsOp": "返售业务资金净减少额（经营）",
    "netIncRepoFunds": "回购业务资金净增加额",
    "netCashFromAgencyTrading": "代理买卖证券收到的现金净额",
    "netCashFromAgencyUnderwriting": "代理承销证券收到的现金净额",
    "netIncDisposalFVTOCI": "处置交易性金融资产净增加额",
    "netDecTradingFinancialAssets": "为交易目的而持有的金融资产净减少额",
    "netIncTradingFinancialLiabs": "为交易目的而持有的金融负债净增加额",
    "cashFromInterestFeesComms": "收取利息、手续费及佣金的现金",
    "refundOfTaxes": "收到的税费返还",
    "recoveryOfWrittenOffLoans": "收回已核销贷款",
    "netDecLentFunds": "融出资金净减少额",
    "cashFromOtherOps": "收到其他与经营活动有关的现金",
    "specItemsOpInflows": "经营活动现金流入特殊项目",
    "adjItemsOpInflows": "经营活动现金流入调整项目",
    "subtotalOpInflows": "经营活动现金流入小计",
    "cashPaidForGoodsServices": "购买商品、接受劳务支付的现金",
    "netDecCustDeposits": "客户存款和同业存放款项净减少额",
    "netDecBorrowFromCentralBank": "向中央银行借款净减少额",
    "netDecBorrowFromOtherFinInst": "向其他金融机构拆入资金净减少额",
    "netIncCustomerLoans": "客户贷款及垫款净增加额",
    "netIncDepositsCentralBank": "存放中央银行和同业款项净增加额",
    "cashPaidInsuranceClaims": "支付原保险合同赔付款项的现金",
    "cashPaidPolicyDividends": "支付保单红利的现金",
    "netCashPaidReinsurance": "支付再保业务现金净额",
    "netIncPolicyLoans": "保单质押贷款净增加额",
    "netDecPolicyholderDeposits": "保户储金及投资款净减少额",
    "netDecBorrowedFunds": "拆入资金净减少额",
    "netIncLoanedFunds": "拆出资金净增加额",
    "netIncReverseRepoFundsOp": "返售业务资金净增加额（经营）",
    "netDecRepoFunds": "回购业务资金净减少额",
    "netCashPaidAgencyTrading": "代理买卖证券支付的现金净额",
    "netCashPaidAgencyUnderwriting": "代理承销证券支付的现金净额",
    "netDecDisposalFVTOCI": "处置交易性金融资产净减少额",
    "netIncTradingFinancialAssets": "为交易目的而持有的金融资产净增加额",
    "netDecTradingFinancialLiabs": "为交易目的而持有的金融负债净减少额",
    "cashPaidInterestFeesComms": "支付利息、手续费及佣金的现金",
    "cashPaidEmployees": "支付给职工以及为职工支付的现金",
    "cashPaidTaxes": "支付的各项税费",
    "businessAdminExpensesPaid": "以现金支付的业务及管理费",
    "netIncLentFunds": "融出资金净增加额",
    "cashPaidOtherOps": "支付其他与经营活动有关的现金",
    "specItemsOpOutflows": "经营活动现金流出特殊项目",
    "adjItemsOpOutflows": "经营活动现金流出调整项目",
    "subtotalOpOutflows": "经营活动现金流出小计",
    "adjItemsNetOpCashFlows": "经营活动现金流量净额调整项目",
    "netOpCashFlows": "经营活动产生的现金流量净额",
    "opCashFlows": "一、经营活动产生的现金流量",
    "invCashFlows": "二、投资活动产生的现金流量",
    "cashRecoveredInvestments": "收回投资收到的现金",
    "cashFromReturnsInvestments": "取得投资收益收到的现金",
    "netCashDisposalAssets": "处置固定资产、无形资产和其他长期资产收回的现金净额",
    "netCashDisposalSubsidiaries": "处置子公司及其他营业单位收到的现金净额",
    "cashFromOtherInv": "收到其他与投资活动有关的现金",
    "specItemsInvInflows": "投资活动现金流入特殊项目",
    "adjItemsInvInflows": "投资活动现金流入调整项目",
    "subtotalInvInflows": "投资活动现金流入小计",
    "cashPaidAcqConstructAssets": "购建固定资产、无形资产和其他长期资产支付的现金",
    "cashPaidInvestments": "投资支付的现金",
    "netIncSecuredLoans": "质押贷款净增加额",
    "netCashPaidAcqSubsidiaries": "取得子公司及其他营业单位支付的现金净额",
    "netIncReverseRepoFundsInv": "返售业务资金净增加额（投资）",
    "cashPaidOtherInv": "支付其他与投资活动有关的现金",
    "specItemsInvOutflows": "投资活动现金流出特殊项目",
    "adjItemsInvOutflows": "投资活动现金流出调整项目",
    "subtotalInvOutflows": "投资活动现金流出小计",
    "adjItemsNetInvCashFlows": "投资活动现金流量净额调整项目",
    "netInvCashFlows": "投资活动产生的现金流量净额",
    "finCashFlows": "三、筹资活动产生的现金流量",
    "cashFromCapitalContributions": "吸收投资收到的现金",
    "cashFromMinorityShareholders": "其中：子公司吸收少数股东投资收到的现金",
    "cashFromBorrowings": "取得借款收到的现金",
    "cashFromBondsIssuance": "发行债券收到的现金",
    "cashFromOtherEquityInstruments": "发行其他权益工具收到的现金",
    "netIncRepoFundsFin": "回购业务资金净增加额（筹资）",
    "cashFromOtherFin": "收到其他与筹资活动有关的现金",
    "specItemsFinInflows": "筹资活动现金流入特殊项目",
    "adjItemsFinInflows": "筹资活动现金流入调整项目",
    "subtotalFinInflows": "筹资活动现金流入小计",
    "cashPaidDebtRepayment": "偿还债务支付的现金",
    "cashPaidDividendsInterest": "分配股利、利润或偿付利息支付的现金",
    "cashPaidToMinorityShareholders": "其中：子公司支付给少数股东的股利、利润或偿付的利息",
    "netDecRepoFundsFin": "回购业务资金净减少额（筹资）",
    "cashPaidOtherFin": "支付其他与筹资活动有关的现金",
    "specItemsFinOutflows": "筹资活动现金流出特殊项目",
    "adjItemsFinOutflows": "筹资活动现金流出调整项目",
    "subtotalFinOutflows": "筹资活动现金流出小计",
    "adjItemsNetFinCashFlows": "筹资活动现金流量净额调整项目",
    "netFinCashFlows": "筹资活动产生的现金流量净额",
    "cashEquivalents": "四、现金及现金等价物",
    "fxEffectOnCash": "汇率变动对现金及现金等价物的影响",
    "otherAccEffectCash": "影响现金及现金等价物的其他科目",
    "adjItemsEffectCash": "影响现金及现金等价物的调整项目",
    "cashEquivalentsIncrease": "五、现金及现金等价物净增加额",
    "netIncCashEquivalents": "现金及现金等价物净增加额",
    "addOpeningCashBalance": "加：期初现金及现金等价物余额",
    "specItemsNetIncCash": "现金及现金等价物净增加额的特殊项目",
    "adjItemsNetIncCash": "现金及现金等价物净增加额的调整项目",
    "closingCashBalance": "期末现金及现金等价物余额",
    "reconciliationToOpCashFlows": "1. 将净利润调节为经营活动现金流量",
    "netProfit": "净利润",
    "netProfitToParent": "其中：归属于母公司所有者的净利润",
    "profitLossToMinority": "其中：少数股东损益",
    "addAssetImpairmentLoss": "加：资产减值准备",
    "depAmortFixedAssets": "固定资产折旧、油气资产折耗、生产性生物资产等资产折旧/摊销",
    "depProductBioAssets": "其中：生产性生物资产折旧",
    "depAmortInvProperties": "投资性房地产折旧/摊销",
    "amortDepRightOfUseAssets": "使用权资产摊销/折旧",
    "amortIntangibleAssets": "无形资产摊销",
    "amortLongTermDeferredExp": "长期待摊费用摊销",
    "decreasePrepaidExp": "待摊费用减少（减：增加）",
    "increaseAccruedExp": "预提费用增加（减：减少）",
    "lossOnDisposalAssets": "处置固定资产、无形资产和其他长期资产的损失",
    "lossOnRetirementFixedAssets": "固定资产报废损失",
    "lossOnFVChanges": "公允价值变动损失",
    "financeExpenses": "财务费用",
    "interestIncome": "利息收入",
    "interestExpLeaseLiabs": "其中：租赁负债利息支出",
    "interestExpBondsIssued": "其中：发行债券利息支出",
    "exchangeGainLoss": "汇兑损失（收益以 \"-\" 号填列）",
    "interestExpense": "利息支出",
    "investmentLoss": "投资损失",
    "increaseRestrictedCash": "受限资金的增加",
    "increaseSpecialReserve": "专项储备增加",
    "creditImpairmentLoss": "信用减值损失",
    "amortDeferredIncome": "递延收益摊销",
    "increaseProvisions": "预计负债的增加（减：减少）",
    "deferredRevenueCredits": "递延税款贷项（减：借项）",
    "decreaseDeferredTaxAssets": "递延所得税资产减少",
    "increaseDeferredTaxLiabs": "递延所得税负债增加",
    "decreaseInventories": "存货的减少",
    "shareBasedPaymentExp": "股份支付费用",
    "decreaseFVTPLAssets": "交易性金融资产的减少",
    "decreaseAFSAssets": "可供出售金融资产的减少",
    "decreaseLoans": "贷款的减少",
    "decreaseOpReceivables": "经营性应收项目的减少",
    "increaseOpPayables": "经营性应付项目的增加",
    "other": "其他",
    "noteSpecItemsNetOpCashFlows": "（附注）经营活动现金流量净额特殊项目",
    "noteAdjItemsNetOpCashFlows": "（附注）经营活动现金流量净额调整项目",
    "noteNetOpCashFlows": "（附注）经营活动产生的现金流量净额",
    "addAdjItemsComparisonOpCashFlows": "加：经营流量净额前后对比调整项目",
    "nonCashInvFinActivities": "2. 不涉及现金收支的投资和筹资活动",
    "conversionDebtToCapital": "债务转为资本",
    "convBondsDueWithinOneYear": "一年内到期的可转换公司债券",
    "financeLeaseFixedAssets": "融资租入固定资产",
    "netChangeCashEquivalents": "3. 现金及现金等价物净变动情况",
    "closingBalanceCash": "现金的期末余额",
    "lessOpeningBalanceCash": "减：现金的期初余额",
    "addClosingBalanceEquivalents": "加：现金等价物的期末余额",
    "lessOpeningBalanceEquivalents": "减：现金等价物的期初余额",
    "noteSpecItemsCash": "（附注）现金特殊项目",
    "noteAdjItemsCash": "（附注）现金调整项目",
    "noteNetIncCashEquivalents": "（附注）现金及现金等价物净增加额",
    "addAdjItemsComparisonNetCash": "加：现金净额前后对比调整项目",
}

OPENAPI_SKILL_VERSION = "1.4.3"
SKILL_CHECK_URL = "https://open.gangtise.com/application/skills-backend/version?skill=openapi"

def check_version(large_version: bool = True):
    response = requests.get(SKILL_CHECK_URL)
    if response.status_code == 200 and large_version:
        return response.json()["state"] == "success" and response.json()["version"].split(".")[0] == OPENAPI_SKILL_VERSION.split(".")[0] and response.json()["version"].split(".")[1] == OPENAPI_SKILL_VERSION.split(".")[1]
    elif response.status_code == 200 and not large_version:
        return response.json()["state"] == "success" and response.json()["version"] == OPENAPI_SKILL_VERSION
    else:
        return False

if __name__ == "__main__":
    print("检查 gangtise-file 相关配置")
    if not os.path.exists(GTS_AUTHORIZATION_PATH):
        print("  无法检测到gangtise授权文件, gangtise-file 无法正常工作")
    elif GTS_AUTHORIZATION is None:
        print("  授权文件存在, 但无法获取gangtise授权, 请检查授权文件内容中是否含有 long-term-token 或者 accessKey 和 secretAccessKey, gangtise-file 无法正常工作")
    else:
        print("  检测到gangtise授权文件, gangtise-file 可以正常工作")
    if GTS_SAVE_FILE is None:
        print("  环境变量 GTS_SAVE_FILE 未配置, 默认值为 False, gangtise服务端 将不保存查询结果到文件中")
    elif GTS_SAVE_FILE == "True":
        print("  环境变量 GTS_SAVE_FILE 为 True, gangtise服务端 将保存查询结果到文件中")
    else:
        print("  环境变量 GTS_SAVE_FILE 为 False, gangtise服务端 将不保存查询结果到文件中")
    if check_version(large_version=False):
        print("  gangtise-file 版本为最新")
    else:
        print("  gangtise-file 版本不是最新, 建议进行更新")
    print(f"  gangtise-file 工作文件目录: {gangtise_workspace_path}")

path: scripts/earning_forecast.py
content: import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, timedelta
from io import TextIOWrapper
from typing import Dict, List, Optional, Tuple

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import GTS_AUTHORIZATION, check_version, format_response

EARNING_FORECAST_URL = "https://open.gangtise.com/application/open-fundamental/earning_forecast"

ALL_CONSENSUS_FIELDS: List[str] = [
    "netIncome",
    "netIncomeYoy",
    "eps",
    "pe",
    "bps",
    "pb",
    "peg",
    "roe",
    "ps",
]

CONSENSUS_CN: Dict[str, str] = {
    "netIncome": "归母净利润",
    "netIncomeYoy": "归母净利润同比增速(%)",
    "eps": "每股收益",
    "pe": "市盈率",
    "bps": "每股净资产",
    "pb": "市净率",
    "peg": "PEG",
    "roe": "净资产收益率",
    "ps": "市销率",
}


def _load_security_codes_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _normalize_consensus_list(consensus_list: Optional[List[str]]) -> Tuple[List[str], Optional[str]]:
    if not consensus_list:
        return list(ALL_CONSENSUS_FIELDS), None
    out: List[str] = []
    seen = set()
    for item in consensus_list:
        key = str(item).strip()
        if not key:
            continue
        if key not in ALL_CONSENSUS_FIELDS:
            return [], f"consensus-list 含无效指标: {key}，可选值为 {','.join(ALL_CONSENSUS_FIELDS)}"
        if key not in seen:
            seen.add(key)
            out.append(key)
    return (out if out else list(ALL_CONSENSUS_FIELDS)), None


def _parse_earning_forecast_body(body: dict, consensus_fields: List[str]) -> pd.DataFrame:
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return pd.DataFrame()

    block = body.get("data") or {}
    security_code = block.get("securityCode")
    security_name = block.get("securityName")
    update_list = block.get("updateList") or []
    if not security_code or not update_list:
        return pd.DataFrame()

    records: List[dict] = []
    for update_item in update_list:
        if not isinstance(update_item, dict):
            continue
        d = str(update_item.get("date") or "").strip()
        if not d:
            continue
        field_list = update_item.get("fieldList") or []
        for field_item in field_list:
            if not isinstance(field_item, dict):
                continue
            forecast_year = str(field_item.get("forecastYear") or "").strip()
            if not forecast_year:
                continue
            row = {
                "security_abbr": security_name,
                "security_code": security_code,
                "date": d,
                "预测年份": forecast_year,
            }
            for f in consensus_fields:
                cn = CONSENSUS_CN[f]
                row[cn] = field_item.get(f)
            records.append(row)

    if not records:
        return pd.DataFrame()
    df = pd.DataFrame(records)

    metric_cols = [CONSENSUS_CN[f] for f in consensus_fields]
    for c in metric_cols:
        if c in df.columns:
            df[c] = pd.to_numeric(df[c], errors="coerce").round(4)

    df["date"] = pd.to_datetime(df["date"], errors="coerce").dt.strftime("%Y-%m-%d")
    df = df.dropna(subset=["date"])

    front = ["security_abbr", "security_code", "date", "预测年份"]
    cols = [c for c in front if c in df.columns] + [c for c in metric_cols if c in df.columns]
    cols += [c for c in df.columns if c not in cols]
    df = df[cols]
    return df


def _fetch_one_security_forecast(
    sec: str,
    start_date: str,
    end_date: str,
    consensus_fields: List[str],
    headers: dict,
) -> Tuple[str, pd.DataFrame, bool, Optional[str]]:
    def _request_once(req_start_date: str, req_end_date: str) -> Tuple[pd.DataFrame, Optional[str]]:
        payload = {
            "securityCode": sec,
            "startDate": req_start_date,
            "endDate": req_end_date,
            "consensusList": consensus_fields,
        }
        try:
            r = requests.post(EARNING_FORECAST_URL, headers=headers, json=payload, timeout=120)
            if r.status_code != 200:
                return pd.DataFrame(), f"接口请求失败: HTTP {r.status_code}"
            body = r.json()
            return _parse_earning_forecast_body(body, consensus_fields), None
        except Exception as e:
            return pd.DataFrame(), f"接口请求异常: {e}"

    df, req_err = _request_once(start_date, end_date)
    if req_err:
        return sec, pd.DataFrame(), False, req_err

    fallback_used = False
    # 当目标为单日且无数据时，仅向前回退一天重试一次。
    if df.empty and start_date == end_date:
        try:
            d = pd.to_datetime(start_date, errors="coerce")
            if pd.notna(d):
                prev_date = (d.date() - timedelta(days=1)).strftime("%Y-%m-%d")
                df_prev, prev_err = _request_once(prev_date, prev_date)
                if prev_err:
                    return sec, pd.DataFrame(), False, prev_err
                if not df_prev.empty:
                    df = df_prev
                    fallback_used = True
        except Exception:
            pass

    return sec, df, fallback_used, None


def earning_forecast_data(
    securities: List[str],
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    consensus_list: Optional[List[str]] = None,
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "earning_forecast",
        )

    codes: List[str] = []
    for sec in securities:
        c = _normalize_security_code(sec)
        if c:
            codes.append(c)
    if not codes:
        return format_response(
            {"state": "error", "message": "未找到有效证券代码，请输入完整代码（如 600519.SH）", "data": [], "usage": usage},
            "earning_forecast",
        )

    if not end_date:
        end_date = date.today().strftime("%Y-%m-%d")
    if not start_date:
        start_date = end_date

    consensus_fields, consensus_err = _normalize_consensus_list(consensus_list)
    if consensus_err:
        return format_response(
            {"state": "error", "message": consensus_err, "data": [], "usage": usage},
            "earning_forecast",
        )

    headers = {"Authorization": GTS_AUTHORIZATION}

    frames: List[pd.DataFrame] = []
    fallback_codes: List[str] = []
    request_errors: List[str] = []
    with ThreadPoolExecutor(max_workers=min(len(codes), 8)) as ex:
        futs = [
            ex.submit(
                _fetch_one_security_forecast,
                code,
                start_date,
                end_date,
                consensus_fields,
                headers,
            )
            for code in codes
        ]
        for fut in as_completed(futs):
            sec, df_one, fallback_used, req_err = fut.result()
            if req_err:
                request_errors.append(f"{sec}: {req_err}")
                continue
            if not df_one.empty:
                frames.append(df_one)
            if fallback_used:
                fallback_codes.append(sec)

    if not frames:
        err_msg = "未找到盈利预测数据"
        if request_errors:
            err_msg = "；".join(request_errors)
        return format_response(
            {"state": "error", "message": err_msg, "data": [], "usage": usage},
            "earning_forecast",
        )

    df = pd.concat(frames, ignore_index=True)
    df = df.sort_values(by=["security_code", "date", "预测年份"], ascending=[True, False, True]).reset_index(drop=True)
    df = df.drop(columns=["security_abbr"], errors="ignore")

    parts = [
        {
            "data": df.to_dict(orient="records"),
            "module": "earning_forecast",
            "type": "data",
        }
    ]
    success_securities = "、".join(codes[:3]) + "等" if len(codes) > 3 else "、".join(codes)
    fallback_note = ""
    if fallback_codes:
        show = "、".join(sorted(set(fallback_codes))[:3])
        fallback_note = f"（以下证券当日无数据，已回退前一日：{show}"
        if len(set(fallback_codes)) > 3:
            fallback_note += "等"
        fallback_note += "）"

    return format_response(
        {
            "state": "success",
            "message": f"已找到{success_securities}盈利预测数据{fallback_note}",
            "data": parts,
            "usage": usage,
        },
        "earning_forecast",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询券商盈利预测（open earning_forecast）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    today_str = date.today().strftime("%Y-%m-%d")
    parser.add_argument("--securities", default=None, help="证券代码逗号分隔")
    parser.add_argument("--securities-file", default=None, help="csv 须含 security_code 列")
    parser.add_argument("-sd", "--start-date", default=today_str, help="开始日期 yyyy-MM-dd")
    parser.add_argument("-ed", "--end-date", default=today_str, help="结束日期 yyyy-MM-dd")
    parser.add_argument(
        "--consensus-list",
        default=None,
        help=f"一致预期指标，逗号分隔；可选 {','.join(ALL_CONSENSUS_FIELDS)}；不传默认全部",
    )
    args = parser.parse_args()

    consensus: Optional[List[str]] = None
    if args.consensus_list:
        consensus = [x.strip() for x in args.consensus_list.replace("，", ",").split(",") if x.strip()]

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)

    if not securities:
        parser.error("必须至少提供 --securities 或 --securities-file")

    out = earning_forecast_data(
        securities=securities,
        start_date=args.start_date,
        end_date=args.end_date,
        consensus_list=consensus,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: scripts/shareholder.py
content: import os
import re
import sys
from datetime import date, timedelta
from io import TextIOWrapper
from typing import List, Optional

import pandas as pd
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir not in sys.path:
    sys.path.append(script_dir)

from utils import GTS_AUTHORIZATION, check_version, format_response

GANGTISE_FUNDAMENTAL_DOMAIN = os.getenv(
    "GANGTISE_DOMAIN", "https://open.gangtise.com/application/open-fundamental"
)
TOP_HOLDERS_URL = f"{GANGTISE_FUNDAMENTAL_DOMAIN}/capital-structure/top-holders"

HOLDER_TYPE_MAP = {
    "top10": "top10",
    "top10float": "top10Float",
    "top10Float": "top10Float",
}
PERIOD_MAP = {
    "q1": "q1",
    "interim": "interim",
    "q3": "q3",
    "annual": "annual",
    "latest": "latest",
}


def _load_security_codes_from_file(path: str) -> List[str]:
    full_path = path
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"证券文件不存在: {path}")
    df = pd.read_csv(full_path)
    if "security_code" not in df.columns:
        raise ValueError("证券文件须包含 security_code 列（完整证券代码如 600519.SH）")
    return [str(x) for x in df["security_code"].dropna().tolist()]


def _looks_like_security_code(s: str) -> bool:
    t = s.strip().upper()
    return bool(re.match(r"^(\d{6}|[A-Z]?\d+)\.[A-Z]{2,4}$", t))


def _normalize_security_code(s: str) -> Optional[str]:
    if not s or not str(s).strip():
        return None
    t = str(s).strip()
    if not _looks_like_security_code(t):
        return None
    return t.upper()


def _normalize_holder_type(holder_type: str) -> Optional[str]:
    if not holder_type:
        return None
    key = str(holder_type).strip()
    if key in HOLDER_TYPE_MAP:
        return HOLDER_TYPE_MAP[key]
    low = key.lower()
    return HOLDER_TYPE_MAP.get(low)


def _normalize_periods(period: Optional[List[str]]) -> Optional[List[str]]:
    if not period:
        return ["latest"]
    out: List[str] = []
    for p in period:
        k = str(p).strip().lower()
        if not k:
            continue
        if k in PERIOD_MAP and PERIOD_MAP[k] not in out:
            out.append(PERIOD_MAP[k])
    return out or ["latest"]


def _normalize_fiscal_year(fiscal_year: Optional[List[str]]) -> Optional[List[str]]:
    if not fiscal_year:
        return None
    out: List[str] = []
    for y in fiscal_year:
        ys = str(y).strip()
        if len(ys) == 4 and ys.isdigit() and ys not in out:
            out.append(ys)
    return out or None


def _parse_shareholder_body(body: dict, holder_type: str, security_code: str) -> pd.DataFrame:
    if not body or str(body.get("code", "")) != "000000" or body.get("status") is False:
        return pd.DataFrame()
    block = body.get("data") or {}
    rows = block.get("list") or []
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    if df.empty:
        return pd.DataFrame()

    df.insert(0, "security_code", security_code)
    df.insert(1, "holder_type", holder_type)
    df = df.rename(
        columns={
            "reportPeriod": "date",
            "rank": "rank",
            "shareholderName": "shareholder_name",
            "shareholderType": "shareholder_type",
            "holdingNum": "holding_num",
            "holdingPct": "holding_pct",
            "chgNum": "chg_num",
            "chgPct": "chg_pct",
            "shareCategory": "share_category",
        }
    )
    return df


def _fetch_shareholder(
    headers: dict,
    security_code: str,
    holder_type: str,
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    fiscal_year: Optional[List[str]] = None,
    period: Optional[List[str]] = None,
) -> pd.DataFrame:
    payload = {
        "securityCode": security_code,
        "holderType": holder_type,
        "period": period or ["latest"],
    }
    if start_date:
        payload["startDate"] = start_date
    if end_date:
        payload["endDate"] = end_date
    if fiscal_year:
        payload["fiscalYear"] = fiscal_year

    try:
        r = requests.post(TOP_HOLDERS_URL, headers=headers, json=payload, timeout=120)
        if r.status_code != 200:
            return pd.DataFrame()
        body = r.json()
        return _parse_shareholder_body(body, holder_type=holder_type, security_code=security_code)
    except Exception:
        return pd.DataFrame()


def shareholder_data(
    securities: List[str],
    holder_type: str,
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
    fiscal_year: Optional[List[str]] = None,
    period: Optional[List[str]] = None,
):
    usage: dict = {}
    if GTS_AUTHORIZATION is None:
        return format_response(
            {"state": "error", "message": "未配置 gangtise 授权，无法调用 open 接口", "data": [], "usage": usage},
            "shareholder",
        )

    normalized_holder_type = _normalize_holder_type(holder_type)
    if not normalized_holder_type:
        return format_response(
            {
                "state": "error",
                "message": "holderType 仅支持 top10 或 top10Float",
                "data": [],
                "usage": usage,
            },
            "shareholder",
        )

    fy = _normalize_fiscal_year(fiscal_year)
    periods = _normalize_periods(period)
    headers = {"Authorization": GTS_AUTHORIZATION}

    if not end_date and start_date:
        end_date = date.today().strftime("%Y-%m-%d")
    if not start_date and end_date:
        start_date = (date.fromisoformat(end_date) - timedelta(days=365 * 3)).strftime("%Y-%m-%d")

    securities_codes: List[str] = []
    for sec in securities:
        c = _normalize_security_code(sec)
        if c:
            securities_codes.append(c)
    if not securities_codes:
        return format_response(
            {
                "state": "error",
                "message": "未找到有效证券代码，请仅输入完整代码（如 600519.SH）",
                "data": [],
                "usage": usage,
            },
            "shareholder",
        )

    frames: List[pd.DataFrame] = []
    for code in securities_codes:
        df_one = _fetch_shareholder(
            headers=headers,
            security_code=code,
            holder_type=normalized_holder_type,
            start_date=start_date,
            end_date=end_date,
            fiscal_year=fy,
            period=periods,
        )
        if not df_one.empty:
            frames.append(df_one)

    if not frames:
        return format_response(
            {"state": "error", "message": "未找到股东数据", "data": [], "usage": usage},
            "shareholder",
        )

    data = pd.concat(frames, ignore_index=True)
    if "date" in data.columns:
        data["_dt"] = pd.to_datetime(data["date"], errors="coerce")
        data = data.sort_values(by=["security_code", "_dt", "rank"], ascending=[True, False, True]).reset_index(drop=True)
        data = data.drop(columns=["_dt"], errors="ignore")

    for c in ["holding_num", "holding_pct", "chg_num", "chg_pct"]:
        if c in data.columns:
            data[c] = pd.to_numeric(data[c], errors="coerce")

    success_label = "、".join(securities_codes[:3]) + "等" if len(securities_codes) > 3 else "、".join(securities_codes)
    parts = [{"data": data.to_dict(orient="records"), "module": "top_holders", "type": "data"}]
    return format_response(
        {
            "state": "success",
            "message": f"已找到{success_label}股东数据",
            "data": parts,
            "usage": usage,
        },
        "shareholder",
    )


def main():
    import argparse

    try:
        if not check_version():
            update_sh = os.path.join(script_dir, "update.sh")
            print(f"[WARNING] 存在 Gangtise data 版本更新，可以执行 {update_sh} 更新，请与用户确认是否更新\n")
    except Exception:
        print("[WARNING] 检查 Gangtise data 版本失败\n")

    parser = argparse.ArgumentParser(
        description="查询前十大股东/前十大流通股东（open top-holders）",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("--holder-type", required=True, help="股东类型：top10 或 top10Float")
    parser.add_argument("-sd", "--start-date", default=None, help="开始日期 yyyy-MM-dd")
    parser.add_argument("-ed", "--end-date", default=None, help="结束日期 yyyy-MM-dd")
    parser.add_argument("--fiscal-year", default=None, help="财报年度，逗号分隔，如 2024,2025")
    parser.add_argument(
        "--period",
        default="latest",
        help="报告期：q1/interim/q3/annual/latest，逗号分隔；默认 latest",
    )
    parser.add_argument("--securities", default=None, help="证券代码逗号分隔")
    parser.add_argument("--securities-file", default=None, help="csv 含 security_code 列")
    args = parser.parse_args()

    securities: Optional[List[str]] = None
    if args.securities:
        securities = [x.strip() for x in args.securities.replace("，", ",").split(",") if x.strip()]
    if not securities and args.securities_file:
        try:
            securities = _load_security_codes_from_file(args.securities_file)
        except Exception as e:
            print(f"根据证券文件解析证券失败: {e}")
            sys.exit(1)
    if not securities:
        parser.error("必须至少提供 --securities 或 --securities-file")

    fy: Optional[List[str]] = None
    if args.fiscal_year:
        fy = [x.strip() for x in args.fiscal_year.replace("，", ",").split(",") if x.strip()]
    period_list = [x.strip() for x in args.period.replace("，", ",").split(",") if x.strip()]

    out = shareholder_data(
        securities=securities,
        holder_type=args.holder_type,
        start_date=args.start_date,
        end_date=args.end_date,
        fiscal_year=fy,
        period=period_list if period_list else None,
    )
    print(out)


if __name__ == "__main__":
    encoding = "utf-8"
    sys.stdout = TextIOWrapper(sys.stdout.buffer, encoding=encoding, errors="ignore")
    main()


path: references/main_business.md
content: # 主营构成 API（Open API · main-business）

## 简介

通过 Gangtise Open API 查询上市公司**主营构成**（按产品/行业/地区等指标），脚本 **`scripts/main_business.py`**。

默认拉取文档中的**全部业务指标字段**；`breakdown` 不传时，对 **product、industry、region** 三种维度**并发**请求，合并为一张表，并以列 **「分行业/分产品/分地区」** 区分维度。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`。未指定时默认约为**结束日往前三年**。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`。未指定时默认**今天**。 |
| `--securities` | 否* | **完整证券代码**，逗号分隔。 |
| `--securities-file` | 否* | CSV 须含列 **`security_code`**。 |
| `--breakdown` | 否 | `product` / `industry` / `region`。**不传**则三种维度并发全取。 |
| `--period` | 否 | 仅 **Q2** 或 **Q4**；见上表。不传则不限定报告期。 |

\* 须至少提供 `--securities` 或 `--securities-file` 之一。

## 约束与说明

- **仅支持完整证券代码**。
- 与 skills-backend 版「主营业务」的列名、接口不同，本脚本以 **Open API** 返回字段映射为中文列为主。

## 调用示例

```bash
python3 scripts/main_business.py --securities 000651.SZ
```

```bash
python3 scripts/main_business.py --securities 600519.SH --breakdown product
```

```bash
python3 scripts/main_business.py --securities-file ./codes.csv --period Q4
```

## 返回说明

- **成功**：在 `workspace/gangtise/main_business/` 下生成 **`main_business_*.csv`**。
- **失败**：如未配置授权、证券无效、无数据、`--period` 不是 Q2/Q4 等。

## 返回数据示例（CSV 列）

| security_abbr | security_code | date | 分行业/分产品/分地区 | 报告期 | 主营业务名称 | 营业收入 | … |
|---------------|----------------|------|----------------------|--------|--------------|----------|---|
| … | 000651.SZ | … | 分产品 | … | 空调 | … | … |

（`date` 对应接口中的报告期截止日期字段；具体列以 `fieldList` 与映射为准。）


path: references/valuation.md
content: # 估值数据 API（Open API · 估值分析）

## 简介

通过 Gangtise Open API 查询估值指标（peTtm、psTtm、pbMrq、peg、pcfTtm、em）及对应历史分位，脚本 **`scripts/valuation.py`**。

对同一证券会**并发 6 次**请求（每指标一次），`fieldList` 固定为 `value`、`percentileRank`，再合并为宽表。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`。未指定时默认**今天**。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`。未指定时默认**今天**。 |
| `--securities` | 否* | **完整证券代码**，逗号分隔（如 `600519.SH`）。**不支持简称**。 |
| `--securities-file` | 否* | CSV 路径，须含列 **`security_code`**（完整代码）。 |
| `--limit` | 否 | 单次指标请求最大行数，默认 `2000`（接口上限以官方文档为准）。 |

\* 须至少提供 `--securities` 或 `--securities-file` 之一。

## 约束与说明

- **仅支持完整证券代码**（如 `600519.SH`），不进行键盘精灵或简称解析。
- 默认时间范围为当天（未传 `-sd/-ed` 时由脚本内默认值体现）。
- 本 OpenAPI 脚本**仅输出估值模块**（`module: valuation`），**不包含** skills-backend 版估值中可能附带的盈利预测（profit_forecast）表。

## 调用示例

```bash
python3 scripts/valuation.py --securities 600519.SH
```

```bash
python3 scripts/valuation.py --securities-file ./codes.csv -sd 2023-01-01 -ed 2026-12-31
```

## 返回说明

- **成功**：在 `workspace/gangtise/valuation/`（或当前环境解析出的 gangtise 工作目录下）生成 **`valuation_*.csv`**，返回文案中含绝对路径与样例 Markdown 表。
- **失败**：如未配置授权、证券代码无效、无数据等，返回错误说明字符串。

## 返回数据示例（CSV 列）

列名经 `format_response` 处理后，前几列为固定字段，其余为中文指标名（示例）：

| security_abbr | security_code | date | 市盈率TTM | 市盈率TTM在3年中所处分位 | 市销率TTM | … |
|---------------|---------------|------|------------|----------------------------|------------|---|
| 600519.SH | 600519.SH | 2026-01-01 | … | … | … | … |

（证券简称无单独解析时，可能与代码列一致，以实际输出为准。）


path: references/shareholder.md
content: # 股东结构 API（Open API · top-holders）

## 简介

通过 Gangtise Open API 查询上市公司 **前十大股东** 与 **前十大流通股东** 持股明细，脚本为 `scripts/shareholder.py`。

返回包含股东名称、股东性质、持股数量/比例、较上期变动、股本性质等字段，适用于股东结构分析与机构持仓跟踪。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--holder-type` | 是 | `top10`（前十大股东）或 `top10Float`（前十大流通股东）。 |
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`。仅传开始日期时，结束日期默认今天。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`。仅传结束日期时，开始日期默认向前 3 年。 |
| `--fiscal-year` | 否 | 财报年度，逗号分隔，如 `2024,2025`。 |
| `--period` | 否 | 报告期：`q1` / `interim` / `q3` / `annual` / `latest`，逗号分隔，默认 `latest`。 |
| `--securities` | 否* | 完整证券代码，逗号分隔。 |
| `--securities-file` | 否* | CSV，须含 `security_code` 列。 |

\* 须至少提供 `--securities` 或 `--securities-file` 之一。

## 调用示例

```bash
python3 scripts/shareholder.py --holder-type top10 --securities 600519.SH
```

```bash
python3 scripts/shareholder.py --holder-type top10Float --securities 600519.SH -sd 2025-01-01 -ed 2025-12-31 --period q3
```

```bash
python3 scripts/shareholder.py --holder-type top10 --securities-file ./codes.csv --fiscal-year 2024,2025 --period q1,q3
```

## 返回说明

- 成功时会在 `workspace/gangtise/shareholder/` 下生成 `top_holders_*.csv`。
- CSV 典型字段：
  - `security_code`
  - `holder_type`
  - `date`
  - `rank`
  - `shareholder_name`
  - `shareholder_type`
  - `holding_num`
  - `holding_pct`
  - `chg_num`
  - `chg_pct`
  - `share_category`


path: references/earning_forecast.md
content: # 盈利预测数据调用指导（Open API · earning_forecast）

## 简介

通过 Gangtise Open API 获取券商一致预期盈利预测数据，脚本：`scripts/earning_forecast.py`。

- 支持并发查询多只证券（按证券逐个请求并合并结果）。
- 支持按日期区间筛选：`startDate`、`endDate`。
- 支持按指标筛选：`consensusList`。
- 默认不传指标时返回全部可选字段，并展开为扁平表：
  `security_code | date | 预测年份 | ...`。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--securities` | 否* | 完整证券代码，逗号分隔，如 `600519.SH,000858.SZ`。 |
| `--securities-file` | 否* | CSV 路径，须含 `security_code` 列。 |
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`；默认今天。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`；默认今天。 |
| `--consensus-list` | 否 | 一致预期指标，逗号分隔；不传默认返回全部。 |

\* 须至少提供 `--securities` 或 `--securities-file` 之一。

`--consensus-list` 可选值：

- `netIncome`：归母净利润
- `netIncomeYoy`：归母净利润同比增速（%）
- `eps`：每股收益
- `pe`：市盈率
- `bps`：每股净资产
- `pb`：市净率
- `peg`：PEG
- `roe`：净资产收益率
- `ps`：市销率

## 约束与说明

- 仅支持完整证券代码，不支持简称解析。
- 对于单日查询（`start-date == end-date`），若当天无数据，脚本会自动仅向前回退一天重试一次；不会继续向前回退。

## 调用示例

**并发查询多证券并返回全部字段：**

```bash
python3 scripts/earning_forecast.py --securities 600519.SH,000858.SZ -sd 2026-03-20 -ed 2026-03-25
```

**从文件读取证券并仅返回部分一致预期指标：**

```bash
python3 scripts/earning_forecast.py --securities-file ./codes.csv -sd 2026-03-20 -ed 2026-03-25 --consensus-list netIncome,eps,pe
```

## 返回说明

- 成功时在 `workspace/gangtise/earning_forecast/` 下生成 `earning_forecast_*.csv`。
- 每一行对应「某日 + 某预测年份」的一条记录。
- 字段默认顺序：
  - `security_code`
  - `date`
  - `预测年份`
  - 其余为所选一致预期指标（中文列名）

## 返回数据示例（CSV 结构）

| security_code | date | 预测年份 | 归母净利润 | 每股收益 | 市盈率 |
|---------------|------|----------|------------|----------|--------|
| 600519.SH | 2026-03-25 | 2026E | 1250.50 | 62.50 | 28.60 |
| 600519.SH | 2026-03-25 | 2027E | 1450.80 | 72.50 | 24.80 |
| 600519.SH | 2026-03-25 | 2028E | 1680.20 | 84.00 | 21.50 |


path: references/financial.md
content: # 财务数据调用指导（Open API · financial-report）

## 简介

通过 Gangtise Open API 获取上市公司财务报表数据，支持三类报表：

- 利润表（`income-statement`）
- 资产负债表（`balance-sheet`）
- 现金流量表（`cashflow-statement`）

脚本：`scripts/financial.py`。

- 默认 `fieldList=[]`，表示返回全部科目（以接口返回为准）。
- 会删除数值列全空的行、数值列全空的列，并剔除 `category`、`announcementDate`（若返回）。
- 科目英文名按内置映射转中文；未映射字段保持原名。
- 数值列统一保留两位小数。
- 输出会把 `证券简称`、`证券代码`、`财报截止日期` 规范为 `security_abbr`、`security_code`、`date` 并放在前面。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `-t` / `--table-type` | 否 | 报表类型：`income`/`利润表`、`balance`/`资产负债表`、`cashflow`/`现金流量表`。默认 `income`。 |
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`。与 `endDate` 同时有值时覆盖 `fiscalYear` 的筛选（按接口规则）。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`。 |
| `--fiscal-year` | 否 | 财报年度，逗号分隔，如 `2025,2026`。对应请求体 `fiscalYear`。 |
| `--period` | 否 | 报告期：**Q1 / Q2 / Q3 / Q4 / Q0**，逗号分隔；默认 **Q0**，即最新一期。 |
| `-g` / `--granularity` | 否 | 财报口径：`accumulated`（累计值，默认）/ `quarterly`（单季度值）。仅对 `income`、`cashflow` 生效；`balance` 仅支持 `accumulated`。 |
| `--report-type` | 否 | 报表类型，逗号分隔：`consolidated` / `consolidatedRestated` / `standalone` / `standaloneRestated`。默认 **`consolidated`**。 |
| `--field-list` | 否 | 指定科目**英文字段名**，逗号分隔；**不传**则 `fieldList=[]` 取全部。 |
| `--securities` | 否* | **完整证券代码**，逗号分隔。 |
| `--securities-file` | 否* | CSV 须含列 **`security_code`**。 |

\* 须至少提供 `--securities` 或 `--securities-file` 之一。

## 约束与说明

- 仅支持完整证券代码（如 `600519.SH`）。
- `--period` 仅支持 `Q1/Q2/Q3/Q4/Q0`，会映射到接口 `period`：`q1/interim/q3/annual/latest`。
- `--granularity` 仅支持 `accumulated/quarterly`（兼容中文别名“累计/单季度”）；其中 `quarterly` 仅适用于利润表和现金流量表。
- 报表类型别名支持中英文，例如：`profit/pl/利润表 -> income`、`bs/资产负债表 -> balance`、`cf/现金流量表 -> cashflow`。
- 若某报告期仅部分科目有值，行会保留；整行数值全空会删除。

## 调用示例

**最新一期合并利润表（默认 Q0）：**

```bash
python3 scripts/financial.py --securities 600519.SH
```

**查询资产负债表：**

```bash
python3 scripts/financial.py --table-type balance --securities 600519.SH
```

**查询现金流量表（中文别名）：**

```bash
python3 scripts/financial.py --table-type 现金流量表 --securities 600519.SH
```

**利润表切换为单季度口径（quarterly）：**

```bash
python3 scripts/financial.py --table-type income --granularity quarterly --securities 600519.SH --fiscal-year 2025 --period Q2 --field-list totalOpRev,totalOpCost,netProfit,basicEPS
```

**现金流量表切换为单季度口径（quarterly）：**

```bash
python3 scripts/financial.py --table-type cashflow --granularity quarterly --securities 600519.SH --fiscal-year 2023,2024,2025 --period Q2 --field-list netFinCashFlows
```

**指定年度与三季报：**

```bash
python3 scripts/financial.py --securities 600519.SH --fiscal-year 2025 --period Q3 --report-type consolidated
```

**按日期区间 + 仅年报（Q4）：**

```bash
python3 scripts/financial.py --securities 600519.SH -sd 2024-01-01 -ed 2026-03-24 --period Q4
```

**仅部分科目：**

```bash
python3 scripts/financial.py --securities 600519.SH --field-list netProfit,basicEarningsPerShare
```

**从文件读取证券：**

```bash
python3 scripts/financial.py --securities-file ./codes.csv
```

## 返回说明

- **成功**：在 `workspace/gangtise/financial/` 下生成 **`financial_*.csv`**，返回文案含路径与样例。
- **失败**：授权失败、证券无效、`table-type` 非法、`period` 非法、过滤后无数据等。

## 返回数据示例（CSV 结构）

| security_abbr | security_code | date | 报告期 | 报表类型 | 一、营业总收入 | … | 六、净利润 | … |
|---------------|---------------|------|--------|----------|----------------|---|------------|---|
| 贵州茅台 | 600519.SH | 2022-03-31 | 2022年一季报 | 合并报表 | … | … | … | … |

（中间列为对应报表科目中文名；未映射的英文字段名将原样保留。）


path: references/quote.md
content: # 行情数据 API（Open API · 日K / 分钟K）

## 简介

通过 Gangtise Open API 查询 A/港股**历史日 K**与 A 股**分钟 K**，脚本 **`scripts/quote.py`**。

日 K 默认**前复权**：不再从日 K 接口拉取 `adjustFactor` 字段，改为调用 **`/adjustFactor`**（`QUOTE_ADJUST_FACTOR_URL`）获取复权因子后，在脚本侧计算**开高低收**及**昨收、涨跌额、涨跌幅**的复权序列（列名带 `（前复权）` 或 `（后复权）`）。**不复权**时不请求复权因子接口，输出原始 OHLC 与涨跌字段。

复权因子接口按自然月拆分请求并合并，以降低单次超过 10000 行的风险；若仍不足以覆盖全部分页场景，请缩短日期区间分批拉取。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `-sd` / `--start-date` | 否 | 开始日期 `yyyy-MM-dd`。未指定时默认**今天**。 |
| `-ed` / `--end-date` | 否 | 结束日期 `yyyy-MM-dd`。未指定时默认**今天**。 |
| `--securities` | 否* | **完整证券代码**，逗号分隔。与 `--all-market` **二选一**。 |
| `--securities-file` | 否* | CSV 须含列 **`security_code`**。 |
| `--all-market` | 否 | 不传 `securityList`，按接口约定拉**全市场**（数据量可能极大，请配合日期与 `limit`）。 |
| `--limit` | 否 | 单次 K 线请求最大行数，默认 `5000`，上限 `10000`。复权因子请求同样受上限约束（脚本按月拆分）。 |
| `--type` | 否 | `daily`（默认，日K）或 `minute`（分钟K，仅 A 股，且不支持 `--all-market`）。 |
| `--adjust` | 否 | **仅日 K**。`forward` / `qfq` / `前复权`（默认）、`backward` / `hfq` / `后复权`、`none` / `raw` / `不复权`。分钟 K 忽略复权（勿显式传非 `none`）。 |

\* 指定证券时须提供 `--securities` 或 `--securities-file`；全市场时使用 `--all-market` 且不要同时传证券列表。

## 约束与说明

- **仅支持完整证券代码**，不支持简称解析。
- **前复权**：\(P'(t) = P(t) \times F(t)/F(t_{\text{latest}})\)，其中 \(t_{\text{latest}}\) 为本次结果中该证券**最后交易日**，\(F\) 来自独立 **adjustFactor** 接口。
- **后复权**：\(P'(t) = P(t) \times F(t)/F(t_{\text{earliest}})\)，其中 \(t_{\text{earliest}}\) 为本次结果中该证券**首个交易日**。
- **不复权**：原始行情字段名仍为 `开盘价`、`收盘价` 等（导出列名以脚本为准）。
- `minute` 类型支持 `securities` 并发拉取并聚合；不支持全市场拉取；不支持复权。

## 调用示例

```bash
python3 scripts/quote.py --securities 600519.SH -sd 2026-04-01 -ed 2026-04-23
```

```bash
python3 scripts/quote.py --securities 600519.SH -sd 2026-04-01 -ed 2026-04-23 --adjust backward
```

```bash
python3 scripts/quote.py --securities-file ./codes.csv --limit 8000
```

```bash
python3 scripts/quote.py --type minute --securities 600519.SH,000001.SZ -sd "2026-04-23 10:00:00" -ed "2026-04-23 15:00:00"
```

## 返回说明

- **成功**：在 `workspace/gangtise/quote/` 下生成 **`quote_*.csv`**，返回文案含路径与样例。
- **失败**：如授权失败、无数据、参数错误、复权因子缺失等。

## 返回数据示例（CSV 列）

当前实现会在写出前**去掉 `security_abbr` 列**，仅保留 **`security_code`** 等（以实际脚本为准）。

日 K **前复权**时典型列为：`开盘价（前复权）`、`最高价（前复权）`、`最低价（前复权）`、`收盘价（前复权）`、`昨收价（前复权）`、`涨跌额（前复权）`、`涨跌幅（前复权）`、`成交量`、`成交额` 等。


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