diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 4dccfec..4e845d5 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -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) ), ) diff --git a/next/backend/data/gateway.py b/next/backend/data/gateway.py index bc2d7bd..e144d43 100644 --- a/next/backend/data/gateway.py +++ b/next/backend/data/gateway.py @@ -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) diff --git a/next/backend/data/providers/base.py b/next/backend/data/providers/base.py index d6b5655..03b3e99 100644 --- a/next/backend/data/providers/base.py +++ b/next/backend/data/providers/base.py @@ -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: ... diff --git a/next/backend/data/providers/eastmoney.py b/next/backend/data/providers/eastmoney.py index b2527dc..726a6e7 100644 --- a/next/backend/data/providers/eastmoney.py +++ b/next/backend/data/providers/eastmoney.py @@ -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: diff --git a/next/backend/data/providers/ifind.py b/next/backend/data/providers/ifind.py index e57fa23..7389277 100644 --- a/next/backend/data/providers/ifind.py +++ b/next/backend/data/providers/ifind.py @@ -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("实时行情服务尚未配置") diff --git a/next/backend/data/providers/tushare.py b/next/backend/data/providers/tushare.py index 69c8c00..60993c1 100644 --- a/next/backend/data/providers/tushare.py +++ b/next/backend/data/providers/tushare.py @@ -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 diff --git a/next/backend/data/repository.py b/next/backend/data/repository.py index 715ce4f..b99e5c5 100644 --- a/next/backend/data/repository.py +++ b/next/backend/data/repository.py @@ -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, diff --git a/next/backend/database/migrations/m0004_sector_members.py b/next/backend/database/migrations/m0004_sector_members.py new file mode 100644 index 0000000..b5f4990 --- /dev/null +++ b/next/backend/database/migrations/m0004_sector_members.py @@ -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, +) diff --git a/next/backend/database/migrations/registry.py b/next/backend/database/migrations/registry.py index 7192df9..2e3c4a7 100644 --- a/next/backend/database/migrations/registry.py +++ b/next/backend/database/migrations/registry.py @@ -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) diff --git a/next/backend/features/market/routes.py b/next/backend/features/market/routes.py index b9469a0..a6dabae 100644 --- a/next/backend/features/market/routes.py +++ b/next/backend/features/market/routes.py @@ -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) diff --git a/next/backend/features/market/schemas.py b/next/backend/features/market/schemas.py index fb49145..d38de9a 100644 --- a/next/backend/features/market/schemas.py +++ b/next/backend/features/market/schemas.py @@ -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) diff --git a/next/backend/features/market/service.py b/next/backend/features/market/service.py index 7042bb3..2ea64cd 100644 --- a/next/backend/features/market/service.py +++ b/next/backend/features/market/service.py @@ -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: diff --git a/next/backend/features/market/snapshot.py b/next/backend/features/market/snapshot.py index 0c05047..22eaede 100644 --- a/next/backend/features/market/snapshot.py +++ b/next/backend/features/market/snapshot.py @@ -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) diff --git a/next/backend/features/market/sync.py b/next/backend/features/market/sync.py index 7156504..70b852a 100644 --- a/next/backend/features/market/sync.py +++ b/next/backend/features/market/sync.py @@ -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 {} diff --git a/next/docs/evidence/stage-7/README.md b/next/docs/evidence/stage-7/README.md new file mode 100644 index 0000000..4acf942 --- /dev/null +++ b/next/docs/evidence/stage-7/README.md @@ -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 生产容器保持不变,最终切换仍需人工明确确认。 diff --git a/next/docs/evidence/stage-7/ladder-light-1920x1080.jpg b/next/docs/evidence/stage-7/ladder-light-1920x1080.jpg new file mode 100644 index 0000000..22a4ad4 Binary files /dev/null and b/next/docs/evidence/stage-7/ladder-light-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-7/rotation-dark-1920x1080.jpg b/next/docs/evidence/stage-7/rotation-dark-1920x1080.jpg new file mode 100644 index 0000000..4fc85af Binary files /dev/null and b/next/docs/evidence/stage-7/rotation-dark-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-7/rotation-dark-390x844.jpg b/next/docs/evidence/stage-7/rotation-dark-390x844.jpg new file mode 100644 index 0000000..d2d6deb Binary files /dev/null and b/next/docs/evidence/stage-7/rotation-dark-390x844.jpg differ diff --git a/next/frontend/src/app/views/WorkspaceView.vue b/next/frontend/src/app/views/WorkspaceView.vue index 46d4b43..7dbad00 100644 --- a/next/frontend/src/app/views/WorkspaceView.vue +++ b/next/frontend/src/app/views/WorkspaceView.vue @@ -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, ), ); diff --git a/next/frontend/src/pages/market/MarketWorkspaceView.vue b/next/frontend/src/pages/market/MarketWorkspaceView.vue index e5f501c..9e0f185 100644 --- a/next/frontend/src/pages/market/MarketWorkspaceView.vue +++ b/next/frontend/src/pages/market/MarketWorkspaceView.vue @@ -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(), + + diff --git a/next/frontend/src/pages/market/structure/LadderPage.vue b/next/frontend/src/pages/market/structure/LadderPage.vue new file mode 100644 index 0000000..5ab4e3d --- /dev/null +++ b/next/frontend/src/pages/market/structure/LadderPage.vue @@ -0,0 +1,106 @@ + + + diff --git a/next/frontend/src/pages/market/structure/RotationPage.vue b/next/frontend/src/pages/market/structure/RotationPage.vue new file mode 100644 index 0000000..4907ea3 --- /dev/null +++ b/next/frontend/src/pages/market/structure/RotationPage.vue @@ -0,0 +1,126 @@ + + + diff --git a/next/frontend/src/shared/api/market.ts b/next/frontend/src/shared/api/market.ts index b892337..bf35c4c 100644 --- a/next/frontend/src/shared/api/market.ts +++ b/next/frontend/src/shared/api/market.ts @@ -67,6 +67,16 @@ export type MarketWorkspaceData = { items?: Record[]; }; +export type RotationMembersData = { + trade_date: string; + sector_name: string; + sector_code: string; + member_count: number; + quoted_count: number; + coverage: number; + items: Record[]; +}; + export const marketApi = { summary(date?: string): Promise { 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 { + return api.get( + `/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`, + ); + }, }; diff --git a/next/frontend/src/shared/styles/market-workspace.css b/next/frontend/src/shared/styles/market-workspace.css index 78c36a7..d3b13c0 100644 --- a/next/frontend/src/shared/styles/market-workspace.css +++ b/next/frontend/src/shared/styles/market-workspace.css @@ -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); } diff --git a/next/frontend/src/shared/styles/mobile.css b/next/frontend/src/shared/styles/mobile.css index f966857..67511c9 100644 --- a/next/frontend/src/shared/styles/mobile.css +++ b/next/frontend/src/shared/styles/mobile.css @@ -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); + } } diff --git a/next/tests/e2e/stage7.spec.js b/next/tests/e2e/stage7.spec.js new file mode 100644 index 0000000..ac2f708 --- /dev/null +++ b/next/tests/e2e/stage7.spec.js @@ -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([]); +}); diff --git a/next/tests/test_market_data.py b/next/tests/test_market_data.py index 9542309..821f862 100644 --- a/next/tests/test_market_data.py +++ b/next/tests/test_market_data.py @@ -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: diff --git a/next/tests/test_migrations.py b/next/tests/test_migrations.py index f493c01..1363c57 100644 --- a/next/tests/test_migrations.py +++ b/next/tests/test_migrations.py @@ -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)