feat(selection): expose run sector aggregates and sector filter on results API

- sector_radar: add batch sector-count aggregation and sector member lookup
  over the strict last-good membership snapshot (postgres + in-memory fakes)
- selection: add SelectionSectorReader port, list_sector_counts use case,
  and sector_stock_codes filtering via run identity resolution; queries stay
  inside the selection context per ADR 0001
- http: add GET /api/v1/selection/sectors and forward sector param on
  /results and /runs/{run_id}
- fix stale positional args in pattern-scoring run tests; cover new behavior
  with read-service, application, and HTTP contract tests
This commit is contained in:
yuxuanhui
2026-09-05 19:48:30 +08:00
parent 41a9b4eb9a
commit 7e0f13d678
13 changed files with 1135 additions and 36 deletions
@@ -27,6 +27,7 @@ from zhixing_server.modules.sector_radar.domain.models import (
from zhixing_server.modules.sector_radar.domain.persistence import (
MembershipRecord,
RankingRecord,
SectorCountEntry,
)
from zhixing_server.modules.sector_radar.domain.ranking import rank_metric_observations
from zhixing_server.modules.sector_radar.infrastructure.memory import (
@@ -315,3 +316,89 @@ def test_stock_membership_query_rejects_invalid_values() -> None:
StockSectorQuery(trade_date=TARGET_DATE, ts_code="x" * 13)
with pytest.raises(ValueError):
StockSectorQuery(trade_date=TARGET_DATE, ts_code="000001.SZ", concept_limit=0)
def test_sector_counts_aggregates_concepts_and_orders_by_count_then_code() -> None:
repository = _membership_repository()
repository.save_memberships(
(
_membership_record("000002.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"),
_membership_record("000003.SZ", SectorType.CONCEPT, "BK0001.DC", "机器人"),
_membership_record("000002.SZ", SectorType.CONCEPT, "BK0003.DC", "数字经济"),
)
)
reader = ReadSectorRadar(repository)
snapshot = reader.sector_counts(["000001.SZ", "000002.SZ", "000003.SZ"], TARGET_DATE)
assert snapshot.status == "success"
assert snapshot.trade_date == TARGET_DATE
assert snapshot.sector_type is SectorType.CONCEPT
assert snapshot.counts == (
SectorCountEntry(sector_code="BK0001.DC", sector_name="机器人", stock_count=3),
SectorCountEntry(sector_code="BK0003.DC", sector_name="数字经济", stock_count=2),
SectorCountEntry(sector_code="BK0002.DC", sector_name="人工智能", stock_count=1),
)
def test_sector_counts_supports_industry_type() -> None:
reader = ReadSectorRadar(_membership_repository())
snapshot = reader.sector_counts(
["000001.SZ", "000002.SZ"],
TARGET_DATE,
sector_type=SectorType.INDUSTRY,
)
assert snapshot.status == "success"
assert snapshot.sector_type is SectorType.INDUSTRY
assert snapshot.counts == (
SectorCountEntry(sector_code="BK0901.DC", sector_name="银行", stock_count=1),
SectorCountEntry(sector_code="BK0902.DC", sector_name="房地产", stock_count=1),
)
def test_sector_counts_without_publication_is_no_data() -> None:
reader = ReadSectorRadar(InMemorySectorRadarRepository())
snapshot = reader.sector_counts(["000001.SZ"], TARGET_DATE)
assert snapshot.status == "no_data"
assert snapshot.trade_date is None
assert snapshot.counts == ()
def test_sector_counts_with_empty_stock_set_skips_publication_lookup() -> None:
reader = ReadSectorRadar(_membership_repository())
snapshot = reader.sector_counts([], TARGET_DATE)
assert snapshot.status == "no_data"
assert snapshot.counts == ()
def test_sector_member_codes_returns_available_members() -> None:
reader = ReadSectorRadar(_membership_repository())
snapshot = reader.sector_member_codes(TARGET_DATE, "BK0001.DC")
assert snapshot.status == "success"
assert snapshot.trade_date == TARGET_DATE
assert snapshot.stock_codes == ("000001.SZ",)
def test_sector_member_codes_without_publication_is_no_data() -> None:
reader = ReadSectorRadar(InMemorySectorRadarRepository())
snapshot = reader.sector_member_codes(TARGET_DATE, "BK0001.DC")
assert snapshot.status == "no_data"
assert snapshot.trade_date is None
assert snapshot.stock_codes == ()
def test_sector_member_codes_rejects_blank_sector_code() -> None:
reader = ReadSectorRadar(_membership_repository())
with pytest.raises(ValueError):
reader.sector_member_codes(TARGET_DATE, " ")