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