feat(selection): 补充策略执行结果查询链路
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user