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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user