rebuild(stage-7): deliver ladder and sector rotation
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user