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), }