from __future__ import annotations import copy import json import math import statistics from collections import defaultdict from datetime import datetime, timedelta from typing import Any from advanced_strategies import ADVANCED_CURATED_STRATEGIES from database import ReviewDatabase from sentiment_engine import build_sentiment_history, latest_contiguous_history from tushare_client import TushareClient, TushareError REGIMES = { "ice": "冰点", "repair": "修复", "fermentation": "发酵", "climax": "高潮", "divergence": "分化", "retreat": "退潮", } FACTOR_FIELDS = { "close": "收盘价", "pct_chg": "当日涨幅", "return_5d": "5日涨幅", "return_10d": "10日涨幅", "return_20d": "20日涨幅", "return_60d": "60日涨幅", "return_5d_rank": "5日涨幅排名", "momentum_60_5": "中期动量", "momentum_60_5_rank": "中期动量排名", "above_ma20": "站上20日线", "rsi_6": "RSI(6)", "ma60_slope": "60日线斜率", "ma20_slope_5d": "20日线5日斜率", "ma_bull_alignment": "均线多头排列", "drawdown_from_high_250": "距250日高点回撤", "donchian_breakout_pct": "唐奇安突破幅度", "range_20d": "20日振幅", "rs_high_120": "RS线120日新高", "excess_return_60d": "60日超额收益", "weekly_trend_signal": "周线趋势信号", "daily_buy_trigger": "日线买点", "weekly_amount_trend": "周成交趋势", "volume_ratio_5d": "5日量比", "turnover_5d": "5日累计换手", "volatility_10d": "10日波动率", "amount_billion": "成交额", "turnover_rate": "换手率", "circ_mv_billion": "流通市值", "net_flow_million": "主力净流入", "large_flow_million": "大单净流入", "net_flow_5d_million": "5日主力净流入", "flow_to_circ_mv_5d": "5日净流入占流通市值", "sector_strength": "板块强度", "sector_return_5d": "行业5日涨幅", "sector_return_20d": "行业20日涨幅", "sector_momentum_rank": "行业20日动量排名", "sector_stock_momentum_rank": "行业内个股动量排名", "sector_net_flow_5d_million": "行业5日主力净流入", "sector_flow_rank": "行业资金流排名", "sector_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": "经营现金流质量", "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_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", ], } 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质量低波": ( "震荡偏弱、重视盈利质量与回撤控制的市场", "主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向", ), "低估值现金流白马": ( "估值修复、价值回归及防守配置阶段", "低估值可能来自基本面持续走弱,需警惕价值陷阱", ), "高增长合理估值": ( "业绩驱动、成长风格占优且趋势获得确认的阶段", "增长预期下修或估值快速收缩时,回撤可能明显放大", ), "行业宽度主线": ( "主线清晰、行业内部多数个股同步走强的行情", "板块快速轮动时,宽度信号容易在确认后迅速衰减", ), "首板低开": ( "情绪修复期的分歧转一致与首板次日承接", "退潮加速或低开缺少量能承接时,弱势可能继续扩大", ), "小碎步临界突破": ( "趋势蓄势、波动收敛后临近突破的结构市", "无量突破或指数剧烈震荡时,容易形成冲高回落", ), "连板龙头": ( "高度拓展、题材梯队完整且接力情绪活跃的阶段", "亏钱效应扩散或高位股集中退潮时,接力风险很高", ), "微盘三正": ( "小盘风格活跃、流动性宽松且风险偏好较高的行情", "风格切向大盘或微盘流动性收缩时,组合波动会显著上升", ), "首板高开弱转强": ( "竞价承接明确、短线情绪修复或主线发酵阶段", "高开缺乏板块共振时,竞价强势可能转为盘中兑现", ), "中期动量·强者恒强": ( "趋势延续、主升段及强弱分化清晰的行情", "无趋势震荡或快速轮动中,动量信号容易反复失效", ), "强者回调": ( "主升趋势未破、强势股完成良性回踩的窗口", "趋势已反转时,回调信号可能演变为下跌中继", ), "超跌反转": ( "急跌后恐慌释放充分、市场进入修复预期的阶段", "单边下跌初段容易过早介入,超跌不等于止跌", ), "相对强度新高": ( "指数偏弱但结构性主线明确,或机构抱团强化的行情", "基准快速补涨或强势方向瓦解时,相对优势可能迅速消失", ), "均线多头排列": ( "中期趋势向上、回撤有序的趋势市与主升段", "高位趋势末端或宽幅震荡中,均线信号通常反应滞后", ), "唐奇安通道突破": ( "整理末端、放量突破并启动新趋势的行情", "无量突破和宽幅震荡环境中,假突破出现概率较高", ), "周线趋势·日线买点": ( "中期趋势稳定、日线回踩或再启动的多周期共振阶段", "周线拐点尚未确认时,日线信号可能只是短暂反抽", ), "空间板": ( "市场高度持续拓展、板块梯队完整的强接力环境", "高度压缩或亏钱效应扩散时,最高板的补跌风险极高", ), "龙头首阴": ( "主线龙头仍有辨识度、首次分歧后存在回流预期的阶段", "题材退潮或龙头地位被替代后,首阴可能只是下跌起点", ), "断板反包": ( "强势题材分歧后快速修复、核心股重新获得资金承接时", "板块强度不足或反包缩量时,形态持续性通常较弱", ), "核按钮反核": ( "恐慌释放后出现明确承接、短线情绪转暖的窗口", "系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高", ), "行业动量轮动": ( "主线相对清晰、行业趋势能够延续两周以上的结构市", "行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减", ), "主力资金行业流入": ( "板块轮动初期、资金先于价格形成连续净流入的阶段", "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势", ), } 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) 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] = {} 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) valuation_dates = set(dates[-min(80, len(dates)):]) valuation_dates.update(last_open_by_year.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 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, "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 } 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")) 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), "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 = [] 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 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, } ) 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" ) for sector_name, sector_rows in sectors.items(): 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 ) 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 _risk_flags( row: dict[str, Any], regime: str, include_regime_risk: bool = True ) -> list[str]: flags = [] if row.get("pct_chg", 0) >= 9.5: flags.append("当日接近涨停,次日存在高开与无法成交风险") if row.get("return_10d", 0) >= 25: flags.append("短期累计涨幅较高") if row.get("volatility_10d", 0) >= 7: flags.append("波动率偏高") if row.get("amount_billion", 0) < 1: flags.append("成交承载力偏弱") if include_regime_risk and regime == "retreat": flags.append("市场处于退潮阶段,策略可能选择空仓") return flags def _regime_reason(regime: str) -> str: return { "ice": "情绪和赚钱效应处于低位,重点观察率先抗跌与转折信号。", "repair": "核心指标从低位改善,适合观察率先修复且有板块共振的方向。", "fermentation": "赚钱效应扩散,主线和梯队持续增强。", "climax": "情绪处于高位,后排跟风与兑现风险同时上升。", "divergence": "指数或核心仍强,但广度、封板质量开始分化。", "retreat": "情绪指标继续走弱,应提高筛选门槛并接受无候选结果。", }.get(regime, "市场阶段待确认。") def _number(value: Any, default: float = 0.0) -> float: try: number = float(value) return number if math.isfinite(number) else default except (TypeError, ValueError): return default def _display_date(value: str) -> str: return f"{value[:4]}-{value[4:6]}-{value[6:8]}" if len(value) == 8 else value