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,3 @@
from backend.features.market.insights.service import MarketInsightService
__all__ = ["MarketInsightService"]
@@ -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
@@ -0,0 +1,302 @@
from __future__ import annotations
import json
import re
from typing import Any
def profiles(rows: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
result = []
seen = set()
for row in rows:
name = _text(row.get("name"))
if not name or name in seen:
continue
seen.add(name)
organizations = _organizations(row.get("orgs"))
result.append(
{
"name": name,
"description": _text(row.get("desc")),
"organizations": organizations,
"organization_count": len(organizations),
}
)
return result
def build_dragon_list(
*,
trade_date: str,
official_rows: tuple[dict[str, Any], ...] | None,
profile_rows: tuple[dict[str, Any], ...] | None,
stock_rows: tuple[dict[str, Any], ...] | None,
seat_rows: tuple[dict[str, Any], ...] | None,
aliases: dict[str, str],
) -> dict[str, Any]:
profile_items = profiles(profile_rows or ())
profile_map = {str(item["name"]): item for item in profile_items}
organization_map = {
organization: str(item["name"])
for item in profile_items
for organization in item["organizations"]
}
stocks = _stock_context(stock_rows or ())
operations = _official_operations(official_rows or (), profile_map, stocks)
official_keys = {
(str(item["identifier"]), str(item["seat_name"]), round(float(item["net_million"]), 2))
for item in operations
}
for row in seat_rows or ():
operation = _seat_operation(row, stocks, aliases, organization_map)
key = (
str(operation["identifier"]),
str(operation["seat_name"]),
round(float(operation["net_million"]), 2),
)
if key not in official_keys:
operations.append(operation)
traders = _aggregate_traders(operations, profile_map)
unclassified = _aggregate_unclassified(operations)
official_stock_count = len(stocks)
detail_available = official_rows is not None or seat_rows is not None
detail_count = len(official_rows or ()) + len(seat_rows or ())
recognized_count = sum(bool(item["recognized"]) for item in operations)
if official_rows is None and stock_rows is None and seat_rows is None:
status = "unavailable"
message = "龙虎榜数据请求失败,请稍后重新检查"
elif official_stock_count == 0 and detail_count == 0:
status = "empty"
message = "该交易日没有股票上榜"
elif official_stock_count > 0 and (not detail_available or detail_count == 0):
status = "detail_missing"
message = f"当日有 {official_stock_count} 只股票上榜,但席位明细尚未返回"
elif detail_count > 0 and recognized_count == 0:
status = "unclassified"
message = f"当日有 {official_stock_count} 只股票上榜,席位均待归类"
else:
status = "success" if not unclassified else "partial"
message = "部分营业部尚未归类" if unclassified else ""
return {
"trade_date": trade_date,
"status": status,
"message": message,
"summary": {
"official_stock_count": official_stock_count,
"trader_count": len(traders),
"operation_count": len(operations),
"unclassified_count": len(unclassified),
"net_million": round(
sum(float(item["net_million"]) for item in operations), 2
),
"profile_count": len(profile_items),
},
"traders": traders,
"operations": sorted(
operations, key=lambda item: abs(float(item["net_million"])), reverse=True
),
"unclassified_seats": unclassified,
"profiles": profile_items,
}
def _stock_context(rows: tuple[dict[str, Any], ...]) -> dict[str, dict[str, Any]]:
result = {}
for row in rows:
identifier = str(row.get("ts_code") or "")
if identifier and identifier not in result:
result[identifier] = {
"name": _text(row.get("name")),
"change": _optional_number(row.get("pct_change")),
"reason": _text(row.get("reason")),
}
return result
def _official_operations(
rows: tuple[dict[str, Any], ...],
profile_map: dict[str, dict[str, Any]],
stocks: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
result = []
for row in rows:
identifier = str(row.get("ts_code") or "")
trader = _text(row.get("hm_name")) or "未命名游资"
profile = profile_map.get(trader) or {}
seat = _text(row.get("hm_orgs")) or ""
stock = stocks.get(identifier) or {}
result.append(
_operation(
identifier=identifier,
name=_text(row.get("ts_name")) or str(stock.get("name") or ""),
change=stock.get("change"),
reason=str(stock.get("reason") or ""),
seat_name=seat or "未提供营业部",
trader_name=trader,
description=str(profile.get("description") or ""),
buy=_number(row.get("buy_amount")) / 1_000_000,
sell=_number(row.get("sell_amount")) / 1_000_000,
net=_number(row.get("net_amount")) / 1_000_000,
recognized=True,
)
)
return result
def _seat_operation(
row: dict[str, Any],
stocks: dict[str, dict[str, Any]],
aliases: dict[str, str],
organization_map: dict[str, str],
) -> dict[str, Any]:
identifier = str(row.get("ts_code") or "")
seat = _text(row.get("exalter")) or "未命名营业部"
trader = aliases.get(seat) or organization_map.get(seat) or ""
stock = stocks.get(identifier) or {}
buy = _number(row.get("buy")) / 1_000_000
sell = _number(row.get("sell")) / 1_000_000
net = _number(row.get("net_buy")) / 1_000_000
if net == 0 and (buy or sell):
net = buy - sell
return _operation(
identifier=identifier,
name=str(stock.get("name") or ""),
change=stock.get("change"),
reason=_text(row.get("reason")) or str(stock.get("reason") or ""),
seat_name=seat,
trader_name=trader,
description="",
buy=buy,
sell=sell,
net=net,
recognized=bool(trader),
)
def _operation(
*,
identifier: str,
name: str,
change: float | None,
reason: str,
seat_name: str,
trader_name: str,
description: str,
buy: float,
sell: float,
net: float,
recognized: bool,
) -> dict[str, Any]:
return {
"identifier": identifier,
"code": identifier.split(".")[0],
"name": name,
"change": change,
"direction": "买入" if net > 0 else "卖出" if net < 0 else "持平",
"buy_million": round(buy, 2),
"sell_million": round(sell, 2),
"net_million": round(net, 2),
"seat_name": seat_name,
"trader_name": trader_name,
"description": description,
"reason": reason,
"recognized": recognized,
}
def _aggregate_traders(
operations: list[dict[str, Any]], profile_map: dict[str, dict[str, Any]]
) -> list[dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
for operation in operations:
name = str(operation.get("trader_name") or "")
if not operation.get("recognized") or not name:
continue
group = groups.setdefault(
name,
{
"name": name,
"description": str((profile_map.get(name) or {}).get("description") or ""),
"buy_million": 0.0,
"sell_million": 0.0,
"net_million": 0.0,
"seats": set(),
"stocks": set(),
"operations": [],
},
)
group["buy_million"] += float(operation["buy_million"])
group["sell_million"] += float(operation["sell_million"])
group["net_million"] += float(operation["net_million"])
group["seats"].add(str(operation["seat_name"]))
group["stocks"].add(str(operation["code"]))
group["operations"].append(operation)
result = []
for group in groups.values():
result.append(
{
"name": group["name"],
"description": group["description"],
"buy_million": round(group["buy_million"], 2),
"sell_million": round(group["sell_million"], 2),
"net_million": round(group["net_million"], 2),
"seat_count": len(group["seats"]),
"stock_count": len(group["stocks"]),
"operation_count": len(group["operations"]),
"operations": sorted(
group["operations"],
key=lambda item: abs(float(item["net_million"])),
reverse=True,
),
}
)
return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True)
def _aggregate_unclassified(operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[str, dict[str, Any]] = {}
for operation in operations:
if operation.get("recognized"):
continue
seat = str(operation["seat_name"])
group = groups.setdefault(
seat, {"seat_name": seat, "net_million": 0.0, "operation_count": 0}
)
group["net_million"] += float(operation["net_million"])
group["operation_count"] += 1
result = [
{**group, "net_million": round(float(group["net_million"]), 2)}
for group in groups.values()
]
return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True)
def _organizations(value: Any) -> list[str]:
text = _text(value)
parsed: Any = None
if text.startswith("["):
try:
parsed = json.loads(text)
except json.JSONDecodeError:
parsed = None
values = parsed if isinstance(parsed, list) else re.split(r"[,;\n]+", text)
return list(dict.fromkeys(_text(item) for item in values if _text(item)))
def _text(value: Any) -> str:
return str(value or "").strip()
def _number(value: Any) -> float:
try:
number = float(value)
return number if number == number else 0.0
except (TypeError, ValueError):
return 0.0
def _optional_number(value: Any) -> float | None:
if value in (None, ""):
return None
return _number(value)
@@ -0,0 +1,111 @@
from __future__ import annotations
import json
from typing import Any
def build_popularity(
trade_date: str,
ths_rows: tuple[dict[str, Any], ...],
dc_rows: tuple[dict[str, Any], ...],
previous_ths: tuple[dict[str, Any], ...],
previous_dc: tuple[dict[str, Any], ...],
) -> dict[str, Any]:
ths = _normalize(ths_rows, "热股", previous_ths)
dc = _normalize(dc_rows, "A股市场", previous_dc)
ths_map = {str(item["identifier"]): item for item in ths}
dc_map = {str(item["identifier"]): item for item in dc}
combined = []
for identifier in set(ths_map) | set(dc_map):
ths_item = ths_map.get(identifier)
dc_item = dc_map.get(identifier)
base = ths_item or dc_item or {}
ths_rank = int(ths_item["rank"]) if ths_item else None
dc_rank = int(dc_item["rank"]) if dc_item else None
score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25
combined.append(
{
**base,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"score": round(score, 2),
"dual_source": bool(ths_item and dc_item),
"concepts": list((ths_item or {}).get("concepts") or []),
}
)
combined.sort(
key=lambda item: (bool(item["dual_source"]), float(item["score"])), reverse=True
)
for rank, item in enumerate(combined, start=1):
item["rank"] = rank
return {
"trade_date": trade_date,
"summary": {
"ths_count": len(ths),
"dc_count": len(dc),
"dual_count": sum(bool(item["dual_source"]) for item in combined),
},
"combined": combined[:200],
"ths": ths,
"dc": dc,
}
def _normalize(
rows: tuple[dict[str, Any], ...],
data_type: str,
previous_rows: tuple[dict[str, Any], ...],
) -> list[dict[str, Any]]:
previous = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in previous_rows
if str(row.get("data_type") or "") == data_type
}
items = []
for row in rows:
if str(row.get("data_type") or "") != data_type:
continue
identifier = str(row.get("ts_code") or "")
rank = int(_number(row.get("rank")))
if not identifier or rank <= 0:
continue
prior = previous.get(identifier)
items.append(
{
"rank": rank,
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(row.get("ts_name") or ""),
"change": round(_number(row.get("pct_change")), 2),
"price": round(_number(row.get("current_price")), 2),
"hot": round(_number(row.get("hot")), 1),
"rank_change": prior - rank if prior else None,
"concepts": _concepts(row.get("concept")),
"reason": str(row.get("rank_reason") or ""),
"rank_time": str(row.get("rank_time") or ""),
}
)
return sorted(items, key=lambda item: int(item["rank"]))
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) -> float:
try:
number = float(value)
return number if number == number else 0.0
except (TypeError, ValueError):
return 0.0
@@ -0,0 +1,490 @@
from __future__ import annotations
import json
from datetime import datetime, time
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import SnapshotState
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.market.insights.auction import build_auction, build_watchlist_rows
from backend.features.market.insights.dragon import build_dragon_list
from backend.features.market.insights.popularity import build_popularity
from backend.features.market.insights.support import (
auction_phase as _auction_phase,
)
from backend.features.market.insights.support import (
clock as _clock,
)
from backend.features.market.insights.support import (
decorate as _decorate,
)
from backend.features.market.insights.support import (
empty_auction as _empty_auction,
)
from backend.features.market.insights.support import (
empty_standard as _empty_standard,
)
from backend.features.market.insights.support import (
number as _number,
)
from backend.features.market.insights.support import (
result as _result,
)
from backend.features.market.insights.support import (
rows as _rows,
)
from backend.features.market.insights.support import (
serialized_rows as _serialized_rows,
)
from backend.features.market.insights.support import (
standard as _standard,
)
from backend.features.market.insights.support import (
tuple_or_none as _tuple_or_none,
)
from backend.features.market.insights.support import (
valid_date as _date,
)
from backend.features.market.insights.themes import build_theme_detail, build_theme_library
SHANGHAI = ZoneInfo("Asia/Shanghai")
class MarketInsightError(RuntimeError):
pass
class MarketInsightService:
def __init__(
self, database: Database, repository: MarketRepository, gateway: DataGateway
) -> None:
self._database = database
self._repository = repository
self._gateway = gateway
def workspace(
self,
key: str,
requested_date: str | None = None,
*,
user_id: int,
force: bool = False,
now: datetime | None = None,
) -> dict[str, Any]:
if key == "auction":
return self.auction(requested_date, user_id=user_id, force=force, now=now)
if key == "themes":
return self.themes(requested_date, force=force)
if key == "popularity":
return self.popularity(requested_date, force=force)
if key == "dragon-list":
return self.dragon_list(requested_date, force=force)
raise MarketInsightError("不支持的市场洞察工作区")
def auction(
self,
requested_date: str | None,
*,
user_id: int,
force: bool = False,
now: datetime | None = None,
) -> dict[str, Any]:
clock = _clock(now)
requested, trade_date, previous = self._trade_dates(requested_date, clock)
phase = _auction_phase(requested, trade_date, clock)
target = previous if phase == "pending" else trade_date
baseline = self._previous_date(target)
cached = self._snapshot("auction", target)
if cached and not force and phase not in {"observing", "selection"}:
return _decorate(
self._personalize_auction(cached, user_id),
requested,
phase,
carried_forward=target != requested,
message=(
"今日竞价尚未开始,显示前一交易日归档"
if phase == "pending"
else ""
),
)
inputs = self._gateway.insight_inputs("auction", target, baseline)
raw = _rows(inputs.get("auction"))
dynamic = False
observed_at = clock
if phase in {"observing", "selection"}:
identifiers = self._auction_universe(baseline, inputs)
end = time(9, 25) if phase == "selection" else clock.time().replace(tzinfo=None)
try:
live = self._gateway.dynamic_auction(
identifiers,
f"{target} 09:15:00",
f"{target} {end.strftime('%H:%M:%S')}",
)
raw = live.rows
observed_at = live.metadata.observed_at
dynamic = True
except (MarketDataUnavailable, ProviderError):
if phase == "observing":
prior = self._snapshot("auction", previous)
if prior:
return _decorate(
self._personalize_auction(prior, user_id),
requested,
phase,
carried_forward=True,
message="今日动态竞价暂不可用,当前显示前一交易日归档",
current_available=False,
)
return _empty_auction(
requested,
previous,
phase,
"今日动态竞价暂不可用,且没有历史归档",
)
if not raw:
if cached:
return _decorate(
self._personalize_auction(cached, user_id),
requested,
phase,
False,
"当前读取失败,保留真实归档",
)
return _empty_auction(requested, target, phase, "该交易日暂无可用竞价快照")
payload = build_auction(
trade_date=target,
raw_rows=raw,
price_limits=_rows(inputs.get("price_limits")),
directory=self._gateway.stock_directory(),
prior_snapshot=self._market_snapshot(baseline),
ths_hot=_rows(inputs.get("ths_hot")),
dc_hot=_rows(inputs.get("dc_hot")),
history=self._auction_history(target),
dynamic=dynamic,
)
minimum = 0.8 if phase == "observing" else 0.9
if float(payload["coverage"]) < minimum:
if cached:
return _decorate(
self._personalize_auction(cached, user_id),
requested,
phase,
False,
f"竞价覆盖率不足{minimum * 100:.0f}%,保留原有真实归档",
)
return _empty_auction(
requested,
target,
phase,
f"竞价覆盖率不足{minimum * 100:.0f}%,未形成正式结果",
)
state = (
SnapshotState.REALTIME
if phase == "observing"
else SnapshotState.FINAL
if target == clock.date().isoformat()
else SnapshotState.ARCHIVE
)
payload["observed_at"] = observed_at.isoformat(timespec="seconds")
payload["state"] = state.value
if phase != "observing":
source = "ifind" if dynamic else "tushare"
self._save("auction", target, "", payload, state, source, payload["coverage"])
return _decorate(
self._personalize_auction(payload, user_id),
requested,
phase,
target != requested,
"",
)
def _personalize_auction(
self, payload: dict[str, Any], user_id: int
) -> dict[str, Any]:
result = {**payload}
market_rows = list(result.pop("_market_rows", ()))
with self._database.read() as connection:
watchlist = tuple(
dict(row) for row in self._repository.watchlist(connection, user_id)
)
result["watchlist_rows"] = build_watchlist_rows(
market_rows,
list(result.get("rows") or ()),
list(result.get("one_price_rows") or ()),
watchlist,
)
result["watchlist_ready"] = bool(market_rows) or not watchlist
return result
def themes(self, requested_date: str | None, *, force: bool = False) -> dict[str, Any]:
requested, trade_date, _ = self._trade_dates(requested_date)
cached = self._snapshot("themes", trade_date)
if cached and not force:
return _standard(cached, requested)
inputs = self._gateway.insight_inputs("themes", trade_date)
directory = _result(inputs.get("directory"))
daily = _result(inputs.get("daily"))
hot = _result(inputs.get("hot"))
if directory is None:
fallback = self._latest_snapshot("themes", trade_date)
if fallback:
return _standard(fallback, requested, "当前题材目录暂不可用,显示最近有效榜单")
raise MarketInsightError("题材目录暂不可用")
payload = build_theme_library(
trade_date,
directory.rows,
daily.rows if daily else (),
hot.rows if hot else (),
)
payload["observed_at"] = directory.metadata.observed_at.isoformat(timespec="seconds")
payload["state"] = SnapshotState.ARCHIVE.value
payload["message"] = "" if daily and daily.rows else "该交易日暂无题材行情"
with self._database.transaction() as connection:
self._repository.replace_themes(
connection,
list(payload["items"]),
directory.metadata.source.value,
payload["observed_at"],
)
self._save("themes", trade_date, "", payload, SnapshotState.ARCHIVE, "tushare", 1)
return _standard(payload, requested)
def theme_detail(self, identifier: str, requested_date: str | None) -> dict[str, Any]:
library = self.themes(requested_date)
code = identifier.strip().upper()
theme = next((item for item in library["items"] if item["code"] == code), None)
if theme is None:
raise MarketInsightError("未找到该题材")
trade_date = str(library["trade_date"])
cached = self._snapshot("themes", trade_date, code)
if cached:
return cached
inputs = self._gateway.insight_inputs("theme-detail", trade_date, identifier=code)
members = _result(inputs.get("members"))
daily = _result(inputs.get("daily"))
payload = build_theme_detail(
trade_date,
theme,
members.rows if members else (),
daily.rows if daily else (),
)
payload["message"] = "" if members and members.rows else "该题材暂无可核验成分股"
payload["observed_at"] = (
members.metadata.observed_at if members else datetime.now(SHANGHAI)
).isoformat(timespec="seconds")
payload["state"] = SnapshotState.ARCHIVE.value
if members is not None:
self._save(
"themes", trade_date, code, payload, SnapshotState.ARCHIVE, "tushare", 1
)
return payload
def popularity(
self, requested_date: str | None, *, force: bool = False
) -> dict[str, Any]:
requested, trade_date, previous = self._trade_dates(requested_date)
cached = self._snapshot("popularity", trade_date)
if cached and not force:
return _standard(cached, requested)
inputs = self._gateway.insight_inputs("popularity", trade_date, previous)
ths = _result(inputs.get("ths"))
dc = _result(inputs.get("dc"))
if not ((ths and ths.rows) or (dc and dc.rows)):
fallback = self._latest_snapshot("popularity", previous)
if fallback:
return _standard(fallback, requested, "当日榜单尚未生成,显示最近有效榜单")
return _empty_standard(requested, trade_date, "该交易日暂无可用人气榜")
payload = build_popularity(
trade_date,
ths.rows if ths else (),
dc.rows if dc else (),
_rows(inputs.get("previous_ths")),
_rows(inputs.get("previous_dc")),
)
payload["observed_at"] = datetime.now(SHANGHAI).isoformat(timespec="seconds")
payload["state"] = SnapshotState.ARCHIVE.value
missing = []
if ths is None:
missing.append("同花顺榜单暂不可用")
if dc is None:
missing.append("东方财富榜单暂不可用")
payload["message"] = "".join(missing)
coverage = (int(ths is not None) + int(dc is not None)) / 2
self._save(
"popularity",
trade_date,
"",
payload,
SnapshotState.ARCHIVE,
"tushare",
coverage,
)
return _standard(payload, requested)
def dragon_list(
self, requested_date: str | None, *, force: bool = False
) -> dict[str, Any]:
requested, trade_date, previous = self._trade_dates(requested_date)
raw = self._snapshot("dragon-list", trade_date)
if raw is None or force:
inputs = self._gateway.insight_inputs("dragon-list", trade_date)
raw = {
"trade_date": trade_date,
"observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"),
"state": SnapshotState.ARCHIVE.value,
"official": _serialized_rows(inputs.get("official")),
"profiles": _serialized_rows(inputs.get("profiles")),
"stocks": _serialized_rows(inputs.get("stocks")),
"seats": _serialized_rows(inputs.get("seats")),
}
coverage = sum(value is not None for value in raw.values() if isinstance(value, list))
self._save(
"dragon-list",
trade_date,
"",
raw,
SnapshotState.ARCHIVE,
"tushare",
min(coverage / 4, 1),
)
with self._database.read() as connection:
aliases = self._repository.seat_aliases(connection)
result = build_dragon_list(
trade_date=trade_date,
official_rows=_tuple_or_none(raw.get("official")),
profile_rows=_tuple_or_none(raw.get("profiles")),
stock_rows=_tuple_or_none(raw.get("stocks")),
seat_rows=_tuple_or_none(raw.get("seats")),
aliases=aliases,
)
result.update(
{
"requested_date": requested,
"previous_date": previous,
"observed_at": raw.get("observed_at"),
"state": SnapshotState.ARCHIVE.value,
"carried_forward": False,
}
)
return result
def save_seat_alias(self, seat_name: str, alias_name: str, user_id: int) -> dict[str, str]:
seat = " ".join(seat_name.split())
alias = " ".join(alias_name.split())
if not seat or not alias:
raise MarketInsightError("营业部和游资名称不能为空")
with self._database.transaction() as connection:
self._repository.save_seat_alias(
connection,
seat,
alias,
datetime.now(SHANGHAI).isoformat(timespec="seconds"),
user_id,
)
return {"seat_name": seat, "alias_name": alias}
def _trade_dates(
self, requested_date: str | None, clock: datetime | None = None
) -> tuple[str, str, str]:
try:
requested = _date(
requested_date or (clock or datetime.now(SHANGHAI)).date().isoformat()
)
except ValueError as exc:
raise MarketInsightError("日期格式无效") from exc
dates = self._gateway.trading_dates(requested, 2)
if len(dates) < 2:
raise MarketInsightError("请先同步完整交易日历")
return requested, dates[0], dates[1]
def _previous_date(self, trade_date: str) -> str:
dates = self._gateway.trading_dates(trade_date, 2)
if len(dates) < 2:
raise MarketInsightError("缺少前一交易日")
return dates[1]
def _auction_universe(
self, baseline: str, inputs: dict[str, Any]
) -> tuple[str, ...]:
snapshot = self._market_snapshot(baseline)
codes = {
str(item.get("identifier") or "")
for key in ("limits", "broken")
for item in snapshot.get(key) or []
}
for key, data_type in (("ths_hot", "热股"), ("dc_hot", "A股市场")):
for row in _rows(inputs.get(key)):
valid_type = str(row.get("data_type") or "") == data_type
top_twenty = int(_number(row.get("rank"), 9999)) <= 20
if valid_type and top_twenty:
codes.add(str(row.get("ts_code") or ""))
return tuple(sorted(code for code in codes if code))
def _market_snapshot(self, trade_date: str) -> dict[str, Any]:
with self._database.read() as connection:
row = self._repository.latest_summary(connection, trade_date)
if row is None or str(row["trade_date"]) != trade_date:
return {}
return json.loads(str(row["payload_json"]))
def _auction_history(self, trade_date: str) -> list[dict[str, Any]]:
with self._database.read() as connection:
rows = self._repository.insight_snapshots(connection, "auction", trade_date, 10)
result = []
for row in rows:
payload = json.loads(str(row["payload_json"]))
summary = payload.get("summary") or {}
result.append(
{
"trade_date": str(row["trade_date"]),
"amount_billion": _number(summary.get("amount_billion")),
"stock_count": int(summary.get("stock_count") or 0),
}
)
return result
def _snapshot(
self, kind: str, trade_date: str, entity_key: str = ""
) -> dict[str, Any] | None:
with self._database.read() as connection:
row = self._repository.insight_snapshot(connection, kind, trade_date, entity_key)
return json.loads(str(row["payload_json"])) if row else None
def _latest_snapshot(
self, kind: str, through: str, entity_key: str = ""
) -> dict[str, Any] | None:
with self._database.read() as connection:
row = self._repository.latest_insight_snapshot(connection, kind, through, entity_key)
return json.loads(str(row["payload_json"])) if row else None
def _save(
self,
kind: str,
trade_date: str,
entity_key: str,
payload: dict[str, Any],
state: SnapshotState,
source: str,
coverage: float,
) -> None:
with self._database.transaction() as connection:
self._repository.save_insight_snapshot(
connection,
kind=kind,
trade_date=trade_date,
entity_key=entity_key,
observed_at=str(
payload.get("observed_at")
or datetime.now(SHANGHAI).isoformat(timespec="seconds")
),
state=state.value,
source=source,
coverage=max(0, min(coverage, 1)),
payload=payload,
)
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import date, datetime, time
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import ProviderResult
SHANGHAI = ZoneInfo("Asia/Shanghai")
def auction_phase(requested: str, trade_date: str, clock: datetime) -> str:
if requested != clock.date().isoformat() or trade_date != clock.date().isoformat():
return "archive"
local = clock.time().replace(tzinfo=None)
if local < time(9, 15):
return "pending"
if local < time(9, 25):
return "observing"
if local < time(9, 30):
return "selection"
return "finalized"
def decorate(
payload: dict[str, Any],
requested: str,
phase: str,
carried_forward: bool,
message: str,
current_available: bool = True,
) -> dict[str, Any]:
return {
**payload,
"requested_date": requested,
"phase": phase,
"carried_forward": carried_forward,
"message": message or str(payload.get("message") or ""),
"current_available": current_available,
}
def standard(payload: dict[str, Any], requested: str, message: str = "") -> dict[str, Any]:
trade_date = str(payload.get("trade_date") or "")
return {
**payload,
"requested_date": requested,
"carried_forward": trade_date != requested,
"message": message or str(payload.get("message") or ""),
}
def empty_standard(requested: str, trade_date: str, message: str) -> dict[str, Any]:
return {
"requested_date": requested,
"trade_date": trade_date,
"observed_at": None,
"state": None,
"carried_forward": trade_date != requested,
"message": message,
"summary": {},
"items": [],
"combined": [],
"ths": [],
"dc": [],
}
def empty_auction(
requested: str, trade_date: str, phase: str, message: str
) -> dict[str, Any]:
return {
**empty_standard(requested, trade_date, message),
"phase": phase,
"current_available": False,
"expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
"themes": {"carry": [], "new_themes": []},
"amount_history": [],
"focus_rows": [],
"one_price_rows": [],
"rows": [],
"watchlist_rows": [],
"watchlist_ready": False,
}
def result(value: Any) -> ProviderResult | None:
return value if isinstance(value, ProviderResult) else None
def rows(value: Any) -> tuple[dict[str, Any], ...]:
provider_result = result(value)
return provider_result.rows if provider_result else ()
def serialized_rows(value: Any) -> list[dict[str, Any]] | None:
provider_result = result(value)
return [dict(row) for row in provider_result.rows] if provider_result else None
def tuple_or_none(value: Any) -> tuple[dict[str, Any], ...] | None:
if value is None:
return None
return tuple(dict(row) for row in value)
def valid_date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise ValueError("日期格式无效") from exc
def clock(value: datetime | None) -> datetime:
current = value or datetime.now(SHANGHAI)
return (
current.replace(tzinfo=SHANGHAI)
if current.tzinfo is None
else current.astimezone(SHANGHAI)
)
def number(value: Any, default: float = 0.0) -> float:
try:
parsed = float(value)
return parsed if parsed == parsed else default
except (TypeError, ValueError):
return default
@@ -0,0 +1,124 @@
from __future__ import annotations
from typing import Any
def build_theme_library(
trade_date: str,
directory_rows: tuple[dict[str, Any], ...],
daily_rows: tuple[dict[str, Any], ...],
hot_rows: tuple[dict[str, Any], ...],
) -> dict[str, Any]:
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
hot = {
str(row.get("ts_code") or ""): int(_number(row.get("rank"), 9999))
for row in hot_rows
if str(row.get("data_type") or "") == "概念板块"
}
items = []
for row in directory_rows:
if str(row.get("type") or "").upper() != "N":
continue
if str(row.get("exchange") or "").upper() != "A":
continue
code = str(row.get("ts_code") or "")
name = str(row.get("name") or "").strip()
if not code or not name:
continue
quote = daily.get(code)
items.append(
{
"code": code,
"name": name,
"member_count": int(_number(row.get("count"))),
"change": round(_number((quote or {}).get("pct_change")), 2)
if quote
else None,
"close": round(_number((quote or {}).get("close")), 3) if quote else None,
"turnover_rate": round(_number((quote or {}).get("turnover_rate")), 2)
if quote
else None,
"hot_rank": hot.get(code),
"has_quote": bool(quote),
}
)
items.sort(
key=lambda item: (
bool(item["has_quote"]),
item["hot_rank"] is not None,
-(item["hot_rank"] or 9999),
_number(item["change"], -999),
),
reverse=True,
)
quoted = [item for item in items if item["has_quote"]]
return {
"trade_date": trade_date,
"summary": {
"theme_count": len(items),
"quoted_count": len(quoted),
"up_count": sum(_number(item["change"]) > 0 for item in quoted),
"down_count": sum(_number(item["change"]) < 0 for item in quoted),
"hot_count": len(hot),
},
"items": items,
}
def build_theme_detail(
trade_date: str,
theme: dict[str, Any],
member_rows: tuple[dict[str, Any], ...],
daily_rows: tuple[dict[str, Any], ...],
) -> dict[str, Any]:
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
members = []
seen = set()
for row in member_rows:
identifier = str(row.get("con_code") or "")
if not identifier or identifier in seen:
continue
seen.add(identifier)
quote = daily.get(identifier)
members.append(
{
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(row.get("con_name") or ""),
"change": round(_number((quote or {}).get("pct_chg")), 2)
if quote
else None,
"close": round(_number((quote or {}).get("close")), 2) if quote else None,
"amount": _number((quote or {}).get("amount")) * 1000 if quote else None,
"quoted": bool(quote),
}
)
members.sort(
key=lambda item: (
bool(item["quoted"]),
_number(item["change"], -999),
_number(item["amount"]),
),
reverse=True,
)
quoted = [item for item in members if item["quoted"]]
return {
"trade_date": trade_date,
"theme": theme,
"summary": {
"member_count": len(members),
"quoted_count": len(quoted),
"up_count": sum(_number(item["change"]) > 0 for item in quoted),
"down_count": sum(_number(item["change"]) < 0 for item in quoted),
"turnover_rate": theme.get("turnover_rate"),
},
"members": members,
}
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