Files
cnfinancialscraper/scripts/market_data_scraper.py

413 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
market_data_scraper.py v1.0 — 市场宏观数据爬虫(v4.7 新增)
============================================================
提供宏观利率类金融数据:
1. LPR 贷款市场报价利率 — 全国银行间同业拆借中心(chinamoney.com.cn)
2. 国债收益率曲线 — 中债登(chinabond.com.cn)
数据源:
- LPR: https://www.chinamoney.com.cn/ags/ms/cm-u-bk-currency/LprHis
- 国债收益率: https://yield.chinabond.com.cn/cbweb-mn/yc/inityc
设计要点:
- 优先 requests,不可用时回退 http_utils(标准库)
- 全部函数异常安全:失败返回空列表 / 空 dict + warning
- 无第三方强依赖,可在缺包环境下 import 与测试
"""
import json
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
try:
import requests as _requests
_HAS_REQUESTS = True
except ImportError:
_HAS_REQUESTS = False
try:
from .http_utils import http_get, http_post, rate_limit
except ImportError:
from http_utils import http_get, http_post, rate_limit # type: ignore
log = logging.getLogger(__name__)
# ==================== 常量 ====================
CHINAMONEY_LPR_API = "https://www.chinamoney.com.cn/ags/ms/cm-u-bk-currency/LprHis"
CHINABOND_YIELD_API = "https://yield.chinabond.com.cn/cbweb-mn/yc/inityc"
# 中国货币网请求头
CM_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.chinamoney.com.cn/",
}
# 中债登请求头
CB_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/javascript, */*",
"X-Requested-With": "XMLHttpRequest",
"Referer": "https://yield.chinabond.com.cn/cbweb-mn/yield_main?locale=zh_CN",
}
# ==================== 工具函数 ====================
def _safe_float(val, default=0.0):
"""安全转换为浮点数。"""
if val is None:
return default
if isinstance(val, str) and val.strip() in ("-", "--", "", "—"):
return default
try:
return float(val)
except (ValueError, TypeError):
return default
def _safe_str(val, default=""):
"""安全转换为字符串。"""
if val is None:
return default
return str(val)
def _normalize_date(date_val) -> str:
"""将各类日期格式规范为 yyyy-MM-dd;None/空 返回空串。"""
if date_val is None:
return ""
d = str(date_val).strip()
if not d:
return ""
d = d.replace("/", "-").replace(".", "-")
# "2026-7-1" → "2026-07-01"
try:
return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%Y-%m-%d")
except ValueError:
return d[:10]
# ==================== LPR 贷款市场报价利率 ====================
def get_lpr_rates(days: int = 365, start_date: str = "",
end_date: str = "") -> List[Dict]:
"""获取 LPR 贷款市场报价利率历史数据。
数据来源: 全国银行间同业拆借中心 LprHis 接口
对应页面: https://www.chinamoney.com.cn/chinese/bklpr/
Args:
days: 返回最近 N 天内的报价记录,默认 365
start_date: 起始日期 yyyy-MM-dd(严格过滤)
end_date: 截止日期 yyyy-MM-dd
Returns:
[
{"date": "2026-07-20", "one_year": 3.00, "five_year": 3.50}, ...
]
"""
page_size = max(1, min(days * 2 + 10, 200))
url = CHINAMONEY_LPR_API
if _HAS_REQUESTS:
try:
resp = _requests.post(
url, json={"pageNum": 1, "pageSize": page_size},
headers=CM_HEADERS, timeout=15,
)
resp.raise_for_status()
data = resp.json()
except Exception as e:
log.error(f"LPR 请求失败: {e}")
return []
else:
try:
from .http_utils import http_post, rate_limit
except ImportError:
from http_utils import http_post, rate_limit # type: ignore
rate_limit(url=url)
body = json.dumps({"pageNum": 1, "pageSize": page_size}).encode("utf-8")
resp = http_post(url, data=body, headers={**CM_HEADERS, "Content-Type": "application/json"}, timeout=15)
if resp is None:
return []
try:
data = json.loads(resp.text)
except Exception:
return []
records = data.get("records") or data.get("data") or []
results = []
for r in records:
date = _normalize_date(_safe_str(r.get("showDateCN", r.get("showDateEN", ""))))
one_year = _safe_float(r.get("1Y"))
five_year = _safe_float(r.get("5Y"))
if not date:
continue
results.append({
"date": date,
"one_year": one_year,
"five_year": five_year,
})
# 去重(同月可能多条),保留最新
seen = {}
for r in results:
seen[r["date"]] = r
results = sorted(seen.values(), key=lambda x: x["date"], reverse=True)
# 严格日期过滤
if start_date:
results = [r for r in results if r["date"] >= start_date]
if end_date:
results = [r for r in results if r["date"] <= end_date]
results = results[:max(1, days // 28 + 2)] if not (start_date or end_date) else results
log.info(f"获取 LPR 利率 {len(results)} 条")
return results
# ==================== 国债收益率曲线 ====================
def get_bond_yield_curve(curve_type: str = "treasury", date: str = "") -> Dict:
"""获取国债收益率曲线。
数据来源: 中债登 yield.chinabond.com.cn inityc 接口
对应页面: https://yield.chinabond.com.cn/cbweb-mn/yield_main
Args:
curve_type: 曲线类型,"treasury"(国债,默认) / "policy"(政策性金融债)
date: 指定日期 yyyy-MM-dd,为空返回最新曲线
Returns:
{
"date": "2026-07-31", # 曲线日期
"curve_type": "中债国债收益率曲线(到期)",
"source": "中债登 chinabond.com.cn",
"terms": {"1Y": 2.15, "2Y": 2.25, ..., "30Y": 3.05},
"spread_10_2": 0.46, # 10年-2年利差
"spread_30_10": 0.34, # 30年-10年利差
"curve_points": [[0.0, 1.15], ...], # 完整曲线点(年, 收益率)
}
"""
xyz = "txy" if curve_type != "policy" else "tpxy"
params = {
"xyzSelect": xyz,
"workTime": "",
"dxbj": "0",
"qxll": "0",
"yqqxN": "N",
"yqqxK": "K",
"wrjxCBFlag": "0",
"locale": "zh_CN",
}
url = CHINABOND_YIELD_API
if _HAS_REQUESTS:
try:
resp = _requests.post(url, data=params, headers=CB_HEADERS, timeout=15)
resp.raise_for_status()
data = resp.json()
except Exception as e:
log.error(f"国债收益率请求失败: {e}")
return {"error": f"请求失败: {e}", "warning": "中债登接口不可用"}
else:
try:
from .http_utils import http_post, rate_limit
except ImportError:
from http_utils import http_post, rate_limit # type: ignore
rate_limit(url=url)
resp = http_post(url, data=params, headers=CB_HEADERS, timeout=15)
if resp is None:
return {"error": "请求失败", "warning": "中债登接口不可用"}
try:
data = json.loads(resp.text)
except Exception:
return {"error": "响应解析失败", "warning": "中债登接口不可用"}
try:
if not isinstance(data, list) or len(data) < 2:
return {"error": "响应结构异常", "warning": "中债登接口返回格式异常"}
curve_obj = data[1][1][0] if len(data) > 1 and isinstance(data[1], list) and data[1] else None
if not isinstance(curve_obj, dict):
return {"error": "曲线数据为空", "warning": "当前日期无曲线数据"}
series = curve_obj.get("seriesData") or []
worktime = _normalize_date(_safe_str(curve_obj.get("worktime", "")))
curve_name = _safe_str(curve_obj.get("ycDefName", "国债收益率曲线"))
except (IndexError, TypeError, KeyError) as e:
log.error(f"国债收益率解析失败: {e}")
return {"error": f"解析失败: {e}", "warning": "中债登接口返回格式异常"}
# 从完整曲线点中提取标准期限收益率(年 → 收益率)
target_years = {"1Y": 1, "2Y": 2, "3Y": 3, "5Y": 5, "7Y": 7, "10Y": 10, "30Y": 30}
terms: Dict[str, float] = {}
for label, year in target_years.items():
# 找到最接近该年限的曲线点
best = None
best_diff = float("inf")
for pt in series:
try:
y, v = float(pt[0]), float(pt[1])
except (TypeError, ValueError, IndexError):
continue
diff = abs(y - year)
if diff < best_diff:
best_diff = diff
best = v
if best is not None and best_diff < 0.05:
terms[label] = round(best, 4)
result = {
"date": worktime,
"curve_type": curve_name,
"source": "中债登 chinabond.com.cn",
"terms": terms,
"curve_points": [[round(float(p[0]), 3), round(float(p[1]), 4)] for p in series if len(p) >= 2],
}
if "2Y" in terms and "10Y" in terms:
result["spread_10_2"] = round(terms["10Y"] - terms["2Y"], 4)
if "10Y" in terms and "30Y" in terms:
result["spread_30_10"] = round(terms["30Y"] - terms["10Y"], 4)
log.info(f"获取国债收益率曲线 {worktime},期限点 {len(terms)} 个")
return result
# ==================== 便捷入口 ====================
def get_market_data(data_type: str, **kwargs) -> Any:
"""市场数据统一便捷入口。
Args:
data_type: "lpr_rates" / "bond_yield_curve" / "macro_cpi" / "macro_ppi"
/ "macro_pmi" / "macro_gdp" / "macro_m2"
**kwargs: 传递给对应函数的参数
Returns:
对应数据(LPR 为列表,国债收益率为 dict,宏观序列为列表)
"""
if data_type == "lpr_rates":
return get_lpr_rates(
days=kwargs.get("days", 365),
start_date=kwargs.get("start_date", ""),
end_date=kwargs.get("end_date", ""),
)
if data_type == "bond_yield_curve":
return get_bond_yield_curve(
curve_type=kwargs.get("curve_type", "treasury"),
date=kwargs.get("date", ""),
)
if data_type.startswith("macro_"):
indicator = data_type[len("macro_"):]
if indicator not in MACRO_REPORTS:
log.error(f"不支持的宏观指标: {indicator},"
f"可选: {'/'.join(MACRO_REPORTS)}")
return {"error": f"不支持的数据类型: {data_type}"}
return get_macro_indicator(indicator, limit=kwargs.get("limit", 24))
log.error(f"不支持的数据类型: {data_type},可选: lpr_rates / bond_yield_curve / "
f"macro_cpi / macro_ppi / macro_pmi / macro_gdp / macro_m2")
return {"error": f"不支持的数据类型: {data_type}"}
# ==================== v7.0.0 宏观经济序列(东财 datacenter 接口)====================
# 东财数据中心宏观报表名(datacenter-web 域名独立于 push2,互不影响)
EM_DATACENTER_API = "https://datacenter-web.eastmoney.com/api/data/v1/get"
EM_DATACENTER_HEADERS = {
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/133.0.0.0 Safari/537.36"),
"Accept": "application/json, text/plain, */*",
"Referer": "https://data.eastmoney.com/",
}
MACRO_REPORTS = {
# indicator: (reportName, 字段映射 → 统一输出键)
"cpi": ("RPT_ECONOMY_CPI", {"NATIONAL_SAME": "yoy",
"NATIONAL_SEQUENTIAL": "mom",
"NATIONAL_ACCUMULATE": "accumulate"}),
"ppi": ("RPT_ECONOMY_PPI", {"BASE_SAME": "yoy",
"BASE": "value",
"BASE_ACCUMULATE": "accumulate"}),
"pmi": ("RPT_ECONOMY_PMI", {"MAKE_INDEX": "manufacturing",
"NO_MAKE_INDEX": "non_manufacturing"}),
"gdp": ("RPT_ECONOMY_GDP", {"SUM_SAME": "yoy",
"DOMESTICL_PRODUCT_BASE": "value",
"FIRST_SAME": "primary_yoy",
"SECOND_SAME": "secondary_yoy",
"THIRD_SAME": "tertiary_yoy"}),
"m2": ("RPT_ECONOMY_CURRENCY_SUPPLY", {"CURRENCY": "m2",
"CURRENCY_SAME": "m2_yoy",
"BASIC_CURRENCY": "m0_m1_base",
"FREE_CASH": "m0"}),
}
def get_macro_indicator(indicator: str = "cpi", limit: int = 24) -> List[Dict]:
"""宏观经济月度/季度序列(CPI/PPI/PMI/GDP/M2)。
零 pip 依赖(纯 HTTP),作为 akshare 不可用时的兜底;
返回按时间倒序的 [{"date","time",...指标}], 失败返回 []。
"""
indicator = (indicator or "").lower().strip()
if indicator not in MACRO_REPORTS:
log.error(f"不支持的宏观指标: {indicator},可选: {'/'.join(MACRO_REPORTS)}")
return []
report_name, field_map = MACRO_REPORTS[indicator]
params = (
f"?reportName={report_name}&columns=ALL&pageSize={max(1, min(limit, 200))}"
f"&pageNumber=1&sortColumns=REPORT_DATE&sortTypes=-1&source=WEB"
)
url = EM_DATACENTER_API + params
try:
if _HAS_REQUESTS:
r = _requests.get(url, headers=EM_DATACENTER_HEADERS, timeout=15)
data = r.json()
else:
from http_utils import http_get # type: ignore
resp = http_get(url, headers=EM_DATACENTER_HEADERS, timeout=15,
rate_limit_delay=0)
if resp is None:
return []
data = resp.json()
rows = (data.get("result") or {}).get("data") or []
out = []
for row in rows:
item = {"date": str(row.get("REPORT_DATE", ""))[:10],
"time": row.get("TIME", "")}
for src, dst in field_map.items():
if row.get(src) is not None:
item[dst] = _safe_float(row.get(src))
if item["date"]:
out.append(item)
return out
except Exception as e:
log.warning(f"宏观指标 {indicator} 获取失败: {e}")
return []
# ==================== CLI 测试入口 ====================
if __name__ == "__main__":
print("=" * 60)
print("市场数据爬虫 v1.0 — 功能测试")
print("=" * 60)
print("\n[测试1] LPR 利率(近 5 条)")
for r in get_lpr_rates(days=150)[:5]:
print(f" {r['date']}: 1Y={r['one_year']}% 5Y={r['five_year']}%")
print("\n[测试2] 国债收益率曲线")
curve = get_bond_yield_curve()
print(f" 日期: {curve.get('date')} 类型: {curve.get('curve_type')}")
print(f" 期限: {curve.get('terms')}")
print(f" 10Y-2Y 利差: {curve.get('spread_10_2')}")
print("\n" + "=" * 60)