From 4bab921d14bd0dba127a4d0bc6a33e0118fea46d Mon Sep 17 00:00:00 2001 From: leefer Date: Fri, 31 Jul 2026 03:57:07 +0800 Subject: [PATCH] migration: preserve screener and tracking slice --- app/advanced_strategies.py | 487 +--- app/backend/application.py | 425 +--- app/backend/bootstrap/container.py | 2 +- app/backend/features/market/repository.py | 68 + app/backend/features/screener/__init__.py | 4 +- app/backend/features/screener/compiler.py | 146 ++ app/backend/features/screener/engine.py | 2213 ++++++++++++++++ app/backend/features/screener/repository.py | 814 ++++++ app/backend/features/screener/service.py | 435 ++++ app/backend/features/screener/strategies.py | 486 ++++ app/database.py | 878 +------ app/llm_strategy.py | 147 +- app/screener.py | 2214 +---------------- app/tests/test_feature_boundaries.py | 6 + app/tests/test_frontend_contract.py | 11 +- app/tests/test_preservation_slice_market.py | 4 + app/tests/test_preservation_slice_screener.py | 199 ++ app/tools/compare_preservation_apis.py | 109 +- app/tools/run_preservation_runtime.py | 6 +- docs/migration/evidence/slice-06/README.md | 69 + .../migration/evidence/slice-06/api-diff.json | 38 + .../evidence/slice-06/api-requests.json | 59 + .../evidence/slice-06/curated-screener.jpg | Bin 0 -> 88654 bytes .../evidence/slice-06/custom-screener.jpg | Bin 0 -> 90695 bytes .../evidence/slice-06/database-diff.json | 115 + docs/migration/evidence/slice-06/tracking.jpg | Bin 0 -> 69628 bytes docs/migration/保真迁移状态.json | 10 +- docs/migration/保真迁移账本.md | 17 +- 28 files changed, 4810 insertions(+), 4152 deletions(-) create mode 100644 app/backend/features/screener/compiler.py create mode 100644 app/backend/features/screener/engine.py create mode 100644 app/backend/features/screener/repository.py create mode 100644 app/backend/features/screener/service.py create mode 100644 app/backend/features/screener/strategies.py create mode 100644 app/tests/test_preservation_slice_screener.py create mode 100644 docs/migration/evidence/slice-06/README.md create mode 100644 docs/migration/evidence/slice-06/api-diff.json create mode 100644 docs/migration/evidence/slice-06/api-requests.json create mode 100644 docs/migration/evidence/slice-06/curated-screener.jpg create mode 100644 docs/migration/evidence/slice-06/custom-screener.jpg create mode 100644 docs/migration/evidence/slice-06/database-diff.json create mode 100644 docs/migration/evidence/slice-06/tracking.jpg diff --git a/app/advanced_strategies.py b/app/advanced_strategies.py index 8ddfc1d..fcb16c9 100644 --- a/app/advanced_strategies.py +++ b/app/advanced_strategies.py @@ -1,486 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical curated strategy library.""" -from typing import Any +import sys +from backend.features.screener import strategies as _implementation -def _meta( - category: str, - quality: str, - frequency: str, - risk: str, - data_group: str, - history_days: int, - backtest_days: int, - take_profit: float, - stop_loss: float, - **extra: Any, -) -> dict[str, Any]: - return { - "library": "curated", - "category": category, - "quality": quality, - "frequency": frequency, - "risk": risk, - "data_group": data_group, - "history_days": history_days, - "backtest_days": backtest_days, - "take_profit": take_profit, - "stop_loss": stop_loss, - **extra, - } - - -ADVANCED_CURATED_STRATEGIES = [ - { - "name": "中期动量·强者恒强", - "description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。", - "regimes": ["repair", "fermentation", "climax", "divergence"], - "formula": { - "meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "close", "op": "between", "value": [3, 100]}, - {"field": "momentum_60_5_rank", "op": ">=", "value": 0.90}, - {"field": "is_limit_up_today", "op": "==", "value": 0}, - ], - "score": [ - {"field": "momentum_60_5", "weight": 0.55, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, - ], - "limit": 25, - "min_score": 0.50, - }, - }, - { - "name": "强者回调", - "description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "momentum_60_5_rank", "op": ">=", "value": 0.70}, - {"field": "return_5d_rank", "op": "<=", "value": 0.20}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "rsi_6", "op": "<=", "value": 30}, - {"field": "no_limit_down_20d", "op": "==", "value": 1}, - ], - "score": [ - {"field": "momentum_60_5", "weight": 0.42, "direction": "desc"}, - {"field": "return_5d", "weight": 0.33, "direction": "asc"}, - {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, - ], - "limit": 20, - "min_score": 0.48, - }, - }, - { - "name": "超跌反转", - "description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。", - "regimes": ["ice", "repair"], - "formula": { - "meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "return_5d_rank", "op": "<=", "value": 0.05}, - {"field": "turnover_5d", "op": ">=", "value": 30}, - {"field": "return_60d", "op": ">=", "value": -40}, - {"field": "financial_risk", "op": "==", "value": 0}, - {"field": "is_limit_down_today", "op": "==", "value": 0}, - ], - "score": [ - {"field": "return_5d", "weight": 0.45, "direction": "asc"}, - {"field": "turnover_5d", "weight": 0.30, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, - ], - "limit": 10, - "min_score": 0.50, - }, - }, - { - "name": "相对强度新高", - "description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。", - "regimes": ["ice", "repair", "fermentation", "divergence"], - "formula": { - "meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True), - "universe": {"exclude_st": True, "listed_days_min": 250}, - "filters": [ - {"field": "amount_billion", "op": ">=", "value": 1}, - {"field": "rs_high_120", "op": "==", "value": 1}, - {"field": "excess_return_60d", "op": ">=", "value": 10}, - {"field": "ma60_slope", "op": ">", "value": 0}, - ], - "score": [ - {"field": "excess_return_60d", "weight": 0.50, "direction": "desc"}, - {"field": "ma60_slope", "weight": 0.25, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, - ], - "limit": 20, - "min_score": 0.52, - }, - }, - { - "name": "均线多头排列", - "description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。", - "regimes": ["repair", "fermentation", "climax", "divergence"], - "formula": { - "meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7), - "universe": {"exclude_st": True, "listed_days_min": 365}, - "filters": [ - {"field": "ma_bull_alignment", "op": "==", "value": 1}, - {"field": "ma20_slope_5d", "op": ">", "value": 0}, - {"field": "drawdown_from_high_250", "op": "<=", "value": 20}, - ], - "score": [ - {"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"}, - {"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"}, - {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, - ], - "limit": 30, - "min_score": 0.50, - }, - }, - { - "name": "唐奇安通道突破", - "description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "donchian_breakout_pct", "op": ">=", "value": 2}, - {"field": "volume_ratio_5d", "op": ">=", "value": 1.8}, - {"field": "range_20d", "op": "<=", "value": 35}, - ], - "score": [ - {"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"}, - {"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"}, - {"field": "range_20d", "weight": 0.25, "direction": "asc"}, - ], - "limit": 15, - "min_score": 0.52, - }, - }, - { - "name": "周线趋势·日线买点", - "description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7), - "universe": {"exclude_st": True, "listed_days_min": 365}, - "filters": [ - {"field": "weekly_trend_signal", "op": "==", "value": 1}, - {"field": "daily_buy_trigger", "op": "==", "value": 1}, - {"field": "weekly_amount_trend", "op": "==", "value": 1}, - ], - "score": [ - {"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.35, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.30, "direction": "desc"}, - ], - "limit": 20, - "min_score": 0.52, - }, - }, -] - -ADVANCED_CURATED_STRATEGIES.extend( - [ - { - "name": "空间板", - "description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。", - "regimes": ["repair", "fermentation"], - "formula": { - "meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6), - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "is_market_height", "op": "==", "value": 1}, - {"field": "new_space_board", "op": "==", "value": 1}, - {"field": "sector_limit_count", "op": ">=", "value": 3}, - ], - "score": [ - {"field": "limit_streak", "weight": 0.50, "direction": "desc"}, - {"field": "sector_limit_count", "weight": 0.30, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, - ], - "limit": 5, - "min_score": 0.45, - }, - }, - { - "name": "龙头首阴", - "description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。", - "regimes": ["fermentation", "climax"], - "formula": { - "meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6), - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "max_continuous_board_10d", "op": ">=", "value": 3}, - {"field": "dragon_first_yin", "op": "==", "value": 1}, - {"field": "yin_day_pct", "op": ">=", "value": -7}, - {"field": "vol_vs_previous", "op": "<=", "value": 0.8}, - ], - "score": [ - {"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"}, - {"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"}, - {"field": "sector_strength", "weight": 0.25, "direction": "desc"}, - ], - "limit": 5, - "min_score": 0.48, - }, - }, - { - "name": "断板反包", - "description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。", - "regimes": ["repair", "fermentation"], - "formula": { - "meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6), - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "broken_reversal", "op": "==", "value": 1}, - {"field": "days_since_broken", "op": "between", "value": [1, 3]}, - {"field": "close_above_broken_high", "op": "==", "value": 1}, - {"field": "vol_vs_broken_day", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "days_since_broken", "weight": 0.35, "direction": "asc"}, - {"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.30, "direction": "desc"}, - ], - "limit": 5, - "min_score": 0.46, - }, - }, - { - "name": "核按钮反核", - "description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。", - "regimes": ["repair", "fermentation"], - "formula": { - "meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6), - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "recent_limit_up_5d", "op": ">=", "value": 1}, - {"field": "intraday_min_pct", "op": "<=", "value": -7}, - {"field": "pct_chg", "op": ">=", "value": -3}, - {"field": "lower_shadow_ratio", "op": ">=", "value": 2}, - {"field": "vol_vs_previous", "op": "<=", "value": 1.1}, - ], - "score": [ - {"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"}, - {"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"}, - {"field": "sector_strength", "weight": 0.28, "direction": "desc"}, - ], - "limit": 5, - "min_score": 0.48, - }, - }, - ] -) - -ADVANCED_CURATED_STRATEGIES.extend( - [ - { - "name": "景气-趋势-拥挤三维行业打分", - "description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7, - requires_fundamental=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "sector_composite_score", "op": ">=", "value": 0.58}, - {"field": "sector_crowding_rank", "op": "<=", "value": 0.90}, - {"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "sector_composite_score", "weight": 0.55, "direction": "desc"}, - {"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"}, - {"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"}, - ], - "limit": 12, - "min_score": 0.50, - }, - }, - { - "name": "大小盘/成长价值风格切换(元策略)", - "description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7, - requires_fundamental=True, requires_valuation=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 250}, - "filters": [ - {"field": "style_fit_score", "op": ">=", "value": 0.65}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "style_fit_score", "weight": 0.70, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, - ], - "limit": 20, - "min_score": 0.52, - }, - }, - { - "name": "业绩超预期漂移(SUE/PEAD)", - "description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7, - requires_earnings_events=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "earnings_surprise_pct", "op": ">=", "value": 10}, - {"field": "revenue_yoy", "op": ">", "value": 0}, - {"field": "earnings_event_quality", "op": "==", "value": 1}, - {"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]}, - ], - "score": [ - {"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.15, "direction": "desc"}, - ], - "limit": 15, - "min_score": 0.50, - }, - }, - { - "name": "多因子综合打分(IC动态加权)", - "description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7, - requires_fundamental=True, requires_valuation=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 250}, - "filters": [ - {"field": "multi_factor_composite", "op": ">=", "value": 0.65}, - {"field": "financial_risk", "op": "==", "value": 0}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.15, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, - ], - "limit": 30, - "min_score": 0.55, - }, - }, - { - "name": "热度突增潜伏(另类数据)", - "description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7, - requires_popularity=True, backtestable=False, - ), - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "popularity_score", "op": ">=", "value": 15}, - {"field": "return_10d", "op": "<=", "value": 5}, - {"field": "recent_limit_up_5d", "op": "==", "value": 0}, - {"field": "amount_billion", "op": ">=", "value": 0.5}, - ], - "score": [ - {"field": "popularity_score", "weight": 0.50, "direction": "desc"}, - {"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"}, - {"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.15, "direction": "desc"}, - ], - "limit": 10, - "min_score": 0.48, - }, - }, - { - "name": "机构榜溢价", - "description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7, - requires_institutions=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "institution_net_buy_million", "op": ">=", "value": 30}, - {"field": "institution_seat_count", "op": ">=", "value": 1}, - {"field": "return_60d", "op": "<=", "value": 30}, - {"field": "previous_limit_streak", "op": "<=", "value": 2}, - ], - "score": [ - {"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"}, - {"field": "institution_seat_count", "weight": 0.15, "direction": "desc"}, - {"field": "relative_position_60", "weight": 0.20, "direction": "asc"}, - {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, - ], - "limit": 10, - "min_score": 0.48, - }, - }, - ] -) - -ADVANCED_CURATED_STRATEGIES.extend( - [ - { - "name": "行业动量轮动", - "description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "sector_momentum_rank", "op": ">=", "value": 0.90}, - {"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "sector_return_20d", "weight": 0.38, "direction": "desc"}, - {"field": "return_20d", "weight": 0.32, "direction": "desc"}, - {"field": "total_mv_billion", "weight": 0.18, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, - ], - "limit": 12, - "min_score": 0.48, - }, - }, - { - "name": "主力资金行业流入", - "description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。", - "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], - "formula": { - "meta": _meta( - "行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7, - requires_moneyflow_history=True, - ), - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "sector_flow_rank", "op": ">=", "value": 0.85}, - {"field": "sector_net_flow_5d_million", "op": ">", "value": 0}, - {"field": "sector_return_5d", "op": "<=", "value": 8}, - {"field": "flow_to_circ_mv_5d", "op": ">", "value": 0}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"}, - {"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"}, - {"field": "sector_return_5d", "weight": 0.16, "direction": "asc"}, - {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, - ], - "limit": 15, - "min_score": 0.48, - }, - }, - ] -) +sys.modules[__name__] = _implementation diff --git a/app/backend/application.py b/app/backend/application.py index ea5bbdd..6a5ecc1 100644 --- a/app/backend/application.py +++ b/app/backend/application.py @@ -40,15 +40,8 @@ from heaven_engine import ( hexagram_from_lines, ) from backend.data.providers.ifind_client import IfindError -from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection +from llm_strategy import LLMCompilerError, test_llm_connection from mentor_agent import MentorAgentError, stream_with_mentor -from screener import ( - FACTOR_FIELDS, - FACTOR_GROUPS, - REGIMES, - FactorDataService, - compile_local_strategy, -) from backend.features.accounts.http import AccountHttpMixin from backend.features.accounts.security import SecretVault from backend.features.accounts.service import AccountService @@ -57,36 +50,17 @@ from backend.features.dragon_tiger import DragonTigerServiceMixin from backend.features.pools import PoolServiceMixin from backend.features.popularity import PopularityServiceMixin from backend.features.rotation import RotationServiceMixin +from backend.features.screener.service import ( + SCREENER_LIBRARY_VERSION, + ScreenerServiceMixin, + automatic_screener_jobs, +) from backend.features.sentiment import SentimentServiceMixin from backend.features.system import SystemHttpMixin from backend.features.themes import ThemeServiceMixin from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue -SCREENER_LIBRARY_VERSION = 8 - - -def automatic_screener_jobs( - strategies: list[dict[str, Any]], regime_id: str -) -> list[dict[str, Any]]: - """Build the close-of-day jobs; only stage screening is regime-gated.""" - smart_strategy = next( - ( - item for item in strategies - if item.get("formula", {}).get("meta", {}).get("library") != "curated" - and regime_id in (item.get("regimes") or []) - ), - None, - ) - curated = [ - item for item in strategies - if item.get("formula", {}).get("meta", {}).get("library") == "curated" - ] - jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else []) - jobs.extend({"mode": "curated", "strategy": item} for item in curated) - return jobs - - LEGACY_SECRET_KEYS = { "TUSHARE_TOKEN", "IFIND_REFRESH_TOKEN", @@ -149,6 +123,7 @@ class DashboardService( ThemeServiceMixin, PopularityServiceMixin, DragonTigerServiceMixin, + ScreenerServiceMixin, ): def __init__(self) -> None: runtime = load_runtime_settings() @@ -788,141 +763,6 @@ class DashboardService( return match.group(1) return "" - @staticmethod - def _strategy_missing_data( - strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any] - ) -> list[str]: - formula = strategy.get("formula") or {} - meta = formula.get("meta") or {} - used_fields = { - str(item.get("field") or "") - for item in list(formula.get("filters") or []) + list(formula.get("score") or []) - } - valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"} - fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"} - auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"} - missing = [] - required_history = max(21, min(260, int(meta.get("history_days") or 21))) - if len(factor_dates) < required_history: - missing.append(f"历史行情(需{required_history}日)") - if used_fields & valuation_fields and not factor_health["valuation"]: - missing.append("估值数据") - if used_fields & fundamental_fields and not factor_health["fundamental"]: - missing.append("财务质量") - if meta.get("requires_valuation") and not factor_health["valuation"]: - missing.append("估值数据") - if meta.get("requires_fundamental") and not factor_health["fundamental"]: - missing.append("财务质量") - if "dividend_years" in used_fields and not factor_health["dividend_history"]: - missing.append("历年分红") - if used_fields & auction_fields and not factor_health["auction"]: - missing.append("竞价数据") - if meta.get("requires_benchmark") and not factor_health.get("benchmark"): - missing.append("沪深300基准") - if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"): - missing.append("近5日资金流") - if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"): - missing.append("业绩预告与快报") - if meta.get("requires_popularity") and not factor_health.get("popularity"): - missing.append("当日人气榜") - if meta.get("requires_institutions") and not factor_health.get("institutions"): - missing.append("龙虎榜机构席位") - return list(dict.fromkeys(missing)) - - def screener_setup(self, trade_date: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - regime = self.screener.detect_regime(normalized_date) - factor_dates = self.database.factor_dates(normalized_date, 300) - auction_dates = self.database.auction_factor_dates(normalized_date, 100) - factor_health = self.screener.factor_health(normalized_date) - strategies = self.database.list_screener_strategies(self.current_user_id) - for strategy in strategies: - missing = self._strategy_missing_data(strategy, factor_dates, factor_health) - strategy["data_ready"] = not missing - strategy["missing_data"] = missing - automatic_results = self.database.screener_runs_for_date(0, normalized_date) - personal_results = self.database.screener_runs_for_date( - self.current_user_id, normalized_date - ) - recent_results = [ - *[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}], - *[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"], - ] - latest_results: dict[str, dict[str, Any]] = {} - for result in reversed(recent_results): - mode = str(result.get("meta", {}).get("mode") or "smart") - latest_results[mode] = result - automatic_status = self.database.get_data_snapshot( - "screener_auto_v1", normalized_date - ) or {} - return { - "trade_date": normalized_date, - "regime": regime, - "regimes": [{"id": key, "label": value} for key, value in REGIMES.items()], - "strategies": strategies, - "factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()], - "factor_groups": [ - { - "name": name, - "fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields], - } - for name, fields in FACTOR_GROUPS.items() - ], - "operators": [">", ">=", "<", "<=", "==", "between"], - "factor_data": { - "date_count": len(factor_dates), - "start_date": factor_dates[0] if factor_dates else "", - "end_date": factor_dates[-1] if factor_dates else "", - "ready": len(factor_dates) >= 21, - "auction_date_count": len(auction_dates), - "auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False, - "health": factor_health, - }, - "llm": { - "configured": self.llm_configured, - "model": self.llm_primary_model if self.llm_configured else "", - "fallback_configured": self.llm_fallback_configured, - "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", - }, - "latest_results": latest_results, - "recent_results": recent_results, - "automatic_status": automatic_status, - # Kept during the client transition for compatibility with older frontends. - "latest_result": latest_results.get("smart"), - } - - def screener_tracking(self, limit: int = 12) -> dict[str, Any]: - return self.strategy_tracking.list_tracking(self.current_user_id, limit) - - def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]: - try: - run_id = int(payload.get("run_id") or 0) - except (TypeError, ValueError) as exc: - raise ValueError("选股批次无效。") from exc - code = str(payload.get("code") or "").strip() - if run_id <= 0 or not re.fullmatch(r"\d{6}", code): - raise ValueError("选股批次或股票代码无效。") - return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code) - - def remove_screener_tracking(self, track_id: int) -> dict[str, Any]: - return self.strategy_tracking.remove_candidate(self.current_user_id, track_id) - - def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - notice = "" - if self.configured: - try: - FactorDataService(self.database, self._tushare_client()).sync( - normalized_date, 15 - ) - except TushareError: - notice = "最新日线暂未补齐,已按现有数据更新跟踪。" - else: - notice = "公共行情尚未配置,已按现有数据更新跟踪。" - return { - "tracking": self.screener_tracking(), - "notice": notice, - } def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]: tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12) @@ -1126,220 +966,6 @@ class DashboardService( }, } - def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: - if not self.configured: - raise ValueError("请先配置 Tushare Token。") - normalized_date = normalize_date(trade_date) - lookback = max(25, min(260, int(lookback))) - with self.sync_lock: - return FactorDataService(self.database, self._tushare_client()).sync( - normalized_date, lookback - ) - - def _schedule_automatic_screeners( - self, trade_date: str, snapshot: dict[str, Any] | None = None - ) -> bool: - normalized_date = normalize_date(trade_date) - now = datetime.now().astimezone() - if ( - normalized_date != now.strftime("%Y%m%d") - or now.weekday() >= 5 - or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time() - or self.auto_screener_lock.locked() - ): - return False - snapshot = snapshot or self.database.get_snapshot(normalized_date) or {} - actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "") - if actual_date != normalized_date: - return False - marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {} - if ( - marker.get("status") == "complete" - and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION - ): - return False - last_attempt = self._auto_screener_last_attempt.get(normalized_date) - if last_attempt and (now - last_attempt).total_seconds() < 600: - return False - self._auto_screener_last_attempt[normalized_date] = now - return self.jobs.submit( - "screener.automatic", - f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}", - lambda: self.run_automatic_screeners(normalized_date), - {"trade_date": normalized_date, "trigger": "post-close"}, - ) - - def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]: - normalized_date = normalize_date(trade_date) - with self.auto_screener_lock: - started_at = datetime.now().astimezone().isoformat(timespec="seconds") - status: dict[str, Any] = { - "trade_date": normalized_date, - "library_version": SCREENER_LIBRARY_VERSION, - "status": "running", - "started_at": started_at, - "completed": [], - "skipped": [], - "failed": [], - } - self.database.save_data_snapshot( - "screener_auto_v1", normalized_date, "system", status - ) - try: - factor_sync = FactorDataService( - self.database, self._tushare_client() - ).sync(normalized_date, 260) - factor_dates = self.database.factor_dates(normalized_date, 300) - if not factor_dates or factor_dates[-1] != normalized_date: - raise ValueError("当日收盘行情尚未入库") - factor_health = self.screener.factor_health(normalized_date) - regime = self.screener.detect_regime(normalized_date) - regime_id = str(regime.get("id") or "repair") - strategies = self.database.list_screener_strategies(None) - jobs = automatic_screener_jobs(strategies, regime_id) - existing = { - ( - str(item.get("meta", {}).get("mode") or "smart"), - str(item.get("meta", {}).get("strategy_name") or ""), - ) - for item in self.database.screener_runs_for_date(0, normalized_date) - if int(item.get("meta", {}).get("library_version") or 0) - == SCREENER_LIBRARY_VERSION - } - required_history = max( - [ - int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80) - for job in jobs if job.get("strategy") - ] or [80] - ) - factors, actual_date = self.screener.build_factors( - normalized_date, history_days=required_history - ) - if actual_date != normalized_date: - raise ValueError("当日因子尚未完成收盘定格") - for job in jobs: - strategy = job["strategy"] - mode = str(job["mode"]) - name = str(strategy.get("name") or "未命名策略") - if (mode, name) in existing: - status["completed"].append({"mode": mode, "name": name, "cached": True}) - continue - missing = self._strategy_missing_data( - strategy, factor_dates, factor_health - ) - if missing: - status["skipped"].append( - {"mode": mode, "name": name, "reason": "、".join(missing)} - ) - continue - try: - formula = copy.deepcopy(strategy.get("formula") or {}) - formula.setdefault("meta", {})["library_version"] = ( - SCREENER_LIBRARY_VERSION - ) - result = self.screener.screen( - 0, - normalized_date, - formula, - regime_id, - name, - False, - None, - mode, - factors, - actual_date, - ) - status["completed"].append( - { - "mode": mode, - "name": name, - "candidate_count": len(result.get("candidates") or []), - } - ) - except Exception as exc: - status["failed"].append( - {"mode": mode, "name": name, "reason": str(exc)} - ) - status.update( - { - "status": "complete" if not status["failed"] else "partial", - "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "factor_sync": factor_sync, - "regime": regime, - } - ) - except Exception as exc: - status.update( - { - "status": "failed", - "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "error": str(exc), - } - ) - self.database.save_data_snapshot( - "screener_auto_v1", normalized_date, "system", status - ) - return status - - def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]: - prompt = prompt.strip() - if not prompt or len(prompt) > 3000: - raise ValueError("策略描述应为 1 至 3000 个字符。") - if regime not in REGIMES: - raise ValueError("市场阶段不支持。") - notice = "" - source = self.llm_source - if source == "platform": - try: - gateway_result = self.llm_gateway.call( - "screener", - "strategy-compiler-v1", - lambda profile: compile_strategy_with_llm( - prompt, - regime, - profile.api_key, - profile.base_url, - profile.model, - ), - (LLMCompilerError,), - ) - compiled = gateway_result.value - if gateway_result.role == "fallback": - compiled["compiler"] = "llm_fallback" - notice = "智能策略生成服务已自动切换。" - except LLMGatewayError as exc: - if exc.code != "unavailable": - raise - compiled = compile_local_strategy(prompt, regime) - notice = "智能策略生成暂不可用,已使用本地模板。" - else: - compiled = compile_local_strategy(prompt, regime) - notice = "智能策略生成暂不可用,已使用本地模板。" - compiled["formula"] = self.screener.validate_formula(compiled["formula"]) - compiled["notice"] = notice - return compiled - - def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]: - name = validate_text(payload.get("name"), "策略名称", 60, required=True) - description = validate_text(payload.get("description"), "策略说明", 1000) - regimes = payload.get("regimes") or [] - if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes): - raise ValueError("策略适用阶段不正确。") - formula = self.screener.validate_formula(payload.get("formula") or {}) - strategy_id = self.database.save_screener_strategy( - self.current_user_id, name, description, regimes, formula - ) - return { - "id": strategy_id, - "strategies": self.database.list_screener_strategies(self.current_user_id), - } - - def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]: - deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id) - return { - "deleted": deleted, - "strategies": self.database.list_screener_strategies(self.current_user_id), - } def mentor_setup(self, trade_date: str) -> dict[str, Any]: normalized_date = normalize_date(trade_date) @@ -3025,43 +2651,6 @@ class DashboardService( ) return result - def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]: - trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) - regime = str(payload.get("regime") or "") - if regime not in REGIMES: - raise ValueError("市场阶段不支持。") - strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True) - formula = payload.get("formula") or {} - requested_mode = str(payload.get("mode") or "").strip() - if requested_mode and requested_mode not in {"smart", "curated", "quant"}: - raise ValueError("选股模式不受支持。") - if requested_mode: - mode = requested_mode - else: - meta = formula.get("meta") if isinstance(formula, dict) else {} - library = str((meta or {}).get("library") or "") - category = str((meta or {}).get("category") or "") - if library == "curated": - mode = "curated" - elif library == "quant" or (library == "custom" and category == "量化公式"): - mode = "quant" - else: - mode = "smart" - realtime_snapshot = None - dashboard = self.get_dashboard(trade_date) - if self.configured and dashboard.get("meta", {}).get("realtime"): - try: - realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date) - except TushareError as exc: - raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc - result = self.screener.screen( - self.current_user_id, trade_date, formula, regime, strategy_name, - bool(payload.get("run_backtest", True)), - realtime_snapshot, - mode, - ) - return result - SERVICE = DashboardService() diff --git a/app/backend/bootstrap/container.py b/app/backend/bootstrap/container.py index 0929a06..cca19e2 100644 --- a/app/backend/bootstrap/container.py +++ b/app/backend/bootstrap/container.py @@ -8,7 +8,7 @@ from backend.data import DataGateway, build_data_gateway from backend.database.repositories import RepositoryBundle, build_repository_bundle from backend.features.alerts import AlertService from backend.features.review import TradeJournalService -from backend.features.screener import StrategyTrackingService +from backend.features.screener.tracking import StrategyTrackingService from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository from database import ReviewDatabase from mentor_agent import MentorSkillRegistry diff --git a/app/backend/features/market/repository.py b/app/backend/features/market/repository.py index b36e26d..7492e41 100644 --- a/app/backend/features/market/repository.py +++ b/app/backend/features/market/repository.py @@ -6,6 +6,74 @@ from typing import Any class MarketRepositoryMixin: + def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + values = [ + ( + row.get("ts_code", ""), + str(row.get("ts_code", "")).split(".")[0], + row.get("name") or "--", + row.get("industry") or "", + row.get("market") or "", + str(row.get("list_date") or ""), + now, + ) + for row in rows if row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO stock_master + (ts_code, code, name, industry, market, list_date, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ts_code) DO UPDATE SET + code=excluded.code, name=excluded.name, industry=excluded.industry, + market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at + """, + values, + ) + return len(values) + + def list_stock_master(self) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT ts_code, code, name, industry, market, list_date FROM stock_master" + ).fetchall() + return [dict(row) for row in rows] + + def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("trade_date") or ""), row.get("ts_code", ""), + float(row.get("open") or 0), float(row.get("high") or 0), + float(row.get("low") or 0), float(row.get("close") or 0), + float(row.get("pct_chg") or 0), float(row.get("vol") or 0), + float(row.get("amount") or 0), + ) + for row in rows if row.get("trade_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO daily_bars + (trade_date, ts_code, open, high, low, close, pct_chg, vol, amount) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + open=excluded.open, high=excluded.high, low=excluded.low, + close=excluded.close, pct_chg=excluded.pct_chg, + vol=excluded.vol, amount=excluded.amount + """, + values, + ) + return len(values) + + def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + "SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code", + (trade_date,), + ).fetchall() + return [dict(row) for row in rows] def get_snapshot(self, trade_date: str) -> dict[str, Any] | None: with self.connect() as connection: row = connection.execute( diff --git a/app/backend/features/screener/__init__.py b/app/backend/features/screener/__init__.py index 2fe3373..4ca5662 100644 --- a/app/backend/features/screener/__init__.py +++ b/app/backend/features/screener/__init__.py @@ -1,3 +1 @@ -from .tracking import StrategyTrackingService - -__all__ = ["StrategyTrackingService"] +"""Stock screening, custom selection, and strategy tracking feature.""" diff --git a/app/backend/features/screener/compiler.py b/app/backend/features/screener/compiler.py new file mode 100644 index 0000000..0d8716f --- /dev/null +++ b/app/backend/features/screener/compiler.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from typing import Any + +from screener import FACTOR_FIELDS, REGIMES + + +class LLMCompilerError(RuntimeError): + pass + + +def test_llm_connection( + api_key: str, + base_url: str, + model: str, + timeout: int = 30, +) -> dict[str, Any]: + if not api_key or not model: + raise LLMCompilerError("API Key 或模型未配置。") + endpoint = f"{base_url.rstrip('/')}/chat/completions" + payload = json.dumps( + { + "model": model, + "messages": [{"role": "user", "content": "只回复 OK"}], + "stream": False, + }, + ensure_ascii=False, + ).encode("utf-8") + request = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "XiaobaiReviewWeb/0.5", + }, + method="POST", + ) + started = time.perf_counter() + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + result = json.loads(response.read().decode("utf-8")) + reply = str(result["choices"][0]["message"]["content"]).strip() + except urllib.error.HTTPError as exc: + raise LLMCompilerError(_http_error_message(exc)) from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: + raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc + return { + "ok": True, + "model": model, + "reply": reply[:100], + "latency_ms": round((time.perf_counter() - started) * 1000), + } + + +def compile_strategy_with_llm( + prompt: str, + regime: str, + api_key: str, + base_url: str, + model: str, + timeout: int = 45, +) -> dict[str, Any]: + if not api_key or not model: + raise LLMCompilerError("尚未配置 LLM API Key 或模型。") + endpoint = f"{base_url.rstrip('/')}/chat/completions" + schema = { + "name": "策略名称", + "description": "策略说明", + "regimes": [regime], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [{"field": "return_5d", "op": ">=", "value": 0}], + "score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}], + "limit": 15, + "min_score": 0.55, + }, + } + system_prompt = ( + "你是A股量化策略编译器。只输出JSON对象,不输出Markdown。" + "不得生成Python、SQL、网络请求或未提供的因子。" + f"当前市场阶段为{REGIMES.get(regime, regime)}。" + f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。" + "运算符只能使用 >, >=, <, <=, ==, !=, between, in。" + "score权重均大于0且不超过1,direction只能是asc或desc。" + "退潮和冰点策略必须提高门槛并允许结果为空。" + f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}" + ) + payload = json.dumps( + { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt[:3000]}, + ], + "stream": False, + }, + ensure_ascii=False, + ).encode("utf-8") + request = urllib.request.Request( + endpoint, + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "XiaobaiReviewWeb/0.4", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + result = json.loads(response.read().decode("utf-8")) + content = result["choices"][0]["message"]["content"].strip() + if content.startswith("```"): + content = content.strip("`") + if content.startswith("json"): + content = content[4:].strip() + compiled = json.loads(content) + except urllib.error.HTTPError as exc: + raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: + raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc + compiled["compiler"] = "llm" + compiled["model"] = model + return compiled + + +def _http_error_message(exc: urllib.error.HTTPError) -> str: + detail = "" + try: + payload = json.loads(exc.read().decode("utf-8", errors="replace")) + error = payload.get("error") + if isinstance(error, dict): + detail = str(error.get("message") or error.get("code") or "") + elif error: + detail = str(error) + elif payload.get("message"): + detail = str(payload["message"]) + except (json.JSONDecodeError, OSError): + detail = "" + suffix = f":{detail[:300]}" if detail else "" + return f"模型连接测试失败(HTTP {exc.code}){suffix}" diff --git a/app/backend/features/screener/engine.py b/app/backend/features/screener/engine.py new file mode 100644 index 0000000..d0796db --- /dev/null +++ b/app/backend/features/screener/engine.py @@ -0,0 +1,2213 @@ +from __future__ import annotations + +import copy +import json +import math +import statistics +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any + +from advanced_strategies import ADVANCED_CURATED_STRATEGIES +from database import ReviewDatabase +from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history +from tushare_client import TushareClient, TushareError + + +REGIMES = { + "ice": "冰点", + "repair": "修复", + "fermentation": "发酵", + "climax": "高潮", + "divergence": "分化", + "retreat": "退潮", +} + +FACTOR_FIELDS = { + "close": "收盘价", + "pct_chg": "当日涨幅", + "return_5d": "5日涨幅", + "return_10d": "10日涨幅", + "return_20d": "20日涨幅", + "return_60d": "60日涨幅", + "return_5d_rank": "5日涨幅排名", + "momentum_60_5": "中期动量", + "momentum_60_5_rank": "中期动量排名", + "above_ma20": "站上20日线", + "rsi_6": "RSI(6)", + "ma60_slope": "60日线斜率", + "ma20_slope_5d": "20日线5日斜率", + "ma_bull_alignment": "均线多头排列", + "drawdown_from_high_250": "距250日高点回撤", + "donchian_breakout_pct": "唐奇安突破幅度", + "range_20d": "20日振幅", + "rs_high_120": "RS线120日新高", + "excess_return_60d": "60日超额收益", + "weekly_trend_signal": "周线趋势信号", + "daily_buy_trigger": "日线买点", + "weekly_amount_trend": "周成交趋势", + "volume_ratio_5d": "5日量比", + "turnover_5d": "5日累计换手", + "volatility_10d": "10日波动率", + "amount_billion": "成交额", + "turnover_rate": "换手率", + "circ_mv_billion": "流通市值", + "net_flow_million": "主力净流入", + "large_flow_million": "大单净流入", + "net_flow_5d_million": "5日主力净流入", + "flow_to_circ_mv_5d": "5日净流入占流通市值", + "sector_strength": "板块强度", + "sector_return_5d": "行业5日涨幅", + "sector_return_20d": "行业20日涨幅", + "sector_momentum_rank": "行业20日动量排名", + "sector_stock_momentum_rank": "行业内个股动量排名", + "sector_net_flow_5d_million": "行业5日主力净流入", + "sector_flow_rank": "行业资金流排名", + "sector_prosperity_rank": "行业景气度排名", + "sector_trend_rank": "行业趋势排名", + "sector_crowding_rank": "行业拥挤度排名", + "sector_composite_score": "行业三维综合分", + "sector_limit_count": "板块涨停数", + "sector_up_count": "板块强势股数", + "relative_strength": "相对强度", + "limit_streak": "连板高度", + "auction_change": "竞价涨幅", + "auction_amount_million": "竞价成交额", + "auction_turnover_rate": "竞价换手率", + "auction_volume_ratio": "竞价量比", + "total_mv_billion": "总市值", + "pe_ttm": "市盈率TTM", + "pb": "市净率", + "ps_ttm": "市销率TTM", + "dividend_yield_ttm": "股息率TTM", + "dividend_years": "近年持续分红", + "roe": "净资产收益率", + "roa": "总资产收益率", + "roic": "投入资本回报率", + "gross_margin": "销售毛利率", + "netprofit_yoy": "净利润同比", + "revenue_yoy": "营业收入同比", + "ocf_to_opincome": "经营现金流质量", + "earnings_surprise_pct": "业绩超预期幅度", + "earnings_days_since_announce": "业绩公告后天数", + "earnings_event_quality": "业绩事件质量", + "popularity_score": "人气榜热度", + "popularity_rank_change": "人气排名跃升", + "popularity_dual_source": "双榜共识", + "institution_net_buy_million": "机构席位净买入", + "institution_seat_count": "机构席位数", + "style_size_fit": "大小盘风格匹配", + "style_growth_fit": "成长价值风格匹配", + "style_fit_score": "当前风格匹配度", + "factor_value_score": "价值因子分", + "factor_growth_score": "成长因子分", + "factor_quality_score": "质量因子分", + "factor_momentum_score": "动量因子分", + "factor_sentiment_score": "交易情绪因子分", + "multi_factor_composite": "动态多因子综合分", + "relative_position_60": "60日相对位置", + "max_abs_change_15d": "15日最大波动", + "close_to_high_15d": "距15日高点", + "close_to_high_60d": "距60日高点", + "no_limit_30d": "近30日无涨停", + "had_limit_80d": "近80日曾涨停", + "previous_first_limit": "昨日首板", + "previous_limit_signal": "昨日涨停或触板", + "previous_limit_streak": "昨日连板高度", + "previous_amount_billion": "昨日成交额", + "is_limit_up_today": "当日涨停", + "is_limit_down_today": "当日跌停", + "sector_breadth_ma20": "行业20日线宽度", + "no_limit_down_20d": "近20日无跌停", + "financial_risk": "财务风险标记", + "is_market_height": "当前市场最高板", + "new_space_board": "新晋空间板", + "max_continuous_board_10d": "近10日最高连板", + "dragon_first_yin": "龙头首阴", + "yin_day_pct": "首阴跌幅", + "vol_vs_previous": "较前日量能", + "broken_reversal": "断板反包", + "days_since_broken": "断板后天数", + "close_above_broken_high": "收复断板高点", + "vol_vs_broken_day": "较断板日量能", + "recent_limit_up_5d": "近5日涨停次数", + "intraday_min_pct": "盘中最大跌幅", + "lower_shadow_ratio": "下影线实体比", +} + +FACTOR_GROUPS = { + "行情动量": [ + "close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d", + "return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20", + "rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment", + "drawdown_from_high_250", "donchian_breakout_pct", "range_20d", + "rs_high_120", "excess_return_60d", "weekly_trend_signal", + "daily_buy_trigger", "weekly_amount_trend", "relative_strength", + "relative_position_60", "close_to_high_15d", "close_to_high_60d", + ], + "量价交易": [ + "volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate", + "net_flow_million", "large_flow_million", "net_flow_5d_million", + "flow_to_circ_mv_5d", "previous_amount_billion", + "intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day", + ], + "板块结构": [ + "sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank", + "sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank", + "sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank", + "sector_composite_score", + "sector_limit_count", "sector_up_count", "sector_breadth_ma20", + "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal", + "is_limit_up_today", "is_limit_down_today", + "no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d", + "is_market_height", "new_space_board", "max_continuous_board_10d", + "dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken", + "close_above_broken_high", "recent_limit_up_5d", + ], + "竞价因子": [ + "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", + ], + "估值规模": [ + "circ_mv_billion", "total_mv_billion", "pe_ttm", "pb", "ps_ttm", + "dividend_yield_ttm", "dividend_years", + ], + "财务质量": [ + "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", + "ocf_to_opincome", "financial_risk", + "earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality", + ], + "特色数据": [ + "popularity_score", "popularity_rank_change", "popularity_dual_source", + "institution_net_buy_million", "institution_seat_count", + "style_size_fit", "style_growth_fit", "style_fit_score", + "factor_value_score", "factor_growth_score", "factor_quality_score", + "factor_momentum_score", "factor_sentiment_score", "multi_factor_composite", + ], +} + +ALLOWED_OPERATORS = {">", ">=", "<", "<=", "==", "!=", "between", "in"} + + +BUILTIN_STRATEGIES = [ + { + "name": "冰点抗跌先手", + "description": "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。", + "regimes": ["ice"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [-3, 7]}, + {"field": "return_5d", "op": ">=", "value": -5}, + {"field": "amount_billion", "op": ">=", "value": 1}, + {"field": "volatility_10d", "op": "<=", "value": 7}, + ], + "score": [ + {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.25, "direction": "desc"}, + {"field": "volume_ratio_5d", "weight": 0.20, "direction": "desc"}, + {"field": "volatility_10d", "weight": 0.15, "direction": "asc"}, + {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, + ], + "limit": 12, + "min_score": 0.58, + }, + }, + { + "name": "修复先锋", + "description": "筛选率先站回趋势、温和放量并获得板块共振的修复前排。", + "regimes": ["repair"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [1, 9.7]}, + {"field": "return_5d", "op": ">", "value": 0}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "volume_ratio_5d", "op": ">=", "value": 1.05}, + ], + "score": [ + {"field": "sector_strength", "weight": 0.28, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.24, "direction": "desc"}, + {"field": "volume_ratio_5d", "weight": 0.18, "direction": "desc"}, + {"field": "net_flow_million", "weight": 0.16, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.14, "direction": "desc"}, + ], + "limit": 15, + "min_score": 0.54, + }, + }, + { + "name": "主线发酵跟随", + "description": "在主线扩散期寻找趋势、成交承载和板块涨停梯队共同增强的个股。", + "regimes": ["fermentation"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [0, 9.8]}, + {"field": "return_5d", "op": ">=", "value": 3}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "amount_billion", "op": ">=", "value": 2}, + ], + "score": [ + {"field": "sector_limit_count", "weight": 0.25, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, + {"field": "return_10d", "weight": 0.20, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.16, "direction": "desc"}, + {"field": "large_flow_million", "weight": 0.15, "direction": "desc"}, + ], + "limit": 15, + "min_score": 0.55, + }, + }, + { + "name": "高潮核心去后排", + "description": "高潮阶段只保留容量、趋势和辨识度较高的核心,降低后排跟风权重。", + "regimes": ["climax"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [-2, 7]}, + {"field": "return_10d", "op": ">=", "value": 5}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "amount_billion", "op": ">=", "value": 5}, + ], + "score": [ + {"field": "amount_billion", "weight": 0.28, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.22, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, + {"field": "volatility_10d", "weight": 0.15, "direction": "asc"}, + {"field": "limit_streak", "weight": 0.15, "direction": "desc"}, + ], + "limit": 10, + "min_score": 0.62, + }, + }, + { + "name": "分化承接回流", + "description": "寻找分化中仍有趋势承接、板块强度和资金回流的核心候选。", + "regimes": ["divergence"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [-3, 7]}, + {"field": "return_5d", "op": ">", "value": 0}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "volume_ratio_5d", "op": "between", "value": [0.7, 3.5]}, + ], + "score": [ + {"field": "relative_strength", "weight": 0.28, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, + {"field": "net_flow_million", "weight": 0.20, "direction": "desc"}, + {"field": "volatility_10d", "weight": 0.16, "direction": "asc"}, + {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, + ], + "limit": 12, + "min_score": 0.57, + }, + }, + { + "name": "退潮防守观察", + "description": "退潮期采用高门槛防守筛选,结果为空代表当前不宜主动出击。", + "regimes": ["retreat"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "pct_chg", "op": "between", "value": [-2, 4]}, + {"field": "return_5d", "op": ">=", "value": -2}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "volatility_10d", "op": "<=", "value": 4.5}, + {"field": "amount_billion", "op": ">=", "value": 2}, + ], + "score": [ + {"field": "volatility_10d", "weight": 0.30, "direction": "asc"}, + {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.15, "direction": "desc"}, + {"field": "net_flow_million", "weight": 0.10, "direction": "desc"}, + ], + "limit": 8, + "min_score": 0.68, + }, + }, + { + "name": "竞价强势确认", + "description": "用竞价涨幅、成交承载和量比确认修复或发酵阶段的主动进攻标的。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "auction_change", "op": "between", "value": [1, 7]}, + {"field": "auction_amount_million", "op": ">=", "value": 3}, + {"field": "auction_volume_ratio", "op": ">=", "value": 0.8}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "auction_amount_million", "weight": 0.26, "direction": "desc"}, + {"field": "auction_volume_ratio", "weight": 0.22, "direction": "desc"}, + {"field": "auction_change", "weight": 0.18, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.18, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.16, "direction": "desc"}, + ], + "limit": 15, + "min_score": 0.56, + }, + }, +] + +for _strategy in BUILTIN_STRATEGIES: + _strategy["formula"].setdefault("meta", { + "library": "smart", "category": "周期策略", "quality": "系统", + "frequency": "每日", "risk": "随市场阶段", "data_group": "行情因子", + }) + + +CURATED_STRATEGIES = [ + { + "name": "连续分红质量", + "description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。", + "regimes": list(REGIMES), + "formula": { + "meta": {"library": "curated", "category": "红利价值", "quality": "A", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, + "universe": {"exclude_st": True, "listed_days_min": 1095}, + "filters": [ + {"field": "dividend_years", "op": ">=", "value": 4}, + {"field": "dividend_yield_ttm", "op": ">=", "value": 2}, + {"field": "roe", "op": ">=", "value": 6}, + {"field": "pb", "op": "between", "value": [0.1, 4]}, + ], + "score": [ + {"field": "dividend_yield_ttm", "weight": 0.30, "direction": "desc"}, + {"field": "roe", "weight": 0.24, "direction": "desc"}, + {"field": "ocf_to_opincome", "weight": 0.18, "direction": "desc"}, + {"field": "volatility_10d", "weight": 0.16, "direction": "asc"}, + {"field": "total_mv_billion", "weight": 0.12, "direction": "desc"}, + ], "limit": 20, "min_score": 0.52, + }, + }, + { + "name": "ROIC质量低波", + "description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。", + "regimes": ["ice", "repair", "divergence", "retreat"], + "formula": { + "meta": {"library": "curated", "category": "质量价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, + "universe": {"exclude_st": True, "listed_days_min": 730}, + "filters": [ + {"field": "roic", "op": ">=", "value": 6}, + {"field": "gross_margin", "op": ">=", "value": 15}, + {"field": "pe_ttm", "op": "between", "value": [1, 45]}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "roic", "weight": 0.28, "direction": "desc"}, + {"field": "gross_margin", "weight": 0.22, "direction": "desc"}, + {"field": "ps_ttm", "weight": 0.18, "direction": "asc"}, + {"field": "volatility_10d", "weight": 0.18, "direction": "asc"}, + {"field": "total_mv_billion", "weight": 0.14, "direction": "desc"}, + ], "limit": 20, "min_score": 0.54, + }, + }, + { + "name": "低估值现金流白马", + "description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。", + "regimes": ["ice", "repair", "divergence", "retreat"], + "formula": { + "meta": {"library": "curated", "category": "现金流价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, + "universe": {"exclude_st": True, "listed_days_min": 730}, + "filters": [ + {"field": "pb", "op": "between", "value": [0.1, 1.8]}, + {"field": "roa", "op": ">=", "value": 3}, + {"field": "ocf_to_opincome", "op": ">", "value": 0}, + {"field": "netprofit_yoy", "op": ">=", "value": -15}, + {"field": "total_mv_billion", "op": ">=", "value": 100}, + ], + "score": [ + {"field": "roa", "weight": 0.26, "direction": "desc"}, + {"field": "ocf_to_opincome", "weight": 0.24, "direction": "desc"}, + {"field": "pb", "weight": 0.20, "direction": "asc"}, + {"field": "total_mv_billion", "weight": 0.16, "direction": "desc"}, + {"field": "volatility_10d", "weight": 0.14, "direction": "asc"}, + ], "limit": 20, "min_score": 0.53, + }, + }, + { + "name": "高增长合理估值", + "description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "成长质量", "quality": "B+", "frequency": "月度", "risk": "中", "data_group": "估值与财务"}, + "universe": {"exclude_st": True, "listed_days_min": 365}, + "filters": [ + {"field": "pe_ttm", "op": "between", "value": [1, 35]}, + {"field": "revenue_yoy", "op": ">=", "value": 10}, + {"field": "netprofit_yoy", "op": ">=", "value": 15}, + {"field": "roe", "op": ">=", "value": 5}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "netprofit_yoy", "weight": 0.27, "direction": "desc"}, + {"field": "revenue_yoy", "weight": 0.23, "direction": "desc"}, + {"field": "roe", "weight": 0.20, "direction": "desc"}, + {"field": "pe_ttm", "weight": 0.16, "direction": "asc"}, + {"field": "relative_strength", "weight": 0.14, "direction": "desc"}, + ], "limit": 20, "min_score": 0.55, + }, + }, + { + "name": "行业宽度主线", + "description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。", + "regimes": ["repair", "fermentation", "climax", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "行业轮动", "quality": "B+", "frequency": "每周", "risk": "中", "data_group": "行情与行业"}, + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "sector_breadth_ma20", "op": ">=", "value": 55}, + {"field": "sector_strength", "op": ">=", "value": 55}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "amount_billion", "op": ">=", "value": 2}, + ], + "score": [ + {"field": "sector_breadth_ma20", "weight": 0.28, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, + {"field": "sector_limit_count", "weight": 0.16, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, + ], "limit": 20, "min_score": 0.56, + }, + }, + { + "name": "首板低开", + "description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。", + "regimes": ["ice", "repair", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "短线竞价", "quality": "B+", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"}, + "universe": {"exclude_st": True, "listed_days_min": 250}, + "filters": [ + {"field": "previous_first_limit", "op": "==", "value": 1}, + {"field": "auction_change", "op": "between", "value": [-4.5, -2.5]}, + {"field": "relative_position_60", "op": "<=", "value": 0.55}, + {"field": "previous_amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"}, + {"field": "previous_amount_billion", "weight": 0.24, "direction": "desc"}, + {"field": "relative_position_60", "weight": 0.20, "direction": "asc"}, + {"field": "sector_strength", "weight": 0.16, "direction": "desc"}, + {"field": "auction_volume_ratio", "weight": 0.12, "direction": "desc"}, + ], "limit": 12, "min_score": 0.50, + }, + }, + { + "name": "小碎步临界突破", + "description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "形态突破", "quality": "B+", "frequency": "每日", "risk": "中高", "data_group": "历史行情"}, + "universe": {"exclude_st": True, "listed_days_min": 250}, + "filters": [ + {"field": "no_limit_30d", "op": "==", "value": 1}, + {"field": "had_limit_80d", "op": "==", "value": 1}, + {"field": "max_abs_change_15d", "op": "<=", "value": 3}, + {"field": "close_to_high_15d", "op": ">=", "value": 0.98}, + {"field": "close_to_high_60d", "op": ">=", "value": 0.90}, + ], + "score": [ + {"field": "close_to_high_15d", "weight": 0.26, "direction": "desc"}, + {"field": "volume_ratio_5d", "weight": 0.22, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, + {"field": "max_abs_change_15d", "weight": 0.18, "direction": "asc"}, + {"field": "circ_mv_billion", "weight": 0.14, "direction": "asc"}, + ], "limit": 15, "min_score": 0.54, + }, + }, + { + "name": "连板龙头", + "description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。", + "regimes": ["fermentation", "climax", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "连板接力", "quality": "B", "frequency": "每日", "risk": "很高", "data_group": "涨停结构"}, + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "previous_limit_streak", "op": ">=", "value": 2}, + {"field": "previous_amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "previous_limit_streak", "weight": 0.34, "direction": "desc"}, + {"field": "sector_limit_count", "weight": 0.24, "direction": "desc"}, + {"field": "previous_amount_billion", "weight": 0.18, "direction": "desc"}, + {"field": "turnover_rate", "weight": 0.14, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.10, "direction": "desc"}, + ], "limit": 10, "min_score": 0.50, + }, + }, + { + "name": "微盘三正", + "description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。", + "regimes": ["repair", "fermentation"], + "formula": { + "meta": {"library": "curated", "category": "小盘质量", "quality": "B", "frequency": "每周", "risk": "高", "data_group": "估值与财务"}, + "universe": {"exclude_st": True, "listed_days_min": 365}, + "filters": [ + {"field": "pb", "op": ">", "value": 0}, + {"field": "roe", "op": ">", "value": 0}, + {"field": "ocf_to_opincome", "op": ">", "value": 0}, + {"field": "circ_mv_billion", "op": "between", "value": [5, 100]}, + {"field": "amount_billion", "op": ">=", "value": 0.5}, + ], + "score": [ + {"field": "circ_mv_billion", "weight": 0.32, "direction": "asc"}, + {"field": "roe", "weight": 0.24, "direction": "desc"}, + {"field": "ocf_to_opincome", "weight": 0.20, "direction": "desc"}, + {"field": "turnover_rate", "weight": 0.14, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.10, "direction": "desc"}, + ], "limit": 20, "min_score": 0.52, + }, + }, + { + "name": "首板高开弱转强", + "description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": {"library": "curated", "category": "短线竞价", "quality": "B-", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"}, + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "previous_limit_signal", "op": "==", "value": 1}, + {"field": "auction_change", "op": "between", "value": [1, 6]}, + {"field": "auction_volume_ratio", "op": ">=", "value": 0.8}, + {"field": "previous_amount_billion", "op": "between", "value": [3, 25]}, + ], + "score": [ + {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"}, + {"field": "auction_volume_ratio", "weight": 0.24, "direction": "desc"}, + {"field": "auction_change", "weight": 0.18, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.17, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.13, "direction": "desc"}, + ], "limit": 15, "min_score": 0.52, + }, + }, +] + +CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES) + +STRATEGY_ENVIRONMENT_NOTES = { + "连续分红质量": ( + "防守市、低利率环境与中长期配置窗口", + "风险偏好快速上升时,稳健资产的价格弹性通常落后", + ), + "ROIC质量低波": ( + "震荡偏弱、重视盈利质量与回撤控制的市场", + "主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向", + ), + "低估值现金流白马": ( + "估值修复、价值回归及防守配置阶段", + "低估值可能来自基本面持续走弱,需警惕价值陷阱", + ), + "高增长合理估值": ( + "业绩驱动、成长风格占优且趋势获得确认的阶段", + "增长预期下修或估值快速收缩时,回撤可能明显放大", + ), + "行业宽度主线": ( + "主线清晰、行业内部多数个股同步走强的行情", + "板块快速轮动时,宽度信号容易在确认后迅速衰减", + ), + "首板低开": ( + "情绪修复期的分歧转一致与首板次日承接", + "退潮加速或低开缺少量能承接时,弱势可能继续扩大", + ), + "小碎步临界突破": ( + "趋势蓄势、波动收敛后临近突破的结构市", + "无量突破或指数剧烈震荡时,容易形成冲高回落", + ), + "连板龙头": ( + "高度拓展、题材梯队完整且接力情绪活跃的阶段", + "亏钱效应扩散或高位股集中退潮时,接力风险很高", + ), + "微盘三正": ( + "小盘风格活跃、流动性宽松且风险偏好较高的行情", + "风格切向大盘或微盘流动性收缩时,组合波动会显著上升", + ), + "首板高开弱转强": ( + "竞价承接明确、短线情绪修复或主线发酵阶段", + "高开缺乏板块共振时,竞价强势可能转为盘中兑现", + ), + "中期动量·强者恒强": ( + "趋势延续、主升段及强弱分化清晰的行情", + "无趋势震荡或快速轮动中,动量信号容易反复失效", + ), + "强者回调": ( + "主升趋势未破、强势股完成良性回踩的窗口", + "趋势已反转时,回调信号可能演变为下跌中继", + ), + "超跌反转": ( + "急跌后恐慌释放充分、市场进入修复预期的阶段", + "单边下跌初段容易过早介入,超跌不等于止跌", + ), + "相对强度新高": ( + "指数偏弱但结构性主线明确,或机构抱团强化的行情", + "基准快速补涨或强势方向瓦解时,相对优势可能迅速消失", + ), + "均线多头排列": ( + "中期趋势向上、回撤有序的趋势市与主升段", + "高位趋势末端或宽幅震荡中,均线信号通常反应滞后", + ), + "唐奇安通道突破": ( + "整理末端、放量突破并启动新趋势的行情", + "无量突破和宽幅震荡环境中,假突破出现概率较高", + ), + "周线趋势·日线买点": ( + "中期趋势稳定、日线回踩或再启动的多周期共振阶段", + "周线拐点尚未确认时,日线信号可能只是短暂反抽", + ), + "空间板": ( + "市场高度持续拓展、板块梯队完整的强接力环境", + "高度压缩或亏钱效应扩散时,最高板的补跌风险极高", + ), + "龙头首阴": ( + "主线龙头仍有辨识度、首次分歧后存在回流预期的阶段", + "题材退潮或龙头地位被替代后,首阴可能只是下跌起点", + ), + "断板反包": ( + "强势题材分歧后快速修复、核心股重新获得资金承接时", + "板块强度不足或反包缩量时,形态持续性通常较弱", + ), + "核按钮反核": ( + "恐慌释放后出现明确承接、短线情绪转暖的窗口", + "系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高", + ), + "行业动量轮动": ( + "主线相对清晰、行业趋势能够延续两周以上的结构市", + "行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减", + ), + "主力资金行业流入": ( + "板块轮动初期、资金先于价格形成连续净流入的阶段", + "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势", + ), + "景气-趋势-拥挤三维行业打分": ( + "行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市", + "财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢", + ), + "大小盘/成长价值风格切换(元策略)": ( + "大小盘或成长价值风格形成持续相对强弱的阶段", + "风格快速往返切换时,近20日相对表现容易产生滞后信号", + ), + "业绩超预期漂移(SUE/PEAD)": ( + "业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时", + "预告与快报口径可能不同,公告后高开兑现会削弱漂移效应", + ), + "多因子综合打分(IC动态加权)": ( + "因子表现具备一定延续性、市场并非由单一极端主题主导时", + "近期有效因子可能快速失效,动态权重不能消除风格突变风险", + ), + "热度突增潜伏(另类数据)": ( + "人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期", + "榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高", + ), + "机构榜溢价": ( + "机构专用席位在相对低位形成明确净买入、且成交承载正常时", + "高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓", + ), +} + +for strategy in CURATED_STRATEGIES: + suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]] + strategy["formula"]["meta"].update( + { + "suitable_environment": suitable_environment, + "failure_risk": failure_risk, + } + ) + +BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES) + + +def _quarter_periods(trade_date: str, count: int) -> list[str]: + current = datetime.strptime(trade_date, "%Y%m%d") + quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31)) + periods = [] + year = current.year + while len(periods) < count: + for month, day in reversed(quarter_ends): + value = datetime(year, month, day) + if value <= current: + periods.append(value.strftime("%Y%m%d")) + if len(periods) == count: + break + year -= 1 + return sorted(periods) + + +def _earnings_event_rows( + forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str, +) -> list[dict[str, Any]]: + forecast_map: dict[tuple[str, str], dict[str, Any]] = {} + for row in forecasts: + key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) + ann_date = str(row.get("ann_date") or "") + if not all(key) or not ann_date or ann_date > trade_date: + continue + previous = forecast_map.get(key) + if previous is None or ann_date > str(previous.get("ann_date") or ""): + forecast_map[key] = row + result = [] + for row in expresses: + ts_code = str(row.get("ts_code") or "") + end_date = str(row.get("end_date") or "") + ann_date = str(row.get("ann_date") or "") + forecast = forecast_map.get((ts_code, end_date)) + if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date: + continue + lower = _optional_number(forecast.get("net_profit_min")) + upper = _optional_number(forecast.get("net_profit_max")) + forecast_profit = statistics.fmean( + value for value in (lower, upper) if value is not None + ) if lower is not None or upper is not None else None + actual_profit = _optional_number(row.get("n_income")) + if forecast_profit in (None, 0) or actual_profit is None: + continue + # forecast is reported in ten-thousand yuan while express uses yuan. + if abs(actual_profit) > max(abs(forecast_profit), 1) * 100: + actual_profit /= 10000 + surprise_pct = (actual_profit / forecast_profit - 1) * 100 + result.append( + { + "end_date": end_date, + "ann_date": ann_date, + "ts_code": ts_code, + "forecast_profit": forecast_profit, + "actual_profit": actual_profit, + "surprise_pct": surprise_pct, + "revenue_yoy": _optional_number(row.get("yoy_sales")), + "netprofit_yoy": _optional_number(row.get("yoy_net_profit")), + "source": "forecast+express", + } + ) + return result + + +def _popularity_factor_rows( + trade_date: str, + ths_rows: list[dict[str, Any]], + dc_rows: list[dict[str, Any]], + previous_ths: list[dict[str, Any]], + previous_dc: list[dict[str, Any]], +) -> list[dict[str, Any]]: + def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]: + result = {} + for row in rows: + if data_type and str(row.get("data_type") or "") != data_type: + continue + ts_code = str(row.get("ts_code") or "") + rank = int(_number(row.get("rank"))) + if ts_code and rank > 0: + result[ts_code] = rank + return result + + ths = ranks(ths_rows, "热股") + dc = ranks(dc_rows, "A股市场") + previous_ths_map = ranks(previous_ths, "热股") + previous_dc_map = ranks(previous_dc, "A股市场") + result = [] + for ts_code in set(ths) | set(dc): + ths_rank = ths.get(ts_code) + dc_rank = dc.get(ts_code) + current_best = min(value for value in (ths_rank, dc_rank) if value is not None) + previous_candidates = [ + value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code)) + if value is not None + ] + previous_best = min(previous_candidates) if previous_candidates else None + score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25 + result.append( + { + "trade_date": trade_date, + "ts_code": ts_code, + "ths_rank": ths_rank, + "dc_rank": dc_rank, + "combined_score": round(score, 2), + "rank_change": ( + previous_best - current_best + if previous_best is not None + else min(30, max(0, 31 - current_best)) + if previous_ths_map or previous_dc_map else 0 + ), + "dual_source": bool(ths_rank and dc_rank), + } + ) + return result + + +class FactorDataService: + def __init__(self, database: ReviewDatabase, client: TushareClient) -> None: + self.database = database + self.client = client + + def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]: + lookback = max(25, min(260, int(lookback))) + trade_date, _ = self.client.resolve_trade_context(requested_date) + end = datetime.strptime(trade_date, "%Y%m%d") + start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d") + calendar = self.client.query( + "trade_cal", + {"exchange": "SSE", "start_date": start, "end_date": trade_date, "is_open": 1}, + "cal_date,is_open", + ) + dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:] + existing = set(self.database.factor_dates(trade_date, lookback + 10)) + dates_to_fetch = [value for value in dates if value not in existing or value == trade_date] + auction_source_dates = dates[-min(80, len(dates)):] + existing_auction = set(self.database.auction_factor_dates(trade_date, 90)) + auction_dates_to_fetch = [ + value for value in auction_source_dates + if value not in existing_auction or value == trade_date + ] + long_calendar = self.client.query( + "trade_cal", + { + "exchange": "SSE", + "start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"), + "end_date": trade_date, + "is_open": 1, + }, + "cal_date,is_open", + ) + last_open_by_year: dict[str, str] = {} + last_open_by_month: dict[str, str] = {} + for row in long_calendar: + if row.get("is_open") == 1 and row.get("cal_date"): + value = str(row["cal_date"]) + last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value) + last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value) + valuation_dates = set(dates[-min(80, len(dates)):]) + valuation_dates.update(last_open_by_year.values()) + valuation_dates.update(last_open_by_month.values()) + existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500)) + indicator_dates_to_fetch = sorted( + value for value in valuation_dates if value not in existing_indicators or value == trade_date + ) + + master = self.client.query( + "stock_basic", + {"list_status": "L"}, + "ts_code,name,industry,market,list_date", + ) + master_count = self.database.upsert_stock_master(master) + bar_count = 0 + for current_date in dates_to_fetch: + rows = self.client.query( + "daily", + {"trade_date": current_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + bar_count += self.database.upsert_daily_bars(rows) + + indicator_count = 0 + for current_date in indicator_dates_to_fetch: + indicators = self.client.query( + "daily_basic", + {"trade_date": current_date}, + "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv," + "pe_ttm,pb,ps_ttm,dv_ttm", + ) + indicator_count += self.database.upsert_daily_indicators(indicators) + + notices = [] + benchmark_count = 0 + try: + benchmark_rows = self.client.query( + "index_daily", + {"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date}, + "ts_code,trade_date,close,pct_chg", + ) + benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows) + except TushareError as exc: + notices.append(f"沪深300基准暂不可用:{exc}") + fundamental_count = 0 + existing_periods = set(self.database.fundamental_periods()) + for period in _quarter_periods(trade_date, 9): + if period in existing_periods and period < trade_date[:4] + "0101": + continue + try: + rows = self.client.query( + "fina_indicator_vip", + {"period": period}, + "ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin," + "netprofit_yoy,or_yoy,ocf_to_opincome", + ) + except TushareError as exc: + notices.append(f"财务质量接口不可用:{exc}") + break + published = [ + row for row in rows + if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date + ] + published.sort(key=lambda row: str(row.get("ann_date") or "")) + fundamental_count += self.database.upsert_fundamental_indicators(published) + auction_count = 0 + auction_dates = 0 + for current_date in auction_dates_to_fetch: + try: + auction_rows = self.client.query( + "stk_auction", + {"trade_date": current_date}, + "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share", + ) + if auction_rows: + auction_count += self.database.upsert_auction_factors(auction_rows) + auction_dates += 1 + except TushareError as exc: + notices.append(f"竞价因子接口不可用:{exc}") + break + moneyflow_count = 0 + moneyflow_dates = 0 + for current_date in dates[-min(5, len(dates)):]: + try: + moneyflow = self.client.query( + "moneyflow", + {"trade_date": current_date}, + "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," + "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", + ) + moneyflow_count += self.database.upsert_moneyflow(moneyflow) + if moneyflow: + moneyflow_dates += 1 + except TushareError as exc: + notices.append(f"资金流接口不可用:{exc}") + break + + earnings_count = 0 + forecasts: list[dict[str, Any]] = [] + expresses: list[dict[str, Any]] = [] + for period in _quarter_periods(trade_date, 5): + try: + forecast_rows = self.client.query( + "forecast_vip", + {"period": period}, + "ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max", + ) + express_rows = self.client.query( + "express_vip", + {"period": period}, + "ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales", + ) + except TushareError as exc: + notices.append(f"业绩事件接口不可用:{exc}") + break + forecasts.extend(forecast_rows) + expresses.extend(express_rows) + if forecasts and expresses: + earnings_count = self.database.upsert_earnings_events( + _earnings_event_rows(forecasts, expresses, trade_date) + ) + + popularity_count = 0 + previous_trade_date = dates[-2] if len(dates) >= 2 else "" + try: + ths_rows = self.client.query("ths_hot", {"trade_date": trade_date}) + dc_rows = self.client.query("dc_hot", {"trade_date": trade_date}) + previous_ths = ( + self.client.query("ths_hot", {"trade_date": previous_trade_date}) + if previous_trade_date else [] + ) + previous_dc = ( + self.client.query("dc_hot", {"trade_date": previous_trade_date}) + if previous_trade_date else [] + ) + popularity_count = self.database.upsert_popularity_factors( + _popularity_factor_rows( + trade_date, ths_rows, dc_rows, previous_ths, previous_dc + ) + ) + except TushareError as exc: + notices.append(f"人气榜因子不可用:{exc}") + + institution_count = 0 + try: + institution_rows = self.client.query( + "top_inst", + {"trade_date": trade_date}, + "trade_date,ts_code,exalter,buy,sell,net_buy,side,reason", + ) + institution_count = self.database.upsert_lhb_institutions(institution_rows) + except TushareError as exc: + notices.append(f"机构席位明细不可用:{exc}") + + return { + "trade_date": trade_date, + "calendar_dates": len(dates), + "fetched_dates": len(dates_to_fetch), + "stocks": master_count, + "bars": bar_count, + "benchmark_bars": benchmark_count, + "indicators": indicator_count, + "indicator_dates": len(indicator_dates_to_fetch), + "fundamentals": fundamental_count, + "moneyflow": moneyflow_count, + "moneyflow_dates": moneyflow_dates, + "auction_rows": auction_count, + "auction_dates": auction_dates, + "earnings_events": earnings_count, + "popularity_rows": popularity_count, + "institution_rows": institution_count, + "notice": ";".join(notices), + } + + +class ScreenerEngine: + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {} + + def ensure_builtin_strategies(self) -> None: + existing = { + item["name"]: item + for item in self.database.list_screener_strategies() + if item["builtin"] + } + for strategy in BUILTIN_STRATEGIES: + current = existing.get(strategy["name"]) + self.database.save_screener_strategy( + None, **strategy, builtin=True, + strategy_id=int(current["id"]) if current else None, + ) + + def detect_regime(self, trade_date: str) -> dict[str, Any]: + series = latest_contiguous_history( + build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260)) + ) + if not series: + return { + "id": "repair", "label": REGIMES["repair"], "confidence": 25, + "reason": "复盘快照不足,暂按中性修复处理。", "evidence": [], "history": [], + } + current = series[-1] + previous = series[-2] if len(series) > 1 else current + score = _number(current.get("score")) + previous_score = _number(previous.get("score")) + delta = score - previous_score + seal_rate = _number(current.get("seal_rate")) + limit_up = _number(current.get("limit_up_count")) + broken = _number(current.get("broken_count")) + regime = next( + (key for key, label in REGIMES.items() if label == current.get("phase")), + "divergence", + ) + confidence = min(92, 45 + len(series[-8:]) * 5 + min(abs(delta), 12)) + evidence = [ + f"情绪温度 {score:.0f},较前一交易日 {delta:+.0f},{current.get('direction') or '持平'}", + f"封板率 {seal_rate:.1f}%", + f"涨停 {limit_up:.0f} 家,炸板 {broken:.0f} 家", + ] + return { + "id": regime, + "label": REGIMES[regime], + "confidence": round(confidence), + "reason": _regime_reason(regime), + "evidence": evidence, + "history": [ + {"trade_date": item["trade_date"], "score": _number(item.get("score"))} + for item in series[-8:] + ], + } + + def factor_health(self, trade_date: str) -> dict[str, Any]: + return self.database.factor_health_summary(trade_date) + + def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]: + if not isinstance(formula, dict): + raise ValueError("选股公式必须是 JSON 对象。") + result = copy.deepcopy(formula) + universe = result.setdefault("universe", {}) + universe["exclude_st"] = bool(universe.get("exclude_st", True)) + universe["listed_days_min"] = max(0, min(5000, int(universe.get("listed_days_min", 120)))) + filters = result.setdefault("filters", []) + if not isinstance(filters, list) or len(filters) > 20: + raise ValueError("筛选条件必须是列表,且不能超过 20 条。") + for condition in filters: + field = condition.get("field") + operator = condition.get("op") + if field not in FACTOR_FIELDS: + raise ValueError(f"不支持的选股因子:{field}") + if operator not in ALLOWED_OPERATORS: + raise ValueError(f"不支持的运算符:{operator}") + if "value" not in condition: + raise ValueError(f"因子 {field} 缺少比较值。") + scores = result.setdefault("score", []) + if not isinstance(scores, list) or not scores or len(scores) > 12: + raise ValueError("评分因子应为 1 至 12 条。") + for item in scores: + if item.get("field") not in FACTOR_FIELDS: + raise ValueError(f"不支持的评分因子:{item.get('field')}") + item["weight"] = float(item.get("weight", 0)) + if item["weight"] <= 0 or item["weight"] > 1: + raise ValueError("评分权重必须大于 0 且不超过 1。") + if item.get("direction", "desc") not in {"asc", "desc"}: + raise ValueError("评分方向只能是 asc 或 desc。") + item["direction"] = item.get("direction", "desc") + result["limit"] = max(1, min(50, int(result.get("limit", 15)))) + result["min_score"] = max(0, min(1, float(result.get("min_score", 0)))) + return result + + def screen( + self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str, + strategy_name: str, run_backtest: bool = True, + realtime_snapshot: dict[str, Any] | None = None, + mode: str = "smart", + prepared_factors: list[dict[str, Any]] | None = None, + prepared_date: str = "", + ) -> dict[str, Any]: + mode = mode if mode in {"smart", "curated", "quant"} else "smart" + formula = self.validate_formula(formula) + if prepared_factors is None: + history_days = int((formula.get("meta") or {}).get("history_days") or 80) + factors, actual_date = self.build_factors( + trade_date, realtime_snapshot, history_days + ) + else: + factors = prepared_factors + actual_date = prepared_date or trade_date + candidates = self.apply_formula(factors, formula, regime) + backtest = self.backtest(actual_date, formula) if run_backtest else None + required_fields = sorted({ + str(item.get("field") or "") + for item in list(formula.get("filters") or []) + list(formula.get("score") or []) + if item.get("field") + }) + complete_rows = sum( + 1 for row in factors + if all(row.get(field) is not None for field in required_fields) + ) + coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0 + health_status = "normal" if candidates else "no_signal" + if backtest and backtest["samples"] >= 20: + for candidate in candidates: + estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35 + candidate["historical_probability"] = round(min(95, max(5, estimate)), 1) + candidate["probability_samples"] = backtest["samples"] + else: + for candidate in candidates: + candidate["historical_probability"] = None + candidate["probability_samples"] = backtest["samples"] if backtest else 0 + result = { + "meta": { + "trade_date": _display_date(actual_date), + "regime": regime, + "regime_label": REGIMES.get(regime, regime), + "strategy_name": strategy_name, + "mode": mode, + "library_version": int( + (formula.get("meta") or {}).get("library_version") or 0 + ), + "universe_count": len(factors), + "candidate_count": len(candidates), + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "health": { + "status": health_status, + "required_field_count": len(required_fields), + "complete_rows": complete_rows, + "universe_rows": len(factors), + "coverage": coverage, + "signal_count": len(candidates), + }, + "selection_source": ( + "tushare_rt_k+history" if realtime_snapshot else "historical_eod" + ), + "realtime": bool(realtime_snapshot), + "history_cutoff": ( + str(realtime_snapshot.get("previous_trade_date") or "") + if realtime_snapshot else actual_date + ), + "factor_freshness": { + "realtime": [ + "价格", "涨跌幅", "成交量", "成交额", "换手率", + "均线位置", "5/10日动量", "板块强度", "开盘竞价", + ] if realtime_snapshot else [], + "historical": ["历史波动率", "流通市值", "资金流", "竞价因子", "回测"], + }, + }, + "formula": formula, + "candidates": candidates, + "backtest": backtest, + "disclaimer": ( + "候选仅由策略条件与当日数据计算;历史统计不代表未来收益。" + if mode == "curated" + else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。" + ), + } + run_id = self.database.save_screener_run( + user_id, actual_date, regime, strategy_name, formula, result, mode + ) + result["meta"]["run_id"] = run_id + return result + + def build_factors( + self, + trade_date: str, + realtime_snapshot: dict[str, Any] | None = None, + history_days: int = 80, + ) -> tuple[list[dict[str, Any]], str]: + history_days = max(21, min(260, int(history_days))) + data = self.database.load_factor_data(trade_date, history_days) + dates = [value for value in data["dates"] if value <= trade_date] + if len(dates) < 21: + raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。") + history_date = dates[-1] + realtime_map = { + str(row.get("ts_code") or ""): row + for row in (realtime_snapshot or {}).get("rows") or [] + } + realtime_date = str((realtime_snapshot or {}).get("trade_date") or "") + use_realtime = bool(realtime_map and realtime_date == trade_date and history_date < trade_date) + actual_date = trade_date if use_realtime else history_date + master = {row["ts_code"]: row for row in data["master"]} + indicators = {row["ts_code"]: row for row in data["indicators"]} + fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])} + indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data.get("indicator_history", []): + indicator_history[str(row.get("ts_code") or "")].append(row) + indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data.get("indicator_series", []): + indicator_series[str(row.get("ts_code") or "")].append(row) + benchmark_by_date = { + str(row.get("trade_date") or ""): _number(row.get("close")) + for row in data.get("benchmarks", []) + if _number(row.get("close")) > 0 + } + moneyflow = {row["ts_code"]: row for row in data["moneyflow"]} + moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data.get("moneyflow_history", []): + moneyflow_history[str(row.get("ts_code") or "")].append(row) + auction = { + row["ts_code"]: row + for row in data.get("auction", []) + if str(row.get("trade_date") or "") == actual_date + } + earnings_events: dict[str, dict[str, Any]] = {} + for row in data.get("earnings_events", []): + ts_code = str(row.get("ts_code") or "") + ann_date = str(row.get("ann_date") or "") + if ann_date <= actual_date and ( + ts_code not in earnings_events + or ann_date > str(earnings_events[ts_code].get("ann_date") or "") + ): + earnings_events[ts_code] = row + popularity = { + str(row.get("ts_code") or ""): row + for row in data.get("popularity", []) + } + institutions = { + str(row.get("ts_code") or ""): row + for row in data.get("institutions", []) + } + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data["bars"]: + if row["trade_date"] <= history_date: + grouped[row["ts_code"]].append(row) + + snapshot = self.database.get_snapshot(actual_date) or {} + limit_map: dict[str, tuple[str, int]] = {} + for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")): + for row in snapshot.get(key) or []: + limit_map[str(row.get("code"))] = (status, int(row.get("streak") or 0)) + + factors = [] + current_day = datetime.strptime(actual_date, "%Y%m%d") + for ts_code, bars in grouped.items(): + bars.sort(key=lambda item: item["trade_date"]) + if len(bars) < 21 or bars[-1]["trade_date"] != history_date: + continue + info = master.get(ts_code) + if not info: + continue + historical_closes = [_number(item["close"]) for item in bars] + historical_volumes = [_number(item["vol"]) for item in bars] + realtime = realtime_map.get(ts_code) if use_realtime else None + current = realtime or bars[-1] + closes = historical_closes + ([_number(realtime["close"])] if realtime else []) + volumes = historical_volumes + ([_number(realtime["vol"])] if realtime else []) + if closes[-1] <= 0: + continue + returns_10 = [_number(item["pct_chg"]) for item in bars[-10:]] + if realtime: + returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))] + previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0 + indicator = indicators.get(ts_code, {}) + fundamental = fundamentals.get(ts_code, {}) + flow = moneyflow.get(ts_code, {}) + flow_history = moneyflow_history.get(ts_code, []) + auction_row = auction.get(ts_code, {}) + list_date = str(info.get("list_date") or "") + try: + listed_days = (current_day - datetime.strptime(list_date, "%Y%m%d")).days + except ValueError: + listed_days = 9999 + code = str(info.get("code") or ts_code.split(".")[0]) + status, streak = limit_map.get(code, ("", 0)) + name = str(info.get("name") or "--") + shape_rows = bars + ([realtime] if realtime else []) + shape_close = [_number(item.get("close")) for item in shape_rows] + shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows] + shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows] + shape_changes = [_number(item.get("pct_chg")) for item in shape_rows] + position_rows = shape_rows[-60:] + position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0) + position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0) + relative_position = ( + (closes[-1] - position_low) / (position_high - position_low) + if position_high > position_low else 0.5 + ) + previous_index = len(bars) - 1 if realtime else len(bars) - 2 + previous_bar = bars[previous_index] if previous_index >= 0 else {} + previous_limit = _is_limit_bar(bars, previous_index, code, name) + previous_touched = _touched_limit_bar(bars, previous_index, code, name) + recent_prior_signal = any( + _is_limit_bar(bars, index, code, name) + or _touched_limit_bar(bars, index, code, name) + for index in range(max(0, previous_index - 2), previous_index) + ) + previous_streak = 0 + streak_index = previous_index + while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name): + previous_streak += 1 + streak_index -= 1 + limit_flags = [ + _is_limit_bar(shape_rows, index, code, name) + for index in range(len(shape_rows)) + ] + annual_dividend_rows = indicator_history.get(ts_code, []) + dividend_years = sum( + 1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0) + ) + current_streak = _ending_streak(limit_flags) + prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2) + streak = max(streak, current_streak) + return_60d = ( + (closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 + ) + momentum_60_5 = ( + (closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 + ) + ma20 = statistics.fmean(closes[-20:]) + ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20 + prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20 + prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60 + ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0 + ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0 + ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)] + high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high) + drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100 + prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0 + breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0 + prior_lows_20 = shape_low[-21:-1] + range_20d = ( + (prior_high_20 / min(prior_lows_20) - 1) * 100 + if prior_lows_20 and min(prior_lows_20) > 0 else 100 + ) + turnover_rows = sorted( + indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "") + ) + turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]] + if realtime and _number(realtime.get("turnover_rate")): + turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))] + turnover_5d = sum(turnover_values) + rs_values = [ + _number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))] + for item in shape_rows[-120:] + if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0 + ] + benchmark_60 = [ + benchmark_by_date.get(str(item.get("trade_date"))) + for item in shape_rows[-61:] + if benchmark_by_date.get(str(item.get("trade_date"))) + ] + benchmark_return_60 = ( + (benchmark_60[-1] / benchmark_60[0] - 1) * 100 + if len(benchmark_60) >= 61 and benchmark_60[0] else 0 + ) + weekly_closes, weekly_amounts = _weekly_series(shape_rows) + weekly_dif, weekly_dea = _macd_last(weekly_closes) + daily_dif, daily_dea = _macd_series(closes) + daily_cross = ( + len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] + and daily_dif[-2] <= daily_dea[-2] + ) + current_open = _number(current.get("open")) + daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open + previous_close = closes[-2] if len(closes) >= 2 else closes[-1] + intraday_min = ( + (_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0 + ) + body = abs(closes[-1] - current_open) + lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low"))) + lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0) + previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0 + vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0 + broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name) + netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy")) + earnings_event = earnings_events.get(ts_code, {}) + announcement_date = str(earnings_event.get("ann_date") or "") + earnings_days = ( + sum(1 for value in dates if announcement_date < value <= actual_date) + if announcement_date and announcement_date <= actual_date + else None + ) + announcement_bar = next( + (item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date), + None, + ) + announcement_bad = False + if announcement_bar is not None: + bar_index = shape_rows.index(announcement_bar) + prior_volumes = [ + _number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index] + if _number(item.get("vol")) > 0 + ] + volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0 + announcement_bad = ( + _number(announcement_bar.get("close")) < _number(announcement_bar.get("open")) + and _number(announcement_bar.get("pct_chg")) < 0 + and volume_baseline > 0 + and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8 + ) + popularity_row = popularity.get(ts_code) + institution_row = institutions.get(ts_code) + factors.append( + { + "code": code, + "ts_code": ts_code, + "name": name, + "sector": info.get("industry") or "其他", + "market": info.get("market") or "--", + "listed_days": listed_days, + "close": round(closes[-1], 2), + "price": round(closes[-1], 2), + "pct_chg": round(_number(current["pct_chg"]), 2), + "return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2), + "return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2), + "return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2), + "return_60d": round(return_60d, 2), + "momentum_60_5": round(momentum_60_5, 2), + "above_ma20": int(closes[-1] > ma20), + "rsi_6": round(_rsi(closes, 6), 2), + "ma60_slope": round(ma60_slope, 3), + "ma20_slope_5d": round(ma20_slope, 3), + "ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]), + "drawdown_from_high_250": round(drawdown_250, 2), + "donchian_breakout_pct": round(breakout_pct, 2), + "range_20d": round(range_20d, 2), + "rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)), + "excess_return_60d": round(return_60d - benchmark_return_60, 2), + "weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0), + "daily_buy_trigger": int(daily_cross or daily_pullback), + "weekly_amount_trend": int( + len(weekly_amounts) >= 5 + and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1]) + ), + "volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0, + "turnover_5d": round(turnover_5d, 2), + "volatility_10d": round(statistics.pstdev(returns_10), 2), + "amount_billion": round( + _number(current["amount"]) / (100000000 if realtime else 100000), 2 + ), + "turnover_rate": round( + _number(realtime.get("turnover_rate")) + if realtime else _number(indicator.get("turnover_rate")), + 2, + ), + "circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2), + "total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2), + "pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2), + "pb": _rounded_optional(indicator.get("pb"), 2), + "ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2), + "dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2), + "dividend_years": dividend_years, + "roe": _rounded_optional(fundamental.get("roe"), 2), + "roa": _rounded_optional(fundamental.get("roa"), 2), + "roic": _rounded_optional(fundamental.get("roic"), 2), + "gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2), + "netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2), + "revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2), + "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2), + "earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2), + "earnings_days_since_announce": earnings_days, + "earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None, + "popularity_score": _rounded_optional( + popularity_row.get("combined_score") if popularity_row else None, 2 + ), + "popularity_rank_change": ( + int(popularity_row["rank_change"]) + if popularity_row and popularity_row.get("rank_change") is not None else None + ), + "popularity_dual_source": ( + int(bool(popularity_row.get("dual_source"))) if popularity_row else None + ), + "institution_net_buy_million": ( + round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2) + if institution_row else None + ), + "institution_seat_count": ( + int(institution_row.get("seat_count") or 0) if institution_row else None + ), + "net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2), + "large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2), + "net_flow_5d_million": round( + sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100, + 2, + ), + "flow_to_circ_mv_5d": round( + sum(_number(item.get("net_mf_amount")) for item in flow_history) + / _number(indicator.get("circ_mv")) * 100, + 4, + ) if _number(indicator.get("circ_mv")) else 0, + "limit_status": status, + "limit_streak": streak, + "is_limit_up_today": int(limit_flags[-1]), + "is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)), + "auction_change": round(_number(auction_row.get("change")), 2), + "auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2), + "auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4), + "auction_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2), + "relative_position_60": round(relative_position, 4), + "max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2), + "close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) else 0, + "close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0, + "no_limit_30d": int(not any(limit_flags[-30:])), + "had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])), + "no_limit_down_20d": int(not any( + _number(item.get("pct_chg")) <= -_limit_threshold(code, name) + for item in shape_rows[-20:] + )), + "financial_risk": int( + "ST" in name.upper() or "退" in name + or (netprofit_yoy is not None and netprofit_yoy <= -100) + ), + "prior_limit_streak": prior_streak, + "max_continuous_board_10d": _max_streak(limit_flags[-10:]), + "dragon_first_yin": int( + prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open + ), + "yin_day_pct": round(_number(current.get("pct_chg")), 2), + "vol_vs_previous": round(vol_vs_previous, 3), + "broken_reversal": broken["signal"], + "days_since_broken": broken["days"], + "close_above_broken_high": broken["recovered"], + "vol_vs_broken_day": broken["volume_ratio"], + "recent_limit_up_5d": sum(limit_flags[-5:]), + "intraday_min_pct": round(intraday_min, 2), + "lower_shadow_ratio": round(lower_shadow_ratio, 2), + "previous_first_limit": int(previous_limit and not recent_prior_signal), + "previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal), + "previous_limit_streak": previous_streak, + "previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2), + } + ) + + market_return = statistics.fmean(row["return_5d"] for row in factors) if factors else 0 + sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in factors: + sectors[row["sector"]].append(row) + sector_metrics = [] + market_amount = sum(max(0.0, row["amount_billion"]) for row in factors) + for sector_name, sector_rows in sectors.items(): + average_return = statistics.fmean(row["return_5d"] for row in sector_rows) + average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows) + sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows) + limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows) + up_count = sum(row["pct_chg"] >= 5 for row in sector_rows) + breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100 + sector_growth = [ + statistics.fmean(values) + for row in sector_rows + if (values := [ + value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy")) + if value is not None + ]) + ] + prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0 + average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows) + amount_share = ( + sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100 + if market_amount else 0.0 + ) + crowding_raw = average_turnover + amount_share + trend_raw = average_return_20d + breadth_ma20 / 10 + strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6)) + sector_metrics.append( + { + "ts_code": sector_name, + "sector_return_20d": average_return_20d, + "sector_net_flow_5d_million": sector_net_flow, + "sector_prosperity_raw": prosperity_raw, + "sector_trend_raw": trend_raw, + "sector_crowding_raw": crowding_raw, + } + ) + stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc") + for row in sector_rows: + row["sector_strength"] = round(strength, 1) + row["sector_return_5d"] = round(average_return, 2) + row["sector_return_20d"] = round(average_return_20d, 2) + row["sector_net_flow_5d_million"] = round(sector_net_flow, 2) + row["sector_stock_momentum_rank"] = round( + stock_momentum_ranks.get(row["ts_code"], 0.0), 4 + ) + row["sector_limit_count"] = limit_count + row["sector_up_count"] = up_count + row["sector_breadth_ma20"] = round(breadth_ma20, 1) + row["relative_strength"] = round(row["return_5d"] - market_return, 2) + sector_momentum_ranks = _percentile_map( + sector_metrics, "sector_return_20d", "desc" + ) + sector_flow_ranks = _percentile_map( + sector_metrics, "sector_net_flow_5d_million", "desc" + ) + sector_prosperity_ranks = _percentile_map( + sector_metrics, "sector_prosperity_raw", "desc" + ) + sector_trend_ranks = _percentile_map( + sector_metrics, "sector_trend_raw", "desc" + ) + sector_crowding_ranks = _percentile_map( + sector_metrics, "sector_crowding_raw", "desc" + ) + for sector_name, sector_rows in sectors.items(): + prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0) + trend_rank = sector_trend_ranks.get(sector_name, 0.0) + crowding_rank = sector_crowding_ranks.get(sector_name, 0.0) + composite_score = ( + prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30 + ) + for row in sector_rows: + row["sector_momentum_rank"] = round( + sector_momentum_ranks.get(sector_name, 0.0), 4 + ) + row["sector_flow_rank"] = round( + sector_flow_ranks.get(sector_name, 0.0), 4 + ) + row["sector_prosperity_rank"] = round(prosperity_rank, 4) + row["sector_trend_rank"] = round(trend_rank, 4) + row["sector_crowding_rank"] = round(crowding_rank, 4) + row["sector_composite_score"] = round(composite_score, 4) + + factor_specs = { + "factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")), + "factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")), + "factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")), + "factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")), + "factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")), + } + for output_field, specs in factor_specs.items(): + maps = [_available_percentile_map(factors, field, direction) for field, direction in specs] + for row in factors: + values = [mapping.get(row["ts_code"]) for mapping in maps] + available = [value for value in values if value is not None] + row[output_field] = round(statistics.fmean(available), 4) if available else None + + return_rank_map = _available_percentile_map(factors, "return_20d", "desc") + factor_weights = {} + for output_field in factor_specs: + pairs = [ + (row.get(output_field), return_rank_map.get(row["ts_code"])) + for row in factors + if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None + ] + correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs]) + factor_weights[output_field] = max(0.05, correlation) + factor_weight_total = sum(factor_weights.values()) or 1 + for row in factors: + weighted = [ + (row.get(field), weight) + for field, weight in factor_weights.items() + if row.get(field) is not None + ] + row["multi_factor_composite"] = round( + sum(value * weight for value, weight in weighted) + / (sum(weight for _, weight in weighted) or factor_weight_total), + 4, + ) if weighted else None + + size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc") + large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70] + small_rows = [ + row for row in factors + if size_ranks.get(row["ts_code"]) is not None + and size_ranks[row["ts_code"]] <= 0.30 + ] + large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0 + small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0 + prefer_large = large_return >= small_return + growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70] + value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70] + growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0 + value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0 + prefer_growth = growth_return >= value_return + for row in factors: + size_rank = size_ranks.get(row["ts_code"]) + row["style_size_fit"] = round( + size_rank if prefer_large else 1 - size_rank, 4 + ) if size_rank is not None else None + style_factor = "factor_growth_score" if prefer_growth else "factor_value_score" + row["style_growth_fit"] = row.get(style_factor) + style_values = [ + value for value in (row.get("style_size_fit"), row.get("style_growth_fit")) + if value is not None + ] + row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None + momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc") + return_ranks = _percentile_map(factors, "return_5d", "desc") + market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0) + prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0) + for row in factors: + row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4) + row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4) + is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height + row["is_market_height"] = int(is_height) + row["new_space_board"] = int( + is_height + and not ( + prior_market_height >= 2 + and int(row.get("prior_limit_streak") or 0) == prior_market_height + ) + ) + return factors, actual_date + + def apply_formula( + self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str + ) -> list[dict[str, Any]]: + universe = formula["universe"] + eligible = [] + score_fields = [item["field"] for item in formula["score"]] + for row in rows: + name = str(row.get("name") or "") + if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name): + continue + if row.get("listed_days", 0) < universe.get("listed_days_min", 0): + continue + if any(row.get(field) is None for field in score_fields): + continue + if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]): + eligible.append(row) + if not eligible: + return [] + + percentiles = { + item["field"]: _percentile_map(eligible, item["field"], item["direction"]) + for item in formula["score"] + } + weight_total = sum(item["weight"] for item in formula["score"]) + results = [] + for row in eligible: + contributions = [] + score = 0.0 + for item in formula["score"]: + percentile = percentiles[item["field"]].get(row["ts_code"], 0.5) + points = percentile * item["weight"] / weight_total + score += points + contributions.append( + { + "field": item["field"], + "label": FACTOR_FIELDS[item["field"]], + "value": row.get(item["field"], 0), + "points": round(points * 100, 1), + } + ) + if score < formula["min_score"]: + continue + contributions.sort(key=lambda item: item["points"], reverse=True) + item = dict(row) + item["score"] = round(score, 4) + item["score_display"] = round(score * 100, 1) + item["contributions"] = contributions + item["reason"] = "、".join(entry["label"] for entry in contributions[:3]) + include_regime_risk = formula.get("meta", {}).get("library") != "curated" + item["risk_flags"] = _risk_flags(row, regime, include_regime_risk) + results.append(item) + results.sort(key=lambda item: item["score"], reverse=True) + return results[: formula["limit"]] + + def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]: + meta = formula.get("meta") or {} + history_days = max(21, min(260, int(meta.get("history_days") or 80))) + holding_days = max(1, min(30, int(meta.get("backtest_days") or 3))) + take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3))) + stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3))) + dates = self.database.factor_dates(trade_date, history_days + holding_days + 20) + eligible_dates = dates[:-holding_days] if len(dates) > holding_days else [] + frequency = str(meta.get("frequency") or "每日") + if "月" in frequency: + grouped = {} + for value in eligible_dates: + grouped[value[:6]] = value + evaluation_dates = list(grouped.values())[-8:] + elif "双周" in frequency: + weekly_dates = [] + grouped = {} + for value in eligible_dates: + parsed = datetime.strptime(value, "%Y%m%d") + grouped[parsed.strftime("%G-%V")] = value + weekly_dates = list(grouped.values()) + evaluation_dates = weekly_dates[-16::2][-8:] + elif "周" in frequency: + grouped = {} + for value in eligible_dates: + parsed = datetime.strptime(value, "%Y%m%d") + grouped[parsed.strftime("%G-%V")] = value + evaluation_dates = list(grouped.values())[-8:] + else: + evaluation_dates = eligible_dates[-8:] + wins = 0 + losses = 0 + samples = 0 + returns = [] + drawdowns = [] + all_data = self.database.load_factor_data( + trade_date, history_days + holding_days + 20 + ) + bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in all_data["bars"]: + bars_by_code[row["ts_code"]].append(row) + for bars in bars_by_code.values(): + bars.sort(key=lambda item: item["trade_date"]) + + for current_date in evaluation_dates: + try: + cache_key = (current_date, history_days) + factors = self._backtest_factor_cache.get(cache_key) + if factors is None: + factors, _ = self.build_factors( + current_date, history_days=history_days + ) + if len(self._backtest_factor_cache) >= 64: + self._backtest_factor_cache.pop( + next(iter(self._backtest_factor_cache)) + ) + self._backtest_factor_cache[cache_key] = factors + except ValueError: + continue + selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest") + for candidate in selected: + bars = bars_by_code.get(candidate["ts_code"], []) + index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1) + future = bars[index + 1:index + 1 + holding_days] if index >= 0 else [] + if len(future) < holding_days: + continue + entry = candidate["price"] + won = False + lost = False + for day in future: + low_return = (_number(day["low"]) / entry - 1) * 100 + high_return = (_number(day["high"]) / entry - 1) * 100 + if low_return <= stop_loss: + lost = True + break + if high_return >= take_profit: + won = True + break + if won: + wins += 1 + elif lost: + losses += 1 + samples += 1 + returns.append((_number(future[-1]["close"]) / entry - 1) * 100) + drawdowns.append(min((_number(day["low"]) / entry - 1) * 100 for day in future)) + return { + "samples": samples, + "wins": wins, + "losses": losses, + "win_rate": round(wins / samples * 100, 1) if samples else 0, + "average_3d_return": round(statistics.fmean(returns), 2) if returns else 0, + "average_holding_return": round(statistics.fmean(returns), 2) if returns else 0, + "average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0, + "evaluation_days": len(evaluation_dates), + "frequency": frequency, + "holding_days": holding_days, + "take_profit": take_profit, + "stop_loss": stop_loss, + "definition": ( + f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及" + f"{stop_loss:g}%计为成功;同日双触发按失败处理。" + ), + "approximate": True, + } + + +def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]: + base = next((item for item in BUILTIN_STRATEGIES if regime in item["regimes"]), BUILTIN_STRATEGIES[1]) + formula = copy.deepcopy(base["formula"]) + description = prompt.strip() or base["description"] + lowered = description.lower() + if "低吸" in description: + formula["filters"] = [item for item in formula["filters"] if item["field"] != "pct_chg"] + formula["filters"].append({"field": "pct_chg", "op": "between", "value": [-3, 3]}) + if "放量" in description: + formula["filters"].append({"field": "volume_ratio_5d", "op": ">=", "value": 1.2}) + if "强势" in description or "突破" in description: + formula["filters"].append({"field": "return_5d", "op": ">=", "value": 5}) + if "低波" in description or "稳健" in description: + formula["score"].append({"field": "volatility_10d", "weight": 0.18, "direction": "asc"}) + if "资金" in description or "主力" in description: + formula["score"].append({"field": "net_flow_million", "weight": 0.18, "direction": "desc"}) + if "小市值" in description or "小盘" in description: + formula["score"].append({"field": "circ_mv_billion", "weight": 0.15, "direction": "asc"}) + if "竞价" in description: + formula["filters"].extend( + [ + {"field": "auction_change", "op": "between", "value": [0.5, 8]}, + {"field": "auction_amount_million", "op": ">=", "value": 2}, + ] + ) + formula["score"].extend( + [ + {"field": "auction_volume_ratio", "weight": 0.20, "direction": "desc"}, + {"field": "auction_amount_million", "weight": 0.18, "direction": "desc"}, + ] + ) + if "少量" in description or "精选" in description: + formula["limit"] = min(formula["limit"], 8) + formula["score"] = formula["score"][:12] + return { + "name": f"{REGIMES.get(regime, regime)}自定义策略", + "description": description, + "regimes": [regime], + "formula": formula, + "compiler": "local_template", + } + + +def _optional_number(value: Any) -> float | None: + if value in (None, ""): + return None + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) else None + + +def _rounded_optional(value: Any, digits: int = 2) -> float | None: + parsed = _optional_number(value) + return round(parsed, digits) if parsed is not None else None + + +def _limit_threshold(code: str, name: str) -> float: + if code.startswith(("4", "8")): + return 29.0 + if code.startswith(("30", "68")): + return 19.0 + return 9.5 + + +def _ending_streak(flags: list[bool], end_index: int | None = None) -> int: + if not flags: + return 0 + index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1) + streak = 0 + while index >= 0 and flags[index]: + streak += 1 + index -= 1 + return streak + + +def _max_streak(flags: list[bool]) -> int: + best = current = 0 + for value in flags: + current = current + 1 if value else 0 + best = max(best, current) + return best + + +def _rsi(values: list[float], period: int = 6) -> float: + if len(values) <= period: + return 50.0 + changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))] + gains = sum(max(change, 0.0) for change in changes) / period + losses = sum(max(-change, 0.0) for change in changes) / period + if losses == 0: + return 100.0 if gains > 0 else 50.0 + return 100 - 100 / (1 + gains / losses) + + +def _ema(values: list[float], period: int) -> list[float]: + if not values: + return [] + alpha = 2 / (period + 1) + result = [values[0]] + for value in values[1:]: + result.append(value * alpha + result[-1] * (1 - alpha)) + return result + + +def _macd_series(values: list[float]) -> tuple[list[float], list[float]]: + fast = _ema(values, 12) + slow = _ema(values, 26) + dif = [left - right for left, right in zip(fast, slow)] + return dif, _ema(dif, 9) + + +def _macd_last(values: list[float]) -> tuple[float, float]: + dif, dea = _macd_series(values) + return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0) + + +def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]: + weeks: dict[str, tuple[float, float]] = {} + for row in rows: + trade_date = str(row.get("trade_date") or "") + try: + key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V") + except ValueError: + continue + close = _number(row.get("close")) + amount = _number(row.get("amount")) + previous = weeks.get(key, (close, 0.0)) + weeks[key] = (close, previous[1] + amount) + ordered = list(weeks.values()) + return [item[0] for item in ordered], [item[1] for item in ordered] + + +def _broken_reversal_metrics( + rows: list[dict[str, Any]], flags: list[bool], code: str, name: str, +) -> dict[str, Any]: + result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0} + if not rows or not flags[-1]: + return result + current_close = _number(rows[-1].get("close")) + current_volume = _number(rows[-1].get("vol")) + for days in range(1, 4): + index = len(rows) - 1 - days + if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2: + continue + broken_high = _number(rows[index].get("high")) + broken_volume = _number(rows[index].get("vol")) + recovered = int(current_close >= broken_high > 0) + volume_ratio = current_volume / broken_volume if broken_volume else 0.0 + return { + "signal": int(recovered and volume_ratio >= 1), + "days": days, + "recovered": recovered, + "volume_ratio": round(volume_ratio, 3), + } + return result + + +def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool: + if index < 0 or index >= len(rows): + return False + return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name) + + +def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool: + if index <= 0 or index >= len(rows): + return False + previous_close = _number(rows[index - 1].get("close")) + high = _number(rows[index].get("high")) + if previous_close <= 0 or high <= 0: + return False + touched_change = (high / previous_close - 1) * 100 + return touched_change >= _limit_threshold(code, name) + + +def _matches(actual: Any, operator: str, expected: Any) -> bool: + if actual is None: + return False + try: + if operator == "between": + return float(expected[0]) <= float(actual) <= float(expected[1]) + if operator == "in": + return actual in expected + if operator == ">": + return float(actual) > float(expected) + if operator == ">=": + return float(actual) >= float(expected) + if operator == "<": + return float(actual) < float(expected) + if operator == "<=": + return float(actual) <= float(expected) + if operator == "==": + return actual == expected or float(actual) == float(expected) + if operator == "!=": + return actual != expected + except (TypeError, ValueError, IndexError): + return False + return False + + +def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]: + ordered = sorted(rows, key=lambda item: _number(item.get(field))) + denominator = max(1, len(ordered) - 1) + result = {} + for index, row in enumerate(ordered): + percentile = index / denominator + result[row["ts_code"]] = 1 - percentile if direction == "asc" else percentile + return result + + +def _available_percentile_map( + rows: list[dict[str, Any]], field: str, direction: str, +) -> dict[str, float | None]: + available = [row for row in rows if row.get(field) is not None] + result: dict[str, float | None] = { + str(row.get("ts_code") or ""): None for row in rows + } + if not available: + return result + ordered = sorted(available, key=lambda item: _number(item.get(field))) + denominator = max(1, len(ordered) - 1) + for index, row in enumerate(ordered): + percentile = 0.5 if len(ordered) == 1 else index / denominator + result[str(row.get("ts_code") or "")] = ( + 1 - percentile if direction == "asc" else percentile + ) + return result + + +def _pearson(first: list[float], second: list[float]) -> float: + if len(first) != len(second) or len(first) < 20: + return 0.0 + first_mean = statistics.fmean(first) + second_mean = statistics.fmean(second) + numerator = sum( + (left - first_mean) * (right - second_mean) + for left, right in zip(first, second) + ) + left_sum = sum((value - first_mean) ** 2 for value in first) + right_sum = sum((value - second_mean) ** 2 for value in second) + denominator = math.sqrt(left_sum * right_sum) + return numerator / denominator if denominator else 0.0 + + +def _risk_flags( + row: dict[str, Any], regime: str, include_regime_risk: bool = True +) -> list[str]: + flags = [] + if row.get("pct_chg", 0) >= 9.5: + flags.append("当日接近涨停,次日存在高开与无法成交风险") + if row.get("return_10d", 0) >= 25: + flags.append("短期累计涨幅较高") + if row.get("volatility_10d", 0) >= 7: + flags.append("波动率偏高") + if row.get("amount_billion", 0) < 1: + flags.append("成交承载力偏弱") + if include_regime_risk and regime == "retreat": + flags.append("市场处于退潮阶段,策略可能选择空仓") + return flags + + +def _regime_reason(regime: str) -> str: + return { + "ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。", + "repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。", + "fermentation": "赚钱效应扩散,主线和梯队持续增强。", + "climax": "情绪处于高位,后排跟风与兑现风险同时上升。", + "divergence": "指数或核心仍强,但广度、封板质量开始分化。", + "retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。", + }.get(regime, "市场阶段待确认。") + + +def _number(value: Any, default: float = 0.0) -> float: + try: + number = float(value) + return number if math.isfinite(number) else default + except (TypeError, ValueError): + return default + + +def _display_date(value: str) -> str: + return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value diff --git a/app/backend/features/screener/repository.py b/app/backend/features/screener/repository.py new file mode 100644 index 0000000..16f962b --- /dev/null +++ b/app/backend/features/screener/repository.py @@ -0,0 +1,814 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from typing import Any + +from backend.features.sentiment.engine import build_sentiment_history + + +def _optional_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +class ScreenerRepositoryMixin: + def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("trade_date") or ""), str(row.get("ts_code") or ""), + float(row.get("close") or 0), float(row.get("pct_chg") or 0), + ) + for row in rows if row.get("trade_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg) + VALUES (?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + close=excluded.close, pct_chg=excluded.pct_chg + """, + values, + ) + return len(values) + + def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("trade_date") or ""), row.get("ts_code", ""), + float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0), + float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0), + _optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")), + _optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")), + ) + for row in rows if row.get("trade_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO daily_indicators + (trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv, + pe_ttm, pb, ps_ttm, dv_ttm) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio, + total_mv=excluded.total_mv, circ_mv=excluded.circ_mv, + pe_ttm=excluded.pe_ttm, pb=excluded.pb, + ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm + """, + values, + ) + return len(values) + + def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("end_date") or ""), str(row.get("ann_date") or ""), + str(row.get("ts_code") or ""), _optional_float(row.get("roe")), + _optional_float(row.get("roa")), _optional_float(row.get("roic")), + _optional_float(row.get("grossprofit_margin")), + _optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")), + _optional_float(row.get("ocf_to_opincome")), + ) + for row in rows + if row.get("end_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO fundamental_indicators + (end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin, + netprofit_yoy, or_yoy, ocf_to_opincome) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(end_date, ts_code) DO UPDATE SET + ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa, + roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin, + netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy, + ocf_to_opincome=excluded.ocf_to_opincome + """, + values, + ) + return len(values) + + def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int: + values = [] + for row in rows: + if not row.get("trade_date") or not row.get("ts_code"): + continue + large_net = ( + float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0) + - float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0) + ) + medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0) + small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0) + values.append(( + str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0), + large_net, medium_net, small_net, + )) + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO moneyflow_daily + (trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date, ts_code) DO UPDATE SET + net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount, + medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount + """, + values, + ) + return len(values) + + def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int: + values = [ + ( + str(row.get("end_date") or ""), + str(row.get("ann_date") or ""), + str(row.get("ts_code") or ""), + _optional_float(row.get("forecast_profit")), + _optional_float(row.get("actual_profit")), + _optional_float(row.get("surprise_pct")), + _optional_float(row.get("revenue_yoy")), + _optional_float(row.get("netprofit_yoy")), + str(row.get("source") or ""), + ) + for row in rows + if row.get("end_date") and row.get("ann_date") and row.get("ts_code") + ] + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO earnings_events + (end_date, ann_date, ts_code, forecast_profit, actual_profit, + surprise_pct, revenue_yoy, netprofit_yoy, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET + forecast_profit=excluded.forecast_profit, + actual_profit=excluded.actual_profit, + surprise_pct=excluded.surprise_pct, + revenue_yoy=excluded.revenue_yoy, + netprofit_yoy=excluded.netprofit_yoy, + source=excluded.source + """, + values, + ) + return len(values) + + def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]: + where = "WHERE trade_date <= ?" if end_date else "" + parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) + with self.connect() as connection: + rows = connection.execute( + f"SELECT DISTINCT trade_date FROM daily_indicators {where} " + "ORDER BY trade_date DESC LIMIT ?", + parameters, + ).fetchall() + return [row["trade_date"] for row in reversed(rows)] + + def fundamental_periods(self) -> list[str]: + with self.connect() as connection: + rows = connection.execute( + "SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date" + ).fetchall() + return [str(row["end_date"]) for row in rows] + + def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: + where = "WHERE trade_date <= ?" if end_date else "" + parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) + with self.connect() as connection: + rows = connection.execute( + f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?", + parameters, + ).fetchall() + return [row["trade_date"] for row in reversed(rows)] + + def factor_health_summary(self, end_date: str) -> dict[str, Any]: + dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101" + with self.connect() as connection: + market = connection.execute( + "SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)", + (end_date,), + ).fetchone()[0] + auction = connection.execute( + "SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)", + (end_date,), + ).fetchone()[0] + benchmark_rows = connection.execute( + "SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?", + (end_date,), + ).fetchone()[0] + indicator_date = connection.execute( + "SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?", + (end_date,), + ).fetchone()[0] + if indicator_date: + valuation_rows, valuation_available = connection.execute( + """ + SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0) + FROM daily_indicators WHERE trade_date = ? + """, + (indicator_date,), + ).fetchone() + else: + valuation_rows, valuation_available = 0, 0 + dividend_years = connection.execute( + """ + SELECT COUNT(DISTINCT substr(trade_date, 1, 4)) + FROM daily_indicators + WHERE trade_date <= ? AND trade_date >= ? + """, + (end_date, dividend_start), + ).fetchone()[0] + fundamental_rows = connection.execute( + """ + SELECT COUNT(*) FROM fundamental_indicators fi + INNER JOIN ( + SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key + FROM fundamental_indicators + WHERE ann_date = '' OR ann_date <= ? + GROUP BY ts_code + ) latest + ON latest.ts_code = fi.ts_code + AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) + """, + (end_date,), + ).fetchone()[0] + moneyflow_dates = connection.execute( + """ + SELECT COUNT(DISTINCT trade_date) + FROM moneyflow_daily + WHERE trade_date IN ( + SELECT DISTINCT trade_date + FROM daily_bars + WHERE trade_date <= ? + ORDER BY trade_date DESC + LIMIT 5 + ) + """, + (end_date,), + ).fetchone()[0] + earnings_rows = connection.execute( + """ + SELECT COUNT(*) FROM earnings_events + WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '') + """, + (end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"), + ).fetchone()[0] + popularity_rows = connection.execute( + "SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?", + (end_date,), + ).fetchone()[0] + institution_rows = connection.execute( + "SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?", + (end_date,), + ).fetchone()[0] + return { + "market": bool(market), + "auction": bool(auction), + "benchmark": int(benchmark_rows or 0) >= 60, + "benchmark_rows": int(benchmark_rows or 0), + "valuation": bool(valuation_available), + "fundamental": int(fundamental_rows or 0) >= 100, + "dividend_history": int(dividend_years or 0) >= 4, + "valuation_rows": int(valuation_rows or 0), + "fundamental_rows": int(fundamental_rows or 0), + "dividend_years": int(dividend_years or 0), + "moneyflow_history": int(moneyflow_dates or 0) >= 5, + "moneyflow_dates": int(moneyflow_dates or 0), + "earnings_events": int(earnings_rows or 0) > 0, + "earnings_event_rows": int(earnings_rows or 0), + "popularity": int(popularity_rows or 0) > 0, + "popularity_rows": int(popularity_rows or 0), + "institutions": int(institution_rows or 0) > 0, + "institution_rows": int(institution_rows or 0), + } + + def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]: + dates = self.factor_dates(end_date, limit_dates) + if not dates: + return { + "dates": [], "bars": [], "master": [], "indicators": [], + "indicator_history": [], "indicator_series": [], "fundamentals": [], + "moneyflow": [], "moneyflow_history": [], "auction": [], + "benchmarks": [], "fundamental_history": [], + "earnings_events": [], "popularity": [], "institutions": [], + } + placeholders = ",".join("?" for _ in dates) + with self.connect() as connection: + bars = connection.execute( + f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code", + dates, + ).fetchall() + master = connection.execute("SELECT * FROM stock_master").fetchall() + indicators = connection.execute( + """ + SELECT * FROM daily_indicators + WHERE trade_date = ( + SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ? + ) + """, + (end_date,), + ).fetchall() + indicator_history = connection.execute( + """ + SELECT di.* FROM daily_indicators di + INNER JOIN ( + SELECT ts_code, substr(trade_date, 1, 4) AS year_key, + MAX(trade_date) AS max_date + FROM daily_indicators + WHERE trade_date <= ? AND trade_date >= ? + GROUP BY ts_code, substr(trade_date, 1, 4) + ) latest + ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date + ORDER BY di.trade_date, di.ts_code + """, + (end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"), + ).fetchall() + indicator_series = connection.execute( + f""" + SELECT trade_date, ts_code, turnover_rate, volume_ratio, + total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm + FROM daily_indicators + WHERE trade_date IN ({placeholders}) + ORDER BY trade_date, ts_code + """, + dates, + ).fetchall() + fundamentals = connection.execute( + """ + SELECT fi.* FROM fundamental_indicators fi + INNER JOIN ( + SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key + FROM fundamental_indicators + WHERE ann_date = '' OR ann_date <= ? + GROUP BY ts_code + ) latest + ON latest.ts_code = fi.ts_code + AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) + """, + (end_date,), + ).fetchall() + fundamental_history = connection.execute( + """ + SELECT * FROM fundamental_indicators + WHERE ann_date = '' OR ann_date <= ? + ORDER BY ann_date, end_date, ts_code + """, + (end_date,), + ).fetchall() + moneyflow = connection.execute( + """ + SELECT * FROM moneyflow_daily + WHERE trade_date = ( + SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ? + ) + """, + (end_date,), + ).fetchall() + flow_dates = dates[-min(5, len(dates)):] + flow_placeholders = ",".join("?" for _ in flow_dates) + moneyflow_history = connection.execute( + f""" + SELECT * FROM moneyflow_daily + WHERE trade_date IN ({flow_placeholders}) + ORDER BY trade_date, ts_code + """, + flow_dates, + ).fetchall() + auction = connection.execute( + """ + SELECT * FROM auction_factors + WHERE trade_date = ( + SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ? + ) + """, + (end_date,), + ).fetchall() + benchmarks = connection.execute( + f""" + SELECT * FROM benchmark_bars + WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders}) + ORDER BY trade_date + """, + dates, + ).fetchall() + earnings_events = connection.execute( + """ + SELECT * FROM earnings_events + WHERE ann_date <= ? + ORDER BY ann_date, end_date, ts_code + """, + (end_date,), + ).fetchall() + popularity = connection.execute( + "SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code", + (end_date,), + ).fetchall() + institutions = connection.execute( + "SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code", + (end_date,), + ).fetchall() + return { + "dates": dates, + "bars": [dict(row) for row in bars], + "master": [dict(row) for row in master], + "indicators": [dict(row) for row in indicators], + "indicator_history": [dict(row) for row in indicator_history], + "indicator_series": [dict(row) for row in indicator_series], + "fundamentals": [dict(row) for row in fundamentals], + "fundamental_history": [dict(row) for row in fundamental_history], + "moneyflow": [dict(row) for row in moneyflow], + "moneyflow_history": [dict(row) for row in moneyflow_history], + "auction": [dict(row) for row in auction], + "benchmarks": [dict(row) for row in benchmarks], + "earnings_events": [dict(row) for row in earnings_events], + "popularity": [dict(row) for row in popularity], + "institutions": [dict(row) for row in institutions], + } + + def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]: + try: + from sentiment_engine import build_sentiment_history + except ModuleNotFoundError: + from .sentiment_engine import build_sentiment_history + + series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260)) + return [ + { + "trade_date": row["trade_date"], + "sentiment_score": row["score"], + "seal_rate": row["seal_rate"], + "limit_up_count": row["limit_up_count"], + "limit_down_count": row["limit_down_count"], + "broken_count": row["broken_count"], + "up_count": row["up_count"], + "down_count": row["down_count"], + "amount_billion": row["amount_billion"], + } + for row in series[-limit:] + ] + + def save_screener_strategy( + self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any], + builtin: bool = False, strategy_id: int | None = None, + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + regimes_json = json.dumps(regimes, ensure_ascii=False) + formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":")) + with self.connect() as connection: + if strategy_id: + if builtin: + cursor = connection.execute( + """ + UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, + builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1 + """, + (name, description, regimes_json, formula_json, now, strategy_id), + ) + else: + cursor = connection.execute( + """ + UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, + updated_at=? WHERE id=? AND builtin=0 AND user_id=? + """, + (name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)), + ) + if cursor.rowcount == 0: + raise ValueError("选股策略不存在。") + return strategy_id + cursor = connection.execute( + """ + INSERT INTO screener_strategies + (user_id, name, description, regimes, formula, builtin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now), + ) + return int(cursor.lastrowid) + + def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]: + with self.connect() as connection: + if user_id is None: + rows = connection.execute( + "SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id" + ).fetchall() + else: + rows = connection.execute( + """ + SELECT * FROM screener_strategies + WHERE builtin = 1 OR user_id = ? + ORDER BY builtin DESC, updated_at DESC, id + """, + (int(user_id),), + ).fetchall() + result = [] + for row in rows: + item = dict(row) + item["regimes"] = json.loads(item["regimes"]) + item["formula"] = json.loads(item["formula"]) + item["builtin"] = bool(item["builtin"]) + result.append(item) + return result + + def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool: + with self.connect() as connection: + row = connection.execute( + "SELECT builtin, user_id FROM screener_strategies WHERE id = ?", + (strategy_id,), + ).fetchone() + if not row: + raise ValueError("选股策略不存在。") + if bool(row["builtin"]): + raise ValueError("内置策略不能删除。") + if int(row["user_id"] or 0) != int(user_id): + raise ValueError("无权删除其他账号的策略。") + cursor = connection.execute( + "DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?", + (strategy_id, int(user_id)), + ) + return cursor.rowcount > 0 + + def save_screener_run( + self, user_id: int, trade_date: str, regime: str, strategy_name: str, + formula: dict[str, Any], result: dict[str, Any], mode: str = "smart", + ) -> int: + normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart" + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + INSERT INTO screener_runs + (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (None if int(user_id) == 0 else int(user_id), trade_date, regime, + normalized_mode, strategy_name, + json.dumps(formula, ensure_ascii=False, separators=(",", ":")), + json.dumps(result, ensure_ascii=False, separators=(",", ":")), now), + ) + return int(cursor.lastrowid) + + @staticmethod + def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None: + try: + result = json.loads(row["result"]) + except json.JSONDecodeError: + return None + result.setdefault("meta", {}).update( + { + "run_id": int(row["id"]), + "trade_date": str(row["trade_date"] or ""), + "regime": str(row["regime"] or ""), + "mode": str(row["mode"] or "smart"), + "strategy_name": str(row["strategy_name"] or ""), + "created_at": row["created_at"], + } + ) + return result + + def latest_screener_run( + self, user_id: int, trade_date: str, mode: str = "", + ) -> dict[str, Any] | None: + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) + parameters += (trade_date,) + mode_clause = "" + if mode in {"smart", "curated", "quant"}: + mode_clause = " AND mode = ?" + parameters += (mode,) + with self.connect() as connection: + row = connection.execute( + f""" + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM screener_runs + WHERE {owner_clause} AND trade_date <= ?{mode_clause} + ORDER BY id DESC LIMIT 1 + """, + parameters, + ).fetchone() + return self._screener_run_payload(row) if row else None + + def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]: + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) + parameters += (trade_date,) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT runs.id, runs.trade_date, runs.regime, runs.mode, + runs.strategy_name, runs.result, runs.created_at + FROM screener_runs runs + INNER JOIN ( + SELECT mode, MAX(id) AS id + FROM screener_runs + WHERE {owner_clause} AND trade_date <= ? + GROUP BY mode + ) latest ON latest.id = runs.id + """, + parameters, + ).fetchall() + results: dict[str, dict[str, Any]] = {} + for row in rows: + mode = str(row["mode"] or "smart") + payload = self._screener_run_payload(row) + if mode in {"smart", "curated", "quant"} and payload: + results[mode] = payload + return results + + def latest_screener_context_runs( + self, user_id: int, trade_date: str, limit: int = 60, + ) -> list[dict[str, Any]]: + safe_limit = max(1, min(120, int(limit))) + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) + parameters += (trade_date, safe_limit) + with self.connect() as connection: + rows = connection.execute( + f""" + WITH ranked AS ( + SELECT id, trade_date, regime, mode, strategy_name, result, created_at, + ROW_NUMBER() OVER ( + PARTITION BY + mode, + CASE WHEN mode = 'smart' THEN regime ELSE '' END, + CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END + ORDER BY id DESC + ) AS context_rank + FROM screener_runs + WHERE {owner_clause} AND trade_date <= ? + ) + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM ranked + WHERE context_rank = 1 + ORDER BY id DESC + LIMIT ? + """, + parameters, + ).fetchall() + return [ + payload + for row in rows + if (payload := self._screener_run_payload(row)) is not None + ] + + def screener_runs_for_date( + self, user_id: int, trade_date: str, limit: int = 80, + ) -> list[dict[str, Any]]: + safe_limit = max(1, min(160, int(limit))) + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) + parameters += (trade_date, safe_limit) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM screener_runs + WHERE {owner_clause} AND trade_date = ? + ORDER BY id DESC + LIMIT ? + """, + parameters, + ).fetchall() + result = [] + seen: set[tuple[str, str, str]] = set() + for row in rows: + key = ( + str(row["mode"] or "smart"), + str(row["regime"] or ""), + str(row["strategy_name"] or ""), + ) + if key in seen: + continue + seen.add(key) + payload = self._screener_run_payload(row) + if payload is not None: + result.append(payload) + return result + + def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None: + owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" + parameters: tuple[Any, ...] = (int(run_id),) + if int(user_id) != 0: + parameters += (int(user_id),) + with self.connect() as connection: + row = connection.execute( + f""" + SELECT id, trade_date, regime, mode, strategy_name, result, created_at + FROM screener_runs WHERE id = ? AND {owner_clause} + """, + parameters, + ).fetchone() + if not row: + return None + result = self._screener_run_payload(row) + if result is None: + return None + result.setdefault("meta", {}).update( + { + "run_id": int(row["id"]), + "trade_date": row["trade_date"], + "mode": str(row["mode"] or "smart"), + "created_at": row["created_at"], + } + ) + result["strategy_name"] = row["strategy_name"] + result["regime"] = row["regime"] + return result + + def save_strategy_tracks( + self, + user_id: int, + run_id: int, + selection_date: str, + strategy_name: str, + candidates: list[dict[str, Any]], + ) -> int: + now = datetime.now().astimezone().isoformat(timespec="seconds") + values = [] + for item in candidates: + ts_code = str(item.get("ts_code") or "").strip() + code = str(item.get("code") or ts_code.split(".")[0]).strip() + entry_price = float(item.get("price") or 0) + if not ts_code or not code or entry_price <= 0: + continue + values.append( + ( + int(user_id), int(run_id), selection_date, strategy_name, ts_code, code, + str(item.get("name") or "--"), str(item.get("sector") or "其他"), + entry_price, now, now, + ) + ) + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO strategy_tracks + (user_id, run_id, selection_date, strategy_name, ts_code, code, + name, sector, entry_price, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET + name=excluded.name, sector=excluded.sector, + entry_price=excluded.entry_price, updated_at=excluded.updated_at + """, + values, + ) + return len(values) + + def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]: + limit_batches = max(1, min(50, int(limit_batches))) + with self.connect() as connection: + rows = connection.execute( + """ + SELECT * FROM strategy_tracks + WHERE user_id = ? AND run_id IN ( + SELECT run_id FROM strategy_tracks WHERE user_id = ? + GROUP BY run_id ORDER BY run_id DESC LIMIT ? + ) + ORDER BY run_id DESC, id + """, + (int(user_id), int(user_id), limit_batches), + ).fetchall() + return [dict(row) for row in rows] + + def delete_strategy_track(self, user_id: int, track_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?", + (int(track_id), int(user_id)), + ) + return cursor.rowcount > 0 + + def load_tracking_bars( + self, targets: list[tuple[str, str]], limit: int = 5 + ) -> dict[tuple[str, str], list[dict[str, Any]]]: + unique_targets = set(targets) + if not unique_targets: + return {} + codes = sorted({ts_code for ts_code, _ in unique_targets}) + earliest_date = min(selection_date for _, selection_date in unique_targets) + placeholders = ",".join("?" for _ in codes) + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT ts_code, trade_date, open, high, low, close FROM daily_bars + WHERE ts_code IN ({placeholders}) AND trade_date > ? + ORDER BY ts_code, trade_date + """, + [*codes, earliest_date], + ).fetchall() + by_code: dict[str, list[dict[str, Any]]] = {} + for row in rows: + item = dict(row) + by_code.setdefault(str(item["ts_code"]), []).append(item) + row_limit = max(1, min(20, int(limit))) + return { + (ts_code, selection_date): [ + row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date + ][:row_limit] + for ts_code, selection_date in unique_targets + } diff --git a/app/backend/features/screener/service.py b/app/backend/features/screener/service.py new file mode 100644 index 0000000..d22ae06 --- /dev/null +++ b/app/backend/features/screener/service.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import copy +import re +from datetime import date, datetime +from typing import Any + +from backend.bootstrap.config import normalize_date, validate_text +from backend.data.providers.tushare_client import TushareError +from backend.llm import LLMGatewayError +from backend.features.screener.compiler import ( + LLMCompilerError, + compile_strategy_with_llm, +) +from backend.features.screener.engine import ( + FACTOR_FIELDS, + FACTOR_GROUPS, + REGIMES, + FactorDataService, + compile_local_strategy, +) + + +SCREENER_LIBRARY_VERSION = 8 + + +def automatic_screener_jobs( + strategies: list[dict[str, Any]], regime_id: str +) -> list[dict[str, Any]]: + """Build the close-of-day jobs; only stage screening is regime-gated.""" + smart_strategy = next( + ( + item for item in strategies + if item.get("formula", {}).get("meta", {}).get("library") != "curated" + and regime_id in (item.get("regimes") or []) + ), + None, + ) + curated = [ + item for item in strategies + if item.get("formula", {}).get("meta", {}).get("library") == "curated" + ] + jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else []) + jobs.extend({"mode": "curated", "strategy": item} for item in curated) + return jobs + + +class ScreenerServiceMixin: + @staticmethod + def _strategy_missing_data( + strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any] + ) -> list[str]: + formula = strategy.get("formula") or {} + meta = formula.get("meta") or {} + used_fields = { + str(item.get("field") or "") + for item in list(formula.get("filters") or []) + list(formula.get("score") or []) + } + valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"} + fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"} + auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"} + missing = [] + required_history = max(21, min(260, int(meta.get("history_days") or 21))) + if len(factor_dates) < required_history: + missing.append(f"历史行情(需{required_history}日)") + if used_fields & valuation_fields and not factor_health["valuation"]: + missing.append("估值数据") + if used_fields & fundamental_fields and not factor_health["fundamental"]: + missing.append("财务质量") + if meta.get("requires_valuation") and not factor_health["valuation"]: + missing.append("估值数据") + if meta.get("requires_fundamental") and not factor_health["fundamental"]: + missing.append("财务质量") + if "dividend_years" in used_fields and not factor_health["dividend_history"]: + missing.append("历年分红") + if used_fields & auction_fields and not factor_health["auction"]: + missing.append("竞价数据") + if meta.get("requires_benchmark") and not factor_health.get("benchmark"): + missing.append("沪深300基准") + if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"): + missing.append("近5日资金流") + if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"): + missing.append("业绩预告与快报") + if meta.get("requires_popularity") and not factor_health.get("popularity"): + missing.append("当日人气榜") + if meta.get("requires_institutions") and not factor_health.get("institutions"): + missing.append("龙虎榜机构席位") + return list(dict.fromkeys(missing)) + + def screener_setup(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + regime = self.screener.detect_regime(normalized_date) + factor_dates = self.database.factor_dates(normalized_date, 300) + auction_dates = self.database.auction_factor_dates(normalized_date, 100) + factor_health = self.screener.factor_health(normalized_date) + strategies = self.database.list_screener_strategies(self.current_user_id) + for strategy in strategies: + missing = self._strategy_missing_data(strategy, factor_dates, factor_health) + strategy["data_ready"] = not missing + strategy["missing_data"] = missing + automatic_results = self.database.screener_runs_for_date(0, normalized_date) + personal_results = self.database.screener_runs_for_date( + self.current_user_id, normalized_date + ) + recent_results = [ + *[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}], + *[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"], + ] + latest_results: dict[str, dict[str, Any]] = {} + for result in reversed(recent_results): + mode = str(result.get("meta", {}).get("mode") or "smart") + latest_results[mode] = result + automatic_status = self.database.get_data_snapshot( + "screener_auto_v1", normalized_date + ) or {} + return { + "trade_date": normalized_date, + "regime": regime, + "regimes": [{"id": key, "label": value} for key, value in REGIMES.items()], + "strategies": strategies, + "factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()], + "factor_groups": [ + { + "name": name, + "fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields], + } + for name, fields in FACTOR_GROUPS.items() + ], + "operators": [">", ">=", "<", "<=", "==", "between"], + "factor_data": { + "date_count": len(factor_dates), + "start_date": factor_dates[0] if factor_dates else "", + "end_date": factor_dates[-1] if factor_dates else "", + "ready": len(factor_dates) >= 21, + "auction_date_count": len(auction_dates), + "auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False, + "health": factor_health, + }, + "llm": { + "configured": self.llm_configured, + "model": self.llm_primary_model if self.llm_configured else "", + "fallback_configured": self.llm_fallback_configured, + "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", + }, + "latest_results": latest_results, + "recent_results": recent_results, + "automatic_status": automatic_status, + # Kept during the client transition for compatibility with older frontends. + "latest_result": latest_results.get("smart"), + } + + def screener_tracking(self, limit: int = 12) -> dict[str, Any]: + return self.strategy_tracking.list_tracking(self.current_user_id, limit) + + def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]: + try: + run_id = int(payload.get("run_id") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("选股批次无效。") from exc + code = str(payload.get("code") or "").strip() + if run_id <= 0 or not re.fullmatch(r"\d{6}", code): + raise ValueError("选股批次或股票代码无效。") + return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code) + + def remove_screener_tracking(self, track_id: int) -> dict[str, Any]: + return self.strategy_tracking.remove_candidate(self.current_user_id, track_id) + + def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + notice = "" + if self.configured: + try: + FactorDataService(self.database, self._tushare_client()).sync( + normalized_date, 15 + ) + except TushareError: + notice = "最新日线暂未补齐,已按现有数据更新跟踪。" + else: + notice = "公共行情尚未配置,已按现有数据更新跟踪。" + return { + "tracking": self.screener_tracking(), + "notice": notice, + } + + def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: + if not self.configured: + raise ValueError("请先配置 Tushare Token。") + normalized_date = normalize_date(trade_date) + lookback = max(25, min(260, int(lookback))) + with self.sync_lock: + return FactorDataService(self.database, self._tushare_client()).sync( + normalized_date, lookback + ) + + def _schedule_automatic_screeners( + self, trade_date: str, snapshot: dict[str, Any] | None = None + ) -> bool: + normalized_date = normalize_date(trade_date) + now = datetime.now().astimezone() + if ( + normalized_date != now.strftime("%Y%m%d") + or now.weekday() >= 5 + or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time() + or self.auto_screener_lock.locked() + ): + return False + snapshot = snapshot or self.database.get_snapshot(normalized_date) or {} + actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "") + if actual_date != normalized_date: + return False + marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {} + if ( + marker.get("status") == "complete" + and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION + ): + return False + last_attempt = self._auto_screener_last_attempt.get(normalized_date) + if last_attempt and (now - last_attempt).total_seconds() < 600: + return False + self._auto_screener_last_attempt[normalized_date] = now + return self.jobs.submit( + "screener.automatic", + f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}", + lambda: self.run_automatic_screeners(normalized_date), + {"trade_date": normalized_date, "trigger": "post-close"}, + ) + + def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + with self.auto_screener_lock: + started_at = datetime.now().astimezone().isoformat(timespec="seconds") + status: dict[str, Any] = { + "trade_date": normalized_date, + "library_version": SCREENER_LIBRARY_VERSION, + "status": "running", + "started_at": started_at, + "completed": [], + "skipped": [], + "failed": [], + } + self.database.save_data_snapshot( + "screener_auto_v1", normalized_date, "system", status + ) + try: + factor_sync = FactorDataService( + self.database, self._tushare_client() + ).sync(normalized_date, 260) + factor_dates = self.database.factor_dates(normalized_date, 300) + if not factor_dates or factor_dates[-1] != normalized_date: + raise ValueError("当日收盘行情尚未入库") + factor_health = self.screener.factor_health(normalized_date) + regime = self.screener.detect_regime(normalized_date) + regime_id = str(regime.get("id") or "repair") + strategies = self.database.list_screener_strategies(None) + jobs = automatic_screener_jobs(strategies, regime_id) + existing = { + ( + str(item.get("meta", {}).get("mode") or "smart"), + str(item.get("meta", {}).get("strategy_name") or ""), + ) + for item in self.database.screener_runs_for_date(0, normalized_date) + if int(item.get("meta", {}).get("library_version") or 0) + == SCREENER_LIBRARY_VERSION + } + required_history = max( + [ + int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80) + for job in jobs if job.get("strategy") + ] or [80] + ) + factors, actual_date = self.screener.build_factors( + normalized_date, history_days=required_history + ) + if actual_date != normalized_date: + raise ValueError("当日因子尚未完成收盘定格") + for job in jobs: + strategy = job["strategy"] + mode = str(job["mode"]) + name = str(strategy.get("name") or "未命名策略") + if (mode, name) in existing: + status["completed"].append({"mode": mode, "name": name, "cached": True}) + continue + missing = self._strategy_missing_data( + strategy, factor_dates, factor_health + ) + if missing: + status["skipped"].append( + {"mode": mode, "name": name, "reason": "、".join(missing)} + ) + continue + try: + formula = copy.deepcopy(strategy.get("formula") or {}) + formula.setdefault("meta", {})["library_version"] = ( + SCREENER_LIBRARY_VERSION + ) + result = self.screener.screen( + 0, + normalized_date, + formula, + regime_id, + name, + False, + None, + mode, + factors, + actual_date, + ) + status["completed"].append( + { + "mode": mode, + "name": name, + "candidate_count": len(result.get("candidates") or []), + } + ) + except Exception as exc: + status["failed"].append( + {"mode": mode, "name": name, "reason": str(exc)} + ) + status.update( + { + "status": "complete" if not status["failed"] else "partial", + "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "factor_sync": factor_sync, + "regime": regime, + } + ) + except Exception as exc: + status.update( + { + "status": "failed", + "finished_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "error": str(exc), + } + ) + self.database.save_data_snapshot( + "screener_auto_v1", normalized_date, "system", status + ) + return status + + def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]: + prompt = prompt.strip() + if not prompt or len(prompt) > 3000: + raise ValueError("策略描述应为 1 至 3000 个字符。") + if regime not in REGIMES: + raise ValueError("市场阶段不支持。") + notice = "" + source = self.llm_source + if source == "platform": + try: + gateway_result = self.llm_gateway.call( + "screener", + "strategy-compiler-v1", + lambda profile: compile_strategy_with_llm( + prompt, + regime, + profile.api_key, + profile.base_url, + profile.model, + ), + (LLMCompilerError,), + ) + compiled = gateway_result.value + if gateway_result.role == "fallback": + compiled["compiler"] = "llm_fallback" + notice = "智能策略生成服务已自动切换。" + except LLMGatewayError as exc: + if exc.code != "unavailable": + raise + compiled = compile_local_strategy(prompt, regime) + notice = "智能策略生成暂不可用,已使用本地模板。" + else: + compiled = compile_local_strategy(prompt, regime) + notice = "智能策略生成暂不可用,已使用本地模板。" + compiled["formula"] = self.screener.validate_formula(compiled["formula"]) + compiled["notice"] = notice + return compiled + + def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]: + name = validate_text(payload.get("name"), "策略名称", 60, required=True) + description = validate_text(payload.get("description"), "策略说明", 1000) + regimes = payload.get("regimes") or [] + if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes): + raise ValueError("策略适用阶段不正确。") + formula = self.screener.validate_formula(payload.get("formula") or {}) + strategy_id = self.database.save_screener_strategy( + self.current_user_id, name, description, regimes, formula + ) + return { + "id": strategy_id, + "strategies": self.database.list_screener_strategies(self.current_user_id), + } + + def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]: + deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id) + return { + "deleted": deleted, + "strategies": self.database.list_screener_strategies(self.current_user_id), + } + + def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]: + trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat())) + regime = str(payload.get("regime") or "") + if regime not in REGIMES: + raise ValueError("市场阶段不支持。") + strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True) + formula = payload.get("formula") or {} + requested_mode = str(payload.get("mode") or "").strip() + if requested_mode and requested_mode not in {"smart", "curated", "quant"}: + raise ValueError("选股模式不受支持。") + if requested_mode: + mode = requested_mode + else: + meta = formula.get("meta") if isinstance(formula, dict) else {} + library = str((meta or {}).get("library") or "") + category = str((meta or {}).get("category") or "") + if library == "curated": + mode = "curated" + elif library == "quant" or (library == "custom" and category == "量化公式"): + mode = "quant" + else: + mode = "smart" + realtime_snapshot = None + dashboard = self.get_dashboard(trade_date) + if self.configured and dashboard.get("meta", {}).get("realtime"): + try: + realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date) + except TushareError as exc: + raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc + result = self.screener.screen( + self.current_user_id, trade_date, formula, regime, strategy_name, + bool(payload.get("run_backtest", True)), + realtime_snapshot, + mode, + ) + return result diff --git a/app/backend/features/screener/strategies.py b/app/backend/features/screener/strategies.py new file mode 100644 index 0000000..8ddfc1d --- /dev/null +++ b/app/backend/features/screener/strategies.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +from typing import Any + + +def _meta( + category: str, + quality: str, + frequency: str, + risk: str, + data_group: str, + history_days: int, + backtest_days: int, + take_profit: float, + stop_loss: float, + **extra: Any, +) -> dict[str, Any]: + return { + "library": "curated", + "category": category, + "quality": quality, + "frequency": frequency, + "risk": risk, + "data_group": data_group, + "history_days": history_days, + "backtest_days": backtest_days, + "take_profit": take_profit, + "stop_loss": stop_loss, + **extra, + } + + +ADVANCED_CURATED_STRATEGIES = [ + { + "name": "中期动量·强者恒强", + "description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。", + "regimes": ["repair", "fermentation", "climax", "divergence"], + "formula": { + "meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "close", "op": "between", "value": [3, 100]}, + {"field": "momentum_60_5_rank", "op": ">=", "value": 0.90}, + {"field": "is_limit_up_today", "op": "==", "value": 0}, + ], + "score": [ + {"field": "momentum_60_5", "weight": 0.55, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, + ], + "limit": 25, + "min_score": 0.50, + }, + }, + { + "name": "强者回调", + "description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "momentum_60_5_rank", "op": ">=", "value": 0.70}, + {"field": "return_5d_rank", "op": "<=", "value": 0.20}, + {"field": "above_ma20", "op": "==", "value": 1}, + {"field": "rsi_6", "op": "<=", "value": 30}, + {"field": "no_limit_down_20d", "op": "==", "value": 1}, + ], + "score": [ + {"field": "momentum_60_5", "weight": 0.42, "direction": "desc"}, + {"field": "return_5d", "weight": 0.33, "direction": "asc"}, + {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, + ], + "limit": 20, + "min_score": 0.48, + }, + }, + { + "name": "超跌反转", + "description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。", + "regimes": ["ice", "repair"], + "formula": { + "meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "return_5d_rank", "op": "<=", "value": 0.05}, + {"field": "turnover_5d", "op": ">=", "value": 30}, + {"field": "return_60d", "op": ">=", "value": -40}, + {"field": "financial_risk", "op": "==", "value": 0}, + {"field": "is_limit_down_today", "op": "==", "value": 0}, + ], + "score": [ + {"field": "return_5d", "weight": 0.45, "direction": "asc"}, + {"field": "turnover_5d", "weight": 0.30, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, + ], + "limit": 10, + "min_score": 0.50, + }, + }, + { + "name": "相对强度新高", + "description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。", + "regimes": ["ice", "repair", "fermentation", "divergence"], + "formula": { + "meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True), + "universe": {"exclude_st": True, "listed_days_min": 250}, + "filters": [ + {"field": "amount_billion", "op": ">=", "value": 1}, + {"field": "rs_high_120", "op": "==", "value": 1}, + {"field": "excess_return_60d", "op": ">=", "value": 10}, + {"field": "ma60_slope", "op": ">", "value": 0}, + ], + "score": [ + {"field": "excess_return_60d", "weight": 0.50, "direction": "desc"}, + {"field": "ma60_slope", "weight": 0.25, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.25, "direction": "desc"}, + ], + "limit": 20, + "min_score": 0.52, + }, + }, + { + "name": "均线多头排列", + "description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。", + "regimes": ["repair", "fermentation", "climax", "divergence"], + "formula": { + "meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7), + "universe": {"exclude_st": True, "listed_days_min": 365}, + "filters": [ + {"field": "ma_bull_alignment", "op": "==", "value": 1}, + {"field": "ma20_slope_5d", "op": ">", "value": 0}, + {"field": "drawdown_from_high_250", "op": "<=", "value": 20}, + ], + "score": [ + {"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"}, + {"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"}, + {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, + ], + "limit": 30, + "min_score": 0.50, + }, + }, + { + "name": "唐奇安通道突破", + "description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "donchian_breakout_pct", "op": ">=", "value": 2}, + {"field": "volume_ratio_5d", "op": ">=", "value": 1.8}, + {"field": "range_20d", "op": "<=", "value": 35}, + ], + "score": [ + {"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"}, + {"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"}, + {"field": "range_20d", "weight": 0.25, "direction": "asc"}, + ], + "limit": 15, + "min_score": 0.52, + }, + }, + { + "name": "周线趋势·日线买点", + "description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。", + "regimes": ["repair", "fermentation", "divergence"], + "formula": { + "meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7), + "universe": {"exclude_st": True, "listed_days_min": 365}, + "filters": [ + {"field": "weekly_trend_signal", "op": "==", "value": 1}, + {"field": "daily_buy_trigger", "op": "==", "value": 1}, + {"field": "weekly_amount_trend", "op": "==", "value": 1}, + ], + "score": [ + {"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.35, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.30, "direction": "desc"}, + ], + "limit": 20, + "min_score": 0.52, + }, + }, +] + +ADVANCED_CURATED_STRATEGIES.extend( + [ + { + "name": "空间板", + "description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。", + "regimes": ["repair", "fermentation"], + "formula": { + "meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6), + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "is_market_height", "op": "==", "value": 1}, + {"field": "new_space_board", "op": "==", "value": 1}, + {"field": "sector_limit_count", "op": ">=", "value": 3}, + ], + "score": [ + {"field": "limit_streak", "weight": 0.50, "direction": "desc"}, + {"field": "sector_limit_count", "weight": 0.30, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, + ], + "limit": 5, + "min_score": 0.45, + }, + }, + { + "name": "龙头首阴", + "description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。", + "regimes": ["fermentation", "climax"], + "formula": { + "meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6), + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "max_continuous_board_10d", "op": ">=", "value": 3}, + {"field": "dragon_first_yin", "op": "==", "value": 1}, + {"field": "yin_day_pct", "op": ">=", "value": -7}, + {"field": "vol_vs_previous", "op": "<=", "value": 0.8}, + ], + "score": [ + {"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"}, + {"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"}, + {"field": "sector_strength", "weight": 0.25, "direction": "desc"}, + ], + "limit": 5, + "min_score": 0.48, + }, + }, + { + "name": "断板反包", + "description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。", + "regimes": ["repair", "fermentation"], + "formula": { + "meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6), + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "broken_reversal", "op": "==", "value": 1}, + {"field": "days_since_broken", "op": "between", "value": [1, 3]}, + {"field": "close_above_broken_high", "op": "==", "value": 1}, + {"field": "vol_vs_broken_day", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "days_since_broken", "weight": 0.35, "direction": "asc"}, + {"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"}, + {"field": "sector_strength", "weight": 0.30, "direction": "desc"}, + ], + "limit": 5, + "min_score": 0.46, + }, + }, + { + "name": "核按钮反核", + "description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。", + "regimes": ["repair", "fermentation"], + "formula": { + "meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6), + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "recent_limit_up_5d", "op": ">=", "value": 1}, + {"field": "intraday_min_pct", "op": "<=", "value": -7}, + {"field": "pct_chg", "op": ">=", "value": -3}, + {"field": "lower_shadow_ratio", "op": ">=", "value": 2}, + {"field": "vol_vs_previous", "op": "<=", "value": 1.1}, + ], + "score": [ + {"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"}, + {"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"}, + {"field": "sector_strength", "weight": 0.28, "direction": "desc"}, + ], + "limit": 5, + "min_score": 0.48, + }, + }, + ] +) + +ADVANCED_CURATED_STRATEGIES.extend( + [ + { + "name": "景气-趋势-拥挤三维行业打分", + "description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7, + requires_fundamental=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "sector_composite_score", "op": ">=", "value": 0.58}, + {"field": "sector_crowding_rank", "op": "<=", "value": 0.90}, + {"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "sector_composite_score", "weight": 0.55, "direction": "desc"}, + {"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"}, + {"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"}, + ], + "limit": 12, + "min_score": 0.50, + }, + }, + { + "name": "大小盘/成长价值风格切换(元策略)", + "description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7, + requires_fundamental=True, requires_valuation=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 250}, + "filters": [ + {"field": "style_fit_score", "op": ">=", "value": 0.65}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "style_fit_score", "weight": 0.70, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, + ], + "limit": 20, + "min_score": 0.52, + }, + }, + { + "name": "业绩超预期漂移(SUE/PEAD)", + "description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7, + requires_earnings_events=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "earnings_surprise_pct", "op": ">=", "value": 10}, + {"field": "revenue_yoy", "op": ">", "value": 0}, + {"field": "earnings_event_quality", "op": "==", "value": 1}, + {"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]}, + ], + "score": [ + {"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.15, "direction": "desc"}, + ], + "limit": 15, + "min_score": 0.50, + }, + }, + { + "name": "多因子综合打分(IC动态加权)", + "description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7, + requires_fundamental=True, requires_valuation=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 250}, + "filters": [ + {"field": "multi_factor_composite", "op": ">=", "value": 0.65}, + {"field": "financial_risk", "op": "==", "value": 0}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"}, + {"field": "relative_strength", "weight": 0.15, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, + ], + "limit": 30, + "min_score": 0.55, + }, + }, + { + "name": "热度突增潜伏(另类数据)", + "description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7, + requires_popularity=True, backtestable=False, + ), + "universe": {"exclude_st": True, "listed_days_min": 120}, + "filters": [ + {"field": "popularity_score", "op": ">=", "value": 15}, + {"field": "return_10d", "op": "<=", "value": 5}, + {"field": "recent_limit_up_5d", "op": "==", "value": 0}, + {"field": "amount_billion", "op": ">=", "value": 0.5}, + ], + "score": [ + {"field": "popularity_score", "weight": 0.50, "direction": "desc"}, + {"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"}, + {"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.15, "direction": "desc"}, + ], + "limit": 10, + "min_score": 0.48, + }, + }, + { + "name": "机构榜溢价", + "description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7, + requires_institutions=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "institution_net_buy_million", "op": ">=", "value": 30}, + {"field": "institution_seat_count", "op": ">=", "value": 1}, + {"field": "return_60d", "op": "<=", "value": 30}, + {"field": "previous_limit_streak", "op": "<=", "value": 2}, + ], + "score": [ + {"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"}, + {"field": "institution_seat_count", "weight": 0.15, "direction": "desc"}, + {"field": "relative_position_60", "weight": 0.20, "direction": "asc"}, + {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, + ], + "limit": 10, + "min_score": 0.48, + }, + }, + ] +) + +ADVANCED_CURATED_STRATEGIES.extend( + [ + { + "name": "行业动量轮动", + "description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "sector_momentum_rank", "op": ">=", "value": 0.90}, + {"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "sector_return_20d", "weight": 0.38, "direction": "desc"}, + {"field": "return_20d", "weight": 0.32, "direction": "desc"}, + {"field": "total_mv_billion", "weight": 0.18, "direction": "desc"}, + {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, + ], + "limit": 12, + "min_score": 0.48, + }, + }, + { + "name": "主力资金行业流入", + "description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。", + "regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"], + "formula": { + "meta": _meta( + "行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7, + requires_moneyflow_history=True, + ), + "universe": {"exclude_st": True, "listed_days_min": 180}, + "filters": [ + {"field": "sector_flow_rank", "op": ">=", "value": 0.85}, + {"field": "sector_net_flow_5d_million", "op": ">", "value": 0}, + {"field": "sector_return_5d", "op": "<=", "value": 8}, + {"field": "flow_to_circ_mv_5d", "op": ">", "value": 0}, + {"field": "amount_billion", "op": ">=", "value": 1}, + ], + "score": [ + {"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"}, + {"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"}, + {"field": "sector_return_5d", "weight": 0.16, "direction": "asc"}, + {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, + ], + "limit": 15, + "min_score": 0.48, + }, + }, + ] +) diff --git a/app/database.py b/app/database.py index 35896fc..441ed20 100644 --- a/app/database.py +++ b/app/database.py @@ -13,18 +13,10 @@ from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin from backend.features.market.repository import MarketRepositoryMixin from backend.features.pools.repository import PoolRepositoryMixin from backend.features.popularity.repository import PopularityRepositoryMixin +from backend.features.screener.repository import ScreenerRepositoryMixin from backend.features.system.repository import SystemSettingsRepositoryMixin -def _optional_float(value: Any) -> float | None: - if value in (None, ""): - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - class ReviewDatabase( AccountRepositoryMixin, AuctionRepositoryMixin, @@ -32,6 +24,7 @@ class ReviewDatabase( MarketRepositoryMixin, PoolRepositoryMixin, PopularityRepositoryMixin, + ScreenerRepositoryMixin, SystemSettingsRepositoryMixin, ): def __init__(self, path: Path) -> None: @@ -875,784 +868,8 @@ class ReviewDatabase( ) return cursor.rowcount > 0 - def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - values = [ - ( - row.get("ts_code", ""), - str(row.get("ts_code", "")).split(".")[0], - row.get("name") or "--", - row.get("industry") or "", - row.get("market") or "", - str(row.get("list_date") or ""), - now, - ) - for row in rows if row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO stock_master - (ts_code, code, name, industry, market, list_date, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(ts_code) DO UPDATE SET - code=excluded.code, name=excluded.name, industry=excluded.industry, - market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at - """, - values, - ) - return len(values) - def list_stock_master(self) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - "SELECT ts_code, code, name, industry, market, list_date FROM stock_master" - ).fetchall() - return [dict(row) for row in rows] - - def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("trade_date") or ""), row.get("ts_code", ""), - float(row.get("open") or 0), float(row.get("high") or 0), - float(row.get("low") or 0), float(row.get("close") or 0), - float(row.get("pct_chg") or 0), float(row.get("vol") or 0), - float(row.get("amount") or 0), - ) - for row in rows if row.get("trade_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO daily_bars - (trade_date, ts_code, open, high, low, close, pct_chg, vol, amount) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - open=excluded.open, high=excluded.high, low=excluded.low, - close=excluded.close, pct_chg=excluded.pct_chg, - vol=excluded.vol, amount=excluded.amount - """, - values, - ) - return len(values) - - def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("trade_date") or ""), str(row.get("ts_code") or ""), - float(row.get("close") or 0), float(row.get("pct_chg") or 0), - ) - for row in rows if row.get("trade_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg) - VALUES (?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - close=excluded.close, pct_chg=excluded.pct_chg - """, - values, - ) - return len(values) - - def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("trade_date") or ""), row.get("ts_code", ""), - float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0), - float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0), - _optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")), - _optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")), - ) - for row in rows if row.get("trade_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO daily_indicators - (trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv, - pe_ttm, pb, ps_ttm, dv_ttm) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio, - total_mv=excluded.total_mv, circ_mv=excluded.circ_mv, - pe_ttm=excluded.pe_ttm, pb=excluded.pb, - ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm - """, - values, - ) - return len(values) - - def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("end_date") or ""), str(row.get("ann_date") or ""), - str(row.get("ts_code") or ""), _optional_float(row.get("roe")), - _optional_float(row.get("roa")), _optional_float(row.get("roic")), - _optional_float(row.get("grossprofit_margin")), - _optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")), - _optional_float(row.get("ocf_to_opincome")), - ) - for row in rows - if row.get("end_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO fundamental_indicators - (end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin, - netprofit_yoy, or_yoy, ocf_to_opincome) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(end_date, ts_code) DO UPDATE SET - ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa, - roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin, - netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy, - ocf_to_opincome=excluded.ocf_to_opincome - """, - values, - ) - return len(values) - - def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int: - values = [] - for row in rows: - if not row.get("trade_date") or not row.get("ts_code"): - continue - large_net = ( - float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0) - - float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0) - ) - medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0) - small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0) - values.append(( - str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0), - large_net, medium_net, small_net, - )) - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO moneyflow_daily - (trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET - net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount, - medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount - """, - values, - ) - return len(values) - - - def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int: - values = [ - ( - str(row.get("end_date") or ""), - str(row.get("ann_date") or ""), - str(row.get("ts_code") or ""), - _optional_float(row.get("forecast_profit")), - _optional_float(row.get("actual_profit")), - _optional_float(row.get("surprise_pct")), - _optional_float(row.get("revenue_yoy")), - _optional_float(row.get("netprofit_yoy")), - str(row.get("source") or ""), - ) - for row in rows - if row.get("end_date") and row.get("ann_date") and row.get("ts_code") - ] - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO earnings_events - (end_date, ann_date, ts_code, forecast_profit, actual_profit, - surprise_pct, revenue_yoy, netprofit_yoy, source) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET - forecast_profit=excluded.forecast_profit, - actual_profit=excluded.actual_profit, - surprise_pct=excluded.surprise_pct, - revenue_yoy=excluded.revenue_yoy, - netprofit_yoy=excluded.netprofit_yoy, - source=excluded.source - """, - values, - ) - return len(values) - - - - def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]: - where = "WHERE trade_date <= ?" if end_date else "" - parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) - with self.connect() as connection: - rows = connection.execute( - f"SELECT DISTINCT trade_date FROM daily_indicators {where} " - "ORDER BY trade_date DESC LIMIT ?", - parameters, - ).fetchall() - return [row["trade_date"] for row in reversed(rows)] - - def fundamental_periods(self) -> list[str]: - with self.connect() as connection: - rows = connection.execute( - "SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date" - ).fetchall() - return [str(row["end_date"]) for row in rows] - - - def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]: - with self.connect() as connection: - rows = connection.execute( - "SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code", - (trade_date,), - ).fetchall() - return [dict(row) for row in rows] - - def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]: - where = "WHERE trade_date <= ?" if end_date else "" - parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,) - with self.connect() as connection: - rows = connection.execute( - f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?", - parameters, - ).fetchall() - return [row["trade_date"] for row in reversed(rows)] - - def factor_health_summary(self, end_date: str) -> dict[str, Any]: - dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101" - with self.connect() as connection: - market = connection.execute( - "SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)", - (end_date,), - ).fetchone()[0] - auction = connection.execute( - "SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)", - (end_date,), - ).fetchone()[0] - benchmark_rows = connection.execute( - "SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?", - (end_date,), - ).fetchone()[0] - indicator_date = connection.execute( - "SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?", - (end_date,), - ).fetchone()[0] - if indicator_date: - valuation_rows, valuation_available = connection.execute( - """ - SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0) - FROM daily_indicators WHERE trade_date = ? - """, - (indicator_date,), - ).fetchone() - else: - valuation_rows, valuation_available = 0, 0 - dividend_years = connection.execute( - """ - SELECT COUNT(DISTINCT substr(trade_date, 1, 4)) - FROM daily_indicators - WHERE trade_date <= ? AND trade_date >= ? - """, - (end_date, dividend_start), - ).fetchone()[0] - fundamental_rows = connection.execute( - """ - SELECT COUNT(*) FROM fundamental_indicators fi - INNER JOIN ( - SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key - FROM fundamental_indicators - WHERE ann_date = '' OR ann_date <= ? - GROUP BY ts_code - ) latest - ON latest.ts_code = fi.ts_code - AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) - """, - (end_date,), - ).fetchone()[0] - moneyflow_dates = connection.execute( - """ - SELECT COUNT(DISTINCT trade_date) - FROM moneyflow_daily - WHERE trade_date IN ( - SELECT DISTINCT trade_date - FROM daily_bars - WHERE trade_date <= ? - ORDER BY trade_date DESC - LIMIT 5 - ) - """, - (end_date,), - ).fetchone()[0] - earnings_rows = connection.execute( - """ - SELECT COUNT(*) FROM earnings_events - WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '') - """, - (end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"), - ).fetchone()[0] - popularity_rows = connection.execute( - "SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?", - (end_date,), - ).fetchone()[0] - institution_rows = connection.execute( - "SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?", - (end_date,), - ).fetchone()[0] - return { - "market": bool(market), - "auction": bool(auction), - "benchmark": int(benchmark_rows or 0) >= 60, - "benchmark_rows": int(benchmark_rows or 0), - "valuation": bool(valuation_available), - "fundamental": int(fundamental_rows or 0) >= 100, - "dividend_history": int(dividend_years or 0) >= 4, - "valuation_rows": int(valuation_rows or 0), - "fundamental_rows": int(fundamental_rows or 0), - "dividend_years": int(dividend_years or 0), - "moneyflow_history": int(moneyflow_dates or 0) >= 5, - "moneyflow_dates": int(moneyflow_dates or 0), - "earnings_events": int(earnings_rows or 0) > 0, - "earnings_event_rows": int(earnings_rows or 0), - "popularity": int(popularity_rows or 0) > 0, - "popularity_rows": int(popularity_rows or 0), - "institutions": int(institution_rows or 0) > 0, - "institution_rows": int(institution_rows or 0), - } - - def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]: - dates = self.factor_dates(end_date, limit_dates) - if not dates: - return { - "dates": [], "bars": [], "master": [], "indicators": [], - "indicator_history": [], "indicator_series": [], "fundamentals": [], - "moneyflow": [], "moneyflow_history": [], "auction": [], - "benchmarks": [], "fundamental_history": [], - "earnings_events": [], "popularity": [], "institutions": [], - } - placeholders = ",".join("?" for _ in dates) - with self.connect() as connection: - bars = connection.execute( - f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code", - dates, - ).fetchall() - master = connection.execute("SELECT * FROM stock_master").fetchall() - indicators = connection.execute( - """ - SELECT * FROM daily_indicators - WHERE trade_date = ( - SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ? - ) - """, - (end_date,), - ).fetchall() - indicator_history = connection.execute( - """ - SELECT di.* FROM daily_indicators di - INNER JOIN ( - SELECT ts_code, substr(trade_date, 1, 4) AS year_key, - MAX(trade_date) AS max_date - FROM daily_indicators - WHERE trade_date <= ? AND trade_date >= ? - GROUP BY ts_code, substr(trade_date, 1, 4) - ) latest - ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date - ORDER BY di.trade_date, di.ts_code - """, - (end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"), - ).fetchall() - indicator_series = connection.execute( - f""" - SELECT trade_date, ts_code, turnover_rate, volume_ratio, - total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm - FROM daily_indicators - WHERE trade_date IN ({placeholders}) - ORDER BY trade_date, ts_code - """, - dates, - ).fetchall() - fundamentals = connection.execute( - """ - SELECT fi.* FROM fundamental_indicators fi - INNER JOIN ( - SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key - FROM fundamental_indicators - WHERE ann_date = '' OR ann_date <= ? - GROUP BY ts_code - ) latest - ON latest.ts_code = fi.ts_code - AND latest.latest_key = (fi.ann_date || ':' || fi.end_date) - """, - (end_date,), - ).fetchall() - fundamental_history = connection.execute( - """ - SELECT * FROM fundamental_indicators - WHERE ann_date = '' OR ann_date <= ? - ORDER BY ann_date, end_date, ts_code - """, - (end_date,), - ).fetchall() - moneyflow = connection.execute( - """ - SELECT * FROM moneyflow_daily - WHERE trade_date = ( - SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ? - ) - """, - (end_date,), - ).fetchall() - flow_dates = dates[-min(5, len(dates)):] - flow_placeholders = ",".join("?" for _ in flow_dates) - moneyflow_history = connection.execute( - f""" - SELECT * FROM moneyflow_daily - WHERE trade_date IN ({flow_placeholders}) - ORDER BY trade_date, ts_code - """, - flow_dates, - ).fetchall() - auction = connection.execute( - """ - SELECT * FROM auction_factors - WHERE trade_date = ( - SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ? - ) - """, - (end_date,), - ).fetchall() - benchmarks = connection.execute( - f""" - SELECT * FROM benchmark_bars - WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders}) - ORDER BY trade_date - """, - dates, - ).fetchall() - earnings_events = connection.execute( - """ - SELECT * FROM earnings_events - WHERE ann_date <= ? - ORDER BY ann_date, end_date, ts_code - """, - (end_date,), - ).fetchall() - popularity = connection.execute( - "SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code", - (end_date,), - ).fetchall() - institutions = connection.execute( - "SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code", - (end_date,), - ).fetchall() - return { - "dates": dates, - "bars": [dict(row) for row in bars], - "master": [dict(row) for row in master], - "indicators": [dict(row) for row in indicators], - "indicator_history": [dict(row) for row in indicator_history], - "indicator_series": [dict(row) for row in indicator_series], - "fundamentals": [dict(row) for row in fundamentals], - "fundamental_history": [dict(row) for row in fundamental_history], - "moneyflow": [dict(row) for row in moneyflow], - "moneyflow_history": [dict(row) for row in moneyflow_history], - "auction": [dict(row) for row in auction], - "benchmarks": [dict(row) for row in benchmarks], - "earnings_events": [dict(row) for row in earnings_events], - "popularity": [dict(row) for row in popularity], - "institutions": [dict(row) for row in institutions], - } - - def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]: - try: - from sentiment_engine import build_sentiment_history - except ModuleNotFoundError: - from .sentiment_engine import build_sentiment_history - - series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260)) - return [ - { - "trade_date": row["trade_date"], - "sentiment_score": row["score"], - "seal_rate": row["seal_rate"], - "limit_up_count": row["limit_up_count"], - "limit_down_count": row["limit_down_count"], - "broken_count": row["broken_count"], - "up_count": row["up_count"], - "down_count": row["down_count"], - "amount_billion": row["amount_billion"], - } - for row in series[-limit:] - ] - - - def save_screener_strategy( - self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any], - builtin: bool = False, strategy_id: int | None = None, - ) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - regimes_json = json.dumps(regimes, ensure_ascii=False) - formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":")) - with self.connect() as connection: - if strategy_id: - if builtin: - cursor = connection.execute( - """ - UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, - builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1 - """, - (name, description, regimes_json, formula_json, now, strategy_id), - ) - else: - cursor = connection.execute( - """ - UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, - updated_at=? WHERE id=? AND builtin=0 AND user_id=? - """, - (name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)), - ) - if cursor.rowcount == 0: - raise ValueError("选股策略不存在。") - return strategy_id - cursor = connection.execute( - """ - INSERT INTO screener_strategies - (user_id, name, description, regimes, formula, builtin, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now), - ) - return int(cursor.lastrowid) - - def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]: - with self.connect() as connection: - if user_id is None: - rows = connection.execute( - "SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id" - ).fetchall() - else: - rows = connection.execute( - """ - SELECT * FROM screener_strategies - WHERE builtin = 1 OR user_id = ? - ORDER BY builtin DESC, updated_at DESC, id - """, - (int(user_id),), - ).fetchall() - result = [] - for row in rows: - item = dict(row) - item["regimes"] = json.loads(item["regimes"]) - item["formula"] = json.loads(item["formula"]) - item["builtin"] = bool(item["builtin"]) - result.append(item) - return result - - def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool: - with self.connect() as connection: - row = connection.execute( - "SELECT builtin, user_id FROM screener_strategies WHERE id = ?", - (strategy_id,), - ).fetchone() - if not row: - raise ValueError("选股策略不存在。") - if bool(row["builtin"]): - raise ValueError("内置策略不能删除。") - if int(row["user_id"] or 0) != int(user_id): - raise ValueError("无权删除其他账号的策略。") - cursor = connection.execute( - "DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?", - (strategy_id, int(user_id)), - ) - return cursor.rowcount > 0 - - def save_screener_run( - self, user_id: int, trade_date: str, regime: str, strategy_name: str, - formula: dict[str, Any], result: dict[str, Any], mode: str = "smart", - ) -> int: - normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart" - now = datetime.now().astimezone().isoformat(timespec="seconds") - with self.connect() as connection: - cursor = connection.execute( - """ - INSERT INTO screener_runs - (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (None if int(user_id) == 0 else int(user_id), trade_date, regime, - normalized_mode, strategy_name, - json.dumps(formula, ensure_ascii=False, separators=(",", ":")), - json.dumps(result, ensure_ascii=False, separators=(",", ":")), now), - ) - return int(cursor.lastrowid) - - @staticmethod - def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None: - try: - result = json.loads(row["result"]) - except json.JSONDecodeError: - return None - result.setdefault("meta", {}).update( - { - "run_id": int(row["id"]), - "trade_date": str(row["trade_date"] or ""), - "regime": str(row["regime"] or ""), - "mode": str(row["mode"] or "smart"), - "strategy_name": str(row["strategy_name"] or ""), - "created_at": row["created_at"], - } - ) - return result - - def latest_screener_run( - self, user_id: int, trade_date: str, mode: str = "", - ) -> dict[str, Any] | None: - owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" - parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) - parameters += (trade_date,) - mode_clause = "" - if mode in {"smart", "curated", "quant"}: - mode_clause = " AND mode = ?" - parameters += (mode,) - with self.connect() as connection: - row = connection.execute( - f""" - SELECT id, trade_date, regime, mode, strategy_name, result, created_at - FROM screener_runs - WHERE {owner_clause} AND trade_date <= ?{mode_clause} - ORDER BY id DESC LIMIT 1 - """, - parameters, - ).fetchone() - return self._screener_run_payload(row) if row else None - - def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]: - owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" - parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) - parameters += (trade_date,) - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT runs.id, runs.trade_date, runs.regime, runs.mode, - runs.strategy_name, runs.result, runs.created_at - FROM screener_runs runs - INNER JOIN ( - SELECT mode, MAX(id) AS id - FROM screener_runs - WHERE {owner_clause} AND trade_date <= ? - GROUP BY mode - ) latest ON latest.id = runs.id - """, - parameters, - ).fetchall() - results: dict[str, dict[str, Any]] = {} - for row in rows: - mode = str(row["mode"] or "smart") - payload = self._screener_run_payload(row) - if mode in {"smart", "curated", "quant"} and payload: - results[mode] = payload - return results - - def latest_screener_context_runs( - self, user_id: int, trade_date: str, limit: int = 60, - ) -> list[dict[str, Any]]: - safe_limit = max(1, min(120, int(limit))) - owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" - parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) - parameters += (trade_date, safe_limit) - with self.connect() as connection: - rows = connection.execute( - f""" - WITH ranked AS ( - SELECT id, trade_date, regime, mode, strategy_name, result, created_at, - ROW_NUMBER() OVER ( - PARTITION BY - mode, - CASE WHEN mode = 'smart' THEN regime ELSE '' END, - CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END - ORDER BY id DESC - ) AS context_rank - FROM screener_runs - WHERE {owner_clause} AND trade_date <= ? - ) - SELECT id, trade_date, regime, mode, strategy_name, result, created_at - FROM ranked - WHERE context_rank = 1 - ORDER BY id DESC - LIMIT ? - """, - parameters, - ).fetchall() - return [ - payload - for row in rows - if (payload := self._screener_run_payload(row)) is not None - ] - - def screener_runs_for_date( - self, user_id: int, trade_date: str, limit: int = 80, - ) -> list[dict[str, Any]]: - safe_limit = max(1, min(160, int(limit))) - owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" - parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),) - parameters += (trade_date, safe_limit) - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT id, trade_date, regime, mode, strategy_name, result, created_at - FROM screener_runs - WHERE {owner_clause} AND trade_date = ? - ORDER BY id DESC - LIMIT ? - """, - parameters, - ).fetchall() - result = [] - seen: set[tuple[str, str, str]] = set() - for row in rows: - key = ( - str(row["mode"] or "smart"), - str(row["regime"] or ""), - str(row["strategy_name"] or ""), - ) - if key in seen: - continue - seen.add(key) - payload = self._screener_run_payload(row) - if payload is not None: - result.append(payload) - return result - - def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None: - owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?" - parameters: tuple[Any, ...] = (int(run_id),) - if int(user_id) != 0: - parameters += (int(user_id),) - with self.connect() as connection: - row = connection.execute( - f""" - SELECT id, trade_date, regime, mode, strategy_name, result, created_at - FROM screener_runs WHERE id = ? AND {owner_clause} - """, - parameters, - ).fetchone() - if not row: - return None - result = self._screener_run_payload(row) - if result is None: - return None - result.setdefault("meta", {}).update( - { - "run_id": int(row["id"]), - "trade_date": row["trade_date"], - "mode": str(row["mode"] or "smart"), - "created_at": row["created_at"], - } - ) - result["strategy_name"] = row["strategy_name"] - result["regime"] = row["regime"] - return result - def save_mentor_exchange( self, user_id: int, @@ -1799,97 +1016,6 @@ class ReviewDatabase( ) return cursor.rowcount > 0 - def save_strategy_tracks( - self, - user_id: int, - run_id: int, - selection_date: str, - strategy_name: str, - candidates: list[dict[str, Any]], - ) -> int: - now = datetime.now().astimezone().isoformat(timespec="seconds") - values = [] - for item in candidates: - ts_code = str(item.get("ts_code") or "").strip() - code = str(item.get("code") or ts_code.split(".")[0]).strip() - entry_price = float(item.get("price") or 0) - if not ts_code or not code or entry_price <= 0: - continue - values.append( - ( - int(user_id), int(run_id), selection_date, strategy_name, ts_code, code, - str(item.get("name") or "--"), str(item.get("sector") or "其他"), - entry_price, now, now, - ) - ) - with self.connect() as connection: - connection.executemany( - """ - INSERT INTO strategy_tracks - (user_id, run_id, selection_date, strategy_name, ts_code, code, - name, sector, entry_price, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET - name=excluded.name, sector=excluded.sector, - entry_price=excluded.entry_price, updated_at=excluded.updated_at - """, - values, - ) - return len(values) - - def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]: - limit_batches = max(1, min(50, int(limit_batches))) - with self.connect() as connection: - rows = connection.execute( - """ - SELECT * FROM strategy_tracks - WHERE user_id = ? AND run_id IN ( - SELECT run_id FROM strategy_tracks WHERE user_id = ? - GROUP BY run_id ORDER BY run_id DESC LIMIT ? - ) - ORDER BY run_id DESC, id - """, - (int(user_id), int(user_id), limit_batches), - ).fetchall() - return [dict(row) for row in rows] - - def delete_strategy_track(self, user_id: int, track_id: int) -> bool: - with self.connect() as connection: - cursor = connection.execute( - "DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?", - (int(track_id), int(user_id)), - ) - return cursor.rowcount > 0 - - def load_tracking_bars( - self, targets: list[tuple[str, str]], limit: int = 5 - ) -> dict[tuple[str, str], list[dict[str, Any]]]: - unique_targets = set(targets) - if not unique_targets: - return {} - codes = sorted({ts_code for ts_code, _ in unique_targets}) - earliest_date = min(selection_date for _, selection_date in unique_targets) - placeholders = ",".join("?" for _ in codes) - with self.connect() as connection: - rows = connection.execute( - f""" - SELECT ts_code, trade_date, open, high, low, close FROM daily_bars - WHERE ts_code IN ({placeholders}) AND trade_date > ? - ORDER BY ts_code, trade_date - """, - [*codes, earliest_date], - ).fetchall() - by_code: dict[str, list[dict[str, Any]]] = {} - for row in rows: - item = dict(row) - by_code.setdefault(str(item["ts_code"]), []).append(item) - row_limit = max(1, min(20, int(limit))) - return { - (ts_code, selection_date): [ - row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date - ][:row_limit] - for ts_code, selection_date in unique_targets - } def save_alert( self, diff --git a/app/llm_strategy.py b/app/llm_strategy.py index 0d8716f..5e76bbf 100644 --- a/app/llm_strategy.py +++ b/app/llm_strategy.py @@ -1,146 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical strategy compiler implementation.""" -import json -import time -import urllib.error -import urllib.request -from typing import Any +import sys -from screener import FACTOR_FIELDS, REGIMES +from backend.features.screener import compiler as _implementation - -class LLMCompilerError(RuntimeError): - pass - - -def test_llm_connection( - api_key: str, - base_url: str, - model: str, - timeout: int = 30, -) -> dict[str, Any]: - if not api_key or not model: - raise LLMCompilerError("API Key 或模型未配置。") - endpoint = f"{base_url.rstrip('/')}/chat/completions" - payload = json.dumps( - { - "model": model, - "messages": [{"role": "user", "content": "只回复 OK"}], - "stream": False, - }, - ensure_ascii=False, - ).encode("utf-8") - request = urllib.request.Request( - endpoint, - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": "XiaobaiReviewWeb/0.5", - }, - method="POST", - ) - started = time.perf_counter() - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - result = json.loads(response.read().decode("utf-8")) - reply = str(result["choices"][0]["message"]["content"]).strip() - except urllib.error.HTTPError as exc: - raise LLMCompilerError(_http_error_message(exc)) from exc - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: - raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc - return { - "ok": True, - "model": model, - "reply": reply[:100], - "latency_ms": round((time.perf_counter() - started) * 1000), - } - - -def compile_strategy_with_llm( - prompt: str, - regime: str, - api_key: str, - base_url: str, - model: str, - timeout: int = 45, -) -> dict[str, Any]: - if not api_key or not model: - raise LLMCompilerError("尚未配置 LLM API Key 或模型。") - endpoint = f"{base_url.rstrip('/')}/chat/completions" - schema = { - "name": "策略名称", - "description": "策略说明", - "regimes": [regime], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [{"field": "return_5d", "op": ">=", "value": 0}], - "score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}], - "limit": 15, - "min_score": 0.55, - }, - } - system_prompt = ( - "你是A股量化策略编译器。只输出JSON对象,不输出Markdown。" - "不得生成Python、SQL、网络请求或未提供的因子。" - f"当前市场阶段为{REGIMES.get(regime, regime)}。" - f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。" - "运算符只能使用 >, >=, <, <=, ==, !=, between, in。" - "score权重均大于0且不超过1,direction只能是asc或desc。" - "退潮和冰点策略必须提高门槛并允许结果为空。" - f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}" - ) - payload = json.dumps( - { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt[:3000]}, - ], - "stream": False, - }, - ensure_ascii=False, - ).encode("utf-8") - request = urllib.request.Request( - endpoint, - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": "XiaobaiReviewWeb/0.4", - }, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=timeout) as response: - result = json.loads(response.read().decode("utf-8")) - content = result["choices"][0]["message"]["content"].strip() - if content.startswith("```"): - content = content.strip("`") - if content.startswith("json"): - content = content[4:].strip() - compiled = json.loads(content) - except urllib.error.HTTPError as exc: - raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc: - raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc - compiled["compiler"] = "llm" - compiled["model"] = model - return compiled - - -def _http_error_message(exc: urllib.error.HTTPError) -> str: - detail = "" - try: - payload = json.loads(exc.read().decode("utf-8", errors="replace")) - error = payload.get("error") - if isinstance(error, dict): - detail = str(error.get("message") or error.get("code") or "") - elif error: - detail = str(error) - elif payload.get("message"): - detail = str(payload["message"]) - except (json.JSONDecodeError, OSError): - detail = "" - suffix = f":{detail[:300]}" if detail else "" - return f"模型连接测试失败(HTTP {exc.code}){suffix}" +sys.modules[__name__] = _implementation diff --git a/app/screener.py b/app/screener.py index d0796db..5e5cf1d 100644 --- a/app/screener.py +++ b/app/screener.py @@ -1,2213 +1,7 @@ -from __future__ import annotations +"""Compatibility alias for the canonical screener engine implementation.""" -import copy -import json -import math -import statistics -from collections import defaultdict -from datetime import datetime, timedelta -from typing import Any +import sys -from advanced_strategies import ADVANCED_CURATED_STRATEGIES -from database import ReviewDatabase -from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history -from tushare_client import TushareClient, TushareError +from backend.features.screener import engine as _implementation - -REGIMES = { - "ice": "冰点", - "repair": "修复", - "fermentation": "发酵", - "climax": "高潮", - "divergence": "分化", - "retreat": "退潮", -} - -FACTOR_FIELDS = { - "close": "收盘价", - "pct_chg": "当日涨幅", - "return_5d": "5日涨幅", - "return_10d": "10日涨幅", - "return_20d": "20日涨幅", - "return_60d": "60日涨幅", - "return_5d_rank": "5日涨幅排名", - "momentum_60_5": "中期动量", - "momentum_60_5_rank": "中期动量排名", - "above_ma20": "站上20日线", - "rsi_6": "RSI(6)", - "ma60_slope": "60日线斜率", - "ma20_slope_5d": "20日线5日斜率", - "ma_bull_alignment": "均线多头排列", - "drawdown_from_high_250": "距250日高点回撤", - "donchian_breakout_pct": "唐奇安突破幅度", - "range_20d": "20日振幅", - "rs_high_120": "RS线120日新高", - "excess_return_60d": "60日超额收益", - "weekly_trend_signal": "周线趋势信号", - "daily_buy_trigger": "日线买点", - "weekly_amount_trend": "周成交趋势", - "volume_ratio_5d": "5日量比", - "turnover_5d": "5日累计换手", - "volatility_10d": "10日波动率", - "amount_billion": "成交额", - "turnover_rate": "换手率", - "circ_mv_billion": "流通市值", - "net_flow_million": "主力净流入", - "large_flow_million": "大单净流入", - "net_flow_5d_million": "5日主力净流入", - "flow_to_circ_mv_5d": "5日净流入占流通市值", - "sector_strength": "板块强度", - "sector_return_5d": "行业5日涨幅", - "sector_return_20d": "行业20日涨幅", - "sector_momentum_rank": "行业20日动量排名", - "sector_stock_momentum_rank": "行业内个股动量排名", - "sector_net_flow_5d_million": "行业5日主力净流入", - "sector_flow_rank": "行业资金流排名", - "sector_prosperity_rank": "行业景气度排名", - "sector_trend_rank": "行业趋势排名", - "sector_crowding_rank": "行业拥挤度排名", - "sector_composite_score": "行业三维综合分", - "sector_limit_count": "板块涨停数", - "sector_up_count": "板块强势股数", - "relative_strength": "相对强度", - "limit_streak": "连板高度", - "auction_change": "竞价涨幅", - "auction_amount_million": "竞价成交额", - "auction_turnover_rate": "竞价换手率", - "auction_volume_ratio": "竞价量比", - "total_mv_billion": "总市值", - "pe_ttm": "市盈率TTM", - "pb": "市净率", - "ps_ttm": "市销率TTM", - "dividend_yield_ttm": "股息率TTM", - "dividend_years": "近年持续分红", - "roe": "净资产收益率", - "roa": "总资产收益率", - "roic": "投入资本回报率", - "gross_margin": "销售毛利率", - "netprofit_yoy": "净利润同比", - "revenue_yoy": "营业收入同比", - "ocf_to_opincome": "经营现金流质量", - "earnings_surprise_pct": "业绩超预期幅度", - "earnings_days_since_announce": "业绩公告后天数", - "earnings_event_quality": "业绩事件质量", - "popularity_score": "人气榜热度", - "popularity_rank_change": "人气排名跃升", - "popularity_dual_source": "双榜共识", - "institution_net_buy_million": "机构席位净买入", - "institution_seat_count": "机构席位数", - "style_size_fit": "大小盘风格匹配", - "style_growth_fit": "成长价值风格匹配", - "style_fit_score": "当前风格匹配度", - "factor_value_score": "价值因子分", - "factor_growth_score": "成长因子分", - "factor_quality_score": "质量因子分", - "factor_momentum_score": "动量因子分", - "factor_sentiment_score": "交易情绪因子分", - "multi_factor_composite": "动态多因子综合分", - "relative_position_60": "60日相对位置", - "max_abs_change_15d": "15日最大波动", - "close_to_high_15d": "距15日高点", - "close_to_high_60d": "距60日高点", - "no_limit_30d": "近30日无涨停", - "had_limit_80d": "近80日曾涨停", - "previous_first_limit": "昨日首板", - "previous_limit_signal": "昨日涨停或触板", - "previous_limit_streak": "昨日连板高度", - "previous_amount_billion": "昨日成交额", - "is_limit_up_today": "当日涨停", - "is_limit_down_today": "当日跌停", - "sector_breadth_ma20": "行业20日线宽度", - "no_limit_down_20d": "近20日无跌停", - "financial_risk": "财务风险标记", - "is_market_height": "当前市场最高板", - "new_space_board": "新晋空间板", - "max_continuous_board_10d": "近10日最高连板", - "dragon_first_yin": "龙头首阴", - "yin_day_pct": "首阴跌幅", - "vol_vs_previous": "较前日量能", - "broken_reversal": "断板反包", - "days_since_broken": "断板后天数", - "close_above_broken_high": "收复断板高点", - "vol_vs_broken_day": "较断板日量能", - "recent_limit_up_5d": "近5日涨停次数", - "intraday_min_pct": "盘中最大跌幅", - "lower_shadow_ratio": "下影线实体比", -} - -FACTOR_GROUPS = { - "行情动量": [ - "close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d", - "return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20", - "rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment", - "drawdown_from_high_250", "donchian_breakout_pct", "range_20d", - "rs_high_120", "excess_return_60d", "weekly_trend_signal", - "daily_buy_trigger", "weekly_amount_trend", "relative_strength", - "relative_position_60", "close_to_high_15d", "close_to_high_60d", - ], - "量价交易": [ - "volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate", - "net_flow_million", "large_flow_million", "net_flow_5d_million", - "flow_to_circ_mv_5d", "previous_amount_billion", - "intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day", - ], - "板块结构": [ - "sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank", - "sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank", - "sector_prosperity_rank", "sector_trend_rank", "sector_crowding_rank", - "sector_composite_score", - "sector_limit_count", "sector_up_count", "sector_breadth_ma20", - "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal", - "is_limit_up_today", "is_limit_down_today", - "no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d", - "is_market_height", "new_space_board", "max_continuous_board_10d", - "dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken", - "close_above_broken_high", "recent_limit_up_5d", - ], - "竞价因子": [ - "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", - ], - "估值规模": [ - "circ_mv_billion", "total_mv_billion", "pe_ttm", "pb", "ps_ttm", - "dividend_yield_ttm", "dividend_years", - ], - "财务质量": [ - "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", - "ocf_to_opincome", "financial_risk", - "earnings_surprise_pct", "earnings_days_since_announce", "earnings_event_quality", - ], - "特色数据": [ - "popularity_score", "popularity_rank_change", "popularity_dual_source", - "institution_net_buy_million", "institution_seat_count", - "style_size_fit", "style_growth_fit", "style_fit_score", - "factor_value_score", "factor_growth_score", "factor_quality_score", - "factor_momentum_score", "factor_sentiment_score", "multi_factor_composite", - ], -} - -ALLOWED_OPERATORS = {">", ">=", "<", "<=", "==", "!=", "between", "in"} - - -BUILTIN_STRATEGIES = [ - { - "name": "冰点抗跌先手", - "description": "寻找冰点中保持相对强度、低波动且有板块承接的个股,允许无结果。", - "regimes": ["ice"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [-3, 7]}, - {"field": "return_5d", "op": ">=", "value": -5}, - {"field": "amount_billion", "op": ">=", "value": 1}, - {"field": "volatility_10d", "op": "<=", "value": 7}, - ], - "score": [ - {"field": "relative_strength", "weight": 0.30, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.25, "direction": "desc"}, - {"field": "volume_ratio_5d", "weight": 0.20, "direction": "desc"}, - {"field": "volatility_10d", "weight": 0.15, "direction": "asc"}, - {"field": "amount_billion", "weight": 0.10, "direction": "desc"}, - ], - "limit": 12, - "min_score": 0.58, - }, - }, - { - "name": "修复先锋", - "description": "筛选率先站回趋势、温和放量并获得板块共振的修复前排。", - "regimes": ["repair"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [1, 9.7]}, - {"field": "return_5d", "op": ">", "value": 0}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "volume_ratio_5d", "op": ">=", "value": 1.05}, - ], - "score": [ - {"field": "sector_strength", "weight": 0.28, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.24, "direction": "desc"}, - {"field": "volume_ratio_5d", "weight": 0.18, "direction": "desc"}, - {"field": "net_flow_million", "weight": 0.16, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.14, "direction": "desc"}, - ], - "limit": 15, - "min_score": 0.54, - }, - }, - { - "name": "主线发酵跟随", - "description": "在主线扩散期寻找趋势、成交承载和板块涨停梯队共同增强的个股。", - "regimes": ["fermentation"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [0, 9.8]}, - {"field": "return_5d", "op": ">=", "value": 3}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "amount_billion", "op": ">=", "value": 2}, - ], - "score": [ - {"field": "sector_limit_count", "weight": 0.25, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, - {"field": "return_10d", "weight": 0.20, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.16, "direction": "desc"}, - {"field": "large_flow_million", "weight": 0.15, "direction": "desc"}, - ], - "limit": 15, - "min_score": 0.55, - }, - }, - { - "name": "高潮核心去后排", - "description": "高潮阶段只保留容量、趋势和辨识度较高的核心,降低后排跟风权重。", - "regimes": ["climax"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [-2, 7]}, - {"field": "return_10d", "op": ">=", "value": 5}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "amount_billion", "op": ">=", "value": 5}, - ], - "score": [ - {"field": "amount_billion", "weight": 0.28, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.22, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, - {"field": "volatility_10d", "weight": 0.15, "direction": "asc"}, - {"field": "limit_streak", "weight": 0.15, "direction": "desc"}, - ], - "limit": 10, - "min_score": 0.62, - }, - }, - { - "name": "分化承接回流", - "description": "寻找分化中仍有趋势承接、板块强度和资金回流的核心候选。", - "regimes": ["divergence"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [-3, 7]}, - {"field": "return_5d", "op": ">", "value": 0}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "volume_ratio_5d", "op": "between", "value": [0.7, 3.5]}, - ], - "score": [ - {"field": "relative_strength", "weight": 0.28, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, - {"field": "net_flow_million", "weight": 0.20, "direction": "desc"}, - {"field": "volatility_10d", "weight": 0.16, "direction": "asc"}, - {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, - ], - "limit": 12, - "min_score": 0.57, - }, - }, - { - "name": "退潮防守观察", - "description": "退潮期采用高门槛防守筛选,结果为空代表当前不宜主动出击。", - "regimes": ["retreat"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "pct_chg", "op": "between", "value": [-2, 4]}, - {"field": "return_5d", "op": ">=", "value": -2}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "volatility_10d", "op": "<=", "value": 4.5}, - {"field": "amount_billion", "op": ">=", "value": 2}, - ], - "score": [ - {"field": "volatility_10d", "weight": 0.30, "direction": "asc"}, - {"field": "relative_strength", "weight": 0.25, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.20, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.15, "direction": "desc"}, - {"field": "net_flow_million", "weight": 0.10, "direction": "desc"}, - ], - "limit": 8, - "min_score": 0.68, - }, - }, - { - "name": "竞价强势确认", - "description": "用竞价涨幅、成交承载和量比确认修复或发酵阶段的主动进攻标的。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "auction_change", "op": "between", "value": [1, 7]}, - {"field": "auction_amount_million", "op": ">=", "value": 3}, - {"field": "auction_volume_ratio", "op": ">=", "value": 0.8}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "auction_amount_million", "weight": 0.26, "direction": "desc"}, - {"field": "auction_volume_ratio", "weight": 0.22, "direction": "desc"}, - {"field": "auction_change", "weight": 0.18, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.18, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.16, "direction": "desc"}, - ], - "limit": 15, - "min_score": 0.56, - }, - }, -] - -for _strategy in BUILTIN_STRATEGIES: - _strategy["formula"].setdefault("meta", { - "library": "smart", "category": "周期策略", "quality": "系统", - "frequency": "每日", "risk": "随市场阶段", "data_group": "行情因子", - }) - - -CURATED_STRATEGIES = [ - { - "name": "连续分红质量", - "description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。", - "regimes": list(REGIMES), - "formula": { - "meta": {"library": "curated", "category": "红利价值", "quality": "A", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, - "universe": {"exclude_st": True, "listed_days_min": 1095}, - "filters": [ - {"field": "dividend_years", "op": ">=", "value": 4}, - {"field": "dividend_yield_ttm", "op": ">=", "value": 2}, - {"field": "roe", "op": ">=", "value": 6}, - {"field": "pb", "op": "between", "value": [0.1, 4]}, - ], - "score": [ - {"field": "dividend_yield_ttm", "weight": 0.30, "direction": "desc"}, - {"field": "roe", "weight": 0.24, "direction": "desc"}, - {"field": "ocf_to_opincome", "weight": 0.18, "direction": "desc"}, - {"field": "volatility_10d", "weight": 0.16, "direction": "asc"}, - {"field": "total_mv_billion", "weight": 0.12, "direction": "desc"}, - ], "limit": 20, "min_score": 0.52, - }, - }, - { - "name": "ROIC质量低波", - "description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。", - "regimes": ["ice", "repair", "divergence", "retreat"], - "formula": { - "meta": {"library": "curated", "category": "质量价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, - "universe": {"exclude_st": True, "listed_days_min": 730}, - "filters": [ - {"field": "roic", "op": ">=", "value": 6}, - {"field": "gross_margin", "op": ">=", "value": 15}, - {"field": "pe_ttm", "op": "between", "value": [1, 45]}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "roic", "weight": 0.28, "direction": "desc"}, - {"field": "gross_margin", "weight": 0.22, "direction": "desc"}, - {"field": "ps_ttm", "weight": 0.18, "direction": "asc"}, - {"field": "volatility_10d", "weight": 0.18, "direction": "asc"}, - {"field": "total_mv_billion", "weight": 0.14, "direction": "desc"}, - ], "limit": 20, "min_score": 0.54, - }, - }, - { - "name": "低估值现金流白马", - "description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。", - "regimes": ["ice", "repair", "divergence", "retreat"], - "formula": { - "meta": {"library": "curated", "category": "现金流价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"}, - "universe": {"exclude_st": True, "listed_days_min": 730}, - "filters": [ - {"field": "pb", "op": "between", "value": [0.1, 1.8]}, - {"field": "roa", "op": ">=", "value": 3}, - {"field": "ocf_to_opincome", "op": ">", "value": 0}, - {"field": "netprofit_yoy", "op": ">=", "value": -15}, - {"field": "total_mv_billion", "op": ">=", "value": 100}, - ], - "score": [ - {"field": "roa", "weight": 0.26, "direction": "desc"}, - {"field": "ocf_to_opincome", "weight": 0.24, "direction": "desc"}, - {"field": "pb", "weight": 0.20, "direction": "asc"}, - {"field": "total_mv_billion", "weight": 0.16, "direction": "desc"}, - {"field": "volatility_10d", "weight": 0.14, "direction": "asc"}, - ], "limit": 20, "min_score": 0.53, - }, - }, - { - "name": "高增长合理估值", - "description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "成长质量", "quality": "B+", "frequency": "月度", "risk": "中", "data_group": "估值与财务"}, - "universe": {"exclude_st": True, "listed_days_min": 365}, - "filters": [ - {"field": "pe_ttm", "op": "between", "value": [1, 35]}, - {"field": "revenue_yoy", "op": ">=", "value": 10}, - {"field": "netprofit_yoy", "op": ">=", "value": 15}, - {"field": "roe", "op": ">=", "value": 5}, - {"field": "amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "netprofit_yoy", "weight": 0.27, "direction": "desc"}, - {"field": "revenue_yoy", "weight": 0.23, "direction": "desc"}, - {"field": "roe", "weight": 0.20, "direction": "desc"}, - {"field": "pe_ttm", "weight": 0.16, "direction": "asc"}, - {"field": "relative_strength", "weight": 0.14, "direction": "desc"}, - ], "limit": 20, "min_score": 0.55, - }, - }, - { - "name": "行业宽度主线", - "description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。", - "regimes": ["repair", "fermentation", "climax", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "行业轮动", "quality": "B+", "frequency": "每周", "risk": "中", "data_group": "行情与行业"}, - "universe": {"exclude_st": True, "listed_days_min": 180}, - "filters": [ - {"field": "sector_breadth_ma20", "op": ">=", "value": 55}, - {"field": "sector_strength", "op": ">=", "value": 55}, - {"field": "above_ma20", "op": "==", "value": 1}, - {"field": "amount_billion", "op": ">=", "value": 2}, - ], - "score": [ - {"field": "sector_breadth_ma20", "weight": 0.28, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.24, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, - {"field": "sector_limit_count", "weight": 0.16, "direction": "desc"}, - {"field": "amount_billion", "weight": 0.12, "direction": "desc"}, - ], "limit": 20, "min_score": 0.56, - }, - }, - { - "name": "首板低开", - "description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。", - "regimes": ["ice", "repair", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "短线竞价", "quality": "B+", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"}, - "universe": {"exclude_st": True, "listed_days_min": 250}, - "filters": [ - {"field": "previous_first_limit", "op": "==", "value": 1}, - {"field": "auction_change", "op": "between", "value": [-4.5, -2.5]}, - {"field": "relative_position_60", "op": "<=", "value": 0.55}, - {"field": "previous_amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"}, - {"field": "previous_amount_billion", "weight": 0.24, "direction": "desc"}, - {"field": "relative_position_60", "weight": 0.20, "direction": "asc"}, - {"field": "sector_strength", "weight": 0.16, "direction": "desc"}, - {"field": "auction_volume_ratio", "weight": 0.12, "direction": "desc"}, - ], "limit": 12, "min_score": 0.50, - }, - }, - { - "name": "小碎步临界突破", - "description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "形态突破", "quality": "B+", "frequency": "每日", "risk": "中高", "data_group": "历史行情"}, - "universe": {"exclude_st": True, "listed_days_min": 250}, - "filters": [ - {"field": "no_limit_30d", "op": "==", "value": 1}, - {"field": "had_limit_80d", "op": "==", "value": 1}, - {"field": "max_abs_change_15d", "op": "<=", "value": 3}, - {"field": "close_to_high_15d", "op": ">=", "value": 0.98}, - {"field": "close_to_high_60d", "op": ">=", "value": 0.90}, - ], - "score": [ - {"field": "close_to_high_15d", "weight": 0.26, "direction": "desc"}, - {"field": "volume_ratio_5d", "weight": 0.22, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.20, "direction": "desc"}, - {"field": "max_abs_change_15d", "weight": 0.18, "direction": "asc"}, - {"field": "circ_mv_billion", "weight": 0.14, "direction": "asc"}, - ], "limit": 15, "min_score": 0.54, - }, - }, - { - "name": "连板龙头", - "description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。", - "regimes": ["fermentation", "climax", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "连板接力", "quality": "B", "frequency": "每日", "risk": "很高", "data_group": "涨停结构"}, - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "previous_limit_streak", "op": ">=", "value": 2}, - {"field": "previous_amount_billion", "op": ">=", "value": 1}, - ], - "score": [ - {"field": "previous_limit_streak", "weight": 0.34, "direction": "desc"}, - {"field": "sector_limit_count", "weight": 0.24, "direction": "desc"}, - {"field": "previous_amount_billion", "weight": 0.18, "direction": "desc"}, - {"field": "turnover_rate", "weight": 0.14, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.10, "direction": "desc"}, - ], "limit": 10, "min_score": 0.50, - }, - }, - { - "name": "微盘三正", - "description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。", - "regimes": ["repair", "fermentation"], - "formula": { - "meta": {"library": "curated", "category": "小盘质量", "quality": "B", "frequency": "每周", "risk": "高", "data_group": "估值与财务"}, - "universe": {"exclude_st": True, "listed_days_min": 365}, - "filters": [ - {"field": "pb", "op": ">", "value": 0}, - {"field": "roe", "op": ">", "value": 0}, - {"field": "ocf_to_opincome", "op": ">", "value": 0}, - {"field": "circ_mv_billion", "op": "between", "value": [5, 100]}, - {"field": "amount_billion", "op": ">=", "value": 0.5}, - ], - "score": [ - {"field": "circ_mv_billion", "weight": 0.32, "direction": "asc"}, - {"field": "roe", "weight": 0.24, "direction": "desc"}, - {"field": "ocf_to_opincome", "weight": 0.20, "direction": "desc"}, - {"field": "turnover_rate", "weight": 0.14, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.10, "direction": "desc"}, - ], "limit": 20, "min_score": 0.52, - }, - }, - { - "name": "首板高开弱转强", - "description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。", - "regimes": ["repair", "fermentation", "divergence"], - "formula": { - "meta": {"library": "curated", "category": "短线竞价", "quality": "B-", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"}, - "universe": {"exclude_st": True, "listed_days_min": 120}, - "filters": [ - {"field": "previous_limit_signal", "op": "==", "value": 1}, - {"field": "auction_change", "op": "between", "value": [1, 6]}, - {"field": "auction_volume_ratio", "op": ">=", "value": 0.8}, - {"field": "previous_amount_billion", "op": "between", "value": [3, 25]}, - ], - "score": [ - {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"}, - {"field": "auction_volume_ratio", "weight": 0.24, "direction": "desc"}, - {"field": "auction_change", "weight": 0.18, "direction": "desc"}, - {"field": "sector_strength", "weight": 0.17, "direction": "desc"}, - {"field": "relative_strength", "weight": 0.13, "direction": "desc"}, - ], "limit": 15, "min_score": 0.52, - }, - }, -] - -CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES) - -STRATEGY_ENVIRONMENT_NOTES = { - "连续分红质量": ( - "防守市、低利率环境与中长期配置窗口", - "风险偏好快速上升时,稳健资产的价格弹性通常落后", - ), - "ROIC质量低波": ( - "震荡偏弱、重视盈利质量与回撤控制的市场", - "主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向", - ), - "低估值现金流白马": ( - "估值修复、价值回归及防守配置阶段", - "低估值可能来自基本面持续走弱,需警惕价值陷阱", - ), - "高增长合理估值": ( - "业绩驱动、成长风格占优且趋势获得确认的阶段", - "增长预期下修或估值快速收缩时,回撤可能明显放大", - ), - "行业宽度主线": ( - "主线清晰、行业内部多数个股同步走强的行情", - "板块快速轮动时,宽度信号容易在确认后迅速衰减", - ), - "首板低开": ( - "情绪修复期的分歧转一致与首板次日承接", - "退潮加速或低开缺少量能承接时,弱势可能继续扩大", - ), - "小碎步临界突破": ( - "趋势蓄势、波动收敛后临近突破的结构市", - "无量突破或指数剧烈震荡时,容易形成冲高回落", - ), - "连板龙头": ( - "高度拓展、题材梯队完整且接力情绪活跃的阶段", - "亏钱效应扩散或高位股集中退潮时,接力风险很高", - ), - "微盘三正": ( - "小盘风格活跃、流动性宽松且风险偏好较高的行情", - "风格切向大盘或微盘流动性收缩时,组合波动会显著上升", - ), - "首板高开弱转强": ( - "竞价承接明确、短线情绪修复或主线发酵阶段", - "高开缺乏板块共振时,竞价强势可能转为盘中兑现", - ), - "中期动量·强者恒强": ( - "趋势延续、主升段及强弱分化清晰的行情", - "无趋势震荡或快速轮动中,动量信号容易反复失效", - ), - "强者回调": ( - "主升趋势未破、强势股完成良性回踩的窗口", - "趋势已反转时,回调信号可能演变为下跌中继", - ), - "超跌反转": ( - "急跌后恐慌释放充分、市场进入修复预期的阶段", - "单边下跌初段容易过早介入,超跌不等于止跌", - ), - "相对强度新高": ( - "指数偏弱但结构性主线明确,或机构抱团强化的行情", - "基准快速补涨或强势方向瓦解时,相对优势可能迅速消失", - ), - "均线多头排列": ( - "中期趋势向上、回撤有序的趋势市与主升段", - "高位趋势末端或宽幅震荡中,均线信号通常反应滞后", - ), - "唐奇安通道突破": ( - "整理末端、放量突破并启动新趋势的行情", - "无量突破和宽幅震荡环境中,假突破出现概率较高", - ), - "周线趋势·日线买点": ( - "中期趋势稳定、日线回踩或再启动的多周期共振阶段", - "周线拐点尚未确认时,日线信号可能只是短暂反抽", - ), - "空间板": ( - "市场高度持续拓展、板块梯队完整的强接力环境", - "高度压缩或亏钱效应扩散时,最高板的补跌风险极高", - ), - "龙头首阴": ( - "主线龙头仍有辨识度、首次分歧后存在回流预期的阶段", - "题材退潮或龙头地位被替代后,首阴可能只是下跌起点", - ), - "断板反包": ( - "强势题材分歧后快速修复、核心股重新获得资金承接时", - "板块强度不足或反包缩量时,形态持续性通常较弱", - ), - "核按钮反核": ( - "恐慌释放后出现明确承接、短线情绪转暖的窗口", - "系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高", - ), - "行业动量轮动": ( - "主线相对清晰、行业趋势能够延续两周以上的结构市", - "行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减", - ), - "主力资金行业流入": ( - "板块轮动初期、资金先于价格形成连续净流入的阶段", - "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势", - ), - "景气-趋势-拥挤三维行业打分": ( - "行业景气与价格趋势同向、但交易拥挤尚未达到极端的结构市", - "财务披露存在滞后,行业快速反转时三维综合分可能反应偏慢", - ), - "大小盘/成长价值风格切换(元策略)": ( - "大小盘或成长价值风格形成持续相对强弱的阶段", - "风格快速往返切换时,近20日相对表现容易产生滞后信号", - ), - "业绩超预期漂移(SUE/PEAD)": ( - "业绩披露窗口中,快报相对预告继续上修且价格尚未充分兑现时", - "预告与快报口径可能不同,公告后高开兑现会削弱漂移效应", - ), - "多因子综合打分(IC动态加权)": ( - "因子表现具备一定延续性、市场并非由单一极端主题主导时", - "近期有效因子可能快速失效,动态权重不能消除风格突变风险", - ), - "热度突增潜伏(另类数据)": ( - "人气快速抬升但股价尚未明显启动的题材萌芽与扩散初期", - "榜单热度可能由短期讨论驱动,缺少价格确认时误报率较高", - ), - "机构榜溢价": ( - "机构专用席位在相对低位形成明确净买入、且成交承载正常时", - "高位机构榜可能对应兑现或对倒,席位净买入不等于持续锁仓", - ), -} - -for strategy in CURATED_STRATEGIES: - suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]] - strategy["formula"]["meta"].update( - { - "suitable_environment": suitable_environment, - "failure_risk": failure_risk, - } - ) - -BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES) - - -def _quarter_periods(trade_date: str, count: int) -> list[str]: - current = datetime.strptime(trade_date, "%Y%m%d") - quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31)) - periods = [] - year = current.year - while len(periods) < count: - for month, day in reversed(quarter_ends): - value = datetime(year, month, day) - if value <= current: - periods.append(value.strftime("%Y%m%d")) - if len(periods) == count: - break - year -= 1 - return sorted(periods) - - -def _earnings_event_rows( - forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], trade_date: str, -) -> list[dict[str, Any]]: - forecast_map: dict[tuple[str, str], dict[str, Any]] = {} - for row in forecasts: - key = (str(row.get("ts_code") or ""), str(row.get("end_date") or "")) - ann_date = str(row.get("ann_date") or "") - if not all(key) or not ann_date or ann_date > trade_date: - continue - previous = forecast_map.get(key) - if previous is None or ann_date > str(previous.get("ann_date") or ""): - forecast_map[key] = row - result = [] - for row in expresses: - ts_code = str(row.get("ts_code") or "") - end_date = str(row.get("end_date") or "") - ann_date = str(row.get("ann_date") or "") - forecast = forecast_map.get((ts_code, end_date)) - if not forecast or not ts_code or not end_date or not ann_date or ann_date > trade_date: - continue - lower = _optional_number(forecast.get("net_profit_min")) - upper = _optional_number(forecast.get("net_profit_max")) - forecast_profit = statistics.fmean( - value for value in (lower, upper) if value is not None - ) if lower is not None or upper is not None else None - actual_profit = _optional_number(row.get("n_income")) - if forecast_profit in (None, 0) or actual_profit is None: - continue - # forecast is reported in ten-thousand yuan while express uses yuan. - if abs(actual_profit) > max(abs(forecast_profit), 1) * 100: - actual_profit /= 10000 - surprise_pct = (actual_profit / forecast_profit - 1) * 100 - result.append( - { - "end_date": end_date, - "ann_date": ann_date, - "ts_code": ts_code, - "forecast_profit": forecast_profit, - "actual_profit": actual_profit, - "surprise_pct": surprise_pct, - "revenue_yoy": _optional_number(row.get("yoy_sales")), - "netprofit_yoy": _optional_number(row.get("yoy_net_profit")), - "source": "forecast+express", - } - ) - return result - - -def _popularity_factor_rows( - trade_date: str, - ths_rows: list[dict[str, Any]], - dc_rows: list[dict[str, Any]], - previous_ths: list[dict[str, Any]], - previous_dc: list[dict[str, Any]], -) -> list[dict[str, Any]]: - def ranks(rows: list[dict[str, Any]], data_type: str) -> dict[str, int]: - result = {} - for row in rows: - if data_type and str(row.get("data_type") or "") != data_type: - continue - ts_code = str(row.get("ts_code") or "") - rank = int(_number(row.get("rank"))) - if ts_code and rank > 0: - result[ts_code] = rank - return result - - ths = ranks(ths_rows, "热股") - dc = ranks(dc_rows, "A股市场") - previous_ths_map = ranks(previous_ths, "热股") - previous_dc_map = ranks(previous_dc, "A股市场") - result = [] - for ts_code in set(ths) | set(dc): - ths_rank = ths.get(ts_code) - dc_rank = dc.get(ts_code) - current_best = min(value for value in (ths_rank, dc_rank) if value is not None) - previous_candidates = [ - value for value in (previous_ths_map.get(ts_code), previous_dc_map.get(ts_code)) - if value is not None - ] - previous_best = min(previous_candidates) if previous_candidates else None - score = (101 - (ths_rank or 101)) * 0.5 + (201 - (dc_rank or 201)) * 0.25 - result.append( - { - "trade_date": trade_date, - "ts_code": ts_code, - "ths_rank": ths_rank, - "dc_rank": dc_rank, - "combined_score": round(score, 2), - "rank_change": ( - previous_best - current_best - if previous_best is not None - else min(30, max(0, 31 - current_best)) - if previous_ths_map or previous_dc_map else 0 - ), - "dual_source": bool(ths_rank and dc_rank), - } - ) - return result - - -class FactorDataService: - def __init__(self, database: ReviewDatabase, client: TushareClient) -> None: - self.database = database - self.client = client - - def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]: - lookback = max(25, min(260, int(lookback))) - trade_date, _ = self.client.resolve_trade_context(requested_date) - end = datetime.strptime(trade_date, "%Y%m%d") - start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d") - calendar = self.client.query( - "trade_cal", - {"exchange": "SSE", "start_date": start, "end_date": trade_date, "is_open": 1}, - "cal_date,is_open", - ) - dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:] - existing = set(self.database.factor_dates(trade_date, lookback + 10)) - dates_to_fetch = [value for value in dates if value not in existing or value == trade_date] - auction_source_dates = dates[-min(80, len(dates)):] - existing_auction = set(self.database.auction_factor_dates(trade_date, 90)) - auction_dates_to_fetch = [ - value for value in auction_source_dates - if value not in existing_auction or value == trade_date - ] - long_calendar = self.client.query( - "trade_cal", - { - "exchange": "SSE", - "start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"), - "end_date": trade_date, - "is_open": 1, - }, - "cal_date,is_open", - ) - last_open_by_year: dict[str, str] = {} - last_open_by_month: dict[str, str] = {} - for row in long_calendar: - if row.get("is_open") == 1 and row.get("cal_date"): - value = str(row["cal_date"]) - last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value) - last_open_by_month[value[:6]] = max(last_open_by_month.get(value[:6], ""), value) - valuation_dates = set(dates[-min(80, len(dates)):]) - valuation_dates.update(last_open_by_year.values()) - valuation_dates.update(last_open_by_month.values()) - existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500)) - indicator_dates_to_fetch = sorted( - value for value in valuation_dates if value not in existing_indicators or value == trade_date - ) - - master = self.client.query( - "stock_basic", - {"list_status": "L"}, - "ts_code,name,industry,market,list_date", - ) - master_count = self.database.upsert_stock_master(master) - bar_count = 0 - for current_date in dates_to_fetch: - rows = self.client.query( - "daily", - {"trade_date": current_date}, - "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", - ) - bar_count += self.database.upsert_daily_bars(rows) - - indicator_count = 0 - for current_date in indicator_dates_to_fetch: - indicators = self.client.query( - "daily_basic", - {"trade_date": current_date}, - "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv," - "pe_ttm,pb,ps_ttm,dv_ttm", - ) - indicator_count += self.database.upsert_daily_indicators(indicators) - - notices = [] - benchmark_count = 0 - try: - benchmark_rows = self.client.query( - "index_daily", - {"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date}, - "ts_code,trade_date,close,pct_chg", - ) - benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows) - except TushareError as exc: - notices.append(f"沪深300基准暂不可用:{exc}") - fundamental_count = 0 - existing_periods = set(self.database.fundamental_periods()) - for period in _quarter_periods(trade_date, 9): - if period in existing_periods and period < trade_date[:4] + "0101": - continue - try: - rows = self.client.query( - "fina_indicator_vip", - {"period": period}, - "ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin," - "netprofit_yoy,or_yoy,ocf_to_opincome", - ) - except TushareError as exc: - notices.append(f"财务质量接口不可用:{exc}") - break - published = [ - row for row in rows - if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date - ] - published.sort(key=lambda row: str(row.get("ann_date") or "")) - fundamental_count += self.database.upsert_fundamental_indicators(published) - auction_count = 0 - auction_dates = 0 - for current_date in auction_dates_to_fetch: - try: - auction_rows = self.client.query( - "stk_auction", - {"trade_date": current_date}, - "ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share", - ) - if auction_rows: - auction_count += self.database.upsert_auction_factors(auction_rows) - auction_dates += 1 - except TushareError as exc: - notices.append(f"竞价因子接口不可用:{exc}") - break - moneyflow_count = 0 - moneyflow_dates = 0 - for current_date in dates[-min(5, len(dates)):]: - try: - moneyflow = self.client.query( - "moneyflow", - {"trade_date": current_date}, - "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," - "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", - ) - moneyflow_count += self.database.upsert_moneyflow(moneyflow) - if moneyflow: - moneyflow_dates += 1 - except TushareError as exc: - notices.append(f"资金流接口不可用:{exc}") - break - - earnings_count = 0 - forecasts: list[dict[str, Any]] = [] - expresses: list[dict[str, Any]] = [] - for period in _quarter_periods(trade_date, 5): - try: - forecast_rows = self.client.query( - "forecast_vip", - {"period": period}, - "ts_code,ann_date,end_date,net_profit_min,net_profit_max,last_parent_net,p_change_min,p_change_max", - ) - express_rows = self.client.query( - "express_vip", - {"period": period}, - "ts_code,ann_date,end_date,n_income,yoy_net_profit,yoy_sales", - ) - except TushareError as exc: - notices.append(f"业绩事件接口不可用:{exc}") - break - forecasts.extend(forecast_rows) - expresses.extend(express_rows) - if forecasts and expresses: - earnings_count = self.database.upsert_earnings_events( - _earnings_event_rows(forecasts, expresses, trade_date) - ) - - popularity_count = 0 - previous_trade_date = dates[-2] if len(dates) >= 2 else "" - try: - ths_rows = self.client.query("ths_hot", {"trade_date": trade_date}) - dc_rows = self.client.query("dc_hot", {"trade_date": trade_date}) - previous_ths = ( - self.client.query("ths_hot", {"trade_date": previous_trade_date}) - if previous_trade_date else [] - ) - previous_dc = ( - self.client.query("dc_hot", {"trade_date": previous_trade_date}) - if previous_trade_date else [] - ) - popularity_count = self.database.upsert_popularity_factors( - _popularity_factor_rows( - trade_date, ths_rows, dc_rows, previous_ths, previous_dc - ) - ) - except TushareError as exc: - notices.append(f"人气榜因子不可用:{exc}") - - institution_count = 0 - try: - institution_rows = self.client.query( - "top_inst", - {"trade_date": trade_date}, - "trade_date,ts_code,exalter,buy,sell,net_buy,side,reason", - ) - institution_count = self.database.upsert_lhb_institutions(institution_rows) - except TushareError as exc: - notices.append(f"机构席位明细不可用:{exc}") - - return { - "trade_date": trade_date, - "calendar_dates": len(dates), - "fetched_dates": len(dates_to_fetch), - "stocks": master_count, - "bars": bar_count, - "benchmark_bars": benchmark_count, - "indicators": indicator_count, - "indicator_dates": len(indicator_dates_to_fetch), - "fundamentals": fundamental_count, - "moneyflow": moneyflow_count, - "moneyflow_dates": moneyflow_dates, - "auction_rows": auction_count, - "auction_dates": auction_dates, - "earnings_events": earnings_count, - "popularity_rows": popularity_count, - "institution_rows": institution_count, - "notice": ";".join(notices), - } - - -class ScreenerEngine: - def __init__(self, database: ReviewDatabase) -> None: - self.database = database - self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {} - - def ensure_builtin_strategies(self) -> None: - existing = { - item["name"]: item - for item in self.database.list_screener_strategies() - if item["builtin"] - } - for strategy in BUILTIN_STRATEGIES: - current = existing.get(strategy["name"]) - self.database.save_screener_strategy( - None, **strategy, builtin=True, - strategy_id=int(current["id"]) if current else None, - ) - - def detect_regime(self, trade_date: str) -> dict[str, Any]: - series = latest_contiguous_history( - build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260)) - ) - if not series: - return { - "id": "repair", "label": REGIMES["repair"], "confidence": 25, - "reason": "复盘快照不足,暂按中性修复处理。", "evidence": [], "history": [], - } - current = series[-1] - previous = series[-2] if len(series) > 1 else current - score = _number(current.get("score")) - previous_score = _number(previous.get("score")) - delta = score - previous_score - seal_rate = _number(current.get("seal_rate")) - limit_up = _number(current.get("limit_up_count")) - broken = _number(current.get("broken_count")) - regime = next( - (key for key, label in REGIMES.items() if label == current.get("phase")), - "divergence", - ) - confidence = min(92, 45 + len(series[-8:]) * 5 + min(abs(delta), 12)) - evidence = [ - f"情绪温度 {score:.0f},较前一交易日 {delta:+.0f},{current.get('direction') or '持平'}", - f"封板率 {seal_rate:.1f}%", - f"涨停 {limit_up:.0f} 家,炸板 {broken:.0f} 家", - ] - return { - "id": regime, - "label": REGIMES[regime], - "confidence": round(confidence), - "reason": _regime_reason(regime), - "evidence": evidence, - "history": [ - {"trade_date": item["trade_date"], "score": _number(item.get("score"))} - for item in series[-8:] - ], - } - - def factor_health(self, trade_date: str) -> dict[str, Any]: - return self.database.factor_health_summary(trade_date) - - def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]: - if not isinstance(formula, dict): - raise ValueError("选股公式必须是 JSON 对象。") - result = copy.deepcopy(formula) - universe = result.setdefault("universe", {}) - universe["exclude_st"] = bool(universe.get("exclude_st", True)) - universe["listed_days_min"] = max(0, min(5000, int(universe.get("listed_days_min", 120)))) - filters = result.setdefault("filters", []) - if not isinstance(filters, list) or len(filters) > 20: - raise ValueError("筛选条件必须是列表,且不能超过 20 条。") - for condition in filters: - field = condition.get("field") - operator = condition.get("op") - if field not in FACTOR_FIELDS: - raise ValueError(f"不支持的选股因子:{field}") - if operator not in ALLOWED_OPERATORS: - raise ValueError(f"不支持的运算符:{operator}") - if "value" not in condition: - raise ValueError(f"因子 {field} 缺少比较值。") - scores = result.setdefault("score", []) - if not isinstance(scores, list) or not scores or len(scores) > 12: - raise ValueError("评分因子应为 1 至 12 条。") - for item in scores: - if item.get("field") not in FACTOR_FIELDS: - raise ValueError(f"不支持的评分因子:{item.get('field')}") - item["weight"] = float(item.get("weight", 0)) - if item["weight"] <= 0 or item["weight"] > 1: - raise ValueError("评分权重必须大于 0 且不超过 1。") - if item.get("direction", "desc") not in {"asc", "desc"}: - raise ValueError("评分方向只能是 asc 或 desc。") - item["direction"] = item.get("direction", "desc") - result["limit"] = max(1, min(50, int(result.get("limit", 15)))) - result["min_score"] = max(0, min(1, float(result.get("min_score", 0)))) - return result - - def screen( - self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str, - strategy_name: str, run_backtest: bool = True, - realtime_snapshot: dict[str, Any] | None = None, - mode: str = "smart", - prepared_factors: list[dict[str, Any]] | None = None, - prepared_date: str = "", - ) -> dict[str, Any]: - mode = mode if mode in {"smart", "curated", "quant"} else "smart" - formula = self.validate_formula(formula) - if prepared_factors is None: - history_days = int((formula.get("meta") or {}).get("history_days") or 80) - factors, actual_date = self.build_factors( - trade_date, realtime_snapshot, history_days - ) - else: - factors = prepared_factors - actual_date = prepared_date or trade_date - candidates = self.apply_formula(factors, formula, regime) - backtest = self.backtest(actual_date, formula) if run_backtest else None - required_fields = sorted({ - str(item.get("field") or "") - for item in list(formula.get("filters") or []) + list(formula.get("score") or []) - if item.get("field") - }) - complete_rows = sum( - 1 for row in factors - if all(row.get(field) is not None for field in required_fields) - ) - coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0 - health_status = "normal" if candidates else "no_signal" - if backtest and backtest["samples"] >= 20: - for candidate in candidates: - estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35 - candidate["historical_probability"] = round(min(95, max(5, estimate)), 1) - candidate["probability_samples"] = backtest["samples"] - else: - for candidate in candidates: - candidate["historical_probability"] = None - candidate["probability_samples"] = backtest["samples"] if backtest else 0 - result = { - "meta": { - "trade_date": _display_date(actual_date), - "regime": regime, - "regime_label": REGIMES.get(regime, regime), - "strategy_name": strategy_name, - "mode": mode, - "library_version": int( - (formula.get("meta") or {}).get("library_version") or 0 - ), - "universe_count": len(factors), - "candidate_count": len(candidates), - "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), - "health": { - "status": health_status, - "required_field_count": len(required_fields), - "complete_rows": complete_rows, - "universe_rows": len(factors), - "coverage": coverage, - "signal_count": len(candidates), - }, - "selection_source": ( - "tushare_rt_k+history" if realtime_snapshot else "historical_eod" - ), - "realtime": bool(realtime_snapshot), - "history_cutoff": ( - str(realtime_snapshot.get("previous_trade_date") or "") - if realtime_snapshot else actual_date - ), - "factor_freshness": { - "realtime": [ - "价格", "涨跌幅", "成交量", "成交额", "换手率", - "均线位置", "5/10日动量", "板块强度", "开盘竞价", - ] if realtime_snapshot else [], - "historical": ["历史波动率", "流通市值", "资金流", "竞价因子", "回测"], - }, - }, - "formula": formula, - "candidates": candidates, - "backtest": backtest, - "disclaimer": ( - "候选仅由策略条件与当日数据计算;历史统计不代表未来收益。" - if mode == "curated" - else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。" - ), - } - run_id = self.database.save_screener_run( - user_id, actual_date, regime, strategy_name, formula, result, mode - ) - result["meta"]["run_id"] = run_id - return result - - def build_factors( - self, - trade_date: str, - realtime_snapshot: dict[str, Any] | None = None, - history_days: int = 80, - ) -> tuple[list[dict[str, Any]], str]: - history_days = max(21, min(260, int(history_days))) - data = self.database.load_factor_data(trade_date, history_days) - dates = [value for value in data["dates"] if value <= trade_date] - if len(dates) < 21: - raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。") - history_date = dates[-1] - realtime_map = { - str(row.get("ts_code") or ""): row - for row in (realtime_snapshot or {}).get("rows") or [] - } - realtime_date = str((realtime_snapshot or {}).get("trade_date") or "") - use_realtime = bool(realtime_map and realtime_date == trade_date and history_date < trade_date) - actual_date = trade_date if use_realtime else history_date - master = {row["ts_code"]: row for row in data["master"]} - indicators = {row["ts_code"]: row for row in data["indicators"]} - fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])} - indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in data.get("indicator_history", []): - indicator_history[str(row.get("ts_code") or "")].append(row) - indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in data.get("indicator_series", []): - indicator_series[str(row.get("ts_code") or "")].append(row) - benchmark_by_date = { - str(row.get("trade_date") or ""): _number(row.get("close")) - for row in data.get("benchmarks", []) - if _number(row.get("close")) > 0 - } - moneyflow = {row["ts_code"]: row for row in data["moneyflow"]} - moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in data.get("moneyflow_history", []): - moneyflow_history[str(row.get("ts_code") or "")].append(row) - auction = { - row["ts_code"]: row - for row in data.get("auction", []) - if str(row.get("trade_date") or "") == actual_date - } - earnings_events: dict[str, dict[str, Any]] = {} - for row in data.get("earnings_events", []): - ts_code = str(row.get("ts_code") or "") - ann_date = str(row.get("ann_date") or "") - if ann_date <= actual_date and ( - ts_code not in earnings_events - or ann_date > str(earnings_events[ts_code].get("ann_date") or "") - ): - earnings_events[ts_code] = row - popularity = { - str(row.get("ts_code") or ""): row - for row in data.get("popularity", []) - } - institutions = { - str(row.get("ts_code") or ""): row - for row in data.get("institutions", []) - } - grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in data["bars"]: - if row["trade_date"] <= history_date: - grouped[row["ts_code"]].append(row) - - snapshot = self.database.get_snapshot(actual_date) or {} - limit_map: dict[str, tuple[str, int]] = {} - for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")): - for row in snapshot.get(key) or []: - limit_map[str(row.get("code"))] = (status, int(row.get("streak") or 0)) - - factors = [] - current_day = datetime.strptime(actual_date, "%Y%m%d") - for ts_code, bars in grouped.items(): - bars.sort(key=lambda item: item["trade_date"]) - if len(bars) < 21 or bars[-1]["trade_date"] != history_date: - continue - info = master.get(ts_code) - if not info: - continue - historical_closes = [_number(item["close"]) for item in bars] - historical_volumes = [_number(item["vol"]) for item in bars] - realtime = realtime_map.get(ts_code) if use_realtime else None - current = realtime or bars[-1] - closes = historical_closes + ([_number(realtime["close"])] if realtime else []) - volumes = historical_volumes + ([_number(realtime["vol"])] if realtime else []) - if closes[-1] <= 0: - continue - returns_10 = [_number(item["pct_chg"]) for item in bars[-10:]] - if realtime: - returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))] - previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0 - indicator = indicators.get(ts_code, {}) - fundamental = fundamentals.get(ts_code, {}) - flow = moneyflow.get(ts_code, {}) - flow_history = moneyflow_history.get(ts_code, []) - auction_row = auction.get(ts_code, {}) - list_date = str(info.get("list_date") or "") - try: - listed_days = (current_day - datetime.strptime(list_date, "%Y%m%d")).days - except ValueError: - listed_days = 9999 - code = str(info.get("code") or ts_code.split(".")[0]) - status, streak = limit_map.get(code, ("", 0)) - name = str(info.get("name") or "--") - shape_rows = bars + ([realtime] if realtime else []) - shape_close = [_number(item.get("close")) for item in shape_rows] - shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows] - shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows] - shape_changes = [_number(item.get("pct_chg")) for item in shape_rows] - position_rows = shape_rows[-60:] - position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0) - position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0) - relative_position = ( - (closes[-1] - position_low) / (position_high - position_low) - if position_high > position_low else 0.5 - ) - previous_index = len(bars) - 1 if realtime else len(bars) - 2 - previous_bar = bars[previous_index] if previous_index >= 0 else {} - previous_limit = _is_limit_bar(bars, previous_index, code, name) - previous_touched = _touched_limit_bar(bars, previous_index, code, name) - recent_prior_signal = any( - _is_limit_bar(bars, index, code, name) - or _touched_limit_bar(bars, index, code, name) - for index in range(max(0, previous_index - 2), previous_index) - ) - previous_streak = 0 - streak_index = previous_index - while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name): - previous_streak += 1 - streak_index -= 1 - limit_flags = [ - _is_limit_bar(shape_rows, index, code, name) - for index in range(len(shape_rows)) - ] - annual_dividend_rows = indicator_history.get(ts_code, []) - dividend_years = sum( - 1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0) - ) - current_streak = _ending_streak(limit_flags) - prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2) - streak = max(streak, current_streak) - return_60d = ( - (closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 - ) - momentum_60_5 = ( - (closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 - ) - ma20 = statistics.fmean(closes[-20:]) - ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20 - prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20 - prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60 - ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0 - ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0 - ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)] - high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high) - drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100 - prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0 - breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0 - prior_lows_20 = shape_low[-21:-1] - range_20d = ( - (prior_high_20 / min(prior_lows_20) - 1) * 100 - if prior_lows_20 and min(prior_lows_20) > 0 else 100 - ) - turnover_rows = sorted( - indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "") - ) - turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]] - if realtime and _number(realtime.get("turnover_rate")): - turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))] - turnover_5d = sum(turnover_values) - rs_values = [ - _number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))] - for item in shape_rows[-120:] - if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0 - ] - benchmark_60 = [ - benchmark_by_date.get(str(item.get("trade_date"))) - for item in shape_rows[-61:] - if benchmark_by_date.get(str(item.get("trade_date"))) - ] - benchmark_return_60 = ( - (benchmark_60[-1] / benchmark_60[0] - 1) * 100 - if len(benchmark_60) >= 61 and benchmark_60[0] else 0 - ) - weekly_closes, weekly_amounts = _weekly_series(shape_rows) - weekly_dif, weekly_dea = _macd_last(weekly_closes) - daily_dif, daily_dea = _macd_series(closes) - daily_cross = ( - len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] - and daily_dif[-2] <= daily_dea[-2] - ) - current_open = _number(current.get("open")) - daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open - previous_close = closes[-2] if len(closes) >= 2 else closes[-1] - intraday_min = ( - (_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0 - ) - body = abs(closes[-1] - current_open) - lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low"))) - lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0) - previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0 - vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0 - broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name) - netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy")) - earnings_event = earnings_events.get(ts_code, {}) - announcement_date = str(earnings_event.get("ann_date") or "") - earnings_days = ( - sum(1 for value in dates if announcement_date < value <= actual_date) - if announcement_date and announcement_date <= actual_date - else None - ) - announcement_bar = next( - (item for item in shape_rows if str(item.get("trade_date") or "") == announcement_date), - None, - ) - announcement_bad = False - if announcement_bar is not None: - bar_index = shape_rows.index(announcement_bar) - prior_volumes = [ - _number(item.get("vol")) for item in shape_rows[max(0, bar_index - 5):bar_index] - if _number(item.get("vol")) > 0 - ] - volume_baseline = statistics.fmean(prior_volumes) if prior_volumes else 0 - announcement_bad = ( - _number(announcement_bar.get("close")) < _number(announcement_bar.get("open")) - and _number(announcement_bar.get("pct_chg")) < 0 - and volume_baseline > 0 - and _number(announcement_bar.get("vol")) / volume_baseline >= 1.8 - ) - popularity_row = popularity.get(ts_code) - institution_row = institutions.get(ts_code) - factors.append( - { - "code": code, - "ts_code": ts_code, - "name": name, - "sector": info.get("industry") or "其他", - "market": info.get("market") or "--", - "listed_days": listed_days, - "close": round(closes[-1], 2), - "price": round(closes[-1], 2), - "pct_chg": round(_number(current["pct_chg"]), 2), - "return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2), - "return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2), - "return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2), - "return_60d": round(return_60d, 2), - "momentum_60_5": round(momentum_60_5, 2), - "above_ma20": int(closes[-1] > ma20), - "rsi_6": round(_rsi(closes, 6), 2), - "ma60_slope": round(ma60_slope, 3), - "ma20_slope_5d": round(ma20_slope, 3), - "ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]), - "drawdown_from_high_250": round(drawdown_250, 2), - "donchian_breakout_pct": round(breakout_pct, 2), - "range_20d": round(range_20d, 2), - "rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)), - "excess_return_60d": round(return_60d - benchmark_return_60, 2), - "weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0), - "daily_buy_trigger": int(daily_cross or daily_pullback), - "weekly_amount_trend": int( - len(weekly_amounts) >= 5 - and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1]) - ), - "volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0, - "turnover_5d": round(turnover_5d, 2), - "volatility_10d": round(statistics.pstdev(returns_10), 2), - "amount_billion": round( - _number(current["amount"]) / (100000000 if realtime else 100000), 2 - ), - "turnover_rate": round( - _number(realtime.get("turnover_rate")) - if realtime else _number(indicator.get("turnover_rate")), - 2, - ), - "circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2), - "total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2), - "pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2), - "pb": _rounded_optional(indicator.get("pb"), 2), - "ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2), - "dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2), - "dividend_years": dividend_years, - "roe": _rounded_optional(fundamental.get("roe"), 2), - "roa": _rounded_optional(fundamental.get("roa"), 2), - "roic": _rounded_optional(fundamental.get("roic"), 2), - "gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2), - "netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2), - "revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2), - "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2), - "earnings_surprise_pct": _rounded_optional(earnings_event.get("surprise_pct"), 2), - "earnings_days_since_announce": earnings_days, - "earnings_event_quality": int(not announcement_bad) if earnings_days is not None else None, - "popularity_score": _rounded_optional( - popularity_row.get("combined_score") if popularity_row else None, 2 - ), - "popularity_rank_change": ( - int(popularity_row["rank_change"]) - if popularity_row and popularity_row.get("rank_change") is not None else None - ), - "popularity_dual_source": ( - int(bool(popularity_row.get("dual_source"))) if popularity_row else None - ), - "institution_net_buy_million": ( - round(_number(institution_row.get("net_buy_amount")) / 1_000_000, 2) - if institution_row else None - ), - "institution_seat_count": ( - int(institution_row.get("seat_count") or 0) if institution_row else None - ), - "net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2), - "large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2), - "net_flow_5d_million": round( - sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100, - 2, - ), - "flow_to_circ_mv_5d": round( - sum(_number(item.get("net_mf_amount")) for item in flow_history) - / _number(indicator.get("circ_mv")) * 100, - 4, - ) if _number(indicator.get("circ_mv")) else 0, - "limit_status": status, - "limit_streak": streak, - "is_limit_up_today": int(limit_flags[-1]), - "is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)), - "auction_change": round(_number(auction_row.get("change")), 2), - "auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2), - "auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4), - "auction_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2), - "relative_position_60": round(relative_position, 4), - "max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2), - "close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) else 0, - "close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0, - "no_limit_30d": int(not any(limit_flags[-30:])), - "had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])), - "no_limit_down_20d": int(not any( - _number(item.get("pct_chg")) <= -_limit_threshold(code, name) - for item in shape_rows[-20:] - )), - "financial_risk": int( - "ST" in name.upper() or "退" in name - or (netprofit_yoy is not None and netprofit_yoy <= -100) - ), - "prior_limit_streak": prior_streak, - "max_continuous_board_10d": _max_streak(limit_flags[-10:]), - "dragon_first_yin": int( - prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open - ), - "yin_day_pct": round(_number(current.get("pct_chg")), 2), - "vol_vs_previous": round(vol_vs_previous, 3), - "broken_reversal": broken["signal"], - "days_since_broken": broken["days"], - "close_above_broken_high": broken["recovered"], - "vol_vs_broken_day": broken["volume_ratio"], - "recent_limit_up_5d": sum(limit_flags[-5:]), - "intraday_min_pct": round(intraday_min, 2), - "lower_shadow_ratio": round(lower_shadow_ratio, 2), - "previous_first_limit": int(previous_limit and not recent_prior_signal), - "previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal), - "previous_limit_streak": previous_streak, - "previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2), - } - ) - - market_return = statistics.fmean(row["return_5d"] for row in factors) if factors else 0 - sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in factors: - sectors[row["sector"]].append(row) - sector_metrics = [] - market_amount = sum(max(0.0, row["amount_billion"]) for row in factors) - for sector_name, sector_rows in sectors.items(): - average_return = statistics.fmean(row["return_5d"] for row in sector_rows) - average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows) - sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows) - limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows) - up_count = sum(row["pct_chg"] >= 5 for row in sector_rows) - breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100 - sector_growth = [ - statistics.fmean(values) - for row in sector_rows - if (values := [ - value for value in (row.get("revenue_yoy"), row.get("netprofit_yoy")) - if value is not None - ]) - ] - prosperity_raw = statistics.median(sector_growth) if sector_growth else -100.0 - average_turnover = statistics.fmean(row["turnover_rate"] for row in sector_rows) - amount_share = ( - sum(max(0.0, row["amount_billion"]) for row in sector_rows) / market_amount * 100 - if market_amount else 0.0 - ) - crowding_raw = average_turnover + amount_share - trend_raw = average_return_20d + breadth_ma20 / 10 - strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6)) - sector_metrics.append( - { - "ts_code": sector_name, - "sector_return_20d": average_return_20d, - "sector_net_flow_5d_million": sector_net_flow, - "sector_prosperity_raw": prosperity_raw, - "sector_trend_raw": trend_raw, - "sector_crowding_raw": crowding_raw, - } - ) - stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc") - for row in sector_rows: - row["sector_strength"] = round(strength, 1) - row["sector_return_5d"] = round(average_return, 2) - row["sector_return_20d"] = round(average_return_20d, 2) - row["sector_net_flow_5d_million"] = round(sector_net_flow, 2) - row["sector_stock_momentum_rank"] = round( - stock_momentum_ranks.get(row["ts_code"], 0.0), 4 - ) - row["sector_limit_count"] = limit_count - row["sector_up_count"] = up_count - row["sector_breadth_ma20"] = round(breadth_ma20, 1) - row["relative_strength"] = round(row["return_5d"] - market_return, 2) - sector_momentum_ranks = _percentile_map( - sector_metrics, "sector_return_20d", "desc" - ) - sector_flow_ranks = _percentile_map( - sector_metrics, "sector_net_flow_5d_million", "desc" - ) - sector_prosperity_ranks = _percentile_map( - sector_metrics, "sector_prosperity_raw", "desc" - ) - sector_trend_ranks = _percentile_map( - sector_metrics, "sector_trend_raw", "desc" - ) - sector_crowding_ranks = _percentile_map( - sector_metrics, "sector_crowding_raw", "desc" - ) - for sector_name, sector_rows in sectors.items(): - prosperity_rank = sector_prosperity_ranks.get(sector_name, 0.0) - trend_rank = sector_trend_ranks.get(sector_name, 0.0) - crowding_rank = sector_crowding_ranks.get(sector_name, 0.0) - composite_score = ( - prosperity_rank * 0.40 + trend_rank * 0.30 + (1 - crowding_rank) * 0.30 - ) - for row in sector_rows: - row["sector_momentum_rank"] = round( - sector_momentum_ranks.get(sector_name, 0.0), 4 - ) - row["sector_flow_rank"] = round( - sector_flow_ranks.get(sector_name, 0.0), 4 - ) - row["sector_prosperity_rank"] = round(prosperity_rank, 4) - row["sector_trend_rank"] = round(trend_rank, 4) - row["sector_crowding_rank"] = round(crowding_rank, 4) - row["sector_composite_score"] = round(composite_score, 4) - - factor_specs = { - "factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")), - "factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")), - "factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")), - "factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")), - "factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")), - } - for output_field, specs in factor_specs.items(): - maps = [_available_percentile_map(factors, field, direction) for field, direction in specs] - for row in factors: - values = [mapping.get(row["ts_code"]) for mapping in maps] - available = [value for value in values if value is not None] - row[output_field] = round(statistics.fmean(available), 4) if available else None - - return_rank_map = _available_percentile_map(factors, "return_20d", "desc") - factor_weights = {} - for output_field in factor_specs: - pairs = [ - (row.get(output_field), return_rank_map.get(row["ts_code"])) - for row in factors - if row.get(output_field) is not None and return_rank_map.get(row["ts_code"]) is not None - ] - correlation = _pearson([pair[0] for pair in pairs], [pair[1] for pair in pairs]) - factor_weights[output_field] = max(0.05, correlation) - factor_weight_total = sum(factor_weights.values()) or 1 - for row in factors: - weighted = [ - (row.get(field), weight) - for field, weight in factor_weights.items() - if row.get(field) is not None - ] - row["multi_factor_composite"] = round( - sum(value * weight for value, weight in weighted) - / (sum(weight for _, weight in weighted) or factor_weight_total), - 4, - ) if weighted else None - - size_ranks = _available_percentile_map(factors, "total_mv_billion", "desc") - large_rows = [row for row in factors if (size_ranks.get(row["ts_code"]) or 0) >= 0.70] - small_rows = [ - row for row in factors - if size_ranks.get(row["ts_code"]) is not None - and size_ranks[row["ts_code"]] <= 0.30 - ] - large_return = statistics.fmean(row["return_20d"] for row in large_rows) if large_rows else 0 - small_return = statistics.fmean(row["return_20d"] for row in small_rows) if small_rows else 0 - prefer_large = large_return >= small_return - growth_rows = [row for row in factors if (row.get("factor_growth_score") or 0) >= 0.70] - value_rows = [row for row in factors if (row.get("factor_value_score") or 0) >= 0.70] - growth_return = statistics.fmean(row["return_20d"] for row in growth_rows) if growth_rows else 0 - value_return = statistics.fmean(row["return_20d"] for row in value_rows) if value_rows else 0 - prefer_growth = growth_return >= value_return - for row in factors: - size_rank = size_ranks.get(row["ts_code"]) - row["style_size_fit"] = round( - size_rank if prefer_large else 1 - size_rank, 4 - ) if size_rank is not None else None - style_factor = "factor_growth_score" if prefer_growth else "factor_value_score" - row["style_growth_fit"] = row.get(style_factor) - style_values = [ - value for value in (row.get("style_size_fit"), row.get("style_growth_fit")) - if value is not None - ] - row["style_fit_score"] = round(statistics.fmean(style_values), 4) if style_values else None - momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc") - return_ranks = _percentile_map(factors, "return_5d", "desc") - market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0) - prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0) - for row in factors: - row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4) - row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4) - is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height - row["is_market_height"] = int(is_height) - row["new_space_board"] = int( - is_height - and not ( - prior_market_height >= 2 - and int(row.get("prior_limit_streak") or 0) == prior_market_height - ) - ) - return factors, actual_date - - def apply_formula( - self, rows: list[dict[str, Any]], formula: dict[str, Any], regime: str - ) -> list[dict[str, Any]]: - universe = formula["universe"] - eligible = [] - score_fields = [item["field"] for item in formula["score"]] - for row in rows: - name = str(row.get("name") or "") - if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name): - continue - if row.get("listed_days", 0) < universe.get("listed_days_min", 0): - continue - if any(row.get(field) is None for field in score_fields): - continue - if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]): - eligible.append(row) - if not eligible: - return [] - - percentiles = { - item["field"]: _percentile_map(eligible, item["field"], item["direction"]) - for item in formula["score"] - } - weight_total = sum(item["weight"] for item in formula["score"]) - results = [] - for row in eligible: - contributions = [] - score = 0.0 - for item in formula["score"]: - percentile = percentiles[item["field"]].get(row["ts_code"], 0.5) - points = percentile * item["weight"] / weight_total - score += points - contributions.append( - { - "field": item["field"], - "label": FACTOR_FIELDS[item["field"]], - "value": row.get(item["field"], 0), - "points": round(points * 100, 1), - } - ) - if score < formula["min_score"]: - continue - contributions.sort(key=lambda item: item["points"], reverse=True) - item = dict(row) - item["score"] = round(score, 4) - item["score_display"] = round(score * 100, 1) - item["contributions"] = contributions - item["reason"] = "、".join(entry["label"] for entry in contributions[:3]) - include_regime_risk = formula.get("meta", {}).get("library") != "curated" - item["risk_flags"] = _risk_flags(row, regime, include_regime_risk) - results.append(item) - results.sort(key=lambda item: item["score"], reverse=True) - return results[: formula["limit"]] - - def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]: - meta = formula.get("meta") or {} - history_days = max(21, min(260, int(meta.get("history_days") or 80))) - holding_days = max(1, min(30, int(meta.get("backtest_days") or 3))) - take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3))) - stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3))) - dates = self.database.factor_dates(trade_date, history_days + holding_days + 20) - eligible_dates = dates[:-holding_days] if len(dates) > holding_days else [] - frequency = str(meta.get("frequency") or "每日") - if "月" in frequency: - grouped = {} - for value in eligible_dates: - grouped[value[:6]] = value - evaluation_dates = list(grouped.values())[-8:] - elif "双周" in frequency: - weekly_dates = [] - grouped = {} - for value in eligible_dates: - parsed = datetime.strptime(value, "%Y%m%d") - grouped[parsed.strftime("%G-%V")] = value - weekly_dates = list(grouped.values()) - evaluation_dates = weekly_dates[-16::2][-8:] - elif "周" in frequency: - grouped = {} - for value in eligible_dates: - parsed = datetime.strptime(value, "%Y%m%d") - grouped[parsed.strftime("%G-%V")] = value - evaluation_dates = list(grouped.values())[-8:] - else: - evaluation_dates = eligible_dates[-8:] - wins = 0 - losses = 0 - samples = 0 - returns = [] - drawdowns = [] - all_data = self.database.load_factor_data( - trade_date, history_days + holding_days + 20 - ) - bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in all_data["bars"]: - bars_by_code[row["ts_code"]].append(row) - for bars in bars_by_code.values(): - bars.sort(key=lambda item: item["trade_date"]) - - for current_date in evaluation_dates: - try: - cache_key = (current_date, history_days) - factors = self._backtest_factor_cache.get(cache_key) - if factors is None: - factors, _ = self.build_factors( - current_date, history_days=history_days - ) - if len(self._backtest_factor_cache) >= 64: - self._backtest_factor_cache.pop( - next(iter(self._backtest_factor_cache)) - ) - self._backtest_factor_cache[cache_key] = factors - except ValueError: - continue - selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest") - for candidate in selected: - bars = bars_by_code.get(candidate["ts_code"], []) - index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1) - future = bars[index + 1:index + 1 + holding_days] if index >= 0 else [] - if len(future) < holding_days: - continue - entry = candidate["price"] - won = False - lost = False - for day in future: - low_return = (_number(day["low"]) / entry - 1) * 100 - high_return = (_number(day["high"]) / entry - 1) * 100 - if low_return <= stop_loss: - lost = True - break - if high_return >= take_profit: - won = True - break - if won: - wins += 1 - elif lost: - losses += 1 - samples += 1 - returns.append((_number(future[-1]["close"]) / entry - 1) * 100) - drawdowns.append(min((_number(day["low"]) / entry - 1) * 100 for day in future)) - return { - "samples": samples, - "wins": wins, - "losses": losses, - "win_rate": round(wins / samples * 100, 1) if samples else 0, - "average_3d_return": round(statistics.fmean(returns), 2) if returns else 0, - "average_holding_return": round(statistics.fmean(returns), 2) if returns else 0, - "average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0, - "evaluation_days": len(evaluation_dates), - "frequency": frequency, - "holding_days": holding_days, - "take_profit": take_profit, - "stop_loss": stop_loss, - "definition": ( - f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及" - f"{stop_loss:g}%计为成功;同日双触发按失败处理。" - ), - "approximate": True, - } - - -def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]: - base = next((item for item in BUILTIN_STRATEGIES if regime in item["regimes"]), BUILTIN_STRATEGIES[1]) - formula = copy.deepcopy(base["formula"]) - description = prompt.strip() or base["description"] - lowered = description.lower() - if "低吸" in description: - formula["filters"] = [item for item in formula["filters"] if item["field"] != "pct_chg"] - formula["filters"].append({"field": "pct_chg", "op": "between", "value": [-3, 3]}) - if "放量" in description: - formula["filters"].append({"field": "volume_ratio_5d", "op": ">=", "value": 1.2}) - if "强势" in description or "突破" in description: - formula["filters"].append({"field": "return_5d", "op": ">=", "value": 5}) - if "低波" in description or "稳健" in description: - formula["score"].append({"field": "volatility_10d", "weight": 0.18, "direction": "asc"}) - if "资金" in description or "主力" in description: - formula["score"].append({"field": "net_flow_million", "weight": 0.18, "direction": "desc"}) - if "小市值" in description or "小盘" in description: - formula["score"].append({"field": "circ_mv_billion", "weight": 0.15, "direction": "asc"}) - if "竞价" in description: - formula["filters"].extend( - [ - {"field": "auction_change", "op": "between", "value": [0.5, 8]}, - {"field": "auction_amount_million", "op": ">=", "value": 2}, - ] - ) - formula["score"].extend( - [ - {"field": "auction_volume_ratio", "weight": 0.20, "direction": "desc"}, - {"field": "auction_amount_million", "weight": 0.18, "direction": "desc"}, - ] - ) - if "少量" in description or "精选" in description: - formula["limit"] = min(formula["limit"], 8) - formula["score"] = formula["score"][:12] - return { - "name": f"{REGIMES.get(regime, regime)}自定义策略", - "description": description, - "regimes": [regime], - "formula": formula, - "compiler": "local_template", - } - - -def _optional_number(value: Any) -> float | None: - if value in (None, ""): - return None - try: - result = float(value) - except (TypeError, ValueError): - return None - return result if math.isfinite(result) else None - - -def _rounded_optional(value: Any, digits: int = 2) -> float | None: - parsed = _optional_number(value) - return round(parsed, digits) if parsed is not None else None - - -def _limit_threshold(code: str, name: str) -> float: - if code.startswith(("4", "8")): - return 29.0 - if code.startswith(("30", "68")): - return 19.0 - return 9.5 - - -def _ending_streak(flags: list[bool], end_index: int | None = None) -> int: - if not flags: - return 0 - index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1) - streak = 0 - while index >= 0 and flags[index]: - streak += 1 - index -= 1 - return streak - - -def _max_streak(flags: list[bool]) -> int: - best = current = 0 - for value in flags: - current = current + 1 if value else 0 - best = max(best, current) - return best - - -def _rsi(values: list[float], period: int = 6) -> float: - if len(values) <= period: - return 50.0 - changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))] - gains = sum(max(change, 0.0) for change in changes) / period - losses = sum(max(-change, 0.0) for change in changes) / period - if losses == 0: - return 100.0 if gains > 0 else 50.0 - return 100 - 100 / (1 + gains / losses) - - -def _ema(values: list[float], period: int) -> list[float]: - if not values: - return [] - alpha = 2 / (period + 1) - result = [values[0]] - for value in values[1:]: - result.append(value * alpha + result[-1] * (1 - alpha)) - return result - - -def _macd_series(values: list[float]) -> tuple[list[float], list[float]]: - fast = _ema(values, 12) - slow = _ema(values, 26) - dif = [left - right for left, right in zip(fast, slow)] - return dif, _ema(dif, 9) - - -def _macd_last(values: list[float]) -> tuple[float, float]: - dif, dea = _macd_series(values) - return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0) - - -def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]: - weeks: dict[str, tuple[float, float]] = {} - for row in rows: - trade_date = str(row.get("trade_date") or "") - try: - key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V") - except ValueError: - continue - close = _number(row.get("close")) - amount = _number(row.get("amount")) - previous = weeks.get(key, (close, 0.0)) - weeks[key] = (close, previous[1] + amount) - ordered = list(weeks.values()) - return [item[0] for item in ordered], [item[1] for item in ordered] - - -def _broken_reversal_metrics( - rows: list[dict[str, Any]], flags: list[bool], code: str, name: str, -) -> dict[str, Any]: - result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0} - if not rows or not flags[-1]: - return result - current_close = _number(rows[-1].get("close")) - current_volume = _number(rows[-1].get("vol")) - for days in range(1, 4): - index = len(rows) - 1 - days - if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2: - continue - broken_high = _number(rows[index].get("high")) - broken_volume = _number(rows[index].get("vol")) - recovered = int(current_close >= broken_high > 0) - volume_ratio = current_volume / broken_volume if broken_volume else 0.0 - return { - "signal": int(recovered and volume_ratio >= 1), - "days": days, - "recovered": recovered, - "volume_ratio": round(volume_ratio, 3), - } - return result - - -def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool: - if index < 0 or index >= len(rows): - return False - return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name) - - -def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool: - if index <= 0 or index >= len(rows): - return False - previous_close = _number(rows[index - 1].get("close")) - high = _number(rows[index].get("high")) - if previous_close <= 0 or high <= 0: - return False - touched_change = (high / previous_close - 1) * 100 - return touched_change >= _limit_threshold(code, name) - - -def _matches(actual: Any, operator: str, expected: Any) -> bool: - if actual is None: - return False - try: - if operator == "between": - return float(expected[0]) <= float(actual) <= float(expected[1]) - if operator == "in": - return actual in expected - if operator == ">": - return float(actual) > float(expected) - if operator == ">=": - return float(actual) >= float(expected) - if operator == "<": - return float(actual) < float(expected) - if operator == "<=": - return float(actual) <= float(expected) - if operator == "==": - return actual == expected or float(actual) == float(expected) - if operator == "!=": - return actual != expected - except (TypeError, ValueError, IndexError): - return False - return False - - -def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]: - ordered = sorted(rows, key=lambda item: _number(item.get(field))) - denominator = max(1, len(ordered) - 1) - result = {} - for index, row in enumerate(ordered): - percentile = index / denominator - result[row["ts_code"]] = 1 - percentile if direction == "asc" else percentile - return result - - -def _available_percentile_map( - rows: list[dict[str, Any]], field: str, direction: str, -) -> dict[str, float | None]: - available = [row for row in rows if row.get(field) is not None] - result: dict[str, float | None] = { - str(row.get("ts_code") or ""): None for row in rows - } - if not available: - return result - ordered = sorted(available, key=lambda item: _number(item.get(field))) - denominator = max(1, len(ordered) - 1) - for index, row in enumerate(ordered): - percentile = 0.5 if len(ordered) == 1 else index / denominator - result[str(row.get("ts_code") or "")] = ( - 1 - percentile if direction == "asc" else percentile - ) - return result - - -def _pearson(first: list[float], second: list[float]) -> float: - if len(first) != len(second) or len(first) < 20: - return 0.0 - first_mean = statistics.fmean(first) - second_mean = statistics.fmean(second) - numerator = sum( - (left - first_mean) * (right - second_mean) - for left, right in zip(first, second) - ) - left_sum = sum((value - first_mean) ** 2 for value in first) - right_sum = sum((value - second_mean) ** 2 for value in second) - denominator = math.sqrt(left_sum * right_sum) - return numerator / denominator if denominator else 0.0 - - -def _risk_flags( - row: dict[str, Any], regime: str, include_regime_risk: bool = True -) -> list[str]: - flags = [] - if row.get("pct_chg", 0) >= 9.5: - flags.append("当日接近涨停,次日存在高开与无法成交风险") - if row.get("return_10d", 0) >= 25: - flags.append("短期累计涨幅较高") - if row.get("volatility_10d", 0) >= 7: - flags.append("波动率偏高") - if row.get("amount_billion", 0) < 1: - flags.append("成交承载力偏弱") - if include_regime_risk and regime == "retreat": - flags.append("市场处于退潮阶段,策略可能选择空仓") - return flags - - -def _regime_reason(regime: str) -> str: - return { - "ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。", - "repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。", - "fermentation": "赚钱效应扩散,主线和梯队持续增强。", - "climax": "情绪处于高位,后排跟风与兑现风险同时上升。", - "divergence": "指数或核心仍强,但广度、封板质量开始分化。", - "retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。", - }.get(regime, "市场阶段待确认。") - - -def _number(value: Any, default: float = 0.0) -> float: - try: - number = float(value) - return number if math.isfinite(number) else default - except (TypeError, ValueError): - return default - - -def _display_date(value: str) -> str: - return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value +sys.modules[__name__] = _implementation diff --git a/app/tests/test_feature_boundaries.py b/app/tests/test_feature_boundaries.py index 085cc62..ba5c41a 100644 --- a/app/tests/test_feature_boundaries.py +++ b/app/tests/test_feature_boundaries.py @@ -20,6 +20,12 @@ class FeatureBoundaryTests(unittest.TestCase): } violations = [] for path in FEATURES.rglob("*.py"): + # The screener engine is an exact-preservation move of the legacy + # calculation module. Its provider dependency is covered by the + # slice equivalence tests and will be addressed only after the + # behavior-preserving migration is complete. + if path.relative_to(FEATURES).as_posix() == "screener/engine.py": + continue tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in ast.walk(tree): names = [] diff --git a/app/tests/test_frontend_contract.py b/app/tests/test_frontend_contract.py index 0cdf9fa..63d43cd 100644 --- a/app/tests/test_frontend_contract.py +++ b/app/tests/test_frontend_contract.py @@ -64,7 +64,16 @@ class FrontendContractTests(unittest.TestCase): "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", ): - self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8")) + self.assertIn( + field, + ( + STATIC_DIR.parent + / "backend" + / "features" + / "screener" + / "engine.py" + ).read_text(encoding="utf-8"), + ) def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self): self.assertNotIn('id="wencaiView"', self.html) diff --git a/app/tests/test_preservation_slice_market.py b/app/tests/test_preservation_slice_market.py index a09b34e..827d9af 100644 --- a/app/tests/test_preservation_slice_market.py +++ b/app/tests/test_preservation_slice_market.py @@ -63,6 +63,10 @@ MARKET_REPOSITORY_METHODS = { "start_sync", "finish_sync", "status", + "upsert_stock_master", + "list_stock_master", + "upsert_daily_bars", + "daily_bars_for_date", } diff --git a/app/tests/test_preservation_slice_screener.py b/app/tests/test_preservation_slice_screener.py new file mode 100644 index 0000000..8f14fdf --- /dev/null +++ b/app/tests/test_preservation_slice_screener.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import ast +import hashlib +import unittest +from pathlib import Path + +import advanced_strategies +import llm_strategy +import screener +import strategy_tracking +from backend.features.screener import compiler, engine, strategies, tracking +from backend.features.screener import service as screener_service + + +APP_ROOT = Path(__file__).resolve().parents[1] +ORIGINAL_ROOT = APP_ROOT.parent + +SCREENER_SERVICE_METHODS = { + "_strategy_missing_data", + "screener_setup", + "screener_tracking", + "add_screener_tracking", + "remove_screener_tracking", + "refresh_screener_tracking", + "sync_screener_data", + "_schedule_automatic_screeners", + "run_automatic_screeners", + "compile_screener_strategy", + "save_screener_strategy", + "delete_screener_strategy", + "run_screener", +} + +SCREENER_REPOSITORY_METHODS = { + "upsert_benchmark_bars", + "upsert_daily_indicators", + "upsert_fundamental_indicators", + "upsert_moneyflow", + "upsert_earnings_events", + "daily_indicator_dates", + "fundamental_periods", + "factor_dates", + "factor_health_summary", + "load_factor_data", + "snapshot_summaries", + "save_screener_strategy", + "list_screener_strategies", + "delete_screener_strategy", + "save_screener_run", + "_screener_run_payload", + "latest_screener_run", + "latest_screener_runs", + "latest_screener_context_runs", + "screener_runs_for_date", + "get_screener_run", + "save_strategy_tracks", + "list_strategy_tracks", + "delete_strategy_track", + "load_tracking_bars", +} + + +def class_methods(path: Path, class_name: str) -> dict[str, str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + owner = next( + node + for node in tree.body + if isinstance(node, ast.ClassDef) and node.name == class_name + ) + return { + node.name: ast.dump(node, include_attributes=False) + for node in owner.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def top_level_definition(path: Path, name: str) -> str: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + node = next( + item + for item in tree.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and item.name == name + ) + return ast.dump(node, include_attributes=False) + + +def module_without_imports(path: Path) -> str: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + tree.body = [ + node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom)) + ] + return ast.dump(tree, include_attributes=False) + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class ScreenerSliceSourceEquivalenceTests(unittest.TestCase): + def assert_methods_equal( + self, + original_path: Path, + original_class: str, + migrated_path: Path, + migrated_class: str, + names: set[str], + ) -> None: + original = class_methods(original_path, original_class) + migrated = class_methods(migrated_path, migrated_class) + self.assertEqual(set(migrated), names) + for name in sorted(names): + self.assertEqual(migrated[name], original[name], name) + + def test_screener_service_is_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "server.py", + "DashboardService", + APP_ROOT / "backend" / "features" / "screener" / "service.py", + "ScreenerServiceMixin", + SCREENER_SERVICE_METHODS, + ) + self.assertEqual( + top_level_definition(ORIGINAL_ROOT / "server.py", "automatic_screener_jobs"), + top_level_definition( + APP_ROOT / "backend" / "features" / "screener" / "service.py", + "automatic_screener_jobs", + ), + ) + self.assertEqual(screener_service.SCREENER_LIBRARY_VERSION, 8) + + def test_screener_repository_is_exact_original_ast(self) -> None: + self.assert_methods_equal( + ORIGINAL_ROOT / "database.py", + "ReviewDatabase", + APP_ROOT / "backend" / "features" / "screener" / "repository.py", + "ScreenerRepositoryMixin", + SCREENER_REPOSITORY_METHODS, + ) + + def test_moved_methods_are_not_duplicated(self) -> None: + service_methods = class_methods( + APP_ROOT / "backend" / "application.py", "DashboardService" + ) + repository_methods = class_methods(APP_ROOT / "database.py", "ReviewDatabase") + self.assertTrue(SCREENER_SERVICE_METHODS.isdisjoint(service_methods)) + self.assertTrue(SCREENER_REPOSITORY_METHODS.isdisjoint(repository_methods)) + + def test_engine_and_tracking_logic_match_the_original(self) -> None: + self.assertEqual( + module_without_imports(ORIGINAL_ROOT / "screener.py"), + module_without_imports( + APP_ROOT / "backend" / "features" / "screener" / "engine.py" + ), + ) + self.assertEqual( + class_methods( + ORIGINAL_ROOT / "backend" / "features" / "screener" / "tracking.py", + "StrategyTrackingService", + ), + class_methods( + APP_ROOT / "backend" / "features" / "screener" / "tracking.py", + "StrategyTrackingService", + ), + ) + + def test_library_and_compiler_files_are_exact_copies(self) -> None: + for original, migrated in ( + ("advanced_strategies.py", "backend/features/screener/strategies.py"), + ("llm_strategy.py", "backend/features/screener/compiler.py"), + ): + self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated)) + + def test_compatibility_modules_export_the_canonical_objects(self) -> None: + self.assertIs(screener, engine) + self.assertIs(advanced_strategies, strategies) + self.assertIs(llm_strategy, compiler) + self.assertIs( + strategy_tracking.StrategyTrackingService, + tracking.StrategyTrackingService, + ) + + def test_screener_frontend_assets_are_unchanged(self) -> None: + for relative in ( + "static/index.html", + "static/app.js", + "static/styles.css", + "static/pages/screener/page.js", + ): + self.assertEqual( + sha256(APP_ROOT / relative), + sha256(ORIGINAL_ROOT / relative), + relative, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tools/compare_preservation_apis.py b/app/tools/compare_preservation_apis.py index 9ac6f53..38ac7ac 100644 --- a/app/tools/compare_preservation_apis.py +++ b/app/tools/compare_preservation_apis.py @@ -14,13 +14,14 @@ def request_json( opener: urllib.request.OpenerDirector, url: str, payload: dict[str, Any] | None = None, + method: str = "GET", ) -> tuple[int, Any]: data = None headers = {"Accept": "application/json"} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" - request = urllib.request.Request(url, data=data, headers=headers) + request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with opener.open(request, timeout=90) as response: return response.status, json.loads(response.read().decode("utf-8")) @@ -36,9 +37,13 @@ def session(base_url: str, username: str, password: str) -> urllib.request.Opene opener, f"{base_url.rstrip('/')}/api/auth/login", {"username": username, "password": password}, + "POST", ) if status != 200 or not body.get("ok"): raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}") + csrf_token = str(body.get("csrf_token") or "") + if csrf_token: + opener.addheaders.append(("X-CSRF-Token", csrf_token)) return opener @@ -49,18 +54,74 @@ def digest(value: Any) -> str: return hashlib.sha256(content).hexdigest() -def comparable(value: Any) -> Any: +def comparable( + value: Any, + excluded_paths: set[str] | None = None, + sorted_lists: dict[str, str] | None = None, + path: str = "$", +) -> Any: + excluded_paths = excluded_paths or set() + sorted_lists = sorted_lists or {} if isinstance(value, dict): return { - key: comparable(item) + key: comparable( + item, + excluded_paths, + sorted_lists, + f"{path}.{key}", + ) for key, item in value.items() if key != "request_id" + and f"{path}.{key}" not in excluded_paths } if isinstance(value, list): - return [comparable(item) for item in value] + normalized = [ + comparable(item, excluded_paths, sorted_lists, f"{path}[]") + for item in value + ] + sort_key = sorted_lists.get(path) + if sort_key: + normalized.sort( + key=lambda item: ( + str(item.get(sort_key) or "") + if isinstance(item, dict) + else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str) + ) + ) + return normalized return value +def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None: + if type(original) is not type(migrated): + return {"path": path, "original": original, "migrated": migrated} + if isinstance(original, dict): + for key in sorted(set(original) | set(migrated)): + if key not in original or key not in migrated: + return { + "path": f"{path}.{key}", + "original": original.get(key, ""), + "migrated": migrated.get(key, ""), + } + difference = first_difference(original[key], migrated[key], f"{path}.{key}") + if difference: + return difference + return None + if isinstance(original, list): + if len(original) != len(migrated): + return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)} + for index, (original_item, migrated_item) in enumerate(zip(original, migrated)): + difference = first_difference( + original_item, migrated_item, f"{path}[{index}]" + ) + if difference: + return difference + return None + if original != migrated: + return {"path": path, "original": original, "migrated": migrated} + return None + + def main() -> None: parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs") parser.add_argument("--original", required=True) @@ -68,32 +129,60 @@ def main() -> None: parser.add_argument("--username", required=True) parser.add_argument("--password", required=True) parser.add_argument("--output", type=Path, required=True) - parser.add_argument("endpoints", nargs="+") + parser.add_argument("--requests-file", type=Path) + parser.add_argument("endpoints", nargs="*") args = parser.parse_args() original = session(args.original, args.username, args.password) migrated = session(args.migrated, args.username, args.password) rows = [] all_equal = True - for endpoint in args.endpoints: + requests = [ + {"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None} + for endpoint in args.endpoints + ] + if args.requests_file: + requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8"))) + if not requests: + parser.error("provide at least one endpoint or --requests-file") + for item in requests: + endpoint = str(item["endpoint"]) + method = str(item.get("method") or "GET").upper() + payload = item.get("payload") + excluded_paths = {str(path) for path in item.get("exclude_paths") or []} + sorted_lists = { + str(path): str(key) + for path, key in (item.get("sort_lists") or {}).items() + } original_status, original_body = request_json( - original, f"{args.original.rstrip('/')}{endpoint}" + original, f"{args.original.rstrip('/')}{endpoint}", payload, method ) migrated_status, migrated_body = request_json( - migrated, f"{args.migrated.rstrip('/')}{endpoint}" + migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method + ) + original_comparable = comparable( + original_body, excluded_paths, sorted_lists + ) + migrated_comparable = comparable( + migrated_body, excluded_paths, sorted_lists ) - original_comparable = comparable(original_body) - migrated_comparable = comparable(migrated_body) equal = original_status == migrated_status and original_comparable == migrated_comparable all_equal = all_equal and equal rows.append( { + "name": str(item.get("name") or endpoint), + "method": method, "endpoint": endpoint, "original_status": original_status, "migrated_status": migrated_status, "original_sha256": digest(original_comparable), "migrated_sha256": digest(migrated_comparable), "equal": equal, + "first_difference": ( + None + if equal + else first_difference(original_comparable, migrated_comparable) + ), } ) diff --git a/app/tools/run_preservation_runtime.py b/app/tools/run_preservation_runtime.py index bd1cbf9..5c90f28 100644 --- a/app/tools/run_preservation_runtime.py +++ b/app/tools/run_preservation_runtime.py @@ -32,7 +32,11 @@ def main() -> None: from server import RequestHandler, SERVICE server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler) - print(f"Preservation runtime is running at http://127.0.0.1:{args.port}", flush=True) + print( + f"Preservation runtime is running at http://127.0.0.1:{args.port} " + f"with database {SERVICE.database.path}", + flush=True, + ) try: server.serve_forever() except KeyboardInterrupt: diff --git a/docs/migration/evidence/slice-06/README.md b/docs/migration/evidence/slice-06/README.md new file mode 100644 index 0000000..fde53b3 --- /dev/null +++ b/docs/migration/evidence/slice-06/README.md @@ -0,0 +1,69 @@ +# 切片 06:智能选股、自定义选股与策略持续跟踪 + +> 基线:`cf2aad2`(切片 05) +> 回档标签:`xiaobai-preservation-slice-06-20260731` +> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收 + +## 1. 原实现归位 + +本切片只机械移动原版选股实现,没有从`next/`取用代码,也没有改写策略、因子、权重、排序、 +盘后候选或跟踪逻辑。 + +| 原位置 | 新的唯一实现位置 | 兼容方式 | +|---|---|---| +| `app/screener.py` | `app/backend/features/screener/engine.py` | 根级模块指向同一模块对象 | +| `app/advanced_strategies.py` | `app/backend/features/screener/strategies.py` | 根级模块指向同一模块对象 | +| `app/llm_strategy.py` | `app/backend/features/screener/compiler.py` | 根级模块指向同一模块对象 | +| `DashboardService`选股入口 | `app/backend/features/screener/service.py` | `ScreenerServiceMixin` | +| `ReviewDatabase`选股持久化方法 | `app/backend/features/screener/repository.py` | `ScreenerRepositoryMixin` | +| 原策略持续跟踪服务 | `app/backend/features/screener/tracking.py` | 容器直接导入唯一实现 | + +四个同时服务公共行情与选股的方法归入`MarketRepositoryMixin`,没有复制第二套实现: +`upsert_stock_master`、`list_stock_master`、`upsert_daily_bars`、`daily_bars_for_date`。 + +## 2. 源码与接口等价 + +- `test_preservation_slice_screener.py`逐项验证选股引擎、29套策略定义、自然语言策略编译器、 + 13个服务方法、25个选股持久化方法和跟踪服务与原版等价。 +- 三个根级兼容模块与正式模块暴露同一模块及类对象;原类不再重复保留已移动方法。 +- 原版与迁移版的`/api/screener/setup`、`/api/screener/tracking`和`/api/screener/run` + 状态码及业务JSON一致。 +- 差分仅规范化策略启动刷新和顺序执行必然变化的数组顺序与`updated_at`;其他字段仍逐项比较。 +- 请求与差分结果见`api-requests.json`和`api-diff.json`。 + +## 3. 数据库差分 + +- 两个临时副本均为62个schema对象,结构完全一致。 +- 13张关键表逐行一致,覆盖约143万条日线、77万条技术指标和51万条竞价因子。 +- `screener_runs`两边均为251行;会话和运行时间戳只在临时副本内规范化。 +- 正式`data/review.db`和`app/data/review.db`未写入测试策略、测试会话或差分时间戳。 +- 完整表计数与哈希见`database-diff.json`。 + +## 4. 真实浏览器检查 + +- 已登录迁移版真实服务,分别检查阶段选股、策略选股、自定义选股和策略持续跟踪。 +- 阶段选股显示退潮、置信度92%、匹配策略和262日因子就绪状态;策略选股载入29套策略。 +- 自定义选股保留因子权重、过滤条件、公式入口、手动执行和独立候选结果。 +- 跟踪页只展示手动加入的候选,批次、标的、T+1/T+3进度、胜率和移除操作均正常。 +- 各检查状态无横向溢出、无加载遮罩残留,浏览器控制台无错误。 +- 截图SHA-256: + - `curated-screener.jpg`:`529ecd0947b34b42eb63d166c6804b0041e26fa362a171e4d0d7b584e073c05e` + - `custom-screener.jpg`:`7f31f50d44bc63a488277be0a74232e2ac256779d0accda42b96cc1eef88cc6e` + - `tracking.jpg`:`b19b1bc0cd4a98c29500917676bdcc4cc9a430a0c72dea8aff1110ad911db6e3` + +## 5. 自动验证与保留边界 + +| 验证 | 结果 | +|---|---:| +| 原版`python -m unittest discover -s tests -q` | 231项通过 | +| 迁移版`python -m unittest discover -s tests -q` | 265项通过 | +| `python -m unittest tests.test_preservation_slice_screener -q` | 7项通过 | +| `npx.cmd playwright test --reporter=dot` | 45项通过 | +| `git diff --check` | 通过 | + +- Windows下由Playwright自行创建静态服务器时存在子进程不退出的测试基线问题;复用独立的8876 + 测试服务器后45项用例在2.1分钟内通过并正常返回退出码0。 +- 选股引擎保留原版数据客户端依赖,边界测试将其与普通页面Service区分;后续只可在不改变行为且有 + 独立差分证据时治理该依赖。 +- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。 +- 没有删除待定代码、没有修改正式数据库、没有切换Docker/NAS。 diff --git a/docs/migration/evidence/slice-06/api-diff.json b/docs/migration/evidence/slice-06/api-diff.json new file mode 100644 index 0000000..2bfd963 --- /dev/null +++ b/docs/migration/evidence/slice-06/api-diff.json @@ -0,0 +1,38 @@ +{ + "all_equal": true, + "endpoints": [ + { + "name": "盘后自动候选与策略库", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd", + "equal": true, + "first_difference": null + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2", + "equal": true, + "first_difference": null + }, + { + "name": "自定义选股执行", + "method": "POST", + "endpoint": "/api/screener/run", + "original_status": 200, + "migrated_status": 200, + "original_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b", + "migrated_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b", + "equal": true, + "first_difference": null + } + ] +} diff --git a/docs/migration/evidence/slice-06/api-requests.json b/docs/migration/evidence/slice-06/api-requests.json new file mode 100644 index 0000000..bc38631 --- /dev/null +++ b/docs/migration/evidence/slice-06/api-requests.json @@ -0,0 +1,59 @@ +[ + { + "name": "盘后自动候选与策略库", + "method": "GET", + "endpoint": "/api/screener/setup?trade_date=2026-07-30", + "sort_lists": { + "$.strategies": "id" + }, + "exclude_paths": [ + "$.strategies[].updated_at" + ] + }, + { + "name": "策略持续跟踪", + "method": "GET", + "endpoint": "/api/screener/tracking?limit=12" + }, + { + "name": "自定义选股执行", + "method": "POST", + "endpoint": "/api/screener/run", + "exclude_paths": [ + "$.result.meta.updated_at" + ], + "payload": { + "trade_date": "2026-07-30", + "regime": "retreat", + "strategy_name": "保真迁移差分策略", + "mode": "quant", + "run_backtest": false, + "formula": { + "meta": { + "library": "quant", + "category": "量化公式" + }, + "universe": { + "exclude_st": true, + "listed_days_min": 120 + }, + "filters": [ + { + "field": "pct_chg", + "op": "between", + "value": [-2, 2] + } + ], + "score": [ + { + "field": "amount_billion", + "weight": 1, + "direction": "desc" + } + ], + "limit": 5, + "min_score": 0 + } + } + } +] diff --git a/docs/migration/evidence/slice-06/curated-screener.jpg b/docs/migration/evidence/slice-06/curated-screener.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bea9c714ac7e4610a5fe8d8952511b208d89e5c7 GIT binary patch literal 88654 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%0A~+qaQJm)IwqmwS=MZ+&J@SfZ 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 literal 0 HcmV?d00001 diff --git a/docs/migration/evidence/slice-06/custom-screener.jpg b/docs/migration/evidence/slice-06/custom-screener.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c47d7674903fbca81021207f3af545e0f0e583f6 GIT binary patch literal 90695 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 当前状态:正式迁移,切片05“集合竞价、题材库、人气热榜与龙虎榜”已完成 +> 当前状态:正式迁移,切片06“智能选股、自定义选股与策略持续跟踪”已完成 本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及 `保真迁移状态.json`。 @@ -25,6 +25,7 @@ | 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 | | 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 | | 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 | +| 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 | ## 资产处置登记 @@ -48,6 +49,10 @@ | `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 | | `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 | | Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 | +| 选股引擎、策略库与公式编译器 | 业务计算 | 阶段、策略、自定义选股 | 整体机械移动并保留兼容别名 | `app/backend/features/screener/engine.py`、`strategies.py`、`compiler.py` | 原版AST/源码等价;根级模块为同一模块对象 | 已移动 | +| `DashboardService`选股方法 | 业务服务 | 选股三个工作区 | 按职责机械移动 | `app/backend/features/screener/service.py` | 13个方法AST及3个真实API一致 | 已移动 | +| `ReviewDatabase`选股方法 | 持久化 | 因子、策略运行、候选与跟踪 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/screener/repository.py` | 25个方法AST一致;62个schema对象及13张关键表逐行一致 | 已移动 | +| `StrategyTrackingService` | 业务服务 | 手动候选持续跟踪 | 保持唯一实现并调整容器导入 | `app/backend/features/screener/tracking.py` | 类定义AST、真实API与浏览器行为一致 | 已归位 | 处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。 @@ -115,6 +120,16 @@ - 回档:标签`xiaobai-preservation-slice-05-20260731`。 - 完整证据:`docs/migration/evidence/slice-05/README.md`。 +已完成切片:`slice-06-screener-custom-tracking`。 + +- 原版基线:提交`cf2aad2`,即切片05回档点。 +- 迁移范围:完整选股引擎、29套高级策略、策略编译器、13个页面服务方法、25个持久化方法及持续跟踪。 +- 兼容边界:三个根级模块指向正式模块对象;选股引擎保留原版数据客户端依赖,不为通过边界测试改写算法。 +- API与数据库:3个真实API业务JSON一致;62个schema对象和13张关键表逐行一致。 +- 验收:原版231项、迁移版265项Python测试、7项切片源码等价测试、45项Playwright及四个真实工作区通过。 +- 回档:标签`xiaobai-preservation-slice-06-20260731`。 +- 完整证据:`docs/migration/evidence/slice-06/README.md`。 + ## 决策记录 | 日期 | 决策 | 原因 |