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
+106 -2
View File
@@ -15,7 +15,10 @@ from zhixing_server.modules.selection.application.chart import (
SelectionChartNotFound,
SelectionChartPoint,
)
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
from zhixing_server.modules.selection.application.run import (
PreparedSelectionRun,
SelectionSectorAggregates,
)
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.pattern_scoring import (
PATTERN_SCORING_VERSION,
@@ -30,6 +33,7 @@ from zhixing_server.modules.selection.domain.runs import (
SelectionResultQuery,
SelectionRun,
SelectionRunInProgress,
SelectionSectorCount,
SelectionStock,
)
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
@@ -45,11 +49,17 @@ TARGET = date(2026, 8, 8)
class FakeSelectionService:
def __init__(self, run: SelectionRun | None = None) -> None:
def __init__(
self,
run: SelectionRun | None = None,
sectors: tuple[SelectionSectorCount, ...] = (),
) -> None:
self.run = run
self.sectors = sectors
self.executed = False
self.mode = "ok"
self.last_query: SelectionResultQuery | None = None
self.sector_type_requested: str | None = None
def prepare(
self,
@@ -103,6 +113,23 @@ class FakeSelectionService:
return None
return self.run
def list_sector_counts(
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,
sectors=self.sectors,
)
class FakeChartService:
"""Return or fail one deterministic chart response."""
@@ -434,6 +461,83 @@ def test_query_rejects_invalid_page_size() -> None:
assert response.status_code == 422
def test_query_forwards_sector_filter() -> None:
service = FakeSelectionService(_run("run-http", "success"))
response = _client(service).get(
"/api/v1/selection/results",
params={"strategy": "zhixing_b1", "sector": " BK0475.DC "},
)
assert response.status_code == 200
assert service.last_query is not None
assert service.last_query.sector == "BK0475.DC"
def test_sectors_returns_aggregated_counts_desc() -> None:
run = _run("run-http", "success")
service = FakeSelectionService(
run,
sectors=(
SelectionSectorCount(sector_code="BK0001.DC", sector_name="机器人", stock_count=3),
SelectionSectorCount(sector_code="BK0003.DC", sector_name="数字经济", stock_count=2),
),
)
response = _client(service).get("/api/v1/selection/sectors")
assert response.status_code == 200
assert response.json() == {
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"run_id": "run-http",
"status": "success",
"snapshot_trade_date": "2026-08-08",
"sector_type": "concept",
"sectors": [
{"sector_code": "BK0001.DC", "sector_name": "机器人", "stock_count": 3},
{"sector_code": "BK0003.DC", "sector_name": "数字经济", "stock_count": 2},
],
}
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")
assert response.status_code == 200
assert response.json() == {
"strategy": "zhixing_b1",
"target_trade_date": None,
"run_id": None,
"status": "no_data",
"snapshot_trade_date": None,
"sector_type": "concept",
"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()