68282f5d46
- 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
617 lines
19 KiB
Python
617 lines
19 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
|
|
|
|
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,
|
|
) -> SelectionSectorAggregates | None:
|
|
if self.run is None:
|
|
return None
|
|
return SelectionSectorAggregates(
|
|
run=self.run,
|
|
snapshot_trade_date=self.run.target_trade_date,
|
|
sector_type="industry",
|
|
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": "industry",
|
|
"sectors": [
|
|
{"sector_code": "BK0001.DC", "sector_name": "机器人", "stock_count": 3},
|
|
{"sector_code": "BK0003.DC", "sector_name": "数字经济", "stock_count": 2},
|
|
],
|
|
}
|
|
|
|
|
|
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": "industry",
|
|
"sectors": [],
|
|
}
|
|
|
|
|
|
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")
|