rebuild(stage-7): deliver ladder and sector rotation
This commit is contained in:
@@ -64,6 +64,6 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
||||
system_credentials=credentials,
|
||||
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
||||
market=MarketService(
|
||||
gateway, MarketSnapshotService(database, market_repository, tushare)
|
||||
gateway, MarketSnapshotService(database, market_repository, gateway)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,6 +107,68 @@ class DataGateway:
|
||||
row = self._repository.latest_summary(connection, context.actual_date)
|
||||
return {"context": context, "values": json.loads(str(row["payload_json"])) if row else None}
|
||||
|
||||
def snapshot_inputs(
|
||||
self, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, Any]:
|
||||
provider = self._provider(DataSource.TUSHARE)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
||||
|
||||
def sector_members(
|
||||
self, trade_date: str, sector_name: str, representative: str
|
||||
) -> dict[str, Any]:
|
||||
with self._database.read() as connection:
|
||||
cached = self._repository.sector_members(connection, trade_date, sector_name)
|
||||
if cached:
|
||||
return json.loads(str(cached["payload_json"]))
|
||||
provider = self._provider(DataSource.TUSHARE)
|
||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||
result = provider.sector_members(representative, trade_date)
|
||||
if not result.rows:
|
||||
raise MarketDataUnavailable("该板块暂无可核验的申万成分股")
|
||||
rows = sorted(
|
||||
(dict(row) for row in result.rows),
|
||||
key=lambda row: (
|
||||
bool(row.get("quoted")),
|
||||
_number(row.get("change")),
|
||||
_number(row.get("amount")),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
payload = {
|
||||
"trade_date": trade_date,
|
||||
"sector_name": str(rows[0].get("sector_name") or sector_name),
|
||||
"sector_code": str(rows[0].get("sector_code") or ""),
|
||||
"member_count": len(rows),
|
||||
"quoted_count": sum(bool(row.get("quoted")) for row in rows),
|
||||
"coverage": round(result.metadata.coverage, 4),
|
||||
"items": [
|
||||
{
|
||||
"identifier": str(row.get("ts_code") or ""),
|
||||
"code": str(row.get("ts_code") or "").split(".")[0],
|
||||
"name": str(row.get("name") or ""),
|
||||
"change": row.get("change"),
|
||||
"open": row.get("open"),
|
||||
"close": row.get("close"),
|
||||
"amount": row.get("amount"),
|
||||
"quoted": bool(row.get("quoted")),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
with self._database.transaction() as connection:
|
||||
self._repository.save_sector_members(
|
||||
connection,
|
||||
trade_date=trade_date,
|
||||
sector_name=sector_name,
|
||||
sector_code=payload["sector_code"],
|
||||
observed_at=result.metadata.observed_at.isoformat(timespec="seconds"),
|
||||
source=result.metadata.source.value,
|
||||
coverage=result.metadata.coverage,
|
||||
payload=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
def search(self, query: str) -> tuple[MarketEntity, ...]:
|
||||
with self._database.read() as connection:
|
||||
return self._repository.search(connection, query)
|
||||
|
||||
@@ -26,3 +26,5 @@ class MarketDataProvider(Protocol):
|
||||
def snapshot_inputs(
|
||||
self, trade_date: str, previous_trade_date: str
|
||||
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
||||
|
||||
@@ -99,6 +99,9 @@ class EastmoneyProvider:
|
||||
) -> dict[str, ProviderResult | dict[str, object]]:
|
||||
raise ProviderError("The display provider cannot build market snapshots")
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("The display provider is not the constituent authority")
|
||||
|
||||
@staticmethod
|
||||
def _secid(entity_type: str, identifier: str) -> str:
|
||||
if entity_type == "index" and identifier in INDEX_CODES:
|
||||
|
||||
@@ -89,6 +89,9 @@ class IfindProvider:
|
||||
) -> dict[str, ProviderResult | dict[str, Any]]:
|
||||
raise ProviderError("iFinD is not the post-close snapshot authority")
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
||||
|
||||
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not self.configured:
|
||||
raise ProviderError("实时行情服务尚未配置")
|
||||
|
||||
@@ -130,6 +130,82 @@ class TushareProvider:
|
||||
)
|
||||
return datasets
|
||||
|
||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||
target = _compact(trade_date)
|
||||
memberships = self._membership_rows({"ts_code": representative})
|
||||
active = [row for row in memberships if _active_on(row, target)]
|
||||
if not active:
|
||||
raise ProviderError("未找到该股票在目标日期的申万行业")
|
||||
industry = max(
|
||||
active,
|
||||
key=lambda row: (
|
||||
str(row.get("in_date") or ""),
|
||||
str(row.get("l2_code") or ""),
|
||||
),
|
||||
)
|
||||
sector_code = str(industry.get("l2_code") or "")
|
||||
sector_name = str(industry.get("l2_name") or "").strip()
|
||||
if not sector_code:
|
||||
raise ProviderError("该股票缺少申万二级行业")
|
||||
members = [
|
||||
row
|
||||
for row in self._membership_rows({"l2_code": sector_code})
|
||||
if _active_on(row, target)
|
||||
]
|
||||
deduplicated: dict[str, dict[str, Any]] = {}
|
||||
for row in members:
|
||||
code = str(row.get("ts_code") or "")
|
||||
current = deduplicated.get(code)
|
||||
if code and (
|
||||
current is None
|
||||
or str(row.get("in_date") or "") > str(current.get("in_date") or "")
|
||||
):
|
||||
deduplicated[code] = row
|
||||
if not deduplicated:
|
||||
raise ProviderError("该申万行业没有有效成分股")
|
||||
daily = self._query(
|
||||
"daily",
|
||||
{"trade_date": target},
|
||||
"ts_code,trade_date,open,close,pct_chg,amount",
|
||||
unit="mixed",
|
||||
)
|
||||
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
|
||||
rows = []
|
||||
for code, member in deduplicated.items():
|
||||
quote = quote_map.get(code) or {}
|
||||
rows.append(
|
||||
{
|
||||
"sector_code": sector_code,
|
||||
"sector_name": sector_name,
|
||||
"ts_code": code,
|
||||
"name": str(member.get("name") or "").strip(),
|
||||
"change": _number(quote.get("pct_chg")) if quote else None,
|
||||
"open": _number(quote.get("open")) if quote else None,
|
||||
"close": _number(quote.get("close")) if quote else None,
|
||||
"amount": _number(quote.get("amount")) * 1000 if quote else None,
|
||||
"quoted": bool(quote),
|
||||
}
|
||||
)
|
||||
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
||||
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
||||
|
||||
def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
fields = (
|
||||
"l1_code,l1_name,l2_code,l2_name,l3_code,l3_name,"
|
||||
"ts_code,name,in_date,out_date,is_new"
|
||||
)
|
||||
for is_new in ("Y", "N"):
|
||||
result = self._query(
|
||||
"index_member_all",
|
||||
{**params, "is_new": is_new},
|
||||
fields,
|
||||
unit="membership",
|
||||
empty_is_complete=True,
|
||||
)
|
||||
rows.extend(result.rows)
|
||||
return rows
|
||||
|
||||
def _query(
|
||||
self,
|
||||
api_name: str,
|
||||
@@ -197,3 +273,17 @@ def _compact(value: str) -> str:
|
||||
def _display(value: str) -> str:
|
||||
compact = _compact(value)
|
||||
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
|
||||
|
||||
|
||||
def _active_on(row: dict[str, Any], trade_date: str) -> bool:
|
||||
start = str(row.get("in_date") or "")
|
||||
end = str(row.get("out_date") or "")
|
||||
return (not start or start <= trade_date) and (not end or end > trade_date)
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
return number if number == number else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@@ -188,6 +188,52 @@ class MarketRepository:
|
||||
(through,),
|
||||
).fetchone()
|
||||
|
||||
def sector_members(
|
||||
self, connection: sqlite3.Connection, trade_date: str, sector_name: str
|
||||
) -> sqlite3.Row | None:
|
||||
return connection.execute(
|
||||
"""
|
||||
SELECT * FROM sector_member_snapshots
|
||||
WHERE trade_date = ? AND sector_name = ?
|
||||
""",
|
||||
(trade_date, sector_name),
|
||||
).fetchone()
|
||||
|
||||
def save_sector_members(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
trade_date: str,
|
||||
sector_name: str,
|
||||
sector_code: str,
|
||||
observed_at: str,
|
||||
source: str,
|
||||
coverage: float,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO sector_member_snapshots (
|
||||
trade_date, sector_name, sector_code, observed_at, source, coverage, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(trade_date, sector_name) DO UPDATE SET
|
||||
sector_code = excluded.sector_code,
|
||||
observed_at = excluded.observed_at,
|
||||
source = excluded.source,
|
||||
coverage = excluded.coverage,
|
||||
payload_json = excluded.payload_json
|
||||
""",
|
||||
(
|
||||
trade_date,
|
||||
sector_name,
|
||||
sector_code,
|
||||
observed_at,
|
||||
source,
|
||||
coverage,
|
||||
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
|
||||
),
|
||||
)
|
||||
|
||||
def save_chart(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
|
||||
def upgrade(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE sector_member_snapshots (
|
||||
trade_date TEXT NOT NULL,
|
||||
sector_name TEXT NOT NULL,
|
||||
sector_code TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
coverage REAL NOT NULL CHECK (coverage >= 0 AND coverage <= 1),
|
||||
payload_json TEXT NOT NULL,
|
||||
PRIMARY KEY (trade_date, sector_name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade(connection: sqlite3.Connection) -> None:
|
||||
connection.execute("DROP TABLE sector_member_snapshots")
|
||||
|
||||
|
||||
MIGRATION = Migration(
|
||||
version=4,
|
||||
name="create_sector_member_snapshots",
|
||||
signature="market:v2:dated-shenwan-sector-members",
|
||||
upgrade=upgrade,
|
||||
downgrade=downgrade,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS
|
||||
from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL
|
||||
from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION
|
||||
from backend.database.migrations.m0004_sector_members import MIGRATION as SECTOR_MEMBERS
|
||||
from backend.database.migrations.runner import Migration
|
||||
|
||||
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION)
|
||||
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION, SECTOR_MEMBERS)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 阶段 7 验收记录
|
||||
|
||||
## 交付范围
|
||||
|
||||
- 市场天梯:动态连板层级、断层保留、等宽个股单元格、封板时间/开板次数排序、展开收起与 CSV 导出。
|
||||
- 板块轮动:九个交易日 Top12、远近日期切换、跨日板块高亮、强度层级与指定交易日成分股。
|
||||
- 涨停表现回归:沿用阶段 6 的唯一实现,本阶段只做跨页回归,不复制统计公式。
|
||||
- 桌面端与 390px 移动端均使用全页滚动,内容不横向溢出。
|
||||
|
||||
## 数据与结构
|
||||
|
||||
- 天梯和板块轮动均消费阶段 6 的同一份日度市场快照,不建立页面专属快照表。
|
||||
- 板块轮动由当日涨停股按行业聚合,统一计算涨停家数、最高连板、成交额、领涨股、强度与相邻交易日趋势。
|
||||
- 九日轨迹读取已归档的日度快照,不在前端重复推导。
|
||||
- 成分股按请求交易日读取申万二级成员关系和日线行情;停牌或缺少行情的成员保留在结果末尾,字段保持空值。
|
||||
- 数据库版本 4 只增加 `sector_member_snapshots` 成分股快照缓存,避免重复外部请求;未引入通用缓存框架。
|
||||
- `MarketSnapshotService` 已收敛为只调用 `DataGateway`,移除其直接依赖行情供应商的出口。
|
||||
|
||||
## 自动验收
|
||||
|
||||
- Ruff:通过。
|
||||
- Pytest:50 项通过。
|
||||
- Vue TypeScript 检查:通过。
|
||||
- Vitest:2 个文件、5 项通过。
|
||||
- Vite 生产构建:通过。
|
||||
- Playwright:5 项通过,覆盖阶段 4 至 7 的完整回归。
|
||||
- 夜间模式切换即时使用最终设计令牌,不保留板块热度背景的中间过渡色。
|
||||
- 敏感值扫描:已提供的账号密码和令牌未进入 `next/`。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
## 视觉证据
|
||||
|
||||
- `ladder-light-1920x1080.jpg`:市场天梯日间桌面视图。
|
||||
- `rotation-dark-1920x1080.jpg`:板块轮动夜间桌面视图。
|
||||
- `rotation-dark-390x844.jpg`:板块轮动夜间移动视图。
|
||||
|
||||
## 减法证据
|
||||
|
||||
- 天梯、轮动、情绪与股池只使用一份权威日度快照。
|
||||
- 涨停表现没有因阶段范围重叠而形成第二套实现。
|
||||
- 成分股接口、Schema、持久化和前端请求均只有一个出口。
|
||||
- 新增页面分别为 106 行和 126 行;市场页面样式总计 415 行,均低于章程门禁。
|
||||
- 未复制旧系统巨型文件、兼容层、供应商客户端或页面专属数据网关。
|
||||
|
||||
## 剩余边界
|
||||
|
||||
- 阶段 8 才迁移集合竞价、题材库、人气热榜和龙虎榜。
|
||||
- 当前外部数据的真实可用性仍由管理员凭据和供应商权限决定;质量门失败时不生成模拟业务数据。
|
||||
- NAS 生产容器保持不变,最终切换仍需人工明确确认。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -18,7 +18,7 @@ const locked = computed(
|
||||
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||
);
|
||||
const implementedMarket = computed(() =>
|
||||
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
|
||||
["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes(
|
||||
workspace.value.key,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import EmotionPage from "./emotion/EmotionPage.vue";
|
||||
import LadderPage from "./structure/LadderPage.vue";
|
||||
import RotationPage from "./structure/RotationPage.vue";
|
||||
import PerformancePage from "./pools/PerformancePage.vue";
|
||||
import PoolPage from "./pools/PoolPage.vue";
|
||||
|
||||
@@ -38,6 +40,8 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
|
||||
<EmptyState v-else-if="error" title="页面数据暂不可用" :description="error" class="card" />
|
||||
<EmptyState v-else-if="!data?.trade_date" title="暂无真实行情快照" :description="data?.message || '等待管理员首次同步真实收盘行情'" class="card" />
|
||||
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
|
||||
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
|
||||
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
|
||||
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
|
||||
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const sortMode = ref<"time" | "open">("time");
|
||||
const expanded = reactive(new Set<number>());
|
||||
const sourceGroups = computed(() => props.data.items ?? []);
|
||||
const maxLevel = computed(() => Math.max(0, ...sourceGroups.value.map((group) => Number(group.level ?? 0))));
|
||||
const topLevel = computed(() => Math.max(5, maxLevel.value));
|
||||
const groups = computed(() => {
|
||||
const byLevel = new Map(sourceGroups.value.map((group) => [Number(group.level), group]));
|
||||
const normalized = Array.from({ length: topLevel.value }, (_, index) => {
|
||||
const level = topLevel.value - index;
|
||||
const source = byLevel.get(level) ?? { level, label: level === 1 ? "首板" : `${level}板`, count: 0, stocks: [] };
|
||||
const stocks = [...((source.stocks as Record<string, unknown>[] | undefined) ?? [])].sort((left, right) => {
|
||||
if (sortMode.value === "open") {
|
||||
return Number(left.open_times ?? 0) - Number(right.open_times ?? 0)
|
||||
|| String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
|
||||
}
|
||||
return String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
|
||||
});
|
||||
const limit = level <= 2 ? 8 : stocks.length;
|
||||
return {
|
||||
level,
|
||||
label: String(source.label ?? (level === 1 ? "首板" : `${level}板`)),
|
||||
count: Number(source.count ?? stocks.length),
|
||||
stocks,
|
||||
visible: expanded.has(level) ? stocks : stocks.slice(0, limit),
|
||||
hidden: Math.max(0, stocks.length - limit),
|
||||
};
|
||||
});
|
||||
return normalized.map((group) => {
|
||||
const lower = normalized.find((item) => item.level === group.level - 1);
|
||||
return {
|
||||
...group,
|
||||
adjacentRate: lower?.count ? group.count / lower.count * 100 : null,
|
||||
};
|
||||
});
|
||||
});
|
||||
const completeness = computed(() => maxLevel.value
|
||||
? sourceGroups.value.filter((group) => Number(group.count ?? 0) > 0).length / maxLevel.value * 100
|
||||
: 0);
|
||||
const priorPerformance = computed(() => props.data.history ?? []);
|
||||
const priorHeight = computed(() => Math.max(0, ...priorPerformance.value.map((row) => Number(row.level ?? 0))));
|
||||
const heightMessage = computed(() => {
|
||||
if (!maxLevel.value) return "当前没有连板梯队。";
|
||||
if (!priorPerformance.value.length) return "暂无昨日梯队样本,先观察当前结构。";
|
||||
if (maxLevel.value > priorHeight.value) return `较昨日${priorHeight.value}板抬升,市场空间打开。`;
|
||||
if (maxLevel.value < priorHeight.value) return `较昨日${priorHeight.value}板回落,市场空间压缩。`;
|
||||
return "市场高度与昨日接近,继续观察梯队承接。";
|
||||
});
|
||||
|
||||
function toggle(level: number): void {
|
||||
if (expanded.has(level)) expanded.delete(level);
|
||||
else expanded.add(level);
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
const lines = [["高度", "代码", "股票", "板块", "首封", "开板次数", "成交额(亿)"]];
|
||||
for (const group of groups.value) {
|
||||
for (const stock of group.stocks) {
|
||||
lines.push([
|
||||
`${group.level}板`, String(stock.code ?? ""), String(stock.name ?? ""),
|
||||
String(stock.sector ?? ""), String(stock.first_time ?? ""), String(stock.open_times ?? ""),
|
||||
(Number(stock.amount ?? 0) / 100_000_000).toFixed(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
|
||||
link.download = `市场天梯-${props.data.trade_date}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>市场天梯</h1><p class="page-subtitle">按连板高度观察空间与梯队完整度 · 数据日期 {{ data.trade_date }}</p></div>
|
||||
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: sortMode === 'time' }" @click="sortMode = 'time'">按封板时间</button><button type="button" :class="{ active: sortMode === 'open' }" @click="sortMode = 'open'">按开板次数</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
|
||||
</header>
|
||||
<section class="ladder-layout">
|
||||
<div class="card ladder-board">
|
||||
<section v-for="group in groups" :key="group.level" class="ladder-level" :class="{ 'is-gap': !Number(group.count) }">
|
||||
<header><div><strong>{{ group.level === 1 ? "首板" : `${group.level}板` }}</strong><span>{{ group.count }}只</span></div><small v-if="group.level > 1 && Number(group.count)">{{ group.adjacentRate === null ? "相邻梯队断层" : `相邻梯队 ${group.adjacentRate.toFixed(1)}%` }}</small></header>
|
||||
<div class="ladder-stocks">
|
||||
<article v-for="stock in group.visible" :key="String(stock.identifier)" class="ladder-stock">
|
||||
<div><strong>{{ stock.name }}</strong><span>{{ stock.code }}</span></div>
|
||||
<p><b>{{ stock.sector || "其他" }}</b><span>{{ stock.first_time || "时间待校正" }}</span></p>
|
||||
<small>开板 {{ stock.open_times }}次 · 成交 {{ (Number(stock.amount ?? 0) / 100_000_000).toFixed(2) }}亿</small>
|
||||
</article>
|
||||
<p v-if="!group.visible.length" class="ladder-gap-note">该层暂时空缺</p>
|
||||
<button v-if="group.stocks.length > 8 && group.level <= 2" class="ladder-expand" type="button" @click="toggle(group.level)">{{ expanded.has(group.level) ? "收起" : `展开更多(${group.hidden})` }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="ladder-insights">
|
||||
<article class="card"><header class="card-header"><h2>市场高度</h2></header><div class="ladder-insight-body"><strong>{{ maxLevel ? `${maxLevel}板` : "--" }}</strong><p>{{ heightMessage }}</p></div></article>
|
||||
<article class="card"><header class="card-header"><h2>梯队完整度</h2></header><div class="ladder-insight-body"><strong>{{ completeness.toFixed(1) }}%</strong><div class="score-track"><span :style="{ width: `${completeness}%` }"></span></div><p>从首板到最高板之间存在的真实高度比例。</p></div></article>
|
||||
<article class="card"><header class="card-header"><h2>昨日梯队 → 今日</h2></header><div class="ladder-rate-list"><div v-for="row in priorPerformance" :key="String(row.level)"><span>昨日{{ row.level }}板</span><div class="score-track"><span :style="{ width: `${row.advance_rate}%` }"></span></div><strong>{{ row.advance_rate }}%</strong></div></div></article>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import { marketApi, type MarketWorkspaceData, type RotationMembersData } from "../../../shared/api/market";
|
||||
import DataTable from "../../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../../shared/components/EmptyState.vue";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const order = ref<"oldest" | "latest">("oldest");
|
||||
const selectedSector = ref("");
|
||||
const selectedDate = ref("");
|
||||
const members = ref<RotationMembersData | null>(null);
|
||||
const memberLoading = ref(false);
|
||||
const memberError = ref("");
|
||||
const history = computed(() => order.value === "oldest" ? props.data.history ?? [] : [...(props.data.history ?? [])].reverse());
|
||||
const appearances = computed(() => (props.data.history ?? []).map((day) => ({
|
||||
trade_date: day.trade_date,
|
||||
sector: ((day.sectors as Record<string, unknown>[] | undefined) ?? []).find((item) => item.name === selectedSector.value),
|
||||
})).filter((item) => item.sector));
|
||||
const memberColumns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: percent },
|
||||
{ key: "open", label: "开盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
|
||||
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
|
||||
];
|
||||
const sortKey = ref("change");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
const memberRows = computed(() => {
|
||||
const rows = members.value?.items ?? [];
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => {
|
||||
if (Boolean(left.quoted) !== Boolean(right.quoted)) return left.quoted ? -1 : 1;
|
||||
return compare(left[sortKey.value], right[sortKey.value]) * direction;
|
||||
});
|
||||
});
|
||||
|
||||
async function selectSector(sector: Record<string, unknown>, tradeDate: unknown): Promise<void> {
|
||||
const name = String(sector.name ?? "");
|
||||
const date = String(tradeDate ?? "");
|
||||
if (name === selectedSector.value && date === selectedDate.value) {
|
||||
selectedSector.value = ""; selectedDate.value = ""; members.value = null; memberError.value = "";
|
||||
return;
|
||||
}
|
||||
selectedSector.value = name;
|
||||
selectedDate.value = date;
|
||||
memberLoading.value = true;
|
||||
memberError.value = "";
|
||||
try {
|
||||
members.value = await marketApi.rotationMembers(name, date);
|
||||
} catch (error) {
|
||||
members.value = null;
|
||||
memberError.value = error instanceof Error ? error.message : "板块成分股读取失败";
|
||||
} finally {
|
||||
memberLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
|
||||
function exportCsv(): void {
|
||||
const lines = [["日期", "排名", "板块", "涨停家数", "强度", "领涨股"]];
|
||||
for (const day of props.data.history ?? []) {
|
||||
for (const sector of (day.sectors as Record<string, unknown>[] | undefined) ?? []) {
|
||||
lines.push([String(day.trade_date ?? ""), String(sector.rank ?? ""), String(sector.name ?? ""), String(sector.count ?? ""), String(sector.strength ?? ""), String(sector.leader ?? "")]);
|
||||
}
|
||||
}
|
||||
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
|
||||
link.download = `板块轮动-${props.data.trade_date}.csv`;
|
||||
link.click(); URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function compare(left: unknown, right: unknown): number {
|
||||
const a = Number(left); const b = Number(right);
|
||||
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
|
||||
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
|
||||
}
|
||||
function numeric(value: unknown, digits: number): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number.toFixed(digits) : "";
|
||||
}
|
||||
function percent(value: unknown): string { return numeric(value, 2); }
|
||||
function decimal(value: unknown): string { return numeric(value, 2); }
|
||||
function amount(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? (number / 100_000_000).toFixed(2) : "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-header market-page-header">
|
||||
<div><h1>板块轮动</h1><p class="page-subtitle">最近9个交易日每日Top12热点 · 数据截至 {{ data.trade_date }}</p></div>
|
||||
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: order === 'oldest' }" @click="order = 'oldest'">由远到近</button><button type="button" :class="{ active: order === 'latest' }" @click="order = 'latest'">由近到远</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
|
||||
</header>
|
||||
<section class="card rotation-trajectory">
|
||||
<header class="card-header"><h2>热点轨迹</h2><span class="faint">{{ selectedSector ? `已联动高亮 ${selectedSector}` : "点击任意板块追踪其连续性" }}</span><span class="tag rotation-top-tag">每日Top12热点</span></header>
|
||||
<div class="rotation-legend"><span><i class="heat-high"></i>强度高(90+)</span><span><i class="heat-medium"></i>强度中(70–89)</span><span><i class="heat-low"></i>强度低(70以下)</span></div>
|
||||
<div v-if="selectedSector" class="rotation-tracker"><strong>{{ selectedSector }}</strong><span>近9日在榜 {{ appearances.length }}天 · 最高排名 {{ appearances.length ? Math.min(...appearances.map((item) => Number(item.sector?.rank ?? 99))) : "--" }}</span><button class="btn btn-small" type="button" @click="selectedSector = ''; selectedDate = ''; members = null">取消追踪</button></div>
|
||||
<div class="rotation-days">
|
||||
<article v-for="day in history" :key="String(day.trade_date)" class="rotation-day">
|
||||
<header><time>{{ String(day.trade_date).slice(5) }}</time><span>{{ ((day.sectors as unknown[]) ?? []).length }}个热点</span></header>
|
||||
<div>
|
||||
<button v-for="sector in (day.sectors as Record<string, unknown>[]) ?? []" :key="String(sector.name)" type="button" class="rotation-sector" :class="[{ selected: selectedSector === sector.name }, Number(sector.strength) >= 90 ? 'heat-high' : Number(sector.strength) >= 70 ? 'heat-medium' : 'heat-low']" @click="selectSector(sector, day.trade_date)">
|
||||
<span>{{ sector.rank }}</span><strong>{{ sector.name }}</strong><small>{{ sector.count }}家 · {{ Number(sector.strength).toFixed(0) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card rotation-members">
|
||||
<header class="card-header"><h2>{{ members?.sector_name ? `${members.sector_name}成分股` : "板块成分股" }}</h2><span class="faint">{{ members ? `${members.trade_date} · ${members.quoted_count}/${members.member_count}只` : selectedSector || "点击上方任意板块查看成分股" }}</span></header>
|
||||
<div v-if="memberLoading" class="workspace-state">正在核验板块成分行情</div>
|
||||
<EmptyState v-else-if="memberError" title="成分行情暂不可用" :description="memberError" />
|
||||
<DataTable v-else-if="memberRows.length" :columns="memberColumns" :rows="memberRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="尚未选择板块" description="点击热点轨迹中的板块后显示目标日期的申万成分股。" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -67,6 +67,16 @@ export type MarketWorkspaceData = {
|
||||
items?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export type RotationMembersData = {
|
||||
trade_date: string;
|
||||
sector_name: string;
|
||||
sector_code: string;
|
||||
member_count: number;
|
||||
quoted_count: number;
|
||||
coverage: number;
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
export const marketApi = {
|
||||
summary(date?: string): Promise<MarketSummary> {
|
||||
const query = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||
@@ -88,4 +98,9 @@ export const marketApi = {
|
||||
syncSnapshot(date: string): Promise<{ trade_date: string; temperature: number }> {
|
||||
return api.post(`/market/snapshot-sync?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
rotationMembers(sector: string, date: string): Promise<RotationMembersData> {
|
||||
return api.get<RotationMembersData>(
|
||||
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,3 +116,300 @@
|
||||
.breadth-up { background: var(--color-up); }
|
||||
.breadth-flat { background: var(--color-text-faint); }
|
||||
.breadth-down { background: var(--color-down); }
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.page-actions .seg-control {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.ladder-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-320);
|
||||
gap: var(--layout-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ladder-board {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ladder-level {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-200) minmax(0, 1fr);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ladder-level:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ladder-level > header {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-12) var(--s-14);
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.ladder-level.is-gap > header,
|
||||
.ladder-level.is-gap .ladder-stocks {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.ladder-level > header div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.ladder-level > header strong {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-15);
|
||||
}
|
||||
|
||||
.ladder-level > header span,
|
||||
.ladder-level > header small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-stocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--s-200), 1fr));
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-10);
|
||||
}
|
||||
|
||||
.ladder-stock {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--s-6);
|
||||
padding: var(--s-9);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-surface);
|
||||
transition: border-color var(--duration-fast) var(--ease-standard),
|
||||
background var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ladder-stock:hover {
|
||||
border-color: var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
transform: translateY(calc(var(--s-1) * -1));
|
||||
}
|
||||
|
||||
.ladder-stock > div,
|
||||
.ladder-stock > p {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.ladder-stock strong,
|
||||
.ladder-stock b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ladder-stock span,
|
||||
.ladder-stock small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-gap-note {
|
||||
align-self: center;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.ladder-expand {
|
||||
min-height: var(--s-44);
|
||||
border: var(--s-1) dashed var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.ladder-insights {
|
||||
display: grid;
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.ladder-insight-body {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-14);
|
||||
}
|
||||
|
||||
.ladder-insight-body > strong {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-18);
|
||||
}
|
||||
|
||||
.ladder-insight-body p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
line-height: var(--s-18);
|
||||
}
|
||||
|
||||
.ladder-rate-list {
|
||||
display: grid;
|
||||
gap: var(--s-9);
|
||||
padding: var(--s-12) var(--s-14);
|
||||
}
|
||||
|
||||
.ladder-rate-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-64) minmax(0, 1fr) var(--s-44);
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.ladder-rate-list strong {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rotation-trajectory,
|
||||
.rotation-members {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rotation-top-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rotation-legend {
|
||||
min-height: var(--s-34);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-16);
|
||||
padding: var(--s-6) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.rotation-legend i {
|
||||
width: var(--s-14);
|
||||
height: var(--s-9);
|
||||
border-radius: var(--radius-4);
|
||||
}
|
||||
|
||||
.rotation-tracker {
|
||||
min-height: var(--s-46);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-14);
|
||||
padding: var(--s-7) var(--s-14);
|
||||
border-bottom: var(--s-1) solid var(--color-primary-border);
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.rotation-tracker strong {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rotation-tracker span {
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rotation-day {
|
||||
min-width: 0;
|
||||
border-right: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.rotation-day:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.rotation-day > header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-8);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.rotation-day > header time {
|
||||
font-weight: var(--weight-700);
|
||||
}
|
||||
|
||||
.rotation-day > header span {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-10-5);
|
||||
}
|
||||
|
||||
.rotation-day > div {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rotation-sector {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: var(--s-20) minmax(0, 1fr);
|
||||
gap: var(--s-2) var(--s-6);
|
||||
padding: var(--s-7) var(--s-8);
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
text-align: left;
|
||||
transition: opacity var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.rotation-sector > span {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rotation-sector strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.rotation-sector small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-10-5);
|
||||
}
|
||||
|
||||
.rotation-sector.selected {
|
||||
color: var(--color-primary);
|
||||
outline: var(--s-1) solid var(--color-primary);
|
||||
outline-offset: calc(var(--s-1) * -1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.heat-high { background: var(--color-up-soft); }
|
||||
.heat-medium { background: var(--color-warning-soft); }
|
||||
.heat-low { background: var(--color-primary-soft); }
|
||||
|
||||
@@ -197,4 +197,51 @@
|
||||
.performance-conclusion {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions .seg-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-page-header .page-actions .seg-control button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ladder-layout,
|
||||
.ladder-level {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ladder-level > header {
|
||||
border-right: 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
|
||||
.ladder-stocks {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rotation-legend {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rotation-tracker {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rotation-days {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rotation-day {
|
||||
border-right: 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { expect, test } = require("@playwright/test");
|
||||
|
||||
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-7");
|
||||
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
|
||||
|
||||
async function authenticate(page) {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("账号名").fill("stage7admin");
|
||||
await page.getByLabel("密码").fill("Stage7-pass-123!");
|
||||
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
|
||||
if (!(await page.locator(".sidebar").isVisible())) {
|
||||
await page.getByRole("tab", { name: "注册" }).click();
|
||||
await page.getByRole("button", { name: "注册并登录" }).click();
|
||||
}
|
||||
}
|
||||
|
||||
const overview = { up_count: 2800, down_count: 2100, flat_count: 100, limit_up: 58, limit_down: 5, broken: 20, seal_rate: 74.4, amount: 1500000000000 };
|
||||
const summary = { context: { requested_date: "2026-07-30", actual_date: "2026-07-29", previous_date: "2026-07-28", observed_at: "2026-07-29T15:00:00+08:00", state: "final", carried_forward: true, message: "沿用最近真实收盘快照" }, values: { ...overview, temperature: 48 } };
|
||||
|
||||
function stock(level, index) {
|
||||
return { identifier: `${level}${String(index).padStart(5, "0")}.SZ`, code: `${level}${String(index).padStart(5, "0")}`, name: `${level}板样本${index}`, streak: level, sector: index % 2 ? "机器人" : "元件", first_time: `09:${String(30 + index).padStart(2, "0")}`, open_times: index % 4, amount: 200000000 + index * 10000000 };
|
||||
}
|
||||
|
||||
const ladders = [7, 5, 4, 3, 2, 1].map((level) => {
|
||||
const count = level === 1 ? 12 : level === 2 ? 10 : 2;
|
||||
return { level, label: level === 1 ? "首板" : `${level}板`, count, stocks: Array.from({ length: count }, (_, index) => stock(level, index + 1)) };
|
||||
});
|
||||
const performance = [1, 2, 3, 4, 5, 7].map((level) => ({ level, advance_rate: 10 + level * 5 }));
|
||||
const names = ["机器人", "元件", "半导体", "油气开采", "保险", "银行", "软件", "通信", "汽车零部件", "电力", "化工", "医药"];
|
||||
const rotation = Array.from({ length: 9 }, (_, day) => ({ trade_date: `2026-07-${String(21 + day).padStart(2, "0")}`, sectors: names.map((name, index) => ({ name, rank: index + 1, count: 8 - index % 5, strength: 96 - index * 3, leader: `${name}龙头`, representative: `${String(index + 1).padStart(6, "0")}.SZ`, trend: day % 2 ? "升温" : "持平" })) }));
|
||||
|
||||
async function mockMarket(page) {
|
||||
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary) }));
|
||||
await page.route("**/api/market/workspaces/*", (route) => {
|
||||
const key = new URL(route.request().url()).pathname.split("/").pop();
|
||||
const base = { trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", carried_forward: false, message: "", overview };
|
||||
if (key === "ladder") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: ladders, history: performance }) });
|
||||
if (key === "rotation") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, history: rotation }) });
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, sentiment: {}, history: [] }) });
|
||||
});
|
||||
await page.route("**/api/market/rotation-members*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", sector_name: "机器人", sector_code: "801072.SI", member_count: 3, quoted_count: 2, coverage: 0.667, items: [{ identifier: "000001.SZ", code: "000001", name: "成分甲", change: 4.2, open: 10, close: 10.4, amount: 500000000, quoted: true }, { identifier: "000002.SZ", code: "000002", name: "成分乙", change: -1.1, open: 8, close: 7.9, amount: 200000000, quoted: true }, { identifier: "000003.SZ", code: "000003", name: "停牌样本", change: null, open: null, close: null, amount: null, quoted: false }] }) }));
|
||||
}
|
||||
|
||||
test("ladder and rotation preserve hierarchy, expansion and dated members", async ({ page }) => {
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
|
||||
await mockMarket(page);
|
||||
await authenticate(page);
|
||||
|
||||
await page.getByRole("link", { name: /市场天梯/ }).click();
|
||||
await expect(page.locator(".ladder-level").nth(0)).toContainText("7板");
|
||||
await expect(page.locator(".ladder-level").nth(1)).toContainText("6板");
|
||||
await page.getByRole("button", { name: "展开更多(4)" }).click();
|
||||
await expect(page.getByRole("button", { name: "收起" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "收起" }).click();
|
||||
await page.getByRole("button", { name: "按开板次数" }).click();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight)).toBe(true);
|
||||
await page.screenshot({ path: path.join(evidence, "ladder-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.getByRole("link", { name: /板块轮动/ }).click();
|
||||
await expect(page.locator(".rotation-day")).toHaveCount(9);
|
||||
await page.locator(".rotation-day").first().getByRole("button", { name: /机器人/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "机器人成分股" })).toBeVisible();
|
||||
await expect(page.getByText("成分甲")).toBeVisible();
|
||||
await page.getByRole("button", { name: "由近到远" }).click();
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
expect(await page.locator(".rotation-sector.heat-high").first().evaluate((element) => getComputedStyle(element).backgroundColor)).toBe("rgb(64, 38, 42)");
|
||||
await page.screenshot({ path: path.join(evidence, "rotation-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
await page.screenshot({ path: path.join(evidence, "rotation-dark-390x844.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
@@ -314,6 +314,10 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
)
|
||||
assert snapshot["overview"]["amount"] == 500_000
|
||||
assert snapshot["broken"][0]["distance_to_limit"] == 20
|
||||
assert snapshot["ladders"][0]["level"] == 2
|
||||
assert snapshot["ladders"][0]["stocks"][0]["code"] == "000001"
|
||||
assert snapshot["sector_rotation"][0]["name"] == "测试行业"
|
||||
assert snapshot["sector_rotation"][0]["delta"] == -4
|
||||
assert [row["outcome"] for row in snapshot["yesterday_limits"]] == [
|
||||
"晋级",
|
||||
"炸板",
|
||||
@@ -336,6 +340,43 @@ def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> Non
|
||||
}
|
||||
|
||||
|
||||
def test_sector_members_are_normalized_and_persistently_cached(tmp_path) -> None:
|
||||
database = Database(tmp_path / "members.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = MarketRepository()
|
||||
provider = TushareProvider("test-token")
|
||||
calls = 0
|
||||
|
||||
def members(*_):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return calculation_result(
|
||||
[
|
||||
{
|
||||
"sector_code": "801082.SI",
|
||||
"sector_name": "元件",
|
||||
"ts_code": "002141.SZ",
|
||||
"name": "贤丰控股",
|
||||
"change": 3.2,
|
||||
"open": 4.1,
|
||||
"close": 4.3,
|
||||
"amount": 123_000_000,
|
||||
"quoted": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
provider.sector_members = members
|
||||
market = DataGateway(database, repository, (provider,), DataSourcePolicy())
|
||||
first = market.sector_members("2026-07-29", "元件", "002141.SZ")
|
||||
second = market.sector_members("2026-07-29", "元件", "002141.SZ")
|
||||
|
||||
assert calls == 1
|
||||
assert first == second
|
||||
assert first["items"][0]["code"] == "002141"
|
||||
assert first["quoted_count"] == 1
|
||||
|
||||
|
||||
def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
|
||||
snapshot = {
|
||||
"overview": {
|
||||
@@ -401,7 +442,11 @@ def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) ->
|
||||
"previous_limit_up": calculation_result([]),
|
||||
"price_limits": calculation_result([]),
|
||||
}
|
||||
service = MarketSnapshotService(database, repository, provider)
|
||||
service = MarketSnapshotService(
|
||||
database,
|
||||
repository,
|
||||
DataGateway(database, repository, (provider,), DataSourcePolicy()),
|
||||
)
|
||||
with pytest.raises(SnapshotSyncError, match="覆盖率"):
|
||||
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
|
||||
with database.read() as connection:
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
database = Database(tmp_path / "app.db")
|
||||
runner = MigrationRunner(database)
|
||||
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3)
|
||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4)
|
||||
assert {
|
||||
"users",
|
||||
"memberships",
|
||||
@@ -124,8 +124,9 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
||||
"market_entities",
|
||||
"market_summaries",
|
||||
"chart_series",
|
||||
"sector_member_snapshots",
|
||||
} <= table_names(database)
|
||||
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (3, 2, 1)
|
||||
assert runner.downgrade(MIGRATIONS, target_version=0) == (4, 3, 2, 1)
|
||||
assert "users" not in table_names(database)
|
||||
assert "llm_models" not in table_names(database)
|
||||
|
||||
Reference in New Issue
Block a user