from __future__ import annotations import json from typing import Any from backend.data.contracts import ProviderResult from backend.data.repository import MarketRepository from backend.database.connection import Database def assemble_screener_inputs( database: Database, repository: MarketRepository, trade_date: str, trade_dates: tuple[str, ...], raw: dict[str, ProviderResult | None], ) -> tuple[dict[str, Any], dict[str, float]]: inputs = { key: [_normalize_row(row) for row in value.rows] for key, value in raw.items() if isinstance(value, ProviderResult) } inputs["earnings"] = _earnings_events( inputs.pop("forecast", []), inputs.pop("express", []), trade_date ) popularity, popularity_coverage = _popularity(database, repository, trade_date) institutions, institution_coverage = _institutions(database, repository, trade_date) local_limits, local_limit_coverage = _local_limit_events( database, repository, trade_dates[-80:] ) inputs["popularity"] = popularity inputs["institutions"] = institutions inputs["limit_events"] = _merge_events(inputs.get("limit_events", []), local_limits) directory_count = len(inputs.get("directory", [])) daily_current = _on_date(inputs.get("daily", []), trade_date) current_count = len({str(row.get("ts_code") or "") for row in daily_current}) expected = max(directory_count, current_count, 1) basic_current = _on_date(inputs.get("daily_basic", []), trade_date) fundamentals = _latest_announced(inputs.get("fundamentals", []), trade_date) moneyflow_counts = _code_date_counts(inputs.get("moneyflow", [])) industry_codes = { str(row.get("ts_code") or "") for row in inputs.get("industry", []) if _active_member(row, trade_date) } auction_count = len({str(row.get("ts_code") or "") for row in inputs.get("auction", [])}) limit_result = raw.get("limit_events") provider_limit_coverage = ( limit_result.metadata.coverage if isinstance(limit_result, ProviderResult) else 0 ) forecast = raw.get("forecast") express = raw.get("express") earnings_coverage = min( forecast.metadata.coverage if isinstance(forecast, ProviderResult) else 0, express.metadata.coverage if isinstance(express, ProviderResult) else 0, ) coverage = { "market": min(current_count / expected, 1), "valuation": min( len({str(row.get("ts_code") or "") for row in basic_current}) / expected, 1 ), "financial": min(len(fundamentals) / expected, 1), "moneyflow": min( sum(count >= min(5, len(trade_dates)) for count in moneyflow_counts.values()) / expected, 1, ), "industry": min(len(industry_codes) / expected, 1), "auction": min(auction_count / expected, 1), "popularity": popularity_coverage, "institutions": institution_coverage, "earnings": earnings_coverage, "limit_events": max(provider_limit_coverage, local_limit_coverage), } return inputs, coverage def _popularity( database: Database, repository: MarketRepository, trade_date: str ) -> tuple[list[dict[str, Any]], float]: with database.read() as connection: row = repository.insight_snapshot(connection, "popularity", trade_date) if row is None: return [], 0.0 payload = json.loads(str(row["payload_json"])) return [ { "ts_code": str(item.get("identifier") or ""), "combined_score": item.get("score"), "rank_change": item.get("rank_change"), "dual_source": bool(item.get("dual_source")), } for item in payload.get("combined") or [] if item.get("identifier") ], float(row["coverage"]) def _institutions( database: Database, repository: MarketRepository, trade_date: str ) -> tuple[list[dict[str, Any]], float]: with database.read() as connection: row = repository.insight_snapshot(connection, "dragon-list", trade_date) if row is None: return [], 0.0 payload = json.loads(str(row["payload_json"])) seats = payload.get("seats") if seats is None: return [], 0.0 grouped: dict[str, dict[str, float]] = {} for item in seats: identifier = str(item.get("ts_code") or "") if not identifier or "机构" not in str(item.get("exalter") or ""): continue target = grouped.setdefault(identifier, {"net": 0.0, "count": 0}) target["net"] += float(item.get("net_buy") or 0) / 1_000_000 target["count"] += 1 return [ { "ts_code": identifier, "net_buy_million": values["net"], "seat_count": int(values["count"]), } for identifier, values in grouped.items() ], float(row["coverage"]) def _local_limit_events( database: Database, repository: MarketRepository, trade_dates: tuple[str, ...], ) -> tuple[list[dict[str, Any]], float]: if not trade_dates: return [], 0.0 with database.read() as connection: rows = repository.summaries(connection, trade_dates[-1], len(trade_dates)) expected = set(trade_dates) covered: set[str] = set() events = [] for row in rows: trade_date = str(row["trade_date"]) if trade_date not in expected: continue covered.add(trade_date) payload = json.loads(str(row["payload_json"])) for key, event in (("limits", "U"), ("broken", "Z"), ("down_limits", "D")): events.extend( { "trade_date": trade_date, "ts_code": str(item["identifier"]), "limit_type": event, } for item in payload.get(key) or [] if item.get("identifier") ) return events, len(covered) / len(expected) def _normalize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for field in ("trade_date", "ann_date", "end_date", "ex_date", "in_date", "out_date"): value = str(result.get(field) or "") if len(value) == 8 and value.isdigit(): result[field] = f"{value[:4]}-{value[4:6]}-{value[6:]}" if result.get("price") is not None and result.get("pre_close") is not None: price = _number(result.get("price")) previous = _number(result.get("pre_close")) result["change"] = (price / previous - 1) * 100 if price is not None and previous else None amount = _number(result.get("amount")) result["amount_million"] = amount / 1_000_000 if amount is not None else None return result def _earnings_events( forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], through: 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 "")) announced = str(row.get("ann_date") or "") current = forecast_map.get(key) if ( all(key) and announced and announced <= through and (current is None or announced > str(current.get("ann_date") or "")) ): forecast_map[key] = row result = [] for row in expresses: key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) announced = str(row.get("ann_date") or "") forecast = forecast_map.get(key) if forecast is None or not announced or announced > through: continue values = [ value for value in ( _number(forecast.get("net_profit_min")), _number(forecast.get("net_profit_max")), ) if value is not None ] expected = sum(values) / len(values) if values else None actual = _number(row.get("n_income")) if expected in (None, 0) or actual is None: continue if abs(actual) > max(abs(expected), 1) * 100: actual /= 10000 result.append( { "ts_code": key[0], "end_date": key[1], "ann_date": announced, "surprise_pct": (actual / expected - 1) * 100, } ) return result def _on_date(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]: return [row for row in rows if str(row.get("trade_date") or "") == trade_date] def _latest_announced(rows: list[dict[str, Any]], through: str) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for row in rows: identifier = str(row.get("ts_code") or "") announced = str(row.get("ann_date") or "") current = result.get(identifier) if ( identifier and announced and announced <= through and (current is None or announced > str(current.get("ann_date") or "")) ): result[identifier] = row return result def _code_date_counts(rows: list[dict[str, Any]]) -> dict[str, int]: values: dict[str, set[str]] = {} for row in rows: identifier = str(row.get("ts_code") or "") if identifier: values.setdefault(identifier, set()).add(str(row.get("trade_date") or "")) return {identifier: len(dates) for identifier, dates in values.items()} def _active_member(row: dict[str, Any], trade_date: str) -> bool: start = str(row.get("in_date") or "") end = str(row.get("out_date") or "") return (not start or start <= trade_date) and (not end or end > trade_date) def _merge_events( provider: list[dict[str, Any]], local: list[dict[str, Any]] ) -> list[dict[str, Any]]: merged = { (str(row.get("trade_date") or ""), str(row.get("ts_code") or "")): row for row in local } for row in provider: merged[(str(row.get("trade_date") or ""), str(row.get("ts_code") or ""))] = row return list(merged.values()) def _number(value: Any) -> float | None: try: result = float(value) return result if result == result else None except (TypeError, ValueError): return None