migration: preserve ladder and rotation slice
This commit is contained in:
+7
-157
@@ -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"]
|
||||||
@@ -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 |
@@ -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",
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
## 决策记录
|
## 决策记录
|
||||||
|
|
||||||
| 日期 | 决策 | 原因 |
|
| 日期 | 决策 | 原因 |
|
||||||
|
|||||||
Reference in New Issue
Block a user