migration: preserve screener and tracking slice

This commit is contained in:
leefer
2026-07-31 03:57:07 +08:00
parent cf2aad28ec
commit 4bab921d14
28 changed files with 4810 additions and 4152 deletions
+4 -483
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
+68
View File
@@ -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
View File
@@ -1,3 +1 @@
from .tracking import StrategyTrackingService """Stock screening, custom selection, and strategy tracking feature."""
__all__ = ["StrategyTrackingService"]
+146
View File
@@ -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且不超过1direction只能是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
+814
View File
@@ -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
}
+435
View File
@@ -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
+486
View File
@@ -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
View File
@@ -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
View File
@@ -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且不超过1direction只能是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
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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 = []
+10 -1
View File
@@ -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()
+99 -10
View File
@@ -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)
),
} }
) )
+5 -1
View File
@@ -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

+5 -5
View File
@@ -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",
+16 -1
View File
@@ -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`
## 决策记录 ## 决策记录
| 日期 | 决策 | 原因 | | 日期 | 决策 | 原因 |