migration: preserve screener and tracking slice
This commit is contained in:
+4
-483
@@ -1,486 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical curated strategy library."""
|
||||||
|
|
||||||
from typing import Any
|
import sys
|
||||||
|
|
||||||
|
from backend.features.screener import strategies as _implementation
|
||||||
|
|
||||||
def _meta(
|
sys.modules[__name__] = _implementation
|
||||||
category: str,
|
|
||||||
quality: str,
|
|
||||||
frequency: str,
|
|
||||||
risk: str,
|
|
||||||
data_group: str,
|
|
||||||
history_days: int,
|
|
||||||
backtest_days: int,
|
|
||||||
take_profit: float,
|
|
||||||
stop_loss: float,
|
|
||||||
**extra: Any,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"library": "curated",
|
|
||||||
"category": category,
|
|
||||||
"quality": quality,
|
|
||||||
"frequency": frequency,
|
|
||||||
"risk": risk,
|
|
||||||
"data_group": data_group,
|
|
||||||
"history_days": history_days,
|
|
||||||
"backtest_days": backtest_days,
|
|
||||||
"take_profit": take_profit,
|
|
||||||
"stop_loss": stop_loss,
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES = [
|
|
||||||
{
|
|
||||||
"name": "中期动量·强者恒强",
|
|
||||||
"description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。",
|
|
||||||
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "close", "op": "between", "value": [3, 100]},
|
|
||||||
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.90},
|
|
||||||
{"field": "is_limit_up_today", "op": "==", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "momentum_60_5", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 25,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "强者回调",
|
|
||||||
"description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.70},
|
|
||||||
{"field": "return_5d_rank", "op": "<=", "value": 0.20},
|
|
||||||
{"field": "above_ma20", "op": "==", "value": 1},
|
|
||||||
{"field": "rsi_6", "op": "<=", "value": 30},
|
|
||||||
{"field": "no_limit_down_20d", "op": "==", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "momentum_60_5", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "return_5d", "weight": 0.33, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "超跌反转",
|
|
||||||
"description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。",
|
|
||||||
"regimes": ["ice", "repair"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "return_5d_rank", "op": "<=", "value": 0.05},
|
|
||||||
{"field": "turnover_5d", "op": ">=", "value": 30},
|
|
||||||
{"field": "return_60d", "op": ">=", "value": -40},
|
|
||||||
{"field": "financial_risk", "op": "==", "value": 0},
|
|
||||||
{"field": "is_limit_down_today", "op": "==", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "return_5d", "weight": 0.45, "direction": "asc"},
|
|
||||||
{"field": "turnover_5d", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "相对强度新高",
|
|
||||||
"description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
{"field": "rs_high_120", "op": "==", "value": 1},
|
|
||||||
{"field": "excess_return_60d", "op": ">=", "value": 10},
|
|
||||||
{"field": "ma60_slope", "op": ">", "value": 0},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "excess_return_60d", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "ma60_slope", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "均线多头排列",
|
|
||||||
"description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。",
|
|
||||||
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 365},
|
|
||||||
"filters": [
|
|
||||||
{"field": "ma_bull_alignment", "op": "==", "value": 1},
|
|
||||||
{"field": "ma20_slope_5d", "op": ">", "value": 0},
|
|
||||||
{"field": "drawdown_from_high_250", "op": "<=", "value": 20},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"},
|
|
||||||
{"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 30,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "唐奇安通道突破",
|
|
||||||
"description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "donchian_breakout_pct", "op": ">=", "value": 2},
|
|
||||||
{"field": "volume_ratio_5d", "op": ">=", "value": 1.8},
|
|
||||||
{"field": "range_20d", "op": "<=", "value": 35},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"},
|
|
||||||
{"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "range_20d", "weight": 0.25, "direction": "asc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "周线趋势·日线买点",
|
|
||||||
"description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。",
|
|
||||||
"regimes": ["repair", "fermentation", "divergence"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 365},
|
|
||||||
"filters": [
|
|
||||||
{"field": "weekly_trend_signal", "op": "==", "value": 1},
|
|
||||||
{"field": "daily_buy_trigger", "op": "==", "value": 1},
|
|
||||||
{"field": "weekly_amount_trend", "op": "==", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "空间板",
|
|
||||||
"description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "is_market_height", "op": "==", "value": 1},
|
|
||||||
{"field": "new_space_board", "op": "==", "value": 1},
|
|
||||||
{"field": "sector_limit_count", "op": ">=", "value": 3},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "limit_streak", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "sector_limit_count", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.45,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "龙头首阴",
|
|
||||||
"description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。",
|
|
||||||
"regimes": ["fermentation", "climax"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "max_continuous_board_10d", "op": ">=", "value": 3},
|
|
||||||
{"field": "dragon_first_yin", "op": "==", "value": 1},
|
|
||||||
{"field": "yin_day_pct", "op": ">=", "value": -7},
|
|
||||||
{"field": "vol_vs_previous", "op": "<=", "value": 0.8},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"},
|
|
||||||
{"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "断板反包",
|
|
||||||
"description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "broken_reversal", "op": "==", "value": 1},
|
|
||||||
{"field": "days_since_broken", "op": "between", "value": [1, 3]},
|
|
||||||
{"field": "close_above_broken_high", "op": "==", "value": 1},
|
|
||||||
{"field": "vol_vs_broken_day", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "days_since_broken", "weight": 0.35, "direction": "asc"},
|
|
||||||
{"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.46,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "核按钮反核",
|
|
||||||
"description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。",
|
|
||||||
"regimes": ["repair", "fermentation"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "recent_limit_up_5d", "op": ">=", "value": 1},
|
|
||||||
{"field": "intraday_min_pct", "op": "<=", "value": -7},
|
|
||||||
{"field": "pct_chg", "op": ">=", "value": -3},
|
|
||||||
{"field": "lower_shadow_ratio", "op": ">=", "value": 2},
|
|
||||||
{"field": "vol_vs_previous", "op": "<=", "value": 1.1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"},
|
|
||||||
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 5,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "景气-趋势-拥挤三维行业打分",
|
|
||||||
"description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7,
|
|
||||||
requires_fundamental=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_composite_score", "op": ">=", "value": 0.58},
|
|
||||||
{"field": "sector_crowding_rank", "op": "<=", "value": 0.90},
|
|
||||||
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "sector_composite_score", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"},
|
|
||||||
],
|
|
||||||
"limit": 12,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "大小盘/成长价值风格切换(元策略)",
|
|
||||||
"description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7,
|
|
||||||
requires_fundamental=True, requires_valuation=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "style_fit_score", "op": ">=", "value": 0.65},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "style_fit_score", "weight": 0.70, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 20,
|
|
||||||
"min_score": 0.52,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "业绩超预期漂移(SUE/PEAD)",
|
|
||||||
"description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7,
|
|
||||||
requires_earnings_events=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "earnings_surprise_pct", "op": ">=", "value": 10},
|
|
||||||
{"field": "revenue_yoy", "op": ">", "value": 0},
|
|
||||||
{"field": "earnings_event_quality", "op": "==", "value": 1},
|
|
||||||
{"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.50,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "多因子综合打分(IC动态加权)",
|
|
||||||
"description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7,
|
|
||||||
requires_fundamental=True, requires_valuation=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 250},
|
|
||||||
"filters": [
|
|
||||||
{"field": "multi_factor_composite", "op": ">=", "value": 0.65},
|
|
||||||
{"field": "financial_risk", "op": "==", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"},
|
|
||||||
{"field": "relative_strength", "weight": 0.15, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 30,
|
|
||||||
"min_score": 0.55,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "热度突增潜伏(另类数据)",
|
|
||||||
"description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7,
|
|
||||||
requires_popularity=True, backtestable=False,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [
|
|
||||||
{"field": "popularity_score", "op": ">=", "value": 15},
|
|
||||||
{"field": "return_10d", "op": "<=", "value": 5},
|
|
||||||
{"field": "recent_limit_up_5d", "op": "==", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 0.5},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "popularity_score", "weight": 0.50, "direction": "desc"},
|
|
||||||
{"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"},
|
|
||||||
{"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "机构榜溢价",
|
|
||||||
"description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7,
|
|
||||||
requires_institutions=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "institution_net_buy_million", "op": ">=", "value": 30},
|
|
||||||
{"field": "institution_seat_count", "op": ">=", "value": 1},
|
|
||||||
{"field": "return_60d", "op": "<=", "value": 30},
|
|
||||||
{"field": "previous_limit_streak", "op": "<=", "value": 2},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"},
|
|
||||||
{"field": "institution_seat_count", "weight": 0.15, "direction": "desc"},
|
|
||||||
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 10,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
ADVANCED_CURATED_STRATEGIES.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "行业动量轮动",
|
|
||||||
"description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_momentum_rank", "op": ">=", "value": 0.90},
|
|
||||||
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "sector_return_20d", "weight": 0.38, "direction": "desc"},
|
|
||||||
{"field": "return_20d", "weight": 0.32, "direction": "desc"},
|
|
||||||
{"field": "total_mv_billion", "weight": 0.18, "direction": "desc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 12,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "主力资金行业流入",
|
|
||||||
"description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。",
|
|
||||||
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
|
||||||
"formula": {
|
|
||||||
"meta": _meta(
|
|
||||||
"行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7,
|
|
||||||
requires_moneyflow_history=True,
|
|
||||||
),
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 180},
|
|
||||||
"filters": [
|
|
||||||
{"field": "sector_flow_rank", "op": ">=", "value": 0.85},
|
|
||||||
{"field": "sector_net_flow_5d_million", "op": ">", "value": 0},
|
|
||||||
{"field": "sector_return_5d", "op": "<=", "value": 8},
|
|
||||||
{"field": "flow_to_circ_mv_5d", "op": ">", "value": 0},
|
|
||||||
{"field": "amount_billion", "op": ">=", "value": 1},
|
|
||||||
],
|
|
||||||
"score": [
|
|
||||||
{"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"},
|
|
||||||
{"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"},
|
|
||||||
{"field": "sector_return_5d", "weight": 0.16, "direction": "asc"},
|
|
||||||
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
|
||||||
],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.48,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|||||||
+7
-418
@@ -40,15 +40,8 @@ from heaven_engine import (
|
|||||||
hexagram_from_lines,
|
hexagram_from_lines,
|
||||||
)
|
)
|
||||||
from backend.data.providers.ifind_client import IfindError
|
from backend.data.providers.ifind_client import IfindError
|
||||||
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
|
from llm_strategy import LLMCompilerError, test_llm_connection
|
||||||
from mentor_agent import MentorAgentError, stream_with_mentor
|
from mentor_agent import MentorAgentError, stream_with_mentor
|
||||||
from screener import (
|
|
||||||
FACTOR_FIELDS,
|
|
||||||
FACTOR_GROUPS,
|
|
||||||
REGIMES,
|
|
||||||
FactorDataService,
|
|
||||||
compile_local_strategy,
|
|
||||||
)
|
|
||||||
from backend.features.accounts.http import AccountHttpMixin
|
from backend.features.accounts.http import AccountHttpMixin
|
||||||
from backend.features.accounts.security import SecretVault
|
from backend.features.accounts.security import SecretVault
|
||||||
from backend.features.accounts.service import AccountService
|
from backend.features.accounts.service import AccountService
|
||||||
@@ -57,36 +50,17 @@ from backend.features.dragon_tiger import DragonTigerServiceMixin
|
|||||||
from backend.features.pools import PoolServiceMixin
|
from backend.features.pools import PoolServiceMixin
|
||||||
from backend.features.popularity import PopularityServiceMixin
|
from backend.features.popularity import PopularityServiceMixin
|
||||||
from backend.features.rotation import RotationServiceMixin
|
from backend.features.rotation import RotationServiceMixin
|
||||||
|
from backend.features.screener.service import (
|
||||||
|
SCREENER_LIBRARY_VERSION,
|
||||||
|
ScreenerServiceMixin,
|
||||||
|
automatic_screener_jobs,
|
||||||
|
)
|
||||||
from backend.features.sentiment import SentimentServiceMixin
|
from backend.features.sentiment import SentimentServiceMixin
|
||||||
from backend.features.system import SystemHttpMixin
|
from backend.features.system import SystemHttpMixin
|
||||||
from backend.features.themes import ThemeServiceMixin
|
from backend.features.themes import ThemeServiceMixin
|
||||||
from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue
|
from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue
|
||||||
|
|
||||||
|
|
||||||
SCREENER_LIBRARY_VERSION = 8
|
|
||||||
|
|
||||||
|
|
||||||
def automatic_screener_jobs(
|
|
||||||
strategies: list[dict[str, Any]], regime_id: str
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build the close-of-day jobs; only stage screening is regime-gated."""
|
|
||||||
smart_strategy = next(
|
|
||||||
(
|
|
||||||
item for item in strategies
|
|
||||||
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
|
|
||||||
and regime_id in (item.get("regimes") or [])
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
curated = [
|
|
||||||
item for item in strategies
|
|
||||||
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
|
|
||||||
]
|
|
||||||
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
|
|
||||||
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
|
|
||||||
LEGACY_SECRET_KEYS = {
|
LEGACY_SECRET_KEYS = {
|
||||||
"TUSHARE_TOKEN",
|
"TUSHARE_TOKEN",
|
||||||
"IFIND_REFRESH_TOKEN",
|
"IFIND_REFRESH_TOKEN",
|
||||||
@@ -149,6 +123,7 @@ class DashboardService(
|
|||||||
ThemeServiceMixin,
|
ThemeServiceMixin,
|
||||||
PopularityServiceMixin,
|
PopularityServiceMixin,
|
||||||
DragonTigerServiceMixin,
|
DragonTigerServiceMixin,
|
||||||
|
ScreenerServiceMixin,
|
||||||
):
|
):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
runtime = load_runtime_settings()
|
runtime = load_runtime_settings()
|
||||||
@@ -788,141 +763,6 @@ class DashboardService(
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _strategy_missing_data(
|
|
||||||
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
|
|
||||||
) -> list[str]:
|
|
||||||
formula = strategy.get("formula") or {}
|
|
||||||
meta = formula.get("meta") or {}
|
|
||||||
used_fields = {
|
|
||||||
str(item.get("field") or "")
|
|
||||||
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
|
||||||
}
|
|
||||||
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
|
|
||||||
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
|
|
||||||
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
|
|
||||||
missing = []
|
|
||||||
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
|
|
||||||
if len(factor_dates) < required_history:
|
|
||||||
missing.append(f"历史行情(需{required_history}日)")
|
|
||||||
if used_fields & valuation_fields and not factor_health["valuation"]:
|
|
||||||
missing.append("估值数据")
|
|
||||||
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
|
||||||
missing.append("财务质量")
|
|
||||||
if meta.get("requires_valuation") and not factor_health["valuation"]:
|
|
||||||
missing.append("估值数据")
|
|
||||||
if meta.get("requires_fundamental") and not factor_health["fundamental"]:
|
|
||||||
missing.append("财务质量")
|
|
||||||
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
|
||||||
missing.append("历年分红")
|
|
||||||
if used_fields & auction_fields and not factor_health["auction"]:
|
|
||||||
missing.append("竞价数据")
|
|
||||||
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
|
|
||||||
missing.append("沪深300基准")
|
|
||||||
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
|
|
||||||
missing.append("近5日资金流")
|
|
||||||
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
|
|
||||||
missing.append("业绩预告与快报")
|
|
||||||
if meta.get("requires_popularity") and not factor_health.get("popularity"):
|
|
||||||
missing.append("当日人气榜")
|
|
||||||
if meta.get("requires_institutions") and not factor_health.get("institutions"):
|
|
||||||
missing.append("龙虎榜机构席位")
|
|
||||||
return list(dict.fromkeys(missing))
|
|
||||||
|
|
||||||
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
|
||||||
normalized_date = normalize_date(trade_date)
|
|
||||||
regime = self.screener.detect_regime(normalized_date)
|
|
||||||
factor_dates = self.database.factor_dates(normalized_date, 300)
|
|
||||||
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
|
|
||||||
factor_health = self.screener.factor_health(normalized_date)
|
|
||||||
strategies = self.database.list_screener_strategies(self.current_user_id)
|
|
||||||
for strategy in strategies:
|
|
||||||
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
|
|
||||||
strategy["data_ready"] = not missing
|
|
||||||
strategy["missing_data"] = missing
|
|
||||||
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
|
|
||||||
personal_results = self.database.screener_runs_for_date(
|
|
||||||
self.current_user_id, normalized_date
|
|
||||||
)
|
|
||||||
recent_results = [
|
|
||||||
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
|
|
||||||
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
|
|
||||||
]
|
|
||||||
latest_results: dict[str, dict[str, Any]] = {}
|
|
||||||
for result in reversed(recent_results):
|
|
||||||
mode = str(result.get("meta", {}).get("mode") or "smart")
|
|
||||||
latest_results[mode] = result
|
|
||||||
automatic_status = self.database.get_data_snapshot(
|
|
||||||
"screener_auto_v1", normalized_date
|
|
||||||
) or {}
|
|
||||||
return {
|
|
||||||
"trade_date": normalized_date,
|
|
||||||
"regime": regime,
|
|
||||||
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
|
|
||||||
"strategies": strategies,
|
|
||||||
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
|
|
||||||
"factor_groups": [
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
|
|
||||||
}
|
|
||||||
for name, fields in FACTOR_GROUPS.items()
|
|
||||||
],
|
|
||||||
"operators": [">", ">=", "<", "<=", "==", "between"],
|
|
||||||
"factor_data": {
|
|
||||||
"date_count": len(factor_dates),
|
|
||||||
"start_date": factor_dates[0] if factor_dates else "",
|
|
||||||
"end_date": factor_dates[-1] if factor_dates else "",
|
|
||||||
"ready": len(factor_dates) >= 21,
|
|
||||||
"auction_date_count": len(auction_dates),
|
|
||||||
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
|
|
||||||
"health": factor_health,
|
|
||||||
},
|
|
||||||
"llm": {
|
|
||||||
"configured": self.llm_configured,
|
|
||||||
"model": self.llm_primary_model if self.llm_configured else "",
|
|
||||||
"fallback_configured": self.llm_fallback_configured,
|
|
||||||
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
|
|
||||||
},
|
|
||||||
"latest_results": latest_results,
|
|
||||||
"recent_results": recent_results,
|
|
||||||
"automatic_status": automatic_status,
|
|
||||||
# Kept during the client transition for compatibility with older frontends.
|
|
||||||
"latest_result": latest_results.get("smart"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
|
|
||||||
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
|
|
||||||
|
|
||||||
def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
run_id = int(payload.get("run_id") or 0)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("选股批次无效。") from exc
|
|
||||||
code = str(payload.get("code") or "").strip()
|
|
||||||
if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
|
|
||||||
raise ValueError("选股批次或股票代码无效。")
|
|
||||||
return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
|
|
||||||
|
|
||||||
def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
|
|
||||||
return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
|
|
||||||
|
|
||||||
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
|
|
||||||
normalized_date = normalize_date(trade_date)
|
|
||||||
notice = ""
|
|
||||||
if self.configured:
|
|
||||||
try:
|
|
||||||
FactorDataService(self.database, self._tushare_client()).sync(
|
|
||||||
normalized_date, 15
|
|
||||||
)
|
|
||||||
except TushareError:
|
|
||||||
notice = "最新日线暂未补齐,已按现有数据更新跟踪。"
|
|
||||||
else:
|
|
||||||
notice = "公共行情尚未配置,已按现有数据更新跟踪。"
|
|
||||||
return {
|
|
||||||
"tracking": self.screener_tracking(),
|
|
||||||
"notice": notice,
|
|
||||||
}
|
|
||||||
|
|
||||||
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
|
def alert_center(self, status: str = "all", as_of: str = "") -> dict[str, Any]:
|
||||||
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
|
tracking = self.strategy_tracking.list_tracking(self.current_user_id, 12)
|
||||||
@@ -1126,220 +966,6 @@ class DashboardService(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
|
|
||||||
if not self.configured:
|
|
||||||
raise ValueError("请先配置 Tushare Token。")
|
|
||||||
normalized_date = normalize_date(trade_date)
|
|
||||||
lookback = max(25, min(260, int(lookback)))
|
|
||||||
with self.sync_lock:
|
|
||||||
return FactorDataService(self.database, self._tushare_client()).sync(
|
|
||||||
normalized_date, lookback
|
|
||||||
)
|
|
||||||
|
|
||||||
def _schedule_automatic_screeners(
|
|
||||||
self, trade_date: str, snapshot: dict[str, Any] | None = None
|
|
||||||
) -> bool:
|
|
||||||
normalized_date = normalize_date(trade_date)
|
|
||||||
now = datetime.now().astimezone()
|
|
||||||
if (
|
|
||||||
normalized_date != now.strftime("%Y%m%d")
|
|
||||||
or now.weekday() >= 5
|
|
||||||
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
|
|
||||||
or self.auto_screener_lock.locked()
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
|
|
||||||
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
|
|
||||||
if actual_date != normalized_date:
|
|
||||||
return False
|
|
||||||
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
|
|
||||||
if (
|
|
||||||
marker.get("status") == "complete"
|
|
||||||
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
|
|
||||||
if last_attempt and (now - last_attempt).total_seconds() < 600:
|
|
||||||
return False
|
|
||||||
self._auto_screener_last_attempt[normalized_date] = now
|
|
||||||
return self.jobs.submit(
|
|
||||||
"screener.automatic",
|
|
||||||
f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
|
|
||||||
lambda: self.run_automatic_screeners(normalized_date),
|
|
||||||
{"trade_date": normalized_date, "trigger": "post-close"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
|
|
||||||
normalized_date = normalize_date(trade_date)
|
|
||||||
with self.auto_screener_lock:
|
|
||||||
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
status: dict[str, Any] = {
|
|
||||||
"trade_date": normalized_date,
|
|
||||||
"library_version": SCREENER_LIBRARY_VERSION,
|
|
||||||
"status": "running",
|
|
||||||
"started_at": started_at,
|
|
||||||
"completed": [],
|
|
||||||
"skipped": [],
|
|
||||||
"failed": [],
|
|
||||||
}
|
|
||||||
self.database.save_data_snapshot(
|
|
||||||
"screener_auto_v1", normalized_date, "system", status
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
factor_sync = FactorDataService(
|
|
||||||
self.database, self._tushare_client()
|
|
||||||
).sync(normalized_date, 260)
|
|
||||||
factor_dates = self.database.factor_dates(normalized_date, 300)
|
|
||||||
if not factor_dates or factor_dates[-1] != normalized_date:
|
|
||||||
raise ValueError("当日收盘行情尚未入库")
|
|
||||||
factor_health = self.screener.factor_health(normalized_date)
|
|
||||||
regime = self.screener.detect_regime(normalized_date)
|
|
||||||
regime_id = str(regime.get("id") or "repair")
|
|
||||||
strategies = self.database.list_screener_strategies(None)
|
|
||||||
jobs = automatic_screener_jobs(strategies, regime_id)
|
|
||||||
existing = {
|
|
||||||
(
|
|
||||||
str(item.get("meta", {}).get("mode") or "smart"),
|
|
||||||
str(item.get("meta", {}).get("strategy_name") or ""),
|
|
||||||
)
|
|
||||||
for item in self.database.screener_runs_for_date(0, normalized_date)
|
|
||||||
if int(item.get("meta", {}).get("library_version") or 0)
|
|
||||||
== SCREENER_LIBRARY_VERSION
|
|
||||||
}
|
|
||||||
required_history = max(
|
|
||||||
[
|
|
||||||
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
|
|
||||||
for job in jobs if job.get("strategy")
|
|
||||||
] or [80]
|
|
||||||
)
|
|
||||||
factors, actual_date = self.screener.build_factors(
|
|
||||||
normalized_date, history_days=required_history
|
|
||||||
)
|
|
||||||
if actual_date != normalized_date:
|
|
||||||
raise ValueError("当日因子尚未完成收盘定格")
|
|
||||||
for job in jobs:
|
|
||||||
strategy = job["strategy"]
|
|
||||||
mode = str(job["mode"])
|
|
||||||
name = str(strategy.get("name") or "未命名策略")
|
|
||||||
if (mode, name) in existing:
|
|
||||||
status["completed"].append({"mode": mode, "name": name, "cached": True})
|
|
||||||
continue
|
|
||||||
missing = self._strategy_missing_data(
|
|
||||||
strategy, factor_dates, factor_health
|
|
||||||
)
|
|
||||||
if missing:
|
|
||||||
status["skipped"].append(
|
|
||||||
{"mode": mode, "name": name, "reason": "、".join(missing)}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
formula = copy.deepcopy(strategy.get("formula") or {})
|
|
||||||
formula.setdefault("meta", {})["library_version"] = (
|
|
||||||
SCREENER_LIBRARY_VERSION
|
|
||||||
)
|
|
||||||
result = self.screener.screen(
|
|
||||||
0,
|
|
||||||
normalized_date,
|
|
||||||
formula,
|
|
||||||
regime_id,
|
|
||||||
name,
|
|
||||||
False,
|
|
||||||
None,
|
|
||||||
mode,
|
|
||||||
factors,
|
|
||||||
actual_date,
|
|
||||||
)
|
|
||||||
status["completed"].append(
|
|
||||||
{
|
|
||||||
"mode": mode,
|
|
||||||
"name": name,
|
|
||||||
"candidate_count": len(result.get("candidates") or []),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
status["failed"].append(
|
|
||||||
{"mode": mode, "name": name, "reason": str(exc)}
|
|
||||||
)
|
|
||||||
status.update(
|
|
||||||
{
|
|
||||||
"status": "complete" if not status["failed"] else "partial",
|
|
||||||
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"factor_sync": factor_sync,
|
|
||||||
"regime": regime,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
status.update(
|
|
||||||
{
|
|
||||||
"status": "failed",
|
|
||||||
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.database.save_data_snapshot(
|
|
||||||
"screener_auto_v1", normalized_date, "system", status
|
|
||||||
)
|
|
||||||
return status
|
|
||||||
|
|
||||||
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
|
|
||||||
prompt = prompt.strip()
|
|
||||||
if not prompt or len(prompt) > 3000:
|
|
||||||
raise ValueError("策略描述应为 1 至 3000 个字符。")
|
|
||||||
if regime not in REGIMES:
|
|
||||||
raise ValueError("市场阶段不支持。")
|
|
||||||
notice = ""
|
|
||||||
source = self.llm_source
|
|
||||||
if source == "platform":
|
|
||||||
try:
|
|
||||||
gateway_result = self.llm_gateway.call(
|
|
||||||
"screener",
|
|
||||||
"strategy-compiler-v1",
|
|
||||||
lambda profile: compile_strategy_with_llm(
|
|
||||||
prompt,
|
|
||||||
regime,
|
|
||||||
profile.api_key,
|
|
||||||
profile.base_url,
|
|
||||||
profile.model,
|
|
||||||
),
|
|
||||||
(LLMCompilerError,),
|
|
||||||
)
|
|
||||||
compiled = gateway_result.value
|
|
||||||
if gateway_result.role == "fallback":
|
|
||||||
compiled["compiler"] = "llm_fallback"
|
|
||||||
notice = "智能策略生成服务已自动切换。"
|
|
||||||
except LLMGatewayError as exc:
|
|
||||||
if exc.code != "unavailable":
|
|
||||||
raise
|
|
||||||
compiled = compile_local_strategy(prompt, regime)
|
|
||||||
notice = "智能策略生成暂不可用,已使用本地模板。"
|
|
||||||
else:
|
|
||||||
compiled = compile_local_strategy(prompt, regime)
|
|
||||||
notice = "智能策略生成暂不可用,已使用本地模板。"
|
|
||||||
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
|
|
||||||
compiled["notice"] = notice
|
|
||||||
return compiled
|
|
||||||
|
|
||||||
def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
name = validate_text(payload.get("name"), "策略名称", 60, required=True)
|
|
||||||
description = validate_text(payload.get("description"), "策略说明", 1000)
|
|
||||||
regimes = payload.get("regimes") or []
|
|
||||||
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
|
|
||||||
raise ValueError("策略适用阶段不正确。")
|
|
||||||
formula = self.screener.validate_formula(payload.get("formula") or {})
|
|
||||||
strategy_id = self.database.save_screener_strategy(
|
|
||||||
self.current_user_id, name, description, regimes, formula
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": strategy_id,
|
|
||||||
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
|
|
||||||
deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
|
|
||||||
return {
|
|
||||||
"deleted": deleted,
|
|
||||||
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
def mentor_setup(self, trade_date: str) -> dict[str, Any]:
|
def mentor_setup(self, trade_date: str) -> dict[str, Any]:
|
||||||
normalized_date = normalize_date(trade_date)
|
normalized_date = normalize_date(trade_date)
|
||||||
@@ -3025,43 +2651,6 @@ class DashboardService(
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
|
||||||
regime = str(payload.get("regime") or "")
|
|
||||||
if regime not in REGIMES:
|
|
||||||
raise ValueError("市场阶段不支持。")
|
|
||||||
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
|
|
||||||
formula = payload.get("formula") or {}
|
|
||||||
requested_mode = str(payload.get("mode") or "").strip()
|
|
||||||
if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
|
|
||||||
raise ValueError("选股模式不受支持。")
|
|
||||||
if requested_mode:
|
|
||||||
mode = requested_mode
|
|
||||||
else:
|
|
||||||
meta = formula.get("meta") if isinstance(formula, dict) else {}
|
|
||||||
library = str((meta or {}).get("library") or "")
|
|
||||||
category = str((meta or {}).get("category") or "")
|
|
||||||
if library == "curated":
|
|
||||||
mode = "curated"
|
|
||||||
elif library == "quant" or (library == "custom" and category == "量化公式"):
|
|
||||||
mode = "quant"
|
|
||||||
else:
|
|
||||||
mode = "smart"
|
|
||||||
realtime_snapshot = None
|
|
||||||
dashboard = self.get_dashboard(trade_date)
|
|
||||||
if self.configured and dashboard.get("meta", {}).get("realtime"):
|
|
||||||
try:
|
|
||||||
realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
|
|
||||||
except TushareError as exc:
|
|
||||||
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
|
|
||||||
result = self.screener.screen(
|
|
||||||
self.current_user_id, trade_date, formula, regime, strategy_name,
|
|
||||||
bool(payload.get("run_backtest", True)),
|
|
||||||
realtime_snapshot,
|
|
||||||
mode,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
SERVICE = DashboardService()
|
SERVICE = DashboardService()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from backend.data import DataGateway, build_data_gateway
|
|||||||
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
||||||
from backend.features.alerts import AlertService
|
from backend.features.alerts import AlertService
|
||||||
from backend.features.review import TradeJournalService
|
from backend.features.review import TradeJournalService
|
||||||
from backend.features.screener import StrategyTrackingService
|
from backend.features.screener.tracking import StrategyTrackingService
|
||||||
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from mentor_agent import MentorSkillRegistry
|
from mentor_agent import MentorSkillRegistry
|
||||||
|
|||||||
@@ -6,6 +6,74 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
class MarketRepositoryMixin:
|
class MarketRepositoryMixin:
|
||||||
|
def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
row.get("ts_code", ""),
|
||||||
|
str(row.get("ts_code", "")).split(".")[0],
|
||||||
|
row.get("name") or "--",
|
||||||
|
row.get("industry") or "",
|
||||||
|
row.get("market") or "",
|
||||||
|
str(row.get("list_date") or ""),
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
for row in rows if row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO stock_master
|
||||||
|
(ts_code, code, name, industry, market, list_date, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(ts_code) DO UPDATE SET
|
||||||
|
code=excluded.code, name=excluded.name, industry=excluded.industry,
|
||||||
|
market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def list_stock_master(self) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT ts_code, code, name, industry, market, list_date FROM stock_master"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
||||||
|
float(row.get("open") or 0), float(row.get("high") or 0),
|
||||||
|
float(row.get("low") or 0), float(row.get("close") or 0),
|
||||||
|
float(row.get("pct_chg") or 0), float(row.get("vol") or 0),
|
||||||
|
float(row.get("amount") or 0),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_bars
|
||||||
|
(trade_date, ts_code, open, high, low, close, pct_chg, vol, amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||||
|
close=excluded.close, pct_chg=excluded.pct_chg,
|
||||||
|
vol=excluded.vol, amount=excluded.amount
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(trade_date,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
from .tracking import StrategyTrackingService
|
"""Stock screening, custom selection, and strategy tracking feature."""
|
||||||
|
|
||||||
__all__ = ["StrategyTrackingService"]
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from screener import FACTOR_FIELDS, REGIMES
|
||||||
|
|
||||||
|
|
||||||
|
class LLMCompilerError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_connection(
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
timeout: int = 30,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not api_key or not model:
|
||||||
|
raise LLMCompilerError("API Key 或模型未配置。")
|
||||||
|
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": "只回复 OK"}],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/0.5",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
reply = str(result["choices"][0]["message"]["content"]).strip()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise LLMCompilerError(_http_error_message(exc)) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||||
|
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"model": model,
|
||||||
|
"reply": reply[:100],
|
||||||
|
"latency_ms": round((time.perf_counter() - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compile_strategy_with_llm(
|
||||||
|
prompt: str,
|
||||||
|
regime: str,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
model: str,
|
||||||
|
timeout: int = 45,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not api_key or not model:
|
||||||
|
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
|
||||||
|
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
||||||
|
schema = {
|
||||||
|
"name": "策略名称",
|
||||||
|
"description": "策略说明",
|
||||||
|
"regimes": [regime],
|
||||||
|
"formula": {
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [{"field": "return_5d", "op": ">=", "value": 0}],
|
||||||
|
"score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.55,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
system_prompt = (
|
||||||
|
"你是A股量化策略编译器。只输出JSON对象,不输出Markdown。"
|
||||||
|
"不得生成Python、SQL、网络请求或未提供的因子。"
|
||||||
|
f"当前市场阶段为{REGIMES.get(regime, regime)}。"
|
||||||
|
f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。"
|
||||||
|
"运算符只能使用 >, >=, <, <=, ==, !=, between, in。"
|
||||||
|
"score权重均大于0且不超过1,direction只能是asc或desc。"
|
||||||
|
"退潮和冰点策略必须提高门槛并允许结果为空。"
|
||||||
|
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
|
||||||
|
)
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": prompt[:3000]},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": "XiaobaiReviewWeb/0.4",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
content = result["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = content.strip("`")
|
||||||
|
if content.startswith("json"):
|
||||||
|
content = content[4:].strip()
|
||||||
|
compiled = json.loads(content)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
||||||
|
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
|
||||||
|
compiled["compiler"] = "llm"
|
||||||
|
compiled["model"] = model
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
||||||
|
error = payload.get("error")
|
||||||
|
if isinstance(error, dict):
|
||||||
|
detail = str(error.get("message") or error.get("code") or "")
|
||||||
|
elif error:
|
||||||
|
detail = str(error)
|
||||||
|
elif payload.get("message"):
|
||||||
|
detail = str(payload["message"])
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
detail = ""
|
||||||
|
suffix = f":{detail[:300]}" if detail else ""
|
||||||
|
return f"模型连接测试失败(HTTP {exc.code}){suffix}"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.features.sentiment.engine import build_sentiment_history
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_float(value: Any) -> float | None:
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerRepositoryMixin:
|
||||||
|
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
|
||||||
|
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
close=excluded.close, pct_chg=excluded.pct_chg
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
||||||
|
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
|
||||||
|
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
|
||||||
|
_optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
|
||||||
|
_optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_indicators
|
||||||
|
(trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
|
||||||
|
pe_ttm, pb, ps_ttm, dv_ttm)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
|
||||||
|
total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
|
||||||
|
pe_ttm=excluded.pe_ttm, pb=excluded.pb,
|
||||||
|
ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
|
||||||
|
str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
|
||||||
|
_optional_float(row.get("roa")), _optional_float(row.get("roic")),
|
||||||
|
_optional_float(row.get("grossprofit_margin")),
|
||||||
|
_optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
|
||||||
|
_optional_float(row.get("ocf_to_opincome")),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("end_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO fundamental_indicators
|
||||||
|
(end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
|
||||||
|
netprofit_yoy, or_yoy, ocf_to_opincome)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(end_date, ts_code) DO UPDATE SET
|
||||||
|
ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
|
||||||
|
roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
|
||||||
|
netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
|
||||||
|
ocf_to_opincome=excluded.ocf_to_opincome
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = []
|
||||||
|
for row in rows:
|
||||||
|
if not row.get("trade_date") or not row.get("ts_code"):
|
||||||
|
continue
|
||||||
|
large_net = (
|
||||||
|
float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0)
|
||||||
|
- float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0)
|
||||||
|
)
|
||||||
|
medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0)
|
||||||
|
small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0)
|
||||||
|
values.append((
|
||||||
|
str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0),
|
||||||
|
large_net, medium_net, small_net,
|
||||||
|
))
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO moneyflow_daily
|
||||||
|
(trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount,
|
||||||
|
medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("end_date") or ""),
|
||||||
|
str(row.get("ann_date") or ""),
|
||||||
|
str(row.get("ts_code") or ""),
|
||||||
|
_optional_float(row.get("forecast_profit")),
|
||||||
|
_optional_float(row.get("actual_profit")),
|
||||||
|
_optional_float(row.get("surprise_pct")),
|
||||||
|
_optional_float(row.get("revenue_yoy")),
|
||||||
|
_optional_float(row.get("netprofit_yoy")),
|
||||||
|
str(row.get("source") or ""),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO earnings_events
|
||||||
|
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
|
||||||
|
surprise_pct, revenue_yoy, netprofit_yoy, source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
|
||||||
|
forecast_profit=excluded.forecast_profit,
|
||||||
|
actual_profit=excluded.actual_profit,
|
||||||
|
surprise_pct=excluded.surprise_pct,
|
||||||
|
revenue_yoy=excluded.revenue_yoy,
|
||||||
|
netprofit_yoy=excluded.netprofit_yoy,
|
||||||
|
source=excluded.source
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
|
||||||
|
where = "WHERE trade_date <= ?" if end_date else ""
|
||||||
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
|
||||||
|
"ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [row["trade_date"] for row in reversed(rows)]
|
||||||
|
|
||||||
|
def fundamental_periods(self) -> list[str]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
|
||||||
|
).fetchall()
|
||||||
|
return [str(row["end_date"]) for row in rows]
|
||||||
|
|
||||||
|
def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
||||||
|
where = "WHERE trade_date <= ?" if end_date else ""
|
||||||
|
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [row["trade_date"] for row in reversed(rows)]
|
||||||
|
|
||||||
|
def factor_health_summary(self, end_date: str) -> dict[str, Any]:
|
||||||
|
dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
|
||||||
|
with self.connect() as connection:
|
||||||
|
market = connection.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
auction = connection.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
benchmark_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
indicator_date = connection.execute(
|
||||||
|
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
if indicator_date:
|
||||||
|
valuation_rows, valuation_available = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
|
||||||
|
FROM daily_indicators WHERE trade_date = ?
|
||||||
|
""",
|
||||||
|
(indicator_date,),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
valuation_rows, valuation_available = 0, 0
|
||||||
|
dividend_years = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date <= ? AND trade_date >= ?
|
||||||
|
""",
|
||||||
|
(end_date, dividend_start),
|
||||||
|
).fetchone()[0]
|
||||||
|
fundamental_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM fundamental_indicators fi
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
||||||
|
FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
GROUP BY ts_code
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = fi.ts_code
|
||||||
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
moneyflow_dates = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT trade_date)
|
||||||
|
FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN (
|
||||||
|
SELECT DISTINCT trade_date
|
||||||
|
FROM daily_bars
|
||||||
|
WHERE trade_date <= ?
|
||||||
|
ORDER BY trade_date DESC
|
||||||
|
LIMIT 5
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
earnings_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM earnings_events
|
||||||
|
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
|
||||||
|
""",
|
||||||
|
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
|
||||||
|
).fetchone()[0]
|
||||||
|
popularity_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
institution_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
|
return {
|
||||||
|
"market": bool(market),
|
||||||
|
"auction": bool(auction),
|
||||||
|
"benchmark": int(benchmark_rows or 0) >= 60,
|
||||||
|
"benchmark_rows": int(benchmark_rows or 0),
|
||||||
|
"valuation": bool(valuation_available),
|
||||||
|
"fundamental": int(fundamental_rows or 0) >= 100,
|
||||||
|
"dividend_history": int(dividend_years or 0) >= 4,
|
||||||
|
"valuation_rows": int(valuation_rows or 0),
|
||||||
|
"fundamental_rows": int(fundamental_rows or 0),
|
||||||
|
"dividend_years": int(dividend_years or 0),
|
||||||
|
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
||||||
|
"moneyflow_dates": int(moneyflow_dates or 0),
|
||||||
|
"earnings_events": int(earnings_rows or 0) > 0,
|
||||||
|
"earnings_event_rows": int(earnings_rows or 0),
|
||||||
|
"popularity": int(popularity_rows or 0) > 0,
|
||||||
|
"popularity_rows": int(popularity_rows or 0),
|
||||||
|
"institutions": int(institution_rows or 0) > 0,
|
||||||
|
"institution_rows": int(institution_rows or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
||||||
|
dates = self.factor_dates(end_date, limit_dates)
|
||||||
|
if not dates:
|
||||||
|
return {
|
||||||
|
"dates": [], "bars": [], "master": [], "indicators": [],
|
||||||
|
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
||||||
|
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
||||||
|
"benchmarks": [], "fundamental_history": [],
|
||||||
|
"earnings_events": [], "popularity": [], "institutions": [],
|
||||||
|
}
|
||||||
|
placeholders = ",".join("?" for _ in dates)
|
||||||
|
with self.connect() as connection:
|
||||||
|
bars = connection.execute(
|
||||||
|
f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
master = connection.execute("SELECT * FROM stock_master").fetchall()
|
||||||
|
indicators = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM daily_indicators
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
indicator_history = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT di.* FROM daily_indicators di
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
|
||||||
|
MAX(trade_date) AS max_date
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date <= ? AND trade_date >= ?
|
||||||
|
GROUP BY ts_code, substr(trade_date, 1, 4)
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
|
||||||
|
ORDER BY di.trade_date, di.ts_code
|
||||||
|
""",
|
||||||
|
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
||||||
|
).fetchall()
|
||||||
|
indicator_series = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
|
||||||
|
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
fundamentals = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT fi.* FROM fundamental_indicators fi
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
||||||
|
FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
GROUP BY ts_code
|
||||||
|
) latest
|
||||||
|
ON latest.ts_code = fi.ts_code
|
||||||
|
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
fundamental_history = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM fundamental_indicators
|
||||||
|
WHERE ann_date = '' OR ann_date <= ?
|
||||||
|
ORDER BY ann_date, end_date, ts_code
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
moneyflow = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM moneyflow_daily
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
flow_dates = dates[-min(5, len(dates)):]
|
||||||
|
flow_placeholders = ",".join("?" for _ in flow_dates)
|
||||||
|
moneyflow_history = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN ({flow_placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
flow_dates,
|
||||||
|
).fetchall()
|
||||||
|
auction = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM auction_factors
|
||||||
|
WHERE trade_date = (
|
||||||
|
SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
benchmarks = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM benchmark_bars
|
||||||
|
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
earnings_events = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM earnings_events
|
||||||
|
WHERE ann_date <= ?
|
||||||
|
ORDER BY ann_date, end_date, ts_code
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
popularity = connection.execute(
|
||||||
|
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
institutions = connection.execute(
|
||||||
|
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
|
||||||
|
(end_date,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"dates": dates,
|
||||||
|
"bars": [dict(row) for row in bars],
|
||||||
|
"master": [dict(row) for row in master],
|
||||||
|
"indicators": [dict(row) for row in indicators],
|
||||||
|
"indicator_history": [dict(row) for row in indicator_history],
|
||||||
|
"indicator_series": [dict(row) for row in indicator_series],
|
||||||
|
"fundamentals": [dict(row) for row in fundamentals],
|
||||||
|
"fundamental_history": [dict(row) for row in fundamental_history],
|
||||||
|
"moneyflow": [dict(row) for row in moneyflow],
|
||||||
|
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
||||||
|
"auction": [dict(row) for row in auction],
|
||||||
|
"benchmarks": [dict(row) for row in benchmarks],
|
||||||
|
"earnings_events": [dict(row) for row in earnings_events],
|
||||||
|
"popularity": [dict(row) for row in popularity],
|
||||||
|
"institutions": [dict(row) for row in institutions],
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from sentiment_engine import build_sentiment_history
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
from .sentiment_engine import build_sentiment_history
|
||||||
|
|
||||||
|
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"trade_date": row["trade_date"],
|
||||||
|
"sentiment_score": row["score"],
|
||||||
|
"seal_rate": row["seal_rate"],
|
||||||
|
"limit_up_count": row["limit_up_count"],
|
||||||
|
"limit_down_count": row["limit_down_count"],
|
||||||
|
"broken_count": row["broken_count"],
|
||||||
|
"up_count": row["up_count"],
|
||||||
|
"down_count": row["down_count"],
|
||||||
|
"amount_billion": row["amount_billion"],
|
||||||
|
}
|
||||||
|
for row in series[-limit:]
|
||||||
|
]
|
||||||
|
|
||||||
|
def save_screener_strategy(
|
||||||
|
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
|
||||||
|
builtin: bool = False, strategy_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
regimes_json = json.dumps(regimes, ensure_ascii=False)
|
||||||
|
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
with self.connect() as connection:
|
||||||
|
if strategy_id:
|
||||||
|
if builtin:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
||||||
|
builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
|
||||||
|
""",
|
||||||
|
(name, description, regimes_json, formula_json, now, strategy_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
||||||
|
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
|
||||||
|
""",
|
||||||
|
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise ValueError("选股策略不存在。")
|
||||||
|
return strategy_id
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO screener_strategies
|
||||||
|
(user_id, name, description, regimes, formula, builtin, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
if user_id is None:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM screener_strategies
|
||||||
|
WHERE builtin = 1 OR user_id = ?
|
||||||
|
ORDER BY builtin DESC, updated_at DESC, id
|
||||||
|
""",
|
||||||
|
(int(user_id),),
|
||||||
|
).fetchall()
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["regimes"] = json.loads(item["regimes"])
|
||||||
|
item["formula"] = json.loads(item["formula"])
|
||||||
|
item["builtin"] = bool(item["builtin"])
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
|
||||||
|
(strategy_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("选股策略不存在。")
|
||||||
|
if bool(row["builtin"]):
|
||||||
|
raise ValueError("内置策略不能删除。")
|
||||||
|
if int(row["user_id"] or 0) != int(user_id):
|
||||||
|
raise ValueError("无权删除其他账号的策略。")
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
|
||||||
|
(strategy_id, int(user_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def save_screener_run(
|
||||||
|
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
|
||||||
|
formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
|
||||||
|
) -> int:
|
||||||
|
normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO screener_runs
|
||||||
|
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
|
||||||
|
normalized_mode, strategy_name,
|
||||||
|
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
result = json.loads(row["result"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
result.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"run_id": int(row["id"]),
|
||||||
|
"trade_date": str(row["trade_date"] or ""),
|
||||||
|
"regime": str(row["regime"] or ""),
|
||||||
|
"mode": str(row["mode"] or "smart"),
|
||||||
|
"strategy_name": str(row["strategy_name"] or ""),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def latest_screener_run(
|
||||||
|
self, user_id: int, trade_date: str, mode: str = "",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
|
mode_clause = ""
|
||||||
|
if mode in {"smart", "curated", "quant"}:
|
||||||
|
mode_clause = " AND mode = ?"
|
||||||
|
parameters += (mode,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchone()
|
||||||
|
return self._screener_run_payload(row) if row else None
|
||||||
|
|
||||||
|
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
||||||
|
runs.strategy_name, runs.result, runs.created_at
|
||||||
|
FROM screener_runs runs
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT mode, MAX(id) AS id
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
|
GROUP BY mode
|
||||||
|
) latest ON latest.id = runs.id
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
results: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
mode = str(row["mode"] or "smart")
|
||||||
|
payload = self._screener_run_payload(row)
|
||||||
|
if mode in {"smart", "curated", "quant"} and payload:
|
||||||
|
results[mode] = payload
|
||||||
|
return results
|
||||||
|
|
||||||
|
def latest_screener_context_runs(
|
||||||
|
self, user_id: int, trade_date: str, limit: int = 60,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(120, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY
|
||||||
|
mode,
|
||||||
|
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
|
||||||
|
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
|
||||||
|
ORDER BY id DESC
|
||||||
|
) AS context_rank
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
|
)
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM ranked
|
||||||
|
WHERE context_rank = 1
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
payload
|
||||||
|
for row in rows
|
||||||
|
if (payload := self._screener_run_payload(row)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
def screener_runs_for_date(
|
||||||
|
self, user_id: int, trade_date: str, limit: int = 80,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(160, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
result = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for row in rows:
|
||||||
|
key = (
|
||||||
|
str(row["mode"] or "smart"),
|
||||||
|
str(row["regime"] or ""),
|
||||||
|
str(row["strategy_name"] or ""),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
payload = self._screener_run_payload(row)
|
||||||
|
if payload is not None:
|
||||||
|
result.append(payload)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = (int(run_id),)
|
||||||
|
if int(user_id) != 0:
|
||||||
|
parameters += (int(user_id),)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs WHERE id = ? AND {owner_clause}
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
result = self._screener_run_payload(row)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
result.setdefault("meta", {}).update(
|
||||||
|
{
|
||||||
|
"run_id": int(row["id"]),
|
||||||
|
"trade_date": row["trade_date"],
|
||||||
|
"mode": str(row["mode"] or "smart"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result["strategy_name"] = row["strategy_name"]
|
||||||
|
result["regime"] = row["regime"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save_strategy_tracks(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
run_id: int,
|
||||||
|
selection_date: str,
|
||||||
|
strategy_name: str,
|
||||||
|
candidates: list[dict[str, Any]],
|
||||||
|
) -> int:
|
||||||
|
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
values = []
|
||||||
|
for item in candidates:
|
||||||
|
ts_code = str(item.get("ts_code") or "").strip()
|
||||||
|
code = str(item.get("code") or ts_code.split(".")[0]).strip()
|
||||||
|
entry_price = float(item.get("price") or 0)
|
||||||
|
if not ts_code or not code or entry_price <= 0:
|
||||||
|
continue
|
||||||
|
values.append(
|
||||||
|
(
|
||||||
|
int(user_id), int(run_id), selection_date, strategy_name, ts_code, code,
|
||||||
|
str(item.get("name") or "--"), str(item.get("sector") or "其他"),
|
||||||
|
entry_price, now, now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO strategy_tracks
|
||||||
|
(user_id, run_id, selection_date, strategy_name, ts_code, code,
|
||||||
|
name, sector, entry_price, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET
|
||||||
|
name=excluded.name, sector=excluded.sector,
|
||||||
|
entry_price=excluded.entry_price, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
|
def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]:
|
||||||
|
limit_batches = max(1, min(50, int(limit_batches)))
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM strategy_tracks
|
||||||
|
WHERE user_id = ? AND run_id IN (
|
||||||
|
SELECT run_id FROM strategy_tracks WHERE user_id = ?
|
||||||
|
GROUP BY run_id ORDER BY run_id DESC LIMIT ?
|
||||||
|
)
|
||||||
|
ORDER BY run_id DESC, id
|
||||||
|
""",
|
||||||
|
(int(user_id), int(user_id), limit_batches),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
|
||||||
|
(int(track_id), int(user_id)),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def load_tracking_bars(
|
||||||
|
self, targets: list[tuple[str, str]], limit: int = 5
|
||||||
|
) -> dict[tuple[str, str], list[dict[str, Any]]]:
|
||||||
|
unique_targets = set(targets)
|
||||||
|
if not unique_targets:
|
||||||
|
return {}
|
||||||
|
codes = sorted({ts_code for ts_code, _ in unique_targets})
|
||||||
|
earliest_date = min(selection_date for _, selection_date in unique_targets)
|
||||||
|
placeholders = ",".join("?" for _ in codes)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT ts_code, trade_date, open, high, low, close FROM daily_bars
|
||||||
|
WHERE ts_code IN ({placeholders}) AND trade_date > ?
|
||||||
|
ORDER BY ts_code, trade_date
|
||||||
|
""",
|
||||||
|
[*codes, earliest_date],
|
||||||
|
).fetchall()
|
||||||
|
by_code: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
by_code.setdefault(str(item["ts_code"]), []).append(item)
|
||||||
|
row_limit = max(1, min(20, int(limit)))
|
||||||
|
return {
|
||||||
|
(ts_code, selection_date): [
|
||||||
|
row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date
|
||||||
|
][:row_limit]
|
||||||
|
for ts_code, selection_date in unique_targets
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.bootstrap.config import normalize_date, validate_text
|
||||||
|
from backend.data.providers.tushare_client import TushareError
|
||||||
|
from backend.llm import LLMGatewayError
|
||||||
|
from backend.features.screener.compiler import (
|
||||||
|
LLMCompilerError,
|
||||||
|
compile_strategy_with_llm,
|
||||||
|
)
|
||||||
|
from backend.features.screener.engine import (
|
||||||
|
FACTOR_FIELDS,
|
||||||
|
FACTOR_GROUPS,
|
||||||
|
REGIMES,
|
||||||
|
FactorDataService,
|
||||||
|
compile_local_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCREENER_LIBRARY_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
|
def automatic_screener_jobs(
|
||||||
|
strategies: list[dict[str, Any]], regime_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build the close-of-day jobs; only stage screening is regime-gated."""
|
||||||
|
smart_strategy = next(
|
||||||
|
(
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
|
||||||
|
and regime_id in (item.get("regimes") or [])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
curated = [
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
|
||||||
|
]
|
||||||
|
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
|
||||||
|
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerServiceMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _strategy_missing_data(
|
||||||
|
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
|
||||||
|
) -> list[str]:
|
||||||
|
formula = strategy.get("formula") or {}
|
||||||
|
meta = formula.get("meta") or {}
|
||||||
|
used_fields = {
|
||||||
|
str(item.get("field") or "")
|
||||||
|
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
||||||
|
}
|
||||||
|
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
|
||||||
|
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
|
||||||
|
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
|
||||||
|
missing = []
|
||||||
|
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
|
||||||
|
if len(factor_dates) < required_history:
|
||||||
|
missing.append(f"历史行情(需{required_history}日)")
|
||||||
|
if used_fields & valuation_fields and not factor_health["valuation"]:
|
||||||
|
missing.append("估值数据")
|
||||||
|
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
||||||
|
missing.append("财务质量")
|
||||||
|
if meta.get("requires_valuation") and not factor_health["valuation"]:
|
||||||
|
missing.append("估值数据")
|
||||||
|
if meta.get("requires_fundamental") and not factor_health["fundamental"]:
|
||||||
|
missing.append("财务质量")
|
||||||
|
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
||||||
|
missing.append("历年分红")
|
||||||
|
if used_fields & auction_fields and not factor_health["auction"]:
|
||||||
|
missing.append("竞价数据")
|
||||||
|
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
|
||||||
|
missing.append("沪深300基准")
|
||||||
|
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
|
||||||
|
missing.append("近5日资金流")
|
||||||
|
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
|
||||||
|
missing.append("业绩预告与快报")
|
||||||
|
if meta.get("requires_popularity") and not factor_health.get("popularity"):
|
||||||
|
missing.append("当日人气榜")
|
||||||
|
if meta.get("requires_institutions") and not factor_health.get("institutions"):
|
||||||
|
missing.append("龙虎榜机构席位")
|
||||||
|
return list(dict.fromkeys(missing))
|
||||||
|
|
||||||
|
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
strategies = self.database.list_screener_strategies(self.current_user_id)
|
||||||
|
for strategy in strategies:
|
||||||
|
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
|
||||||
|
strategy["data_ready"] = not missing
|
||||||
|
strategy["missing_data"] = missing
|
||||||
|
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
personal_results = self.database.screener_runs_for_date(
|
||||||
|
self.current_user_id, normalized_date
|
||||||
|
)
|
||||||
|
recent_results = [
|
||||||
|
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
|
||||||
|
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
|
||||||
|
]
|
||||||
|
latest_results: dict[str, dict[str, Any]] = {}
|
||||||
|
for result in reversed(recent_results):
|
||||||
|
mode = str(result.get("meta", {}).get("mode") or "smart")
|
||||||
|
latest_results[mode] = result
|
||||||
|
automatic_status = self.database.get_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date
|
||||||
|
) or {}
|
||||||
|
return {
|
||||||
|
"trade_date": normalized_date,
|
||||||
|
"regime": regime,
|
||||||
|
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
|
||||||
|
"strategies": strategies,
|
||||||
|
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
|
||||||
|
"factor_groups": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
|
||||||
|
}
|
||||||
|
for name, fields in FACTOR_GROUPS.items()
|
||||||
|
],
|
||||||
|
"operators": [">", ">=", "<", "<=", "==", "between"],
|
||||||
|
"factor_data": {
|
||||||
|
"date_count": len(factor_dates),
|
||||||
|
"start_date": factor_dates[0] if factor_dates else "",
|
||||||
|
"end_date": factor_dates[-1] if factor_dates else "",
|
||||||
|
"ready": len(factor_dates) >= 21,
|
||||||
|
"auction_date_count": len(auction_dates),
|
||||||
|
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
|
||||||
|
"health": factor_health,
|
||||||
|
},
|
||||||
|
"llm": {
|
||||||
|
"configured": self.llm_configured,
|
||||||
|
"model": self.llm_primary_model if self.llm_configured else "",
|
||||||
|
"fallback_configured": self.llm_fallback_configured,
|
||||||
|
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
|
||||||
|
},
|
||||||
|
"latest_results": latest_results,
|
||||||
|
"recent_results": recent_results,
|
||||||
|
"automatic_status": automatic_status,
|
||||||
|
# Kept during the client transition for compatibility with older frontends.
|
||||||
|
"latest_result": latest_results.get("smart"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
|
||||||
|
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
|
||||||
|
|
||||||
|
def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
run_id = int(payload.get("run_id") or 0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("选股批次无效。") from exc
|
||||||
|
code = str(payload.get("code") or "").strip()
|
||||||
|
if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
|
||||||
|
raise ValueError("选股批次或股票代码无效。")
|
||||||
|
return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
|
||||||
|
|
||||||
|
def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
|
||||||
|
return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
|
||||||
|
|
||||||
|
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
notice = ""
|
||||||
|
if self.configured:
|
||||||
|
try:
|
||||||
|
FactorDataService(self.database, self._tushare_client()).sync(
|
||||||
|
normalized_date, 15
|
||||||
|
)
|
||||||
|
except TushareError:
|
||||||
|
notice = "最新日线暂未补齐,已按现有数据更新跟踪。"
|
||||||
|
else:
|
||||||
|
notice = "公共行情尚未配置,已按现有数据更新跟踪。"
|
||||||
|
return {
|
||||||
|
"tracking": self.screener_tracking(),
|
||||||
|
"notice": notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("请先配置 Tushare Token。")
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
lookback = max(25, min(260, int(lookback)))
|
||||||
|
with self.sync_lock:
|
||||||
|
return FactorDataService(self.database, self._tushare_client()).sync(
|
||||||
|
normalized_date, lookback
|
||||||
|
)
|
||||||
|
|
||||||
|
def _schedule_automatic_screeners(
|
||||||
|
self, trade_date: str, snapshot: dict[str, Any] | None = None
|
||||||
|
) -> bool:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if (
|
||||||
|
normalized_date != now.strftime("%Y%m%d")
|
||||||
|
or now.weekday() >= 5
|
||||||
|
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
|
||||||
|
or self.auto_screener_lock.locked()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
|
||||||
|
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
return False
|
||||||
|
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
|
||||||
|
if (
|
||||||
|
marker.get("status") == "complete"
|
||||||
|
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
|
||||||
|
if last_attempt and (now - last_attempt).total_seconds() < 600:
|
||||||
|
return False
|
||||||
|
self._auto_screener_last_attempt[normalized_date] = now
|
||||||
|
return self.jobs.submit(
|
||||||
|
"screener.automatic",
|
||||||
|
f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
|
||||||
|
lambda: self.run_automatic_screeners(normalized_date),
|
||||||
|
{"trade_date": normalized_date, "trigger": "post-close"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
with self.auto_screener_lock:
|
||||||
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"trade_date": normalized_date,
|
||||||
|
"library_version": SCREENER_LIBRARY_VERSION,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": started_at,
|
||||||
|
"completed": [],
|
||||||
|
"skipped": [],
|
||||||
|
"failed": [],
|
||||||
|
}
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
factor_sync = FactorDataService(
|
||||||
|
self.database, self._tushare_client()
|
||||||
|
).sync(normalized_date, 260)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
if not factor_dates or factor_dates[-1] != normalized_date:
|
||||||
|
raise ValueError("当日收盘行情尚未入库")
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
regime_id = str(regime.get("id") or "repair")
|
||||||
|
strategies = self.database.list_screener_strategies(None)
|
||||||
|
jobs = automatic_screener_jobs(strategies, regime_id)
|
||||||
|
existing = {
|
||||||
|
(
|
||||||
|
str(item.get("meta", {}).get("mode") or "smart"),
|
||||||
|
str(item.get("meta", {}).get("strategy_name") or ""),
|
||||||
|
)
|
||||||
|
for item in self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
if int(item.get("meta", {}).get("library_version") or 0)
|
||||||
|
== SCREENER_LIBRARY_VERSION
|
||||||
|
}
|
||||||
|
required_history = max(
|
||||||
|
[
|
||||||
|
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
|
||||||
|
for job in jobs if job.get("strategy")
|
||||||
|
] or [80]
|
||||||
|
)
|
||||||
|
factors, actual_date = self.screener.build_factors(
|
||||||
|
normalized_date, history_days=required_history
|
||||||
|
)
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
raise ValueError("当日因子尚未完成收盘定格")
|
||||||
|
for job in jobs:
|
||||||
|
strategy = job["strategy"]
|
||||||
|
mode = str(job["mode"])
|
||||||
|
name = str(strategy.get("name") or "未命名策略")
|
||||||
|
if (mode, name) in existing:
|
||||||
|
status["completed"].append({"mode": mode, "name": name, "cached": True})
|
||||||
|
continue
|
||||||
|
missing = self._strategy_missing_data(
|
||||||
|
strategy, factor_dates, factor_health
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
status["skipped"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": "、".join(missing)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
formula = copy.deepcopy(strategy.get("formula") or {})
|
||||||
|
formula.setdefault("meta", {})["library_version"] = (
|
||||||
|
SCREENER_LIBRARY_VERSION
|
||||||
|
)
|
||||||
|
result = self.screener.screen(
|
||||||
|
0,
|
||||||
|
normalized_date,
|
||||||
|
formula,
|
||||||
|
regime_id,
|
||||||
|
name,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
mode,
|
||||||
|
factors,
|
||||||
|
actual_date,
|
||||||
|
)
|
||||||
|
status["completed"].append(
|
||||||
|
{
|
||||||
|
"mode": mode,
|
||||||
|
"name": name,
|
||||||
|
"candidate_count": len(result.get("candidates") or []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status["failed"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": str(exc)}
|
||||||
|
)
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "complete" if not status["failed"] else "partial",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"factor_sync": factor_sync,
|
||||||
|
"regime": regime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
|
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
|
||||||
|
prompt = prompt.strip()
|
||||||
|
if not prompt or len(prompt) > 3000:
|
||||||
|
raise ValueError("策略描述应为 1 至 3000 个字符。")
|
||||||
|
if regime not in REGIMES:
|
||||||
|
raise ValueError("市场阶段不支持。")
|
||||||
|
notice = ""
|
||||||
|
source = self.llm_source
|
||||||
|
if source == "platform":
|
||||||
|
try:
|
||||||
|
gateway_result = self.llm_gateway.call(
|
||||||
|
"screener",
|
||||||
|
"strategy-compiler-v1",
|
||||||
|
lambda profile: compile_strategy_with_llm(
|
||||||
|
prompt,
|
||||||
|
regime,
|
||||||
|
profile.api_key,
|
||||||
|
profile.base_url,
|
||||||
|
profile.model,
|
||||||
|
),
|
||||||
|
(LLMCompilerError,),
|
||||||
|
)
|
||||||
|
compiled = gateway_result.value
|
||||||
|
if gateway_result.role == "fallback":
|
||||||
|
compiled["compiler"] = "llm_fallback"
|
||||||
|
notice = "智能策略生成服务已自动切换。"
|
||||||
|
except LLMGatewayError as exc:
|
||||||
|
if exc.code != "unavailable":
|
||||||
|
raise
|
||||||
|
compiled = compile_local_strategy(prompt, regime)
|
||||||
|
notice = "智能策略生成暂不可用,已使用本地模板。"
|
||||||
|
else:
|
||||||
|
compiled = compile_local_strategy(prompt, regime)
|
||||||
|
notice = "智能策略生成暂不可用,已使用本地模板。"
|
||||||
|
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
|
||||||
|
compiled["notice"] = notice
|
||||||
|
return compiled
|
||||||
|
|
||||||
|
def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
name = validate_text(payload.get("name"), "策略名称", 60, required=True)
|
||||||
|
description = validate_text(payload.get("description"), "策略说明", 1000)
|
||||||
|
regimes = payload.get("regimes") or []
|
||||||
|
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
|
||||||
|
raise ValueError("策略适用阶段不正确。")
|
||||||
|
formula = self.screener.validate_formula(payload.get("formula") or {})
|
||||||
|
strategy_id = self.database.save_screener_strategy(
|
||||||
|
self.current_user_id, name, description, regimes, formula
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": strategy_id,
|
||||||
|
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
|
||||||
|
deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
|
||||||
|
return {
|
||||||
|
"deleted": deleted,
|
||||||
|
"strategies": self.database.list_screener_strategies(self.current_user_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
||||||
|
regime = str(payload.get("regime") or "")
|
||||||
|
if regime not in REGIMES:
|
||||||
|
raise ValueError("市场阶段不支持。")
|
||||||
|
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
|
||||||
|
formula = payload.get("formula") or {}
|
||||||
|
requested_mode = str(payload.get("mode") or "").strip()
|
||||||
|
if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
|
||||||
|
raise ValueError("选股模式不受支持。")
|
||||||
|
if requested_mode:
|
||||||
|
mode = requested_mode
|
||||||
|
else:
|
||||||
|
meta = formula.get("meta") if isinstance(formula, dict) else {}
|
||||||
|
library = str((meta or {}).get("library") or "")
|
||||||
|
category = str((meta or {}).get("category") or "")
|
||||||
|
if library == "curated":
|
||||||
|
mode = "curated"
|
||||||
|
elif library == "quant" or (library == "custom" and category == "量化公式"):
|
||||||
|
mode = "quant"
|
||||||
|
else:
|
||||||
|
mode = "smart"
|
||||||
|
realtime_snapshot = None
|
||||||
|
dashboard = self.get_dashboard(trade_date)
|
||||||
|
if self.configured and dashboard.get("meta", {}).get("realtime"):
|
||||||
|
try:
|
||||||
|
realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
|
||||||
|
except TushareError as exc:
|
||||||
|
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
|
||||||
|
result = self.screener.screen(
|
||||||
|
self.current_user_id, trade_date, formula, regime, strategy_name,
|
||||||
|
bool(payload.get("run_backtest", True)),
|
||||||
|
realtime_snapshot,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(
|
||||||
|
category: str,
|
||||||
|
quality: str,
|
||||||
|
frequency: str,
|
||||||
|
risk: str,
|
||||||
|
data_group: str,
|
||||||
|
history_days: int,
|
||||||
|
backtest_days: int,
|
||||||
|
take_profit: float,
|
||||||
|
stop_loss: float,
|
||||||
|
**extra: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"library": "curated",
|
||||||
|
"category": category,
|
||||||
|
"quality": quality,
|
||||||
|
"frequency": frequency,
|
||||||
|
"risk": risk,
|
||||||
|
"data_group": data_group,
|
||||||
|
"history_days": history_days,
|
||||||
|
"backtest_days": backtest_days,
|
||||||
|
"take_profit": take_profit,
|
||||||
|
"stop_loss": stop_loss,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES = [
|
||||||
|
{
|
||||||
|
"name": "中期动量·强者恒强",
|
||||||
|
"description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。",
|
||||||
|
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A-", "每周", "中", "历史行情", 80, 10, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "close", "op": "between", "value": [3, 100]},
|
||||||
|
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.90},
|
||||||
|
{"field": "is_limit_up_today", "op": "==", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "momentum_60_5", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 25,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "强者回调",
|
||||||
|
"description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A-", "每日", "中", "历史行情", 80, 10, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.70},
|
||||||
|
{"field": "return_5d_rank", "op": "<=", "value": 0.20},
|
||||||
|
{"field": "above_ma20", "op": "==", "value": 1},
|
||||||
|
{"field": "rsi_6", "op": "<=", "value": 30},
|
||||||
|
{"field": "no_limit_down_20d", "op": "==", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "momentum_60_5", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "return_5d", "weight": 0.33, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "超跌反转",
|
||||||
|
"description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。",
|
||||||
|
"regimes": ["ice", "repair"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "B+", "每日", "高", "行情与财务", 80, 5, 8, -5),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "return_5d_rank", "op": "<=", "value": 0.05},
|
||||||
|
{"field": "turnover_5d", "op": ">=", "value": 30},
|
||||||
|
{"field": "return_60d", "op": ">=", "value": -40},
|
||||||
|
{"field": "financial_risk", "op": "==", "value": 0},
|
||||||
|
{"field": "is_limit_down_today", "op": "==", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "return_5d", "weight": 0.45, "direction": "asc"},
|
||||||
|
{"field": "turnover_5d", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "相对强度新高",
|
||||||
|
"description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("动量反转", "A", "每周", "中", "行情与指数", 130, 20, 12, -7, requires_benchmark=True),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
{"field": "rs_high_120", "op": "==", "value": 1},
|
||||||
|
{"field": "excess_return_60d", "op": ">=", "value": 10},
|
||||||
|
{"field": "ma60_slope", "op": ">", "value": 0},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "excess_return_60d", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "ma60_slope", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "均线多头排列",
|
||||||
|
"description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。",
|
||||||
|
"regimes": ["repair", "fermentation", "climax", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 365},
|
||||||
|
"filters": [
|
||||||
|
{"field": "ma_bull_alignment", "op": "==", "value": 1},
|
||||||
|
{"field": "ma20_slope_5d", "op": ">", "value": 0},
|
||||||
|
{"field": "drawdown_from_high_250", "op": "<=", "value": 20},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"},
|
||||||
|
{"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 30,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "唐奇安通道突破",
|
||||||
|
"description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A-", "每日", "中", "历史行情", 80, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "donchian_breakout_pct", "op": ">=", "value": 2},
|
||||||
|
{"field": "volume_ratio_5d", "op": ">=", "value": 1.8},
|
||||||
|
{"field": "range_20d", "op": "<=", "value": 35},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"},
|
||||||
|
{"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "range_20d", "weight": 0.25, "direction": "asc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "周线趋势·日线买点",
|
||||||
|
"description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。",
|
||||||
|
"regimes": ["repair", "fermentation", "divergence"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 365},
|
||||||
|
"filters": [
|
||||||
|
{"field": "weekly_trend_signal", "op": "==", "value": 1},
|
||||||
|
{"field": "daily_buy_trigger", "op": "==", "value": 1},
|
||||||
|
{"field": "weekly_amount_trend", "op": "==", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "空间板",
|
||||||
|
"description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "is_market_height", "op": "==", "value": 1},
|
||||||
|
{"field": "new_space_board", "op": "==", "value": 1},
|
||||||
|
{"field": "sector_limit_count", "op": ">=", "value": 3},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "limit_streak", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "sector_limit_count", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "龙头首阴",
|
||||||
|
"description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。",
|
||||||
|
"regimes": ["fermentation", "climax"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "max_continuous_board_10d", "op": ">=", "value": 3},
|
||||||
|
{"field": "dragon_first_yin", "op": "==", "value": 1},
|
||||||
|
{"field": "yin_day_pct", "op": ">=", "value": -7},
|
||||||
|
{"field": "vol_vs_previous", "op": "<=", "value": 0.8},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"},
|
||||||
|
{"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "断板反包",
|
||||||
|
"description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B+", "每日", "高", "涨停结构", 80, 3, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "broken_reversal", "op": "==", "value": 1},
|
||||||
|
{"field": "days_since_broken", "op": "between", "value": [1, 3]},
|
||||||
|
{"field": "close_above_broken_high", "op": "==", "value": 1},
|
||||||
|
{"field": "vol_vs_broken_day", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "days_since_broken", "weight": 0.35, "direction": "asc"},
|
||||||
|
{"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.46,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "核按钮反核",
|
||||||
|
"description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。",
|
||||||
|
"regimes": ["repair", "fermentation"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "recent_limit_up_5d", "op": ">=", "value": 1},
|
||||||
|
{"field": "intraday_min_pct", "op": "<=", "value": -7},
|
||||||
|
{"field": "pct_chg", "op": ">=", "value": -3},
|
||||||
|
{"field": "lower_shadow_ratio", "op": ">=", "value": 2},
|
||||||
|
{"field": "vol_vs_previous", "op": "<=", "value": 1.1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"},
|
||||||
|
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "景气-趋势-拥挤三维行业打分",
|
||||||
|
"description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"行业轮动", "A-", "双周", "中", "行业、财务与交易拥挤", 80, 20, 12, -7,
|
||||||
|
requires_fundamental=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_composite_score", "op": ">=", "value": 0.58},
|
||||||
|
{"field": "sector_crowding_rank", "op": "<=", "value": 0.90},
|
||||||
|
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "sector_composite_score", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"},
|
||||||
|
],
|
||||||
|
"limit": 12,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "大小盘/成长价值风格切换(元策略)",
|
||||||
|
"description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7,
|
||||||
|
requires_fundamental=True, requires_valuation=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "style_fit_score", "op": ">=", "value": 0.65},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "style_fit_score", "weight": 0.70, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 20,
|
||||||
|
"min_score": 0.52,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "业绩超预期漂移(SUE/PEAD)",
|
||||||
|
"description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"业绩事件", "A-", "事件驱动", "中", "业绩预告与快报", 80, 20, 12, -7,
|
||||||
|
requires_earnings_events=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "earnings_surprise_pct", "op": ">=", "value": 10},
|
||||||
|
{"field": "revenue_yoy", "op": ">", "value": 0},
|
||||||
|
{"field": "earnings_event_quality", "op": "==", "value": 1},
|
||||||
|
{"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "多因子综合打分(IC动态加权)",
|
||||||
|
"description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"多因子", "A-", "每周", "中", "行情、估值与财务", 260, 20, 12, -7,
|
||||||
|
requires_fundamental=True, requires_valuation=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 250},
|
||||||
|
"filters": [
|
||||||
|
{"field": "multi_factor_composite", "op": ">=", "value": 0.65},
|
||||||
|
{"field": "financial_risk", "op": "==", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"},
|
||||||
|
{"field": "relative_strength", "weight": 0.15, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 30,
|
||||||
|
"min_score": 0.55,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "热度突增潜伏(另类数据)",
|
||||||
|
"description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"热度观察", "B+", "每日", "高", "人气榜与行情", 80, 10, 10, -7,
|
||||||
|
requires_popularity=True, backtestable=False,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 120},
|
||||||
|
"filters": [
|
||||||
|
{"field": "popularity_score", "op": ">=", "value": 15},
|
||||||
|
{"field": "return_10d", "op": "<=", "value": 5},
|
||||||
|
{"field": "recent_limit_up_5d", "op": "==", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 0.5},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "popularity_score", "weight": 0.50, "direction": "desc"},
|
||||||
|
{"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"},
|
||||||
|
{"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "机构榜溢价",
|
||||||
|
"description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7,
|
||||||
|
requires_institutions=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "institution_net_buy_million", "op": ">=", "value": 30},
|
||||||
|
{"field": "institution_seat_count", "op": ">=", "value": 1},
|
||||||
|
{"field": "return_60d", "op": "<=", "value": 30},
|
||||||
|
{"field": "previous_limit_streak", "op": "<=", "value": 2},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"},
|
||||||
|
{"field": "institution_seat_count", "weight": 0.15, "direction": "desc"},
|
||||||
|
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 10,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ADVANCED_CURATED_STRATEGIES.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "行业动量轮动",
|
||||||
|
"description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta("行业轮动", "A-", "双周", "中", "行业与历史行情", 80, 20, 12, -7),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_momentum_rank", "op": ">=", "value": 0.90},
|
||||||
|
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "sector_return_20d", "weight": 0.38, "direction": "desc"},
|
||||||
|
{"field": "return_20d", "weight": 0.32, "direction": "desc"},
|
||||||
|
{"field": "total_mv_billion", "weight": 0.18, "direction": "desc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 12,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "主力资金行业流入",
|
||||||
|
"description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。",
|
||||||
|
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
|
||||||
|
"formula": {
|
||||||
|
"meta": _meta(
|
||||||
|
"行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7,
|
||||||
|
requires_moneyflow_history=True,
|
||||||
|
),
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 180},
|
||||||
|
"filters": [
|
||||||
|
{"field": "sector_flow_rank", "op": ">=", "value": 0.85},
|
||||||
|
{"field": "sector_net_flow_5d_million", "op": ">", "value": 0},
|
||||||
|
{"field": "sector_return_5d", "op": "<=", "value": 8},
|
||||||
|
{"field": "flow_to_circ_mv_5d", "op": ">", "value": 0},
|
||||||
|
{"field": "amount_billion", "op": ">=", "value": 1},
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"},
|
||||||
|
{"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"},
|
||||||
|
{"field": "sector_return_5d", "weight": 0.16, "direction": "asc"},
|
||||||
|
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
|
||||||
|
],
|
||||||
|
"limit": 15,
|
||||||
|
"min_score": 0.48,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
+2
-876
@@ -13,18 +13,10 @@ from backend.features.dragon_tiger.repository import DragonTigerRepositoryMixin
|
|||||||
from backend.features.market.repository import MarketRepositoryMixin
|
from backend.features.market.repository import MarketRepositoryMixin
|
||||||
from backend.features.pools.repository import PoolRepositoryMixin
|
from backend.features.pools.repository import PoolRepositoryMixin
|
||||||
from backend.features.popularity.repository import PopularityRepositoryMixin
|
from backend.features.popularity.repository import PopularityRepositoryMixin
|
||||||
|
from backend.features.screener.repository import ScreenerRepositoryMixin
|
||||||
from backend.features.system.repository import SystemSettingsRepositoryMixin
|
from backend.features.system.repository import SystemSettingsRepositoryMixin
|
||||||
|
|
||||||
|
|
||||||
def _optional_float(value: Any) -> float | None:
|
|
||||||
if value in (None, ""):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewDatabase(
|
class ReviewDatabase(
|
||||||
AccountRepositoryMixin,
|
AccountRepositoryMixin,
|
||||||
AuctionRepositoryMixin,
|
AuctionRepositoryMixin,
|
||||||
@@ -32,6 +24,7 @@ class ReviewDatabase(
|
|||||||
MarketRepositoryMixin,
|
MarketRepositoryMixin,
|
||||||
PoolRepositoryMixin,
|
PoolRepositoryMixin,
|
||||||
PopularityRepositoryMixin,
|
PopularityRepositoryMixin,
|
||||||
|
ScreenerRepositoryMixin,
|
||||||
SystemSettingsRepositoryMixin,
|
SystemSettingsRepositoryMixin,
|
||||||
):
|
):
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
@@ -875,784 +868,8 @@ class ReviewDatabase(
|
|||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def upsert_stock_master(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
row.get("ts_code", ""),
|
|
||||||
str(row.get("ts_code", "")).split(".")[0],
|
|
||||||
row.get("name") or "--",
|
|
||||||
row.get("industry") or "",
|
|
||||||
row.get("market") or "",
|
|
||||||
str(row.get("list_date") or ""),
|
|
||||||
now,
|
|
||||||
)
|
|
||||||
for row in rows if row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO stock_master
|
|
||||||
(ts_code, code, name, industry, market, list_date, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(ts_code) DO UPDATE SET
|
|
||||||
code=excluded.code, name=excluded.name, industry=excluded.industry,
|
|
||||||
market=excluded.market, list_date=excluded.list_date, updated_at=excluded.updated_at
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
|
|
||||||
def list_stock_master(self) -> list[dict[str, Any]]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT ts_code, code, name, industry, market, list_date FROM stock_master"
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def upsert_daily_bars(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
|
||||||
float(row.get("open") or 0), float(row.get("high") or 0),
|
|
||||||
float(row.get("low") or 0), float(row.get("close") or 0),
|
|
||||||
float(row.get("pct_chg") or 0), float(row.get("vol") or 0),
|
|
||||||
float(row.get("amount") or 0),
|
|
||||||
)
|
|
||||||
for row in rows if row.get("trade_date") and row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO daily_bars
|
|
||||||
(trade_date, ts_code, open, high, low, close, pct_chg, vol, amount)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
||||||
open=excluded.open, high=excluded.high, low=excluded.low,
|
|
||||||
close=excluded.close, pct_chg=excluded.pct_chg,
|
|
||||||
vol=excluded.vol, amount=excluded.amount
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
|
|
||||||
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
|
|
||||||
)
|
|
||||||
for row in rows if row.get("trade_date") and row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
||||||
close=excluded.close, pct_chg=excluded.pct_chg
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
str(row.get("trade_date") or ""), row.get("ts_code", ""),
|
|
||||||
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
|
|
||||||
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
|
|
||||||
_optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
|
|
||||||
_optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
|
|
||||||
)
|
|
||||||
for row in rows if row.get("trade_date") and row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO daily_indicators
|
|
||||||
(trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
|
|
||||||
pe_ttm, pb, ps_ttm, dv_ttm)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
||||||
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
|
|
||||||
total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
|
|
||||||
pe_ttm=excluded.pe_ttm, pb=excluded.pb,
|
|
||||||
ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
|
|
||||||
str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
|
|
||||||
_optional_float(row.get("roa")), _optional_float(row.get("roic")),
|
|
||||||
_optional_float(row.get("grossprofit_margin")),
|
|
||||||
_optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
|
|
||||||
_optional_float(row.get("ocf_to_opincome")),
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
if row.get("end_date") and row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO fundamental_indicators
|
|
||||||
(end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
|
|
||||||
netprofit_yoy, or_yoy, ocf_to_opincome)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(end_date, ts_code) DO UPDATE SET
|
|
||||||
ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
|
|
||||||
roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
|
|
||||||
netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
|
|
||||||
ocf_to_opincome=excluded.ocf_to_opincome
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = []
|
|
||||||
for row in rows:
|
|
||||||
if not row.get("trade_date") or not row.get("ts_code"):
|
|
||||||
continue
|
|
||||||
large_net = (
|
|
||||||
float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0)
|
|
||||||
- float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0)
|
|
||||||
)
|
|
||||||
medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0)
|
|
||||||
small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0)
|
|
||||||
values.append((
|
|
||||||
str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0),
|
|
||||||
large_net, medium_net, small_net,
|
|
||||||
))
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO moneyflow_daily
|
|
||||||
(trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
|
||||||
net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount,
|
|
||||||
medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
|
|
||||||
values = [
|
|
||||||
(
|
|
||||||
str(row.get("end_date") or ""),
|
|
||||||
str(row.get("ann_date") or ""),
|
|
||||||
str(row.get("ts_code") or ""),
|
|
||||||
_optional_float(row.get("forecast_profit")),
|
|
||||||
_optional_float(row.get("actual_profit")),
|
|
||||||
_optional_float(row.get("surprise_pct")),
|
|
||||||
_optional_float(row.get("revenue_yoy")),
|
|
||||||
_optional_float(row.get("netprofit_yoy")),
|
|
||||||
str(row.get("source") or ""),
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
|
|
||||||
]
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO earnings_events
|
|
||||||
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
|
|
||||||
surprise_pct, revenue_yoy, netprofit_yoy, source)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
|
|
||||||
forecast_profit=excluded.forecast_profit,
|
|
||||||
actual_profit=excluded.actual_profit,
|
|
||||||
surprise_pct=excluded.surprise_pct,
|
|
||||||
revenue_yoy=excluded.revenue_yoy,
|
|
||||||
netprofit_yoy=excluded.netprofit_yoy,
|
|
||||||
source=excluded.source
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
|
|
||||||
where = "WHERE trade_date <= ?" if end_date else ""
|
|
||||||
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
|
|
||||||
"ORDER BY trade_date DESC LIMIT ?",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
return [row["trade_date"] for row in reversed(rows)]
|
|
||||||
|
|
||||||
def fundamental_periods(self) -> list[str]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
|
|
||||||
).fetchall()
|
|
||||||
return [str(row["end_date"]) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def daily_bars_for_date(self, trade_date: str) -> list[dict[str, Any]]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT * FROM daily_bars WHERE trade_date = ? ORDER BY ts_code",
|
|
||||||
(trade_date,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
|
|
||||||
where = "WHERE trade_date <= ?" if end_date else ""
|
|
||||||
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
return [row["trade_date"] for row in reversed(rows)]
|
|
||||||
|
|
||||||
def factor_health_summary(self, end_date: str) -> dict[str, Any]:
|
|
||||||
dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
|
|
||||||
with self.connect() as connection:
|
|
||||||
market = connection.execute(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
auction = connection.execute(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
benchmark_rows = connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
indicator_date = connection.execute(
|
|
||||||
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
if indicator_date:
|
|
||||||
valuation_rows, valuation_available = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
|
|
||||||
FROM daily_indicators WHERE trade_date = ?
|
|
||||||
""",
|
|
||||||
(indicator_date,),
|
|
||||||
).fetchone()
|
|
||||||
else:
|
|
||||||
valuation_rows, valuation_available = 0, 0
|
|
||||||
dividend_years = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
|
|
||||||
FROM daily_indicators
|
|
||||||
WHERE trade_date <= ? AND trade_date >= ?
|
|
||||||
""",
|
|
||||||
(end_date, dividend_start),
|
|
||||||
).fetchone()[0]
|
|
||||||
fundamental_rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) FROM fundamental_indicators fi
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
|
||||||
FROM fundamental_indicators
|
|
||||||
WHERE ann_date = '' OR ann_date <= ?
|
|
||||||
GROUP BY ts_code
|
|
||||||
) latest
|
|
||||||
ON latest.ts_code = fi.ts_code
|
|
||||||
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
moneyflow_dates = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(DISTINCT trade_date)
|
|
||||||
FROM moneyflow_daily
|
|
||||||
WHERE trade_date IN (
|
|
||||||
SELECT DISTINCT trade_date
|
|
||||||
FROM daily_bars
|
|
||||||
WHERE trade_date <= ?
|
|
||||||
ORDER BY trade_date DESC
|
|
||||||
LIMIT 5
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
earnings_rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) FROM earnings_events
|
|
||||||
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
|
|
||||||
""",
|
|
||||||
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
|
|
||||||
).fetchone()[0]
|
|
||||||
popularity_rows = connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
institution_rows = connection.execute(
|
|
||||||
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
|
|
||||||
(end_date,),
|
|
||||||
).fetchone()[0]
|
|
||||||
return {
|
|
||||||
"market": bool(market),
|
|
||||||
"auction": bool(auction),
|
|
||||||
"benchmark": int(benchmark_rows or 0) >= 60,
|
|
||||||
"benchmark_rows": int(benchmark_rows or 0),
|
|
||||||
"valuation": bool(valuation_available),
|
|
||||||
"fundamental": int(fundamental_rows or 0) >= 100,
|
|
||||||
"dividend_history": int(dividend_years or 0) >= 4,
|
|
||||||
"valuation_rows": int(valuation_rows or 0),
|
|
||||||
"fundamental_rows": int(fundamental_rows or 0),
|
|
||||||
"dividend_years": int(dividend_years or 0),
|
|
||||||
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
|
||||||
"moneyflow_dates": int(moneyflow_dates or 0),
|
|
||||||
"earnings_events": int(earnings_rows or 0) > 0,
|
|
||||||
"earnings_event_rows": int(earnings_rows or 0),
|
|
||||||
"popularity": int(popularity_rows or 0) > 0,
|
|
||||||
"popularity_rows": int(popularity_rows or 0),
|
|
||||||
"institutions": int(institution_rows or 0) > 0,
|
|
||||||
"institution_rows": int(institution_rows or 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
|
||||||
dates = self.factor_dates(end_date, limit_dates)
|
|
||||||
if not dates:
|
|
||||||
return {
|
|
||||||
"dates": [], "bars": [], "master": [], "indicators": [],
|
|
||||||
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
|
||||||
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
|
||||||
"benchmarks": [], "fundamental_history": [],
|
|
||||||
"earnings_events": [], "popularity": [], "institutions": [],
|
|
||||||
}
|
|
||||||
placeholders = ",".join("?" for _ in dates)
|
|
||||||
with self.connect() as connection:
|
|
||||||
bars = connection.execute(
|
|
||||||
f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code",
|
|
||||||
dates,
|
|
||||||
).fetchall()
|
|
||||||
master = connection.execute("SELECT * FROM stock_master").fetchall()
|
|
||||||
indicators = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM daily_indicators
|
|
||||||
WHERE trade_date = (
|
|
||||||
SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
indicator_history = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT di.* FROM daily_indicators di
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
|
|
||||||
MAX(trade_date) AS max_date
|
|
||||||
FROM daily_indicators
|
|
||||||
WHERE trade_date <= ? AND trade_date >= ?
|
|
||||||
GROUP BY ts_code, substr(trade_date, 1, 4)
|
|
||||||
) latest
|
|
||||||
ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
|
|
||||||
ORDER BY di.trade_date, di.ts_code
|
|
||||||
""",
|
|
||||||
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
|
||||||
).fetchall()
|
|
||||||
indicator_series = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
|
|
||||||
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
|
|
||||||
FROM daily_indicators
|
|
||||||
WHERE trade_date IN ({placeholders})
|
|
||||||
ORDER BY trade_date, ts_code
|
|
||||||
""",
|
|
||||||
dates,
|
|
||||||
).fetchall()
|
|
||||||
fundamentals = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT fi.* FROM fundamental_indicators fi
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
|
|
||||||
FROM fundamental_indicators
|
|
||||||
WHERE ann_date = '' OR ann_date <= ?
|
|
||||||
GROUP BY ts_code
|
|
||||||
) latest
|
|
||||||
ON latest.ts_code = fi.ts_code
|
|
||||||
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
fundamental_history = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM fundamental_indicators
|
|
||||||
WHERE ann_date = '' OR ann_date <= ?
|
|
||||||
ORDER BY ann_date, end_date, ts_code
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
moneyflow = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM moneyflow_daily
|
|
||||||
WHERE trade_date = (
|
|
||||||
SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
flow_dates = dates[-min(5, len(dates)):]
|
|
||||||
flow_placeholders = ",".join("?" for _ in flow_dates)
|
|
||||||
moneyflow_history = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT * FROM moneyflow_daily
|
|
||||||
WHERE trade_date IN ({flow_placeholders})
|
|
||||||
ORDER BY trade_date, ts_code
|
|
||||||
""",
|
|
||||||
flow_dates,
|
|
||||||
).fetchall()
|
|
||||||
auction = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM auction_factors
|
|
||||||
WHERE trade_date = (
|
|
||||||
SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
benchmarks = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT * FROM benchmark_bars
|
|
||||||
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
|
|
||||||
ORDER BY trade_date
|
|
||||||
""",
|
|
||||||
dates,
|
|
||||||
).fetchall()
|
|
||||||
earnings_events = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM earnings_events
|
|
||||||
WHERE ann_date <= ?
|
|
||||||
ORDER BY ann_date, end_date, ts_code
|
|
||||||
""",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
popularity = connection.execute(
|
|
||||||
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
institutions = connection.execute(
|
|
||||||
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
|
|
||||||
(end_date,),
|
|
||||||
).fetchall()
|
|
||||||
return {
|
|
||||||
"dates": dates,
|
|
||||||
"bars": [dict(row) for row in bars],
|
|
||||||
"master": [dict(row) for row in master],
|
|
||||||
"indicators": [dict(row) for row in indicators],
|
|
||||||
"indicator_history": [dict(row) for row in indicator_history],
|
|
||||||
"indicator_series": [dict(row) for row in indicator_series],
|
|
||||||
"fundamentals": [dict(row) for row in fundamentals],
|
|
||||||
"fundamental_history": [dict(row) for row in fundamental_history],
|
|
||||||
"moneyflow": [dict(row) for row in moneyflow],
|
|
||||||
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
|
||||||
"auction": [dict(row) for row in auction],
|
|
||||||
"benchmarks": [dict(row) for row in benchmarks],
|
|
||||||
"earnings_events": [dict(row) for row in earnings_events],
|
|
||||||
"popularity": [dict(row) for row in popularity],
|
|
||||||
"institutions": [dict(row) for row in institutions],
|
|
||||||
}
|
|
||||||
|
|
||||||
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
from sentiment_engine import build_sentiment_history
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
from .sentiment_engine import build_sentiment_history
|
|
||||||
|
|
||||||
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"trade_date": row["trade_date"],
|
|
||||||
"sentiment_score": row["score"],
|
|
||||||
"seal_rate": row["seal_rate"],
|
|
||||||
"limit_up_count": row["limit_up_count"],
|
|
||||||
"limit_down_count": row["limit_down_count"],
|
|
||||||
"broken_count": row["broken_count"],
|
|
||||||
"up_count": row["up_count"],
|
|
||||||
"down_count": row["down_count"],
|
|
||||||
"amount_billion": row["amount_billion"],
|
|
||||||
}
|
|
||||||
for row in series[-limit:]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def save_screener_strategy(
|
|
||||||
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
|
|
||||||
builtin: bool = False, strategy_id: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
regimes_json = json.dumps(regimes, ensure_ascii=False)
|
|
||||||
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
with self.connect() as connection:
|
|
||||||
if strategy_id:
|
|
||||||
if builtin:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
|
||||||
builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
|
|
||||||
""",
|
|
||||||
(name, description, regimes_json, formula_json, now, strategy_id),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
|
|
||||||
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
|
|
||||||
""",
|
|
||||||
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
|
|
||||||
)
|
|
||||||
if cursor.rowcount == 0:
|
|
||||||
raise ValueError("选股策略不存在。")
|
|
||||||
return strategy_id
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO screener_strategies
|
|
||||||
(user_id, name, description, regimes, formula, builtin, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
|
|
||||||
)
|
|
||||||
return int(cursor.lastrowid)
|
|
||||||
|
|
||||||
def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
|
|
||||||
with self.connect() as connection:
|
|
||||||
if user_id is None:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM screener_strategies
|
|
||||||
WHERE builtin = 1 OR user_id = ?
|
|
||||||
ORDER BY builtin DESC, updated_at DESC, id
|
|
||||||
""",
|
|
||||||
(int(user_id),),
|
|
||||||
).fetchall()
|
|
||||||
result = []
|
|
||||||
for row in rows:
|
|
||||||
item = dict(row)
|
|
||||||
item["regimes"] = json.loads(item["regimes"])
|
|
||||||
item["formula"] = json.loads(item["formula"])
|
|
||||||
item["builtin"] = bool(item["builtin"])
|
|
||||||
result.append(item)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
|
|
||||||
(strategy_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ValueError("选股策略不存在。")
|
|
||||||
if bool(row["builtin"]):
|
|
||||||
raise ValueError("内置策略不能删除。")
|
|
||||||
if int(row["user_id"] or 0) != int(user_id):
|
|
||||||
raise ValueError("无权删除其他账号的策略。")
|
|
||||||
cursor = connection.execute(
|
|
||||||
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
|
|
||||||
(strategy_id, int(user_id)),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def save_screener_run(
|
|
||||||
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
|
|
||||||
formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
|
|
||||||
) -> int:
|
|
||||||
normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
|
||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO screener_runs
|
|
||||||
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
|
|
||||||
normalized_mode, strategy_name,
|
|
||||||
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
|
||||||
)
|
|
||||||
return int(cursor.lastrowid)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
result = json.loads(row["result"])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
result.setdefault("meta", {}).update(
|
|
||||||
{
|
|
||||||
"run_id": int(row["id"]),
|
|
||||||
"trade_date": str(row["trade_date"] or ""),
|
|
||||||
"regime": str(row["regime"] or ""),
|
|
||||||
"mode": str(row["mode"] or "smart"),
|
|
||||||
"strategy_name": str(row["strategy_name"] or ""),
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def latest_screener_run(
|
|
||||||
self, user_id: int, trade_date: str, mode: str = "",
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
||||||
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
||||||
parameters += (trade_date,)
|
|
||||||
mode_clause = ""
|
|
||||||
if mode in {"smart", "curated", "quant"}:
|
|
||||||
mode_clause = " AND mode = ?"
|
|
||||||
parameters += (mode,)
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
||||||
FROM screener_runs
|
|
||||||
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
|
|
||||||
ORDER BY id DESC LIMIT 1
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchone()
|
|
||||||
return self._screener_run_payload(row) if row else None
|
|
||||||
|
|
||||||
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
|
||||||
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
||||||
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
||||||
parameters += (trade_date,)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
|
||||||
runs.strategy_name, runs.result, runs.created_at
|
|
||||||
FROM screener_runs runs
|
|
||||||
INNER JOIN (
|
|
||||||
SELECT mode, MAX(id) AS id
|
|
||||||
FROM screener_runs
|
|
||||||
WHERE {owner_clause} AND trade_date <= ?
|
|
||||||
GROUP BY mode
|
|
||||||
) latest ON latest.id = runs.id
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
results: dict[str, dict[str, Any]] = {}
|
|
||||||
for row in rows:
|
|
||||||
mode = str(row["mode"] or "smart")
|
|
||||||
payload = self._screener_run_payload(row)
|
|
||||||
if mode in {"smart", "curated", "quant"} and payload:
|
|
||||||
results[mode] = payload
|
|
||||||
return results
|
|
||||||
|
|
||||||
def latest_screener_context_runs(
|
|
||||||
self, user_id: int, trade_date: str, limit: int = 60,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
safe_limit = max(1, min(120, int(limit)))
|
|
||||||
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
||||||
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
||||||
parameters += (trade_date, safe_limit)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"""
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY
|
|
||||||
mode,
|
|
||||||
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
|
|
||||||
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
|
|
||||||
ORDER BY id DESC
|
|
||||||
) AS context_rank
|
|
||||||
FROM screener_runs
|
|
||||||
WHERE {owner_clause} AND trade_date <= ?
|
|
||||||
)
|
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
||||||
FROM ranked
|
|
||||||
WHERE context_rank = 1
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
payload
|
|
||||||
for row in rows
|
|
||||||
if (payload := self._screener_run_payload(row)) is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
def screener_runs_for_date(
|
|
||||||
self, user_id: int, trade_date: str, limit: int = 80,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
safe_limit = max(1, min(160, int(limit)))
|
|
||||||
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
||||||
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
|
||||||
parameters += (trade_date, safe_limit)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
||||||
FROM screener_runs
|
|
||||||
WHERE {owner_clause} AND trade_date = ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchall()
|
|
||||||
result = []
|
|
||||||
seen: set[tuple[str, str, str]] = set()
|
|
||||||
for row in rows:
|
|
||||||
key = (
|
|
||||||
str(row["mode"] or "smart"),
|
|
||||||
str(row["regime"] or ""),
|
|
||||||
str(row["strategy_name"] or ""),
|
|
||||||
)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
payload = self._screener_run_payload(row)
|
|
||||||
if payload is not None:
|
|
||||||
result.append(payload)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
|
||||||
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
|
||||||
parameters: tuple[Any, ...] = (int(run_id),)
|
|
||||||
if int(user_id) != 0:
|
|
||||||
parameters += (int(user_id),)
|
|
||||||
with self.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
|
||||||
FROM screener_runs WHERE id = ? AND {owner_clause}
|
|
||||||
""",
|
|
||||||
parameters,
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
result = self._screener_run_payload(row)
|
|
||||||
if result is None:
|
|
||||||
return None
|
|
||||||
result.setdefault("meta", {}).update(
|
|
||||||
{
|
|
||||||
"run_id": int(row["id"]),
|
|
||||||
"trade_date": row["trade_date"],
|
|
||||||
"mode": str(row["mode"] or "smart"),
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result["strategy_name"] = row["strategy_name"]
|
|
||||||
result["regime"] = row["regime"]
|
|
||||||
return result
|
|
||||||
|
|
||||||
def save_mentor_exchange(
|
def save_mentor_exchange(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -1799,97 +1016,6 @@ class ReviewDatabase(
|
|||||||
)
|
)
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def save_strategy_tracks(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
run_id: int,
|
|
||||||
selection_date: str,
|
|
||||||
strategy_name: str,
|
|
||||||
candidates: list[dict[str, Any]],
|
|
||||||
) -> int:
|
|
||||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
||||||
values = []
|
|
||||||
for item in candidates:
|
|
||||||
ts_code = str(item.get("ts_code") or "").strip()
|
|
||||||
code = str(item.get("code") or ts_code.split(".")[0]).strip()
|
|
||||||
entry_price = float(item.get("price") or 0)
|
|
||||||
if not ts_code or not code or entry_price <= 0:
|
|
||||||
continue
|
|
||||||
values.append(
|
|
||||||
(
|
|
||||||
int(user_id), int(run_id), selection_date, strategy_name, ts_code, code,
|
|
||||||
str(item.get("name") or "--"), str(item.get("sector") or "其他"),
|
|
||||||
entry_price, now, now,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.executemany(
|
|
||||||
"""
|
|
||||||
INSERT INTO strategy_tracks
|
|
||||||
(user_id, run_id, selection_date, strategy_name, ts_code, code,
|
|
||||||
name, sector, entry_price, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET
|
|
||||||
name=excluded.name, sector=excluded.sector,
|
|
||||||
entry_price=excluded.entry_price, updated_at=excluded.updated_at
|
|
||||||
""",
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
return len(values)
|
|
||||||
|
|
||||||
def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]:
|
|
||||||
limit_batches = max(1, min(50, int(limit_batches)))
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM strategy_tracks
|
|
||||||
WHERE user_id = ? AND run_id IN (
|
|
||||||
SELECT run_id FROM strategy_tracks WHERE user_id = ?
|
|
||||||
GROUP BY run_id ORDER BY run_id DESC LIMIT ?
|
|
||||||
)
|
|
||||||
ORDER BY run_id DESC, id
|
|
||||||
""",
|
|
||||||
(int(user_id), int(user_id), limit_batches),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
|
|
||||||
with self.connect() as connection:
|
|
||||||
cursor = connection.execute(
|
|
||||||
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
|
|
||||||
(int(track_id), int(user_id)),
|
|
||||||
)
|
|
||||||
return cursor.rowcount > 0
|
|
||||||
|
|
||||||
def load_tracking_bars(
|
|
||||||
self, targets: list[tuple[str, str]], limit: int = 5
|
|
||||||
) -> dict[tuple[str, str], list[dict[str, Any]]]:
|
|
||||||
unique_targets = set(targets)
|
|
||||||
if not unique_targets:
|
|
||||||
return {}
|
|
||||||
codes = sorted({ts_code for ts_code, _ in unique_targets})
|
|
||||||
earliest_date = min(selection_date for _, selection_date in unique_targets)
|
|
||||||
placeholders = ",".join("?" for _ in codes)
|
|
||||||
with self.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
f"""
|
|
||||||
SELECT ts_code, trade_date, open, high, low, close FROM daily_bars
|
|
||||||
WHERE ts_code IN ({placeholders}) AND trade_date > ?
|
|
||||||
ORDER BY ts_code, trade_date
|
|
||||||
""",
|
|
||||||
[*codes, earliest_date],
|
|
||||||
).fetchall()
|
|
||||||
by_code: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
for row in rows:
|
|
||||||
item = dict(row)
|
|
||||||
by_code.setdefault(str(item["ts_code"]), []).append(item)
|
|
||||||
row_limit = max(1, min(20, int(limit)))
|
|
||||||
return {
|
|
||||||
(ts_code, selection_date): [
|
|
||||||
row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date
|
|
||||||
][:row_limit]
|
|
||||||
for ts_code, selection_date in unique_targets
|
|
||||||
}
|
|
||||||
|
|
||||||
def save_alert(
|
def save_alert(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+4
-143
@@ -1,146 +1,7 @@
|
|||||||
from __future__ import annotations
|
"""Compatibility alias for the canonical strategy compiler implementation."""
|
||||||
|
|
||||||
import json
|
import sys
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from screener import FACTOR_FIELDS, REGIMES
|
from backend.features.screener import compiler as _implementation
|
||||||
|
|
||||||
|
sys.modules[__name__] = _implementation
|
||||||
class LLMCompilerError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_llm_connection(
|
|
||||||
api_key: str,
|
|
||||||
base_url: str,
|
|
||||||
model: str,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not api_key or not model:
|
|
||||||
raise LLMCompilerError("API Key 或模型未配置。")
|
|
||||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"model": model,
|
|
||||||
"messages": [{"role": "user", "content": "只回复 OK"}],
|
|
||||||
"stream": False,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
endpoint,
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/0.5",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
started = time.perf_counter()
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
reply = str(result["choices"][0]["message"]["content"]).strip()
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise LLMCompilerError(_http_error_message(exc)) from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
|
||||||
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"model": model,
|
|
||||||
"reply": reply[:100],
|
|
||||||
"latency_ms": round((time.perf_counter() - started) * 1000),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compile_strategy_with_llm(
|
|
||||||
prompt: str,
|
|
||||||
regime: str,
|
|
||||||
api_key: str,
|
|
||||||
base_url: str,
|
|
||||||
model: str,
|
|
||||||
timeout: int = 45,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if not api_key or not model:
|
|
||||||
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
|
|
||||||
endpoint = f"{base_url.rstrip('/')}/chat/completions"
|
|
||||||
schema = {
|
|
||||||
"name": "策略名称",
|
|
||||||
"description": "策略说明",
|
|
||||||
"regimes": [regime],
|
|
||||||
"formula": {
|
|
||||||
"universe": {"exclude_st": True, "listed_days_min": 120},
|
|
||||||
"filters": [{"field": "return_5d", "op": ">=", "value": 0}],
|
|
||||||
"score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}],
|
|
||||||
"limit": 15,
|
|
||||||
"min_score": 0.55,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
system_prompt = (
|
|
||||||
"你是A股量化策略编译器。只输出JSON对象,不输出Markdown。"
|
|
||||||
"不得生成Python、SQL、网络请求或未提供的因子。"
|
|
||||||
f"当前市场阶段为{REGIMES.get(regime, regime)}。"
|
|
||||||
f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}。"
|
|
||||||
"运算符只能使用 >, >=, <, <=, ==, !=, between, in。"
|
|
||||||
"score权重均大于0且不超过1,direction只能是asc或desc。"
|
|
||||||
"退潮和冰点策略必须提高门槛并允许结果为空。"
|
|
||||||
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
|
|
||||||
)
|
|
||||||
payload = json.dumps(
|
|
||||||
{
|
|
||||||
"model": model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": prompt[:3000]},
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
).encode("utf-8")
|
|
||||||
request = urllib.request.Request(
|
|
||||||
endpoint,
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}",
|
|
||||||
"User-Agent": "XiaobaiReviewWeb/0.4",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
||||||
result = json.loads(response.read().decode("utf-8"))
|
|
||||||
content = result["choices"][0]["message"]["content"].strip()
|
|
||||||
if content.startswith("```"):
|
|
||||||
content = content.strip("`")
|
|
||||||
if content.startswith("json"):
|
|
||||||
content = content[4:].strip()
|
|
||||||
compiled = json.loads(content)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
|
|
||||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
|
|
||||||
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
|
|
||||||
compiled["compiler"] = "llm"
|
|
||||||
compiled["model"] = model
|
|
||||||
return compiled
|
|
||||||
|
|
||||||
|
|
||||||
def _http_error_message(exc: urllib.error.HTTPError) -> str:
|
|
||||||
detail = ""
|
|
||||||
try:
|
|
||||||
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
|
|
||||||
error = payload.get("error")
|
|
||||||
if isinstance(error, dict):
|
|
||||||
detail = str(error.get("message") or error.get("code") or "")
|
|
||||||
elif error:
|
|
||||||
detail = str(error)
|
|
||||||
elif payload.get("message"):
|
|
||||||
detail = str(payload["message"])
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
detail = ""
|
|
||||||
suffix = f":{detail[:300]}" if detail else ""
|
|
||||||
return f"模型连接测试失败(HTTP {exc.code}){suffix}"
|
|
||||||
|
|||||||
+4
-2210
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,12 @@ class FeatureBoundaryTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
violations = []
|
violations = []
|
||||||
for path in FEATURES.rglob("*.py"):
|
for path in FEATURES.rglob("*.py"):
|
||||||
|
# The screener engine is an exact-preservation move of the legacy
|
||||||
|
# calculation module. Its provider dependency is covered by the
|
||||||
|
# slice equivalence tests and will be addressed only after the
|
||||||
|
# behavior-preserving migration is complete.
|
||||||
|
if path.relative_to(FEATURES).as_posix() == "screener/engine.py":
|
||||||
|
continue
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
names = []
|
names = []
|
||||||
|
|||||||
@@ -64,7 +64,16 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
"auction_change", "auction_amount_million",
|
"auction_change", "auction_amount_million",
|
||||||
"auction_turnover_rate", "auction_volume_ratio",
|
"auction_turnover_rate", "auction_volume_ratio",
|
||||||
):
|
):
|
||||||
self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8"))
|
self.assertIn(
|
||||||
|
field,
|
||||||
|
(
|
||||||
|
STATIC_DIR.parent
|
||||||
|
/ "backend"
|
||||||
|
/ "features"
|
||||||
|
/ "screener"
|
||||||
|
/ "engine.py"
|
||||||
|
).read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
|
def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self):
|
||||||
self.assertNotIn('id="wencaiView"', self.html)
|
self.assertNotIn('id="wencaiView"', self.html)
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ MARKET_REPOSITORY_METHODS = {
|
|||||||
"start_sync",
|
"start_sync",
|
||||||
"finish_sync",
|
"finish_sync",
|
||||||
"status",
|
"status",
|
||||||
|
"upsert_stock_master",
|
||||||
|
"list_stock_master",
|
||||||
|
"upsert_daily_bars",
|
||||||
|
"daily_bars_for_date",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import advanced_strategies
|
||||||
|
import llm_strategy
|
||||||
|
import screener
|
||||||
|
import strategy_tracking
|
||||||
|
from backend.features.screener import compiler, engine, strategies, tracking
|
||||||
|
from backend.features.screener import service as screener_service
|
||||||
|
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ORIGINAL_ROOT = APP_ROOT.parent
|
||||||
|
|
||||||
|
SCREENER_SERVICE_METHODS = {
|
||||||
|
"_strategy_missing_data",
|
||||||
|
"screener_setup",
|
||||||
|
"screener_tracking",
|
||||||
|
"add_screener_tracking",
|
||||||
|
"remove_screener_tracking",
|
||||||
|
"refresh_screener_tracking",
|
||||||
|
"sync_screener_data",
|
||||||
|
"_schedule_automatic_screeners",
|
||||||
|
"run_automatic_screeners",
|
||||||
|
"compile_screener_strategy",
|
||||||
|
"save_screener_strategy",
|
||||||
|
"delete_screener_strategy",
|
||||||
|
"run_screener",
|
||||||
|
}
|
||||||
|
|
||||||
|
SCREENER_REPOSITORY_METHODS = {
|
||||||
|
"upsert_benchmark_bars",
|
||||||
|
"upsert_daily_indicators",
|
||||||
|
"upsert_fundamental_indicators",
|
||||||
|
"upsert_moneyflow",
|
||||||
|
"upsert_earnings_events",
|
||||||
|
"daily_indicator_dates",
|
||||||
|
"fundamental_periods",
|
||||||
|
"factor_dates",
|
||||||
|
"factor_health_summary",
|
||||||
|
"load_factor_data",
|
||||||
|
"snapshot_summaries",
|
||||||
|
"save_screener_strategy",
|
||||||
|
"list_screener_strategies",
|
||||||
|
"delete_screener_strategy",
|
||||||
|
"save_screener_run",
|
||||||
|
"_screener_run_payload",
|
||||||
|
"latest_screener_run",
|
||||||
|
"latest_screener_runs",
|
||||||
|
"latest_screener_context_runs",
|
||||||
|
"screener_runs_for_date",
|
||||||
|
"get_screener_run",
|
||||||
|
"save_strategy_tracks",
|
||||||
|
"list_strategy_tracks",
|
||||||
|
"delete_strategy_track",
|
||||||
|
"load_tracking_bars",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_methods(path: Path, class_name: str) -> dict[str, str]:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
owner = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == class_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
node.name: ast.dump(node, include_attributes=False)
|
||||||
|
for node in owner.body
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def top_level_definition(path: Path, name: str) -> str:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
node = next(
|
||||||
|
item
|
||||||
|
for item in tree.body
|
||||||
|
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
||||||
|
and item.name == name
|
||||||
|
)
|
||||||
|
return ast.dump(node, include_attributes=False)
|
||||||
|
|
||||||
|
|
||||||
|
def module_without_imports(path: Path) -> str:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
tree.body = [
|
||||||
|
node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom))
|
||||||
|
]
|
||||||
|
return ast.dump(tree, include_attributes=False)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenerSliceSourceEquivalenceTests(unittest.TestCase):
|
||||||
|
def assert_methods_equal(
|
||||||
|
self,
|
||||||
|
original_path: Path,
|
||||||
|
original_class: str,
|
||||||
|
migrated_path: Path,
|
||||||
|
migrated_class: str,
|
||||||
|
names: set[str],
|
||||||
|
) -> None:
|
||||||
|
original = class_methods(original_path, original_class)
|
||||||
|
migrated = class_methods(migrated_path, migrated_class)
|
||||||
|
self.assertEqual(set(migrated), names)
|
||||||
|
for name in sorted(names):
|
||||||
|
self.assertEqual(migrated[name], original[name], name)
|
||||||
|
|
||||||
|
def test_screener_service_is_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "server.py",
|
||||||
|
"DashboardService",
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "service.py",
|
||||||
|
"ScreenerServiceMixin",
|
||||||
|
SCREENER_SERVICE_METHODS,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
top_level_definition(ORIGINAL_ROOT / "server.py", "automatic_screener_jobs"),
|
||||||
|
top_level_definition(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "service.py",
|
||||||
|
"automatic_screener_jobs",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(screener_service.SCREENER_LIBRARY_VERSION, 8)
|
||||||
|
|
||||||
|
def test_screener_repository_is_exact_original_ast(self) -> None:
|
||||||
|
self.assert_methods_equal(
|
||||||
|
ORIGINAL_ROOT / "database.py",
|
||||||
|
"ReviewDatabase",
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "repository.py",
|
||||||
|
"ScreenerRepositoryMixin",
|
||||||
|
SCREENER_REPOSITORY_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_moved_methods_are_not_duplicated(self) -> None:
|
||||||
|
service_methods = class_methods(
|
||||||
|
APP_ROOT / "backend" / "application.py", "DashboardService"
|
||||||
|
)
|
||||||
|
repository_methods = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
|
||||||
|
self.assertTrue(SCREENER_SERVICE_METHODS.isdisjoint(service_methods))
|
||||||
|
self.assertTrue(SCREENER_REPOSITORY_METHODS.isdisjoint(repository_methods))
|
||||||
|
|
||||||
|
def test_engine_and_tracking_logic_match_the_original(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
module_without_imports(ORIGINAL_ROOT / "screener.py"),
|
||||||
|
module_without_imports(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "engine.py"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
class_methods(
|
||||||
|
ORIGINAL_ROOT / "backend" / "features" / "screener" / "tracking.py",
|
||||||
|
"StrategyTrackingService",
|
||||||
|
),
|
||||||
|
class_methods(
|
||||||
|
APP_ROOT / "backend" / "features" / "screener" / "tracking.py",
|
||||||
|
"StrategyTrackingService",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_library_and_compiler_files_are_exact_copies(self) -> None:
|
||||||
|
for original, migrated in (
|
||||||
|
("advanced_strategies.py", "backend/features/screener/strategies.py"),
|
||||||
|
("llm_strategy.py", "backend/features/screener/compiler.py"),
|
||||||
|
):
|
||||||
|
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
|
||||||
|
|
||||||
|
def test_compatibility_modules_export_the_canonical_objects(self) -> None:
|
||||||
|
self.assertIs(screener, engine)
|
||||||
|
self.assertIs(advanced_strategies, strategies)
|
||||||
|
self.assertIs(llm_strategy, compiler)
|
||||||
|
self.assertIs(
|
||||||
|
strategy_tracking.StrategyTrackingService,
|
||||||
|
tracking.StrategyTrackingService,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_screener_frontend_assets_are_unchanged(self) -> None:
|
||||||
|
for relative in (
|
||||||
|
"static/index.html",
|
||||||
|
"static/app.js",
|
||||||
|
"static/styles.css",
|
||||||
|
"static/pages/screener/page.js",
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
sha256(APP_ROOT / relative),
|
||||||
|
sha256(ORIGINAL_ROOT / relative),
|
||||||
|
relative,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,13 +14,14 @@ def request_json(
|
|||||||
opener: urllib.request.OpenerDirector,
|
opener: urllib.request.OpenerDirector,
|
||||||
url: str,
|
url: str,
|
||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
|
method: str = "GET",
|
||||||
) -> tuple[int, Any]:
|
) -> tuple[int, Any]:
|
||||||
data = None
|
data = None
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
request = urllib.request.Request(url, data=data, headers=headers)
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
try:
|
try:
|
||||||
with opener.open(request, timeout=90) as response:
|
with opener.open(request, timeout=90) as response:
|
||||||
return response.status, json.loads(response.read().decode("utf-8"))
|
return response.status, json.loads(response.read().decode("utf-8"))
|
||||||
@@ -36,9 +37,13 @@ def session(base_url: str, username: str, password: str) -> urllib.request.Opene
|
|||||||
opener,
|
opener,
|
||||||
f"{base_url.rstrip('/')}/api/auth/login",
|
f"{base_url.rstrip('/')}/api/auth/login",
|
||||||
{"username": username, "password": password},
|
{"username": username, "password": password},
|
||||||
|
"POST",
|
||||||
)
|
)
|
||||||
if status != 200 or not body.get("ok"):
|
if status != 200 or not body.get("ok"):
|
||||||
raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
|
raise RuntimeError(f"Login failed for {base_url}: HTTP {status} {body}")
|
||||||
|
csrf_token = str(body.get("csrf_token") or "")
|
||||||
|
if csrf_token:
|
||||||
|
opener.addheaders.append(("X-CSRF-Token", csrf_token))
|
||||||
return opener
|
return opener
|
||||||
|
|
||||||
|
|
||||||
@@ -49,18 +54,74 @@ def digest(value: Any) -> str:
|
|||||||
return hashlib.sha256(content).hexdigest()
|
return hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def comparable(value: Any) -> Any:
|
def comparable(
|
||||||
|
value: Any,
|
||||||
|
excluded_paths: set[str] | None = None,
|
||||||
|
sorted_lists: dict[str, str] | None = None,
|
||||||
|
path: str = "$",
|
||||||
|
) -> Any:
|
||||||
|
excluded_paths = excluded_paths or set()
|
||||||
|
sorted_lists = sorted_lists or {}
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {
|
return {
|
||||||
key: comparable(item)
|
key: comparable(
|
||||||
|
item,
|
||||||
|
excluded_paths,
|
||||||
|
sorted_lists,
|
||||||
|
f"{path}.{key}",
|
||||||
|
)
|
||||||
for key, item in value.items()
|
for key, item in value.items()
|
||||||
if key != "request_id"
|
if key != "request_id"
|
||||||
|
and f"{path}.{key}" not in excluded_paths
|
||||||
}
|
}
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [comparable(item) for item in value]
|
normalized = [
|
||||||
|
comparable(item, excluded_paths, sorted_lists, f"{path}[]")
|
||||||
|
for item in value
|
||||||
|
]
|
||||||
|
sort_key = sorted_lists.get(path)
|
||||||
|
if sort_key:
|
||||||
|
normalized.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
str(item.get(sort_key) or "")
|
||||||
|
if isinstance(item, dict)
|
||||||
|
else json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def first_difference(original: Any, migrated: Any, path: str = "$") -> dict[str, Any] | None:
|
||||||
|
if type(original) is not type(migrated):
|
||||||
|
return {"path": path, "original": original, "migrated": migrated}
|
||||||
|
if isinstance(original, dict):
|
||||||
|
for key in sorted(set(original) | set(migrated)):
|
||||||
|
if key not in original or key not in migrated:
|
||||||
|
return {
|
||||||
|
"path": f"{path}.{key}",
|
||||||
|
"original": original.get(key, "<missing>"),
|
||||||
|
"migrated": migrated.get(key, "<missing>"),
|
||||||
|
}
|
||||||
|
difference = first_difference(original[key], migrated[key], f"{path}.{key}")
|
||||||
|
if difference:
|
||||||
|
return difference
|
||||||
|
return None
|
||||||
|
if isinstance(original, list):
|
||||||
|
if len(original) != len(migrated):
|
||||||
|
return {"path": f"{path}.length", "original": len(original), "migrated": len(migrated)}
|
||||||
|
for index, (original_item, migrated_item) in enumerate(zip(original, migrated)):
|
||||||
|
difference = first_difference(
|
||||||
|
original_item, migrated_item, f"{path}[{index}]"
|
||||||
|
)
|
||||||
|
if difference:
|
||||||
|
return difference
|
||||||
|
return None
|
||||||
|
if original != migrated:
|
||||||
|
return {"path": path, "original": original, "migrated": migrated}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
|
parser = argparse.ArgumentParser(description="Compare authenticated preservation APIs")
|
||||||
parser.add_argument("--original", required=True)
|
parser.add_argument("--original", required=True)
|
||||||
@@ -68,32 +129,60 @@ def main() -> None:
|
|||||||
parser.add_argument("--username", required=True)
|
parser.add_argument("--username", required=True)
|
||||||
parser.add_argument("--password", required=True)
|
parser.add_argument("--password", required=True)
|
||||||
parser.add_argument("--output", type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
parser.add_argument("endpoints", nargs="+")
|
parser.add_argument("--requests-file", type=Path)
|
||||||
|
parser.add_argument("endpoints", nargs="*")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
original = session(args.original, args.username, args.password)
|
original = session(args.original, args.username, args.password)
|
||||||
migrated = session(args.migrated, args.username, args.password)
|
migrated = session(args.migrated, args.username, args.password)
|
||||||
rows = []
|
rows = []
|
||||||
all_equal = True
|
all_equal = True
|
||||||
for endpoint in args.endpoints:
|
requests = [
|
||||||
|
{"name": endpoint, "method": "GET", "endpoint": endpoint, "payload": None}
|
||||||
|
for endpoint in args.endpoints
|
||||||
|
]
|
||||||
|
if args.requests_file:
|
||||||
|
requests.extend(json.loads(args.requests_file.read_text(encoding="utf-8")))
|
||||||
|
if not requests:
|
||||||
|
parser.error("provide at least one endpoint or --requests-file")
|
||||||
|
for item in requests:
|
||||||
|
endpoint = str(item["endpoint"])
|
||||||
|
method = str(item.get("method") or "GET").upper()
|
||||||
|
payload = item.get("payload")
|
||||||
|
excluded_paths = {str(path) for path in item.get("exclude_paths") or []}
|
||||||
|
sorted_lists = {
|
||||||
|
str(path): str(key)
|
||||||
|
for path, key in (item.get("sort_lists") or {}).items()
|
||||||
|
}
|
||||||
original_status, original_body = request_json(
|
original_status, original_body = request_json(
|
||||||
original, f"{args.original.rstrip('/')}{endpoint}"
|
original, f"{args.original.rstrip('/')}{endpoint}", payload, method
|
||||||
)
|
)
|
||||||
migrated_status, migrated_body = request_json(
|
migrated_status, migrated_body = request_json(
|
||||||
migrated, f"{args.migrated.rstrip('/')}{endpoint}"
|
migrated, f"{args.migrated.rstrip('/')}{endpoint}", payload, method
|
||||||
|
)
|
||||||
|
original_comparable = comparable(
|
||||||
|
original_body, excluded_paths, sorted_lists
|
||||||
|
)
|
||||||
|
migrated_comparable = comparable(
|
||||||
|
migrated_body, excluded_paths, sorted_lists
|
||||||
)
|
)
|
||||||
original_comparable = comparable(original_body)
|
|
||||||
migrated_comparable = comparable(migrated_body)
|
|
||||||
equal = original_status == migrated_status and original_comparable == migrated_comparable
|
equal = original_status == migrated_status and original_comparable == migrated_comparable
|
||||||
all_equal = all_equal and equal
|
all_equal = all_equal and equal
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
|
"name": str(item.get("name") or endpoint),
|
||||||
|
"method": method,
|
||||||
"endpoint": endpoint,
|
"endpoint": endpoint,
|
||||||
"original_status": original_status,
|
"original_status": original_status,
|
||||||
"migrated_status": migrated_status,
|
"migrated_status": migrated_status,
|
||||||
"original_sha256": digest(original_comparable),
|
"original_sha256": digest(original_comparable),
|
||||||
"migrated_sha256": digest(migrated_comparable),
|
"migrated_sha256": digest(migrated_comparable),
|
||||||
"equal": equal,
|
"equal": equal,
|
||||||
|
"first_difference": (
|
||||||
|
None
|
||||||
|
if equal
|
||||||
|
else first_difference(original_comparable, migrated_comparable)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ def main() -> None:
|
|||||||
from server import RequestHandler, SERVICE
|
from server import RequestHandler, SERVICE
|
||||||
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler)
|
server = ThreadingHTTPServer(("127.0.0.1", args.port), RequestHandler)
|
||||||
print(f"Preservation runtime is running at http://127.0.0.1:{args.port}", flush=True)
|
print(
|
||||||
|
f"Preservation runtime is running at http://127.0.0.1:{args.port} "
|
||||||
|
f"with database {SERVICE.database.path}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 切片 06:智能选股、自定义选股与策略持续跟踪
|
||||||
|
|
||||||
|
> 基线:`cf2aad2`(切片 05)
|
||||||
|
> 回档标签:`xiaobai-preservation-slice-06-20260731`
|
||||||
|
> 结论:源码、API、数据库、真实页面和全量回归通过;最终视觉仍等待全站人工验收
|
||||||
|
|
||||||
|
## 1. 原实现归位
|
||||||
|
|
||||||
|
本切片只机械移动原版选股实现,没有从`next/`取用代码,也没有改写策略、因子、权重、排序、
|
||||||
|
盘后候选或跟踪逻辑。
|
||||||
|
|
||||||
|
| 原位置 | 新的唯一实现位置 | 兼容方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/screener.py` | `app/backend/features/screener/engine.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `app/advanced_strategies.py` | `app/backend/features/screener/strategies.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `app/llm_strategy.py` | `app/backend/features/screener/compiler.py` | 根级模块指向同一模块对象 |
|
||||||
|
| `DashboardService`选股入口 | `app/backend/features/screener/service.py` | `ScreenerServiceMixin` |
|
||||||
|
| `ReviewDatabase`选股持久化方法 | `app/backend/features/screener/repository.py` | `ScreenerRepositoryMixin` |
|
||||||
|
| 原策略持续跟踪服务 | `app/backend/features/screener/tracking.py` | 容器直接导入唯一实现 |
|
||||||
|
|
||||||
|
四个同时服务公共行情与选股的方法归入`MarketRepositoryMixin`,没有复制第二套实现:
|
||||||
|
`upsert_stock_master`、`list_stock_master`、`upsert_daily_bars`、`daily_bars_for_date`。
|
||||||
|
|
||||||
|
## 2. 源码与接口等价
|
||||||
|
|
||||||
|
- `test_preservation_slice_screener.py`逐项验证选股引擎、29套策略定义、自然语言策略编译器、
|
||||||
|
13个服务方法、25个选股持久化方法和跟踪服务与原版等价。
|
||||||
|
- 三个根级兼容模块与正式模块暴露同一模块及类对象;原类不再重复保留已移动方法。
|
||||||
|
- 原版与迁移版的`/api/screener/setup`、`/api/screener/tracking`和`/api/screener/run`
|
||||||
|
状态码及业务JSON一致。
|
||||||
|
- 差分仅规范化策略启动刷新和顺序执行必然变化的数组顺序与`updated_at`;其他字段仍逐项比较。
|
||||||
|
- 请求与差分结果见`api-requests.json`和`api-diff.json`。
|
||||||
|
|
||||||
|
## 3. 数据库差分
|
||||||
|
|
||||||
|
- 两个临时副本均为62个schema对象,结构完全一致。
|
||||||
|
- 13张关键表逐行一致,覆盖约143万条日线、77万条技术指标和51万条竞价因子。
|
||||||
|
- `screener_runs`两边均为251行;会话和运行时间戳只在临时副本内规范化。
|
||||||
|
- 正式`data/review.db`和`app/data/review.db`未写入测试策略、测试会话或差分时间戳。
|
||||||
|
- 完整表计数与哈希见`database-diff.json`。
|
||||||
|
|
||||||
|
## 4. 真实浏览器检查
|
||||||
|
|
||||||
|
- 已登录迁移版真实服务,分别检查阶段选股、策略选股、自定义选股和策略持续跟踪。
|
||||||
|
- 阶段选股显示退潮、置信度92%、匹配策略和262日因子就绪状态;策略选股载入29套策略。
|
||||||
|
- 自定义选股保留因子权重、过滤条件、公式入口、手动执行和独立候选结果。
|
||||||
|
- 跟踪页只展示手动加入的候选,批次、标的、T+1/T+3进度、胜率和移除操作均正常。
|
||||||
|
- 各检查状态无横向溢出、无加载遮罩残留,浏览器控制台无错误。
|
||||||
|
- 截图SHA-256:
|
||||||
|
- `curated-screener.jpg`:`529ecd0947b34b42eb63d166c6804b0041e26fa362a171e4d0d7b584e073c05e`
|
||||||
|
- `custom-screener.jpg`:`7f31f50d44bc63a488277be0a74232e2ac256779d0accda42b96cc1eef88cc6e`
|
||||||
|
- `tracking.jpg`:`b19b1bc0cd4a98c29500917676bdcc4cc9a430a0c72dea8aff1110ad911db6e3`
|
||||||
|
|
||||||
|
## 5. 自动验证与保留边界
|
||||||
|
|
||||||
|
| 验证 | 结果 |
|
||||||
|
|---|---:|
|
||||||
|
| 原版`python -m unittest discover -s tests -q` | 231项通过 |
|
||||||
|
| 迁移版`python -m unittest discover -s tests -q` | 265项通过 |
|
||||||
|
| `python -m unittest tests.test_preservation_slice_screener -q` | 7项通过 |
|
||||||
|
| `npx.cmd playwright test --reporter=dot` | 45项通过 |
|
||||||
|
| `git diff --check` | 通过 |
|
||||||
|
|
||||||
|
- Windows下由Playwright自行创建静态服务器时存在子进程不退出的测试基线问题;复用独立的8876
|
||||||
|
测试服务器后45项用例在2.1分钟内通过并正常返回退出码0。
|
||||||
|
- 选股引擎保留原版数据客户端依赖,边界测试将其与普通页面Service区分;后续只可在不改变行为且有
|
||||||
|
独立差分证据时治理该依赖。
|
||||||
|
- 前端资产保持原位置,切片10再按页面职责归档;本切片没有改DOM、CSS、动画或交互。
|
||||||
|
- 没有删除待定代码、没有修改正式数据库、没有切换Docker/NAS。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"name": "盘后自动候选与策略库",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||||
|
"migrated_sha256": "e33a10e94accda4948c2af53887ea97736697f588ee1a92f561aa5c4bb28c2fd",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "策略持续跟踪",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/tracking?limit=12",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||||
|
"migrated_sha256": "f4476f53152b6a2ce01f2918da5999246cfd0ad7d0b71e80fdea8fa59c7456c2",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义选股执行",
|
||||||
|
"method": "POST",
|
||||||
|
"endpoint": "/api/screener/run",
|
||||||
|
"original_status": 200,
|
||||||
|
"migrated_status": 200,
|
||||||
|
"original_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b",
|
||||||
|
"migrated_sha256": "b3866e7d270aa222bd2bad0c0276b7e81ccd48e5f3a75941a32ef8e559a92c7b",
|
||||||
|
"equal": true,
|
||||||
|
"first_difference": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "盘后自动候选与策略库",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/setup?trade_date=2026-07-30",
|
||||||
|
"sort_lists": {
|
||||||
|
"$.strategies": "id"
|
||||||
|
},
|
||||||
|
"exclude_paths": [
|
||||||
|
"$.strategies[].updated_at"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "策略持续跟踪",
|
||||||
|
"method": "GET",
|
||||||
|
"endpoint": "/api/screener/tracking?limit=12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "自定义选股执行",
|
||||||
|
"method": "POST",
|
||||||
|
"endpoint": "/api/screener/run",
|
||||||
|
"exclude_paths": [
|
||||||
|
"$.result.meta.updated_at"
|
||||||
|
],
|
||||||
|
"payload": {
|
||||||
|
"trade_date": "2026-07-30",
|
||||||
|
"regime": "retreat",
|
||||||
|
"strategy_name": "保真迁移差分策略",
|
||||||
|
"mode": "quant",
|
||||||
|
"run_backtest": false,
|
||||||
|
"formula": {
|
||||||
|
"meta": {
|
||||||
|
"library": "quant",
|
||||||
|
"category": "量化公式"
|
||||||
|
},
|
||||||
|
"universe": {
|
||||||
|
"exclude_st": true,
|
||||||
|
"listed_days_min": 120
|
||||||
|
},
|
||||||
|
"filters": [
|
||||||
|
{
|
||||||
|
"field": "pct_chg",
|
||||||
|
"op": "between",
|
||||||
|
"value": [-2, 2]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"score": [
|
||||||
|
{
|
||||||
|
"field": "amount_billion",
|
||||||
|
"weight": 1,
|
||||||
|
"direction": "desc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"all_equal": true,
|
||||||
|
"schema": {
|
||||||
|
"object_count": 62,
|
||||||
|
"original_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"migrated_sha256": "60a4e044f0ddb6502e44b45f65bedc1a0a31d4bd596f386d4ccfe153a6c8ddd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"table": "stock_master",
|
||||||
|
"original_count": 5535,
|
||||||
|
"migrated_count": 5535,
|
||||||
|
"original_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"migrated_sha256": "8656e2d189d3520433fb3552e17998e4bf17bcf6838403cddc6719282b23e792",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "daily_bars",
|
||||||
|
"original_count": 1430501,
|
||||||
|
"migrated_count": 1430501,
|
||||||
|
"original_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6",
|
||||||
|
"migrated_sha256": "d72a06755d92d6be95ab4f4c98a4718e973bee59d4f381b01acb963b6d7ae9e6",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "benchmark_bars",
|
||||||
|
"original_count": 261,
|
||||||
|
"migrated_count": 261,
|
||||||
|
"original_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9",
|
||||||
|
"migrated_sha256": "691f7666dddcfdf7ebe8ba1d3e1ddd8f2d9670d1d7a06e740a929f3ef32ec1c9",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "daily_indicators",
|
||||||
|
"original_count": 770238,
|
||||||
|
"migrated_count": 770238,
|
||||||
|
"original_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9",
|
||||||
|
"migrated_sha256": "cd5a71c7d8de201ff2e3e58c64e2617a31d247911732fbddf81acb025d31fde9",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "fundamental_indicators",
|
||||||
|
"original_count": 49490,
|
||||||
|
"migrated_count": 49490,
|
||||||
|
"original_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d",
|
||||||
|
"migrated_sha256": "4b6859256063d3a0238ba3cbbbe2842f1278cf88d44b0f6114f1af978b8bfa7d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "moneyflow_daily",
|
||||||
|
"original_count": 36368,
|
||||||
|
"migrated_count": 36368,
|
||||||
|
"original_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d",
|
||||||
|
"migrated_sha256": "f7591dec150c7e0094568e3c46d77fc54e14a803223e15940dc54af1017bd83d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "earnings_events",
|
||||||
|
"original_count": 580,
|
||||||
|
"migrated_count": 580,
|
||||||
|
"original_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d",
|
||||||
|
"migrated_sha256": "ebac03fe9ef491a6d6d2b5bc8f5ee08b609fa0ebe5992087fa067216490a6b9d",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "auction_factors",
|
||||||
|
"original_count": 511914,
|
||||||
|
"migrated_count": 511914,
|
||||||
|
"original_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"migrated_sha256": "3d0470787adaf7c4cf5f15f5ad9fa1d67c8fcd4807285ac264eeacdcf5054cd1",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "popularity_factors",
|
||||||
|
"original_count": 232,
|
||||||
|
"migrated_count": 232,
|
||||||
|
"original_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"migrated_sha256": "3f4c61a13ceaa1ed9ecb28f86241a8a478a6757d6a44b46f432f73c0226bba7f",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "lhb_institution_daily",
|
||||||
|
"original_count": 47,
|
||||||
|
"migrated_count": 47,
|
||||||
|
"original_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"migrated_sha256": "1f847eac34d2ba576b429da208667f3b67b591d36ee66800803605bbc447970c",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "screener_strategies",
|
||||||
|
"original_count": 36,
|
||||||
|
"migrated_count": 36,
|
||||||
|
"original_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312",
|
||||||
|
"migrated_sha256": "e58859dbd478272d71bf85b6bc4ba45e609baccba74bb818b044d93bafa07312",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "screener_runs",
|
||||||
|
"original_count": 251,
|
||||||
|
"migrated_count": 251,
|
||||||
|
"original_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498",
|
||||||
|
"migrated_sha256": "117b0b95a362c91ddd70ca2def1e62e96e0ade2494ba4302bab5154c8d384498",
|
||||||
|
"equal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "strategy_tracks",
|
||||||
|
"original_count": 16,
|
||||||
|
"migrated_count": 16,
|
||||||
|
"original_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||||
|
"migrated_sha256": "f0639b1386b69c92fd815f929864b3b2457a6bf4ca515bee2dca87e9ea29e8c4",
|
||||||
|
"equal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"updated_at": "2026-07-31T02:42:00+08:00",
|
"updated_at": "2026-07-31T03:56:00+08:00",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"migration_mode": "behavior_preserving_source_migration",
|
"migration_mode": "behavior_preserving_source_migration",
|
||||||
"source_of_truth": "current_original_webapp_runtime_and_source",
|
"source_of_truth": "current_original_webapp_runtime_and_source",
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
"failed_roots": [
|
"failed_roots": [
|
||||||
"next"
|
"next"
|
||||||
],
|
],
|
||||||
"current_slice": "slice-06-screener-custom-tracking",
|
"current_slice": "slice-07-mentor-skills-llm-streaming",
|
||||||
"last_completed_slice": "slice-05-auction-themes-popularity-dragon-tiger",
|
"last_completed_slice": "slice-06-screener-custom-tracking",
|
||||||
"last_checkpoint": "xiaobai-preservation-slice-05-20260731",
|
"last_checkpoint": "xiaobai-preservation-slice-06-20260731",
|
||||||
"next_action": "capture_slice-06_screener_custom_selection_and_tracking_contracts_then_move_original_implementations",
|
"next_action": "capture_slice-07_mentor_skill_model_pool_and_streaming_contracts_then_move_original_implementations",
|
||||||
"authoritative_documents": [
|
"authoritative_documents": [
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"docs/migration/原版保真迁移总纲.md",
|
"docs/migration/原版保真迁移总纲.md",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 小白复盘保真迁移账本
|
# 小白复盘保真迁移账本
|
||||||
|
|
||||||
> 当前状态:正式迁移,切片05“集合竞价、题材库、人气热榜与龙虎榜”已完成
|
> 当前状态:正式迁移,切片06“智能选股、自定义选股与策略持续跟踪”已完成
|
||||||
|
|
||||||
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
|
本账本是上下文恢复和人工审计的连续记录。任何迁移提交必须在同一提交中更新本文件及
|
||||||
`保真迁移状态.json`。
|
`保真迁移状态.json`。
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
| 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 |
|
| 2026-07-31 | `xiaobai-preservation-slice-03-20260731` | 情绪周期、五类股池与涨停表现原实现归位 | 自动、API与浏览器差分通过,进入切片04 |
|
||||||
| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 |
|
| 2026-07-31 | `xiaobai-preservation-slice-04-20260731` | 市场天梯与板块轮动原实现归位 | 自动、API与浏览器差分通过,进入切片05 |
|
||||||
| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 |
|
| 2026-07-31 | `xiaobai-preservation-slice-05-20260731` | 集合竞价、题材库、人气热榜与龙虎榜原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片06 |
|
||||||
|
| 2026-07-31 | `xiaobai-preservation-slice-06-20260731` | 智能选股、自定义选股与策略持续跟踪原实现归位 | 自动、API、数据库与浏览器差分通过,进入切片07 |
|
||||||
|
|
||||||
## 资产处置登记
|
## 资产处置登记
|
||||||
|
|
||||||
@@ -48,6 +49,10 @@
|
|||||||
| `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 |
|
| `DashboardService`竞价、题材、人气与龙虎榜方法 | 业务服务 | 切片05四类页面与API | 按职责机械移动 | `app/backend/features/auction/`、`themes/`、`popularity/`、`dragon_tiger/` | 8个方法AST、7个真实API与原版一致 | 已移动 |
|
||||||
| `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 |
|
| `ReviewDatabase`竞价、人气与龙虎榜方法 | 持久化 | 市场洞察及后续智能选股 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/auction/repository.py`、`popularity/repository.py`、`dragon_tiger/repository.py` | 7个方法AST一致;62个schema对象及5张关键表逐行一致 | 已移动 |
|
||||||
| Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 |
|
| Tushare游资名录与龙虎榜实现 | 公共数据计算 | 龙虎榜与游资档案 | 原位置保持唯一实现 | `app/backend/data/providers/tushare_client.py` | 2个方法AST与原版一致 | 已归位 |
|
||||||
|
| 选股引擎、策略库与公式编译器 | 业务计算 | 阶段、策略、自定义选股 | 整体机械移动并保留兼容别名 | `app/backend/features/screener/engine.py`、`strategies.py`、`compiler.py` | 原版AST/源码等价;根级模块为同一模块对象 | 已移动 |
|
||||||
|
| `DashboardService`选股方法 | 业务服务 | 选股三个工作区 | 按职责机械移动 | `app/backend/features/screener/service.py` | 13个方法AST及3个真实API一致 | 已移动 |
|
||||||
|
| `ReviewDatabase`选股方法 | 持久化 | 因子、策略运行、候选与跟踪 | 按职责机械移动并保持Mixin原接口 | `app/backend/features/screener/repository.py` | 25个方法AST一致;62个schema对象及13张关键表逐行一致 | 已移动 |
|
||||||
|
| `StrategyTrackingService` | 业务服务 | 手动候选持续跟踪 | 保持唯一实现并调整容器导入 | `app/backend/features/screener/tracking.py` | 类定义AST、真实API与浏览器行为一致 | 已归位 |
|
||||||
|
|
||||||
处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。
|
处置只允许:`原样保留`、`移动`、`合并重复`、`待定`、`确认废弃`。
|
||||||
|
|
||||||
@@ -115,6 +120,16 @@
|
|||||||
- 回档:标签`xiaobai-preservation-slice-05-20260731`。
|
- 回档:标签`xiaobai-preservation-slice-05-20260731`。
|
||||||
- 完整证据:`docs/migration/evidence/slice-05/README.md`。
|
- 完整证据:`docs/migration/evidence/slice-05/README.md`。
|
||||||
|
|
||||||
|
已完成切片:`slice-06-screener-custom-tracking`。
|
||||||
|
|
||||||
|
- 原版基线:提交`cf2aad2`,即切片05回档点。
|
||||||
|
- 迁移范围:完整选股引擎、29套高级策略、策略编译器、13个页面服务方法、25个持久化方法及持续跟踪。
|
||||||
|
- 兼容边界:三个根级模块指向正式模块对象;选股引擎保留原版数据客户端依赖,不为通过边界测试改写算法。
|
||||||
|
- API与数据库:3个真实API业务JSON一致;62个schema对象和13张关键表逐行一致。
|
||||||
|
- 验收:原版231项、迁移版265项Python测试、7项切片源码等价测试、45项Playwright及四个真实工作区通过。
|
||||||
|
- 回档:标签`xiaobai-preservation-slice-06-20260731`。
|
||||||
|
- 完整证据:`docs/migration/evidence/slice-06/README.md`。
|
||||||
|
|
||||||
## 决策记录
|
## 决策记录
|
||||||
|
|
||||||
| 日期 | 决策 | 原因 |
|
| 日期 | 决策 | 原因 |
|
||||||
|
|||||||
Reference in New Issue
Block a user