311 lines
11 KiB
Python
311 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from statistics import mean
|
|
from typing import Any
|
|
|
|
from backend.data.contracts import ProviderResult
|
|
|
|
|
|
def build_snapshot(
|
|
trade_date: str,
|
|
previous_trade_date: str,
|
|
inputs: dict[str, ProviderResult | dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
daily_rows = _rows(inputs, "daily")
|
|
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
|
|
limits = _pool(_rows(inputs, "limit_up"), "涨停")
|
|
broken = _pool(_rows(inputs, "broken"), "炸板")
|
|
down_limits = _pool(_rows(inputs, "limit_down"), "跌停")
|
|
previous_limits = _pool(_rows(inputs, "previous_limit_up"), "涨停")
|
|
price_limits = {str(row.get("ts_code") or ""): row for row in _rows(inputs, "price_limits")}
|
|
for row in broken:
|
|
up_limit = _number(price_limits.get(row["identifier"], {}).get("up_limit"))
|
|
row["distance_to_limit"] = (
|
|
round((up_limit - row["price"]) / up_limit * 100, 2) if up_limit else None
|
|
)
|
|
|
|
yesterday = _yesterday(
|
|
previous_limits,
|
|
daily,
|
|
limits,
|
|
broken,
|
|
down_limits,
|
|
)
|
|
up_count = sum(_number(row.get("pct_chg")) > 0 for row in daily_rows)
|
|
down_count = sum(_number(row.get("pct_chg")) < 0 for row in daily_rows)
|
|
flat_count = len(daily_rows) - up_count - down_count
|
|
amount = sum(_amount_yuan(row) for row in daily_rows)
|
|
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
|
|
sectors = _sectors(limits)
|
|
previous_sectors = _sectors(previous_limits)
|
|
overview = {
|
|
"up_count": up_count,
|
|
"down_count": down_count,
|
|
"flat_count": flat_count,
|
|
"limit_up": len(limits),
|
|
"limit_down": len(down_limits),
|
|
"broken": len(broken),
|
|
"seal_rate": round(seal_rate, 1),
|
|
"amount": round(amount, 2),
|
|
}
|
|
return {
|
|
"trade_date": trade_date,
|
|
"previous_trade_date": previous_trade_date,
|
|
"overview": overview,
|
|
"limits": limits,
|
|
"broken": broken,
|
|
"down_limits": down_limits,
|
|
"yesterday_limits": yesterday,
|
|
"limit_performance": _performance(yesterday),
|
|
"ladders": _ladders(limits),
|
|
"sectors": sectors,
|
|
"sector_rotation": _rotation(sectors, previous_sectors),
|
|
}
|
|
|
|
|
|
def build_realtime_inputs(
|
|
inputs: dict[str, ProviderResult | dict[str, Any]],
|
|
directory: dict[str, dict[str, Any]],
|
|
) -> dict[str, ProviderResult | dict[str, Any]]:
|
|
daily_result = inputs.get("daily")
|
|
limits_result = inputs.get("price_limits")
|
|
previous_result = inputs.get("previous_limit_up")
|
|
if not isinstance(daily_result, ProviderResult) or not isinstance(
|
|
limits_result, ProviderResult
|
|
):
|
|
return inputs
|
|
prices = {str(row.get("ts_code") or ""): row for row in limits_result.rows}
|
|
previous = {
|
|
str(row.get("ts_code") or ""): row
|
|
for row in previous_result.rows
|
|
} if isinstance(previous_result, ProviderResult) else {}
|
|
pools: dict[str, list[dict[str, Any]]] = {
|
|
"limit_up": [],
|
|
"limit_down": [],
|
|
"broken": [],
|
|
}
|
|
for quote in daily_result.rows:
|
|
identifier = str(quote.get("ts_code") or "")
|
|
price = prices.get(identifier) or {}
|
|
current = _number(quote.get("close"))
|
|
high = _number(quote.get("high"))
|
|
up_limit = _number(price.get("up_limit"))
|
|
down_limit = _number(price.get("down_limit"))
|
|
event_type = ""
|
|
if up_limit > 0 and current >= up_limit - 0.001:
|
|
event_type = "limit_up"
|
|
elif up_limit > 0 and high >= up_limit - 0.001:
|
|
event_type = "broken"
|
|
elif down_limit > 0 and current <= down_limit + 0.001:
|
|
event_type = "limit_down"
|
|
if not event_type:
|
|
continue
|
|
identity = directory.get(identifier) or {}
|
|
prior_streak = int(_number(previous.get(identifier, {}).get("limit_times")))
|
|
pools[event_type].append(
|
|
{
|
|
**quote,
|
|
"name": str(quote.get("name") or identity.get("name") or ""),
|
|
"industry": str(identity.get("sector") or ""),
|
|
"first_time": "",
|
|
"last_time": "",
|
|
"open_times": 0,
|
|
"limit_times": prior_streak + 1 if event_type == "limit_up" else 1,
|
|
"fd_amount": 0,
|
|
}
|
|
)
|
|
metadata = daily_result.metadata
|
|
return {
|
|
**inputs,
|
|
"limit_up": ProviderResult(tuple(pools["limit_up"]), metadata),
|
|
"limit_down": ProviderResult(tuple(pools["limit_down"]), metadata),
|
|
"broken": ProviderResult(tuple(pools["broken"]), metadata),
|
|
}
|
|
|
|
|
|
def _rows(
|
|
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
|
|
) -> tuple[dict[str, Any], ...]:
|
|
value = inputs.get(key)
|
|
return value.rows if isinstance(value, ProviderResult) else ()
|
|
|
|
|
|
def _pool(rows: tuple[dict[str, Any], ...], status: str) -> list[dict[str, Any]]:
|
|
result = []
|
|
for row in rows:
|
|
identifier = str(row.get("ts_code") or "")
|
|
amount = _amount_yuan(row)
|
|
result.append(
|
|
{
|
|
"identifier": identifier,
|
|
"code": identifier.split(".")[0],
|
|
"name": str(row.get("name") or "").strip(),
|
|
"price": _number(row.get("close")),
|
|
"change": _number(row.get("pct_chg")),
|
|
"sector": str(row.get("industry") or "").strip(),
|
|
"reason": "",
|
|
"first_time": _time(row.get("first_time")),
|
|
"last_time": _time(row.get("last_time")),
|
|
"open_times": int(_number(row.get("open_times"))),
|
|
"streak": max(1, int(_number(row.get("limit_times"), 1))),
|
|
"turnover_rate": _number(row.get("turnover_ratio")),
|
|
"amount": amount,
|
|
"seal_amount": _number(row.get("fd_amount")),
|
|
"float_market_value": _number(row.get("float_mv")),
|
|
"status": status,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _yesterday(
|
|
previous: list[dict[str, Any]],
|
|
daily: dict[str, dict[str, Any]],
|
|
current: list[dict[str, Any]],
|
|
broken: list[dict[str, Any]],
|
|
down: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
current_map = {row["identifier"]: row for row in current}
|
|
broken_codes = {row["identifier"] for row in broken}
|
|
down_codes = {row["identifier"] for row in down}
|
|
result = []
|
|
for prior in previous:
|
|
identifier = prior["identifier"]
|
|
quote = daily.get(identifier, {})
|
|
change = _number(quote.get("pct_chg"))
|
|
if identifier in current_map:
|
|
outcome = "晋级"
|
|
elif identifier in broken_codes:
|
|
outcome = "炸板"
|
|
elif identifier in down_codes:
|
|
outcome = "跌停"
|
|
elif change > 0:
|
|
outcome = "红盘"
|
|
else:
|
|
outcome = "断板"
|
|
result.append(
|
|
{
|
|
"identifier": identifier,
|
|
"code": prior["code"],
|
|
"name": prior["name"],
|
|
"prior_streak": prior["streak"],
|
|
"current_streak": current_map.get(identifier, {}).get("streak", 0),
|
|
"current_change": change,
|
|
"current_price": _number(quote.get("close")),
|
|
"sector": prior["sector"],
|
|
"reason": prior["reason"],
|
|
"outcome": outcome,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
result = []
|
|
for level in sorted({int(row["prior_streak"]) for row in rows}, reverse=True):
|
|
group = [row for row in rows if int(row["prior_streak"]) == level]
|
|
advanced = sum(row["outcome"] == "晋级" for row in group)
|
|
positive = sum(_number(row["current_change"]) > 0 for row in group)
|
|
outcomes = {
|
|
outcome: sum(row["outcome"] == outcome for row in group)
|
|
for outcome in ("晋级", "红盘", "断板", "炸板", "跌停")
|
|
}
|
|
result.append(
|
|
{
|
|
"level": level,
|
|
"count": len(group),
|
|
"advanced": advanced,
|
|
"red": outcomes["红盘"],
|
|
"broken": outcomes["断板"],
|
|
"opened": outcomes["炸板"],
|
|
"limit_down": outcomes["跌停"],
|
|
"advance_rate": round(advanced / len(group) * 100, 1),
|
|
"positive_rate": round(positive / len(group) * 100, 1),
|
|
"average_change": round(mean(_number(row["current_change"]) for row in group), 2),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _ladders(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
levels = sorted({int(row["streak"]) for row in rows}, reverse=True)
|
|
return [
|
|
{
|
|
"level": level,
|
|
"label": "首板" if level == 1 else f"{level}板",
|
|
"count": sum(int(row["streak"]) == level for row in rows),
|
|
"stocks": sorted(
|
|
(row for row in rows if int(row["streak"]) == level),
|
|
key=lambda row: row.get("first_time") or "99:99",
|
|
),
|
|
}
|
|
for level in levels
|
|
]
|
|
|
|
|
|
def _sectors(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
names = sorted({str(row.get("sector") or "").strip() for row in rows} - {""})
|
|
result = []
|
|
for name in names:
|
|
stocks = [row for row in rows if str(row.get("sector") or "").strip() == name]
|
|
leader = max(stocks, key=lambda row: (int(row["streak"]), _number(row["amount"])))
|
|
max_streak = max(int(row["streak"]) for row in stocks)
|
|
count = len(stocks)
|
|
result.append(
|
|
{
|
|
"name": name,
|
|
"count": count,
|
|
"strength": min(100, 44 + count * 8 + max_streak * 5),
|
|
"amount": round(sum(_number(row["amount"]) for row in stocks), 2),
|
|
"leader": leader["name"],
|
|
"representative": leader["identifier"],
|
|
"change": round(mean(_number(row["change"]) for row in stocks), 2),
|
|
"max_streak": max_streak,
|
|
}
|
|
)
|
|
return sorted(
|
|
result,
|
|
key=lambda row: (int(row["count"]), int(row["max_streak"]), _number(row["amount"])),
|
|
reverse=True,
|
|
)[:20]
|
|
|
|
|
|
def _rotation(
|
|
current: list[dict[str, Any]], previous: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
previous_map = {str(row["name"]): row for row in previous}
|
|
result = []
|
|
for rank, sector in enumerate(current[:12], start=1):
|
|
previous_count = int(previous_map.get(str(sector["name"]), {}).get("count") or 0)
|
|
delta = int(sector["count"]) - previous_count
|
|
result.append(
|
|
{
|
|
**sector,
|
|
"rank": rank,
|
|
"previous_count": previous_count,
|
|
"delta": delta,
|
|
"trend": "升温" if delta > 0 else "降温" if delta < 0 else "持平",
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
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
|
|
|
|
|
|
def _amount_yuan(row: dict[str, Any]) -> float:
|
|
amount = _number(row.get("amount"))
|
|
return amount if row.get("amount_unit") == "yuan" else amount * 1000
|
|
|
|
|
|
def _time(value: Any) -> str:
|
|
text = str(value or "").strip().replace(":", "")
|
|
if len(text) < 4 or not text[:4].isdigit():
|
|
return ""
|
|
return f"{text[:2]}:{text[2:4]}"
|