提交
This commit is contained in:
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -18,12 +18,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ..config import settings
|
||||
from ..db import async_session
|
||||
from ..models import StockCompany
|
||||
from .sync_utils import call_retry, f_clean, fresh, get_pro_lazy, s_clean, utcnow
|
||||
|
||||
_REFRESH_DAYS = 30
|
||||
|
||||
# 频率超限特征(等待 62s 重试一次;与 screener.market_sync / data.etf_sync._call_retry 同款语义)
|
||||
_RATE_MARKS = ("频率超限", "每分钟")
|
||||
|
||||
# 显式列出全部字段:introduction/office/main_business/business_scope 文档标注默认不显示,
|
||||
# 不传 fields 时 tushare 不返回这四列(实测 000001.SZ)
|
||||
_FIELDS = (
|
||||
@@ -32,87 +30,38 @@ _FIELDS = (
|
||||
"employees,main_business,business_scope"
|
||||
)
|
||||
|
||||
_pro = None # 惰性单例(get_pro 每次都 ts.set_token 写文件,没必要重复)
|
||||
|
||||
|
||||
def _get_pro():
|
||||
if not settings.tushare_token:
|
||||
raise RuntimeError("未配置 TUSHARE_TOKEN,无法拉取公司简介(backend/.env)")
|
||||
global _pro
|
||||
if _pro is None:
|
||||
from .tushare_provider import get_pro
|
||||
|
||||
_pro = get_pro()
|
||||
return _pro
|
||||
|
||||
|
||||
def _call_retry(fn, *args, **kwargs):
|
||||
"""同步调用 tushare 接口;「每分钟」级频率超限等 62s 重试一次。"""
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except Exception as e: # noqa: BLE001
|
||||
msg = str(e)
|
||||
if any(m in msg for m in _RATE_MARKS) and "小时" not in msg:
|
||||
time.sleep(62)
|
||||
return fn(*args, **kwargs)
|
||||
raise
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _fresh(updated_at: datetime | None) -> bool:
|
||||
return updated_at is not None and updated_at >= _utcnow() - timedelta(days=_REFRESH_DAYS)
|
||||
|
||||
|
||||
def _s(v) -> str | None:
|
||||
"""pandas NaN / 空串 / None -> None,其余 strip。"""
|
||||
if v is None or (isinstance(v, float) and v != v):
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _f(v) -> float | None:
|
||||
try:
|
||||
f = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if f != f else f # NaN -> None
|
||||
|
||||
|
||||
def _i(v) -> int | None:
|
||||
f = _f(v)
|
||||
f = f_clean(v)
|
||||
return None if f is None else int(f)
|
||||
|
||||
|
||||
def fetch_company_sync(ts_code: str) -> dict | None:
|
||||
"""同步拉单只公司简介(需在 to_thread 里跑);返回行 dict,无此股返回 None。"""
|
||||
time.sleep(settings.screener_sync_interval)
|
||||
df = _call_retry(_get_pro().stock_company, ts_code=ts_code, fields=_FIELDS)
|
||||
df = call_retry(get_pro_lazy().stock_company, ts_code=ts_code, fields=_FIELDS)
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
r = df.iloc[0]
|
||||
return {
|
||||
"ts_code": ts_code,
|
||||
"com_name": _s(r.get("com_name")),
|
||||
"com_id": _s(r.get("com_id")),
|
||||
"chairman": _s(r.get("chairman")),
|
||||
"manager": _s(r.get("manager")),
|
||||
"secretary": _s(r.get("secretary")),
|
||||
"reg_capital": _f(r.get("reg_capital")),
|
||||
"setup_date": _s(r.get("setup_date")),
|
||||
"province": _s(r.get("province")),
|
||||
"city": _s(r.get("city")),
|
||||
"introduction": _s(r.get("introduction")),
|
||||
"website": _s(r.get("website")),
|
||||
"email": _s(r.get("email")),
|
||||
"office": _s(r.get("office")),
|
||||
"com_name": s_clean(r.get("com_name")),
|
||||
"com_id": s_clean(r.get("com_id")),
|
||||
"chairman": s_clean(r.get("chairman")),
|
||||
"manager": s_clean(r.get("manager")),
|
||||
"secretary": s_clean(r.get("secretary")),
|
||||
"reg_capital": f_clean(r.get("reg_capital")),
|
||||
"setup_date": s_clean(r.get("setup_date")),
|
||||
"province": s_clean(r.get("province")),
|
||||
"city": s_clean(r.get("city")),
|
||||
"introduction": s_clean(r.get("introduction")),
|
||||
"website": s_clean(r.get("website")),
|
||||
"email": s_clean(r.get("email")),
|
||||
"office": s_clean(r.get("office")),
|
||||
"employees": _i(r.get("employees")),
|
||||
"main_business": _s(r.get("main_business")),
|
||||
"business_scope": _s(r.get("business_scope")),
|
||||
"updated_at": _utcnow(),
|
||||
"main_business": s_clean(r.get("main_business")),
|
||||
"business_scope": s_clean(r.get("business_scope")),
|
||||
"updated_at": utcnow(),
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +100,7 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None:
|
||||
"""
|
||||
row = (await session.execute(
|
||||
select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none()
|
||||
if row is not None and _fresh(row.updated_at):
|
||||
if row is not None and fresh(row.updated_at, _REFRESH_DAYS):
|
||||
return _row_dict(row) if row.com_name is not None else None # 墓碑 -> None
|
||||
# 释放请求会话持有的连接:后面可能隔着 1-2s 的 tushare 调用,别长占连接池。
|
||||
# 用 close() 而非 rollback():rollback 会把会话身份映射里的实例全部 expire——
|
||||
@@ -165,7 +114,7 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None:
|
||||
async with async_session() as s2: # 锁内重读 + 写入走新会话
|
||||
row = (await s2.execute(
|
||||
select(StockCompany).where(StockCompany.ts_code == ts_code))).scalar_one_or_none()
|
||||
if row is not None and _fresh(row.updated_at):
|
||||
if row is not None and fresh(row.updated_at, _REFRESH_DAYS):
|
||||
return _row_dict(row) if row.com_name is not None else None
|
||||
try:
|
||||
fetched = await asyncio.to_thread(fetch_company_sync, ts_code)
|
||||
@@ -174,5 +123,5 @@ async def get_company(session: AsyncSession, ts_code: str) -> dict | None:
|
||||
if row is not None and row.com_name is not None:
|
||||
return _row_dict(row)
|
||||
raise
|
||||
await _upsert(s2, fetched or {"ts_code": ts_code, "updated_at": _utcnow()})
|
||||
await _upsert(s2, fetched or {"ts_code": ts_code, "updated_at": utcnow()})
|
||||
return fetched
|
||||
|
||||
Reference in New Issue
Block a user