From 1cc80583b3991fbf90e0f05a1e324f0dde02320a Mon Sep 17 00:00:00 2001 From: leefer Date: Tue, 28 Jul 2026 22:47:50 +0800 Subject: [PATCH] feat: expand screeners and stabilize interactive feedback --- advanced_strategies.py | 333 +++++++++++++++++ database.py | 183 +++++++++- market_insights.py | 28 +- screener.py | 619 ++++++++++++++++++++++++++++++-- sentiment_engine.py | 18 +- server.py | 381 ++++++++++++++++++-- static/app.js | 464 +++++++++++++----------- static/design-system.css | 5 - static/index.html | 124 ++++--- static/redesign-v2.css | 411 ++++++++++++++++++--- static/styles.css | 18 +- static/theme.css | 114 +++++- static/wentian-v2.css | 2 - strategy_tracking.py | 2 + tests/e2e/app-shell.spec.js | 130 +++---- tests/test_curated_screener.py | 251 ++++++++++++- tests/test_dashboard_cache.py | 5 + tests/test_frontend_contract.py | 43 ++- tests/test_market_insights.py | 14 + tests/test_sentiment_engine.py | 44 +++ tests/test_strategy_tracking.py | 23 ++ tushare_client.py | 4 + 22 files changed, 2707 insertions(+), 509 deletions(-) create mode 100644 advanced_strategies.py create mode 100644 tests/test_sentiment_engine.py diff --git a/advanced_strategies.py b/advanced_strategies.py new file mode 100644 index 0000000..752643e --- /dev/null +++ b/advanced_strategies.py @@ -0,0 +1,333 @@ +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": "选择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, + }, + }, + ] +) diff --git a/database.py b/database.py index 67e9b11..e45d0c0 100644 --- a/database.py +++ b/database.py @@ -208,6 +208,17 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date ON daily_bars(ts_code, trade_date DESC); + CREATE TABLE IF NOT EXISTS benchmark_bars ( + trade_date TEXT NOT NULL, + ts_code TEXT NOT NULL, + close REAL NOT NULL DEFAULT 0, + pct_chg REAL NOT NULL DEFAULT 0, + PRIMARY KEY (trade_date, ts_code) + ); + + CREATE INDEX IF NOT EXISTS idx_benchmark_bars_code_date + ON benchmark_bars(ts_code, trade_date DESC); + CREATE TABLE IF NOT EXISTS daily_indicators ( trade_date TEXT NOT NULL, ts_code TEXT NOT NULL, @@ -532,6 +543,7 @@ class ReviewDatabase: run_columns = { str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)") } + legacy_run_ownership = "user_id" not in run_columns if "user_id" not in run_columns: connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER") if "mode" not in run_columns: @@ -561,16 +573,28 @@ class ReviewDatabase: "UPDATE screener_runs SET mode = ? WHERE id = ?", (mode, int(run["id"])), ) + connection.execute( + "UPDATE screener_runs SET user_id = NULL WHERE user_id = 0" + ) if first_user and first_user["id"]: first_user_id = int(first_user["id"]) connection.execute( "UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL", (first_user_id,), ) - connection.execute( - "UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL", - (first_user_id,), - ) + if legacy_run_ownership: + connection.execute( + "UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL", + (first_user_id,), + ) + else: + connection.execute( + """ + UPDATE screener_runs SET user_id = ? + WHERE user_id IS NULL AND mode = 'quant' + """, + (first_user_id,), + ) connection.execute( """ CREATE INDEX IF NOT EXISTS idx_screener_strategies_user @@ -1276,6 +1300,26 @@ class ReviewDatabase: ) 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 = [ ( @@ -1468,6 +1512,10 @@ class ReviewDatabase: "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,), @@ -1504,15 +1552,33 @@ class ReviewDatabase: """, (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] 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), } def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]: @@ -1520,7 +1586,9 @@ class ReviewDatabase: if not dates: return { "dates": [], "bars": [], "master": [], "indicators": [], - "indicator_history": [], "fundamentals": [], "moneyflow": [], "auction": [], + "indicator_history": [], "indicator_series": [], "fundamentals": [], + "moneyflow": [], "moneyflow_history": [], "auction": [], + "benchmarks": [], } placeholders = ",".join("?" for _ in dates) with self.connect() as connection: @@ -1553,6 +1621,15 @@ class ReviewDatabase: """, (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 + 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 @@ -1576,6 +1653,16 @@ class ReviewDatabase: """, (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 @@ -1585,15 +1672,26 @@ class ReviewDatabase: """, (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() 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], "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], } def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]: @@ -1602,7 +1700,7 @@ class ReviewDatabase: except ModuleNotFoundError: from .sentiment_engine import build_sentiment_history - series = build_sentiment_history(self.list_snapshot_payloads(end_date, 240)) + series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260)) return [ { "trade_date": row["trade_date"], @@ -1618,7 +1716,7 @@ class ReviewDatabase: for row in series[-limit:] ] - def list_snapshot_payloads(self, end_date: str, limit: int = 240) -> list[dict[str, Any]]: + def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]: with self.connect() as connection: rows = connection.execute( """ @@ -1730,7 +1828,8 @@ class ReviewDatabase: (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (int(user_id), trade_date, regime, normalized_mode, strategy_name, + (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), ) @@ -1757,7 +1856,9 @@ class ReviewDatabase: def latest_screener_run( self, user_id: int, trade_date: str, mode: str = "", ) -> dict[str, Any] | None: - parameters: tuple[Any, ...] = (int(user_id), trade_date) + 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 = ?" @@ -1767,7 +1868,7 @@ class ReviewDatabase: f""" SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM screener_runs - WHERE user_id = ? AND trade_date <= ?{mode_clause} + WHERE {owner_clause} AND trade_date <= ?{mode_clause} ORDER BY id DESC LIMIT 1 """, parameters, @@ -1775,20 +1876,23 @@ class ReviewDatabase: 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 user_id = ? AND trade_date <= ? + WHERE {owner_clause} AND trade_date <= ? GROUP BY mode ) latest ON latest.id = runs.id """, - (int(user_id), trade_date), + parameters, ).fetchall() results: dict[str, dict[str, Any]] = {} for row in rows: @@ -1802,9 +1906,12 @@ class ReviewDatabase: 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 ( @@ -1815,7 +1922,7 @@ class ReviewDatabase: ORDER BY id DESC ) AS context_rank FROM screener_runs - WHERE user_id = ? AND trade_date <= ? + WHERE {owner_clause} AND trade_date <= ? ) SELECT id, trade_date, regime, mode, strategy_name, result, created_at FROM ranked @@ -1823,7 +1930,7 @@ class ReviewDatabase: ORDER BY id DESC LIMIT ? """, - (int(user_id), trade_date, safe_limit), + parameters, ).fetchall() return [ payload @@ -1831,14 +1938,52 @@ class ReviewDatabase: 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 user_id = ? + FROM screener_runs WHERE id = ? AND {owner_clause} """, - (int(run_id), int(user_id)), + parameters, ).fetchone() if not row: return None diff --git a/market_insights.py b/market_insights.py index aa6dc35..8d2483e 100644 --- a/market_insights.py +++ b/market_insights.py @@ -467,9 +467,23 @@ class MarketInsightsService: def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]: dates = self.database.auction_factor_dates(trade_date, 10) + stock_list_dates = { + str(item.get("ts_code") or ""): str(item.get("list_date") or "") + for item in self.database.list_stock_master() + if item.get("ts_code") + } history = [] for current_date in dates: - rows = self.database.auction_factors_for_date(current_date) + rows = [ + row for row in self.database.auction_factors_for_date(current_date) + if ( + str(row.get("ts_code") or "") in stock_list_dates + and ( + not stock_list_dates[str(row.get("ts_code") or "")] + or stock_list_dates[str(row.get("ts_code") or "")] < current_date + ) + ) + ] history.append( { "trade_date": _display_date(current_date), @@ -726,7 +740,7 @@ class MarketInsightsService: carried_forward = data_date != trade_date cache_key = data_date if not force and not dynamic: - cached = self.database.get_data_snapshot("auction_center_v5", cache_key) + cached = self.database.get_data_snapshot("auction_center_v6", cache_key) if cached: result = copy.deepcopy(cached) result["meta"] = { @@ -790,7 +804,13 @@ class MarketInsightsService: stock = master.get(ts_code) price = _number(row.get("price")) pre_close = _number(row.get("pre_close")) - if not stock or price <= 0 or pre_close <= 0: + list_date = str((stock or {}).get("list_date") or "") + if ( + not stock + or price <= 0 + or pre_close <= 0 + or (list_date and list_date >= data_date) + ): continue change = (price / pre_close - 1) * 100 amount_million = _number(row.get("amount")) / 1_000_000 @@ -908,7 +928,7 @@ class MarketInsightsService: "rows": candidates, } if not dynamic: - self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result) + self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result) return self._with_auction_watchlist(result, data_date, user_id) def _theme_directory(self) -> list[dict[str, Any]]: diff --git a/screener.py b/screener.py index e62616f..dbdd71b 100644 --- a/screener.py +++ b/screener.py @@ -8,6 +8,7 @@ from collections import defaultdict from datetime import datetime, timedelta from typing import Any +from advanced_strategies import ADVANCED_CURATED_STRATEGIES from database import ReviewDatabase from sentiment_engine import build_sentiment_history, latest_contiguous_history from tushare_client import TushareClient, TushareError @@ -23,18 +24,45 @@ REGIMES = { } FACTOR_FIELDS = { + "close": "收盘价", "pct_chg": "当日涨幅", "return_5d": "5日涨幅", "return_10d": "10日涨幅", + "return_20d": "20日涨幅", + "return_60d": "60日涨幅", + "return_5d_rank": "5日涨幅排名", + "momentum_60_5": "中期动量", + "momentum_60_5_rank": "中期动量排名", "above_ma20": "站上20日线", + "rsi_6": "RSI(6)", + "ma60_slope": "60日线斜率", + "ma20_slope_5d": "20日线5日斜率", + "ma_bull_alignment": "均线多头排列", + "drawdown_from_high_250": "距250日高点回撤", + "donchian_breakout_pct": "唐奇安突破幅度", + "range_20d": "20日振幅", + "rs_high_120": "RS线120日新高", + "excess_return_60d": "60日超额收益", + "weekly_trend_signal": "周线趋势信号", + "daily_buy_trigger": "日线买点", + "weekly_amount_trend": "周成交趋势", "volume_ratio_5d": "5日量比", + "turnover_5d": "5日累计换手", "volatility_10d": "10日波动率", "amount_billion": "成交额", "turnover_rate": "换手率", "circ_mv_billion": "流通市值", "net_flow_million": "主力净流入", "large_flow_million": "大单净流入", + "net_flow_5d_million": "5日主力净流入", + "flow_to_circ_mv_5d": "5日净流入占流通市值", "sector_strength": "板块强度", + "sector_return_5d": "行业5日涨幅", + "sector_return_20d": "行业20日涨幅", + "sector_momentum_rank": "行业20日动量排名", + "sector_stock_momentum_rank": "行业内个股动量排名", + "sector_net_flow_5d_million": "行业5日主力净流入", + "sector_flow_rank": "行业资金流排名", "sector_limit_count": "板块涨停数", "sector_up_count": "板块强势股数", "relative_strength": "相对强度", @@ -66,22 +94,52 @@ FACTOR_FIELDS = { "previous_limit_signal": "昨日涨停或触板", "previous_limit_streak": "昨日连板高度", "previous_amount_billion": "昨日成交额", + "is_limit_up_today": "当日涨停", + "is_limit_down_today": "当日跌停", "sector_breadth_ma20": "行业20日线宽度", + "no_limit_down_20d": "近20日无跌停", + "financial_risk": "财务风险标记", + "is_market_height": "当前市场最高板", + "new_space_board": "新晋空间板", + "max_continuous_board_10d": "近10日最高连板", + "dragon_first_yin": "龙头首阴", + "yin_day_pct": "首阴跌幅", + "vol_vs_previous": "较前日量能", + "broken_reversal": "断板反包", + "days_since_broken": "断板后天数", + "close_above_broken_high": "收复断板高点", + "vol_vs_broken_day": "较断板日量能", + "recent_limit_up_5d": "近5日涨停次数", + "intraday_min_pct": "盘中最大跌幅", + "lower_shadow_ratio": "下影线实体比", } FACTOR_GROUPS = { "行情动量": [ - "pct_chg", "return_5d", "return_10d", "above_ma20", "relative_strength", + "close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d", + "return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20", + "rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment", + "drawdown_from_high_250", "donchian_breakout_pct", "range_20d", + "rs_high_120", "excess_return_60d", "weekly_trend_signal", + "daily_buy_trigger", "weekly_amount_trend", "relative_strength", "relative_position_60", "close_to_high_15d", "close_to_high_60d", ], "量价交易": [ - "volume_ratio_5d", "volatility_10d", "amount_billion", "turnover_rate", - "net_flow_million", "large_flow_million", "previous_amount_billion", + "volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate", + "net_flow_million", "large_flow_million", "net_flow_5d_million", + "flow_to_circ_mv_5d", "previous_amount_billion", + "intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day", ], "板块结构": [ - "sector_strength", "sector_limit_count", "sector_up_count", "sector_breadth_ma20", + "sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank", + "sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank", + "sector_limit_count", "sector_up_count", "sector_breadth_ma20", "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal", - "no_limit_30d", "had_limit_80d", "max_abs_change_15d", + "is_limit_up_today", "is_limit_down_today", + "no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d", + "is_market_height", "new_space_board", "max_continuous_board_10d", + "dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken", + "close_above_broken_high", "recent_limit_up_5d", ], "竞价因子": [ "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", @@ -92,7 +150,7 @@ FACTOR_GROUPS = { ], "财务质量": [ "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", - "ocf_to_opincome", + "ocf_to_opincome", "financial_risk", ], } @@ -496,6 +554,112 @@ CURATED_STRATEGIES = [ }, ] +CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES) + +STRATEGY_ENVIRONMENT_NOTES = { + "连续分红质量": ( + "防守市、低利率环境与中长期配置窗口", + "风险偏好快速上升时,稳健资产的价格弹性通常落后", + ), + "ROIC质量低波": ( + "震荡偏弱、重视盈利质量与回撤控制的市场", + "主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向", + ), + "低估值现金流白马": ( + "估值修复、价值回归及防守配置阶段", + "低估值可能来自基本面持续走弱,需警惕价值陷阱", + ), + "高增长合理估值": ( + "业绩驱动、成长风格占优且趋势获得确认的阶段", + "增长预期下修或估值快速收缩时,回撤可能明显放大", + ), + "行业宽度主线": ( + "主线清晰、行业内部多数个股同步走强的行情", + "板块快速轮动时,宽度信号容易在确认后迅速衰减", + ), + "首板低开": ( + "情绪修复期的分歧转一致与首板次日承接", + "退潮加速或低开缺少量能承接时,弱势可能继续扩大", + ), + "小碎步临界突破": ( + "趋势蓄势、波动收敛后临近突破的结构市", + "无量突破或指数剧烈震荡时,容易形成冲高回落", + ), + "连板龙头": ( + "高度拓展、题材梯队完整且接力情绪活跃的阶段", + "亏钱效应扩散或高位股集中退潮时,接力风险很高", + ), + "微盘三正": ( + "小盘风格活跃、流动性宽松且风险偏好较高的行情", + "风格切向大盘或微盘流动性收缩时,组合波动会显著上升", + ), + "首板高开弱转强": ( + "竞价承接明确、短线情绪修复或主线发酵阶段", + "高开缺乏板块共振时,竞价强势可能转为盘中兑现", + ), + "中期动量·强者恒强": ( + "趋势延续、主升段及强弱分化清晰的行情", + "无趋势震荡或快速轮动中,动量信号容易反复失效", + ), + "强者回调": ( + "主升趋势未破、强势股完成良性回踩的窗口", + "趋势已反转时,回调信号可能演变为下跌中继", + ), + "超跌反转": ( + "急跌后恐慌释放充分、市场进入修复预期的阶段", + "单边下跌初段容易过早介入,超跌不等于止跌", + ), + "相对强度新高": ( + "指数偏弱但结构性主线明确,或机构抱团强化的行情", + "基准快速补涨或强势方向瓦解时,相对优势可能迅速消失", + ), + "均线多头排列": ( + "中期趋势向上、回撤有序的趋势市与主升段", + "高位趋势末端或宽幅震荡中,均线信号通常反应滞后", + ), + "唐奇安通道突破": ( + "整理末端、放量突破并启动新趋势的行情", + "无量突破和宽幅震荡环境中,假突破出现概率较高", + ), + "周线趋势·日线买点": ( + "中期趋势稳定、日线回踩或再启动的多周期共振阶段", + "周线拐点尚未确认时,日线信号可能只是短暂反抽", + ), + "空间板": ( + "市场高度持续拓展、板块梯队完整的强接力环境", + "高度压缩或亏钱效应扩散时,最高板的补跌风险极高", + ), + "龙头首阴": ( + "主线龙头仍有辨识度、首次分歧后存在回流预期的阶段", + "题材退潮或龙头地位被替代后,首阴可能只是下跌起点", + ), + "断板反包": ( + "强势题材分歧后快速修复、核心股重新获得资金承接时", + "板块强度不足或反包缩量时,形态持续性通常较弱", + ), + "核按钮反核": ( + "恐慌释放后出现明确承接、短线情绪转暖的窗口", + "系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高", + ), + "行业动量轮动": ( + "主线相对清晰、行业趋势能够延续两周以上的结构市", + "行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减", + ), + "主力资金行业流入": ( + "板块轮动初期、资金先于价格形成连续净流入的阶段", + "资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势", + ), +} + +for strategy in CURATED_STRATEGIES: + suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]] + strategy["formula"]["meta"].update( + { + "suitable_environment": suitable_environment, + "failure_risk": failure_risk, + } + ) + BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES) @@ -521,6 +685,7 @@ class FactorDataService: self.client = client def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]: + lookback = max(25, min(260, int(lookback))) trade_date, _ = self.client.resolve_trade_context(requested_date) end = datetime.strptime(trade_date, "%Y%m%d") start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d") @@ -532,9 +697,11 @@ class FactorDataService: dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:] existing = set(self.database.factor_dates(trade_date, lookback + 10)) dates_to_fetch = [value for value in dates if value not in existing or value == trade_date] - existing_auction = set(self.database.auction_factor_dates(trade_date, lookback + 10)) + auction_source_dates = dates[-min(80, len(dates)):] + existing_auction = set(self.database.auction_factor_dates(trade_date, 90)) auction_dates_to_fetch = [ - value for value in dates if value not in existing_auction or value == trade_date + value for value in auction_source_dates + if value not in existing_auction or value == trade_date ] long_calendar = self.client.query( "trade_cal", @@ -551,7 +718,7 @@ class FactorDataService: if row.get("is_open") == 1 and row.get("cal_date"): value = str(row["cal_date"]) last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value) - valuation_dates = set(dates) + valuation_dates = set(dates[-min(80, len(dates)):]) valuation_dates.update(last_open_by_year.values()) existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500)) indicator_dates_to_fetch = sorted( @@ -584,6 +751,16 @@ class FactorDataService: indicator_count += self.database.upsert_daily_indicators(indicators) notices = [] + benchmark_count = 0 + try: + benchmark_rows = self.client.query( + "index_daily", + {"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date}, + "ts_code,trade_date,close,pct_chg", + ) + benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows) + except TushareError as exc: + notices.append(f"沪深300基准暂不可用:{exc}") fundamental_count = 0 existing_periods = set(self.database.fundamental_periods()) for period in _quarter_periods(trade_date, 9): @@ -620,17 +797,22 @@ class FactorDataService: except TushareError as exc: notices.append(f"竞价因子接口不可用:{exc}") break - try: - moneyflow = self.client.query( - "moneyflow", - {"trade_date": trade_date}, - "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," - "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", - ) - moneyflow_count = self.database.upsert_moneyflow(moneyflow) - except TushareError as exc: - moneyflow_count = 0 - notices.append(f"资金流接口不可用:{exc}") + moneyflow_count = 0 + moneyflow_dates = 0 + for current_date in dates[-min(5, len(dates)):]: + try: + moneyflow = self.client.query( + "moneyflow", + {"trade_date": current_date}, + "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," + "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", + ) + moneyflow_count += self.database.upsert_moneyflow(moneyflow) + if moneyflow: + moneyflow_dates += 1 + except TushareError as exc: + notices.append(f"资金流接口不可用:{exc}") + break return { "trade_date": trade_date, @@ -638,10 +820,12 @@ class FactorDataService: "fetched_dates": len(dates_to_fetch), "stocks": master_count, "bars": bar_count, + "benchmark_bars": benchmark_count, "indicators": indicator_count, "indicator_dates": len(indicator_dates_to_fetch), "fundamentals": fundamental_count, "moneyflow": moneyflow_count, + "moneyflow_dates": moneyflow_dates, "auction_rows": auction_count, "auction_dates": auction_dates, "notice": ";".join(notices), @@ -651,6 +835,7 @@ class FactorDataService: class ScreenerEngine: def __init__(self, database: ReviewDatabase) -> None: self.database = database + self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {} def ensure_builtin_strategies(self) -> None: existing = { @@ -667,7 +852,7 @@ class ScreenerEngine: def detect_regime(self, trade_date: str) -> dict[str, Any]: series = latest_contiguous_history( - build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 240)) + build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260)) ) if not series: return { @@ -747,12 +932,32 @@ class ScreenerEngine: strategy_name: str, run_backtest: bool = True, realtime_snapshot: dict[str, Any] | None = None, mode: str = "smart", + prepared_factors: list[dict[str, Any]] | None = None, + prepared_date: str = "", ) -> dict[str, Any]: mode = mode if mode in {"smart", "curated", "quant"} else "smart" formula = self.validate_formula(formula) - factors, actual_date = self.build_factors(trade_date, realtime_snapshot) + if prepared_factors is None: + history_days = int((formula.get("meta") or {}).get("history_days") or 80) + factors, actual_date = self.build_factors( + trade_date, realtime_snapshot, history_days + ) + else: + factors = prepared_factors + actual_date = prepared_date or trade_date candidates = self.apply_formula(factors, formula, regime) backtest = self.backtest(actual_date, formula) if run_backtest else None + required_fields = sorted({ + str(item.get("field") or "") + for item in list(formula.get("filters") or []) + list(formula.get("score") or []) + if item.get("field") + }) + complete_rows = sum( + 1 for row in factors + if all(row.get(field) is not None for field in required_fields) + ) + coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0 + health_status = "normal" if candidates else "no_signal" if backtest and backtest["samples"] >= 20: for candidate in candidates: estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35 @@ -769,9 +974,20 @@ class ScreenerEngine: "regime_label": REGIMES.get(regime, regime), "strategy_name": strategy_name, "mode": mode, + "library_version": int( + (formula.get("meta") or {}).get("library_version") or 0 + ), "universe_count": len(factors), "candidate_count": len(candidates), "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "health": { + "status": health_status, + "required_field_count": len(required_fields), + "complete_rows": complete_rows, + "universe_rows": len(factors), + "coverage": coverage, + "signal_count": len(candidates), + }, "selection_source": ( "tushare_rt_k+history" if realtime_snapshot else "historical_eod" ), @@ -791,7 +1007,11 @@ class ScreenerEngine: "formula": formula, "candidates": candidates, "backtest": backtest, - "disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。", + "disclaimer": ( + "候选仅由策略条件与当日数据计算;历史统计不代表未来收益。" + if mode == "curated" + else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。" + ), } run_id = self.database.save_screener_run( user_id, actual_date, regime, strategy_name, formula, result, mode @@ -803,8 +1023,10 @@ class ScreenerEngine: self, trade_date: str, realtime_snapshot: dict[str, Any] | None = None, + history_days: int = 80, ) -> tuple[list[dict[str, Any]], str]: - data = self.database.load_factor_data(trade_date, 80) + history_days = max(21, min(260, int(history_days))) + data = self.database.load_factor_data(trade_date, history_days) dates = [value for value in data["dates"] if value <= trade_date] if len(dates) < 21: raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。") @@ -822,7 +1044,18 @@ class ScreenerEngine: indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in data.get("indicator_history", []): indicator_history[str(row.get("ts_code") or "")].append(row) + indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data.get("indicator_series", []): + indicator_series[str(row.get("ts_code") or "")].append(row) + benchmark_by_date = { + str(row.get("trade_date") or ""): _number(row.get("close")) + for row in data.get("benchmarks", []) + if _number(row.get("close")) > 0 + } moneyflow = {row["ts_code"]: row for row in data["moneyflow"]} + moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in data.get("moneyflow_history", []): + moneyflow_history[str(row.get("ts_code") or "")].append(row) auction = { row["ts_code"]: row for row in data.get("auction", []) @@ -863,6 +1096,7 @@ class ScreenerEngine: indicator = indicators.get(ts_code, {}) fundamental = fundamentals.get(ts_code, {}) flow = moneyflow.get(ts_code, {}) + flow_history = moneyflow_history.get(ts_code, []) auction_row = auction.get(ts_code, {}) list_date = str(info.get("list_date") or "") try: @@ -906,6 +1140,72 @@ class ScreenerEngine: dividend_years = sum( 1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0) ) + current_streak = _ending_streak(limit_flags) + prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2) + streak = max(streak, current_streak) + return_60d = ( + (closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 + ) + momentum_60_5 = ( + (closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0 + ) + ma20 = statistics.fmean(closes[-20:]) + ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20 + prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20 + prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60 + ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0 + ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0 + ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)] + high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high) + drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100 + prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0 + breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0 + prior_lows_20 = shape_low[-21:-1] + range_20d = ( + (prior_high_20 / min(prior_lows_20) - 1) * 100 + if prior_lows_20 and min(prior_lows_20) > 0 else 100 + ) + turnover_rows = sorted( + indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "") + ) + turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]] + if realtime and _number(realtime.get("turnover_rate")): + turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))] + turnover_5d = sum(turnover_values) + rs_values = [ + _number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))] + for item in shape_rows[-120:] + if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0 + ] + benchmark_60 = [ + benchmark_by_date.get(str(item.get("trade_date"))) + for item in shape_rows[-61:] + if benchmark_by_date.get(str(item.get("trade_date"))) + ] + benchmark_return_60 = ( + (benchmark_60[-1] / benchmark_60[0] - 1) * 100 + if len(benchmark_60) >= 61 and benchmark_60[0] else 0 + ) + weekly_closes, weekly_amounts = _weekly_series(shape_rows) + weekly_dif, weekly_dea = _macd_last(weekly_closes) + daily_dif, daily_dea = _macd_series(closes) + daily_cross = ( + len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] + and daily_dif[-2] <= daily_dea[-2] + ) + current_open = _number(current.get("open")) + daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open + previous_close = closes[-2] if len(closes) >= 2 else closes[-1] + intraday_min = ( + (_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0 + ) + body = abs(closes[-1] - current_open) + lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low"))) + lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0) + previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0 + vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0 + broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name) + netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy")) factors.append( { "code": code, @@ -914,12 +1214,32 @@ class ScreenerEngine: "sector": info.get("industry") or "其他", "market": info.get("market") or "--", "listed_days": listed_days, + "close": round(closes[-1], 2), "price": round(closes[-1], 2), "pct_chg": round(_number(current["pct_chg"]), 2), "return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2), "return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2), - "above_ma20": int(closes[-1] > statistics.fmean(closes[-20:])), + "return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2), + "return_60d": round(return_60d, 2), + "momentum_60_5": round(momentum_60_5, 2), + "above_ma20": int(closes[-1] > ma20), + "rsi_6": round(_rsi(closes, 6), 2), + "ma60_slope": round(ma60_slope, 3), + "ma20_slope_5d": round(ma20_slope, 3), + "ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]), + "drawdown_from_high_250": round(drawdown_250, 2), + "donchian_breakout_pct": round(breakout_pct, 2), + "range_20d": round(range_20d, 2), + "rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)), + "excess_return_60d": round(return_60d - benchmark_return_60, 2), + "weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0), + "daily_buy_trigger": int(daily_cross or daily_pullback), + "weekly_amount_trend": int( + len(weekly_amounts) >= 5 + and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1]) + ), "volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0, + "turnover_5d": round(turnover_5d, 2), "volatility_10d": round(statistics.pstdev(returns_10), 2), "amount_billion": round( _number(current["amount"]) / (100000000 if realtime else 100000), 2 @@ -945,8 +1265,19 @@ class ScreenerEngine: "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2), "net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2), "large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2), + "net_flow_5d_million": round( + sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100, + 2, + ), + "flow_to_circ_mv_5d": round( + sum(_number(item.get("net_mf_amount")) for item in flow_history) + / _number(indicator.get("circ_mv")) * 100, + 4, + ) if _number(indicator.get("circ_mv")) else 0, "limit_status": status, "limit_streak": streak, + "is_limit_up_today": int(limit_flags[-1]), + "is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)), "auction_change": round(_number(auction_row.get("change")), 2), "auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2), "auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4), @@ -957,6 +1288,28 @@ class ScreenerEngine: "close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0, "no_limit_30d": int(not any(limit_flags[-30:])), "had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])), + "no_limit_down_20d": int(not any( + _number(item.get("pct_chg")) <= -_limit_threshold(code, name) + for item in shape_rows[-20:] + )), + "financial_risk": int( + "ST" in name.upper() or "退" in name + or (netprofit_yoy is not None and netprofit_yoy <= -100) + ), + "prior_limit_streak": prior_streak, + "max_continuous_board_10d": _max_streak(limit_flags[-10:]), + "dragon_first_yin": int( + prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open + ), + "yin_day_pct": round(_number(current.get("pct_chg")), 2), + "vol_vs_previous": round(vol_vs_previous, 3), + "broken_reversal": broken["signal"], + "days_since_broken": broken["days"], + "close_above_broken_high": broken["recovered"], + "vol_vs_broken_day": broken["volume_ratio"], + "recent_limit_up_5d": sum(limit_flags[-5:]), + "intraday_min_pct": round(intraday_min, 2), + "lower_shadow_ratio": round(lower_shadow_ratio, 2), "previous_first_limit": int(previous_limit and not recent_prior_signal), "previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal), "previous_limit_streak": previous_streak, @@ -968,18 +1321,65 @@ class ScreenerEngine: sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in factors: sectors[row["sector"]].append(row) - for sector_rows in sectors.values(): + sector_metrics = [] + for sector_name, sector_rows in sectors.items(): average_return = statistics.fmean(row["return_5d"] for row in sector_rows) + average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows) + sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows) limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows) up_count = sum(row["pct_chg"] >= 5 for row in sector_rows) breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100 strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6)) + sector_metrics.append( + { + "ts_code": sector_name, + "sector_return_20d": average_return_20d, + "sector_net_flow_5d_million": sector_net_flow, + } + ) + stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc") for row in sector_rows: row["sector_strength"] = round(strength, 1) + row["sector_return_5d"] = round(average_return, 2) + row["sector_return_20d"] = round(average_return_20d, 2) + row["sector_net_flow_5d_million"] = round(sector_net_flow, 2) + row["sector_stock_momentum_rank"] = round( + stock_momentum_ranks.get(row["ts_code"], 0.0), 4 + ) row["sector_limit_count"] = limit_count row["sector_up_count"] = up_count row["sector_breadth_ma20"] = round(breadth_ma20, 1) row["relative_strength"] = round(row["return_5d"] - market_return, 2) + sector_momentum_ranks = _percentile_map( + sector_metrics, "sector_return_20d", "desc" + ) + sector_flow_ranks = _percentile_map( + sector_metrics, "sector_net_flow_5d_million", "desc" + ) + for sector_name, sector_rows in sectors.items(): + for row in sector_rows: + row["sector_momentum_rank"] = round( + sector_momentum_ranks.get(sector_name, 0.0), 4 + ) + row["sector_flow_rank"] = round( + sector_flow_ranks.get(sector_name, 0.0), 4 + ) + momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc") + return_ranks = _percentile_map(factors, "return_5d", "desc") + market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0) + prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0) + for row in factors: + row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4) + row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4) + is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height + row["is_market_height"] = int(is_height) + row["new_space_board"] = int( + is_height + and not ( + prior_market_height >= 2 + and int(row.get("prior_limit_streak") or 0) == prior_market_height + ) + ) return factors, actual_date def apply_formula( @@ -1030,20 +1430,50 @@ class ScreenerEngine: item["score_display"] = round(score * 100, 1) item["contributions"] = contributions item["reason"] = "、".join(entry["label"] for entry in contributions[:3]) - item["risk_flags"] = _risk_flags(row, regime) + include_regime_risk = formula.get("meta", {}).get("library") != "curated" + item["risk_flags"] = _risk_flags(row, regime, include_regime_risk) results.append(item) results.sort(key=lambda item: item["score"], reverse=True) return results[: formula["limit"]] def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]: - dates = self.database.factor_dates(trade_date, 55) - evaluation_dates = dates[20:-3][-8:] + meta = formula.get("meta") or {} + history_days = max(21, min(260, int(meta.get("history_days") or 80))) + holding_days = max(1, min(30, int(meta.get("backtest_days") or 3))) + take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3))) + stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3))) + dates = self.database.factor_dates(trade_date, history_days + holding_days + 20) + eligible_dates = dates[:-holding_days] if len(dates) > holding_days else [] + frequency = str(meta.get("frequency") or "每日") + if "月" in frequency: + grouped = {} + for value in eligible_dates: + grouped[value[:6]] = value + evaluation_dates = list(grouped.values())[-8:] + elif "双周" in frequency: + weekly_dates = [] + grouped = {} + for value in eligible_dates: + parsed = datetime.strptime(value, "%Y%m%d") + grouped[parsed.strftime("%G-%V")] = value + weekly_dates = list(grouped.values()) + evaluation_dates = weekly_dates[-16::2][-8:] + elif "周" in frequency: + grouped = {} + for value in eligible_dates: + parsed = datetime.strptime(value, "%Y%m%d") + grouped[parsed.strftime("%G-%V")] = value + evaluation_dates = list(grouped.values())[-8:] + else: + evaluation_dates = eligible_dates[-8:] wins = 0 losses = 0 samples = 0 returns = [] drawdowns = [] - all_data = self.database.load_factor_data(trade_date, 60) + all_data = self.database.load_factor_data( + trade_date, history_days + holding_days + 20 + ) bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in all_data["bars"]: bars_by_code[row["ts_code"]].append(row) @@ -1052,15 +1482,25 @@ class ScreenerEngine: for current_date in evaluation_dates: try: - factors, _ = self.build_factors(current_date) + cache_key = (current_date, history_days) + factors = self._backtest_factor_cache.get(cache_key) + if factors is None: + factors, _ = self.build_factors( + current_date, history_days=history_days + ) + if len(self._backtest_factor_cache) >= 64: + self._backtest_factor_cache.pop( + next(iter(self._backtest_factor_cache)) + ) + self._backtest_factor_cache[cache_key] = factors except ValueError: continue selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest") for candidate in selected: bars = bars_by_code.get(candidate["ts_code"], []) index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1) - future = bars[index + 1:index + 4] if index >= 0 else [] - if len(future) < 3: + future = bars[index + 1:index + 1 + holding_days] if index >= 0 else [] + if len(future) < holding_days: continue entry = candidate["price"] won = False @@ -1068,10 +1508,10 @@ class ScreenerEngine: for day in future: low_return = (_number(day["low"]) / entry - 1) * 100 high_return = (_number(day["high"]) / entry - 1) * 100 - if low_return <= -3: + if low_return <= stop_loss: lost = True break - if high_return >= 3: + if high_return >= take_profit: won = True break if won: @@ -1087,9 +1527,17 @@ class ScreenerEngine: "losses": losses, "win_rate": round(wins / samples * 100, 1) if samples else 0, "average_3d_return": round(statistics.fmean(returns), 2) if returns else 0, + "average_holding_return": round(statistics.fmean(returns), 2) if returns else 0, "average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0, "evaluation_days": len(evaluation_dates), - "definition": "收盘后选股,未来3日先触及+3%且未先触及-3%计为成功;同日双触发按失败处理。", + "frequency": frequency, + "holding_days": holding_days, + "take_profit": take_profit, + "stop_loss": stop_loss, + "definition": ( + f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及" + f"{stop_loss:g}%计为成功;同日双触发按失败处理。" + ), "approximate": True, } @@ -1160,6 +1608,99 @@ def _limit_threshold(code: str, name: str) -> float: return 9.5 +def _ending_streak(flags: list[bool], end_index: int | None = None) -> int: + if not flags: + return 0 + index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1) + streak = 0 + while index >= 0 and flags[index]: + streak += 1 + index -= 1 + return streak + + +def _max_streak(flags: list[bool]) -> int: + best = current = 0 + for value in flags: + current = current + 1 if value else 0 + best = max(best, current) + return best + + +def _rsi(values: list[float], period: int = 6) -> float: + if len(values) <= period: + return 50.0 + changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))] + gains = sum(max(change, 0.0) for change in changes) / period + losses = sum(max(-change, 0.0) for change in changes) / period + if losses == 0: + return 100.0 if gains > 0 else 50.0 + return 100 - 100 / (1 + gains / losses) + + +def _ema(values: list[float], period: int) -> list[float]: + if not values: + return [] + alpha = 2 / (period + 1) + result = [values[0]] + for value in values[1:]: + result.append(value * alpha + result[-1] * (1 - alpha)) + return result + + +def _macd_series(values: list[float]) -> tuple[list[float], list[float]]: + fast = _ema(values, 12) + slow = _ema(values, 26) + dif = [left - right for left, right in zip(fast, slow)] + return dif, _ema(dif, 9) + + +def _macd_last(values: list[float]) -> tuple[float, float]: + dif, dea = _macd_series(values) + return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0) + + +def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]: + weeks: dict[str, tuple[float, float]] = {} + for row in rows: + trade_date = str(row.get("trade_date") or "") + try: + key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V") + except ValueError: + continue + close = _number(row.get("close")) + amount = _number(row.get("amount")) + previous = weeks.get(key, (close, 0.0)) + weeks[key] = (close, previous[1] + amount) + ordered = list(weeks.values()) + return [item[0] for item in ordered], [item[1] for item in ordered] + + +def _broken_reversal_metrics( + rows: list[dict[str, Any]], flags: list[bool], code: str, name: str, +) -> dict[str, Any]: + result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0} + if not rows or not flags[-1]: + return result + current_close = _number(rows[-1].get("close")) + current_volume = _number(rows[-1].get("vol")) + for days in range(1, 4): + index = len(rows) - 1 - days + if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2: + continue + broken_high = _number(rows[index].get("high")) + broken_volume = _number(rows[index].get("vol")) + recovered = int(current_close >= broken_high > 0) + volume_ratio = current_volume / broken_volume if broken_volume else 0.0 + return { + "signal": int(recovered and volume_ratio >= 1), + "days": days, + "recovered": recovered, + "volume_ratio": round(volume_ratio, 3), + } + return result + + def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool: if index < 0 or index >= len(rows): return False @@ -1212,7 +1753,9 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d return result -def _risk_flags(row: dict[str, Any], regime: str) -> list[str]: +def _risk_flags( + row: dict[str, Any], regime: str, include_regime_risk: bool = True +) -> list[str]: flags = [] if row.get("pct_chg", 0) >= 9.5: flags.append("当日接近涨停,次日存在高开与无法成交风险") @@ -1222,7 +1765,7 @@ def _risk_flags(row: dict[str, Any], regime: str) -> list[str]: flags.append("波动率偏高") if row.get("amount_billion", 0) < 1: flags.append("成交承载力偏弱") - if regime == "retreat": + if include_regime_risk and regime == "retreat": flags.append("市场处于退潮阶段,策略可能选择空仓") return flags diff --git a/sentiment_engine.py b/sentiment_engine.py index f158d49..9345cd5 100644 --- a/sentiment_engine.py +++ b/sentiment_engine.py @@ -13,6 +13,8 @@ COMPONENT_WEIGHTS = { "liquidity": 10, } +SENTIMENT_ENGINE_VERSION = 2 + def _number(value: Any, default: float = 0.0) -> float: try: @@ -43,7 +45,7 @@ def _percentile(value: float, history: list[float]) -> float: def _adaptive_score(value: float, fixed: float, history: list[float]) -> float: if len(history) < 20: return fixed - return fixed * 0.4 + _percentile(value, history[-120:]) * 0.6 + return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75 def _trade_date(payload: dict[str, Any]) -> str: @@ -274,7 +276,9 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A systemic_health = breadth_score * 0.60 + down_relief * 0.40 systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65 ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30 - limit_ecology_score = ecology_base_score * (0.25 + systemic_gate * 0.75) + # Systemic risk is applied once to the final temperature. Reapplying it here + # would count market breadth and limit-down pressure twice. + limit_ecology_score = ecology_base_score if stats["previous_limit_count"]: positive_score = float(stats["previous_positive_rate"]) @@ -357,6 +361,11 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A else None ) day_change = score - float(previous_result["score"]) if previous_result else 0.0 + ema_score = round( + score if not previous_result + else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5, + 1, + ) phase_signal = _phase_signal(score, momentum, profit_effect_score) fermentation_ready = ( phase_signal == "发酵" @@ -428,6 +437,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A { **stats, "score": score, + "ema_score": ema_score, "label": _sentiment_label(score), "phase": phase, "phase_signal": phase_signal, @@ -436,7 +446,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A "day_change": round(day_change, 1), "direction": direction, "momentum": round(momentum, 1), - "normalization": normalization, + "normalization": "250日历史百分位" if len(previous) >= 20 else normalization, "history_days": len(previous) + 1, "systemic_health": round(systemic_health, 1), "risk_multiplier": round(systemic_gate, 3), @@ -474,10 +484,12 @@ def apply_sentiment_to_dashboard( overview.update( { "sentiment_score": sentiment["score"], + "sentiment_trend_score": sentiment["ema_score"], "sentiment_label": sentiment["label"], "sentiment_phase": sentiment["phase"], "sentiment_direction": sentiment["direction"], "sentiment_components": sentiment["components"], + "sentiment_engine_version": SENTIMENT_ENGINE_VERSION, } ) result["overview"] = overview diff --git a/server.py b/server.py index 42b037e..a09fced 100644 --- a/server.py +++ b/server.py @@ -66,6 +66,7 @@ from screener import ( from security import SecretVault, hash_password, token_hash, verify_password from sentiment_engine import ( COMPONENT_WEIGHTS, + SENTIMENT_ENGINE_VERSION, apply_sentiment_to_dashboard, build_sentiment_history, latest_contiguous_history, @@ -75,6 +76,30 @@ from trade_journal import TradeJournalService from tushare_client import TushareClient, TushareError, _sector_coverage_issue +SCREENER_LIBRARY_VERSION = 7 + + +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 = { "TUSHARE_TOKEN", "IFIND_REFRESH_TOKEN", @@ -175,6 +200,8 @@ class DashboardService: self.sync_lock = threading.Lock() self.auth_lock = threading.Lock() self.system_lock = threading.Lock() + self.auto_screener_lock = threading.Lock() + self._auto_screener_last_attempt: dict[str, datetime] = {} self._ifind_event_lock = threading.Lock() self._request_context = threading.local() self._system_credentials = self._load_system_credentials(environment_credentials) @@ -788,6 +815,7 @@ class DashboardService: snapshot = self.database.get_snapshot(today) or {} if self._realtime_snapshot_due(today, snapshot): self._run_background_sync(today) + self._schedule_automatic_screeners(today, snapshot) except Exception: pass self._background_stop.wait(5) @@ -936,6 +964,11 @@ class DashboardService: ) if not self._dashboard_sentiment_ready(snapshot): snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) + self.database.save_snapshot( + normalized_date, + str((snapshot.get("meta") or {}).get("source") or "tushare"), + snapshot, + ) snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date) return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) resolved = self.database.get_data_snapshot( @@ -968,7 +1001,7 @@ class DashboardService: @staticmethod def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: overview = dashboard.get("overview") or {} - return all( + return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all( key in overview for key in ( "sentiment_score", @@ -1091,7 +1124,7 @@ class DashboardService: dashboard: dict[str, Any], end_date: str, ) -> dict[str, Any]: - history = self.database.list_snapshot_payloads(end_date, 240) + history = self.database.list_snapshot_payloads(end_date, 260) return apply_sentiment_to_dashboard(dashboard, history) def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]: @@ -1168,6 +1201,103 @@ class DashboardService: "rows": rows, } + def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]: + normalized_date = normalize_date(trade_date) + sector_name = validate_text(sector_name, "板块名称", 60, required=True) + dashboard = self.get_dashboard(normalized_date) + actual_date = normalize_date( + str((dashboard.get("meta") or {}).get("trade_date") or normalized_date) + ) + cache_key = f"{actual_date}:{sector_name}" + cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key) + if cached: + cached["meta"] = {**(cached.get("meta") or {}), "cached": True} + return cached + if not self.configured: + raise ValueError("板块成分数据暂不可用。") + + representative = next( + ( + item for item in dashboard.get("limits") or [] + if str(item.get("sector") or "").strip() == sector_name + ), + None, + ) + if not representative: + raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。") + raw_code = str(representative.get("ts_code") or representative.get("code") or "") + if "." in raw_code: + ts_code = raw_code + elif raw_code.startswith(("4", "8", "92")): + ts_code = f"{raw_code}.BJ" + elif raw_code.startswith(("6", "68", "90")): + ts_code = f"{raw_code}.SH" + else: + ts_code = f"{raw_code}.SZ" + client = TushareClient(self.token) + try: + industry = client.sw_stock_industry(ts_code, actual_date) + sector_code = str(industry.get("l2_code") or "") + members = client.sw_sector_members(sector_code, actual_date) + except TushareError as exc: + raise ValueError(f"该板块成分股暂不可用:{exc}") from exc + + daily_rows = self.database.daily_bars_for_date(actual_date) + if len(daily_rows) < 1000: + try: + daily_rows = client.query( + "daily", + {"trade_date": actual_date}, + "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount", + ) + if daily_rows: + self.database.upsert_daily_bars(daily_rows) + except TushareError: + daily_rows = self.database.daily_bars_for_date(actual_date) + daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows} + rows = [] + for member in members: + member_code = str(member.get("ts_code") or "") + quote = daily_map.get(member_code) or {} + rows.append( + { + "code": member_code.split(".")[0], + "ts_code": member_code, + "name": str(member.get("name") or "--"), + "change": quote.get("pct_chg"), + "open": quote.get("open"), + "close": quote.get("close"), + "amount_billion": ( + round(float(quote.get("amount") or 0) / 100000, 2) + if quote else None + ), + "quoted": bool(quote), + } + ) + rows.sort( + key=lambda item: ( + bool(item.get("quoted")), + float(item.get("change") or -999), + float(item.get("amount_billion") or 0), + ), + reverse=True, + ) + result = { + "meta": { + "trade_date": self._display_compact_date(actual_date), + "sector_name": str(industry.get("l2_name") or sector_name), + "sector_code": sector_code, + "member_count": len(rows), + "quoted_count": sum(bool(item.get("quoted")) for item in rows), + "cached": False, + }, + "rows": rows, + } + self.database.save_data_snapshot( + "rotation_sector_members_v1", cache_key, "tushare", result + ) + return result + def status(self) -> dict[str, Any]: llm_access = self.llm_access_status() return { @@ -1234,35 +1364,63 @@ class DashboardService: return match.group(1) return "" - 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, 100) - 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) + @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 "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日资金流") + return 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: - formula = strategy.get("formula") or {} - used_fields = { - str(item.get("field") or "") - for item in list(formula.get("filters") or []) + list(formula.get("score") or []) - } - missing = [] - if len(factor_dates) < 21: - missing.append("基础行情") - 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 "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("竞价数据") + 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, @@ -1292,16 +1450,11 @@ class DashboardService: "fallback_configured": self.llm_fallback_configured, "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", }, - "latest_results": self.database.latest_screener_runs( - self.current_user_id, normalized_date - ), - "recent_results": self.database.latest_screener_context_runs( - self.current_user_id, normalized_date - ), + "latest_results": latest_results, + "recent_results": recent_results, + "automatic_status": automatic_status, # Kept during the client transition for compatibility with older frontends. - "latest_result": self.database.latest_screener_run( - self.current_user_id, normalized_date, "smart" - ), + "latest_result": latest_results.get("smart"), } def screener_tracking(self, limit: int = 12) -> dict[str, Any]: @@ -1565,12 +1718,158 @@ class DashboardService: if not self.configured: raise ValueError("请先配置 Tushare Token。") normalized_date = normalize_date(trade_date) - lookback = max(25, min(80, int(lookback))) + lookback = max(25, min(260, int(lookback))) with self.sync_lock: return FactorDataService(self.database, TushareClient(self.token)).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 + threading.Thread( + target=self.run_automatic_screeners, + args=(normalized_date,), + name=f"automatic-screeners-{normalized_date}", + daemon=True, + ).start() + return True + + 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, TushareClient(self.token) + ).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: @@ -4641,6 +4940,18 @@ class RequestHandler(BaseHTTPRequestHandler): except (TypeError, ValueError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return + if parsed.path == "/api/rotation/members": + query = parse_qs(parsed.query) + try: + self.send_json( + SERVICE.rotation_sector_members( + query.get("trade_date", [date.today().isoformat()])[0], + query.get("sector", [""])[0], + ) + ) + except (TypeError, ValueError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return if parsed.path == "/api/dragon-tiger": query = parse_qs(parsed.query) trade_date = query.get("trade_date", [date.today().isoformat()])[0] diff --git a/static/app.js b/static/app.js index 41794df..afc3973 100644 --- a/static/app.js +++ b/static/app.js @@ -53,6 +53,10 @@ const state = { rotationHistory: null, rotationHistoryKey: "", rotationSelectedSector: "", + rotationSelectedDate: "", + rotationMembers: null, + rotationMembersKey: "", + rotationMembersLoading: false, rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", rotationLoading: false, auctionData: null, @@ -104,6 +108,7 @@ const state = { screenerSetupPromise: null, selectedRegime: "", selectedStrategy: null, + customStrategyDraft: null, screenerRunning: false, screenerRunningMode: "", screenerResults: { smart: null, curated: null, quant: null }, @@ -114,7 +119,9 @@ const state = { ? localStorage.getItem("xiaobaiScreenerMode") : "smart", curatedCategory: "全部", + curatedSchool: "全部", curatedQuery: "", + curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list", selectedCuratedStrategyId: 0, quantFilters: [], quantScores: [], @@ -519,8 +526,6 @@ async function applyAuthenticatedSession(session) { document.querySelector("#syncButton").hidden = !isAdmin; document.querySelector("#reasonForm").hidden = !isAdmin; document.querySelector("#sectorPhaseManager").hidden = !isAdmin; - const factorSyncButton = document.querySelector("#factorSyncButton"); - if (factorSyncButton) factorSyncButton.hidden = false; document.querySelector("#authGate").hidden = true; applyMembershipAccess(); await startAuthenticatedApp(); @@ -815,8 +820,7 @@ function bindEvents() { } renderAuctionTable(); }); - document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library")); - document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor")); + document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer); document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close()); document.querySelector("#strategyDrawer").addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.close(); @@ -911,8 +915,6 @@ function bindEvents() { document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder); document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride); document.querySelector("#backfillButton").addEventListener("click", backfillData); - document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData); - document.querySelector("#screenerRunButton").addEventListener("click", runScreener); document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => { await loadScreenerTracking(true); openView("screenerTrackingView"); @@ -943,11 +945,22 @@ function bindEvents() { state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); renderCuratedStrategyLibrary(); }); - document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy); - document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus); - document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close()); - document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => { - if (event.target === event.currentTarget) event.currentTarget.close(); + document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => { + state.curatedCategory = event.target.value; + renderCuratedStrategyLibrary(); + }); + document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => { + const button = event.target.closest("[data-curated-school]"); + if (!button) return; + state.curatedSchool = button.dataset.curatedSchool; + renderCuratedStrategyLibrary(); + }); + document.querySelectorAll("[data-curated-view]").forEach((button) => { + button.addEventListener("click", () => { + state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list"; + localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode); + renderCuratedStrategyLibrary(); + }); }); document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder); document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter()); @@ -1161,7 +1174,7 @@ function renderDashboard() { renderYesterdayTable(state.dashboard.yesterday_limits || []); renderPerformance(state.dashboard.limit_performance || []); renderLadderBoard(ladders || []); - renderRotationTable(state.dashboard.sector_rotation || [], sectors || []); + renderRotationMembers(); } async function loadSentimentHistory(force = false) { @@ -1787,6 +1800,7 @@ function updateYesterdayControls() { } function renderPerformance(rows) { + rows = normalizePerformanceRows(rows); const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || ""); setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`); @@ -1803,6 +1817,39 @@ function renderPerformance(rows) { renderMarketBreadth(state.dashboard?.overview || {}); } +function normalizePerformanceRows(rows) { + const groups = new Map(); + (rows || []).forEach((row) => { + const level = Math.max(1, number(row.level)); + const displayLevel = Math.min(level, 5); + const group = groups.get(displayLevel) || { + level: displayLevel, + label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`, + count: 0, + advanced: 0, + positive: 0, + changeTotal: 0, + }; + const count = number(row.count); + group.count += count; + group.advanced += number(row.advanced); + group.positive += count * number(row.positive_rate) / 100; + group.changeTotal += count * number(row.average_change); + groups.set(displayLevel, group); + }); + return [...groups.values()] + .sort((left, right) => right.level - left.level) + .map((group) => ({ + level: group.level, + label: group.label, + count: group.count, + advanced: group.advanced, + advance_rate: group.count ? group.advanced / group.count * 100 : 0, + positive_rate: group.count ? group.positive / group.count * 100 : 0, + average_change: group.count ? group.changeTotal / group.count : 0, + })); +} + function performanceRateState(rate) { const value = number(rate); if (value === 0) return { label: "失效", className: "is-neutral" }; @@ -1956,8 +2003,9 @@ function renderRotationHistory() { `; tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => { state.rotationSelectedSector = ""; + state.rotationSelectedDate = ""; renderRotationHistory(); - updateRotationTableSelection(); + loadRotationMembers(""); }); } else { tracker.hidden = true; @@ -1974,7 +2022,7 @@ function renderRotationHistory() { const strength = clamp(number(sector.strength), 0, 100); const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild"; return ` - `; @@ -1983,58 +2031,76 @@ function renderRotationHistory() { }).join(""); container.querySelectorAll("[data-rotation-sector]").forEach((button) => { button.addEventListener("click", () => { - state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector - ? "" - : button.dataset.rotationSector; + const clickedSector = button.dataset.rotationSector; + const clickedDate = button.dataset.rotationDate; + const isSameSelection = clickedSector === state.rotationSelectedSector + && clickedDate === state.rotationSelectedDate; + state.rotationSelectedSector = isSameSelection ? "" : clickedSector; + state.rotationSelectedDate = isSameSelection ? "" : clickedDate; renderRotationHistory(); - updateRotationTableSelection(); + loadRotationMembers(state.rotationSelectedSector); }); }); } -function renderRotationTable(rows, sectors) { - const sectorMap = new Map(sectors.map((sector) => [sector.name, sector])); +async function loadRotationMembers(sector, force = false) { + if (!sector) { + state.rotationMembers = null; + state.rotationMembersKey = ""; + renderRotationMembers(); + return; + } + const memberDate = state.rotationSelectedDate || elements.tradeDate.value; + const key = `${memberDate}:${sector}`; + if (!force && state.rotationMembersKey === key && state.rotationMembers) { + renderRotationMembers(); + return; + } + state.rotationMembersLoading = true; + renderRotationMembers(); + try { + const query = new URLSearchParams({ trade_date: memberDate, sector }); + state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`); + state.rotationMembersKey = key; + } catch (error) { + state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] }; + state.rotationMembersKey = key; + } finally { + state.rotationMembersLoading = false; + renderRotationMembers(); + } +} + +function renderRotationMembers() { const body = document.querySelector("#rotationTableBody"); - const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); - setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`); - body.innerHTML = rows.map((row) => { - const sector = sectorMap.get(row.name) || {}; - const strength = number(row.strength ?? sector.strength); - const currentCount = number(row.count); - const previousCount = number(row.previous_count); - const delta = row.delta == null ? currentCount - previousCount : number(row.delta); - const trend = previousCount === 0 && currentCount > 0 - ? "新进" - : currentCount > previousCount - ? "升温" - : currentCount < previousCount ? "降温" : "持平"; - return ` - ${number(row.rank)}${escapeHtml(row.name)} - ${trend} - ${currentCount}${previousCount} - ${delta > 0 ? "+" : ""}${delta} -
${formatNumber(strength, 0)}
- ${streakLabel(row.max_streak || 1)} - ${signed(sector.change)}% - ${escapeHtml(row.leader || sector.leader || "--")}${formatNumber(row.amount_billion, 1)} 亿 - `; - }).join(""); - body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => { - row.addEventListener("click", () => { - const sector = row.dataset.rotationDetailSector; - state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector; - renderRotationHistory(); - updateRotationTableSelection(); - document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" }); - }); - }); + const empty = document.querySelector("#rotationMembersEmpty"); + if (state.rotationMembersLoading) { + body.innerHTML = ""; + empty.textContent = `正在核验${state.rotationSelectedSector}成分股`; + empty.hidden = false; + return; + } + const payload = state.rotationMembers; + const rows = payload?.rows || []; + if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) { + body.innerHTML = ""; + empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股"); + empty.hidden = false; + setText("rotationDetailTitle", "板块成分股"); + setText("rotationDetailMeta", state.rotationSelectedSector || "--"); + return; + } + empty.hidden = true; + setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`); + setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`); + body.innerHTML = rows.map((row, index) => ` + ${index + 1}${escapeHtml(row.code)}${escapeHtml(row.name)} + ${row.quoted ? signed(row.change) : ""} + ${row.quoted ? formatNumber(row.open, 2) : ""}${row.quoted ? formatNumber(row.close, 2) : ""} + ${row.quoted ? formatNumber(row.amount_billion, 2) : ""}${row.quoted ? "正常交易" : "当日无行情"} + `).join(""); animateRows(body); -} - -function updateRotationTableSelection() { - document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => { - row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector); - }); + bindStockRows(body); } function renderLadderMini(ladders) { @@ -3498,12 +3564,7 @@ function applyScreenerSetup(payload, requestKey) { if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result; const smartLatestMeta = latestResults.smart?.meta || {}; const curatedLatestMeta = latestResults.curated?.meta || {}; - const availableRegimes = new Set((payload.regimes || []).map((item) => item.id)); - if (!state.selectedRegime || dateChanged) { - state.selectedRegime = availableRegimes.has(smartLatestMeta.regime) - ? smartLatestMeta.regime - : payload.regime.id; - } + state.selectedRegime = payload.regime.id; const selectedId = state.selectedStrategy?.id; const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated"); @@ -3617,13 +3678,9 @@ function renderScreenerSetup() { const selector = document.querySelector("#regimeSelector"); selector.innerHTML = setup.regimes.map((item) => ` - + ${escapeHtml(item.label)} `).join(""); - selector.querySelectorAll("[data-regime]").forEach((button) => { - button.addEventListener("click", () => selectRegime(button.dataset.regime)); - }); renderStrategyList(); - populateStrategyEditor(state.selectedStrategy); renderStrategySummary(); renderScreenerMode(); renderCuratedStrategyLibrary(); @@ -3663,7 +3720,7 @@ function renderScreenerMode() { const results = document.querySelector("#screenerView .screener-results-view"); const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`); if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results); - const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" }; + const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" }; setText("screenerResultTitle", titles[mode]); renderScreenerResult(); } @@ -3677,56 +3734,83 @@ function activeCuratedStrategy() { return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null; } +function curatedStrategySchool(strategy) { + const category = String(strategy?.formula?.meta?.category || ""); + if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面"; + if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势"; + if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线"; + if (["动量反转"].includes(category)) return "动量"; + if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面"; + if (/趋势|轮动|突破/.test(category)) return "趋势"; + if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线"; + if (/动量|反转/.test(category)) return "动量"; + return "其他"; +} + +function curatedSchoolIcon(school) { + return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes"; +} + +function curatedStrategyRunState(strategy, result) { + const missingData = strategy?.missing_data || []; + if (!strategy?.data_ready || missingData.length) { + return { label: "数据不足", className: "missing", verifiedEmpty: false }; + } + if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false }; + const count = (result.candidates || []).length; + if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false }; + return { label: "暂无信号", className: "quiet", verifiedEmpty: true }; +} + function renderCuratedStrategyLibrary() { if (!state.screenerSetup) return; const strategies = curatedStrategies(); const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))]; + const schools = ["全部", "基本面", "趋势", "短线", "动量"]; if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部"; + if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部"; setText("curatedStrategyCount", `${strategies.length} 套`); - const filters = document.querySelector("#curatedCategoryFilters"); - filters.innerHTML = categories.map((category) => ` - + const categorySelect = document.querySelector("#curatedCategoryFilter"); + categorySelect.innerHTML = categories.map((category) => ` + `).join(""); - filters.querySelectorAll("[data-curated-category]").forEach((button) => { - button.addEventListener("click", () => { - state.curatedCategory = button.dataset.curatedCategory; - renderCuratedStrategyLibrary(); - }); + document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => { + const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length; + return ``; + }).join(""); + document.querySelectorAll("[data-curated-view]").forEach((button) => { + const active = button.dataset.curatedView === state.curatedViewMode; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); }); const query = state.curatedQuery; const visible = strategies.filter((item) => { const meta = item.formula?.meta || {}; const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory; - const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query); - return categoryMatch && queryMatch; + const school = curatedStrategySchool(item); + const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool; + const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query); + return categoryMatch && schoolMatch && queryMatch; }); const list = document.querySelector("#curatedStrategyList"); + list.classList.toggle("is-grid", state.curatedViewMode === "grid"); list.innerHTML = visible.length ? visible.map((strategy) => { const meta = strategy.formula?.meta || {}; + const school = curatedStrategySchool(strategy); const rank = strategies.findIndex((item) => item.id === strategy.id) + 1; + const resultKey = screenerResultKey(screenerResultContext("curated", null, { + regime: strategy.regimes[0] || state.selectedRegime, + strategyId: strategy.id, + strategyName: strategy.name, + })); + const result = state.screenerResultStore[resultKey]?.result; + const runState = curatedStrategyRunState(strategy, result); return `
- ${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(meta.category || "策略")} - 质量 ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")} - ${escapeHtml(strategy.description || "查看策略条件与适用环境。")} - ${strategy.data_ready ? "数据已就绪" : `缺少 ${(strategy.missing_data || []).length} 项数据`} + + ${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}${escapeHtml(runState.label)} + ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")}
`; }).join("") : '
没有符合条件的策略
'; - list.querySelectorAll("[data-curated-inspect]").forEach((button) => { - button.addEventListener("click", () => { - state.selectedCuratedStrategyId = number(button.dataset.curatedInspect); - renderCuratedStrategyLibrary(); - renderScreenerResult(); - document.querySelector("#curatedDetailDialog").showModal(); - }); - }); - list.querySelectorAll("[data-curated-run]").forEach((button) => { - button.addEventListener("click", () => { - state.selectedCuratedStrategyId = number(button.dataset.curatedRun); - renderCuratedStrategyLibrary(); - renderScreenerResult(); - runCuratedStrategy(); - }); - }); renderCuratedStrategyDetail(); } @@ -3735,6 +3819,10 @@ function renderCuratedStrategyDetail() { if (!strategy) return; const formula = strategy.formula || {}; const meta = formula.meta || {}; + const result = activeScreenerResult("curated"); + const resultMeta = result?.meta || {}; + const health = resultMeta.health || {}; + const runState = curatedStrategyRunState(strategy, result); setText("curatedStrategyCategory", meta.category || "精选策略"); setText("curatedStrategyName", strategy.name); setText("curatedStrategyDescription", strategy.description); @@ -3742,6 +3830,8 @@ function renderCuratedStrategyDetail() { `质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`, meta.data_group || "行情因子", ].map((value) => `${escapeHtml(value)}`).join(""); + setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准"); + setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效"); const filters = formula.filters || []; setText("curatedFilterCount", `${filters.length} 项`); document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => ` @@ -3754,14 +3844,27 @@ function renderCuratedStrategyDetail() { const percent = number(item.weight) / total * 100; return `
${escapeHtml(factorLabel(item.field))}${formatNumber(percent, 0)}%
`; }).join(""); + const candidateCount = (result?.candidates || []).length; + const statusLabel = runState.className === "ready" ? "运行正常" : runState.label; + const statusClass = runState.className; + let updatedLabel = "--"; + if (resultMeta.updated_at) { + const updated = new Date(resultMeta.updated_at); + if (!Number.isNaN(updated.getTime())) { + updatedLabel = `${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")} ${updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`; + } + } + document.querySelector("#curatedHealthMetrics").innerHTML = [ + ["运行状态", statusLabel, statusClass], + ["当日信号", result ? `${candidateCount} 只` : "--", ""], + ["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""], + ["最近更新", updatedLabel, ""], + ].map(([label, value, className]) => `
${escapeHtml(label)}${escapeHtml(value)}
`).join(""); const status = document.querySelector("#curatedDataStatus"); - status.classList.toggle("missing", !strategy.data_ready); + status.classList.toggle("missing", statusClass === "missing"); status.innerHTML = strategy.data_ready - ? '策略数据已就绪可按当前数据日期执行' - : `需要补充数据${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}`; - const runButton = document.querySelector("#curatedRunButton"); - runButton.disabled = !strategy.data_ready; - runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(strategy.missing_data || []).join("、")}`; + ? `${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}` + : `数据尚未完备${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}`; refreshIcons(); } @@ -3973,6 +4076,22 @@ function openStrategyDrawer(target = "editor") { }); } +function openCustomStrategyDrawer() { + if (!state.customStrategyDraft) { + state.customStrategyDraft = { + id: null, + builtin: false, + name: "自定义选股策略", + description: "", + regimes: [state.selectedRegime], + formula: buildQuantFormula(), + }; + } + populateStrategyEditor(state.customStrategyDraft); + renderStrategyList(); + openStrategyDrawer("editor"); +} + function selectScreenerMobileView(view) { state.screenerMobileView = view === "results" ? "results" : "strategy"; const workspace = document.querySelector("#screenerView"); @@ -3986,11 +4105,7 @@ function selectScreenerMobileView(view) { } function updateBacktestTaskStatus() { - const enabled = document.querySelector("#runBacktestToggle")?.checked; - const backtest = activeScreenerResult("smart")?.backtest; - setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled - ? "正在回测" - : backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行"); + setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成"); renderScreenerProgress(); } @@ -4003,18 +4118,18 @@ function selectRegime(regime) { function renderStrategyList() { const list = document.querySelector("#strategyList"); - const strategies = state.screenerSetup.strategies.filter((item) => item.formula?.meta?.library !== "curated"); + const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated"); list.innerHTML = strategies.map((strategy) => ` - - `).join(""); + `).join("") || '
暂无已保存的自定义公式
'; list.querySelectorAll("[data-strategy-id]").forEach((button) => { button.addEventListener("click", () => { - state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); - state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime; - renderScreenerSetup(); + state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); + populateStrategyEditor(state.customStrategyDraft); + renderStrategyList(); }); }); } @@ -4033,32 +4148,6 @@ function populateStrategyEditor(strategy) { document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2); } -async function syncFactorData() { - const button = document.querySelector("#factorSyncButton"); - button.disabled = true; - setText("factorTaskStatus", "同步中"); - setLoading(true, "正在同步 45 个交易日因子数据"); - setStatus("正在同步选股因子"); - try { - const payload = await apiRequest("/api/screener/sync", "POST", { - trade_date: elements.tradeDate.value, - lookback: 45, - }); - const result = payload.result; - showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)} 日`); - await loadScreenerSetup(true); - setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)} 日`); - setStatus("选股因子已同步"); - } catch (error) { - showToast(error.message); - setStatus("选股因子同步失败"); - setText("factorTaskStatus", "同步失败"); - } finally { - setLoading(false); - button.disabled = false; - } -} - async function compileStrategy() { const prompt = document.querySelector("#strategyPrompt").value.trim(); const button = document.querySelector("#compileStrategyButton"); @@ -4071,9 +4160,8 @@ async function compileStrategy() { regime: state.selectedRegime, }); const strategy = payload.strategy; - state.selectedStrategy = { ...strategy, id: null, builtin: false }; + state.customStrategyDraft = { ...strategy, id: null, builtin: false }; document.querySelector("#deleteStrategyButton").hidden = true; - renderStrategySummary(); document.querySelector("#strategyNameInput").value = strategy.name; document.querySelector("#strategyDescriptionInput").value = strategy.description; document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2); @@ -4099,8 +4187,9 @@ async function saveCurrentStrategy() { formula, }); state.screenerSetup.strategies = payload.strategies; - state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id); - renderScreenerSetup(); + state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id); + renderStrategyList(); + populateStrategyEditor(state.customStrategyDraft); showToast("自定义策略已保存"); } catch (error) { showToast(error.message); @@ -4108,7 +4197,7 @@ async function saveCurrentStrategy() { } async function deleteCurrentStrategy() { - const strategy = state.selectedStrategy; + const strategy = state.customStrategyDraft; if (!strategy?.id || strategy.builtin) { showToast("只能删除已保存的自定义策略"); return; @@ -4120,10 +4209,9 @@ async function deleteCurrentStrategy() { try { const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE"); state.screenerSetup.strategies = payload.strategies; - state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime)) - || payload.strategies.find((item) => item.formula?.meta?.library !== "curated") - || null; - renderScreenerSetup(); + state.customStrategyDraft = null; + renderStrategyList(); + openCustomStrategyDrawer(); showToast("自定义策略已删除"); } catch (error) { showToast(error.message || "策略删除失败"); @@ -4132,26 +4220,6 @@ async function deleteCurrentStrategy() { } } -async function runCuratedStrategy() { - const strategy = activeCuratedStrategy(); - if (!strategy) return; - if (!strategy.data_ready) { - showToast(`请先同步${(strategy.missing_data || []).join("、")}`); - return; - } - const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0]; - await executeScreenerFormula({ - mode: "curated", - formula: strategy.formula, - strategyName: strategy.name, - strategyId: strategy.id, - regime, - runBacktest: document.querySelector("#curatedBacktestToggle").checked, - button: document.querySelector("#curatedRunButton"), - loadingText: `正在执行“${strategy.name}”并计算历史样本`, - }); -} - async function runQuantStrategy() { let formula; try { @@ -4163,27 +4231,27 @@ async function runQuantStrategy() { await executeScreenerFormula({ mode: "quant", formula, - strategyName: "自定义量化公式", + strategyName: "自定义选股公式", regime: state.selectedRegime, runBacktest: document.querySelector("#quantBacktestToggle").checked, button: document.querySelector("#quantRunButton"), - loadingText: "正在执行量化公式并计算因子贡献", + loadingText: "正在执行自定义公式并计算因子贡献", }); } function saveQuantAsStrategy() { try { const formula = buildQuantFormula(); - state.selectedStrategy = { + state.customStrategyDraft = { id: null, builtin: false, - name: "自定义量化策略", - description: "由量化因子工作台生成,可在高级公式中继续调整。", + name: "自定义选股策略", + description: "由自定义因子工作台生成,可在高级公式中继续调整。", regimes: [state.selectedRegime], formula, }; - populateStrategyEditor(state.selectedStrategy); - document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式"; + populateStrategyEditor(state.customStrategyDraft); + document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式"; openStrategyDrawer("editor"); } catch (error) { showToast(error.message); @@ -4239,26 +4307,6 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId } } -async function runScreener() { - let formula; - try { - formula = parseFormulaEditor(); - } catch (error) { - showToast(error.message); - return; - } - await executeScreenerFormula({ - mode: "smart", - formula, - strategyName: document.querySelector("#strategyNameInput").value, - strategyId: state.selectedStrategy?.id, - regime: state.selectedRegime, - runBacktest: document.querySelector("#runBacktestToggle").checked, - button: document.querySelector("#screenerRunButton"), - loadingText: "正在计算因子排名与滚动回测", - }); -} - async function loadMentorSetup(force = false) { const requestedDate = elements.tradeDate.value.replaceAll("-", ""); if (!force && state.mentorSetup?.requestedDate === requestedDate) { @@ -6619,9 +6667,9 @@ function renderScreenerResult() { const context = activeScreenerResultContext(mode); const source = document.querySelector("#screenerResultSource"); const emptyMessages = { - smart: "尚未执行当前阶段与策略的选股", - curated: "尚未执行所选策略", - quant: "尚未执行量化选股", + smart: "当日盘后候选尚未生成", + curated: "所选策略的当日候选尚未生成", + quant: "尚未执行自定义选股", }; if (!result) { setText("screenerResultCount", "0 只"); @@ -6639,9 +6687,9 @@ function renderScreenerResult() { } const candidates = result.candidates || []; setText("screenerResultCount", `${candidates.length} 只`); - const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" }; + const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" }; const sourceParts = [modeLabels[mode]]; - if (mode !== "quant" && context?.regime) sourceParts.push(regimeLabel(context.regime)); + if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime)); sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略"); source.textContent = sourceParts.join(" · "); source.hidden = false; @@ -6652,7 +6700,9 @@ function renderScreenerResult() { ? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}` : `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`, ); - document.querySelector("#screenerEmpty").hidden = candidates.length > 0; + const empty = document.querySelector("#screenerEmpty"); + empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode]; + empty.hidden = candidates.length > 0; const body = document.querySelector("#screenerTableBody"); const runId = number(meta.run_id); body.innerHTML = candidates.map((row, index) => ` diff --git a/static/design-system.css b/static/design-system.css index 26d2594..95353da 100644 --- a/static/design-system.css +++ b/static/design-system.css @@ -219,11 +219,6 @@ body.drawer-open .drawer-mask{display:block} .field textarea{min-height:90px;resize:vertical} .field input:focus,.field textarea:focus{border-color:var(--blue-line)} -/* toast */ -.toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff; - padding:8px 18px;border-radius:8px;font-size:12.5px;z-index:200;opacity:0;transition:opacity .2s;pointer-events:none} -.toast.show{opacity:.95} - /* ========== 集合竞价页 ========== */ .auc-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap} .auc-head h2{font-size:17px;font-weight:800} diff --git a/static/index.html b/static/index.html index b295f5c..e6d89a9 100644 --- a/static/index.html +++ b/static/index.html @@ -19,9 +19,9 @@ - + - + @@ -449,12 +449,12 @@

判定口径

温度 + 结构共同判定
-
冰点涨停稀少、跌停成堆、高度压至 2 板0-20抗跌先手,允许无结果
-
修复跌停减少、首板增多、出现反包20-40修复先锋,小仓试错
-
发酵主线清晰、梯队成型、晋级率走高40-60主线跟随
-
高潮涨停扩散、空间打开、情绪充沛60-85核心去后排
-
分化高低切换、炸板增多、主线内部分歧45-65承接回流
-
退潮高度压缩、晋级走低、亏钱效应扩散20-40 且下降防守观察
+
冰点涨停稀少、跌停成堆、高度显著压缩低于 25抗跌先手,允许无结果
+
修复风险收敛、温度从低位有效回升25+ 且回升修复先锋,小仓试错
+
发酵主线清晰、梯队成型、连续转强45+ 且连续确认主线跟随
+
高潮温度、赚钱效应与涨停生态共振80+ 且生态达标核心去后排
+
分化高低切换、炸板增多、主线内部分歧45+ 且结构转弱承接回流
+
退潮温度或系统健康度继续走弱低于 45 且走弱防守观察
@@ -801,7 +801,7 @@
- +
@@ -831,8 +831,8 @@
--
-
-
跟随自动识别(点击可手动覆盖)0 日尚未同步
+
+
盘后行情定格后自动更新0 日等待后台数据
@@ -842,17 +842,12 @@
--

等待匹配当前市场阶段的策略。

-
- - -
+
由系统按当前阶段自动匹配
- -
@@ -863,37 +858,52 @@
- - + 结果按加权总分排序,并生成逐股贡献解释

-
+
+ @@ -946,7 +956,7 @@ -
阶段选股

编辑 / 自定义策略

+
自定义选股

自然语言与受控公式

排名股票板块综合分 历史估计(%)当日涨幅(%)5日涨幅(%)
- - - - - + + + +
排名板块趋势今日涨停(只)昨日涨停(只)变化(只)强度(分)最高板(板)平均涨幅(%)领涨股涨停股成交额(亿)序号代码股票涨跌幅(%)开盘价(元)收盘价(元)成交额(亿)行情状态
+
点击上方任意板块查看成分股
@@ -1873,6 +1883,6 @@ - + diff --git a/static/redesign-v2.css b/static/redesign-v2.css index 35e2ae9..06a9182 100644 --- a/static/redesign-v2.css +++ b/static/redesign-v2.css @@ -616,7 +616,7 @@ body.sidebar-collapsed .status-bar { left: 64px; } .sentiment-current-phase-badge strong { display: block; color: var(--r2-up); font-size: 19px; font-weight: 800; line-height: 1.35; } .sentiment-current-phase-badge span { display: block; margin-top: 2px; color: var(--r2-sub); font-size: 11px; white-space: nowrap; } .sentiment-phase-info { min-width: 0; flex: 1; } -.sentiment-phase-info p { color: #374151; font-size: 12.5px; line-height: 1.7; } +.sentiment-phase-info p { color: var(--r2-sub); font-size: 12.5px; line-height: 1.7; } .sentiment-phase-info p b { font-weight: 700; } .sentiment-phase-info p .down { color: var(--r2-down); } .sentiment-phase-info p .up { color: var(--r2-up); } @@ -1050,7 +1050,7 @@ body.sidebar-collapsed .status-bar { left: 64px; } .pool-side-group p { margin: 0; overflow: hidden; - color: #4b5563; + color: var(--r2-sub); font-size: 12px; line-height: 1.8; text-overflow: ellipsis; @@ -1819,7 +1819,7 @@ body.sidebar-collapsed .status-bar { left: 64px; } .performance-conclusion { padding: 14px 16px; - color: #374151; + color: var(--r2-sub); font-size: 12.5px; line-height: 2; } @@ -5222,38 +5222,114 @@ body.sidebar-collapsed .status-bar { left: 64px; } /* Curated strategy workspace. */ #screenerView .curated-screener-panel { display: block; } -#screenerView .curated-library-pane { padding: 0; border: 0; background: transparent; box-shadow: none; } +#screenerView .curated-workspace { + display: grid; + grid-template-columns: minmax(340px, .9fr) minmax(560px, 1.5fr); + gap: 12px; + align-items: stretch; +} + +#screenerView .curated-library-pane, +#screenerView .curated-detail-pane { + min-width: 0; + overflow: hidden; + border: 1px solid var(--r2-line); + border-radius: 10px; + background: #fff; + box-shadow: var(--r2-shadow); +} + +#screenerView .curated-library-pane { + display: flex; + flex-direction: column; + padding: 0; +} #screenerView .curated-library-heading { min-height: 58px; display: flex; align-items: center; padding: 9px 14px; - border: 1px solid var(--r2-line); - border-radius: 10px 10px 0 0; + border-bottom: 1px solid var(--r2-line-soft); background: #fff; } #screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; } -#screenerView .curated-library-heading h3 { display: inline; margin: 0 9px 0 0; font-size: 14px; } -#screenerView .curated-library-heading p { display: inline; margin: 0; color: var(--r2-faint); font-size: 10.5px; } +#screenerView .curated-library-heading h3 { margin: 1px 0 0; font-size: 14px; } #screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; } #screenerView .curated-library-controls { - min-height: 50px; - display: flex; + min-height: 48px; + display: grid; + grid-template-columns: minmax(0, 1fr) 104px 62px; align-items: center; - gap: 12px; - margin: 0 0 12px; - padding: 8px 14px; + gap: 7px; + padding: 7px 10px; + border-bottom: 1px solid var(--r2-line-soft); + background: #fafbfc; +} + +#screenerView .curated-view-toggle { + min-height: 32px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 2px; border: 1px solid var(--r2-line); - border-top: 0; - border-radius: 0 0 10px 10px; + border-radius: 7px; background: #fff; } +#screenerView .curated-view-toggle button { + min-width: 0; + display: grid; + place-items: center; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--r2-faint); + cursor: pointer; +} + +#screenerView .curated-view-toggle button.active { background: var(--scr-blue-soft); color: var(--scr-blue); } +#screenerView .curated-view-toggle button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; } +#screenerView .curated-view-toggle .lucide { width: 13px; height: 13px; } + +#screenerView .curated-school-filters { + min-height: 38px; + display: flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + overflow-x: auto; + border-bottom: 1px solid var(--r2-line-soft); + background: #fff; + scrollbar-width: none; +} + +#screenerView .curated-school-filters::-webkit-scrollbar { display: none; } +#screenerView .curated-school-filters button { + min-height: 26px; + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + padding: 0 7px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--r2-sub); + font-size: 10px; + cursor: pointer; +} + +#screenerView .curated-school-filters button small { color: var(--r2-faint); font-size: 8.5px; font-variant-numeric: tabular-nums; } +#screenerView .curated-school-filters button:hover { background: var(--scr-blue-soft); color: var(--scr-blue); } +#screenerView .curated-school-filters button.active { border-color: #c5d4f1; background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; } +#screenerView .curated-school-filters button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; } + #screenerView .curated-search { - width: 240px; + width: 100%; min-height: 32px; display: flex; align-items: center; @@ -5266,51 +5342,176 @@ body.sidebar-collapsed .status-bar { left: 64px; } #screenerView .curated-search .lucide { width: 14px; color: var(--r2-faint); } #screenerView .curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; font-size: 11.5px; } -#screenerView .curated-category-filters { display: flex; gap: 5px; overflow-x: auto; } -#screenerView .curated-category-filters button { min-height: 28px; padding: 0 10px; border: 1px solid var(--r2-line); border-radius: 6px; background: #fff; color: var(--r2-sub); font-size: 10.5px; white-space: nowrap; } -#screenerView .curated-category-filters button.active { border-color: var(--scr-blue); background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; } -#screenerView .curated-strategy-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); - gap: 12px; - padding: 0; +#screenerView .curated-category-select { + position: relative; + min-height: 32px; + display: flex; + align-items: center; } -#screenerView .curated-strategy-card { - min-height: 158px; +#screenerView .curated-category-select select { + width: 100%; + min-height: 32px; + padding: 0 27px 0 9px; + border: 1px solid var(--r2-line); + border-radius: 7px; + appearance: none; + background: #fff; + color: var(--r2-sub); + font-size: 10.5px; +} + +#screenerView .curated-category-select .lucide { + position: absolute; + right: 8px; + width: 13px; + pointer-events: none; + color: var(--r2-faint); +} + +#screenerView .curated-strategy-list { + min-height: 352px; + max-height: 532px; display: flex; flex-direction: column; - gap: 8px; - padding: 13px 15px; - border: 1px solid var(--r2-line); - border-radius: 10px; - background: #fff; - box-shadow: var(--r2-shadow); - transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; + gap: 6px; + overflow-y: auto; + padding: 8px; + scrollbar-gutter: stable; +} + +#screenerView .curated-strategy-icon { display: none; } + +#screenerView .curated-strategy-list.is-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; +} + +#screenerView .curated-strategy-list.is-grid .curated-strategy-card { + min-height: 124px; + align-items: center; + justify-content: center; + gap: 7px; + text-align: center; +} + +#screenerView .curated-strategy-list.is-grid .curated-strategy-card:hover { transform: translateY(-2px); } +#screenerView .curated-strategy-list.is-grid .curated-strategy-card.active { box-shadow: inset 0 3px var(--scr-blue); } +#screenerView .curated-strategy-list.is-grid .curated-strategy-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 7px; + background: var(--scr-blue-soft); + color: var(--scr-blue); +} +#screenerView .curated-strategy-list.is-grid .curated-strategy-icon .lucide { width: 16px; height: 16px; } +#screenerView .curated-strategy-list.is-grid .curated-card-head { width: 100%; grid-template-columns: minmax(0, 1fr); gap: 4px; } +#screenerView .curated-strategy-list.is-grid .curated-strategy-rank, +#screenerView .curated-strategy-list.is-grid .curated-card-tags { display: none; } +#screenerView .curated-strategy-list.is-grid .curated-card-result { justify-self: center; } + +#screenerView .curated-strategy-card { + min-height: 70px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 0 0 auto; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: 7px; + background: #f8fafc; + box-shadow: none; + cursor: pointer; + transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease; } #screenerView .curated-strategy-card:hover { border-color: #b7c9ee; - transform: translateY(-2px); - box-shadow: 0 5px 16px rgba(37, 99, 235, .08); + background: #f4f7fd; + transform: translateX(2px); } -#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: #fbfdff; box-shadow: inset 0 3px var(--scr-blue), var(--r2-shadow); } -#screenerView .curated-card-head { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 8px; } -#screenerView .curated-strategy-rank { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 5px; background: #f1f3f6; color: var(--r2-sub); font-size: 9.5px; font-style: normal; } +#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: var(--scr-blue-soft); box-shadow: inset 3px 0 var(--scr-blue); } +#screenerView .curated-card-head { display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 8px; } +#screenerView .curated-strategy-rank { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 5px; background: #eef1f5; color: var(--r2-sub); font-size: 9px; font-style: normal; } #screenerView .curated-card-head strong, #screenerView .curated-card-head small { display: block; } -#screenerView .curated-card-head strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } -#screenerView .curated-card-head small { margin-top: 2px; color: var(--r2-faint); font-size: 9.5px; } +#screenerView .curated-card-head strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +#screenerView .curated-card-head small { margin-top: 1px; color: var(--r2-faint); font-size: 9px; } +#screenerView .curated-card-result { padding: 2px 6px; border-radius: 4px; background: #eef1f5; color: var(--r2-faint); font-size: 9px; font-style: normal; white-space: nowrap; } +#screenerView .curated-card-result.ready { background: var(--scr-green-soft); color: var(--scr-green); } +#screenerView .curated-card-result.quiet { background: var(--scr-blue-soft); color: var(--scr-blue); } +#screenerView .curated-card-result.missing { background: var(--scr-amber-soft); color: var(--scr-amber); } #screenerView .curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; } #screenerView .curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--scr-blue-soft); color: var(--scr-blue); font-size: 9px; font-style: normal; } #screenerView .curated-card-tags em:nth-child(2) { background: #f3f4f6; color: var(--r2-sub); } #screenerView .curated-card-tags em:nth-child(3) { background: var(--scr-amber-soft); color: var(--scr-amber); } -#screenerView .curated-card-description { flex: 1; color: var(--r2-sub); font-size: 10.5px; line-height: 1.65; } -#screenerView .curated-card-foot { min-height: 35px; display: flex; align-items: center; gap: 7px; padding-top: 7px; border-top: 1px solid var(--r2-line-soft); } -#screenerView .curated-card-foot small { color: var(--r2-faint); font-size: 9.5px; } -#screenerView .curated-card-actions { display: flex; gap: 5px; margin-left: auto; } + +#screenerView .curated-detail-pane { + display: flex; + flex-direction: column; + padding: 14px 16px 0; +} + +#screenerView .curated-detail-header { gap: 14px; padding-bottom: 11px; } +#screenerView .curated-detail-header h3 { font-size: 16px; } +#screenerView .curated-detail-header p { max-width: 650px; margin-top: 5px; font-size: 11px; line-height: 1.55; } +#screenerView .curated-strategy-badges span { min-height: 24px; padding: 0 7px; font-size: 10px; } + +#screenerView .curated-environment-notes { + display: grid; + gap: 5px; + padding: 10px 0; + border-bottom: 1px solid var(--r2-line-soft); +} + +#screenerView .curated-environment-notes p { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 8px; + margin: 0; + color: var(--r2-sub); + font-size: 10.5px; + line-height: 1.55; +} + +#screenerView .curated-environment-notes strong { color: var(--scr-green); } +#screenerView .curated-environment-notes p:last-child strong { color: var(--scr-amber); } + +#screenerView .curated-health-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 0 -16px; + border-bottom: 1px solid var(--r2-line-soft); + background: #fafbfc; +} + +#screenerView .curated-health-grid > div { min-width: 0; padding: 8px 12px; border-right: 1px solid var(--r2-line-soft); } +#screenerView .curated-health-grid > div:last-child { border-right: 0; } +#screenerView .curated-health-grid span, +#screenerView .curated-health-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#screenerView .curated-health-grid span { color: var(--r2-faint); font-size: 9px; } +#screenerView .curated-health-grid strong { margin-top: 2px; color: var(--r2-ink); font-size: 11.5px; font-variant-numeric: tabular-nums; } +#screenerView .curated-health-grid strong.ready { color: var(--scr-green); } +#screenerView .curated-health-grid strong.quiet { color: var(--scr-blue); } +#screenerView .curated-health-grid strong.missing { color: var(--scr-amber); } + +#screenerView .curated-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 12px 0; } +#screenerView .curated-condition-section { min-width: 0; } +#screenerView .curated-rule-list, +#screenerView .curated-score-list { margin-top: 6px; } +#screenerView .curated-rule-row { min-height: 34px; padding: 5px 4px; font-size: 10.5px; } +#screenerView .curated-rule-row span, +#screenerView .curated-rule-row strong { font-size: 10.5px; } +#screenerView .curated-score-row { min-height: 31px; grid-template-columns: minmax(90px, 1fr) minmax(80px, 1.25fr) 38px; gap: 7px; font-size: 10px; } +#screenerView .curated-execution-bar { min-height: 48px; margin: auto -16px 0; padding: 7px 12px; border-top: 1px solid var(--r2-line-soft); background: #fafbfc; } +#screenerView .curated-data-status > .lucide { width: 16px; height: 16px; } +#screenerView .curated-data-status strong { font-size: 11px; } +#screenerView .curated-data-status small { margin-top: 1px; font-size: 9.5px; } /* Quant workspace. */ #screenerView .quant-screener-panel { @@ -5418,16 +5619,13 @@ body.sidebar-collapsed .status-bar { left: 64px; } #screenerView .quant-validation-message { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; } #screenerView .quant-validation-message.error { color: var(--scr-amber); } -#screenerView .curated-detail-dialog { width: min(850px, calc(100vw - 30px)); max-height: min(760px, calc(100dvh - 30px)); padding: 0; overflow: visible; border: 0; border-radius: 11px; background: transparent; } -#screenerView .curated-detail-dialog::backdrop, #screenerView .strategy-drawer::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); } -#screenerView .curated-detail-pane { position: relative; max-height: min(760px, calc(100dvh - 30px)); overflow-y: auto; border-radius: 11px; background: #fff; } @media (max-width: 1180px) { #screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } #screenerView .screener-overview-grid, #screenerView .quant-screener-panel { grid-template-columns: 1fr; } - #screenerView .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); } + #screenerView .curated-workspace { grid-template-columns: minmax(310px, .82fr) minmax(480px, 1.3fr); } } @media (max-width: 820px) { @@ -5443,9 +5641,10 @@ body.sidebar-collapsed .status-bar { left: 64px; } #screenerView .screener-pipeline-status { margin-left: 0; } #screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; } #screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; } - #screenerView .curated-strategy-list { grid-template-columns: 1fr 1fr; } - #screenerView .curated-library-controls { align-items: stretch; flex-direction: column; } - #screenerView .curated-search { width: 100%; } + #screenerView .curated-workspace { grid-template-columns: 1fr; } + #screenerView .curated-strategy-list { min-height: 0; max-height: 310px; } + #screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + #screenerView .curated-detail-pane { min-height: 430px; } } @media (max-width: 560px) { @@ -5461,9 +5660,14 @@ body.sidebar-collapsed .status-bar { left: 64px; } #screenerView .result-toolbar .section-subtitle { margin-left: 0; } #screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); } #screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); } - #screenerView .curated-strategy-list { grid-template-columns: 1fr; } #screenerView .curated-library-heading { align-items: flex-start; } - #screenerView .curated-library-heading p { display: block; margin-top: 2px; } + #screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 104px 62px; } + #screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + #screenerView .curated-health-grid { grid-template-columns: 1fr 1fr; } + #screenerView .curated-health-grid > div:nth-child(2) { border-right: 0; } + #screenerView .curated-health-grid > div:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line-soft); } + #screenerView .curated-detail-grid { grid-template-columns: 1fr; } + #screenerView .curated-execution-bar { align-items: flex-start; flex-direction: column; } #screenerView .quant-universe-grid, #screenerView .quant-formula-summary { grid-template-columns: 1fr; } #screenerView .quant-st-toggle { grid-column: auto; } @@ -7911,10 +8115,13 @@ body.sidebar-collapsed .status-bar { left: 64px; } .settings-dialog:not(.heaven-reading-dialog) { width: min(740px, calc(100vw - 28px)); max-height: min(820px, calc(100dvh - 28px)); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; } + +.settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; } .settings-dialog:not(.heaven-reading-dialog) .settings-section { padding: 18px 20px; border-color: var(--dialog-line); @@ -8031,7 +8238,10 @@ body.sidebar-collapsed .status-bar { left: 64px; } .admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; } /* Editing dialogs share sensible proportions without changing their fields. */ -.settings-dialog.trade-log-dialog { width: min(880px, calc(100vw - 28px)); } +.settings-dialog.trade-log-dialog { + width: min(880px, calc(100vw - 28px)); + max-height: min(760px, calc(100dvh - 28px)); +} .trade-log-dialog .trade-log-form { padding: 18px 20px 20px; background: #fafbfc; } .trade-log-dialog .trade-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; } .trade-log-dialog .trade-tags-field { grid-column: span 2; } @@ -8086,6 +8296,8 @@ body.sidebar-collapsed .status-bar { left: 64px; } } @media (max-width: 460px) { + #screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 62px; } + #screenerView .curated-search { grid-column: 1 / -1; } .global-search-dialog { margin-top: 8px; } .global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; } .global-search-head kbd { display: none; } @@ -8337,3 +8549,94 @@ body.sidebar-collapsed .status-bar { left: 64px; } white-space: normal; } } + +/* Automatic screening and the manual custom-formula workspace. */ +#screenerView .screener-auto-note { + color: var(--r2-faint); + font-size: 12px; + line-height: 1.6; +} + +#screenerView .regime-selector .regime-option { + cursor: default; +} + +#screenerView .quant-screener-panel { + grid-template-columns: minmax(360px, .88fr) minmax(0, 1.12fr); + align-items: start; +} + +#screenerView .custom-screener-tools, +#screenerView .custom-results-slot { + grid-column: 1 / -1; + min-width: 0; +} + +#screenerView .custom-screener-tools { + min-height: 68px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid var(--r2-line); + border-radius: var(--r2-radius); + background: var(--r2-card); +} + +#screenerView .custom-screener-tools > div:first-child { + min-width: 0; + display: grid; + gap: 3px; +} + +#screenerView .custom-screener-tools span, +#screenerView .custom-screener-tools small { + color: var(--r2-faint); + font-size: 11.5px; +} + +#screenerView .custom-screener-tools strong { + color: var(--r2-ink); + font-size: 14px; +} + +#screenerView .custom-screener-tools > div:last-child { + flex: 0 0 auto; + display: flex; + gap: 8px; +} + +#screenerView .custom-results-slot .screener-results-view { + margin: 0; +} + +#screenerView .screener-result-frame { + overflow-x: auto; + scrollbar-gutter: stable; +} + +#screenerView .screener-result-frame .data-table { + width: 100%; + min-width: 1180px; + table-layout: fixed; +} + +#screenerView .screener-result-columns col:nth-child(1) { width: 54px; } +#screenerView .screener-result-columns col:nth-child(2) { width: 122px; } +#screenerView .screener-result-columns col:nth-child(3) { width: 104px; } +#screenerView .screener-result-columns col:nth-child(4) { width: 78px; } +#screenerView .screener-result-columns col:nth-child(5) { width: 104px; } +#screenerView .screener-result-columns col:nth-child(6), +#screenerView .screener-result-columns col:nth-child(7), +#screenerView .screener-result-columns col:nth-child(8), +#screenerView .screener-result-columns col:nth-child(9) { width: 82px; } +#screenerView .screener-result-columns col:nth-child(10) { width: 210px; } +#screenerView .screener-result-columns col:nth-child(11) { width: 150px; } +#screenerView .screener-result-columns col:nth-child(12) { width: 142px; } + +@media (max-width: 980px) { + #screenerView .quant-screener-panel { grid-template-columns: 1fr; } + #screenerView .custom-screener-tools { align-items: stretch; flex-direction: column; } + #screenerView .custom-screener-tools > div:last-child { flex-wrap: wrap; } +} diff --git a/static/styles.css b/static/styles.css index 780b300..50a1e03 100644 --- a/static/styles.css +++ b/static/styles.css @@ -3983,20 +3983,29 @@ dialog::backdrop { to { transform: rotate(360deg); } } -.toast { +#toast.toast { position: fixed; + top: auto; + left: auto; right: 18px; bottom: 48px; z-index: 60; + width: max-content; + height: auto; max-width: min(420px, calc(100vw - 36px)); padding: 11px 14px; border-radius: 4px; background: #21313c; color: #fff; box-shadow: var(--shadow); + opacity: 1; + pointer-events: none; + transform: none; animation: toast-enter var(--motion-medium) var(--ease-out) both; } +#toast.toast[hidden] { display: none; } + @keyframes toast-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } @@ -5147,10 +5156,15 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { display: none; } - .toast { + #toast.toast { + top: auto; + left: auto; right: 10px; bottom: 76px; + width: max-content; + height: auto; max-width: calc(100vw - 20px); + transform: none; } } diff --git a/static/theme.css b/static/theme.css index a53b9d4..db4c3ce 100644 --- a/static/theme.css +++ b/static/theme.css @@ -568,6 +568,54 @@ background: var(--action-soft); } +:root[data-theme="dark"] #screenerView .curated-detail-pane { + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is(.curated-detail-header, .curated-execution-bar) { + border-color: var(--line-soft); +} + +:root[data-theme="dark"] #screenerView :is( + .curated-detail-header h3, + .mini-section-heading h4, + .curated-rule-row strong, + .curated-score-row strong, + .curated-data-status strong +) { + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is( + .curated-detail-header > div > span, + .curated-detail-header p, + .mini-section-heading > span, + .curated-rule-row span, + .curated-score-row > span:first-child, + .curated-data-status small +) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView .curated-rule-row { + border-color: var(--line-soft); +} + +:root[data-theme="dark"] #screenerView .curated-score-track { + background: var(--surface-muted); +} + +:root[data-theme="dark"] #screenerView .curated-strategy-badges span { + background: var(--surface-muted); + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView .curated-strategy-badges span:first-child { + background: var(--action-soft); + color: var(--action); +} + :root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) { border-color: var(--line-soft); background: var(--surface-muted); @@ -1030,10 +1078,11 @@ } :root[data-theme="dark"] #screenerView :is( + .curated-library-pane, .curated-library-heading, .curated-library-controls, .curated-search, - .curated-category-filters button, + .curated-category-select select, .curated-strategy-card, .curated-strategy-rank, .curated-card-tags em, @@ -1052,7 +1101,6 @@ } :root[data-theme="dark"] #screenerView :is( - .curated-category-filters button.active, .curated-strategy-card.active, .curated-card-tags em, .curated-strategy-rank, @@ -1061,6 +1109,30 @@ background: var(--surface-muted); } +:root[data-theme="dark"] #screenerView .curated-strategy-card:hover { + border-color: var(--blue-line); + background: var(--surface-subtle); +} + +:root[data-theme="dark"] #screenerView .curated-strategy-card.active { + border-color: var(--blue-line); + background: var(--action-soft); +} + +:root[data-theme="dark"] #screenerView :is(.curated-health-grid, .curated-execution-bar) { + border-color: var(--line-soft); + background: var(--surface-muted); +} + +:root[data-theme="dark"] #screenerView .curated-health-grid > div { + border-color: var(--line-soft); +} + +:root[data-theme="dark"] #screenerView .curated-search input { + background: transparent; + color: var(--text-primary); +} + :root[data-theme="dark"] #screenerView :is( .quant-rule-row select, .quant-rule-row input, @@ -1257,6 +1329,44 @@ color: var(--text-secondary); } +/* High-specificity dark surfaces for workspaces with later light-theme hover rules. */ +:root[data-theme="dark"] #screenerView .curated-library-heading h3 { + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is(.curated-view-toggle, .curated-school-filters) { + border-color: var(--border); + background: var(--surface); + color: var(--text-primary); +} + +:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button) { + color: var(--text-secondary); +} + +:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button):is(:hover, .active) { + border-color: var(--blue-line); + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #screenerView .curated-strategy-list.is-grid .curated-strategy-icon { + background: var(--action-soft); + color: var(--action); +} + +:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td, +:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td:last-child, +:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover, +:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover td { + background: var(--action-soft) !important; + color: var(--text-primary); +} + +:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody td { + color: var(--text-primary); +} + /* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former paper-theme overrides here would repaint its controls and ritual stages. */ diff --git a/static/wentian-v2.css b/static/wentian-v2.css index 4256201..e1fab63 100644 --- a/static/wentian-v2.css +++ b/static/wentian-v2.css @@ -614,8 +614,6 @@ button { cursor: pointer; } .login-dialog form { display: grid; gap: 14px; padding: 26px; } .login-dialog h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; } .login-dialog .button { width: 100%; margin-top: 4px; } -.toast { position: fixed; right: 20px; bottom: 20px; z-index: 100; padding: 10px 14px; border: 1px solid var(--wt-line); border-radius: 7px; background: rgba(13,21,38,.96); color: var(--wt-paper); box-shadow: 0 12px 30px rgba(0,0,0,.3); font-size: 12px; } - @media (max-width: 960px) { .heaven-shell { width: min(100% - 20px, 760px); } .heaven-controls { align-items: stretch; flex-direction: column; } diff --git a/strategy_tracking.py b/strategy_tracking.py index 8be8b99..926545c 100644 --- a/strategy_tracking.py +++ b/strategy_tracking.py @@ -23,6 +23,8 @@ class StrategyTrackingService: def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]: run = self.database.get_screener_run(user_id, run_id) + if not run: + run = self.database.get_screener_run(0, run_id) if not run: raise ValueError("选股结果不存在或不属于当前账号。") normalized_code = str(code or "").strip().split(".")[0] diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index ce7bbf8..558ccaf 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -1433,7 +1433,8 @@ test("regular account cannot see admin controls and member features are gated", await expect(page.locator("#accountVipLabel")).toHaveText("非会员"); await page.locator('[data-view="screenerView"]').first().click(); await expect(page.locator("#screenerView .member-gate")).toBeVisible(); - await expect(page.locator("#screenerRunButton")).toBeDisabled(); + await page.locator('[data-screener-mode="quant"]').click(); + await expect(page.locator("#quantRunButton")).toBeDisabled(); await page.locator("#assistantButton").click(); await expect(page.locator("#settingsDialog")).toBeHidden(); await expect(page.locator("#assistantDialog")).toBeVisible(); @@ -1817,7 +1818,8 @@ test("curated strategies and quant builder form independent screener workspaces" await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1); await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1); - await expect(page.locator("#curatedRunButton")).toBeEnabled(); + await expect(page.locator("#curatedRunButton")).toHaveCount(0); + await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden(); @@ -1958,32 +1960,35 @@ test("screener redesign preserves three clear workspaces across desktop and mobi }); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2); - await expect(page.locator("#curatedDetailDialog")).toBeHidden(); + await expect(page.locator(".curated-detail-pane")).toBeVisible(); const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]'); - await secondStrategy.locator(".curated-card-description").click(); + await secondStrategy.click(); await expect(secondStrategy).toHaveClass(/active/); await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/); - await secondStrategy.locator('[data-curated-inspect="4"]').click(); - await expect(page.locator("#curatedDetailDialog")).toBeVisible(); - const dialogBox = await page.locator("#curatedDetailDialog").boundingBox(); - expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2); - expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2); - await page.locator("#closeCuratedDetailButton").click(); + await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量"); + const [libraryBox, detailBox] = await Promise.all([ + page.locator(".curated-library-pane").boundingBox(), + page.locator(".curated-detail-pane").boundingBox(), + ]); + expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2); + expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1); + expect(libraryBox.width).toBeLessThan(detailBox.width); + await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4); await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true }); await page.locator('[data-screener-mode="quant"]').click(); await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0); await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0); - await expect(page.locator("#screenerResultTitle")).toHaveText("打分结果"); + await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果"); const [builderBox, summaryBox] = await Promise.all([ page.locator(".quant-builder-pane").boundingBox(), page.locator(".quant-summary-pane").boundingBox(), ]); expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1); expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2); - expect(builderBox.width).toBeGreaterThanOrEqual(395); - expect(builderBox.width).toBeLessThanOrEqual(405); + expect(builderBox.width).toBeGreaterThanOrEqual(490); + expect(builderBox.width).toBeLessThanOrEqual(540); const quantRunBox = await page.locator("#quantRunButton").boundingBox(); expect(quantRunBox.width).toBeLessThan(180); expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225); @@ -1996,7 +2001,7 @@ test("screener redesign preserves three clear workspaces across desktop and mobi await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible(); }); -test("screener stage completion follows its execution context and mode results stay isolated", async ({ page }) => { +test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockApplication(page, session("user", true)); await page.goto("/index.html"); @@ -2024,15 +2029,11 @@ test("screener stage completion follows its execution context and mode results s await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3); await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); - await page.locator('[data-regime="retreat"]').click(); - await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2); - await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行"); - await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行"); - await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略"); - await expect(page.locator("#screenerResultSource")).toBeHidden(); - - await page.locator('[data-regime="repair"]').click(); - await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); + await expect(page.locator('[data-regime]')).toHaveCount(0); + await expect(page.locator("#screenerRunButton")).toHaveCount(0); + await expect(page.locator("#syncScreenerButton")).toHaveCount(0); + await expect(page.locator("#changeStrategyButton")).toHaveCount(0); + await expect(page.locator("#editStrategyButton")).toHaveCount(0); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerEmpty")).toContainText("所选策略"); await page.evaluate(() => { @@ -2044,10 +2045,10 @@ test("screener stage completion follows its execution context and mode results s }); await expect(page.locator("#screenerTableBody")).toContainText("策略结果"); await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果"); - await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 修复 · 连续分红质量"); + await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量"); await page.locator('[data-screener-mode="quant"]').click(); - await expect(page.locator("#screenerEmpty")).toContainText("量化选股"); + await expect(page.locator("#screenerEmpty")).toContainText("自定义选股"); await page.evaluate(() => { setScreenerResult("quant", { meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" }, @@ -2056,7 +2057,7 @@ test("screener stage completion follows its execution context and mode results s renderScreenerResult(); }); await expect(page.locator("#screenerTableBody")).toContainText("量化结果"); - await expect(page.locator("#screenerResultSource")).toHaveText("量化选股 · 自定义因子权重"); + await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重"); await page.locator('[data-screener-mode="smart"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); @@ -2064,20 +2065,34 @@ test("screener stage completion follows its execution context and mode results s await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果"); }); -test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => { +test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => { const formula = { meta: { library: "smart" }, universe: {}, filters: [], score: [{ field: "relative_strength", weight: 1, direction: "desc" }], limit: 10, min_score: 0.5, }; + const candidate = (code, name) => ({ + code, name, sector: "Test Sector", score_display: 80, + historical_probability: 50, probability_samples: 20, pct_chg: 1, + return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70, + reason: "Context result", risk_flags: [], + }); + const result = (mode, runId, strategyName, row) => ({ + meta: { + run_id: runId, trade_date: "20260722", regime: "repair", + strategy_name: strategyName, mode, + }, + candidates: [row], + disclaimer: "Historical statistics do not predict future returns.", + backtest: null, + }); + const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair")); + const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A")); + const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B")); const options = { - recentScreenerResults: [], - additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }], + latestScreenerResults: { smart: smartResult, curated: curatedA }, + recentScreenerResults: [smartResult, curatedA, curatedB], additionalScreenerStrategies: [ - { - id: 3, name: "Retreat Defense", description: "Retreat-stage strategy", - regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula, - }, { id: 4, name: "Quality B", description: "Second curated strategy", regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], @@ -2088,66 +2103,27 @@ test("screener keeps results for each stage and curated strategy across switchin }, ], }; - options.screenerRunResult = (body) => { - const candidateName = body.mode === "smart" - ? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair" - : body.strategy_name === "Quality B" ? "Curated B" : "Curated A"; - return { - meta: { - run_id: 100 + options.recentScreenerResults.length, - trade_date: "20260722", - regime: body.regime, - strategy_name: body.strategy_name, - mode: body.mode, - }, - candidates: [{ - code: `60000${options.recentScreenerResults.length + 1}`, - name: candidateName, - sector: "Test Sector", - score_display: 80, - historical_probability: 50, - probability_samples: 20, - pct_chg: 1, - return_5d: 2, - volume_ratio_5d: 1.2, - sector_strength: 70, - reason: "Context result", - risk_flags: [], - }], - disclaimer: "Historical statistics do not predict future returns.", - backtest: null, - }; - }; await mockApplication(page, session("user", true), options); await page.goto("/index.html?view=screenerView"); - await page.locator("#screenerRunButton").click(); await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); - await page.locator('[data-regime="retreat"]').click(); - await page.locator("#screenerRunButton").click(); - await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); - await page.locator('[data-regime="repair"]').click(); - await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); - await page.locator('[data-regime="retreat"]').click(); - await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); + await expect(page.locator("#screenerRunButton")).toHaveCount(0); await page.locator('[data-screener-mode="curated"]').click(); - await page.locator('[data-curated-run="2"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); - await page.locator('[data-curated-run="4"]').click(); + await page.locator('[data-curated-strategy="4"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); - await page.locator('[data-curated-strategy="2"] .curated-card-description').click(); + await page.locator('[data-curated-strategy="2"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); + expect(options.screenerRunBodies || []).toHaveLength(0); await page.reload(); await page.locator('[data-screener-mode="smart"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); - await page.locator('[data-regime="retreat"]').click(); - await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat"); await page.locator('[data-screener-mode="curated"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); - await page.locator('[data-curated-strategy="4"] .curated-card-description').click(); + await page.locator('[data-curated-strategy="4"]').click(); await expect(page.locator("#screenerTableBody")).toContainText("Curated B"); }); diff --git a/tests/test_curated_screener.py b/tests/test_curated_screener.py index ac1db6e..93889a7 100644 --- a/tests/test_curated_screener.py +++ b/tests/test_curated_screener.py @@ -1,26 +1,78 @@ import sqlite3 import tempfile import unittest +from datetime import datetime, timedelta from pathlib import Path from database import ReviewDatabase from screener import ( + ADVANCED_CURATED_STRATEGIES, CURATED_STRATEGIES, FACTOR_FIELDS, FACTOR_GROUPS, ScreenerEngine, + _broken_reversal_metrics, + _risk_flags, + _rsi, _quarter_periods, ) +from server import automatic_screener_jobs class CuratedScreenerTests(unittest.TestCase): - def test_first_batch_contains_ten_distinct_curated_strategies(self): - self.assertEqual(10, len(CURATED_STRATEGIES)) - self.assertEqual(10, len({item["name"] for item in CURATED_STRATEGIES})) + def test_curated_library_contains_original_and_advanced_strategies(self): + self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES)) + self.assertEqual(23, len(CURATED_STRATEGIES)) + self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES})) + self.assertTrue( + {"行业动量轮动", "主力资金行业流入"}.issubset( + {item["name"] for item in CURATED_STRATEGIES} + ) + ) self.assertTrue( all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES) ) + def test_every_curated_strategy_explains_environment_and_failure_risk(self): + for strategy in CURATED_STRATEGIES: + meta = strategy["formula"]["meta"] + self.assertTrue(meta.get("suitable_environment"), strategy["name"]) + self.assertTrue(meta.get("failure_risk"), strategy["name"]) + self.assertNotIn("emotion_gate", meta, strategy["name"]) + + def test_automatic_curated_jobs_are_not_filtered_by_market_regime(self): + strategies = [ + { + "name": "阶段策略", + "regimes": ["retreat"], + "formula": {"meta": {"library": "stage"}}, + }, + *CURATED_STRATEGIES, + ] + for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"): + jobs = automatic_screener_jobs(strategies, regime) + curated_names = { + job["strategy"]["name"] for job in jobs if job["mode"] == "curated" + } + self.assertEqual( + {strategy["name"] for strategy in CURATED_STRATEGIES}, + curated_names, + regime, + ) + + def test_curated_risk_flags_do_not_reintroduce_regime_gating(self): + row = { + "pct_chg": 0, + "return_10d": 0, + "volatility_10d": 0, + "amount_billion": 5, + } + self.assertIn("市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat")) + self.assertNotIn( + "市场处于退潮阶段,策略可能选择空仓", + _risk_flags(row, "retreat", include_regime_risk=False), + ) + def test_every_curated_formula_uses_supported_factors(self): with tempfile.TemporaryDirectory() as root: database = ReviewDatabase(Path(root) / "review.db") @@ -56,6 +108,15 @@ class CuratedScreenerTests(unittest.TestCase): connection.close() self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns)) self.assertIn("fundamental_indicators", tables) + self.assertIn("benchmark_bars", tables) + + def test_advanced_strategies_declare_history_and_backtest_contracts(self): + for strategy in ADVANCED_CURATED_STRATEGIES: + meta = strategy["formula"]["meta"] + self.assertGreaterEqual(meta["history_days"], 80, strategy["name"]) + self.assertGreaterEqual(meta["backtest_days"], 1, strategy["name"]) + self.assertGreater(meta["take_profit"], 0, strategy["name"]) + self.assertLess(meta["stop_loss"], 0, strategy["name"]) def test_quarter_periods_stop_at_selected_date(self): periods = _quarter_periods("20260722", 5) @@ -98,6 +159,10 @@ class CuratedScreenerTests(unittest.TestCase): for index in range(100) ], ) + connection.executemany( + "INSERT INTO benchmark_bars (trade_date, ts_code, close) VALUES (?, ?, ?)", + [(f"2026{index + 1:04d}", "000300.SH", 4000 + index) for index in range(60)], + ) health = database.factor_health_summary("20260722") self.assertTrue(health["market"]) @@ -105,10 +170,190 @@ class CuratedScreenerTests(unittest.TestCase): self.assertTrue(health["valuation"]) self.assertTrue(health["fundamental"]) self.assertTrue(health["dividend_history"]) + self.assertTrue(health["benchmark"]) self.assertEqual(health["valuation_rows"], 1) self.assertEqual(health["fundamental_rows"], 100) self.assertEqual(health["dividend_years"], 5) + def test_moneyflow_health_requires_the_latest_five_market_dates(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + dates = [f"202607{day:02d}" for day in range(20, 25)] + database.upsert_daily_bars([ + { + "trade_date": trade_date, "ts_code": "600000.SH", + "open": 10, "high": 10.2, "low": 9.8, "close": 10, + "pct_chg": 0, "vol": 1000, "amount": 100000, + } + for trade_date in dates + ]) + database.upsert_moneyflow([ + {"trade_date": "20260105", "ts_code": "600000.SH", "net_mf_amount": 10} + ] * 5) + self.assertFalse(database.factor_health_summary(dates[-1])["moneyflow_history"]) + database.upsert_moneyflow([ + {"trade_date": trade_date, "ts_code": "600000.SH", "net_mf_amount": 10} + for trade_date in dates + ]) + health = database.factor_health_summary(dates[-1]) + self.assertTrue(health["moneyflow_history"]) + self.assertEqual(health["moneyflow_dates"], 5) + + def test_technical_helpers_detect_rsi_and_daily_reversal_path(self): + self.assertLess(_rsi([10, 9, 8, 7, 6, 5, 4], 6), 1) + rows = [ + {"close": 10, "high": 10, "vol": 100}, + {"close": 11, "high": 11, "vol": 120}, + {"close": 12, "high": 12, "vol": 130}, + {"close": 11.2, "high": 11.8, "vol": 100}, + {"close": 12.5, "high": 12.5, "vol": 140}, + ] + metrics = _broken_reversal_metrics( + rows, [False, True, True, False, True], "600000", "示例" + ) + self.assertEqual(metrics["signal"], 1) + self.assertEqual(metrics["days"], 1) + + def test_factor_builder_generates_long_window_and_benchmark_factors(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + database.upsert_stock_master([ + { + "ts_code": "600000.SH", "name": "趋势样本", "industry": "银行", + "market": "主板", "list_date": "20000101", + } + ]) + dates = [] + cursor = datetime(2025, 6, 1) + while len(dates) < 260: + if cursor.weekday() < 5: + dates.append(cursor.strftime("%Y%m%d")) + cursor += timedelta(days=1) + bars = [] + benchmarks = [] + indicators = [] + for index, trade_date in enumerate(dates): + close = 10 + index * 0.05 + bars.append({ + "trade_date": trade_date, "ts_code": "600000.SH", + "open": close - 0.02, "high": close + 0.08, "low": close - 0.08, + "close": close, "pct_chg": 0.25, "vol": 1000 + index, + "amount": 200000, + }) + benchmarks.append({ + "trade_date": trade_date, "ts_code": "000300.SH", + "close": 4000 + index, "pct_chg": 0.02, + }) + if index >= 250: + indicators.append({ + "trade_date": trade_date, "ts_code": "600000.SH", + "turnover_rate": 2, "volume_ratio": 1, + }) + database.upsert_daily_bars(bars) + database.upsert_benchmark_bars(benchmarks) + database.upsert_daily_indicators(indicators) + + factors, actual_date = ScreenerEngine(database).build_factors( + dates[-1], history_days=260 + ) + + self.assertEqual(actual_date, dates[-1]) + self.assertEqual(len(factors), 1) + factor = factors[0] + self.assertEqual(factor["ma_bull_alignment"], 1) + self.assertEqual(factor["rs_high_120"], 1) + self.assertGreater(factor["momentum_60_5"], 0) + self.assertEqual(factor["momentum_60_5_rank"], 0) + + def test_factor_builder_generates_sector_momentum_and_five_day_flow(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + stocks = [ + ("600001.SH", "动量样本", "电子", 0.16, 180), + ("600002.SH", "对照样本", "银行", 0.02, -40), + ] + database.upsert_stock_master([ + { + "ts_code": code, "name": name, "industry": industry, + "market": "主板", "list_date": "20000101", + } + for code, name, industry, _, _ in stocks + ]) + dates = [] + cursor = datetime(2026, 4, 1) + while len(dates) < 80: + if cursor.weekday() < 5: + dates.append(cursor.strftime("%Y%m%d")) + cursor += timedelta(days=1) + bars = [] + for index, trade_date in enumerate(dates): + for code, _, _, slope, _ in stocks: + close = 10 + index * slope + bars.append({ + "trade_date": trade_date, "ts_code": code, + "open": close - 0.03, "high": close + 0.08, + "low": close - 0.08, "close": close, + "pct_chg": slope, "vol": 1000 + index, + "amount": 300000, + }) + database.upsert_daily_bars(bars) + database.upsert_daily_indicators([ + { + "trade_date": dates[-1], "ts_code": code, + "turnover_rate": 2, "volume_ratio": 1, + "circ_mv": 1000000, "total_mv": 1500000, + } + for code, *_ in stocks + ]) + database.upsert_moneyflow([ + { + "trade_date": trade_date, "ts_code": code, + "net_mf_amount": daily_flow, + } + for trade_date in dates[-5:] + for code, _, _, _, daily_flow in stocks + ]) + + factors, _ = ScreenerEngine(database).build_factors( + dates[-1], history_days=80 + ) + by_code = {item["ts_code"]: item for item in factors} + leader = by_code["600001.SH"] + laggard = by_code["600002.SH"] + self.assertGreater(leader["return_20d"], laggard["return_20d"]) + self.assertEqual(leader["sector_momentum_rank"], 1) + self.assertEqual(laggard["sector_momentum_rank"], 0) + self.assertGreater(leader["net_flow_5d_million"], 0) + self.assertLess(laggard["net_flow_5d_million"], 0) + self.assertEqual(leader["sector_flow_rank"], 1) + + def test_screen_reports_signal_health(self): + with tempfile.TemporaryDirectory() as root: + database = ReviewDatabase(Path(root) / "review.db") + engine = ScreenerEngine(database) + formula = { + "universe": {"exclude_st": True, "listed_days_min": 0}, + "filters": [{"field": "pct_chg", "op": ">", "value": 0}], + "score": [{"field": "amount_billion", "weight": 1, "direction": "desc"}], + "limit": 5, + "min_score": 0, + } + result = engine.screen( + 0, "20260724", formula, "repair", "健康检查", False, + mode="curated", + prepared_factors=[{ + "ts_code": "600000.SH", "code": "600000", "name": "浦发银行", + "sector": "银行", "listed_days": 1000, "pct_chg": 1, + "amount_billion": 5, "price": 10, "return_5d": 1, + "volume_ratio_5d": 1, "sector_strength": 50, + }], + prepared_date="20260724", + ) + health = result["meta"]["health"] + self.assertEqual(health["status"], "normal") + self.assertEqual(health["signal_count"], 1) + self.assertEqual(health["coverage"], 100) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dashboard_cache.py b/tests/test_dashboard_cache.py index 0fd5882..7e60bf4 100644 --- a/tests/test_dashboard_cache.py +++ b/tests/test_dashboard_cache.py @@ -24,6 +24,9 @@ class SnapshotDatabase: def save_data_snapshot(self, kind, cache_key, _source, payload): self.aliases[(kind, cache_key)] = copy.deepcopy(payload) + def save_snapshot(self, _trade_date, _source, payload): + self.snapshot = copy.deepcopy(payload) + def get_latest_real_snapshot(self, _trade_date, strictly_before=False): return copy.deepcopy(self.latest) @@ -43,6 +46,7 @@ class DashboardCacheTests(unittest.TestCase): "sentiment_phase": "retreat", "sentiment_direction": "cooling", "sentiment_components": {}, + "sentiment_engine_version": 2, }, } service = self.service(snapshot) @@ -71,6 +75,7 @@ class DashboardCacheTests(unittest.TestCase): "sentiment_phase": "ice", "sentiment_direction": "cooling", "sentiment_components": {}, + "sentiment_engine_version": 2, }) return payload diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index 7a32216..dd8cfc5 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -114,15 +114,56 @@ class FrontendContractTests(unittest.TestCase): self.assertIn(f'data-screener-mode="{mode}"', self.html) self.assertIn(f'data-screener-panel="{mode}"', self.html) for element_id in ( - "curatedStrategyList", "curatedRunButton", "quantFilterRows", + "curatedStrategyList", "quantFilterRows", "quantScoreRows", "quantRunButton", "quantSaveButton", ): self.assertIn(f'id="{element_id}"', self.html) + for removed_id in ( + "curatedRunButton", "factorSyncButton", "screenerRunButton", + "changeStrategyButton", + ): + self.assertNotIn(f'id="{removed_id}"', self.html) + self.assertIn("盘后自动候选池", self.html) + self.assertIn("自定义选股", self.html) self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html) self.assertIn('id="openStrategyDrawerButton"', self.html) self.assertIn('id="closeStrategyDrawerButton"', self.html) self.assertIn('id="activeStrategyDescription"', self.html) self.assertIn('openStrategyDrawer("editor")', self.script) + for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"): + self.assertIn(f'id="{element_id}"', self.html) + self.assertIn("meta.suitable_environment", self.script) + self.assertIn("meta.failure_risk", self.script) + self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script) + + def test_curated_library_explains_empty_signals_and_supports_school_views(self): + for element_id in ("curatedSchoolFilters", "curatedStrategyList"): + self.assertIn(f'id="{element_id}"', self.html) + for view in ("list", "grid"): + self.assertIn(f'data-curated-view="{view}"', self.html) + for school in ("基本面", "趋势", "短线", "动量"): + self.assertIn(school, self.script) + self.assertIn("curatedStrategyRunState", self.script) + self.assertIn("必需数据已完整,本日没有股票同时满足", self.script) + + def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self): + redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8") + self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign) + self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign) + self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme) + self.assertIn('#reviewWorkspaceView .data-table tbody td', self.theme) + self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme) + + def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self): + styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8") + wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8") + self.assertIn("#toast.toast {", styles) + self.assertIn("top: auto;", styles) + self.assertIn("left: auto;", styles) + self.assertIn("height: auto;", styles) + self.assertIn("#toast.toast[hidden] { display: none; }", styles) + self.assertNotIn(".toast{position:fixed", self.design_system) + self.assertNotRegex(wentian, r"(?m)^\.toast\s*\{") def test_public_knowledge_editors_are_hidden_for_non_admins(self): self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script) diff --git a/tests/test_market_insights.py b/tests/test_market_insights.py index 9f94f96..f7c2e47 100644 --- a/tests/test_market_insights.py +++ b/tests/test_market_insights.py @@ -106,6 +106,20 @@ class MarketInsightsTests(unittest.TestCase): self.assertEqual(payload["amount_history"][-1]["stock_count"], 2) self.assertEqual(payload["focus_rows"][0]["code"], "000001") + def test_auction_amount_history_uses_the_same_a_share_universe_as_summary(self): + self.database.upsert_stock_master([ + {"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"}, + {"ts_code": "688001.SH", "name": "首日上市", "industry": "半导体", "market": "科创板", "list_date": "20260723"}, + ]) + self.database.upsert_auction_factors([ + {"ts_code": "000001.SZ", "trade_date": "20260723", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000}, + {"ts_code": "688001.SH", "trade_date": "20260723", "price": 50, "pre_close": 10, "amount": 150_000_000, "vol": 3_000_000}, + {"ts_code": "159001.SZ", "trade_date": "20260723", "price": 1.1, "pre_close": 1, "amount": 90_000_000, "vol": 90_000_000}, + ]) + history = self.service._auction_amount_history("20260723") + self.assertEqual(history[-1]["stock_count"], 1) + self.assertEqual(history[-1]["amount_billion"], 0.05) + def test_real_limit_price_is_isolated_from_scored_candidates(self): class OnePriceClient(FakeMarketClient): def query(self, api_name, params=None, fields=""): diff --git a/tests/test_sentiment_engine.py b/tests/test_sentiment_engine.py new file mode 100644 index 0000000..c431e8b --- /dev/null +++ b/tests/test_sentiment_engine.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import unittest + +from sentiment_engine import _adaptive_score, _confirmed_phase + + +class SentimentEngineTests(unittest.TestCase): + def test_adaptive_score_uses_latest_250_observations(self): + history = [0.0] * 50 + [100.0] * 250 + self.assertEqual(_adaptive_score(50.0, 50.0, history), 12.5) + + def test_ice_must_repair_before_fermentation(self): + phase, reason = _confirmed_phase( + {"phase": "冰点"}, + score=70, + day_change=45, + systemic_health=70, + profit_score=70, + ecology_score=75, + phase_signal="发酵", + extreme_ice=False, + fermentation_signal_count=2, + ) + self.assertEqual(phase, "修复") + self.assertIn("冰点后", reason) + + def test_repair_requires_continuous_fermentation_confirmation(self): + phase, _ = _confirmed_phase( + {"phase": "修复"}, + score=58, + day_change=5, + systemic_health=55, + profit_score=60, + ecology_score=65, + phase_signal="发酵", + extreme_ice=False, + fermentation_signal_count=1, + ) + self.assertEqual(phase, "修复") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_tracking.py b/tests/test_strategy_tracking.py index 56acf37..bea77d7 100644 --- a/tests/test_strategy_tracking.py +++ b/tests/test_strategy_tracking.py @@ -130,6 +130,29 @@ class StrategyTrackingTests(unittest.TestCase): self.assertTrue(removed["deleted"]) self.assertEqual(removed["tracking"]["batches"], []) + def test_shared_automatic_run_can_be_added_to_private_tracking(self): + run_id = self.database.save_screener_run( + 0, + "20260711", + "repair", + "系统盘后策略", + {}, + { + "meta": {}, + "candidates": [{ + "ts_code": "600000.SH", + "code": "600000", + "name": "浦发银行", + "sector": "银行", + "price": 12.5, + }], + }, + ) + result = self.service.add_candidate(self.other["id"], run_id, "600000") + self.assertEqual(result["added"], 1) + self.assertEqual(len(self.database.list_strategy_tracks(self.other["id"])), 1) + self.assertEqual(self.database.list_strategy_tracks(self.owner["id"]), []) + if __name__ == "__main__": unittest.main() diff --git a/tushare_client.py b/tushare_client.py index 9cc539c..a2f65b0 100644 --- a/tushare_client.py +++ b/tushare_client.py @@ -866,6 +866,10 @@ class TushareClient: deduped[code] = row return list(deduped.values()) + def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]: + """Return constituents active in a Shenwan L2 industry on the target date.""" + return self._sw_sector_members(sector_code, trade_date) + def _stock_listing_reference(self) -> dict[str, dict[str, Any]]: now = datetime.now().astimezone() with self._stock_listing_lock: