refactor(selection): replace concept sector filter with sub-industry
- pin the selection sector vocabulary to industry: the /sectors endpoint no longer accepts sector_type, and the port, adapter, and use case all resolve counts and member codes with sector_type="industry" - relabel the results filter to 细分行业 and drop concept-type plumbing from the frontend API, query keys, and types - show only 细分行业 in the signal detail panel; remove the concept-board chips and keep the snapshot-date tooltip on the industry line - update backend and frontend tests to the industry vocabulary and record the revised scope in the task PRD
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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, ...]: ...
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user