rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.screener.catalog import (
|
||||
CatalogError,
|
||||
factor_catalog,
|
||||
strategy_catalog,
|
||||
validate_formula,
|
||||
)
|
||||
from backend.features.screener.engine import execute_formula
|
||||
from backend.features.screener.repository import ScreenerRepository, decode_track
|
||||
from backend.features.screener.service import automatic_strategies
|
||||
|
||||
|
||||
def _formula(field: str = "close", *, minimum: float = 0) -> dict:
|
||||
return {
|
||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||
"filters": [{"field": field, "op": ">", "value": minimum}],
|
||||
"score": [{"field": field, "weight": 1.0, "direction": "desc"}],
|
||||
"limit": 10,
|
||||
"min_score": 0,
|
||||
}
|
||||
|
||||
|
||||
def _row(identifier: str, close: float | None) -> dict:
|
||||
return {
|
||||
"identifier": identifier,
|
||||
"code": identifier.split(".")[0],
|
||||
"name": identifier,
|
||||
"sector": "测试行业",
|
||||
"listed_days": 500,
|
||||
"is_st": False,
|
||||
"close": close,
|
||||
"pct_chg": 1,
|
||||
"amount_billion": 2,
|
||||
}
|
||||
|
||||
|
||||
def _users(database: Database) -> None:
|
||||
with database.transaction() as connection:
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
id, username, username_key, password_hash, is_admin,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'hash', 0, 'active', '2026-07-30', '2026-07-30')
|
||||
""",
|
||||
((1, "account-a", "account-a"), (2, "account-b", "account-b")),
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(database: Database, repository: ScreenerRepository) -> int:
|
||||
with database.transaction() as connection:
|
||||
return repository.save_factor_snapshot(
|
||||
connection,
|
||||
trade_date="2026-07-30",
|
||||
version="fixture-v1",
|
||||
observed_at="2026-07-30T15:10:00+08:00",
|
||||
state="final",
|
||||
sources=["fixture"],
|
||||
coverage={"market": 1},
|
||||
rows=[_row("000001.SZ", 10)],
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_has_the_exact_reviewed_scope() -> None:
|
||||
factors = factor_catalog()
|
||||
strategies = strategy_catalog()
|
||||
|
||||
assert len(factors["factors"]) == 109
|
||||
assert len(strategies) == 36
|
||||
assert sum(item["kind"] == "stage" for item in strategies) == 7
|
||||
assert sum(item["kind"] == "curated" for item in strategies) == 29
|
||||
assert {item["id"] for item in strategies if item["kind"] == "curated"} == {
|
||||
f"curated-{index:02d}" for index in range(1, 30)
|
||||
}
|
||||
|
||||
|
||||
def test_curated_strategies_run_independently_of_emotion_phase() -> None:
|
||||
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
|
||||
_stage, curated = automatic_strategies(regime)
|
||||
assert len(curated) == 29
|
||||
assert {item["id"] for item in curated} == {
|
||||
f"curated-{index:02d}" for index in range(1, 30)
|
||||
}
|
||||
|
||||
|
||||
def test_formula_is_deterministic_and_best_value_scores_first() -> None:
|
||||
rows = [_row("000002.SZ", 20), _row("000001.SZ", 20), _row("000003.SZ", 10)]
|
||||
|
||||
first = execute_formula(rows, _formula(), {"market": 1})
|
||||
second = execute_formula(list(reversed(rows)), _formula(), {"market": 1})
|
||||
|
||||
assert first == second
|
||||
assert [item["identifier"] for item in first["items"]] == [
|
||||
"000001.SZ",
|
||||
"000002.SZ",
|
||||
"000003.SZ",
|
||||
]
|
||||
assert first["items"][0]["score"] == 1
|
||||
assert first["items"][-1]["score"] == 0
|
||||
|
||||
|
||||
def test_missing_required_factor_and_complete_no_match_are_distinct() -> None:
|
||||
incomplete = execute_formula(
|
||||
[{**_row("000001.SZ", 10), "roic": None}],
|
||||
_formula("roic"),
|
||||
{"financial": 1},
|
||||
)
|
||||
no_signal = execute_formula(
|
||||
[_row("000001.SZ", 10)],
|
||||
_formula(minimum=100),
|
||||
{"market": 1},
|
||||
)
|
||||
|
||||
assert incomplete["status"] == "data_incomplete"
|
||||
assert incomplete["missing_fields"] == ["roic"]
|
||||
assert no_signal["status"] == "no_signal"
|
||||
assert no_signal["missing_fields"] == []
|
||||
|
||||
|
||||
def test_formula_weights_must_total_one_hundred_percent() -> None:
|
||||
formula = _formula()
|
||||
formula["score"][0]["weight"] = 0.9
|
||||
|
||||
with pytest.raises(CatalogError, match="100%"):
|
||||
validate_formula(formula)
|
||||
|
||||
|
||||
def test_formula_rejects_invalid_comparisons_and_duplicate_scores() -> None:
|
||||
malformed = _formula()
|
||||
malformed["filters"][0] = {"field": "close", "op": "between", "value": [20]}
|
||||
with pytest.raises(CatalogError, match="两个边界"):
|
||||
validate_formula(malformed)
|
||||
|
||||
duplicate = _formula()
|
||||
duplicate["score"].append({"field": "close", "weight": 0.5, "direction": "desc"})
|
||||
duplicate["score"][0]["weight"] = 0.5
|
||||
with pytest.raises(CatalogError, match="不能重复"):
|
||||
validate_formula(duplicate)
|
||||
|
||||
|
||||
def test_custom_strategies_and_tracks_are_account_isolated(tmp_path) -> None:
|
||||
database = Database(tmp_path / "screener.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = ScreenerRepository()
|
||||
_users(database)
|
||||
snapshot_id = _snapshot(database, repository)
|
||||
|
||||
with database.transaction() as connection:
|
||||
first = repository.save_custom_strategy(connection, 1, "我的策略", _formula())
|
||||
repository.save_custom_strategy(connection, 2, "我的策略", _formula())
|
||||
run = repository.begin_run(
|
||||
connection,
|
||||
owner_user_id=None,
|
||||
mode="curated",
|
||||
strategy_id="curated-01",
|
||||
strategy_name="测试策略",
|
||||
strategy_version=1,
|
||||
selection_date="2026-07-30",
|
||||
factor_snapshot_id=snapshot_id,
|
||||
)
|
||||
repository.finish_run(
|
||||
connection,
|
||||
int(run["id"]),
|
||||
status="completed",
|
||||
coverage=1,
|
||||
missing_fields=[],
|
||||
result=[{**_row("000001.SZ", 10), "score": 1}],
|
||||
)
|
||||
repository.add_track(
|
||||
connection,
|
||||
user_id=1,
|
||||
run=run,
|
||||
candidate={**_row("000001.SZ", 10), "score": 1},
|
||||
)
|
||||
|
||||
with database.read() as connection:
|
||||
assert [row["id"] for row in repository.custom_strategies(connection, 1)] == [first["id"]]
|
||||
assert len(repository.custom_strategies(connection, 2)) == 1
|
||||
assert len(repository.tracks(connection, 1)) == 1
|
||||
assert repository.tracks(connection, 2) == ()
|
||||
|
||||
|
||||
def test_running_a_strategy_never_creates_tracking_rows(tmp_path) -> None:
|
||||
database = Database(tmp_path / "runs.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = ScreenerRepository()
|
||||
_users(database)
|
||||
snapshot_id = _snapshot(database, repository)
|
||||
|
||||
with database.transaction() as connection:
|
||||
run = repository.begin_run(
|
||||
connection,
|
||||
owner_user_id=None,
|
||||
mode="curated",
|
||||
strategy_id="curated-01",
|
||||
strategy_name="测试策略",
|
||||
strategy_version=1,
|
||||
selection_date="2026-07-30",
|
||||
factor_snapshot_id=snapshot_id,
|
||||
)
|
||||
repository.finish_run(
|
||||
connection,
|
||||
int(run["id"]),
|
||||
status="completed",
|
||||
coverage=1,
|
||||
missing_fields=[],
|
||||
result=[{**_row("000001.SZ", 10), "score": 1}],
|
||||
)
|
||||
|
||||
with database.read() as connection:
|
||||
assert repository.tracks(connection, 1) == ()
|
||||
stored = connection.execute(
|
||||
"SELECT result_json FROM screener_runs WHERE id = ?", (run["id"],)
|
||||
).fetchone()
|
||||
assert len(json.loads(stored["result_json"])) == 1
|
||||
|
||||
|
||||
def test_tracking_statistics_and_milestone_events_are_persistent_and_idempotent(tmp_path) -> None:
|
||||
database = Database(tmp_path / "tracking.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = ScreenerRepository()
|
||||
_users(database)
|
||||
snapshot_id = _snapshot(database, repository)
|
||||
|
||||
with database.transaction() as connection:
|
||||
run = repository.begin_run(
|
||||
connection,
|
||||
owner_user_id=None,
|
||||
mode="curated",
|
||||
strategy_id="curated-01",
|
||||
strategy_name="测试策略",
|
||||
strategy_version=1,
|
||||
selection_date="2026-07-30",
|
||||
factor_snapshot_id=snapshot_id,
|
||||
)
|
||||
repository.finish_run(
|
||||
connection,
|
||||
int(run["id"]),
|
||||
status="completed",
|
||||
coverage=1,
|
||||
missing_fields=[],
|
||||
result=[{**_row("000001.SZ", 10), "score": 1}],
|
||||
)
|
||||
track_id = repository.add_track(
|
||||
connection,
|
||||
user_id=1,
|
||||
run=run,
|
||||
candidate={**_row("000001.SZ", 10), "score": 1},
|
||||
)
|
||||
bars = (
|
||||
("2026-07-31", 10.5, 11.2, 9.5, 11.0),
|
||||
("2026-08-03", 11.0, 11.5, 10.0, 10.5),
|
||||
("2026-08-04", 10.5, 12.5, 10.2, 12.0),
|
||||
("2026-08-05", 12.0, 12.2, 9.0, 11.5),
|
||||
("2026-08-06", 11.5, 14.0, 11.0, 13.0),
|
||||
)
|
||||
for trade_date, open_price, high, low, close in bars:
|
||||
repository.save_track_bar(
|
||||
connection,
|
||||
track_id,
|
||||
trade_date,
|
||||
{"open": open_price, "high": high, "low": low, "close": close},
|
||||
)
|
||||
assert repository.record_track_event(connection, track_id, "t1") is True
|
||||
assert repository.record_track_event(connection, track_id, "t1") is False
|
||||
assert repository.record_track_event(connection, track_id, "t5") is True
|
||||
assert repository.record_track_event(connection, track_id, "t5") is False
|
||||
|
||||
with database.read() as connection:
|
||||
track = repository.tracks(connection, 1)[0]
|
||||
decoded = decode_track(track, repository.track_bars(connection, track_id))
|
||||
events = connection.execute(
|
||||
"SELECT milestone FROM strategy_track_events WHERE track_id = ? ORDER BY milestone",
|
||||
(track_id,),
|
||||
).fetchall()
|
||||
|
||||
assert decoded["t1_open_return"] == 5
|
||||
assert decoded["t1_return"] == 10
|
||||
assert decoded["t3_return"] == 20
|
||||
assert decoded["t5_return"] == 30
|
||||
assert decoded["max_gain"] == 40
|
||||
assert decoded["max_drawdown"] == -10
|
||||
assert decoded["observed_days"] == 5
|
||||
assert [row["milestone"] for row in events] == ["t1", "t5"]
|
||||
|
||||
|
||||
def test_repeated_after_close_run_uses_one_persistent_run(tmp_path) -> None:
|
||||
database = Database(tmp_path / "idempotent.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
repository = ScreenerRepository()
|
||||
_users(database)
|
||||
snapshot_id = _snapshot(database, repository)
|
||||
|
||||
with database.transaction() as connection:
|
||||
first = repository.begin_run(
|
||||
connection,
|
||||
owner_user_id=None,
|
||||
mode="curated",
|
||||
strategy_id="curated-01",
|
||||
strategy_name="测试策略",
|
||||
strategy_version=1,
|
||||
selection_date="2026-07-30",
|
||||
factor_snapshot_id=snapshot_id,
|
||||
)
|
||||
repository.finish_run(
|
||||
connection,
|
||||
int(first["id"]),
|
||||
status="no_signal",
|
||||
coverage=1,
|
||||
missing_fields=[],
|
||||
result=[],
|
||||
)
|
||||
repeated = repository.begin_run(
|
||||
connection,
|
||||
owner_user_id=None,
|
||||
mode="curated",
|
||||
strategy_id="curated-01",
|
||||
strategy_name="测试策略",
|
||||
strategy_version=1,
|
||||
selection_date="2026-07-30",
|
||||
factor_snapshot_id=snapshot_id,
|
||||
)
|
||||
count = connection.execute("SELECT COUNT(*) AS total FROM screener_runs").fetchone()
|
||||
|
||||
assert repeated["id"] == first["id"]
|
||||
assert count["total"] == 1
|
||||
Reference in New Issue
Block a user