303 lines
10 KiB
Python
303 lines
10 KiB
Python
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)
|