from __future__ import annotations import json from statistics import median from typing import Any def build_auction( *, trade_date: str, raw_rows: tuple[dict[str, Any], ...], price_limits: tuple[dict[str, Any], ...], directory: dict[str, dict[str, Any]], prior_snapshot: dict[str, Any], ths_hot: tuple[dict[str, Any], ...], dc_hot: tuple[dict[str, Any], ...], history: list[dict[str, Any]], dynamic: bool, ) -> dict[str, Any]: rows = _normalize_rows(raw_rows, price_limits, directory, dynamic) candidates, focus_rows = _score_candidates(rows, prior_snapshot, ths_hot, dc_hot) scored = {str(item["code"]): item for item in candidates} candidate_codes = {str(item["code"]) for item in candidates} one_price_rows = [ { **item, **scored.get(str(item["code"]), {}), "attention_score": None, "expectation": "", "expected_change": None, "expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离", } for item in rows if item["is_one_price"] ] one_price_codes = {str(item["code"]) for item in one_price_rows} candidates = [item for item in candidates if item["code"] not in one_price_codes] focus_rows = [item for item in focus_rows if item["code"] not in one_price_codes] one_price_rows.sort( key=lambda item: ( bool(item.get("is_market_core")), _number(item.get("prior_streak")), _number(item.get("amount_million")), ), reverse=True, ) changes = [float(item["change"]) for item in rows] amount_billion = round(sum(float(item["amount_million"]) for item in rows) / 100, 2) amount_history = [item for item in history if item.get("trade_date") != trade_date][-9:] amount_history.append( {"trade_date": trade_date, "amount_billion": amount_billion, "stock_count": len(rows)} ) prior_amounts = [float(item["amount_billion"]) for item in amount_history[:-1]] previous_amount = prior_amounts[-1] if prior_amounts else 0 five_day = prior_amounts[-5:] five_day_average = sum(five_day) / len(five_day) if five_day else 0 eligible = sum( bool(item.get("identifier")) and not str(item.get("name") or "").upper().startswith(("N", "C")) for item in directory.values() ) coverage = min(len(rows) / max(eligible, 1), 1) expectations = { label: sum(item.get("expectation") == label for item in candidates) for label in ("超预期", "符合预期", "低于预期") } return { "trade_date": trade_date, "dynamic": dynamic, "coverage": round(coverage, 4), "summary": { "stock_count": len(rows), "candidate_count": len(candidates), "focus_count": len(focus_rows), "one_price_count": len(one_price_rows), "amount_billion": amount_billion, "amount_change_previous": ( round((amount_billion / previous_amount - 1) * 100, 1) if previous_amount else None ), "amount_change_5d": ( round((amount_billion / five_day_average - 1) * 100, 1) if five_day_average else None ), "median_change": round(median(changes), 2) if changes else None, }, "expectations": expectations, "themes": _theme_evidence(prior_snapshot, candidates + one_price_rows), "amount_history": amount_history, "focus_rows": focus_rows, "one_price_rows": one_price_rows, "_market_rows": rows, "rows": candidates, "all_market_count": len(rows), "candidate_market_count": len(candidate_codes), } def build_watchlist_rows( market_rows: list[dict[str, Any]], candidates: list[dict[str, Any]], one_price_rows: list[dict[str, Any]], watchlist: tuple[dict[str, Any], ...], ) -> list[dict[str, Any]]: market = {str(item["identifier"]): item for item in market_rows} enriched = { str(item["identifier"]): item for item in candidates + one_price_rows } result = [] for saved in watchlist: identifier = str(saved.get("identifier") or "") row = enriched.get(identifier) if row: result.append({**row, "is_watchlist": True, "available": True}) continue raw = market.get(identifier) if raw: actual = _number(raw.get("change")) + _confirmation(raw) item = { **raw, "candidate_sources": ["我的自选"], "source_label": "我的自选", "prior_streak": 0, "concepts": [], "expected_change": 0.0, "actual_strength": round(actual, 2), "expectation": _expectation(actual, 0), "core_tags": [], "is_market_core": False, "is_watchlist": True, "available": True, } item["attention_score"] = _attention(item, 0, False, False) item["expectation_reason"] = "自选观察,按当日竞价强度与成交确认评估" result.append(item) continue result.append( { "identifier": identifier, "code": identifier.split(".")[0], "name": str(saved.get("name") or ""), "sector": str(saved.get("sector") or ""), "is_watchlist": True, "available": False, } ) return sorted( result, key=lambda item: ( bool(item.get("available")), _number(item.get("attention_score")), ), reverse=True, ) def _normalize_rows( raw_rows: tuple[dict[str, Any], ...], price_limits: tuple[dict[str, Any], ...], directory: dict[str, dict[str, Any]], dynamic: bool, ) -> list[dict[str, Any]]: limits = {str(row.get("ts_code") or ""): row for row in price_limits} latest: dict[str, dict[str, Any]] = {} for raw in raw_rows: identifier = str(raw.get("thscode") or raw.get("ts_code") or "").upper() if not identifier or identifier not in directory: continue previous = latest.get(identifier) if previous is None or str(raw.get("time") or "") >= str(previous.get("time") or ""): latest[identifier] = raw rows = [] for identifier, raw in latest.items(): stock = directory[identifier] price = _number(raw.get("latest" if dynamic else "price")) pre_close = _number(raw.get("preClose" if dynamic else "pre_close")) volume = _number(raw.get("volume" if dynamic else "vol")) amount = _number(raw.get("amount")) if amount <= 0 and price > 0 and volume > 0: amount = price * volume if price <= 0 or pre_close <= 0: continue change = (price / pre_close - 1) * 100 up_limit = _number((limits.get(identifier) or {}).get("up_limit")) rows.append( { "identifier": identifier, "code": str(stock.get("code") or identifier.split(".")[0]), "name": str(stock.get("name") or ""), "sector": str(stock.get("sector") or "其他"), "price": round(price, 2), "change": round(change, 2), "amount_million": round(amount / 1_000_000, 2), "turnover_rate": round( _number(raw.get("turnoverRatio" if dynamic else "turnover_rate")), 4 ), "volume_ratio": round( _number(raw.get("volumeRatio" if dynamic else "volume_ratio")), 2 ), "up_limit": round(up_limit, 2) if up_limit else None, "is_one_price": bool( up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005) ), "snapshot_time": str(raw.get("time") or ""), } ) rows.sort( key=lambda item: (float(item["amount_million"]), float(item["volume_ratio"])), reverse=True, ) return rows def _score_candidates( rows: list[dict[str, Any]], prior_snapshot: dict[str, Any], ths_hot: tuple[dict[str, Any], ...], dc_hot: tuple[dict[str, Any], ...], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: prior_limits = list(prior_snapshot.get("limits") or []) prior_broken = list(prior_snapshot.get("broken") or []) prior_sectors = list(prior_snapshot.get("sectors") or []) strong_sectors = {str(item.get("name") or "") for item in prior_sectors[:5]} identities: dict[str, dict[str, Any]] = {} core_tags: dict[str, set[str]] = {} def ensure(item: dict[str, Any]) -> tuple[str, dict[str, Any]] | None: code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0]) if not code: return None return code, identities.setdefault( code, { "sources": [], "streak": 0, "sector": str(item.get("sector") or "其他"), "concepts": [], "ths_rank": None, "dc_rank": None, }, ) highest = max((int(_number(item.get("streak"), 1)) for item in prior_limits), default=0) for item in prior_limits: entry = ensure(item) if not entry: continue code, identity = entry streak = max(1, int(_number(item.get("streak"), 1))) identity["streak"] = streak identity["sources"].append("昨日涨停") if streak >= 3: core_tags.setdefault(code, set()).add("三板以上") if highest and streak == highest: core_tags.setdefault(code, set()).add("市场最高板") for item in prior_broken: entry = ensure(item) if entry and "昨日炸板" not in entry[1]["sources"]: entry[1]["sources"].append("昨日炸板") for sector in prior_sectors[:5]: name = str(sector.get("name") or "") members = [item for item in prior_limits if str(item.get("sector") or "") == name] if members: leader = max( members, key=lambda item: ( int(_number(item.get("streak"), 1)), _number(item.get("amount")), ), ) core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心") if prior_limits: leader = max( prior_limits, key=lambda item: ( int(_number(item.get("streak"), 1)), str(item.get("sector") or "") in strong_sectors, _number(item.get("amount")), ), ) core_tags.setdefault(str(leader.get("code") or ""), set()).add("市场领涨") hot_records: dict[str, dict[str, Any]] = {} for rows_source, source, expected_type, rank_key in ( (ths_hot, "同花顺热榜", "热股", "ths_rank"), (dc_hot, "东方财富热榜", "A股市场", "dc_rank"), ): for item in rows_source: if str(item.get("data_type") or "") != expected_type: continue code = str(item.get("ts_code") or "").split(".")[0] rank = max(1, int(_number(item.get("rank"), 9999))) if not code or rank > 20: continue hot = hot_records.setdefault( code, {"ths_rank": None, "dc_rank": None, "concepts": []} ) hot[rank_key] = rank if rank_key == "ths_rank": hot["concepts"] = _concepts(item.get("concept")) identity = identities.setdefault( code, { "sources": [], "streak": 0, "sector": "其他", "concepts": [], "ths_rank": None, "dc_rank": None, }, ) identity[rank_key] = rank identity["concepts"] = hot["concepts"] or identity["concepts"] if source not in identity["sources"]: identity["sources"].append(source) hot_ranked = sorted( hot_records, key=lambda code: ( (21 - (hot_records[code]["ths_rank"] or 21)) * 0.5 + (21 - (hot_records[code]["dc_rank"] or 21)) * 0.25 + (10 if hot_records[code]["ths_rank"] and hot_records[code]["dc_rank"] else 0) ), reverse=True, ) for code in hot_ranked[:5]: core_tags.setdefault(code, set()).add("人气前5") normalized = [] for row in rows: identity = identities.get(str(row["code"])) if not identity: continue ranks = [ rank for rank in (identity.get("ths_rank"), identity.get("dc_rank")) if isinstance(rank, int) ] if ranks and min(ranks) > 10 and len(ranks) == 1 and row["code"] not in core_tags: if not any(source in {"昨日涨停", "昨日炸板"} for source in identity["sources"]): continue streak = int(identity["streak"]) expected = {0: 0.5, 1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0) expected += 0.8 if len(ranks) == 2 else 0.7 if ranks and min(ranks) <= 10 else 0 expected = min(expected, 6.5) confirmation = _confirmation(row) actual_strength = float(row["change"]) + confirmation expectation = _expectation(actual_strength, expected) tags = sorted(core_tags.get(str(row["code"]), set())) scored = { **row, "sector": identity["sector"] if identity["sector"] != "其他" else row["sector"], "candidate_sources": identity["sources"], "source_label": " · ".join(identity["sources"]), "prior_streak": streak, "concepts": identity["concepts"], "expected_change": round(expected, 2), "actual_strength": round(actual_strength, 2), "expectation": expectation, "core_tags": tags, "is_market_core": bool(tags), } scored["attention_score"] = _attention( scored, expected, bool(tags), str(scored["sector"]) in strong_sectors, ) scored["expectation_reason"] = _reason(scored) normalized.append(scored) normalized.sort( key=lambda item: (float(item["attention_score"]), float(item["amount_million"])), reverse=True, ) matched = { str(item["code"]) for item in [row for row in normalized if row["expectation"] == "符合预期"][:20] } mandatory = [item for item in normalized if item["is_market_core"]] optional = [ item for item in normalized if not item["is_market_core"] and ( (item["attention_score"] >= 55 and item["expectation"] != "符合预期") or item["code"] in matched ) ] focus = mandatory + optional[: max(0, 30 - len(mandatory))] focus.sort(key=lambda item: float(item["attention_score"]), reverse=True) return normalized, focus def _confirmation(row: dict[str, Any]) -> float: volume_ratio = _number(row.get("volume_ratio")) turnover = _number(row.get("turnover_rate")) amount = _number(row.get("amount_million")) return ( ( 0.6 if volume_ratio >= 2 else 0.3 if volume_ratio >= 1.2 else -0.5 if volume_ratio < 0.6 else 0 ) + (0.25 if turnover >= 0.15 else -0.25 if turnover < 0.03 else 0) + (0.3 if amount >= 20 else 0.15 if amount >= 5 else -0.3 if amount < 1 else 0) ) def _attention( row: dict[str, Any], expected: float, core: bool, strong_sector: bool ) -> float: sources = list(row.get("candidate_sources") or []) streak = int(row.get("prior_streak") or 0) identity = 35 if core else 27 if streak >= 2 else 21 if sources else 14 deviation = min(30, abs(_number(row.get("change")) - expected) * 5) volume = min(10, max(0, _number(row.get("volume_ratio"))) / 2 * 10) amount = min(6, max(0, _number(row.get("amount_million"))) / 10 * 6) turnover = min(4, max(0, _number(row.get("turnover_rate"))) / 0.2 * 4) theme = 15 if strong_sector else 7 if row.get("concepts") else 0 return round(min(100, identity + deviation + volume + amount + turnover + theme), 1) def _expectation(actual: float, expected: float) -> str: difference = actual - expected return "超预期" if difference >= 1.5 else "低于预期" if difference <= -1.5 else "符合预期" def _reason(row: dict[str, Any]) -> str: streak = int(row.get("prior_streak") or 0) identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak else "热榜标的" difference = _number(row.get("change")) - _number(row.get("expected_change")) direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合" return f"{identity},竞价涨幅{direction}预期{abs(difference):.1f}个百分点" def _theme_evidence( prior_snapshot: dict[str, Any], rows: list[dict[str, Any]] ) -> dict[str, list[dict[str, Any]]]: prior_sectors = list(prior_snapshot.get("sectors") or []) carry = [] for sector in prior_sectors[:10]: name = str(sector.get("name") or "其他") members = [row for row in rows if str(row.get("sector") or "其他") == name] changes = [_number(row.get("change")) for row in members] middle = median(changes) if changes else None positive = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0 status = ( "强承接" if middle is not None and middle >= 2 and positive >= 60 else "有承接" if middle is not None and middle >= 0 and positive >= 50 else "分歧" if middle is not None and middle > -2 else "承接弱" ) carry.append( { "name": name, "status": status, "prior_limit_count": int(_number(sector.get("count"))), "matched_count": len(members), "median_change": round(middle, 2) if middle is not None else None, "positive_rate": round(positive, 1), } ) prior_names = {str(item.get("name") or "") for item in prior_sectors} groups: dict[str, dict[str, dict[str, Any]]] = {} for row in rows: for concept in row.get("concepts") or []: if concept and concept not in prior_names: groups.setdefault(str(concept), {})[str(row["code"])] = row new_themes = [] for name, mapped in groups.items(): members = list(mapped.values()) changes = [_number(item.get("change")) for item in members] positive_rate = sum(value > 0.2 for value in changes) / len(changes) if len(members) >= 2 and median(changes) >= 2 and positive_rate >= 0.67: new_themes.append( { "name": name, "stock_count": len(members), "median_change": round(median(changes), 2), "leaders": [ str(item.get("name") or "") for item in sorted( members, key=lambda item: _number(item.get("change")), reverse=True, )[:3] ], } ) new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"]), reverse=True) return {"carry": carry, "new_themes": new_themes[:8]} def _concepts(value: Any) -> list[str]: if isinstance(value, list): return [str(item).strip() for item in value if str(item).strip()] text = str(value or "").strip() if not text: return [] try: parsed = json.loads(text) if isinstance(parsed, list): return [str(item).strip() for item in parsed if str(item).strip()] except json.JSONDecodeError: pass return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()] def _number(value: Any, default: float = 0.0) -> float: try: number = float(value) return number if number == number else default except (TypeError, ValueError): return default