import sqlite3 import tempfile import unittest from datetime import datetime, timedelta from pathlib import Path from database import ReviewDatabase from screener import ( ADVANCED_CURATED_STRATEGIES, CURATED_STRATEGIES, FACTOR_FIELDS, FACTOR_GROUPS, ScreenerEngine, _broken_reversal_metrics, _earnings_event_rows, _popularity_factor_rows, _risk_flags, _rsi, _quarter_periods, ) from server import DashboardService, automatic_screener_jobs class CuratedScreenerTests(unittest.TestCase): def test_curated_library_contains_original_and_advanced_strategies(self): self.assertEqual(19, len(ADVANCED_CURATED_STRATEGIES)) self.assertEqual(29, len(CURATED_STRATEGIES)) self.assertEqual(29, len({item["name"] for item in CURATED_STRATEGIES})) self.assertTrue( {"行业动量轮动", "主力资金行业流入"}.issubset( {item["name"] for item in CURATED_STRATEGIES} ) ) self.assertTrue( all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES) ) self.assertTrue( { "景气-趋势-拥挤三维行业打分", "大小盘/成长价值风格切换(元策略)", "业绩超预期漂移(SUE/PEAD)", "多因子综合打分(IC动态加权)", "热度突增潜伏(另类数据)", "机构榜溢价", }.issubset({item["name"] for item in CURATED_STRATEGIES}) ) def test_every_curated_strategy_explains_environment_and_failure_risk(self): for strategy in CURATED_STRATEGIES: meta = strategy["formula"]["meta"] self.assertTrue(meta.get("suitable_environment"), strategy["name"]) self.assertTrue(meta.get("failure_risk"), strategy["name"]) self.assertNotIn("emotion_gate", meta, strategy["name"]) def test_automatic_curated_jobs_are_not_filtered_by_market_regime(self): strategies = [ { "name": "阶段策略", "regimes": ["retreat"], "formula": {"meta": {"library": "stage"}}, }, *CURATED_STRATEGIES, ] for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"): jobs = automatic_screener_jobs(strategies, regime) curated_names = { job["strategy"]["name"] for job in jobs if job["mode"] == "curated" } self.assertEqual( {strategy["name"] for strategy in CURATED_STRATEGIES}, curated_names, regime, ) def test_curated_risk_flags_do_not_reintroduce_regime_gating(self): row = { "pct_chg": 0, "return_10d": 0, "volatility_10d": 0, "amount_billion": 5, } self.assertIn("市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat")) self.assertNotIn( "市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat", include_regime_risk=False), ) def test_every_curated_formula_uses_supported_factors(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") engine = ScreenerEngine(database) for strategy in CURATED_STRATEGIES: formula = engine.validate_formula(strategy["formula"]) fields = { item["field"] for item in formula["filters"] + formula["score"] } self.assertTrue(fields.issubset(FACTOR_FIELDS), strategy["name"]) def test_server_gate_blocks_specialized_strategies_until_sources_are_ready(self): factor_dates = [f"2026{index + 1:04d}" for index in range(260)] health = { "market": True, "auction": True, "benchmark": True, "valuation": True, "fundamental": True, "dividend_history": True, "moneyflow_history": True, "earnings_events": False, "popularity": False, "institutions": False, } expected = { "业绩超预期漂移(SUE/PEAD)": "业绩预告与快报", "热度突增潜伏(另类数据)": "当日人气榜", "机构榜溢价": "龙虎榜机构席位", } by_name = {strategy["name"]: strategy for strategy in CURATED_STRATEGIES} for name, missing_label in expected.items(): self.assertEqual( [missing_label], DashboardService._strategy_missing_data( by_name[name], factor_dates, health ), name, ) ready_health = { **health, "earnings_events": True, "popularity": True, "institutions": True, } for name in expected: self.assertEqual( [], DashboardService._strategy_missing_data( by_name[name], factor_dates, ready_health ), name, ) def test_factor_groups_cover_every_quant_factor(self): grouped = [field for fields in FACTOR_GROUPS.values() for field in fields] self.assertEqual(set(FACTOR_FIELDS), set(grouped)) self.assertEqual(len(grouped), len(set(grouped))) def test_database_migrates_valuation_and_fundamental_columns(self): with tempfile.TemporaryDirectory() as root: path = Path(root) / "review.db" ReviewDatabase(path) connection = sqlite3.connect(path) try: indicator_columns = { row[1] for row in connection.execute("PRAGMA table_info(daily_indicators)") } tables = { row[0] for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) } finally: connection.close() self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns)) self.assertIn("fundamental_indicators", tables) self.assertIn("benchmark_bars", tables) self.assertIn("earnings_events", tables) self.assertIn("popularity_factors", tables) self.assertIn("lhb_institution_daily", tables) def test_advanced_strategies_declare_history_and_backtest_contracts(self): for strategy in ADVANCED_CURATED_STRATEGIES: meta = strategy["formula"]["meta"] self.assertGreaterEqual(meta["history_days"], 80, strategy["name"]) self.assertGreaterEqual(meta["backtest_days"], 1, strategy["name"]) self.assertGreater(meta["take_profit"], 0, strategy["name"]) self.assertLess(meta["stop_loss"], 0, strategy["name"]) def test_quarter_periods_stop_at_selected_date(self): periods = _quarter_periods("20260722", 5) self.assertEqual( ["20250630", "20250930", "20251231", "20260331", "20260630"], periods, ) def test_factor_health_summary_uses_availability_counts(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") with database.connect() as connection: connection.execute( "INSERT INTO daily_bars (trade_date, ts_code) VALUES (?, ?)", ("20260722", "600000.SH"), ) connection.execute( """ INSERT INTO daily_indicators (trade_date, ts_code, pe_ttm) VALUES (?, ?, ?) """, ("20260722", "600000.SH", 8.5), ) connection.executemany( "INSERT INTO daily_indicators (trade_date, ts_code) VALUES (?, ?)", [(f"{year}1231", f"{year % 100:02d}0000.SZ") for year in range(2022, 2026)], ) connection.execute( "INSERT INTO auction_factors (trade_date, ts_code) VALUES (?, ?)", ("20260722", "600000.SH"), ) connection.executemany( """ INSERT INTO fundamental_indicators (end_date, ann_date, ts_code, roe) VALUES (?, ?, ?, ?) """, [ ("20251231", "20260430", f"{index:06d}.SZ", 10.0) for index in range(100) ], ) connection.executemany( "INSERT INTO benchmark_bars (trade_date, ts_code, close) VALUES (?, ?, ?)", [(f"2026{index + 1:04d}", "000300.SH", 4000 + index) for index in range(60)], ) health = database.factor_health_summary("20260722") self.assertTrue(health["market"]) self.assertTrue(health["auction"]) self.assertTrue(health["valuation"]) self.assertTrue(health["fundamental"]) self.assertTrue(health["dividend_history"]) self.assertTrue(health["benchmark"]) self.assertEqual(health["valuation_rows"], 1) self.assertEqual(health["fundamental_rows"], 100) self.assertEqual(health["dividend_years"], 5) def test_moneyflow_health_requires_the_latest_five_market_dates(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") dates = [f"202607{day:02d}" for day in range(20, 25)] database.upsert_daily_bars([ { "trade_date": trade_date, "ts_code": "600000.SH", "open": 10, "high": 10.2, "low": 9.8, "close": 10, "pct_chg": 0, "vol": 1000, "amount": 100000, } for trade_date in dates ]) database.upsert_moneyflow([ {"trade_date": "20260105", "ts_code": "600000.SH", "net_mf_amount": 10} ] * 5) self.assertFalse(database.factor_health_summary(dates[-1])["moneyflow_history"]) database.upsert_moneyflow([ {"trade_date": trade_date, "ts_code": "600000.SH", "net_mf_amount": 10} for trade_date in dates ]) health = database.factor_health_summary(dates[-1]) self.assertTrue(health["moneyflow_history"]) self.assertEqual(health["moneyflow_dates"], 5) def test_technical_helpers_detect_rsi_and_daily_reversal_path(self): self.assertLess(_rsi([10, 9, 8, 7, 6, 5, 4], 6), 1) rows = [ {"close": 10, "high": 10, "vol": 100}, {"close": 11, "high": 11, "vol": 120}, {"close": 12, "high": 12, "vol": 130}, {"close": 11.2, "high": 11.8, "vol": 100}, {"close": 12.5, "high": 12.5, "vol": 140}, ] metrics = _broken_reversal_metrics( rows, [False, True, True, False, True], "600000", "示例" ) self.assertEqual(metrics["signal"], 1) self.assertEqual(metrics["days"], 1) def test_factor_builder_generates_long_window_and_benchmark_factors(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") database.upsert_stock_master([ { "ts_code": "600000.SH", "name": "趋势样本", "industry": "银行", "market": "主板", "list_date": "20000101", } ]) dates = [] cursor = datetime(2025, 6, 1) while len(dates) < 260: if cursor.weekday() < 5: dates.append(cursor.strftime("%Y%m%d")) cursor += timedelta(days=1) bars = [] benchmarks = [] indicators = [] for index, trade_date in enumerate(dates): close = 10 + index * 0.05 bars.append({ "trade_date": trade_date, "ts_code": "600000.SH", "open": close - 0.02, "high": close + 0.08, "low": close - 0.08, "close": close, "pct_chg": 0.25, "vol": 1000 + index, "amount": 200000, }) benchmarks.append({ "trade_date": trade_date, "ts_code": "000300.SH", "close": 4000 + index, "pct_chg": 0.02, }) if index >= 250: indicators.append({ "trade_date": trade_date, "ts_code": "600000.SH", "turnover_rate": 2, "volume_ratio": 1, }) database.upsert_daily_bars(bars) database.upsert_benchmark_bars(benchmarks) database.upsert_daily_indicators(indicators) factors, actual_date = ScreenerEngine(database).build_factors( dates[-1], history_days=260 ) self.assertEqual(actual_date, dates[-1]) self.assertEqual(len(factors), 1) factor = factors[0] self.assertEqual(factor["ma_bull_alignment"], 1) self.assertEqual(factor["rs_high_120"], 1) self.assertGreater(factor["momentum_60_5"], 0) self.assertEqual(factor["momentum_60_5_rank"], 0) def test_factor_builder_generates_sector_momentum_and_five_day_flow(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") stocks = [ ("600001.SH", "动量样本", "电子", 0.16, 180), ("600002.SH", "对照样本", "银行", 0.02, -40), ] database.upsert_stock_master([ { "ts_code": code, "name": name, "industry": industry, "market": "主板", "list_date": "20000101", } for code, name, industry, _, _ in stocks ]) dates = [] cursor = datetime(2026, 4, 1) while len(dates) < 80: if cursor.weekday() < 5: dates.append(cursor.strftime("%Y%m%d")) cursor += timedelta(days=1) bars = [] for index, trade_date in enumerate(dates): for code, _, _, slope, _ in stocks: close = 10 + index * slope bars.append({ "trade_date": trade_date, "ts_code": code, "open": close - 0.03, "high": close + 0.08, "low": close - 0.08, "close": close, "pct_chg": slope, "vol": 1000 + index, "amount": 300000, }) database.upsert_daily_bars(bars) database.upsert_daily_indicators([ { "trade_date": dates[-1], "ts_code": code, "turnover_rate": 2, "volume_ratio": 1, "circ_mv": 1000000, "total_mv": 1500000, } for code, *_ in stocks ]) database.upsert_moneyflow([ { "trade_date": trade_date, "ts_code": code, "net_mf_amount": daily_flow, } for trade_date in dates[-5:] for code, _, _, _, daily_flow in stocks ]) factors, _ = ScreenerEngine(database).build_factors( dates[-1], history_days=80 ) by_code = {item["ts_code"]: item for item in factors} leader = by_code["600001.SH"] laggard = by_code["600002.SH"] self.assertGreater(leader["return_20d"], laggard["return_20d"]) self.assertEqual(leader["sector_momentum_rank"], 1) self.assertEqual(laggard["sector_momentum_rank"], 0) self.assertGreater(leader["net_flow_5d_million"], 0) self.assertLess(laggard["net_flow_5d_million"], 0) self.assertEqual(leader["sector_flow_rank"], 1) def test_stage_three_event_and_composite_factors_are_date_scoped(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") stocks = [ ("600001.SH", "成长样本", "电子", 0.08), ("600002.SH", "价值样本", "银行", 0.02), ] database.upsert_stock_master([ { "ts_code": code, "name": name, "industry": industry, "market": "主板", "list_date": "20000101", } for code, name, industry, _ in stocks ]) dates = [] cursor = datetime(2026, 3, 1) while len(dates) < 80: if cursor.weekday() < 5: dates.append(cursor.strftime("%Y%m%d")) cursor += timedelta(days=1) database.upsert_daily_bars([ { "trade_date": trade_date, "ts_code": code, "open": 10 + index * slope - 0.02, "high": 10 + index * slope + 0.08, "low": 10 + index * slope - 0.08, "close": 10 + index * slope, "pct_chg": slope, "vol": 1000 + index, "amount": 300000, } for index, trade_date in enumerate(dates) for code, _, _, slope in stocks ]) database.upsert_daily_indicators([ { "trade_date": dates[-1], "ts_code": "600001.SH", "turnover_rate": 3, "volume_ratio": 1.4, "total_mv": 900000, "circ_mv": 700000, "pe_ttm": 25, "pb": 3, "ps_ttm": 4, }, { "trade_date": dates[-1], "ts_code": "600002.SH", "turnover_rate": 1, "volume_ratio": 0.9, "total_mv": 5000000, "circ_mv": 4000000, "pe_ttm": 8, "pb": 0.8, "ps_ttm": 1, }, ]) database.upsert_fundamental_indicators([ { "end_date": "20260331", "ann_date": dates[-10], "ts_code": "600001.SH", "roe": 16, "roic": 13, "grossprofit_margin": 35, "netprofit_yoy": 45, "or_yoy": 30, }, { "end_date": "20260331", "ann_date": dates[-10], "ts_code": "600002.SH", "roe": 9, "roic": 7, "grossprofit_margin": 18, "netprofit_yoy": 5, "or_yoy": 3, }, ]) database.upsert_earnings_events([{ "end_date": "20260331", "ann_date": dates[-3], "ts_code": "600001.SH", "forecast_profit": 100, "actual_profit": 125, "surprise_pct": 25, "revenue_yoy": 30, "netprofit_yoy": 45, "source": "forecast+express", }]) database.upsert_popularity_factors([{ "trade_date": dates[-1], "ts_code": "600001.SH", "ths_rank": 5, "dc_rank": 8, "combined_score": 75, "rank_change": 12, "dual_source": True, }]) database.upsert_lhb_institutions([{ "trade_date": dates[-1], "ts_code": "600001.SH", "exalter": "机构专用", "buy": 80_000_000, "sell": 20_000_000, "net_buy": 60_000_000, }]) factors, actual_date = ScreenerEngine(database).build_factors( dates[-1], history_days=80 ) by_code = {item["ts_code"]: item for item in factors} factor = by_code["600001.SH"] self.assertEqual(actual_date, dates[-1]) self.assertEqual(factor["earnings_days_since_announce"], 2) self.assertEqual(factor["earnings_surprise_pct"], 25) self.assertEqual(factor["popularity_score"], 75) self.assertEqual(factor["popularity_dual_source"], 1) self.assertEqual(factor["institution_net_buy_million"], 60) self.assertEqual(factor["institution_seat_count"], 1) self.assertIsNotNone(factor["sector_composite_score"]) self.assertIsNotNone(factor["style_fit_score"]) self.assertIsNotNone(factor["multi_factor_composite"]) health = database.factor_health_summary(dates[-1]) self.assertTrue(health["earnings_events"]) self.assertTrue(health["popularity"]) self.assertTrue(health["institutions"]) def test_stage_three_sources_normalize_units_and_rank_changes(self): earnings = _earnings_event_rows( [{ "ts_code": "600001.SH", "ann_date": "20260401", "end_date": "20260331", "net_profit_min": 10000, "net_profit_max": 12000, }], [{ "ts_code": "600001.SH", "ann_date": "20260420", "end_date": "20260331", "n_income": 132_000_000, "yoy_net_profit": 30, "yoy_sales": 18, }], "20260420", ) self.assertEqual(len(earnings), 1) self.assertEqual(round(earnings[0]["actual_profit"]), 13200) self.assertEqual(round(earnings[0]["surprise_pct"]), 20) popularity = _popularity_factor_rows( "20260420", [{"data_type": "热股", "ts_code": "600001.SH", "rank": 5}], [{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 8}], [{"data_type": "热股", "ts_code": "600001.SH", "rank": 20}], [{"data_type": "A股市场", "ts_code": "600001.SH", "rank": 30}], ) self.assertEqual(len(popularity), 1) self.assertEqual(popularity[0]["rank_change"], 15) self.assertTrue(popularity[0]["dual_source"]) def test_screen_reports_signal_health(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") engine = ScreenerEngine(database) formula = { "universe": {"exclude_st": True, "listed_days_min": 0}, "filters": [{"field": "pct_chg", "op": ">", "value": 0}], "score": [{"field": "amount_billion", "weight": 1, "direction": "desc"}], "limit": 5, "min_score": 0, } result = engine.screen( 0, "20260724", formula, "repair", "健康检查", False, mode="curated", prepared_factors=[{ "ts_code": "600000.SH", "code": "600000", "name": "浦发银行", "sector": "银行", "listed_days": 1000, "pct_chg": 1, "amount_billion": 5, "price": 10, "return_5d": 1, "volume_ratio_5d": 1, "sector_strength": 50, }], prepared_date="20260724", ) health = result["meta"]["health"] self.assertEqual(health["status"], "normal") self.assertEqual(health["signal_count"], 1) self.assertEqual(health["coverage"], 100) if __name__ == "__main__": unittest.main()