diff --git a/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md b/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md
index 2a5e701..8d5001f 100644
--- a/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md
+++ b/.trellis/tasks/09-05-selection-layout-sector-filter/prd.md
@@ -1,40 +1,43 @@
-# 选股页面迭代:布局重构与板块筛选
+# 选股页面迭代:布局重构与细分行业筛选
## 需求
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。
-- 板块数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
+- 行业数据不在 selection 表中,按 ADR 0001 通过端口委托 sector_radar 读服务(不跨上下文 join SQL)。
## 实现
后端(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)。
-- 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):
-- `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`。
- `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。
-- 前端:页面测试新增「filters by sector and shows per-sector counts」。
+- 后端:`test_read.py` +8、`test_sector_filter.py` 新建 9 个(口径 industry)、`test_selection_http.py` +3(不再有 sector_type 转发/422 用例)。
+- 前端:页面测试新增「filters by sub-industry and shows per-industry counts」;详情面板测试改为细分行业断言并删除概念 chips 用例。
## 验收
- [x] 页面布局为上搜索栏 + 左列表右详情(移动端纵向堆叠 搜索→列表→详情)
-- [x] 板块下拉选项为当次策略结果的概念板块聚合,选项旁展示数量,按数量倒序
-- [x] 选择板块后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
+- [x] 细分行业下拉选项为当次策略结果的细分行业聚合,选项旁展示数量,按数量倒序
+- [x] 选择细分行业后结果列表服务端过滤,`筛选结果 N 只` 反映叠加计数
+- [x] 详情面板只展示细分行业,不展示概念板块
- [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 已随本次格式化移除)。
diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/run.py b/zhixing-server/src/zhixing_server/modules/selection/application/run.py
index 76c50a0..457c521 100644
--- a/zhixing-server/src/zhixing_server/modules/selection/application/run.py
+++ b/zhixing-server/src/zhixing_server/modules/selection/application/run.py
@@ -642,20 +642,22 @@ class RunZhixingB1:
target_trade_date: date,
sector_code: str,
) -> 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:
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(
self,
strategy: StrategyName,
target_trade_date: date | None = None,
- *,
- sector_type: str = "concept",
) -> 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)
if run is None:
@@ -665,11 +667,11 @@ class RunZhixingB1:
for item in run.items
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(
run=run,
snapshot_trade_date=membership.snapshot_trade_date,
- sector_type=sector_type,
+ sector_type="industry",
sectors=membership.sector_counts,
)
@@ -677,16 +679,15 @@ class RunZhixingB1:
self,
stock_codes: Sequence[str],
target_trade_date: date,
- sector_type: str,
) -> 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:
return SelectionSectorMembership(snapshot_trade_date=None, sector_counts=())
return self.sector_reader.sector_counts(
stock_codes,
target_trade_date,
- sector_type=sector_type,
+ sector_type="industry",
)
diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py
index d9878d2..2219a0d 100644
--- a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py
+++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py
@@ -67,7 +67,7 @@ class SelectionSectorReader(Protocol):
stock_codes: Sequence[str],
target_trade_date: date,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> SelectionSectorMembership: ...
def sector_member_codes(
@@ -75,7 +75,7 @@ class SelectionSectorReader(Protocol):
target_trade_date: date,
sector_code: str,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> tuple[str, ...]: ...
diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py
index 86e31da..83772d0 100644
--- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py
+++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/sector_membership.py
@@ -43,7 +43,7 @@ class SectorRadarSelectionReader:
stock_codes: Sequence[str],
target_trade_date: date,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> SelectionSectorMembership:
"""Return per-sector stock counts for one run's selected stocks."""
@@ -69,7 +69,7 @@ class SectorRadarSelectionReader:
target_trade_date: date,
sector_code: str,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> tuple[str, ...]:
"""Return one sector's member stock codes on the aligned snapshot."""
diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py
index 9409bb1..4cbfcd6 100644
--- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py
+++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py
@@ -231,14 +231,14 @@ def _empty_sectors() -> list[SelectionSectorCountResponse]:
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
target_trade_date: date | None
run_id: str | None
status: SelectionStatusValue
snapshot_trade_date: date | None
- sector_type: Literal["concept", "industry"]
+ sector_type: Literal["industry"]
sectors: list[SelectionSectorCountResponse] = Field(default_factory=_empty_sectors)
@@ -391,16 +391,11 @@ def get_selection_sectors(
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
strategy: StrategyValue = "zhixing_b1",
target_trade_date: date | None = None,
- sector_type: Literal["concept", "industry"] = "concept",
) -> 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:
- aggregates = service.list_sector_counts(
- strategy,
- target_trade_date,
- sector_type=sector_type,
- )
+ aggregates = service.list_sector_counts(strategy, target_trade_date)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if aggregates is None or aggregates.run is None:
@@ -410,7 +405,7 @@ def get_selection_sectors(
run_id=None,
status="no_data",
snapshot_trade_date=None,
- sector_type=sector_type,
+ sector_type="industry",
)
run = aggregates.run
return SelectionSectorsResponse(
@@ -419,7 +414,7 @@ def get_selection_sectors(
run_id=run.id,
status=run.status,
snapshot_trade_date=aggregates.snapshot_trade_date,
- sector_type=sector_type,
+ sector_type="industry",
sectors=[
SelectionSectorCountResponse(
sector_code=count.sector_code,
diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py
index 2294d29..4e5df5a 100644
--- a/zhixing-server/tests/test_selection_http.py
+++ b/zhixing-server/tests/test_selection_http.py
@@ -59,7 +59,6 @@ class FakeSelectionService:
self.executed = False
self.mode = "ok"
self.last_query: SelectionResultQuery | None = None
- self.sector_type_requested: str | None = None
def prepare(
self,
@@ -117,16 +116,13 @@ class FakeSelectionService:
self,
strategy: str,
target_trade_date: date | None = None,
- *,
- sector_type: str = "concept",
) -> SelectionSectorAggregates | None:
- self.sector_type_requested = sector_type
if self.run is None:
return None
return SelectionSectorAggregates(
run=self.run,
snapshot_trade_date=self.run.target_trade_date,
- sector_type=sector_type,
+ sector_type="industry",
sectors=self.sectors,
)
@@ -493,7 +489,7 @@ def test_sectors_returns_aggregated_counts_desc() -> None:
"run_id": "run-http",
"status": "success",
"snapshot_trade_date": "2026-08-08",
- "sector_type": "concept",
+ "sector_type": "industry",
"sectors": [
{"sector_code": "BK0001.DC", "sector_name": "机器人", "stock_count": 3},
{"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:
response = _client(FakeSelectionService()).get("/api/v1/selection/sectors")
@@ -524,20 +507,11 @@ def test_sectors_without_run_returns_no_data() -> None:
"run_id": None,
"status": "no_data",
"snapshot_trade_date": None,
- "sector_type": "concept",
+ "sector_type": "industry",
"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:
chart_service = FakeChartService()
diff --git a/zhixing-server/tests/unit/selection/test_sector_filter.py b/zhixing-server/tests/unit/selection/test_sector_filter.py
index 7dccd33..3a8fd03 100644
--- a/zhixing-server/tests/unit/selection/test_sector_filter.py
+++ b/zhixing-server/tests/unit/selection/test_sector_filter.py
@@ -49,7 +49,7 @@ class FakeSectorReader:
stock_codes: Sequence[str],
target_trade_date: date,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> SelectionSectorMembership:
self.count_calls.append((tuple(stock_codes), target_trade_date, sector_type))
return SelectionSectorMembership(
@@ -62,7 +62,7 @@ class FakeSectorReader:
target_trade_date: date,
sector_code: str,
*,
- sector_type: str = "concept",
+ sector_type: str = "industry",
) -> tuple[str, ...]:
self.member_calls.append((target_trade_date, sector_code, sector_type))
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")
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.sector_type == "concept"
+ assert aggregates.sector_type == "industry"
assert aggregates.sectors == sector_reader.counts
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 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.queries[-1].sector is None
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"))
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",)
@@ -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
-
-
-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")]
diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts
index beb57c0..fda0a25 100644
--- a/zhixing-web/src/features/selection/api/selection.api.ts
+++ b/zhixing-web/src/features/selection/api/selection.api.ts
@@ -71,12 +71,10 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
export function getSelectionResultSectors(
strategy: SelectionStrategy,
targetTradeDate?: string,
- sectorType: "concept" | "industry" = "concept",
signal?: AbortSignal,
) {
const params = new URLSearchParams({
strategy,
- sector_type: sectorType,
})
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson
- 行业:{industries.map((industry) => industry.name).join("、")} -
- )} - {concepts.length > 0 && ( -