This commit is contained in:
@@ -1,40 +1,43 @@
|
|||||||
# 选股页面迭代:布局重构与板块筛选
|
# 选股页面迭代:布局重构与细分行业筛选
|
||||||
|
|
||||||
## 需求
|
## 需求
|
||||||
|
|
||||||
1. 选股页面从左右布局改为「上搜索栏 + 左列表 / 右详情」三段式布局。
|
1. 选股页面从左右布局改为「上搜索栏 + 左列表 / 右详情」三段式布局。
|
||||||
2. 搜索模块增加板块筛选:下拉选项为当次策略结果的板块聚合,选项旁展示数量,按数量倒序。
|
2. 搜索模块增加细分行业筛选:下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序。
|
||||||
|
3. 选股模块不展示「板块」(概念板块):列表筛选与详情页均去掉;详情页「行业」口径改称「细分行业」。(2026-09-05 修订,原口径为概念板块)
|
||||||
|
|
||||||
## 关键决策
|
## 关键决策
|
||||||
|
|
||||||
- 板块口径:概念板块(sector_type=concept),与详情面板「板块」标签一致;接口保留 sector_type 参数可扩展行业。
|
- 板块口径:细分行业(sector_type=industry,即东财行业快照),selection 上下文固定为 industry——HTTP `/sectors` 不再接受 sector_type 参数,端口/适配器默认值均为 industry;「细分行业」仍是 sector 词汇的一种,内部参数名保持 `sector` 不变。
|
||||||
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
|
- 单选下拉;排序按 stock_count 倒序(后端保证),名称升序 tie-break。
|
||||||
- 板块数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
|
- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
|
||||||
|
|
||||||
## 实现
|
## 实现
|
||||||
|
|
||||||
后端(zhixing-server):
|
后端(zhixing-server):
|
||||||
- sector_radar:`domain/persistence.py` 新增 `SectorCountEntry` + 2 个协议方法;`infrastructure/postgres.py` / `infrastructure/memory.py` 实现 `load_sector_counts` / `load_sector_member_codes`;`application/read.py` 新增 `sector_counts` / `sector_member_codes`(先解析 last-good publication)。
|
- sector_radar:`domain/persistence.py` 新增 `SectorCountEntry` + 2 个协议方法;`infrastructure/postgres.py` / `infrastructure/memory.py` 实现 `load_sector_counts` / `load_sector_member_codes`;`application/read.py` 新增 `sector_counts` / `sector_member_codes`(先解析 last-good publication)。
|
||||||
- selection:`domain/runs.py` 新增 `SelectionSectorReader` 端口、`SelectionSectorCount` / `SelectionSectorMembership` / `SelectionRunIdentity`,`SelectionResultQuery.sector`;`application/run.py` 新增 `list_sector_counts`,`get_run`/`get_latest` 经 identity → 成员代码 → `sector_stock_codes` 过滤;`infrastructure/postgres_runs.py` `_stock_filter` 支持 `ts_code = ANY(...)` / FALSE;`infrastructure/sector_membership.py` 桥接适配器;`presentation/http.py` 新增 `GET /sectors`,results/runs 增加 `sector` 参数。
|
- selection:`domain/runs.py` 新增 `SelectionSectorReader` 端口、`SelectionSectorCount` / `SelectionSectorMembership` / `SelectionRunIdentity`,`SelectionResultQuery.sector`;`application/run.py` 新增 `list_sector_counts`(固定 industry),`get_run`/`get_latest` 经 identity → 成员代码 → `sector_stock_codes` 过滤;`infrastructure/postgres_runs.py` `_stock_filter` 支持 `ts_code = ANY(...)` / FALSE;`infrastructure/sector_membership.py` 桥接适配器;`presentation/http.py` 新增 `GET /sectors`,results/runs 增加 `sector` 参数。
|
||||||
|
|
||||||
前端(zhixing-web):
|
前端(zhixing-web):
|
||||||
- `selection.types.ts` / `selection.api.ts` / `selection.query.ts`:`SelectionSectors`、`getSelectionResultSectors`、`useSelectionResultSectors`,query key 加 sector,结果失效同时失效 sectors。
|
- `selection.types.ts` / `selection.api.ts` / `selection.query.ts`:`SelectionSectors`(sector_type 收窄为 "industry")、`getSelectionResultSectors`、`useSelectionResultSectors`(不传 sector_type),query key 加 sector,结果失效同时失效 sectors。
|
||||||
- `route-tree.tsx`:selection 路由 search 增加 `sector`。
|
- `route-tree.tsx`:selection 路由 search 增加 `sector`。
|
||||||
- `selection-results-page.tsx`:结果查询带 sector,页面调用 sectors hook 并下传 workbench;切策略重置 sector。
|
- `selection-results-page.tsx`:结果查询带 sector,页面调用 sectors hook 并下传 workbench;切策略重置 sector。
|
||||||
- `selection-results-workbench.tsx`:布局重构为上搜索栏 + 下方 320px 列表/详情两栏;板块下拉(全部板块 + 聚合选项带数量徽标);聚合加载后自动清掉失效 sector。
|
- `selection-results-workbench.tsx`:布局重构为上搜索栏 + 下方 320px 列表/详情两栏;细分行业下拉(全部细分行业 + 聚合选项带数量徽标);聚合加载后自动清掉失效 sector。
|
||||||
|
- `signal-detail-panel.tsx`:展示「细分行业:…」(title 提示成分快照日期),不再展示概念板块 chips。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
- 后端:`test_read.py` +8、`test_sector_filter.py` 新建 9 个、`test_selection_http.py` +5。
|
- 后端:`test_read.py` +8、`test_sector_filter.py` 新建 9 个(口径 industry)、`test_selection_http.py` +3(不再有 sector_type 转发/422 用例)。
|
||||||
- 前端:页面测试新增「filters by sector and shows per-sector counts」。
|
- 前端:页面测试新增「filters by sub-industry and shows per-industry counts」;详情面板测试改为细分行业断言并删除概念 chips 用例。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
- [x] 页面布局为上搜索栏 + 左列表右详情(移动端纵向堆叠 搜索→列表→详情)
|
- [x] 页面布局为上搜索栏 + 左列表右详情(移动端纵向堆叠 搜索→列表→详情)
|
||||||
- [x] 板块下拉选项为当次策略结果的概念板块聚合,选项旁展示数量,按数量倒序
|
- [x] 细分行业下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序
|
||||||
- [x] 选择板块后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
|
- [x] 选择细分行业后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
|
||||||
|
- [x] 详情面板只展示细分行业,不展示概念板块
|
||||||
- [x] 后端 ruff/pyright/pytest 与前端 lint/typecheck/test/build 门禁通过(遗留项均为基线原有)
|
- [x] 后端 ruff/pyright/pytest 与前端 lint/typecheck/test/build 门禁通过(遗留项均为基线原有)
|
||||||
|
|
||||||
## 遗留(均为基线原有,非本次引入)
|
## 遗留(均为基线原有,非本次引入)
|
||||||
|
|
||||||
- 后端 pyright 16 个错误(chart.py/gold_brick.py pandas 相关、test_run.py 旧 fake 类型);前端 4 个执行状态抽屉测试失败;前端 format:check 有基线未格式化文件(.playwright-cli、sector-radar 部分文件、signal-detail-panel.tsx)。
|
- 后端 pyright 16 个错误(chart.py/gold_brick.py pandas 相关、test_run.py 旧 fake 类型);前端 4 个执行状态抽屉测试失败;前端 format:check 有基线未格式化文件(.playwright-cli、sector-radar 部分文件;signal-detail-panel.tsx 已随本次格式化移除)。
|
||||||
|
|||||||
@@ -642,20 +642,22 @@ class RunZhixingB1:
|
|||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
) -> tuple[str, ...] | None:
|
) -> tuple[str, ...] | None:
|
||||||
"""Resolve one sector's members, or None when the port is absent."""
|
"""Resolve one sub-industry's members, or None when the port is absent."""
|
||||||
|
|
||||||
if self.sector_reader is None:
|
if self.sector_reader is None:
|
||||||
return None
|
return None
|
||||||
return self.sector_reader.sector_member_codes(target_trade_date, sector_code)
|
return self.sector_reader.sector_member_codes(
|
||||||
|
target_trade_date,
|
||||||
|
sector_code,
|
||||||
|
sector_type="industry",
|
||||||
|
)
|
||||||
|
|
||||||
def list_sector_counts(
|
def list_sector_counts(
|
||||||
self,
|
self,
|
||||||
strategy: StrategyName,
|
strategy: StrategyName,
|
||||||
target_trade_date: date | None = None,
|
target_trade_date: date | None = None,
|
||||||
*,
|
|
||||||
sector_type: str = "concept",
|
|
||||||
) -> SelectionSectorAggregates | None:
|
) -> SelectionSectorAggregates | None:
|
||||||
"""Aggregate the current run's selected stocks by point-in-time sector."""
|
"""Aggregate the current run's selected stocks by sub-industry."""
|
||||||
|
|
||||||
run = self.store.get_latest_run(strategy, target_trade_date)
|
run = self.store.get_latest_run(strategy, target_trade_date)
|
||||||
if run is None:
|
if run is None:
|
||||||
@@ -665,11 +667,11 @@ class RunZhixingB1:
|
|||||||
for item in run.items
|
for item in run.items
|
||||||
if item.status == "selected" and item.signal_count > 0
|
if item.status == "selected" and item.signal_count > 0
|
||||||
]
|
]
|
||||||
membership = self._sector_membership(selected_codes, run.target_trade_date, sector_type)
|
membership = self._sector_membership(selected_codes, run.target_trade_date)
|
||||||
return SelectionSectorAggregates(
|
return SelectionSectorAggregates(
|
||||||
run=run,
|
run=run,
|
||||||
snapshot_trade_date=membership.snapshot_trade_date,
|
snapshot_trade_date=membership.snapshot_trade_date,
|
||||||
sector_type=sector_type,
|
sector_type="industry",
|
||||||
sectors=membership.sector_counts,
|
sectors=membership.sector_counts,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -677,16 +679,15 @@ class RunZhixingB1:
|
|||||||
self,
|
self,
|
||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_type: str,
|
|
||||||
) -> SelectionSectorMembership:
|
) -> SelectionSectorMembership:
|
||||||
"""Read sector counts for a stock set, tolerating a missing port."""
|
"""Read sub-industry counts for a stock set, tolerating a missing port."""
|
||||||
|
|
||||||
if self.sector_reader is None or not stock_codes:
|
if self.sector_reader is None or not stock_codes:
|
||||||
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
|
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
|
||||||
return self.sector_reader.sector_counts(
|
return self.sector_reader.sector_counts(
|
||||||
stock_codes,
|
stock_codes,
|
||||||
target_trade_date,
|
target_trade_date,
|
||||||
sector_type=sector_type,
|
sector_type="industry",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class SelectionSectorReader(Protocol):
|
|||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> SelectionSectorMembership: ...
|
) -> SelectionSectorMembership: ...
|
||||||
|
|
||||||
def sector_member_codes(
|
def sector_member_codes(
|
||||||
@@ -75,7 +75,7 @@ class SelectionSectorReader(Protocol):
|
|||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> tuple[str, ...]: ...
|
) -> tuple[str, ...]: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -43,7 +43,7 @@ class SectorRadarSelectionReader:
|
|||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> SelectionSectorMembership:
|
) -> SelectionSectorMembership:
|
||||||
"""Return per-sector stock counts for one run's selected stocks."""
|
"""Return per-sector stock counts for one run's selected stocks."""
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class SectorRadarSelectionReader:
|
|||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Return one sector's member stock codes on the aligned snapshot."""
|
"""Return one sector's member stock codes on the aligned snapshot."""
|
||||||
|
|
||||||
|
|||||||
@@ -231,14 +231,14 @@ def _empty_sectors() -> list[SelectionSectorCountResponse]:
|
|||||||
|
|
||||||
|
|
||||||
class SelectionSectorsResponse(BaseModel):
|
class SelectionSectorsResponse(BaseModel):
|
||||||
"""The current run's selected stocks aggregated by point-in-time sector."""
|
"""The current run's selected stocks aggregated by point-in-time sub-industry."""
|
||||||
|
|
||||||
strategy: StrategyValue
|
strategy: StrategyValue
|
||||||
target_trade_date: date | None
|
target_trade_date: date | None
|
||||||
run_id: str | None
|
run_id: str | None
|
||||||
status: SelectionStatusValue
|
status: SelectionStatusValue
|
||||||
snapshot_trade_date: date | None
|
snapshot_trade_date: date | None
|
||||||
sector_type: Literal["concept", "industry"]
|
sector_type: Literal["industry"]
|
||||||
sectors: list[SelectionSectorCountResponse] = Field(default_factory=_empty_sectors)
|
sectors: list[SelectionSectorCountResponse] = Field(default_factory=_empty_sectors)
|
||||||
|
|
||||||
|
|
||||||
@@ -391,16 +391,11 @@ def get_selection_sectors(
|
|||||||
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
|
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
|
||||||
strategy: StrategyValue = "zhixing_b1",
|
strategy: StrategyValue = "zhixing_b1",
|
||||||
target_trade_date: date | None = None,
|
target_trade_date: date | None = None,
|
||||||
sector_type: Literal["concept", "industry"] = "concept",
|
|
||||||
) -> SelectionSectorsResponse:
|
) -> SelectionSectorsResponse:
|
||||||
"""Aggregate the current run's selected stocks by point-in-time sector."""
|
"""Aggregate the current run's selected stocks by point-in-time sub-industry."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
aggregates = service.list_sector_counts(
|
aggregates = service.list_sector_counts(strategy, target_trade_date)
|
||||||
strategy,
|
|
||||||
target_trade_date,
|
|
||||||
sector_type=sector_type,
|
|
||||||
)
|
|
||||||
except SelectionRunStoreError as exc:
|
except SelectionRunStoreError as exc:
|
||||||
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
|
||||||
if aggregates is None or aggregates.run is None:
|
if aggregates is None or aggregates.run is None:
|
||||||
@@ -410,7 +405,7 @@ def get_selection_sectors(
|
|||||||
run_id=None,
|
run_id=None,
|
||||||
status="no_data",
|
status="no_data",
|
||||||
snapshot_trade_date=None,
|
snapshot_trade_date=None,
|
||||||
sector_type=sector_type,
|
sector_type="industry",
|
||||||
)
|
)
|
||||||
run = aggregates.run
|
run = aggregates.run
|
||||||
return SelectionSectorsResponse(
|
return SelectionSectorsResponse(
|
||||||
@@ -419,7 +414,7 @@ def get_selection_sectors(
|
|||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
status=run.status,
|
status=run.status,
|
||||||
snapshot_trade_date=aggregates.snapshot_trade_date,
|
snapshot_trade_date=aggregates.snapshot_trade_date,
|
||||||
sector_type=sector_type,
|
sector_type="industry",
|
||||||
sectors=[
|
sectors=[
|
||||||
SelectionSectorCountResponse(
|
SelectionSectorCountResponse(
|
||||||
sector_code=count.sector_code,
|
sector_code=count.sector_code,
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ class FakeSelectionService:
|
|||||||
self.executed = False
|
self.executed = False
|
||||||
self.mode = "ok"
|
self.mode = "ok"
|
||||||
self.last_query: SelectionResultQuery | None = None
|
self.last_query: SelectionResultQuery | None = None
|
||||||
self.sector_type_requested: str | None = None
|
|
||||||
|
|
||||||
def prepare(
|
def prepare(
|
||||||
self,
|
self,
|
||||||
@@ -117,16 +116,13 @@ class FakeSelectionService:
|
|||||||
self,
|
self,
|
||||||
strategy: str,
|
strategy: str,
|
||||||
target_trade_date: date | None = None,
|
target_trade_date: date | None = None,
|
||||||
*,
|
|
||||||
sector_type: str = "concept",
|
|
||||||
) -> SelectionSectorAggregates | None:
|
) -> SelectionSectorAggregates | None:
|
||||||
self.sector_type_requested = sector_type
|
|
||||||
if self.run is None:
|
if self.run is None:
|
||||||
return None
|
return None
|
||||||
return SelectionSectorAggregates(
|
return SelectionSectorAggregates(
|
||||||
run=self.run,
|
run=self.run,
|
||||||
snapshot_trade_date=self.run.target_trade_date,
|
snapshot_trade_date=self.run.target_trade_date,
|
||||||
sector_type=sector_type,
|
sector_type="industry",
|
||||||
sectors=self.sectors,
|
sectors=self.sectors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,7 +489,7 @@ def test_sectors_returns_aggregated_counts_desc() -> None:
|
|||||||
"run_id": "run-http",
|
"run_id": "run-http",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"snapshot_trade_date": "2026-08-08",
|
"snapshot_trade_date": "2026-08-08",
|
||||||
"sector_type": "concept",
|
"sector_type": "industry",
|
||||||
"sectors": [
|
"sectors": [
|
||||||
{"sector_code": "BK0001.DC", "sector_name": "机器人", "stock_count": 3},
|
{"sector_code": "BK0001.DC", "sector_name": "机器人", "stock_count": 3},
|
||||||
{"sector_code": "BK0003.DC", "sector_name": "数字经济", "stock_count": 2},
|
{"sector_code": "BK0003.DC", "sector_name": "数字经济", "stock_count": 2},
|
||||||
@@ -501,19 +497,6 @@ def test_sectors_returns_aggregated_counts_desc() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_sectors_forwards_sector_type() -> None:
|
|
||||||
service = FakeSelectionService(_run("run-http", "success"))
|
|
||||||
|
|
||||||
response = _client(service).get(
|
|
||||||
"/api/v1/selection/sectors",
|
|
||||||
params={"sector_type": "industry"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert service.sector_type_requested == "industry"
|
|
||||||
assert response.json()["sector_type"] == "industry"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sectors_without_run_returns_no_data() -> None:
|
def test_sectors_without_run_returns_no_data() -> None:
|
||||||
response = _client(FakeSelectionService()).get("/api/v1/selection/sectors")
|
response = _client(FakeSelectionService()).get("/api/v1/selection/sectors")
|
||||||
|
|
||||||
@@ -524,20 +507,11 @@ def test_sectors_without_run_returns_no_data() -> None:
|
|||||||
"run_id": None,
|
"run_id": None,
|
||||||
"status": "no_data",
|
"status": "no_data",
|
||||||
"snapshot_trade_date": None,
|
"snapshot_trade_date": None,
|
||||||
"sector_type": "concept",
|
"sector_type": "industry",
|
||||||
"sectors": [],
|
"sectors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_sectors_rejects_unknown_sector_type() -> None:
|
|
||||||
response = _client(FakeSelectionService()).get(
|
|
||||||
"/api/v1/selection/sectors",
|
|
||||||
params={"sector_type": "macro"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_chart_returns_bounded_qfq_contract() -> None:
|
def test_chart_returns_bounded_qfq_contract() -> None:
|
||||||
chart_service = FakeChartService()
|
chart_service = FakeChartService()
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class FakeSectorReader:
|
|||||||
stock_codes: Sequence[str],
|
stock_codes: Sequence[str],
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> SelectionSectorMembership:
|
) -> SelectionSectorMembership:
|
||||||
self.count_calls.append((tuple(stock_codes), target_trade_date, sector_type))
|
self.count_calls.append((tuple(stock_codes), target_trade_date, sector_type))
|
||||||
return SelectionSectorMembership(
|
return SelectionSectorMembership(
|
||||||
@@ -62,7 +62,7 @@ class FakeSectorReader:
|
|||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
sector_code: str,
|
sector_code: str,
|
||||||
*,
|
*,
|
||||||
sector_type: str = "concept",
|
sector_type: str = "industry",
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
self.member_calls.append((target_trade_date, sector_code, sector_type))
|
self.member_calls.append((target_trade_date, sector_code, sector_type))
|
||||||
return self.member_codes
|
return self.member_codes
|
||||||
@@ -161,9 +161,9 @@ def test_list_sector_counts_aggregates_only_selected_stocks() -> None:
|
|||||||
aggregates = service.list_sector_counts("zhixing_b1")
|
aggregates = service.list_sector_counts("zhixing_b1")
|
||||||
|
|
||||||
assert isinstance(aggregates, SelectionSectorAggregates)
|
assert isinstance(aggregates, SelectionSectorAggregates)
|
||||||
assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET, "concept")]
|
assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET, "industry")]
|
||||||
assert aggregates.snapshot_trade_date == TARGET
|
assert aggregates.snapshot_trade_date == TARGET
|
||||||
assert aggregates.sector_type == "concept"
|
assert aggregates.sector_type == "industry"
|
||||||
assert aggregates.sectors == sector_reader.counts
|
assert aggregates.sectors == sector_reader.counts
|
||||||
assert aggregates.run is store.run
|
assert aggregates.run is store.run
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ def test_get_latest_resolves_sector_filter_against_run_snapshot() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert run is store.run
|
assert run is store.run
|
||||||
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "concept")]
|
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")]
|
||||||
assert store.sector_codes_seen == ("000001.SZ", "000002.SZ")
|
assert store.sector_codes_seen == ("000001.SZ", "000002.SZ")
|
||||||
assert store.queries[-1].sector is None
|
assert store.queries[-1].sector is None
|
||||||
assert store.queries[-1].page == 2
|
assert store.queries[-1].page == 2
|
||||||
@@ -239,7 +239,7 @@ def test_get_run_resolves_sector_filter_against_run_snapshot() -> None:
|
|||||||
run = service.get_run("run-1", query=SelectionResultQuery(sector="BK0001.DC"))
|
run = service.get_run("run-1", query=SelectionResultQuery(sector="BK0001.DC"))
|
||||||
|
|
||||||
assert run is store.run
|
assert run is store.run
|
||||||
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "concept")]
|
assert sector_reader.member_calls == [(TARGET, "BK0001.DC", "industry")]
|
||||||
assert store.sector_codes_seen == ("000001.SZ",)
|
assert store.sector_codes_seen == ("000001.SZ",)
|
||||||
|
|
||||||
|
|
||||||
@@ -251,14 +251,3 @@ def test_get_latest_without_run_and_sector_filter_returns_none() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert service.get_latest("zhixing_b1", query=SelectionResultQuery(sector="BK0001.DC")) is None
|
assert service.get_latest("zhixing_b1", query=SelectionResultQuery(sector="BK0001.DC")) is None
|
||||||
|
|
||||||
|
|
||||||
def test_list_sector_counts_accepts_industry_type() -> None:
|
|
||||||
sector_reader = FakeSectorReader()
|
|
||||||
service = RunZhixingB1(FakeReader(), FakeStore(_run()), sector_reader=sector_reader)
|
|
||||||
|
|
||||||
aggregates = service.list_sector_counts("zhixing_b1", sector_type="industry")
|
|
||||||
|
|
||||||
assert aggregates is not None
|
|
||||||
assert aggregates.sector_type == "industry"
|
|
||||||
assert sector_reader.count_calls == [(("000001.SZ", "000002.SZ"), TARGET, "industry")]
|
|
||||||
|
|||||||
@@ -71,12 +71,10 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
|
|||||||
export function getSelectionResultSectors(
|
export function getSelectionResultSectors(
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
sectorType: "concept" | "industry" = "concept",
|
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
strategy,
|
strategy,
|
||||||
sector_type: sectorType,
|
|
||||||
})
|
})
|
||||||
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
|
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
|
||||||
return requestJson<SelectionSectors>(
|
return requestJson<SelectionSectors>(
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ export const selectionRunQueryKey = (
|
|||||||
export const selectionSectorsQueryKey = (
|
export const selectionSectorsQueryKey = (
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
sectorType: "concept" | "industry" = "concept",
|
) => ["selection", "sectors", strategy, targetTradeDate] as const
|
||||||
) => ["selection", "sectors", strategy, targetTradeDate, sectorType] as const
|
|
||||||
|
|
||||||
export function flattenSelectionResults(
|
export function flattenSelectionResults(
|
||||||
data: InfiniteData<SelectionResults> | undefined,
|
data: InfiniteData<SelectionResults> | undefined,
|
||||||
@@ -133,12 +132,11 @@ export function useSelectionChart(
|
|||||||
export function useSelectionResultSectors(
|
export function useSelectionResultSectors(
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
sectorType: "concept" | "industry" = "concept",
|
|
||||||
) {
|
) {
|
||||||
return useQuery<SelectionSectors, Error>({
|
return useQuery<SelectionSectors, Error>({
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
getSelectionResultSectors(strategy, targetTradeDate, sectorType, signal),
|
getSelectionResultSectors(strategy, targetTradeDate, signal),
|
||||||
queryKey: selectionSectorsQueryKey(strategy, targetTradeDate, sectorType),
|
queryKey: selectionSectorsQueryKey(strategy, targetTradeDate),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ export interface SelectionResultsQuery {
|
|||||||
sector?: string
|
sector?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SelectionSectorType = "concept" | "industry"
|
export type SelectionSectorType = "industry"
|
||||||
|
|
||||||
export const selectionSectorTypes = ["concept", "industry"] as const
|
|
||||||
|
|
||||||
export interface SelectionSectorAggregate {
|
export interface SelectionSectorAggregate {
|
||||||
sector_code: string
|
sector_code: string
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function SelectionResultsWorkbench({
|
|||||||
visibleStocks[0]
|
visibleStocks[0]
|
||||||
const sectorSelectItems = useMemo(
|
const sectorSelectItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "全部板块", value: ALL_SECTORS_VALUE },
|
{ label: "全部细分行业", value: ALL_SECTORS_VALUE },
|
||||||
...sectorOptions.map((option) => ({
|
...sectorOptions.map((option) => ({
|
||||||
label: option.sector_name,
|
label: option.sector_name,
|
||||||
value: option.sector_code,
|
value: option.sector_code,
|
||||||
@@ -158,14 +158,14 @@ export function SelectionResultsWorkbench({
|
|||||||
value={sector ?? ALL_SECTORS_VALUE}
|
value={sector ?? ALL_SECTORS_VALUE}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
aria-label="筛选板块"
|
aria-label="筛选细分行业"
|
||||||
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="筛选板块" />
|
<SelectValue placeholder="筛选细分行业" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectItem value={ALL_SECTORS_VALUE}>全部板块</SelectItem>
|
<SelectItem value={ALL_SECTORS_VALUE}>全部细分行业</SelectItem>
|
||||||
{sectorOptions.map((option) => (
|
{sectorOptions.map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.sector_code}
|
key={option.sector_code}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe("SignalDetailPanel", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("shows the stock industry and concept boards from the membership query", () => {
|
it("shows the stock sub-industries from the membership query", () => {
|
||||||
useStockSectorMembership.mockReturnValue({
|
useStockSectorMembership.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
status: "success",
|
status: "success",
|
||||||
@@ -69,42 +69,12 @@ describe("SignalDetailPanel", () => {
|
|||||||
|
|
||||||
render(<SignalDetailPanel stock={stock} />)
|
render(<SignalDetailPanel stock={stock} />)
|
||||||
|
|
||||||
expect(screen.getByText("行业:银行")).toBeInTheDocument()
|
expect(screen.getByText(/细分行业:银行/)).toBeInTheDocument()
|
||||||
expect(screen.getByText("板块")).toBeInTheDocument()
|
expect(screen.queryByText("机器人")).not.toBeInTheDocument()
|
||||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
expect(screen.queryByText("人工智能")).not.toBeInTheDocument()
|
||||||
expect(screen.getByText("人工智能")).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/等 \d+ 个/)).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("caps visible concepts and reports the total", () => {
|
it("hides the sub-industry row when the membership query returns no data", () => {
|
||||||
const concepts = Array.from({ length: 10 }, (_, index) => ({
|
|
||||||
code: `BK${String(index + 1).padStart(4, "0")}.DC`,
|
|
||||||
name: `板块${index + 1}`,
|
|
||||||
}))
|
|
||||||
useStockSectorMembership.mockReturnValue({
|
|
||||||
data: {
|
|
||||||
status: "success",
|
|
||||||
ts_code: "000001.SZ",
|
|
||||||
requested_trade_date: "2026-08-28",
|
|
||||||
trade_date: "2026-08-28",
|
|
||||||
industries: [],
|
|
||||||
concepts,
|
|
||||||
concept_total: 10,
|
|
||||||
concept_limit: 20,
|
|
||||||
},
|
|
||||||
isError: false,
|
|
||||||
isPending: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
render(<SignalDetailPanel stock={stock} />)
|
|
||||||
|
|
||||||
expect(screen.getByText("板块1")).toBeInTheDocument()
|
|
||||||
expect(screen.getByText("板块8")).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText("板块9")).not.toBeInTheDocument()
|
|
||||||
expect(screen.getByText("等 10 个")).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("hides the sector rows when the membership query returns no data", () => {
|
|
||||||
useStockSectorMembership.mockReturnValue({
|
useStockSectorMembership.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
status: "no_data",
|
status: "no_data",
|
||||||
@@ -122,7 +92,6 @@ describe("SignalDetailPanel", () => {
|
|||||||
|
|
||||||
render(<SignalDetailPanel stock={stock} />)
|
render(<SignalDetailPanel stock={stock} />)
|
||||||
|
|
||||||
expect(screen.queryByText(/行业:/)).not.toBeInTheDocument()
|
expect(screen.queryByText(/细分行业:/)).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText("板块")).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ const SelectionChart = lazy(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const displayedConceptCount = 8
|
|
||||||
|
|
||||||
interface SignalDetailPanelProps {
|
interface SignalDetailPanelProps {
|
||||||
stock: SelectionStockResult | undefined
|
stock: SelectionStockResult | undefined
|
||||||
}
|
}
|
||||||
@@ -71,16 +69,13 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
const membership =
|
const membership =
|
||||||
membershipQuery.data?.status === "success" ? membershipQuery.data : undefined
|
membershipQuery.data?.status === "success"
|
||||||
|
? membershipQuery.data
|
||||||
|
: undefined
|
||||||
const industries = membership?.industries ?? []
|
const industries = membership?.industries ?? []
|
||||||
const concepts = membership?.concepts ?? []
|
const industrySnapshotTitle =
|
||||||
const extraConceptCount = Math.max(
|
|
||||||
0,
|
|
||||||
concepts.length - displayedConceptCount,
|
|
||||||
)
|
|
||||||
const sectorSnapshotTitle =
|
|
||||||
membership?.trade_date != null
|
membership?.trade_date != null
|
||||||
? `板块成分截至 ${membership.trade_date}`
|
? `细分行业成分截至 ${membership.trade_date}`
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,30 +133,13 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{industries.length > 0 && (
|
{industries.length > 0 && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p
|
||||||
行业:{industries.map((industry) => industry.name).join("、")}
|
className="mt-1 text-xs text-muted-foreground"
|
||||||
</p>
|
title={industrySnapshotTitle}
|
||||||
)}
|
|
||||||
{concepts.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="mt-2 flex flex-wrap items-center gap-1"
|
|
||||||
title={sectorSnapshotTitle}
|
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">板块</span>
|
细分行业:
|
||||||
{concepts.slice(0, displayedConceptCount).map((concept) => (
|
{industries.map((industry) => industry.name).join("、")}
|
||||||
<span
|
</p>
|
||||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
|
||||||
key={concept.code}
|
|
||||||
>
|
|
||||||
{concept.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{extraConceptCount > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
等 {concepts.length} 个
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("filters by sector and shows per-sector counts", async () => {
|
it("filters by sub-industry and shows per-industry counts", async () => {
|
||||||
useSelectionResultSectors.mockReturnValue({
|
useSelectionResultSectors.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
strategy: "zhixing_b1",
|
strategy: "zhixing_b1",
|
||||||
@@ -551,10 +551,10 @@ describe("SelectionResultsPage", () => {
|
|||||||
run_id: "run-1",
|
run_id: "run-1",
|
||||||
status: "success",
|
status: "success",
|
||||||
snapshot_trade_date: "2026-08-08",
|
snapshot_trade_date: "2026-08-08",
|
||||||
sector_type: "concept",
|
sector_type: "industry",
|
||||||
sectors: [
|
sectors: [
|
||||||
{ sector_code: "BK0001.DC", sector_name: "机器人", stock_count: 3 },
|
{ sector_code: "BK0475.DC", sector_name: "银行", stock_count: 3 },
|
||||||
{ sector_code: "BK0003.DC", sector_name: "数字经济", stock_count: 2 },
|
{ sector_code: "BK0477.DC", sector_name: "保险", stock_count: 2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -562,16 +562,16 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
render(<SelectionResultsPage />)
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
const sectorTrigger = screen.getByRole("combobox", { name: "筛选板块" })
|
const sectorTrigger = screen.getByRole("combobox", { name: "筛选细分行业" })
|
||||||
expect(sectorTrigger).toHaveTextContent("全部板块")
|
expect(sectorTrigger).toHaveTextContent("全部细分行业")
|
||||||
|
|
||||||
fireEvent.click(sectorTrigger)
|
fireEvent.click(sectorTrigger)
|
||||||
const robotOption = await screen.findByRole("option", {
|
const bankOption = await screen.findByRole("option", {
|
||||||
name: /机器人/,
|
name: /银行/,
|
||||||
})
|
})
|
||||||
expect(robotOption).toHaveTextContent("3")
|
expect(bankOption).toHaveTextContent("3")
|
||||||
fireEvent.pointerDown(robotOption, { pointerType: "mouse" })
|
fireEvent.pointerDown(bankOption, { pointerType: "mouse" })
|
||||||
fireEvent.click(robotOption)
|
fireEvent.click(bankOption)
|
||||||
|
|
||||||
expect(routerNavigate).toHaveBeenCalled()
|
expect(routerNavigate).toHaveBeenCalled()
|
||||||
const lastCall =
|
const lastCall =
|
||||||
@@ -579,7 +579,7 @@ describe("SelectionResultsPage", () => {
|
|||||||
const searchUpdate = lastCall[0].search as (previous: {
|
const searchUpdate = lastCall[0].search as (previous: {
|
||||||
sector?: string
|
sector?: string
|
||||||
}) => Record<string, unknown>
|
}) => Record<string, unknown>
|
||||||
expect(searchUpdate({})).toEqual({ sector: "BK0001.DC" })
|
expect(searchUpdate({})).toEqual({ sector: "BK0475.DC" })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("requests database-backed score sorting", async () => {
|
it("requests database-backed score sorting", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user