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 0000000..bea9c71 Binary files /dev/null and b/docs/migration/evidence/slice-06/curated-screener.jpg differ 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 0000000..c47d767 Binary files /dev/null and b/docs/migration/evidence/slice-06/custom-screener.jpg differ diff --git a/docs/migration/evidence/slice-06/database-diff.json b/docs/migration/evidence/slice-06/database-diff.json new file mode 100644 index 0000000..cf90a71 --- /dev/null +++ b/docs/migration/evidence/slice-06/database-diff.json @@ -0,0 +1,115 @@ +{ + "all_equal": true, + "schema": { + "object_count": 62, + "original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1", + "equal": true + }, + "tables": [ + { + "table": "stock_master", + "original_count": 5535, + "migrated_count": 5535, + "original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792", + "equal": true + }, + { + "table": "daily_bars", + "original_count": 1430501, + "migrated_count": 1430501, + "original_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6", + "migrated_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6", + "equal": true + }, + { + "table": "benchmark_bars", + "original_count": 261, + "migrated_count": 261, + "original_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9", + "migrated_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9", + "equal": true + }, + { + "table": "daily_indicators", + "original_count": 770238, + "migrated_count": 770238, + "original_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9", + "migrated_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9", + "equal": true + }, + { + "table": "fundamental_indicators", + "original_count": 49490, + "migrated_count": 49490, + "original_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d", + "migrated_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d", + "equal": true + }, + { + "table": "moneyflow_daily", + "original_count": 36368, + "migrated_count": 36368, + "original_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d", + "migrated_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d", + "equal": true + }, + { + "table": "earnings_events", + "original_count": 580, + "migrated_count": 580, + "original_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d", + "migrated_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d", + "equal": true + }, + { + "table": "auction_factors", + "original_count": 511914, + "migrated_count": 511914, + "original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1", + "equal": true + }, + { + "table": "popularity_factors", + "original_count": 232, + "migrated_count": 232, + "original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f", + "equal": true + }, + { + "table": "lhb_institution_daily", + "original_count": 47, + "migrated_count": 47, + "original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c", + "equal": true + }, + { + "table": "screener_strategies", + "original_count": 36, + "migrated_count": 36, + "original_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312", + "migrated_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312", + "equal": true + }, + { + "table": "screener_runs", + "original_count": 251, + "migrated_count": 251, + "original_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498", + "migrated_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498", + "equal": true + }, + { + "table": "strategy_tracks", + "original_count": 16, + "migrated_count": 16, + "original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4", + "equal": true + } + ] +} diff --git a/docs/migration/evidence/slice-06/tracking.jpg b/docs/migration/evidence/slice-06/tracking.jpg new file mode 100644 index 0000000..060afda Binary files /dev/null and b/docs/migration/evidence/slice-06/tracking.jpg differ diff --git a/docs/migration/保真迁移状态.json b/docs/migration/保真迁移状态.json index 144e800..1f9bb72 100644 --- a/docs/migration/保真迁移状态.json +++ b/docs/migration/保真迁移状态.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "updated_at": "2026-07-31T02:42:00+08:00", + "updated_at": "2026-07-31T03:56:00+08:00", "status": "active", "migration_mode": "behavior_preserving_source_migration", "source_of_truth": "current_original_webapp_runtime_and_source", @@ -9,10 +9,10 @@ "failed_roots": [ "next" ], - "current_slice": "slice-06-screener-custom-tracking", - "last_completed_slice": "slice-05-auction-themes-popularity-dragon-tiger", - "last_checkpoint": "xiaobai-preservation-slice-05-20260731", - "next_action": "capture_slice-06_screener_custom_selection_and_tracking_contracts_then_move_original_implementations", + "current_slice": "slice-07-mentor-skills-llm-streaming", + "last_completed_slice": "slice-06-screener-custom-tracking", + "last_checkpoint": "xiaobai-preservation-slice-06-20260731", + "next_action": "capture_slice-07_mentor_skill_model_pool_and_streaming_contracts_then_move_original_implementations", "authoritative_documents": [ "AGENTS.md", "docs/migration/原版保真迁移总纲.md", diff --git a/docs/migration/保真迁移账本.md b/docs/migration/保真迁移账本.md index 862fedd..cdc4f6c 100644 --- a/docs/migration/保真迁移账本.md +++ b/docs/migration/保真迁移账本.md @@ -1,6 +1,6 @@ # 小白复盘保真迁移账本 -> 当前状态:正式迁移,切片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`。 + ## 决策记录 | 日期 | 决策 | 原因 |