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
+1 -1
View File
@@ -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)
),
)
+62
View File
@@ -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)
+2
View File
@@ -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: ...
+3
View File
@@ -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:
+3
View File
@@ -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("实时行情服务尚未配置")
+90
View File
@@ -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
+46
View File
@@ -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,
)
+2 -1
View File
@@ -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)
+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 {}
+49
View File
@@ -0,0 +1,49 @@
# 阶段 7 验收记录
## 交付范围
- 市场天梯:动态连板层级、断层保留、等宽个股单元格、封板时间/开板次数排序、展开收起与 CSV 导出。
- 板块轮动:九个交易日 Top12、远近日期切换、跨日板块高亮、强度层级与指定交易日成分股。
- 涨停表现回归:沿用阶段 6 的唯一实现,本阶段只做跨页回归,不复制统计公式。
- 桌面端与 390px 移动端均使用全页滚动,内容不横向溢出。
## 数据与结构
- 天梯和板块轮动均消费阶段 6 的同一份日度市场快照,不建立页面专属快照表。
- 板块轮动由当日涨停股按行业聚合,统一计算涨停家数、最高连板、成交额、领涨股、强度与相邻交易日趋势。
- 九日轨迹读取已归档的日度快照,不在前端重复推导。
- 成分股按请求交易日读取申万二级成员关系和日线行情;停牌或缺少行情的成员保留在结果末尾,字段保持空值。
- 数据库版本 4 只增加 `sector_member_snapshots` 成分股快照缓存,避免重复外部请求;未引入通用缓存框架。
- `MarketSnapshotService` 已收敛为只调用 `DataGateway`,移除其直接依赖行情供应商的出口。
## 自动验收
- Ruff:通过。
- Pytest50 项通过。
- Vue TypeScript 检查:通过。
- Vitest:2 个文件、5 项通过。
- Vite 生产构建:通过。
- Playwright5 项通过,覆盖阶段 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>强度中7089</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>
+15
View File
@@ -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);
}
}
+78
View File
@@ -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([]);
});
+46 -1
View File
@@ -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:
+3 -2
View File
@@ -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)