migration: preserve ladder and rotation slice

This commit is contained in:
leefer
2026-07-31 01:59:36 +08:00
parent b3555d2603
commit 1116a0d25e
9 changed files with 346 additions and 163 deletions
+7 -157
View File
@@ -54,11 +54,8 @@ from backend.features.accounts.http import AccountHttpMixin
from backend.features.accounts.security import SecretVault from backend.features.accounts.security import SecretVault
from backend.features.accounts.service import AccountService from backend.features.accounts.service import AccountService
from backend.features.pools import PoolServiceMixin from backend.features.pools import PoolServiceMixin
from backend.features.rotation import RotationServiceMixin
from backend.features.sentiment import SentimentServiceMixin from backend.features.sentiment import SentimentServiceMixin
from backend.features.sentiment.engine import (
build_sentiment_history,
latest_contiguous_history,
)
from backend.features.system import SystemHttpMixin from backend.features.system import SystemHttpMixin
from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue
@@ -140,7 +137,12 @@ MENTOR_ETF_UNIVERSE = (
) )
class DashboardService(MarketServiceMixin, SentimentServiceMixin, PoolServiceMixin): class DashboardService(
MarketServiceMixin,
SentimentServiceMixin,
PoolServiceMixin,
RotationServiceMixin,
):
def __init__(self) -> None: def __init__(self) -> None:
runtime = load_runtime_settings() runtime = load_runtime_settings()
self.vault = SecretVault(runtime.encryption_key) self.vault = SecretVault(runtime.encryption_key)
@@ -734,158 +736,6 @@ class DashboardService(MarketServiceMixin, SentimentServiceMixin, PoolServiceMix
def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
# 板块轮动固定展示最近 9 个交易日,按由近到远排列。
limit = 9
snapshots = self.database.list_snapshot_payloads(normalized_date, 240)
by_trade_date: dict[str, dict[str, Any]] = {}
for snapshot in snapshots:
meta = snapshot.get("meta") or {}
actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "")
compact_date = actual_date.replace("-", "")
if len(compact_date) == 8:
by_trade_date[compact_date] = snapshot
sentiment_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in latest_contiguous_history(build_sentiment_history(snapshots))
}
ordered_dates = sorted(
date_key for date_key in by_trade_date
if not sentiment_dates or date_key in sentiment_dates
)[-limit:][::-1]
rows = []
for date_key in ordered_dates:
snapshot = by_trade_date[date_key]
sector_context = {
str(item.get("name") or ""): item
for item in snapshot.get("sectors") or []
}
sectors = []
for item in (snapshot.get("sector_rotation") or [])[:12]:
name = str(item.get("name") or "").strip()
context = sector_context.get(name, {})
sectors.append(
{
"name": name,
"rank": int(item.get("rank") or len(sectors) + 1),
"trend": item.get("trend") or "持平",
"count": int(item.get("count") or 0),
"strength": float(item.get("strength") or context.get("strength") or 0),
"change": float(context.get("change") or 0),
"leader": item.get("leader") or context.get("leader") or "--",
}
)
rows.append(
{
"trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}",
"sectors": sectors,
}
)
return {
"trade_date": rows[0]["trade_date"] if rows else normalized_date,
"available_days": len(ordered_dates),
"requested_days": limit,
"rows": rows,
}
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
dashboard = self.get_dashboard(normalized_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
)
cache_key = f"{actual_date}:{sector_name}"
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
if cached:
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
return cached
if not self.configured:
raise ValueError("板块成分数据暂不可用。")
representative = next(
(
item for item in dashboard.get("limits") or []
if str(item.get("sector") or "").strip() == sector_name
),
None,
)
if not representative:
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
if "." in raw_code:
ts_code = raw_code
elif raw_code.startswith(("4", "8", "92")):
ts_code = f"{raw_code}.BJ"
elif raw_code.startswith(("6", "68", "90")):
ts_code = f"{raw_code}.SH"
else:
ts_code = f"{raw_code}.SZ"
client = self._tushare_client()
try:
industry = client.sw_stock_industry(ts_code, actual_date)
sector_code = str(industry.get("l2_code") or "")
members = client.sw_sector_members(sector_code, actual_date)
except TushareError as exc:
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
daily_rows = self.database.daily_bars_for_date(actual_date)
if len(daily_rows) < 1000:
try:
daily_rows = client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if daily_rows:
self.database.upsert_daily_bars(daily_rows)
except TushareError:
daily_rows = self.database.daily_bars_for_date(actual_date)
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
rows = []
for member in members:
member_code = str(member.get("ts_code") or "")
quote = daily_map.get(member_code) or {}
rows.append(
{
"code": member_code.split(".")[0],
"ts_code": member_code,
"name": str(member.get("name") or "--"),
"change": quote.get("pct_chg"),
"open": quote.get("open"),
"close": quote.get("close"),
"amount_billion": (
round(float(quote.get("amount") or 0) / 100000, 2)
if quote else None
),
"quoted": bool(quote),
}
)
rows.sort(
key=lambda item: (
bool(item.get("quoted")),
float(item.get("change") or -999),
float(item.get("amount_billion") or 0),
),
reverse=True,
)
result = {
"meta": {
"trade_date": self._display_compact_date(actual_date),
"sector_name": str(industry.get("l2_name") or sector_name),
"sector_code": sector_code,
"member_count": len(rows),
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
"cached": False,
},
"rows": rows,
}
self.database.save_data_snapshot(
"rotation_sector_members_v1", cache_key, "tushare", result
)
return result
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status() llm_access = self.llm_access_status()
@@ -0,0 +1,5 @@
"""Sector rotation history and constituent detail feature."""
from .service import RotationServiceMixin
__all__ = ["RotationServiceMixin"]
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
from typing import Any
from backend.bootstrap.config import normalize_date, validate_text
from backend.data.providers.tushare_client import TushareError
from backend.features.sentiment.engine import (
build_sentiment_history,
latest_contiguous_history,
)
class RotationServiceMixin:
def rotation_history(self, trade_date: str, limit: int = 9) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
# 板块轮动固定展示最近 9 个交易日,按由近到远排列。
limit = 9
snapshots = self.database.list_snapshot_payloads(normalized_date, 240)
by_trade_date: dict[str, dict[str, Any]] = {}
for snapshot in snapshots:
meta = snapshot.get("meta") or {}
actual_date = str(meta.get("trade_date") or snapshot.get("_snapshot_date") or "")
compact_date = actual_date.replace("-", "")
if len(compact_date) == 8:
by_trade_date[compact_date] = snapshot
sentiment_dates = {
str(row.get("trade_date") or "").replace("-", "")
for row in latest_contiguous_history(build_sentiment_history(snapshots))
}
ordered_dates = sorted(
date_key for date_key in by_trade_date
if not sentiment_dates or date_key in sentiment_dates
)[-limit:][::-1]
rows = []
for date_key in ordered_dates:
snapshot = by_trade_date[date_key]
sector_context = {
str(item.get("name") or ""): item
for item in snapshot.get("sectors") or []
}
sectors = []
for item in (snapshot.get("sector_rotation") or [])[:12]:
name = str(item.get("name") or "").strip()
context = sector_context.get(name, {})
sectors.append(
{
"name": name,
"rank": int(item.get("rank") or len(sectors) + 1),
"trend": item.get("trend") or "持平",
"count": int(item.get("count") or 0),
"strength": float(item.get("strength") or context.get("strength") or 0),
"change": float(context.get("change") or 0),
"leader": item.get("leader") or context.get("leader") or "--",
}
)
rows.append(
{
"trade_date": f"{date_key[:4]}-{date_key[4:6]}-{date_key[6:]}",
"sectors": sectors,
}
)
return {
"trade_date": rows[0]["trade_date"] if rows else normalized_date,
"available_days": len(ordered_dates),
"requested_days": limit,
"rows": rows,
}
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
dashboard = self.get_dashboard(normalized_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
)
cache_key = f"{actual_date}:{sector_name}"
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
if cached:
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
return cached
if not self.configured:
raise ValueError("板块成分数据暂不可用。")
representative = next(
(
item for item in dashboard.get("limits") or []
if str(item.get("sector") or "").strip() == sector_name
),
None,
)
if not representative:
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
if "." in raw_code:
ts_code = raw_code
elif raw_code.startswith(("4", "8", "92")):
ts_code = f"{raw_code}.BJ"
elif raw_code.startswith(("6", "68", "90")):
ts_code = f"{raw_code}.SH"
else:
ts_code = f"{raw_code}.SZ"
client = self._tushare_client()
try:
industry = client.sw_stock_industry(ts_code, actual_date)
sector_code = str(industry.get("l2_code") or "")
members = client.sw_sector_members(sector_code, actual_date)
except TushareError as exc:
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
daily_rows = self.database.daily_bars_for_date(actual_date)
if len(daily_rows) < 1000:
try:
daily_rows = client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if daily_rows:
self.database.upsert_daily_bars(daily_rows)
except TushareError:
daily_rows = self.database.daily_bars_for_date(actual_date)
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
rows = []
for member in members:
member_code = str(member.get("ts_code") or "")
quote = daily_map.get(member_code) or {}
rows.append(
{
"code": member_code.split(".")[0],
"ts_code": member_code,
"name": str(member.get("name") or "--"),
"change": quote.get("pct_chg"),
"open": quote.get("open"),
"close": quote.get("close"),
"amount_billion": (
round(float(quote.get("amount") or 0) / 100000, 2)
if quote else None
),
"quoted": bool(quote),
}
)
rows.sort(
key=lambda item: (
bool(item.get("quoted")),
float(item.get("change") or -999),
float(item.get("amount_billion") or 0),
),
reverse=True,
)
result = {
"meta": {
"trade_date": self._display_compact_date(actual_date),
"sector_name": str(industry.get("l2_name") or sector_name),
"sector_code": sector_code,
"member_count": len(rows),
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
"cached": False,
},
"rows": rows,
}
self.database.save_data_snapshot(
"rotation_sector_members_v1", cache_key, "tushare", result
)
return result
@@ -0,0 +1,92 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
ROTATION_METHODS = {
"rotation_history",
"rotation_sector_members",
}
LADDER_ROTATION_BUILDERS = {
"_build_ladders",
"_build_sector_rotation",
}
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def top_level_functions(path: Path) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
return {
node.name: ast.dump(node, include_attributes=False)
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in LADDER_ROTATION_BUILDERS
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
class LadderRotationSliceSourceEquivalenceTests(unittest.TestCase):
def test_rotation_service_methods_are_exact_original_ast(self) -> None:
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
migrated = class_methods(
APP_ROOT / "backend" / "features" / "rotation" / "service.py",
"RotationServiceMixin",
)
self.assertEqual(set(migrated), ROTATION_METHODS)
for name in sorted(ROTATION_METHODS):
self.assertEqual(migrated[name], original[name], name)
def test_dashboard_service_no_longer_duplicates_rotation_methods(self) -> None:
remaining = class_methods(
APP_ROOT / "backend" / "application.py", "DashboardService"
)
self.assertTrue(ROTATION_METHODS.isdisjoint(remaining))
def test_ladder_and_rotation_builders_are_exact_original_ast(self) -> None:
self.assertEqual(
top_level_functions(ORIGINAL_ROOT / "tushare_client.py"),
top_level_functions(
APP_ROOT / "backend" / "data" / "providers" / "tushare_client.py"
),
)
def test_api_and_frontend_assets_are_unchanged(self) -> None:
for relative in (
"config/api.config.json",
"static/index.html",
"static/app.js",
"static/styles.css",
"static/pages/ladder/page.js",
"static/pages/rotation/page.js",
):
self.assertEqual(
sha256(APP_ROOT / relative),
sha256(ORIGINAL_ROOT / relative),
relative,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
# 切片 04:市场天梯与板块轮动
> 基线:`b3555d2`(切片 03
> 回档标签:`xiaobai-preservation-slice-04-20260731`
> 结论:源码、API、真实页面和浏览器回归通过;最终视觉仍等待全站人工验收
## 1. 原实现归位
本切片从原版副本机械移动板块轮动服务,没有从 `next/` 取用代码,也没有修改天梯、轮动的计算、
排序、展开、配色、页面结构或交互。
| 原位置 | 新的唯一实现位置 | 原位置兼容 |
|---|---|---|
| `app/backend/application.py` 的 2 个轮动方法 | `app/backend/features/rotation/service.py` | `DashboardService` 继承 `RotationServiceMixin` |
| Tushare Provider 的天梯与轮动构造函数 | 保持 `app/backend/data/providers/tushare_client.py` | 切片 02 已归位的公共数据实现 |
市场天梯没有独立后端 API 或第二套计算,直接展示 `/api/dashboard` 中原 Tushare 实现生成的
`ladders`;因此没有为目录形式建立空的天梯服务。
## 2. 等价证据
- `test_preservation_slice_ladder_rotation.py` 对 2 个轮动服务方法逐项执行无位置信息 AST 比较,
全部与根目录原版 `server.py` 完全相同。
- `_build_ladders``_build_sector_rotation` 两个原数据构造函数的 AST 与根目录原版完全相同。
- 原版 `8784` 与迁移版 `8785` 在相同账号、日期和数据库副本上返回的天梯数据及 9 日轮动历史
JSON 逐字段完全相同。
- 成分股接口在当前外部网络状态下两版均返回 HTTP 400、`bad_request` 和相同的
`该板块成分股暂不可用:Tushare request failed:`,没有改变错误或增加静默降级。
- `config/api.config.json`、API 路径、鉴权、数据库 schema 和 `app/static/` 未修改。
## 3. 真实运行检查
- 市场天梯:8 个层级(含断层)、18 个首屏股票单元格、3 个结构分析模块正常;1920×1080 下
页面宽度无溢出,首板展开入口保留。
- 板块轮动:9 个交易日、每日 Top 12 共 108 个板块单元格、由远到近/由近到远两个排序入口正常;
1920×1080 下页面宽度无溢出并保持全页滚动。
- 日间模式页面控制台没有错误或警告。
- `app-light-ladder-1920x1080.png` SHA-256
`9e57d18d92e745fd92131f7bf08f21faaaa745476dd942cdaa2a703b9a7a303a`
- `app-light-rotation-1920x1080.png` SHA-256
`03c092bb40bd0eb672136dff853abffc87e9790840107c2f22e6cf31d5f83c09`
## 4. 自动验证
| 验证 | 结果 |
|---|---:|
| `python -m unittest discover -s tests -q` | 252 项通过 |
| `python -m unittest tests.test_preservation_slice_ladder_rotation -q` | 4 项通过 |
| 切片 02 至 04 与总览缓存专项集合 | 24 项通过 |
| `npx.cmd playwright test --reporter=dot` | 45 项通过 |
| `git diff --check` | 通过 |
## 5. 保留边界
- 成分股接口依赖的日行情与因子持久化方法仍由原 `ReviewDatabase` 提供,因其同时服务智能选股,
待切片 06 随完整共享职责归位。
- 天梯和轮动前端资产保持原位,切片 10 再按页面职责归档;当前没有复制或改写。
- 没有删除待定代码、没有改动根目录正式数据库、没有切换 Docker/NAS。
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": 1, "schema_version": 1,
"updated_at": "2026-07-31T01:38:00+08:00", "updated_at": "2026-07-31T01:57:00+08:00",
"status": "active", "status": "active",
"migration_mode": "behavior_preserving_source_migration", "migration_mode": "behavior_preserving_source_migration",
"source_of_truth": "current_original_webapp_runtime_and_source", "source_of_truth": "current_original_webapp_runtime_and_source",
@@ -9,10 +9,10 @@
"failed_roots": [ "failed_roots": [
"next" "next"
], ],
"current_slice": "slice-04-ladder-rotation", "current_slice": "slice-05-auction-themes-popularity-dragon-tiger",
"last_completed_slice": "slice-03-sentiment-pools-performance", "last_completed_slice": "slice-04-ladder-rotation",
"last_checkpoint": "xiaobai-preservation-slice-03-20260731", "last_checkpoint": "xiaobai-preservation-slice-04-20260731",
"next_action": "capture_slice-04_ladder_rotation_contracts_then_move_original_implementations", "next_action": "capture_slice-05_auction_theme_popularity_dragon_tiger_contracts_then_move_original_implementations",
"authoritative_documents": [ "authoritative_documents": [
"AGENTS.md", "AGENTS.md",
"docs/migration/原版保真迁移总纲.md", "docs/migration/原版保真迁移总纲.md",
+14 -1
View File
@@ -1,6 +1,6 @@
# 小白复盘保真迁移账本 # 小白复盘保真迁移账本
> 当前状态:正式迁移,切片03“情绪周期、五类股池与涨停表现”已完成 > 当前状态:正式迁移,切片04“市场天梯与板块轮动”已完成
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
`保真迁移状态.json` `保真迁移状态.json`
@@ -23,6 +23,7 @@
| 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 | | 2026-07-31 | `xiaobai-preservation-slice-01-20260731` | 启动、HTTP、账号、会员与系统管理原实现归位 | 自动差分通过,进入切片02 |
| 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 | | 2026-07-31 | `xiaobai-preservation-slice-02-20260731` | 公共行情、搜索、详情、图表与数据适配原实现归位 | 自动与浏览器差分通过,进入切片03 |
| 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 | | 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 |
| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 |
## 资产处置登记 ## 资产处置登记
@@ -40,6 +41,8 @@
| `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 | | `sentiment_engine.py` | 情绪周期计算 | 总览、轮动、选股 | 移动并保留兼容别名 | `app/backend/features/sentiment/engine.py` | 文件哈希与原版一致;248项Python与45项Playwright通过 | 已移动 |
| `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/``app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 | | `DashboardService`情绪及股池原因方法 | 业务服务 | 情绪页、五类股池、涨停表现 | 按职责机械移动 | `app/backend/features/sentiment/``app/backend/features/pools/` | 8个方法AST与原版一致;真实API完全一致 | 已移动 |
| `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 | | `ReviewDatabase`原因覆盖方法 | 持久化 | 股池原因人工覆盖 | 按职责机械移动 | `app/backend/features/pools/repository.py` | 2个方法AST与原版一致;数据库schema哈希一致 | 已移动 |
| `DashboardService`板块轮动方法 | 业务服务 | 板块轮动页 | 按职责机械移动 | `app/backend/features/rotation/service.py` | 2个方法AST、真实API与原版一致 | 已移动 |
| Tushare天梯与轮动构造函数 | 公共数据计算 | 市场天梯、板块轮动 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个构造函数AST与原版一致 | 已归位 |
处置只允许:`原样保留``移动``合并重复``待定``确认废弃` 处置只允许:`原样保留``移动``合并重复``待定``确认废弃`
@@ -87,6 +90,16 @@
- 回档:标签`xiaobai-preservation-slice-03-20260731` - 回档:标签`xiaobai-preservation-slice-03-20260731`
- 完整证据:`docs/migration/evidence/slice-03/README.md` - 完整证据:`docs/migration/evidence/slice-03/README.md`
已完成切片:`slice-04-ladder-rotation`
- 原版基线:提交`b3555d2`,即切片03回档点。
- 迁移范围:2个板块轮动服务方法;市场天梯继续使用切片02已归位的原Tushare数据构造实现。
- 兼容边界:`DashboardService`通过`RotationServiceMixin`保持所有原调用;天梯不制造空服务或第二套计算。
- API与错误:天梯与9日轮动历史JSON完全一致;成分股两版均返回同一Tushare外部失败语义。
- 验收:252项Python测试、4项切片源码等价测试、45项Playwright测试及两个真实页面流程通过。
- 回档:标签`xiaobai-preservation-slice-04-20260731`
- 完整证据:`docs/migration/evidence/slice-04/README.md`
## 决策记录 ## 决策记录
| 日期 | 决策 | 原因 | | 日期 | 决策 | 原因 |