feat(selection): 补充策略执行结果查询链路

This commit is contained in:
yuxuanhui
2026-08-09 09:34:46 +08:00
parent e9d06df5de
commit 9c1a1eac23
31 changed files with 3566 additions and 14 deletions
@@ -33,6 +33,9 @@ def test_postgres_migration_creates_market_data_contract(
"market_daily_basic",
"market_sync_batch",
"market_sync_item",
"selection_run",
"selection_run_item",
"selection_signal",
} <= tables
finally:
engine.dispose()
+207
View File
@@ -0,0 +1,207 @@
"""HTTP contracts for triggering and querying persisted selection runs."""
from datetime import date
from decimal import Decimal
from fastapi.testclient import TestClient
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunInProgress,
SelectionStock,
)
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
SelectionMarketDataNotReady,
)
from zhixing_server.modules.selection.presentation.http import get_selection_service
TARGET = date(2026, 8, 8)
class FakeSelectionService:
def __init__(self, run: SelectionRun | None = None) -> None:
self.run = run
self.executed = False
self.mode = "ok"
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) -> SelectionRun | None:
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,
) -> SelectionRun | None:
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 _run(run_id: str, status: str) -> SelectionRun:
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},
)
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=1,
failed_count=0,
coverage=Decimal("1"),
items=(
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
),
signals=(signal,),
)
def _client(service: FakeSelectionService) -> TestClient:
app = create_app()
app.dependency_overrides[get_selection_service] = lambda: service
return TestClient(app)
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()["signals"] == []
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"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1"
assert body["signals"][0]["details"] == {"j": 12.0}
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()["signals"][0]["category"] == "zhixing_b1_original_b1"
@@ -1,6 +1,7 @@
"""PostgreSQL reader contract tests using a fake connection."""
from datetime import date
from decimal import Decimal
from typing import cast
import psycopg
@@ -8,6 +9,7 @@ import pytest
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
SelectionMarketDataNotReady,
)
@@ -82,3 +84,77 @@ def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.Monk
assert connection.parameters == ("000001.SZ", date(2024, 1, 3))
assert "source_adj = 'qfq'" in cast(str, connection.query)
assert "trade_date <= %s" in cast(str, connection.query)
class SourceConnection:
def __init__(self, source_row: tuple[object, ...] | None) -> None:
self.source_row = source_row
self.queries: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "SourceConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "SourceResult":
self.queries.append((query, parameters))
if "FROM market_sync_batch" in query:
return SourceResult(row=self.source_row)
return SourceResult(rows=[("000001.SZ", "平安银行")])
class SourceResult:
def __init__(
self,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_reader_loads_only_eligible_stocks_from_finished_market_batch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = SourceConnection(("market-run-1", 2, 2, Decimal("1")))
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
source = PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
assert source.market_sync_batch_id == "market-run-1"
assert source.target_count == 2
assert source.coverage == Decimal("1")
assert source.stocks[0].ts_code == "000001.SZ"
assert "strategy_eligible = true" in connection.queries[0][0]
assert "source_adj = 'qfq'" in connection.queries[1][0]
def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.MonkeyPatch) -> None:
connection = SourceConnection(None)
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
with pytest.raises(SelectionMarketDataNotReady):
PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
@@ -0,0 +1,251 @@
"""Persistence transaction tests for selection runs."""
from datetime import date
from decimal import Decimal
import psycopg
import pytest
from psycopg.types.json import Jsonb
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRunInProgress,
SelectionRunItem,
SelectionStock,
)
from zhixing_server.modules.selection.domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
from zhixing_server.modules.selection.infrastructure.postgres_runs import (
PostgresSelectionRunRepository,
)
TARGET = date(2026, 8, 8)
class FakeResult:
def __init__(self, row: tuple[object, ...] | None = None) -> None:
self.row = row
def fetchone(self) -> tuple[object, ...] | None:
return self.row
class FakeTransaction:
def __enter__(self) -> "FakeTransaction":
return self
def __exit__(self, *args: object) -> None:
return None
class FakeConnection:
def __init__(self, existing: tuple[object, ...] | None) -> None:
self.existing = existing
self.statements: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "FakeConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def transaction(self) -> FakeTransaction:
return FakeTransaction()
def execute(self, query: str, parameters: tuple[object, ...]) -> FakeResult:
self.statements.append((query, parameters))
if "SELECT id, status" in query:
return FakeResult(self.existing)
return FakeResult()
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=1,
valid_count=1,
coverage=Decimal("1"),
stocks=(SelectionStock("000001.SZ", "平安银行"),),
)
def _repository(
monkeypatch: pytest.MonkeyPatch,
connection: FakeConnection,
) -> PostgresSelectionRunRepository:
def connect(database_url: str) -> FakeConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
return PostgresSelectionRunRepository("postgresql://test")
def test_prepare_claims_new_business_key(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=False,
)
assert run.status == "running"
assert run.target_trade_date == TARGET
assert any("INSERT INTO selection_run" in query for query, _ in connection.statements)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_requires_confirmation_for_terminal_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "success"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRerunRequired):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=False)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_rejects_duplicate_running_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "running"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRunInProgress):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=True)
def test_prepare_rerun_deletes_old_result_before_insert(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "failed"))
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=True,
)
statements = [query for query, _ in connection.statements]
assert "DELETE FROM selection_run WHERE strategy = %s AND target_trade_date = %s" in statements
assert run.id != "old-run"
def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
repository = _repository(monkeypatch, connection)
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},
)
repository.record_item(
"run-1",
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
)
signal_insert = next(
parameters
for query, parameters in connection.statements
if "INSERT INTO selection_signal" in query
)
assert isinstance(signal_insert[-1], Jsonb)
class LoadConnection:
def __enter__(self) -> "LoadConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
if "FROM selection_run\n" in query:
return LoadResult(
row=(
"run-1",
"zhixing_b1",
TARGET,
"market-run-1",
"success",
1,
1,
1,
1,
2,
0,
Decimal("1"),
None,
None,
None,
None,
)
)
if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
return LoadResult(
rows=[
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[-1].value,
Decimal("10.5"),
{},
),
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[0].value,
Decimal("10.5"),
{},
),
]
)
class LoadResult:
def __init__(
self,
*,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPatch) -> None:
connection = LoadConnection()
def connect(database_url: str) -> LoadConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1")
assert run is not None
assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0],
ZHIXING_B1_SIGNAL_ORDER[-1],
]
@@ -0,0 +1,228 @@
"""Application tests for persisted whole-universe selection runs."""
from datetime import date
from decimal import Decimal
from typing import Literal
from zhixing_server.modules.selection.application.run import (
PreparedSelectionRun,
RunZhixingB1,
)
from zhixing_server.modules.selection.domain.models import (
SelectionEvaluation,
SelectionSignal,
StockHistory,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRun,
SelectionRunItem,
SelectionRunStatus,
SelectionStock,
)
TARGET = date(2026, 8, 8)
class FakeReader:
def __init__(self, source: SelectionExecutionSource) -> None:
self.source = source
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
return self.source
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
raise AssertionError("the fake evaluator should be used")
class FakeStore:
def __init__(self) -> None:
self.items: list[SelectionRunItem] = []
self.finished: tuple[str, SelectionRunStatus, dict[str, object]] | None = None
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
assert rerun is False
return SelectionRun(
id="run-1",
strategy="zhixing_b1",
target_trade_date=TARGET,
market_sync_batch_id=source.market_sync_batch_id,
status="running",
target_count=source.target_count,
eligible_count=len(source.stocks),
evaluated_count=0,
selected_stock_count=0,
signal_count=0,
failed_count=0,
coverage=source.coverage,
)
def record_item(self, run_id: str, item: SelectionRunItem) -> None:
assert run_id == "run-1"
self.items.append(item)
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None:
kwargs: dict[str, object] = {
"evaluated_count": evaluated_count,
"selected_stock_count": selected_stock_count,
"signal_count": signal_count,
"failed_count": failed_count,
}
if error_type is not None:
kwargs["error_type"] = error_type
if error_message is not None:
kwargs["error_message"] = error_message
self.finished = (run_id, status, kwargs)
def get_run(self, run_id: str):
return None
def get_latest_run(self, strategy: str, target_trade_date: date | None = None):
return None
class FakeEvaluator:
def __init__(self, results: dict[str, SelectionEvaluation]) -> None:
self.results = results
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
return self.results[ts_code]
class RaisingEvaluator:
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
if ts_code == "600000.SH":
raise RuntimeError("temporary evaluator failure")
return SelectionEvaluation(ts_code, target_trade_date, "no_signal")
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=2,
valid_count=2,
coverage=Decimal("1"),
stocks=(
SelectionStock("000001.SZ", "平安银行"),
SelectionStock("600000.SH", "浦发银行"),
),
)
def _signal(ts_code: str, category: str) -> SelectionSignal:
from zhixing_server.modules.selection.domain.models import ZhixingB1Category
return SelectionSignal(
ts_code=ts_code,
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category(category),
close=10.5,
details={"j": 12.0},
)
def test_prepare_captures_market_source_and_execute_persists_all_categories() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(
_signal("000001.SZ", "zhixing_b1_original_b1"),
_signal("000001.SZ", "zhixing_b1_pullback_white"),
),
),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"no_signal",
reason="no category matched",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
prepared = service.prepare("zhixing_b1", TARGET, rerun=False)
assert isinstance(prepared, PreparedSelectionRun)
service.execute(prepared)
assert [item.status for item in store.items] == ["selected", "no_signal"]
assert store.items[0].signal_count == 2
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2] == {
"evaluated_count": 2,
"selected_stock_count": 1,
"signal_count": 2,
"failed_count": 0,
}
def test_execute_marks_partial_success_when_one_stock_lacks_history() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation("000001.SZ", TARGET, "no_signal"),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"insufficient_history",
reason="warm-up data is incomplete",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["failed_count"] == 1
def test_execute_isolates_unexpected_single_stock_failure() -> None:
source = _source()
store = FakeStore()
service = RunZhixingB1(FakeReader(source), store, RaisingEvaluator())
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert [item.status for item in store.items] == ["no_signal", "data_error"]
assert store.items[1].reason == "temporary evaluator failure"
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["evaluated_count"] == 2
assert store.finished[2]["failed_count"] == 1