Files

436 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 -*-
"""
集思录可转债数据爬虫模块
数据源: jisilu.cn(可转债数据)
提供可转债全量列表查询和单只可转债详细信息获取。
主要函数:
- get_convertible_bonds() — 获取全量可转债列表
- get_bond_detail(bond_code) — 获取单只可转债详细信息
API:
- 数据接口: https://www.jisilu.cn/data/cbnew/cb_list/
- 需要设置正确的 User-Agent 和 Referer
- 返回 JSON 格式
"""
import logging
from typing import Optional, Dict, Any, List
try:
from .http_utils import http_get_json, http_get, http_post, rate_limit, get_session
except ImportError:
from http_utils import http_get_json, http_get, http_post, rate_limit, get_session
log = logging.getLogger(__name__)
# ==================== 常量 ====================
JISILU_CB_LIST_URL = "https://www.jisilu.cn/data/cbnew/cb_list/"
JISILU_CB_DETAIL_URL = "https://www.jisilu.cn/data/cbnew/cb_detail_new/"
JISILU_REFERER = "https://www.jisilu.cn/cbnew/"
# 集思录需要特定的请求头来绕过反爬
JISILU_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Referer": JISILU_REFERER,
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"X-Requested-With": "XMLHttpRequest",
}
# 可转债列表 POST 请求的默认参数
DEFAULT_CB_LIST_PARAMS = {
"btype": "C", # C=可转债
"listed": "Y", # Y=已上市
"rp": "50", # 每页条数
"page": "1",
}
# ==================== 安全类型转换 ====================
def _safe_float(val, default=None):
"""安全转换为浮点数"""
if val is None or val == "" or val == "-":
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 _akshare_bond_fallback(listed_only: bool = True) -> List[Dict[str, Any]]:
"""v5.0.0: 集思录接口被封时降级到 akshare 可转债全量数据(尽力而为)。"""
try:
from scripts.akshare_fallback import fallback_akshare
return fallback_akshare(
"bond_zh_hs_cov",
{"symbol": "沪深" if listed_only else "沪深京"},
{"bond_id": ["代码"], "bond_nm": ["名称"], "price": ["最新价"],
"convert_price": ["转股价"], "premium_rt": ["溢价率"],
"ytm_rt": ["到期税前收益"], "rating_cd": ["信用评级"],
"year_left": ["剩余年限"], "stock_id": ["正股代码"], "stock_nm": ["正股名称"]},
)
except Exception:
return []
def get_convertible_bonds(listed_only: bool = True,
page: int = 1,
page_size: int = 50) -> List[Dict[str, Any]]:
"""
获取集思录全量可转债列表。
通过集思录 cb_list 接口获取可转债数据,每只包含:
代码、名称、现价、转股价、溢价率、到期收益率、评级、剩余年限等字段。
Args:
listed_only: 是否仅获取已上市可转债(默认 True)
page: 页码(默认 1)
page_size: 每页条数(默认 50)
Returns:
可转债信息列表,每个元素为 dict,主要字段包括:
- bond_id: 可转债代码
- bond_nm: 可转债名称
- price: 现价
- convert_price: 转股价
- premium_rt: 溢价率(%)
- ytm_rt: 到期收益率(%)
- rating_cd: 评级
- year_left: 剩余年限
- stock_id: 正股代码
- stock_nm: 正股名称
- put_convert_price: 回售价
失败返回空列表
"""
# 先 GET 首页获取 Cookie/Token(集思录需要 Cookie 才能访问数据接口)
log.info("正在访问集思录首页以获取 Cookie...")
session = get_session(headers=JISILU_HEADERS)
home_resp = http_get(
"https://www.jisilu.cn/cbnew/",
headers=JISILU_HEADERS,
session=session,
use_cache=False,
)
if home_resp is None:
log.warning("访问集思录首页失败,尝试直接请求数据接口")
# 构造请求参数
params = dict(DEFAULT_CB_LIST_PARAMS)
params["rp"] = str(page_size)
params["page"] = str(page)
if not listed_only:
params["listed"] = "N"
# 限流
rate_limit(url=JISILU_CB_LIST_URL)
# POST 请求数据接口(集思录 cb_list 使用 POST)
log.info(f"正在请求可转债列表: page={page}, page_size={page_size}")
try:
resp = http_post(
JISILU_CB_LIST_URL,
data=params,
headers=JISILU_HEADERS,
session=session,
timeout=30,
)
if resp is None:
log.error("集思录可转债列表请求失败")
return _akshare_bond_fallback(listed_only)
data = resp.json()
except Exception as e:
log.error(f"集思录可转债列表解析失败: {e}")
return _akshare_bond_fallback(listed_only)
# 解析返回数据
# 集思录返回格式: {"rows": [...], "total": N, "page": 1}
rows = data.get("rows", [])
if not rows:
# 某些情况下数据可能在 data 字段下
rows = data.get("data", {}).get("rows", [])
if not rows:
log.warning("集思录返回数据为空")
return []
bonds = []
for row in rows:
cell = row.get("cell", row) # 兼容不同返回格式
bond = {
"bond_id": _safe_str(cell.get("bond_id", "")),
"bond_nm": _safe_str(cell.get("bond_nm", "")),
"price": _safe_float(cell.get("price")),
"convert_price": _safe_float(cell.get("convert_price")),
"premium_rt": _safe_float(cell.get("premium_rt")),
"ytm_rt": _safe_float(cell.get("ytm_rt")),
"rating_cd": _safe_str(cell.get("rating_cd", "")),
"year_left": _safe_float(cell.get("year_left")),
"stock_id": _safe_str(cell.get("stock_id", "")),
"stock_nm": _safe_str(cell.get("stock_nm", "")),
"convert_value": _safe_float(cell.get("convert_value")),
"volume": _safe_float(cell.get("volume")),
"svolume": _safe_float(cell.get("svolume")),
"put_convert_price": _safe_float(cell.get("put_convert_price")),
"force_redeem_price": _safe_float(cell.get("force_redeem_price")),
"maturity_dt": _safe_str(cell.get("maturity_dt", "")),
"list_dt": _safe_str(cell.get("list_dt", "")),
"issuer_rating_cd": _safe_str(cell.get("issuer_rating_cd", "")),
"guarantor": _safe_str(cell.get("guarantor", "")),
"coupon_rate": _safe_str(cell.get("coupon_rate", "")),
"issue_size": _safe_float(cell.get("issue_size")),
"remain_size": _safe_float(cell.get("remain_size")),
"adj_scnt": _safe_float(cell.get("adj_scnt")), # 已下调转股价次数
"adj_cnt": _safe_float(cell.get("adj_cnt")), # 总下调转股价次数
"ration_rt": _safe_float(cell.get("ration_rt")), # 股东配售率
"syl_rt": _safe_float(cell.get("syl_rt")), # 收益率
"cb_type": _safe_str(cell.get("cb_type", "")), # 可转债类型
}
bonds.append(bond)
log.info(f"成功获取 {len(bonds)} 只可转债数据")
return bonds
def get_all_convertible_bonds(listed_only: bool = True) -> List[Dict[str, Any]]:
"""
分页获取全量可转债列表(自动遍历所有页码)。
Args:
listed_only: 是否仅获取已上市可转债
Returns:
全量可转债信息列表
"""
all_bonds = []
page = 1
while True:
bonds = get_convertible_bonds(
listed_only=listed_only,
page=page,
page_size=100, # 集思录单页最大约100条
)
if not bonds:
break
all_bonds.extend(bonds)
# 如果返回数量不足一页,说明已到末尾
if len(bonds) < 100:
break
page += 1
log.info(f"全量获取完成,共 {len(all_bonds)} 只可转债")
return all_bonds
# ==================== 单只可转债详情 ====================
def get_bond_detail(bond_code: str) -> Optional[Dict[str, Any]]:
"""
获取单只可转债的详细信息。
通过集思录 cb_detail_new 接口获取单只可转债的详细数据,
包含历史价格、回售条款、强赎条款、下修条款等。
Args:
bond_code: 可转债代码(如 "110059")
Returns:
可转债详情字典,包含:
- 基本信息: 代码、名称、现价、转股价
- 条款信息: 回售价、强赎触发价、下修触发条件
- 历史数据: 价格走势关键节点
失败返回 None
"""
log.info(f"正在获取可转债详情: {bond_code}")
session = get_session(headers=JISILU_HEADERS)
# 请求详情接口
params = {"bond_id": bond_code}
rate_limit(url=JISILU_CB_DETAIL_URL)
try:
resp = http_post(
JISILU_CB_DETAIL_URL,
data=params,
headers=JISILU_HEADERS,
session=session,
timeout=30,
)
if resp is None:
log.error(f"可转债 {bond_code} 详情请求失败")
return None
data = resp.json()
except Exception as e:
log.error(f"可转债 {bond_code} 详情解析失败: {e}")
return None
# 解析详情数据
if not data:
return None
# 集思录详情可能嵌套在多层结构下
detail = data.get("data", data)
return {
"bond_id": bond_code,
"bond_nm": _safe_str(detail.get("bond_nm", "")),
"price": _safe_float(detail.get("price")),
"convert_price": _safe_float(detail.get("convert_price")),
"premium_rt": _safe_float(detail.get("premium_rt")),
"ytm_rt": _safe_float(detail.get("ytm_rt")),
"rating_cd": _safe_str(detail.get("rating_cd", "")),
"stock_id": _safe_str(detail.get("stock_id", "")),
"stock_nm": _safe_str(detail.get("stock_nm", "")),
"put_convert_price": _safe_float(detail.get("put_convert_price")),
"force_redeem_price": _safe_float(detail.get("force_redeem_price")),
"convert_low_price": _safe_float(detail.get("convert_low_price")),
"maturity_dt": _safe_str(detail.get("maturity_dt", "")),
"year_left": _safe_float(detail.get("year_left")),
"issue_size": _safe_float(detail.get("issue_size")),
"remain_size": _safe_float(detail.get("remain_size")),
"coupon_rate": _safe_str(detail.get("coupon_rate", "")),
"guarantor": _safe_str(detail.get("guarantor", "")),
"issuer_rating_cd": _safe_str(detail.get("issuer_rating_cd", "")),
"raw_data": detail, # 保留原始数据以备扩展
}
# ==================== 便捷查询 ====================
def search_bonds(keyword: str) -> List[Dict[str, Any]]:
"""
根据关键词搜索可转债(从全量列表中按代码或名称筛选)。
Args:
keyword: 搜索关键词(可转债代码或名称关键字)
Returns:
匹配的可转债列表
"""
all_bonds = get_convertible_bonds(listed_only=False, page_size=100)
keyword_lower = keyword.lower()
results = []
for bond in all_bonds:
bond_id = str(bond.get("bond_id", "")).lower()
bond_nm = str(bond.get("bond_nm", "")).lower()
stock_nm = str(bond.get("stock_nm", "")).lower()
if (keyword_lower in bond_id
or keyword_lower in bond_nm
or keyword_lower in stock_nm):
results.append(bond)
log.info(f"搜索 '{keyword}' 找到 {len(results)} 只可转债")
return results
def get_low_premium_bonds(top_n: int = 20,
max_premium: float = 30.0) -> List[Dict[str, Any]]:
"""
获取低溢价率可转债排行。
Args:
top_n: 返回前 N 只
max_premium: 溢价率上限(%),超过此值不纳入
Returns:
低溢价率可转债列表,按溢价率升序排列
"""
all_bonds = get_convertible_bonds(listed_only=True, page_size=100)
# 筛选有效溢价率数据
valid_bonds = []
for bond in all_bonds:
premium = bond.get("premium_rt")
price = bond.get("price")
if premium is not None and price is not None and price > 0:
if premium <= max_premium:
valid_bonds.append(bond)
# 按溢价率升序排列
valid_bonds.sort(key=lambda x: x.get("premium_rt", 999))
result = valid_bonds[:top_n]
log.info(f"低溢价率可转债: 找到 {len(result)} 只(溢价率 <= {max_premium}%)")
return result
# ==================== 测试入口 ====================
if __name__ == "__main__":
import json
print("=" * 60)
print("集思录可转债爬虫 - 功能测试")
print("=" * 60)
# 测试1: 获取可转债列表(第一页)
print("\n[测试1] 获取可转债列表(前20条)...")
bonds = get_convertible_bonds(page_size=20)
if bonds:
print(f"成功获取 {len(bonds)} 只可转债")
print(f"\n前3只示例:")
for bond in bonds[:3]:
print(f" {bond.get('bond_id')} {bond.get('bond_nm')} "
f"现价:{bond.get('price')} "
f"溢价率:{bond.get('premium_rt')}% "
f"评级:{bond.get('rating_cd')}")
else:
print("获取失败(可能需要有效的集思录 Cookie)")
# 测试2: 搜索可转债
print("\n[测试2] 搜索含'银行'的可转债...")
results = search_bonds("银行")
for bond in results[:5]:
print(f" {bond.get('bond_id')} {bond.get('bond_nm')} "
f"正股:{bond.get('stock_nm')}")
# 测试3: 低溢价率排行
print("\n[测试3] 低溢价率可转债 Top 10...")
low_p = get_low_premium_bonds(top_n=10, max_premium=20)
for i, bond in enumerate(low_p[:10], 1):
print(f" {i}. {bond.get('bond_id')} {bond.get('bond_nm')} "
f"溢价率:{bond.get('premium_rt')}% "
f"现价:{bond.get('price')}")
# 测试4: 单只可转债详情
if bonds:
test_code = bonds[0].get("bond_id", "")
if test_code:
print(f"\n[测试4] 获取可转债详情: {test_code}")
detail = get_bond_detail(test_code)
if detail:
print(json.dumps(detail, ensure_ascii=False, indent=2, default=str))
else:
print("获取详情失败")
print("\n" + "=" * 60)
print("测试完成")