7e0f13d678
- 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
643 lines
20 KiB
Python
643 lines
20 KiB
Python
"""HTTP contracts for triggering and querying persisted selection runs."""
|
|
|
|
from dataclasses import replace
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import zhixing_server.modules.selection.presentation.http as selection_http
|
|
from zhixing_server.bootstrap.app import create_app
|
|
from zhixing_server.bootstrap.config import Settings
|
|
from zhixing_server.modules.selection.application.chart import (
|
|
SelectionChart,
|
|
SelectionChartNotFound,
|
|
SelectionChartPoint,
|
|
)
|
|
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,
|
|
ZHIXING_B1_PATTERN_CASES,
|
|
PatternScore,
|
|
PatternScoreBreakdown,
|
|
)
|
|
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
|
|
from zhixing_server.modules.selection.domain.runs import (
|
|
SelectionExecutionSource,
|
|
SelectionRerunRequired,
|
|
SelectionResultQuery,
|
|
SelectionRun,
|
|
SelectionRunInProgress,
|
|
SelectionSectorCount,
|
|
SelectionStock,
|
|
)
|
|
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
|
SelectionMarketDataNotReady,
|
|
)
|
|
from zhixing_server.modules.selection.presentation.http import (
|
|
get_selection_chart_service,
|
|
get_selection_postgres_pool,
|
|
get_selection_service,
|
|
)
|
|
|
|
TARGET = date(2026, 8, 8)
|
|
|
|
|
|
class FakeSelectionService:
|
|
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,
|
|
strategy: str,
|
|
target_trade_date: date,
|
|
*,
|
|
rerun: bool,
|
|
) -> PreparedSelectionRun:
|
|
if self.mode == "in_progress":
|
|
raise SelectionRunInProgress("already running")
|
|
if self.mode == "rerun_required":
|
|
raise SelectionRerunRequired("confirm rerun")
|
|
if self.mode == "market_data_not_ready":
|
|
raise SelectionMarketDataNotReady("market data is not ready")
|
|
run = self.run or _run("run-http", "running")
|
|
return PreparedSelectionRun(
|
|
run=run,
|
|
source=SelectionExecutionSource(
|
|
market_sync_batch_id="market-run-1",
|
|
target_trade_date=target_trade_date,
|
|
target_count=1,
|
|
valid_count=1,
|
|
coverage=Decimal("1"),
|
|
stocks=(SelectionStock("000001.SZ", "平安银行"),),
|
|
),
|
|
)
|
|
|
|
def execute(self, prepared: PreparedSelectionRun) -> None:
|
|
self.executed = True
|
|
|
|
def get_run(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
query: SelectionResultQuery | None = None,
|
|
) -> SelectionRun | None:
|
|
self.last_query = query
|
|
return self.run if self.run and self.run.id == run_id else None
|
|
|
|
def get_latest(
|
|
self,
|
|
strategy: str,
|
|
target_trade_date: date | None = None,
|
|
*,
|
|
query: SelectionResultQuery | None = None,
|
|
) -> SelectionRun | None:
|
|
self.last_query = query
|
|
if self.run is None:
|
|
return None
|
|
if target_trade_date is not None and self.run.target_trade_date != target_trade_date:
|
|
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."""
|
|
|
|
def __init__(self) -> None:
|
|
self.mode = "ok"
|
|
self.request: tuple[str, date, bool] | None = None
|
|
|
|
def execute(
|
|
self,
|
|
ts_code: str,
|
|
target_trade_date: date,
|
|
include_brick_chart: bool = False,
|
|
) -> SelectionChart:
|
|
self.request = (ts_code, target_trade_date, include_brick_chart)
|
|
if self.mode == "missing":
|
|
raise SelectionChartNotFound("chart unavailable")
|
|
if self.mode == "storage_error":
|
|
raise MarketDataReaderError("chart storage unavailable")
|
|
return SelectionChart(
|
|
ts_code=ts_code,
|
|
name="平安银行",
|
|
target_trade_date=target_trade_date,
|
|
source_adj="qfq",
|
|
points=(
|
|
SelectionChartPoint(
|
|
trade_date=target_trade_date,
|
|
open=10.0,
|
|
high=11.0,
|
|
low=9.5,
|
|
close=10.5,
|
|
volume=1000.0,
|
|
k=52.0,
|
|
d=48.0,
|
|
j=60.0,
|
|
trend_white=10.2,
|
|
trend_yellow=10.4,
|
|
brick_chart=5.0 if include_brick_chart else None,
|
|
brick_strong_red=True if include_brick_chart else None,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def _run(run_id: str, status: str) -> SelectionRun:
|
|
original_signal = SelectionSignal(
|
|
ts_code="000001.SZ",
|
|
name="平安银行",
|
|
target_trade_date=TARGET,
|
|
strategy="zhixing_b1",
|
|
category=ZhixingB1Category.ORIGINAL_B1,
|
|
close=10.5,
|
|
details={"j": 12.0},
|
|
)
|
|
pullback_signal = SelectionSignal(
|
|
ts_code="000001.SZ",
|
|
name="平安银行",
|
|
target_trade_date=TARGET,
|
|
strategy="zhixing_b1",
|
|
category=ZhixingB1Category.PULLBACK_WHITE,
|
|
close=10.5,
|
|
details={"j": 13.0, "rsi": 20.0},
|
|
)
|
|
from zhixing_server.modules.selection.domain.runs import SelectionRunItem
|
|
|
|
return SelectionRun(
|
|
id=run_id,
|
|
strategy="zhixing_b1",
|
|
target_trade_date=TARGET,
|
|
market_sync_batch_id="market-run-1",
|
|
status=status, # type: ignore[arg-type]
|
|
target_count=1,
|
|
eligible_count=1,
|
|
evaluated_count=1,
|
|
selected_stock_count=1,
|
|
signal_count=2,
|
|
failed_count=0,
|
|
coverage=Decimal("1"),
|
|
items=(
|
|
SelectionRunItem(
|
|
ts_code="000001.SZ",
|
|
name="平安银行",
|
|
status="selected",
|
|
signal_count=2,
|
|
pattern_score=PatternScore(
|
|
status="matched",
|
|
value=86.4,
|
|
threshold=60.0,
|
|
version=PATTERN_SCORING_VERSION,
|
|
case=ZHIXING_B1_PATTERN_CASES[0],
|
|
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
|
|
),
|
|
signals=(original_signal, pullback_signal),
|
|
),
|
|
),
|
|
signals=(original_signal, pullback_signal),
|
|
stocks_total=1,
|
|
)
|
|
|
|
|
|
def _client(
|
|
service: FakeSelectionService,
|
|
chart_service: FakeChartService | None = None,
|
|
) -> TestClient:
|
|
app = create_app()
|
|
app.dependency_overrides[get_selection_service] = lambda: service
|
|
if chart_service is not None:
|
|
app.dependency_overrides[get_selection_chart_service] = lambda: chart_service
|
|
return TestClient(app)
|
|
|
|
|
|
def test_selection_dependency_reuses_one_bounded_pool(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(selection_http, "_SELECTION_POOL_CACHE", {})
|
|
settings = Settings(database_url="postgresql://test", selection_max_workers=4)
|
|
|
|
first = get_selection_postgres_pool(settings)
|
|
second = get_selection_postgres_pool(settings)
|
|
|
|
assert first is second
|
|
assert first.max_connections == 6
|
|
|
|
|
|
def test_trigger_returns_accepted_run_and_schedules_execution() -> None:
|
|
service = FakeSelectionService()
|
|
|
|
response = _client(service).post(
|
|
"/api/v1/selection/runs",
|
|
json={
|
|
"strategy": "zhixing_b1",
|
|
"target_trade_date": "2026-08-08",
|
|
"rerun": False,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 202
|
|
assert response.json()["status"] == "running"
|
|
assert response.json()["target_trade_date"] == "2026-08-08"
|
|
assert service.executed is True
|
|
|
|
|
|
def test_trigger_requires_explicit_rerun_confirmation() -> None:
|
|
service = FakeSelectionService()
|
|
service.mode = "rerun_required"
|
|
|
|
response = _client(service).post(
|
|
"/api/v1/selection/runs",
|
|
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"]["code"] == "rerun_confirmation_required"
|
|
|
|
|
|
def test_trigger_rejects_a_duplicate_running_request() -> None:
|
|
service = FakeSelectionService()
|
|
service.mode = "in_progress"
|
|
|
|
response = _client(service).post(
|
|
"/api/v1/selection/runs",
|
|
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"]["code"] == "run_in_progress"
|
|
|
|
|
|
def test_trigger_rejects_unqualified_market_data() -> None:
|
|
service = FakeSelectionService()
|
|
service.mode = "market_data_not_ready"
|
|
|
|
response = _client(service).post(
|
|
"/api/v1/selection/runs",
|
|
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"]["code"] == "market_data_not_ready"
|
|
|
|
|
|
def test_query_returns_no_data_without_fabricating_a_result() -> None:
|
|
response = _client(FakeSelectionService()).get(
|
|
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "no_data"
|
|
assert response.json()["stocks"] == []
|
|
|
|
|
|
def test_query_returns_persisted_signal_details() -> None:
|
|
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
|
|
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["run_id"] == "run-http"
|
|
assert body["signal_count"] == 2
|
|
assert body["page"] == 1
|
|
assert body["page_size"] == 10
|
|
assert body["stocks_total"] == 1
|
|
assert "signals_total" not in body
|
|
assert "signals" not in body
|
|
assert len(body["stocks"]) == 1
|
|
assert body["stocks"][0]["ts_code"] == "000001.SZ"
|
|
assert body["stocks"][0]["score"] == {
|
|
"status": "matched",
|
|
"value": 86.4,
|
|
"threshold": 60.0,
|
|
"version": PATTERN_SCORING_VERSION,
|
|
"case": {
|
|
"id": "case_001",
|
|
"name": "华纳药厂",
|
|
"breakout_date": "2025-05-12",
|
|
},
|
|
"breakdown": {
|
|
"trend_structure": 71.2,
|
|
"kdj_state": 83.0,
|
|
"volume_pattern": 88.0,
|
|
"price_shape": 90.1,
|
|
},
|
|
"reason": None,
|
|
}
|
|
assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [
|
|
"zhixing_b1_original_b1",
|
|
"zhixing_b1_pullback_white",
|
|
]
|
|
assert [signal["details"] for signal in body["stocks"][0]["signals"]] == [
|
|
{"j": 12.0},
|
|
{"j": 13.0, "rsi": 20.0},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("pattern_score", "expected_score"),
|
|
[
|
|
(
|
|
PatternScore(
|
|
status="below_threshold",
|
|
value=42.5,
|
|
threshold=60.0,
|
|
version=PATTERN_SCORING_VERSION,
|
|
case=ZHIXING_B1_PATTERN_CASES[0],
|
|
breakdown=PatternScoreBreakdown(40.0, 42.0, 43.0, 44.0),
|
|
),
|
|
{
|
|
"status": "below_threshold",
|
|
"value": 42.5,
|
|
"threshold": 60.0,
|
|
"version": PATTERN_SCORING_VERSION,
|
|
"case": {
|
|
"id": "case_001",
|
|
"name": "华纳药厂",
|
|
"breakout_date": "2025-05-12",
|
|
},
|
|
"breakdown": {
|
|
"trend_structure": 40.0,
|
|
"kdj_state": 42.0,
|
|
"volume_pattern": 43.0,
|
|
"price_shape": 44.0,
|
|
},
|
|
"reason": None,
|
|
},
|
|
),
|
|
(
|
|
PatternScore.failed("FastDTW unavailable"),
|
|
{
|
|
"status": "failed",
|
|
"value": None,
|
|
"threshold": None,
|
|
"version": None,
|
|
"case": None,
|
|
"breakdown": None,
|
|
"reason": "FastDTW unavailable",
|
|
},
|
|
),
|
|
(PatternScore(), None),
|
|
],
|
|
)
|
|
def test_query_preserves_signals_for_every_pattern_score_state(
|
|
pattern_score: PatternScore,
|
|
expected_score: dict[str, object] | None,
|
|
) -> None:
|
|
run = _run("run-http", "success")
|
|
run = replace(run, items=(replace(run.items[0], pattern_score=pattern_score),))
|
|
|
|
response = _client(FakeSelectionService(run)).get("/api/v1/selection/results")
|
|
|
|
assert response.status_code == 200
|
|
stock = response.json()["stocks"][0]
|
|
assert stock["score"] == expected_score
|
|
assert len(stock["signals"]) == 2
|
|
assert response.json()["failures"] == []
|
|
|
|
|
|
def test_query_forwards_pagination_and_filters() -> None:
|
|
service = FakeSelectionService(_run("run-http", "success"))
|
|
|
|
response = _client(service).get(
|
|
"/api/v1/selection/results",
|
|
params={
|
|
"strategy": "zhixing_b1",
|
|
"page": 2,
|
|
"page_size": 5,
|
|
"search": " 平安银行 ",
|
|
"category": "original",
|
|
"sort": "score_desc",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert service.last_query == SelectionResultQuery(
|
|
page=2,
|
|
page_size=5,
|
|
search="平安银行",
|
|
category="original",
|
|
sort="score_desc",
|
|
)
|
|
assert response.json()["page"] == 2
|
|
assert response.json()["page_size"] == 5
|
|
|
|
|
|
def test_query_rejects_invalid_page_size() -> None:
|
|
response = _client(FakeSelectionService()).get(
|
|
"/api/v1/selection/results",
|
|
params={"strategy": "zhixing_b1", "page_size": 101},
|
|
)
|
|
|
|
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()
|
|
|
|
response = _client(FakeSelectionService(), chart_service).get(
|
|
"/api/v1/selection/stocks/000001.SZ/chart",
|
|
params={"target_trade_date": "2026-08-08"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert chart_service.request == ("000001.SZ", TARGET, False)
|
|
assert response.json() == {
|
|
"ts_code": "000001.SZ",
|
|
"name": "平安银行",
|
|
"target_trade_date": "2026-08-08",
|
|
"source_adj": "qfq",
|
|
"points": [
|
|
{
|
|
"trade_date": "2026-08-08",
|
|
"open": 10.0,
|
|
"high": 11.0,
|
|
"low": 9.5,
|
|
"close": 10.5,
|
|
"volume": 1000.0,
|
|
"k": 52.0,
|
|
"d": 48.0,
|
|
"j": 60.0,
|
|
"trend_white": 10.2,
|
|
"trend_yellow": 10.4,
|
|
"brick_chart": None,
|
|
"brick_strong_red": None,
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def test_chart_requests_brick_series_for_gold_brick_strategy() -> None:
|
|
chart_service = FakeChartService()
|
|
|
|
response = _client(FakeSelectionService(), chart_service).get(
|
|
"/api/v1/selection/stocks/000001.SZ/chart",
|
|
params={"target_trade_date": "2026-08-08", "strategy": "gold_brick"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert chart_service.request == ("000001.SZ", TARGET, True)
|
|
assert response.json()["points"][0]["brick_chart"] == 5.0
|
|
assert response.json()["points"][0]["brick_strong_red"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "status_code", "error_code"),
|
|
[
|
|
("missing", 404, "chart_data_not_found"),
|
|
("storage_error", 503, "selection_storage_unavailable"),
|
|
],
|
|
)
|
|
def test_chart_maps_application_errors(
|
|
mode: str,
|
|
status_code: int,
|
|
error_code: str,
|
|
) -> None:
|
|
chart_service = FakeChartService()
|
|
chart_service.mode = mode
|
|
|
|
response = _client(FakeSelectionService(), chart_service).get(
|
|
"/api/v1/selection/stocks/000001.SZ/chart",
|
|
params={"target_trade_date": "2026-08-08"},
|
|
)
|
|
|
|
assert response.status_code == status_code
|
|
assert response.json()["detail"]["code"] == error_code
|
|
|
|
|
|
def test_chart_requires_a_valid_target_date() -> None:
|
|
response = _client(FakeSelectionService(), FakeChartService()).get(
|
|
"/api/v1/selection/stocks/000001.SZ/chart",
|
|
params={"target_trade_date": "not-a-date"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_query_forwards_score_ascending_sort() -> None:
|
|
service = FakeSelectionService(_run("run-http", "success"))
|
|
|
|
response = _client(service).get(
|
|
"/api/v1/selection/results",
|
|
params={"strategy": "zhixing_b1", "sort": "score_asc"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert service.last_query == SelectionResultQuery(sort="score_asc")
|
|
|
|
|
|
def test_run_polling_returns_the_persisted_terminal_result() -> None:
|
|
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
|
|
"/api/v1/selection/runs/run-http"
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "success"
|
|
assert response.json()["stocks"][0]["signals"][0]["category"] == ("zhixing_b1_original_b1")
|