公司介绍 财务数据
This commit is contained in:
@@ -119,3 +119,58 @@ class ShareLink(BaseModel):
|
||||
short_code: str
|
||||
created_at: str
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
class CompanyProfile(BaseModel):
|
||||
"""公司概况"""
|
||||
code: str
|
||||
name: str
|
||||
fullName: str
|
||||
englishName: str
|
||||
boardType: str
|
||||
industry: str
|
||||
exchange: str
|
||||
industryDetail: str
|
||||
chairman: str
|
||||
generalManager: str
|
||||
secretary: str
|
||||
phone: str
|
||||
email: str
|
||||
fax: str
|
||||
website: str
|
||||
officeAddress: str
|
||||
registeredAddress: str
|
||||
region: str
|
||||
postalCode: str
|
||||
registeredCapital: str
|
||||
unifiedCreditCode: str
|
||||
employees: str
|
||||
managementCount: str
|
||||
lawFirm: str
|
||||
auditor: str
|
||||
businessScope: str
|
||||
companyProfile: str
|
||||
establishmentDate: str
|
||||
listingDate: str
|
||||
listingPrice: str
|
||||
issuePriceEarningsRatio: str
|
||||
issueAmount: str
|
||||
netProceeds: str
|
||||
issueMethod: str
|
||||
|
||||
|
||||
class FinancialReportItem(BaseModel):
|
||||
"""单期财务报告"""
|
||||
reportDate: str
|
||||
reportType: str
|
||||
reportDateName: str
|
||||
noticeDate: str
|
||||
indicators: dict
|
||||
|
||||
|
||||
class FinancialDataResponse(BaseModel):
|
||||
"""财务数据响应"""
|
||||
code: str
|
||||
name: str
|
||||
count: int
|
||||
data: list[FinancialReportItem]
|
||||
|
||||
+68
-1
@@ -5,7 +5,7 @@ import re
|
||||
import math
|
||||
|
||||
from services import tencent, sina, eastmoney
|
||||
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary
|
||||
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -71,6 +71,73 @@ async def stock_history(
|
||||
raise HTTPException(status_code=404, detail="未获取到K线数据")
|
||||
|
||||
|
||||
@router.get("/profile", response_model=dict, summary="公司概况")
|
||||
async def company_profile(code: str = Query(..., description="6位股票代码")):
|
||||
"""获取东方财富F10公司概况数据"""
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
data = await eastmoney.fetch_company_profile(code)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="未获取到公司概况数据")
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.get("/financial", response_model=dict, summary="财务分析数据")
|
||||
async def financial_data(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
years: int = Query(5, description="获取年数", ge=1, le=10),
|
||||
):
|
||||
"""获取东方财富财务分析指标数据(每股指标、盈利能力、成长能力、偿债能力等)"""
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
data = await eastmoney.fetch_financial_data_main(code, years)
|
||||
|
||||
# 获取股票名称
|
||||
name = code
|
||||
if data:
|
||||
quote = await tencent.fetch_quote(code)
|
||||
if quote:
|
||||
name = quote.get("name", code)
|
||||
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="未获取到财务数据")
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"count": len(data),
|
||||
"code": code,
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/business-segments", response_model=dict, summary="主营构成/收入构成")
|
||||
async def business_segments(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
by_type: str = Query("product", description="分类方式: product=按产品, industry=按行业, region=按地区"),
|
||||
years: int = Query(3, description="获取年数", ge=1, le=30),
|
||||
):
|
||||
"""获取东方财富经营分析 - 主营构成数据(收入构成、成本构成、利润构成、毛利率)"""
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
if by_type not in ("product", "industry", "region"):
|
||||
raise HTTPException(status_code=400, detail="by_type 需为 product/industry/region")
|
||||
|
||||
data = await eastmoney.fetch_business_segments(code, by_type, years)
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="未获取到主营构成数据")
|
||||
|
||||
quote = await tencent.fetch_quote(code)
|
||||
name = quote.get("name", code) if quote else code
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"count": len(data),
|
||||
"code": code,
|
||||
"name": name,
|
||||
"byType": by_type,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fund-flow", summary="资金流向")
|
||||
async def stock_fund_flow(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
|
||||
@@ -363,6 +363,376 @@ async def fetch_sector_list_direct(sector_type: str) -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
# ---- 公司概况 ----
|
||||
|
||||
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
|
||||
_DATACENTER_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
|
||||
|
||||
|
||||
def _code_to_f10(code: str) -> str:
|
||||
"""6位代码 → 东方财富 F10 格式 (SH600519 / SZ000001)"""
|
||||
prefix = code[0]
|
||||
mkt = _F10_MARKET_MAP.get(prefix, "SH")
|
||||
return f"{mkt}{code}"
|
||||
|
||||
|
||||
def _code_to_secucode(code: str) -> str:
|
||||
"""6位代码 → 数据中心格式 (600519.SH / 000001.SZ)"""
|
||||
prefix = code[0]
|
||||
mkt = _DATACENTER_MARKET_MAP.get(prefix, "SH")
|
||||
return f"{code}.{mkt}"
|
||||
|
||||
|
||||
async def fetch_company_profile(code: str) -> Optional[dict]:
|
||||
"""东方财富 F10 - 公司概况(缓存24小时)"""
|
||||
cache_key = f"company_profile:{code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
em_code = _code_to_f10(code)
|
||||
url = (
|
||||
f"https://emweb.securities.eastmoney.com/PC_HSF10"
|
||||
f"/CompanySurvey/CompanySurveyAjax?code={em_code}"
|
||||
)
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": (
|
||||
f"https://emweb.securities.eastmoney.com/PC_HSF10"
|
||||
f"/CompanySurvey/Index?type=web&code={em_code}"
|
||||
),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
raw = resp.json()
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] fetch_company_profile error: {e}")
|
||||
return None
|
||||
|
||||
jbzl = raw.get("jbzl", {})
|
||||
fxxg = raw.get("fxxg", {})
|
||||
|
||||
profile = {
|
||||
"code": code,
|
||||
"name": jbzl.get("agjc", ""),
|
||||
"fullName": jbzl.get("gsmc", ""),
|
||||
"englishName": jbzl.get("ywmc", ""),
|
||||
"boardType": jbzl.get("zqlb", ""),
|
||||
"industry": jbzl.get("sshy", ""),
|
||||
"exchange": jbzl.get("ssjys", ""),
|
||||
"industryDetail": jbzl.get("sszjhhy", ""),
|
||||
|
||||
# 高管
|
||||
"chairman": jbzl.get("dsz", ""),
|
||||
"generalManager": jbzl.get("zjl", ""),
|
||||
"secretary": jbzl.get("dm", ""),
|
||||
|
||||
# 联系方式
|
||||
"phone": jbzl.get("lxdh", ""),
|
||||
"email": jbzl.get("dzxx", ""),
|
||||
"fax": jbzl.get("cz", ""),
|
||||
"website": jbzl.get("gswz", ""),
|
||||
|
||||
# 地址
|
||||
"officeAddress": jbzl.get("bgdz", ""),
|
||||
"registeredAddress": jbzl.get("zcdz", ""),
|
||||
"region": jbzl.get("qy", ""),
|
||||
"postalCode": jbzl.get("yzbm", ""),
|
||||
|
||||
# 注册信息
|
||||
"registeredCapital": jbzl.get("zczb", ""),
|
||||
"unifiedCreditCode": jbzl.get("gsdj", ""),
|
||||
"employees": jbzl.get("gyrs", 0),
|
||||
"managementCount": jbzl.get("glryrs", 0),
|
||||
|
||||
# 中介机构
|
||||
"lawFirm": jbzl.get("lssws", ""),
|
||||
"auditor": jbzl.get("kjssws", ""),
|
||||
|
||||
# 公司简介&业务
|
||||
"businessScope": jbzl.get("jyfw", ""),
|
||||
"companyProfile": jbzl.get("gsjj", ""),
|
||||
|
||||
# 发行信息
|
||||
"establishmentDate": fxxg.get("clrq", ""),
|
||||
"listingDate": fxxg.get("ssrq", ""),
|
||||
"listingPrice": fxxg.get("mgfxj", ""),
|
||||
"issuePriceEarningsRatio": fxxg.get("fxsyl", ""),
|
||||
"issueAmount": fxxg.get("fxl", ""),
|
||||
"netProceeds": fxxg.get("mjzjje", ""),
|
||||
"issueMethod": fxxg.get("fxfs", ""),
|
||||
}
|
||||
|
||||
set_cache(cache_key, json.dumps(profile, ensure_ascii=False), ttl_hours=24)
|
||||
return profile
|
||||
|
||||
|
||||
# ---- 财务数据 ----
|
||||
|
||||
# 财务指标关键字段映射(英文 → 中文)
|
||||
FINANCIAL_FIELD_LABELS: dict[str, str] = {
|
||||
"EPSJB": "基本每股收益",
|
||||
"EPSKCJB": "扣非每股收益",
|
||||
"BPS": "每股净资产",
|
||||
"MGWFPLR": "每股未分配利润",
|
||||
"MGJYXJJE": "每股经营现金流",
|
||||
"TOTALOPERATEREVE": "营业总收入",
|
||||
"MLR": "毛利润",
|
||||
"PARENTNETPROFIT": "归母净利润",
|
||||
"KCFJCXSYJLR": "扣非净利润",
|
||||
"TOTALOPERATEREVETZ": "营收同比增长",
|
||||
"PARENTNETPROFITTZ": "净利润同比增长",
|
||||
"KCFJCXSYJLRTZ": "扣非净利润同比增长",
|
||||
"ROEJQ": "加权净资产收益率",
|
||||
"ROEKCJQ": "扣非ROE",
|
||||
"ZZCJLL": "总资产净利率",
|
||||
"XSJLL": "销售净利率",
|
||||
"XSMLL": "销售毛利率",
|
||||
"LD": "流动比率",
|
||||
"SD": "速动比率",
|
||||
"ZCFZL": "资产负债率",
|
||||
"XJLLB": "现金流比率",
|
||||
"YSZKZZTS": "应收账款周转天数",
|
||||
"CHZZTS": "存货周转天数",
|
||||
"ZZCZZTS": "总资产周转天数",
|
||||
"TOAZZL": "总资产周转率",
|
||||
"CHZZL": "存货周转率",
|
||||
"ROIC": "投入资本回报率",
|
||||
"QYCS": "权益乘数",
|
||||
"CQBL": "产权比率",
|
||||
"TAXRATE": "实际税率",
|
||||
"TOTAL_SHARE": "总股本",
|
||||
"A_FREE_SHARE": "流通A股",
|
||||
"DJD_TOI_YOY": "单季营收同比",
|
||||
"DJD_DPNP_YOY": "单季净利润同比",
|
||||
"EPSJBTZ": "每股收益同比增长",
|
||||
"BPSTZ": "每股净资产同比增长",
|
||||
"ROEJQTZ": "ROE同比增长",
|
||||
# 利润表补充
|
||||
"OPERATE_PROFIT_PK": "营业利润",
|
||||
"PER_TOI": "每股营业收入",
|
||||
"PER_EBIT": "每股EBIT",
|
||||
# 资产负债表补充
|
||||
"TOTAL_ASSETS_PK": "总资产",
|
||||
"LIABILITY": "总负债",
|
||||
"TOTAL_EQUITY_PK": "所有者权益",
|
||||
"MGZBGJ": "每股资本公积",
|
||||
"CA_TA": "流动资产占比",
|
||||
"NCA_TA": "非流动资产占比",
|
||||
"CL_TL": "流动负债占比",
|
||||
# 现金流量表补充
|
||||
"NETCASH_OPERATE_PK": "经营活动现金流净额",
|
||||
"NETCASH_INVEST_PK": "投资活动现金流净额",
|
||||
"NETCASH_FINANCE_PK": "筹资活动现金流净额",
|
||||
"JYXJLYYSR": "经营现金流占营收比",
|
||||
"XSJXLYYSR": "销售现金流占营收比",
|
||||
"FCFF_FORWARD": "企业自由现金流",
|
||||
# 营运能力补充
|
||||
"OPERATE_CYCLE": "营业周期",
|
||||
"CURRENT_ASSET_TR": "流动资产周转率",
|
||||
"FIXED_ASSET_TR": "固定资产周转率",
|
||||
"ACCOUNTS_PAYABLE_TR": "应付账款周转率",
|
||||
"PREPAID_ACCOUNTS_RATIO": "预收账款占营收比",
|
||||
"PAYABLE_TDAYS": "应付账款周转天数",
|
||||
}
|
||||
|
||||
|
||||
# 小数比率字段(后端返回小数,前端需显示为百分比)
|
||||
_DECIMAL_RATIO_FIELDS = {
|
||||
"JYXJLYYSR", "XSJXLYYSR", "PREPAID_ACCOUNTS_RATIO",
|
||||
"NCO_OP", "NCO_FIXED", "SALE_ER_PK",
|
||||
}
|
||||
|
||||
|
||||
def _parse_financial_record(record: dict) -> dict:
|
||||
"""将原始财务记录转为整洁的输出"""
|
||||
result = {}
|
||||
for field, label in FINANCIAL_FIELD_LABELS.items():
|
||||
val = record.get(field)
|
||||
if val is not None:
|
||||
if isinstance(val, (int, float)):
|
||||
# 小数比率字段转为百分比
|
||||
if field in _DECIMAL_RATIO_FIELDS and abs(val) < 1:
|
||||
result[label] = round(val * 100, 2)
|
||||
result[label + "(%)"] = True
|
||||
elif abs(val) > 1_0000_0000:
|
||||
result[label] = round(val / 1_0000_0000, 2)
|
||||
result[label + "(亿)"] = True
|
||||
elif abs(val) > 1_0000:
|
||||
result[label] = round(val / 1_0000, 2)
|
||||
result[label + "(万)"] = True
|
||||
else:
|
||||
result[label] = round(val, 2)
|
||||
else:
|
||||
result[label] = val
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_financial_data_main(code: str, years: int = 5) -> Optional[list[dict]]:
|
||||
"""东方财富数据中心 - 主要财务指标(缓存6小时)"""
|
||||
cache_key = f"financial_main:{code}:{years}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
secucode = _code_to_secucode(code)
|
||||
url = "https://datacenter.eastmoney.com/securities/api/data/v1/get"
|
||||
params = {
|
||||
"reportName": "RPT_F10_FINANCE_MAINFINADATA",
|
||||
"columns": "ALL",
|
||||
"filter": f'(SECUCODE="{secucode}")',
|
||||
"pageNumber": 1,
|
||||
"pageSize": max(years * 4, 4), # 每年最多4份报告
|
||||
"sortTypes": -1,
|
||||
"sortColumns": "REPORT_DATE",
|
||||
}
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://datacenter.eastmoney.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, params=params, headers=headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
body = resp.json()
|
||||
if not body.get("result") or not body["result"].get("data"):
|
||||
return None
|
||||
records = body["result"]["data"]
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] fetch_financial_data error: {e}")
|
||||
return None
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
items.append({
|
||||
"reportDate": r.get("REPORT_DATE", ""),
|
||||
"reportType": r.get("REPORT_TYPE", ""),
|
||||
"reportDateName": r.get("REPORT_DATE_NAME", ""),
|
||||
"noticeDate": r.get("NOTICE_DATE", ""),
|
||||
"indicators": _parse_financial_record(r),
|
||||
})
|
||||
|
||||
set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_hours=6)
|
||||
return items
|
||||
|
||||
|
||||
# ---- 经营分析(主营构成/收入构成)----
|
||||
|
||||
_SEGMENT_TYPE_MAP = {
|
||||
"industry": "1", # 按行业
|
||||
"product": "2", # 按产品
|
||||
"region": "3", # 按地区
|
||||
}
|
||||
|
||||
|
||||
async def fetch_business_segments(code: str, by_type: str = "product", years: int = 3) -> Optional[list[dict]]:
|
||||
"""东方财富经营分析 - 主营构成(按产品/行业/地区),缓存6小时
|
||||
|
||||
Args:
|
||||
code: 6位股票代码
|
||||
by_type: "product"|"industry"|"region"
|
||||
years: 返回最近N年的数据
|
||||
"""
|
||||
seg_type = _SEGMENT_TYPE_MAP.get(by_type, "2")
|
||||
cache_key = f"business_segments:{code}:{by_type}:{years}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
secucode = _code_to_secucode(code)
|
||||
url = "https://datacenter.eastmoney.com/securities/api/data/v1/get"
|
||||
params = {
|
||||
"reportName": "RPT_F10_FN_SEGMENTSV",
|
||||
"columns": (
|
||||
"SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,MAINOP_TYPE,ITEM_NAME,"
|
||||
"ITEM_CODE,ITEM_PARENT_CODE,MAIN_BUSINESS_INCOME,MAIN_BUSINESS_RPOFIT,"
|
||||
"GROSS_RPOFIT_RATIO,MBI_RATIO,MBR_RATIO,REPORT_DATE,REPORT_NAME,"
|
||||
"REPORT_DATE_CODE,MAIN_BUSINESS_COST,MBC_RATIO"
|
||||
),
|
||||
"filter": f'(SECUCODE="{secucode}")(MAINOP_TYPE="{seg_type}")(ITEM_PARENT_CODE="999999")',
|
||||
"pageNumber": 1,
|
||||
"pageSize": 200,
|
||||
"sortTypes": "1,1",
|
||||
"sortColumns": "MAINOP_TYPE,RANK3",
|
||||
}
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://datacenter.eastmoney.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, params=params, headers=headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
body = resp.json()
|
||||
if not body.get("result") or not body["result"].get("data"):
|
||||
return None
|
||||
records = body["result"]["data"]
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] fetch_business_segments error: {e}")
|
||||
return None
|
||||
|
||||
# 按报告期分组
|
||||
from collections import OrderedDict
|
||||
period_map: dict[str, list[dict]] = OrderedDict()
|
||||
for r in records:
|
||||
report_name = r.get("REPORT_NAME", "")
|
||||
report_date = r.get("REPORT_DATE", "")
|
||||
item_name = r.get("ITEM_NAME", "")
|
||||
income = r.get("MAIN_BUSINESS_INCOME")
|
||||
cost = r.get("MAIN_BUSINESS_COST")
|
||||
profit = r.get("MAIN_BUSINESS_RPOFIT")
|
||||
gross_ratio = r.get("GROSS_RPOFIT_RATIO")
|
||||
mbi_ratio = r.get("MBI_RATIO")
|
||||
mbr_ratio = r.get("MBR_RATIO")
|
||||
mbc_ratio = r.get("MBC_RATIO")
|
||||
|
||||
entry = {
|
||||
"itemName": item_name,
|
||||
"income": income,
|
||||
"cost": cost,
|
||||
"profit": profit,
|
||||
"grossRatio": round(gross_ratio * 100, 2) if gross_ratio is not None else None,
|
||||
"incomeRatio": round(mbi_ratio * 100, 2) if mbi_ratio is not None else None,
|
||||
"profitRatio": round(mbr_ratio * 100, 2) if mbr_ratio is not None else None,
|
||||
"costRatio": round(mbc_ratio * 100, 2) if mbc_ratio is not None else None,
|
||||
}
|
||||
|
||||
if report_name not in period_map:
|
||||
period_map[report_name] = []
|
||||
period_map[report_name].append(entry)
|
||||
|
||||
# 补充 profitRatio:若 API 未返回,用该期总利润推算各项目利润占比
|
||||
for pname, items in period_map.items():
|
||||
total_profit = sum(it["profit"] for it in items if it["profit"] is not None)
|
||||
if total_profit != 0:
|
||||
for it in items:
|
||||
if it["profitRatio"] is None and it["profit"] is not None:
|
||||
it["profitRatio"] = round(it["profit"] / total_profit * 100, 2)
|
||||
|
||||
# 取最近N年
|
||||
all_periods = list(period_map.keys())
|
||||
all_periods.sort(reverse=True)
|
||||
selected = all_periods[:max(years * 4, 4)]
|
||||
|
||||
result = []
|
||||
for p in selected:
|
||||
result.append({
|
||||
"reportName": p,
|
||||
"items": period_map[p],
|
||||
})
|
||||
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=6)
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_push2his(code: str, days: int) -> Optional[list]:
|
||||
"""回退到东方财富 push2his 接口(自动重试一次)"""
|
||||
market = get_eastmoney_market(code)
|
||||
|
||||
Reference in New Issue
Block a user