Files
xiaobaifupan/app/backend/features/screener/engine.py
T

2204 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "市场阶段待确认。")