rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user