feat: expand screeners and stabilize interactive feedback
This commit is contained in:
+581
-38
@@ -8,6 +8,7 @@ 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
|
||||
@@ -23,18 +24,45 @@ REGIMES = {
|
||||
}
|
||||
|
||||
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": "相对强度",
|
||||
@@ -66,22 +94,52 @@ FACTOR_FIELDS = {
|
||||
"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 = {
|
||||
"行情动量": [
|
||||
"pct_chg", "return_5d", "return_10d", "above_ma20", "relative_strength",
|
||||
"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", "volatility_10d", "amount_billion", "turnover_rate",
|
||||
"net_flow_million", "large_flow_million", "previous_amount_billion",
|
||||
"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_limit_count", "sector_up_count", "sector_breadth_ma20",
|
||||
"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",
|
||||
"no_limit_30d", "had_limit_80d", "max_abs_change_15d",
|
||||
"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",
|
||||
@@ -92,7 +150,7 @@ FACTOR_GROUPS = {
|
||||
],
|
||||
"财务质量": [
|
||||
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
|
||||
"ocf_to_opincome",
|
||||
"ocf_to_opincome", "financial_risk",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -496,6 +554,112 @@ CURATED_STRATEGIES = [
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -521,6 +685,7 @@ class FactorDataService:
|
||||
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")
|
||||
@@ -532,9 +697,11 @@ class FactorDataService:
|
||||
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]
|
||||
existing_auction = set(self.database.auction_factor_dates(trade_date, lookback + 10))
|
||||
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 dates if value not in existing_auction or value == trade_date
|
||||
value for value in auction_source_dates
|
||||
if value not in existing_auction or value == trade_date
|
||||
]
|
||||
long_calendar = self.client.query(
|
||||
"trade_cal",
|
||||
@@ -551,7 +718,7 @@ class FactorDataService:
|
||||
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)
|
||||
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(
|
||||
@@ -584,6 +751,16 @@ class FactorDataService:
|
||||
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):
|
||||
@@ -620,17 +797,22 @@ class FactorDataService:
|
||||
except TushareError as exc:
|
||||
notices.append(f"竞价因子接口不可用:{exc}")
|
||||
break
|
||||
try:
|
||||
moneyflow = self.client.query(
|
||||
"moneyflow",
|
||||
{"trade_date": trade_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)
|
||||
except TushareError as exc:
|
||||
moneyflow_count = 0
|
||||
notices.append(f"资金流接口不可用:{exc}")
|
||||
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,
|
||||
@@ -638,10 +820,12 @@ class FactorDataService:
|
||||
"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),
|
||||
@@ -651,6 +835,7 @@ class FactorDataService:
|
||||
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 = {
|
||||
@@ -667,7 +852,7 @@ class ScreenerEngine:
|
||||
|
||||
def detect_regime(self, trade_date: str) -> dict[str, Any]:
|
||||
series = latest_contiguous_history(
|
||||
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 240))
|
||||
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260))
|
||||
)
|
||||
if not series:
|
||||
return {
|
||||
@@ -747,12 +932,32 @@ class ScreenerEngine:
|
||||
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)
|
||||
factors, actual_date = self.build_factors(trade_date, realtime_snapshot)
|
||||
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
|
||||
@@ -769,9 +974,20 @@ class ScreenerEngine:
|
||||
"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"
|
||||
),
|
||||
@@ -791,7 +1007,11 @@ class ScreenerEngine:
|
||||
"formula": formula,
|
||||
"candidates": candidates,
|
||||
"backtest": backtest,
|
||||
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。",
|
||||
"disclaimer": (
|
||||
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
|
||||
if mode == "curated"
|
||||
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
|
||||
),
|
||||
}
|
||||
run_id = self.database.save_screener_run(
|
||||
user_id, actual_date, regime, strategy_name, formula, result, mode
|
||||
@@ -803,8 +1023,10 @@ class ScreenerEngine:
|
||||
self,
|
||||
trade_date: str,
|
||||
realtime_snapshot: dict[str, Any] | None = None,
|
||||
history_days: int = 80,
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
data = self.database.load_factor_data(trade_date, 80)
|
||||
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 个交易日,请先同步因子数据。")
|
||||
@@ -822,7 +1044,18 @@ class ScreenerEngine:
|
||||
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", [])
|
||||
@@ -863,6 +1096,7 @@ class ScreenerEngine:
|
||||
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:
|
||||
@@ -906,6 +1140,72 @@ class ScreenerEngine:
|
||||
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,
|
||||
@@ -914,12 +1214,32 @@ class ScreenerEngine:
|
||||
"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),
|
||||
"above_ma20": int(closes[-1] > statistics.fmean(closes[-20:])),
|
||||
"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
|
||||
@@ -945,8 +1265,19 @@ class ScreenerEngine:
|
||||
"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),
|
||||
@@ -957,6 +1288,28 @@ class ScreenerEngine:
|
||||
"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,
|
||||
@@ -968,18 +1321,65 @@ class ScreenerEngine:
|
||||
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in factors:
|
||||
sectors[row["sector"]].append(row)
|
||||
for sector_rows in sectors.values():
|
||||
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(
|
||||
@@ -1030,20 +1430,50 @@ class ScreenerEngine:
|
||||
item["score_display"] = round(score * 100, 1)
|
||||
item["contributions"] = contributions
|
||||
item["reason"] = "、".join(entry["label"] for entry in contributions[:3])
|
||||
item["risk_flags"] = _risk_flags(row, regime)
|
||||
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]:
|
||||
dates = self.database.factor_dates(trade_date, 55)
|
||||
evaluation_dates = dates[20:-3][-8:]
|
||||
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, 60)
|
||||
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)
|
||||
@@ -1052,15 +1482,25 @@ class ScreenerEngine:
|
||||
|
||||
for current_date in evaluation_dates:
|
||||
try:
|
||||
factors, _ = self.build_factors(current_date)
|
||||
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 + 4] if index >= 0 else []
|
||||
if len(future) < 3:
|
||||
future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
|
||||
if len(future) < holding_days:
|
||||
continue
|
||||
entry = candidate["price"]
|
||||
won = False
|
||||
@@ -1068,10 +1508,10 @@ class ScreenerEngine:
|
||||
for day in future:
|
||||
low_return = (_number(day["low"]) / entry - 1) * 100
|
||||
high_return = (_number(day["high"]) / entry - 1) * 100
|
||||
if low_return <= -3:
|
||||
if low_return <= stop_loss:
|
||||
lost = True
|
||||
break
|
||||
if high_return >= 3:
|
||||
if high_return >= take_profit:
|
||||
won = True
|
||||
break
|
||||
if won:
|
||||
@@ -1087,9 +1527,17 @@ class ScreenerEngine:
|
||||
"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),
|
||||
"definition": "收盘后选股,未来3日先触及+3%且未先触及-3%计为成功;同日双触发按失败处理。",
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -1160,6 +1608,99 @@ def _limit_threshold(code: str, name: str) -> float:
|
||||
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
|
||||
@@ -1212,7 +1753,9 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
|
||||
return result
|
||||
|
||||
|
||||
def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
|
||||
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("当日接近涨停,次日存在高开与无法成交风险")
|
||||
@@ -1222,7 +1765,7 @@ def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
|
||||
flags.append("波动率偏高")
|
||||
if row.get("amount_billion", 0) < 1:
|
||||
flags.append("成交承载力偏弱")
|
||||
if regime == "retreat":
|
||||
if include_regime_risk and regime == "retreat":
|
||||
flags.append("市场处于退潮阶段,策略可能选择空仓")
|
||||
return flags
|
||||
|
||||
|
||||
Reference in New Issue
Block a user