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