Files
xiaobaifupan/app/backend/features/screener/data_sync.py
T

343 lines
14 KiB
Python
Raw 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.
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Any
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.screener.indicators import _optional_number
from database import ReviewDatabase
def _quarter_periods(trade_date: str, count: int) -> list[str]:
current = datetime.strptime(trade_date, "%Y%m%d")
quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31))
periods = []
year = current.year
while len(periods) < count:
for month, day in reversed(quarter_ends):
value = datetime(year, month, day)
if value <= current:
periods.append(value.strftime("%Y%m%d"))
if len(periods) == count:
break
year -= 1
return sorted(periods)
def _earnings_event_rows(
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str,
) -> list[dict[str, Any]]:
forecast_map: dict[tuple[str, str], dict[str, Any]] = {}
for row in forecasts:
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
ann_date = str(row.get("ann_date") or "")
if not all(key) or not ann_date or ann_date > trade_date:
continue
previous = forecast_map.get(key)
if previous is None or ann_date > str(previous.get("ann_date") or ""):
forecast_map[key] = row
result = []
for row in expresses:
ts_code = str(row.get("ts_code") or "")
end_date = str(row.get("end_date") or "")
ann_date = str(row.get("ann_date") or "")
forecast = forecast_map.get((ts_code, end_date))
if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date:
continue
lower = _optional_number(forecast.get("net_profit_min"))
upper = _optional_number(forecast.get("net_profit_max"))
forecast_profit = statistics.fmean(
value for value in (lower, upper) if value is not None
) if lower is not None or upper is not None else None
actual_profit = _optional_number(row.get("n_income"))
if forecast_profit in (None, 0) or actual_profit is None:
continue
# forecast is reported in ten-thousand yuan while express uses yuan.
if abs(actual_profit) > max(abs(forecast_profit), 1) * 100:
actual_profit /= 10000
surprise_pct = (actual_profit / forecast_profit - 1) * 100
result.append(
{
"end_date": end_date,
"ann_date": ann_date,
"ts_code": ts_code,
"forecast_profit": forecast_profit,
"actual_profit": actual_profit,
"surprise_pct": surprise_pct,
"revenue_yoy": _optional_number(row.get("yoy_sales")),
"netprofit_yoy": _optional_number(row.get("yoy_net_profit")),
"source": "forecast+express",
}
)
return result
def _popularity_factor_rows(
trade_date: str,
ths_rows: list[dict[str, Any]],
dc_rows: list[dict[str, Any]],
previous_ths: list[dict[str, Any]],
previous_dc: list[dict[str, Any]],
) -> list[dict[str, Any]]:
def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]:
result = {}
for row in rows:
if data_type and str(row.get("data_type") or "") != data_type:
continue
ts_code = str(row.get("ts_code") or "")
rank = int(_number(row.get("rank")))
if ts_code and rank > 0:
result[ts_code] = rank
return result
ths = ranks(ths_rows, "热股")
dc = ranks(dc_rows, "A股市场")
previous_ths_map = ranks(previous_ths, "热股")
previous_dc_map = ranks(previous_dc, "A股市场")
result = []
for ts_code in set(ths) | set(dc):
ths_rank = ths.get(ts_code)
dc_rank = dc.get(ts_code)
current_best = min(value for value in (ths_rank, dc_rank) if value is not None)
previous_candidates = [
value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code))
if value is not None
]
previous_best = min(previous_candidates) if previous_candidates else None
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
result.append(
{
"trade_date": trade_date,
"ts_code": ts_code,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"combined_score": round(score, 2),
"rank_change": (
previous_best - current_best
if previous_best is not None
else min(30, max(0, 31 - current_best))
if previous_ths_map or previous_dc_map else 0
),
"dual_source": bool(ths_rank and dc_rank),
}
)
return result
class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
self.database = database
self.client = client
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
lookback = max(25, min(260, int(lookback)))
trade_date, _ = self.client.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
calendar = self.client.query(
"trade_cal",
{"exchange": "SSE", "start_date": start, "end_date": trade_date, "is_open": 1},
"cal_date,is_open",
)
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
existing = set(self.database.factor_dates(trade_date, lookback + 10))
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
auction_source_dates = dates[-min(80, len(dates)):]
existing_auction = set(self.database.auction_factor_dates(trade_date, 90))
auction_dates_to_fetch = [
value for value in auction_source_dates
if value not in existing_auction or value == trade_date
]
long_calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"),
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
last_open_by_year: dict[str, str] = {}
last_open_by_month: dict[str, str] = {}
for row in long_calendar:
if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value)
valuation_dates = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values())
valuation_dates.update(last_open_by_month.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted(
value for value in valuation_dates if value not in existing_indicators or value == trade_date
)
master = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
master_count = self.database.upsert_stock_master(master)
bar_count = 0
for current_date in dates_to_fetch:
rows = self.client.query(
"daily",
{"trade_date": current_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
bar_count += self.database.upsert_daily_bars(rows)
indicator_count = 0
for current_date in indicator_dates_to_fetch:
indicators = self.client.query(
"daily_basic",
{"trade_date": current_date},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,"
"pe_ttm,pb,ps_ttm,dv_ttm",
)
indicator_count += self.database.upsert_daily_indicators(indicators)
notices = []
benchmark_count = 0
try:
benchmark_rows = self.client.query(
"index_daily",
{"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows)
except TushareError as exc:
notices.append(f"沪深300基准暂不可用:{exc}")
fundamental_count = 0
existing_periods = set(self.database.fundamental_periods())
for period in _quarter_periods(trade_date, 9):
if period in existing_periods and period < trade_date[:4] + "0101":
continue
try:
rows = self.client.query(
"fina_indicator_vip",
{"period": period},
"ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin,"
"netprofit_yoy,or_yoy,ocf_to_opincome",
)
except TushareError as exc:
notices.append(f"财务质量接口不可用:{exc}")
break
published = [
row for row in rows
if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date
]
published.sort(key=lambda row: str(row.get("ann_date") or ""))
fundamental_count += self.database.upsert_fundamental_indicators(published)
auction_count = 0
auction_dates = 0
for current_date in auction_dates_to_fetch:
try:
auction_rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
if auction_rows:
auction_count += self.database.upsert_auction_factors(auction_rows)
auction_dates += 1
except TushareError as exc:
notices.append(f"竞价因子接口不可用:{exc}")
break
moneyflow_count = 0
moneyflow_dates = 0
for current_date in dates[-min(5, len(dates)):]:
try:
moneyflow = self.client.query(
"moneyflow",
{"trade_date": current_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
moneyflow_count += self.database.upsert_moneyflow(moneyflow)
if moneyflow:
moneyflow_dates += 1
except TushareError as exc:
notices.append(f"资金流接口不可用:{exc}")
break
earnings_count = 0
forecasts: list[dict[str, Any]] = []
expresses: list[dict[str, Any]] = []
for period in _quarter_periods(trade_date, 5):
try:
forecast_rows = self.client.query(
"forecast_vip",
{"period": period},
"ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max",
)
express_rows = self.client.query(
"express_vip",
{"period": period},
"ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales",
)
except TushareError as exc:
notices.append(f"业绩事件接口不可用:{exc}")
break
forecasts.extend(forecast_rows)
expresses.extend(express_rows)
if forecasts and expresses:
earnings_count = self.database.upsert_earnings_events(
_earnings_event_rows(forecasts, expresses, trade_date)
)
popularity_count = 0
previous_trade_date = dates[-2] if len(dates) >= 2 else ""
try:
ths_rows = self.client.query("ths_hot", {"trade_date": trade_date})
dc_rows = self.client.query("dc_hot", {"trade_date": trade_date})
previous_ths = (
self.client.query("ths_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
previous_dc = (
self.client.query("dc_hot", {"trade_date": previous_trade_date})
if previous_trade_date else []
)
popularity_count = self.database.upsert_popularity_factors(
_popularity_factor_rows(
trade_date, ths_rows, dc_rows, previous_ths, previous_dc
)
)
except TushareError as exc:
notices.append(f"人气榜因子不可用:{exc}")
institution_count = 0
try:
institution_rows = self.client.query(
"top_inst",
{"trade_date": trade_date},
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
)
institution_count = self.database.upsert_lhb_institutions(institution_rows)
except TushareError as exc:
notices.append(f"机构席位明细不可用:{exc}")
return {
"trade_date": trade_date,
"calendar_dates": len(dates),
"fetched_dates": len(dates_to_fetch),
"stocks": master_count,
"bars": bar_count,
"benchmark_bars": benchmark_count,
"indicators": indicator_count,
"indicator_dates": len(indicator_dates_to_fetch),
"fundamentals": fundamental_count,
"moneyflow": moneyflow_count,
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count,
"auction_dates": auction_dates,
"earnings_events": earnings_count,
"popularity_rows": popularity_count,
"institution_rows": institution_count,
"notice": "".join(notices),
}