rebuild(stage-7): deliver ladder and sector rotation

This commit is contained in:
leefer
2026-07-30 03:28:24 +08:00
parent 31a53de890
commit a76d344a98
28 changed files with 1161 additions and 13 deletions
+15 -1
View File
@@ -10,6 +10,7 @@ from backend.features.market.schemas import (
MarketSummaryResponse,
MarketWorkspaceResponse,
ReferenceSyncResponse,
RotationMembersResponse,
SearchResponse,
SnapshotSyncResponse,
TradeContextResponse,
@@ -75,9 +76,22 @@ def workspace(
request: Request,
_principal: AuthenticatedPrincipal,
key: Annotated[
Literal["emotion", "pool", "broken", "limit-down", "yesterday", "performance"],
Literal[
"emotion", "pool", "broken", "limit-down", "yesterday", "performance",
"ladder", "rotation",
],
Path(),
],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.workspace(key, requested_date)
@router.get("/rotation-members", response_model=RotationMembersResponse)
def rotation_members(
request: Request,
_principal: AuthenticatedPrincipal,
sector_name: Annotated[str, Query(alias="sector", min_length=1, max_length=60)],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.rotation_members(sector_name, requested_date)
+10
View File
@@ -91,3 +91,13 @@ class MarketWorkspaceResponse(BaseModel):
sentiment: dict[str, Any] | None = None
history: list[dict[str, Any]] | None = None
items: list[dict[str, Any]] | None = None
class RotationMembersResponse(BaseModel):
trade_date: str
sector_name: str
sector_code: str
member_count: int = Field(ge=0)
quoted_count: int = Field(ge=0)
coverage: float = Field(ge=0, le=1)
items: list[dict[str, Any]] = Field(default_factory=list)
+10
View File
@@ -84,6 +84,16 @@ class MarketService:
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.workspace, key, requested_date)
def rotation_members(
self, sector_name: str, requested_date: str | None = None
) -> dict[str, Any]:
trade_date, representative = self._call(
self._snapshots.rotation_member_target, requested_date, sector_name
)
return self._call(
self._gateway.sector_members, trade_date, sector_name, representative
)
@staticmethod
def _call(function, *args):
try:
+70 -1
View File
@@ -16,6 +16,7 @@ def build_snapshot(
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"))
@@ -24,7 +25,7 @@ def build_snapshot(
)
yesterday = _yesterday(
_pool(_rows(inputs, "previous_limit_up"), "涨停"),
previous_limits,
daily,
limits,
broken,
@@ -35,6 +36,8 @@ def build_snapshot(
flat_count = len(daily_rows) - up_count - down_count
amount = sum(_number(row.get("amount")) * 1000 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,
@@ -54,6 +57,9 @@ def build_snapshot(
"down_limits": down_limits,
"yesterday_limits": yesterday,
"limit_performance": _performance(yesterday),
"ladders": _ladders(limits),
"sectors": sectors,
"sector_rotation": _rotation(sectors, previous_sectors),
}
@@ -161,6 +167,69 @@ def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
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)
+40 -5
View File
@@ -6,8 +6,8 @@ from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import ProviderResult, SnapshotState
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.market.sentiment import calculate_sentiment
@@ -22,11 +22,11 @@ class SnapshotSyncError(RuntimeError):
class MarketSnapshotService:
def __init__(
self, database: Database, repository: MarketRepository, provider: TushareProvider
self, database: Database, repository: MarketRepository, gateway: DataGateway
) -> None:
self._database = database
self._repository = repository
self._provider = provider
self._gateway = gateway
def sync(
self, requested_date: str | None = None, now: datetime | None = None
@@ -47,8 +47,8 @@ class MarketSnapshotService:
raise SnapshotSyncError("请先同步股票目录")
trade_date, previous_date = dates[0], dates[1]
try:
inputs = self._provider.snapshot_inputs(trade_date, previous_date)
except ProviderError as exc:
inputs = self._gateway.snapshot_inputs(trade_date, previous_date)
except (ProviderError, MarketDataUnavailable) as exc:
raise SnapshotSyncError("收盘行情读取失败,已保留原有快照") from exc
daily = inputs.get("daily")
if not isinstance(daily, ProviderResult):
@@ -126,10 +126,45 @@ class MarketSnapshotService:
response["items"] = payload.get("yesterday_limits") or []
elif key == "performance":
response["items"] = payload.get("limit_performance") or []
elif key == "ladder":
response["items"] = payload.get("ladders") or []
response["history"] = payload.get("limit_performance") or []
elif key == "rotation":
response["history"] = [
{
"trade_date": item_payload.get("trade_date"),
"sectors": item_payload.get("sector_rotation") or [],
}
for item_payload in (
json.loads(str(item["payload_json"])) for item in history_rows[-9:]
)
]
else:
raise SnapshotSyncError("不支持的市场工作区")
return response
def rotation_member_target(
self, requested_date: str | None, sector_name: str
) -> tuple[str, str]:
requested = _date(requested_date or datetime.now(SHANGHAI).date().isoformat())
with self._database.read() as connection:
row = self._repository.latest_summary(connection, requested)
if row is None:
raise SnapshotSyncError("等待管理员首次同步真实收盘行情")
payload = json.loads(str(row["payload_json"]))
sector = next(
(
item
for item in payload.get("sectors") or []
if str(item.get("name") or "") == sector_name
),
None,
)
representative = str((sector or {}).get("representative") or "")
if not representative:
raise SnapshotSyncError("该板块缺少可核验的代表股票")
return str(row["trade_date"]), representative
def _history_item(payload: dict[str, Any]) -> dict[str, Any]:
sentiment = payload.get("sentiment") or {}