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.backtest import ( attach_historical_estimate, rolling_backtest, ) from backend.features.screener.catalog import ( CatalogError, factor_catalog, strategy_catalog, validate_formula, ) from backend.features.screener.compiler import parse_compiled_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_natural_language_output_is_reduced_to_the_controlled_formula_schema() -> None: compiled = parse_compiled_formula( """```json { "universe": {"exclude_st": true, "listed_days_min": 120}, "filters": [{"field": "amount_billion", "op": ">=", "value": 3}], "score": [ {"field": "return_20d", "weight": 60, "direction": "desc"}, {"field": "sector_strength", "weight": 40, "direction": "desc"} ], "limit": 20, "min_score": 55, "invented_instruction": "直接选择某只股票" } ```""" ) assert [item["weight"] for item in compiled["score"]] == [0.6, 0.4] assert compiled["min_score"] == 0.55 assert "invented_instruction" not in compiled with pytest.raises(CatalogError, match="未知筛选因子"): parse_compiled_formula( json.dumps( { **compiled, "filters": [{"field": "future_price", "op": ">", "value": 1}], } ) ) def test_rolling_backtest_hides_small_samples_and_uses_t_plus_three() -> None: formula = _formula() snapshots = [ { "trade_date": f"2026-07-{index + 1:02d}", "coverage": {"market": 1}, "rows": [_row("000001.SZ", 10 + index)], } for index in range(23) ] small = rolling_backtest(snapshots[:10], formula) assert small["sample_size"] == 7 assert small["stable"] is False assert small["win_rate"] is None assert small["average_return_3d"] is None stable = rolling_backtest(snapshots, formula) assert stable["sample_size"] == 20 assert stable["stable"] is True assert stable["win_rate"] == 100 assert stable["average_return_3d"] == pytest.approx(16.99, abs=0.01) enriched = attach_historical_estimate( {"items": [{"score_display": 80.0}]}, stable ) assert enriched["items"][0]["historical_estimate"] == 93.0 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.save_backtest( connection, int(run["id"]), {"sample_size": 7, "stable": False, "win_rate": None}, ) repository.save_backtest( connection, int(run["id"]), {"sample_size": 99, "stable": True, "win_rate": 100}, ) 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) == () assert repository.backtest(connection, int(run["id"])) == { "sample_size": 7, "stable": False, "win_rate": None, } 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