rebuild(stage-8): deliver market insight workspaces

This commit is contained in:
leefer
2026-07-30 04:12:04 +08:00
parent a18e8e9d27
commit 976a5cac03
39 changed files with 3671 additions and 14 deletions
@@ -0,0 +1,519 @@
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