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 backend.bootstrap.config import display_compact_date as _display_date
from backend.data.numbers import finite_number as _number
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES
from database import ReviewDatabase
from backend.features.sentiment.engine import build_sentiment_history, latest_contiguous_history


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, "市场阶段待确认。")
