Files
xiaobaifupan/next/backend/features/market/insights/auction.py
T

520 lines
20 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 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