feat: expand screeners and stabilize interactive feedback

This commit is contained in:
leefer
2026-07-28 22:47:50 +08:00
parent f4b2d7152a
commit 1cc80583b3
22 changed files with 2707 additions and 509 deletions
+333
View File
@@ -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,
},
},
]
)
+164 -19
View File
@@ -208,6 +208,17 @@ class ReviewDatabase:
CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date
ON daily_bars(ts_code, trade_date DESC); 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 ( CREATE TABLE IF NOT EXISTS daily_indicators (
trade_date TEXT NOT NULL, trade_date TEXT NOT NULL,
ts_code TEXT NOT NULL, ts_code TEXT NOT NULL,
@@ -532,6 +543,7 @@ class ReviewDatabase:
run_columns = { run_columns = {
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)") 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: if "user_id" not in run_columns:
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER") connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
if "mode" not in run_columns: if "mode" not in run_columns:
@@ -561,16 +573,28 @@ class ReviewDatabase:
"UPDATE screener_runs SET mode = ? WHERE id = ?", "UPDATE screener_runs SET mode = ? WHERE id = ?",
(mode, int(run["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"]: if first_user and first_user["id"]:
first_user_id = int(first_user["id"]) first_user_id = int(first_user["id"])
connection.execute( connection.execute(
"UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL", "UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL",
(first_user_id,), (first_user_id,),
) )
connection.execute( if legacy_run_ownership:
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL", connection.execute(
(first_user_id,), "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( connection.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_screener_strategies_user CREATE INDEX IF NOT EXISTS idx_screener_strategies_user
@@ -1276,6 +1300,26 @@ class ReviewDatabase:
) )
return len(values) return len(values)
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
)
for row in rows if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
VALUES (?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
close=excluded.close, pct_chg=excluded.pct_chg
""",
values,
)
return len(values)
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int: def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
values = [ values = [
( (
@@ -1468,6 +1512,10 @@ class ReviewDatabase:
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)", "SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
(end_date,), (end_date,),
).fetchone()[0] ).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( indicator_date = connection.execute(
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?", "SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
(end_date,), (end_date,),
@@ -1504,15 +1552,33 @@ class ReviewDatabase:
""", """,
(end_date,), (end_date,),
).fetchone()[0] ).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 { return {
"market": bool(market), "market": bool(market),
"auction": bool(auction), "auction": bool(auction),
"benchmark": int(benchmark_rows or 0) >= 60,
"benchmark_rows": int(benchmark_rows or 0),
"valuation": bool(valuation_available), "valuation": bool(valuation_available),
"fundamental": int(fundamental_rows or 0) >= 100, "fundamental": int(fundamental_rows or 0) >= 100,
"dividend_history": int(dividend_years or 0) >= 4, "dividend_history": int(dividend_years or 0) >= 4,
"valuation_rows": int(valuation_rows or 0), "valuation_rows": int(valuation_rows or 0),
"fundamental_rows": int(fundamental_rows or 0), "fundamental_rows": int(fundamental_rows or 0),
"dividend_years": int(dividend_years 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]: def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
@@ -1520,7 +1586,9 @@ class ReviewDatabase:
if not dates: if not dates:
return { return {
"dates": [], "bars": [], "master": [], "indicators": [], "dates": [], "bars": [], "master": [], "indicators": [],
"indicator_history": [], "fundamentals": [], "moneyflow": [], "auction": [], "indicator_history": [], "indicator_series": [], "fundamentals": [],
"moneyflow": [], "moneyflow_history": [], "auction": [],
"benchmarks": [],
} }
placeholders = ",".join("?" for _ in dates) placeholders = ",".join("?" for _ in dates)
with self.connect() as connection: with self.connect() as connection:
@@ -1553,6 +1621,15 @@ class ReviewDatabase:
""", """,
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"), (end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
).fetchall() ).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( fundamentals = connection.execute(
""" """
SELECT fi.* FROM fundamental_indicators fi SELECT fi.* FROM fundamental_indicators fi
@@ -1576,6 +1653,16 @@ class ReviewDatabase:
""", """,
(end_date,), (end_date,),
).fetchall() ).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( auction = connection.execute(
""" """
SELECT * FROM auction_factors SELECT * FROM auction_factors
@@ -1585,15 +1672,26 @@ class ReviewDatabase:
""", """,
(end_date,), (end_date,),
).fetchall() ).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 { return {
"dates": dates, "dates": dates,
"bars": [dict(row) for row in bars], "bars": [dict(row) for row in bars],
"master": [dict(row) for row in master], "master": [dict(row) for row in master],
"indicators": [dict(row) for row in indicators], "indicators": [dict(row) for row in indicators],
"indicator_history": [dict(row) for row in indicator_history], "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], "fundamentals": [dict(row) for row in fundamentals],
"moneyflow": [dict(row) for row in moneyflow], "moneyflow": [dict(row) for row in moneyflow],
"moneyflow_history": [dict(row) for row in moneyflow_history],
"auction": [dict(row) for row in auction], "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]]: def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
@@ -1602,7 +1700,7 @@ class ReviewDatabase:
except ModuleNotFoundError: except ModuleNotFoundError:
from .sentiment_engine import build_sentiment_history 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 [ return [
{ {
"trade_date": row["trade_date"], "trade_date": row["trade_date"],
@@ -1618,7 +1716,7 @@ class ReviewDatabase:
for row in series[-limit:] 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: with self.connect() as connection:
rows = connection.execute( rows = connection.execute(
""" """
@@ -1730,7 +1828,8 @@ class ReviewDatabase:
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at) (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) 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(formula, ensure_ascii=False, separators=(",", ":")),
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now), json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
) )
@@ -1757,7 +1856,9 @@ class ReviewDatabase:
def latest_screener_run( def latest_screener_run(
self, user_id: int, trade_date: str, mode: str = "", self, user_id: int, trade_date: str, mode: str = "",
) -> dict[str, Any] | None: ) -> 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 = "" mode_clause = ""
if mode in {"smart", "curated", "quant"}: if mode in {"smart", "curated", "quant"}:
mode_clause = " AND mode = ?" mode_clause = " AND mode = ?"
@@ -1767,7 +1868,7 @@ class ReviewDatabase:
f""" f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs 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 ORDER BY id DESC LIMIT 1
""", """,
parameters, parameters,
@@ -1775,20 +1876,23 @@ class ReviewDatabase:
return self._screener_run_payload(row) if row else None 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]]: 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: with self.connect() as connection:
rows = connection.execute( rows = connection.execute(
""" f"""
SELECT runs.id, runs.trade_date, runs.regime, runs.mode, SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
runs.strategy_name, runs.result, runs.created_at runs.strategy_name, runs.result, runs.created_at
FROM screener_runs runs FROM screener_runs runs
INNER JOIN ( INNER JOIN (
SELECT mode, MAX(id) AS id SELECT mode, MAX(id) AS id
FROM screener_runs FROM screener_runs
WHERE user_id = ? AND trade_date <= ? WHERE {owner_clause} AND trade_date <= ?
GROUP BY mode GROUP BY mode
) latest ON latest.id = runs.id ) latest ON latest.id = runs.id
""", """,
(int(user_id), trade_date), parameters,
).fetchall() ).fetchall()
results: dict[str, dict[str, Any]] = {} results: dict[str, dict[str, Any]] = {}
for row in rows: for row in rows:
@@ -1802,9 +1906,12 @@ class ReviewDatabase:
self, user_id: int, trade_date: str, limit: int = 60, self, user_id: int, trade_date: str, limit: int = 60,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit))) 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: with self.connect() as connection:
rows = connection.execute( rows = connection.execute(
""" f"""
WITH ranked AS ( WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at, SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
@@ -1815,7 +1922,7 @@ class ReviewDatabase:
ORDER BY id DESC ORDER BY id DESC
) AS context_rank ) AS context_rank
FROM screener_runs 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 SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked FROM ranked
@@ -1823,7 +1930,7 @@ class ReviewDatabase:
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
""", """,
(int(user_id), trade_date, safe_limit), parameters,
).fetchall() ).fetchall()
return [ return [
payload payload
@@ -1831,14 +1938,52 @@ class ReviewDatabase:
if (payload := self._screener_run_payload(row)) is not None 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: 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: with self.connect() as connection:
row = connection.execute( row = connection.execute(
""" f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at 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() ).fetchone()
if not row: if not row:
return None return None
+24 -4
View File
@@ -467,9 +467,23 @@ class MarketInsightsService:
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]: def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
dates = self.database.auction_factor_dates(trade_date, 10) 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 = [] history = []
for current_date in dates: 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( history.append(
{ {
"trade_date": _display_date(current_date), "trade_date": _display_date(current_date),
@@ -726,7 +740,7 @@ class MarketInsightsService:
carried_forward = data_date != trade_date carried_forward = data_date != trade_date
cache_key = data_date cache_key = data_date
if not force and not dynamic: 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: if cached:
result = copy.deepcopy(cached) result = copy.deepcopy(cached)
result["meta"] = { result["meta"] = {
@@ -790,7 +804,13 @@ class MarketInsightsService:
stock = master.get(ts_code) stock = master.get(ts_code)
price = _number(row.get("price")) price = _number(row.get("price"))
pre_close = _number(row.get("pre_close")) 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 continue
change = (price / pre_close - 1) * 100 change = (price / pre_close - 1) * 100
amount_million = _number(row.get("amount")) / 1_000_000 amount_million = _number(row.get("amount")) / 1_000_000
@@ -908,7 +928,7 @@ class MarketInsightsService:
"rows": candidates, "rows": candidates,
} }
if not dynamic: 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) return self._with_auction_watchlist(result, data_date, user_id)
def _theme_directory(self) -> list[dict[str, Any]]: def _theme_directory(self) -> list[dict[str, Any]]:
+581 -38
View File
@@ -8,6 +8,7 @@ from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
from advanced_strategies import ADVANCED_CURATED_STRATEGIES
from database import ReviewDatabase from database import ReviewDatabase
from sentiment_engine import build_sentiment_history, latest_contiguous_history from sentiment_engine import build_sentiment_history, latest_contiguous_history
from tushare_client import TushareClient, TushareError from tushare_client import TushareClient, TushareError
@@ -23,18 +24,45 @@ REGIMES = {
} }
FACTOR_FIELDS = { FACTOR_FIELDS = {
"close": "收盘价",
"pct_chg": "当日涨幅", "pct_chg": "当日涨幅",
"return_5d": "5日涨幅", "return_5d": "5日涨幅",
"return_10d": "10日涨幅", "return_10d": "10日涨幅",
"return_20d": "20日涨幅",
"return_60d": "60日涨幅",
"return_5d_rank": "5日涨幅排名",
"momentum_60_5": "中期动量",
"momentum_60_5_rank": "中期动量排名",
"above_ma20": "站上20日线", "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日量比", "volume_ratio_5d": "5日量比",
"turnover_5d": "5日累计换手",
"volatility_10d": "10日波动率", "volatility_10d": "10日波动率",
"amount_billion": "成交额", "amount_billion": "成交额",
"turnover_rate": "换手率", "turnover_rate": "换手率",
"circ_mv_billion": "流通市值", "circ_mv_billion": "流通市值",
"net_flow_million": "主力净流入", "net_flow_million": "主力净流入",
"large_flow_million": "大单净流入", "large_flow_million": "大单净流入",
"net_flow_5d_million": "5日主力净流入",
"flow_to_circ_mv_5d": "5日净流入占流通市值",
"sector_strength": "板块强度", "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_limit_count": "板块涨停数",
"sector_up_count": "板块强势股数", "sector_up_count": "板块强势股数",
"relative_strength": "相对强度", "relative_strength": "相对强度",
@@ -66,22 +94,52 @@ FACTOR_FIELDS = {
"previous_limit_signal": "昨日涨停或触板", "previous_limit_signal": "昨日涨停或触板",
"previous_limit_streak": "昨日连板高度", "previous_limit_streak": "昨日连板高度",
"previous_amount_billion": "昨日成交额", "previous_amount_billion": "昨日成交额",
"is_limit_up_today": "当日涨停",
"is_limit_down_today": "当日跌停",
"sector_breadth_ma20": "行业20日线宽度", "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 = { 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", "relative_position_60", "close_to_high_15d", "close_to_high_60d",
], ],
"量价交易": [ "量价交易": [
"volume_ratio_5d", "volatility_10d", "amount_billion", "turnover_rate", "volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate",
"net_flow_million", "large_flow_million", "previous_amount_billion", "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", "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", "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", "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) BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
@@ -521,6 +685,7 @@ class FactorDataService:
self.client = client self.client = client
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]: 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) trade_date, _ = self.client.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d") end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%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:] 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)) 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] 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 = [ 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( long_calendar = self.client.query(
"trade_cal", "trade_cal",
@@ -551,7 +718,7 @@ class FactorDataService:
if row.get("is_open") == 1 and row.get("cal_date"): if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"]) value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value) last_open_by_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()) valuation_dates.update(last_open_by_year.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500)) existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted( indicator_dates_to_fetch = sorted(
@@ -584,6 +751,16 @@ class FactorDataService:
indicator_count += self.database.upsert_daily_indicators(indicators) indicator_count += self.database.upsert_daily_indicators(indicators)
notices = [] 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 fundamental_count = 0
existing_periods = set(self.database.fundamental_periods()) existing_periods = set(self.database.fundamental_periods())
for period in _quarter_periods(trade_date, 9): for period in _quarter_periods(trade_date, 9):
@@ -620,17 +797,22 @@ class FactorDataService:
except TushareError as exc: except TushareError as exc:
notices.append(f"竞价因子接口不可用:{exc}") notices.append(f"竞价因子接口不可用:{exc}")
break break
try: moneyflow_count = 0
moneyflow = self.client.query( moneyflow_dates = 0
"moneyflow", for current_date in dates[-min(5, len(dates)):]:
{"trade_date": trade_date}, try:
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount," moneyflow = self.client.query(
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount", "moneyflow",
) {"trade_date": current_date},
moneyflow_count = self.database.upsert_moneyflow(moneyflow) "ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
except TushareError as exc: "buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
moneyflow_count = 0 )
notices.append(f"资金流接口不可用:{exc}") moneyflow_count += self.database.upsert_moneyflow(moneyflow)
if moneyflow:
moneyflow_dates += 1
except TushareError as exc:
notices.append(f"资金流接口不可用:{exc}")
break
return { return {
"trade_date": trade_date, "trade_date": trade_date,
@@ -638,10 +820,12 @@ class FactorDataService:
"fetched_dates": len(dates_to_fetch), "fetched_dates": len(dates_to_fetch),
"stocks": master_count, "stocks": master_count,
"bars": bar_count, "bars": bar_count,
"benchmark_bars": benchmark_count,
"indicators": indicator_count, "indicators": indicator_count,
"indicator_dates": len(indicator_dates_to_fetch), "indicator_dates": len(indicator_dates_to_fetch),
"fundamentals": fundamental_count, "fundamentals": fundamental_count,
"moneyflow": moneyflow_count, "moneyflow": moneyflow_count,
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count, "auction_rows": auction_count,
"auction_dates": auction_dates, "auction_dates": auction_dates,
"notice": "".join(notices), "notice": "".join(notices),
@@ -651,6 +835,7 @@ class FactorDataService:
class ScreenerEngine: class ScreenerEngine:
def __init__(self, database: ReviewDatabase) -> None: def __init__(self, database: ReviewDatabase) -> None:
self.database = database self.database = database
self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {}
def ensure_builtin_strategies(self) -> None: def ensure_builtin_strategies(self) -> None:
existing = { existing = {
@@ -667,7 +852,7 @@ class ScreenerEngine:
def detect_regime(self, trade_date: str) -> dict[str, Any]: def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history( 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: if not series:
return { return {
@@ -747,12 +932,32 @@ class ScreenerEngine:
strategy_name: str, run_backtest: bool = True, strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None, realtime_snapshot: dict[str, Any] | None = None,
mode: str = "smart", mode: str = "smart",
prepared_factors: list[dict[str, Any]] | None = None,
prepared_date: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
mode = mode if mode in {"smart", "curated", "quant"} else "smart" mode = mode if mode in {"smart", "curated", "quant"} else "smart"
formula = self.validate_formula(formula) 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) candidates = self.apply_formula(factors, formula, regime)
backtest = self.backtest(actual_date, formula) if run_backtest else None 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: if backtest and backtest["samples"] >= 20:
for candidate in candidates: for candidate in candidates:
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35 estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
@@ -769,9 +974,20 @@ class ScreenerEngine:
"regime_label": REGIMES.get(regime, regime), "regime_label": REGIMES.get(regime, regime),
"strategy_name": strategy_name, "strategy_name": strategy_name,
"mode": mode, "mode": mode,
"library_version": int(
(formula.get("meta") or {}).get("library_version") or 0
),
"universe_count": len(factors), "universe_count": len(factors),
"candidate_count": len(candidates), "candidate_count": len(candidates),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "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": ( "selection_source": (
"tushare_rt_k+history" if realtime_snapshot else "historical_eod" "tushare_rt_k+history" if realtime_snapshot else "historical_eod"
), ),
@@ -791,7 +1007,11 @@ class ScreenerEngine:
"formula": formula, "formula": formula,
"candidates": candidates, "candidates": candidates,
"backtest": backtest, "backtest": backtest,
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。", "disclaimer": (
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
if mode == "curated"
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
),
} }
run_id = self.database.save_screener_run( run_id = self.database.save_screener_run(
user_id, actual_date, regime, strategy_name, formula, result, mode user_id, actual_date, regime, strategy_name, formula, result, mode
@@ -803,8 +1023,10 @@ class ScreenerEngine:
self, self,
trade_date: str, trade_date: str,
realtime_snapshot: dict[str, Any] | None = None, realtime_snapshot: dict[str, Any] | None = None,
history_days: int = 80,
) -> tuple[list[dict[str, Any]], str]: ) -> 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] dates = [value for value in data["dates"] if value <= trade_date]
if len(dates) < 21: if len(dates) < 21:
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。") raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
@@ -822,7 +1044,18 @@ class ScreenerEngine:
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list) indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_history", []): for row in data.get("indicator_history", []):
indicator_history[str(row.get("ts_code") or "")].append(row) 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 = {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 = { auction = {
row["ts_code"]: row row["ts_code"]: row
for row in data.get("auction", []) for row in data.get("auction", [])
@@ -863,6 +1096,7 @@ class ScreenerEngine:
indicator = indicators.get(ts_code, {}) indicator = indicators.get(ts_code, {})
fundamental = fundamentals.get(ts_code, {}) fundamental = fundamentals.get(ts_code, {})
flow = moneyflow.get(ts_code, {}) flow = moneyflow.get(ts_code, {})
flow_history = moneyflow_history.get(ts_code, [])
auction_row = auction.get(ts_code, {}) auction_row = auction.get(ts_code, {})
list_date = str(info.get("list_date") or "") list_date = str(info.get("list_date") or "")
try: try:
@@ -906,6 +1140,72 @@ class ScreenerEngine:
dividend_years = sum( dividend_years = sum(
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0) 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( factors.append(
{ {
"code": code, "code": code,
@@ -914,12 +1214,32 @@ class ScreenerEngine:
"sector": info.get("industry") or "其他", "sector": info.get("industry") or "其他",
"market": info.get("market") or "--", "market": info.get("market") or "--",
"listed_days": listed_days, "listed_days": listed_days,
"close": round(closes[-1], 2),
"price": round(closes[-1], 2), "price": round(closes[-1], 2),
"pct_chg": round(_number(current["pct_chg"]), 2), "pct_chg": round(_number(current["pct_chg"]), 2),
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2), "return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
"return_10d": round((closes[-1] / closes[-11] - 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, "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), "volatility_10d": round(statistics.pstdev(returns_10), 2),
"amount_billion": round( "amount_billion": round(
_number(current["amount"]) / (100000000 if realtime else 100000), 2 _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), "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 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), "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_status": status,
"limit_streak": streak, "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_change": round(_number(auction_row.get("change")), 2),
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2), "auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4), "auction_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, "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:])), "no_limit_30d": int(not any(limit_flags[-30:])),
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])), "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_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_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
"previous_limit_streak": previous_streak, "previous_limit_streak": previous_streak,
@@ -968,18 +1321,65 @@ class ScreenerEngine:
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list) sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in factors: for row in factors:
sectors[row["sector"]].append(row) 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 = 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) 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) 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 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)) 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: for row in sector_rows:
row["sector_strength"] = round(strength, 1) 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_limit_count"] = limit_count
row["sector_up_count"] = up_count row["sector_up_count"] = up_count
row["sector_breadth_ma20"] = round(breadth_ma20, 1) row["sector_breadth_ma20"] = round(breadth_ma20, 1)
row["relative_strength"] = round(row["return_5d"] - market_return, 2) 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 return factors, actual_date
def apply_formula( def apply_formula(
@@ -1030,20 +1430,50 @@ class ScreenerEngine:
item["score_display"] = round(score * 100, 1) item["score_display"] = round(score * 100, 1)
item["contributions"] = contributions item["contributions"] = contributions
item["reason"] = "".join(entry["label"] for entry in contributions[:3]) 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.append(item)
results.sort(key=lambda item: item["score"], reverse=True) results.sort(key=lambda item: item["score"], reverse=True)
return results[: formula["limit"]] return results[: formula["limit"]]
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]: def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
dates = self.database.factor_dates(trade_date, 55) meta = formula.get("meta") or {}
evaluation_dates = dates[20:-3][-8:] 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 wins = 0
losses = 0 losses = 0
samples = 0 samples = 0
returns = [] returns = []
drawdowns = [] 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) bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in all_data["bars"]: for row in all_data["bars"]:
bars_by_code[row["ts_code"]].append(row) bars_by_code[row["ts_code"]].append(row)
@@ -1052,15 +1482,25 @@ class ScreenerEngine:
for current_date in evaluation_dates: for current_date in evaluation_dates:
try: 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: except ValueError:
continue continue
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest") selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
for candidate in selected: for candidate in selected:
bars = bars_by_code.get(candidate["ts_code"], []) bars = bars_by_code.get(candidate["ts_code"], [])
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1) 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 [] future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
if len(future) < 3: if len(future) < holding_days:
continue continue
entry = candidate["price"] entry = candidate["price"]
won = False won = False
@@ -1068,10 +1508,10 @@ class ScreenerEngine:
for day in future: for day in future:
low_return = (_number(day["low"]) / entry - 1) * 100 low_return = (_number(day["low"]) / entry - 1) * 100
high_return = (_number(day["high"]) / entry - 1) * 100 high_return = (_number(day["high"]) / entry - 1) * 100
if low_return <= -3: if low_return <= stop_loss:
lost = True lost = True
break break
if high_return >= 3: if high_return >= take_profit:
won = True won = True
break break
if won: if won:
@@ -1087,9 +1527,17 @@ class ScreenerEngine:
"losses": losses, "losses": losses,
"win_rate": round(wins / samples * 100, 1) if samples else 0, "win_rate": round(wins / samples * 100, 1) if samples else 0,
"average_3d_return": round(statistics.fmean(returns), 2) if returns 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, "average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
"evaluation_days": len(evaluation_dates), "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, "approximate": True,
} }
@@ -1160,6 +1608,99 @@ def _limit_threshold(code: str, name: str) -> float:
return 9.5 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: def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index < 0 or index >= len(rows): if index < 0 or index >= len(rows):
return False return False
@@ -1212,7 +1753,9 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
return result 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 = [] flags = []
if row.get("pct_chg", 0) >= 9.5: if row.get("pct_chg", 0) >= 9.5:
flags.append("当日接近涨停,次日存在高开与无法成交风险") flags.append("当日接近涨停,次日存在高开与无法成交风险")
@@ -1222,7 +1765,7 @@ def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
flags.append("波动率偏高") flags.append("波动率偏高")
if row.get("amount_billion", 0) < 1: if row.get("amount_billion", 0) < 1:
flags.append("成交承载力偏弱") flags.append("成交承载力偏弱")
if regime == "retreat": if include_regime_risk and regime == "retreat":
flags.append("市场处于退潮阶段,策略可能选择空仓") flags.append("市场处于退潮阶段,策略可能选择空仓")
return flags return flags
+15 -3
View File
@@ -13,6 +13,8 @@ COMPONENT_WEIGHTS = {
"liquidity": 10, "liquidity": 10,
} }
SENTIMENT_ENGINE_VERSION = 2
def _number(value: Any, default: float = 0.0) -> float: def _number(value: Any, default: float = 0.0) -> float:
try: try:
@@ -43,7 +45,7 @@ def _percentile(value: float, history: list[float]) -> float:
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float: def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20: if len(history) < 20:
return fixed 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: 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_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 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 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"]: if stats["previous_limit_count"]:
positive_score = float(stats["previous_positive_rate"]) 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 else None
) )
day_change = score - float(previous_result["score"]) if previous_result else 0.0 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) phase_signal = _phase_signal(score, momentum, profit_effect_score)
fermentation_ready = ( fermentation_ready = (
phase_signal == "发酵" phase_signal == "发酵"
@@ -428,6 +437,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
{ {
**stats, **stats,
"score": score, "score": score,
"ema_score": ema_score,
"label": _sentiment_label(score), "label": _sentiment_label(score),
"phase": phase, "phase": phase,
"phase_signal": phase_signal, "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), "day_change": round(day_change, 1),
"direction": direction, "direction": direction,
"momentum": round(momentum, 1), "momentum": round(momentum, 1),
"normalization": normalization, "normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
"history_days": len(previous) + 1, "history_days": len(previous) + 1,
"systemic_health": round(systemic_health, 1), "systemic_health": round(systemic_health, 1),
"risk_multiplier": round(systemic_gate, 3), "risk_multiplier": round(systemic_gate, 3),
@@ -474,10 +484,12 @@ def apply_sentiment_to_dashboard(
overview.update( overview.update(
{ {
"sentiment_score": sentiment["score"], "sentiment_score": sentiment["score"],
"sentiment_trend_score": sentiment["ema_score"],
"sentiment_label": sentiment["label"], "sentiment_label": sentiment["label"],
"sentiment_phase": sentiment["phase"], "sentiment_phase": sentiment["phase"],
"sentiment_direction": sentiment["direction"], "sentiment_direction": sentiment["direction"],
"sentiment_components": sentiment["components"], "sentiment_components": sentiment["components"],
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
} }
) )
result["overview"] = overview result["overview"] = overview
+346 -35
View File
@@ -66,6 +66,7 @@ from screener import (
from security import SecretVault, hash_password, token_hash, verify_password from security import SecretVault, hash_password, token_hash, verify_password
from sentiment_engine import ( from sentiment_engine import (
COMPONENT_WEIGHTS, COMPONENT_WEIGHTS,
SENTIMENT_ENGINE_VERSION,
apply_sentiment_to_dashboard, apply_sentiment_to_dashboard,
build_sentiment_history, build_sentiment_history,
latest_contiguous_history, latest_contiguous_history,
@@ -75,6 +76,30 @@ from trade_journal import TradeJournalService
from tushare_client import TushareClient, TushareError, _sector_coverage_issue 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 = { LEGACY_SECRET_KEYS = {
"TUSHARE_TOKEN", "TUSHARE_TOKEN",
"IFIND_REFRESH_TOKEN", "IFIND_REFRESH_TOKEN",
@@ -175,6 +200,8 @@ class DashboardService:
self.sync_lock = threading.Lock() self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock() self.auth_lock = threading.Lock()
self.system_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._ifind_event_lock = threading.Lock()
self._request_context = threading.local() self._request_context = threading.local()
self._system_credentials = self._load_system_credentials(environment_credentials) self._system_credentials = self._load_system_credentials(environment_credentials)
@@ -788,6 +815,7 @@ class DashboardService:
snapshot = self.database.get_snapshot(today) or {} snapshot = self.database.get_snapshot(today) or {}
if self._realtime_snapshot_due(today, snapshot): if self._realtime_snapshot_due(today, snapshot):
self._run_background_sync(today) self._run_background_sync(today)
self._schedule_automatic_screeners(today, snapshot)
except Exception: except Exception:
pass pass
self._background_stop.wait(5) self._background_stop.wait(5)
@@ -936,6 +964,11 @@ class DashboardService:
) )
if not self._dashboard_sentiment_ready(snapshot): if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date) 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) snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True)) return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot( resolved = self.database.get_data_snapshot(
@@ -968,7 +1001,7 @@ class DashboardService:
@staticmethod @staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool: def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {} overview = dashboard.get("overview") or {}
return all( return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
key in overview key in overview
for key in ( for key in (
"sentiment_score", "sentiment_score",
@@ -1091,7 +1124,7 @@ class DashboardService:
dashboard: dict[str, Any], dashboard: dict[str, Any],
end_date: str, end_date: str,
) -> dict[str, Any]: ) -> 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) return apply_sentiment_to_dashboard(dashboard, history)
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]: def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
@@ -1168,6 +1201,103 @@ class DashboardService:
"rows": rows, "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]: def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status() llm_access = self.llm_access_status()
return { return {
@@ -1234,35 +1364,63 @@ class DashboardService:
return match.group(1) return match.group(1)
return "" return ""
def screener_setup(self, trade_date: str) -> dict[str, Any]: @staticmethod
normalized_date = normalize_date(trade_date) def _strategy_missing_data(
regime = self.screener.detect_regime(normalized_date) strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
factor_dates = self.database.factor_dates(normalized_date, 100) ) -> list[str]:
auction_dates = self.database.auction_factor_dates(normalized_date, 100) formula = strategy.get("formula") or {}
factor_health = self.screener.factor_health(normalized_date) meta = formula.get("meta") or {}
strategies = self.database.list_screener_strategies(self.current_user_id) 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"} 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"} 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"} 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: for strategy in strategies:
formula = strategy.get("formula") or {} missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
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("竞价数据")
strategy["data_ready"] = not missing strategy["data_ready"] = not missing
strategy["missing_data"] = 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 { return {
"trade_date": normalized_date, "trade_date": normalized_date,
"regime": regime, "regime": regime,
@@ -1292,16 +1450,11 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured, "fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
}, },
"latest_results": self.database.latest_screener_runs( "latest_results": latest_results,
self.current_user_id, normalized_date "recent_results": recent_results,
), "automatic_status": automatic_status,
"recent_results": self.database.latest_screener_context_runs(
self.current_user_id, normalized_date
),
# Kept during the client transition for compatibility with older frontends. # Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run( "latest_result": latest_results.get("smart"),
self.current_user_id, normalized_date, "smart"
),
} }
def screener_tracking(self, limit: int = 12) -> dict[str, Any]: def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
@@ -1565,12 +1718,158 @@ class DashboardService:
if not self.configured: if not self.configured:
raise ValueError("请先配置 Tushare Token。") raise ValueError("请先配置 Tushare Token。")
normalized_date = normalize_date(trade_date) normalized_date = normalize_date(trade_date)
lookback = max(25, min(80, int(lookback))) lookback = max(25, min(260, int(lookback)))
with self.sync_lock: with self.sync_lock:
return FactorDataService(self.database, TushareClient(self.token)).sync( return FactorDataService(self.database, TushareClient(self.token)).sync(
normalized_date, lookback 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]: def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
prompt = prompt.strip() prompt = prompt.strip()
if not prompt or len(prompt) > 3000: if not prompt or len(prompt) > 3000:
@@ -4641,6 +4940,18 @@ class RequestHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return 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": if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0] trade_date = query.get("trade_date", [date.today().isoformat()])[0]
+257 -207
View File
@@ -53,6 +53,10 @@ const state = {
rotationHistory: null, rotationHistory: null,
rotationHistoryKey: "", rotationHistoryKey: "",
rotationSelectedSector: "", rotationSelectedSector: "",
rotationSelectedDate: "",
rotationMembers: null,
rotationMembersKey: "",
rotationMembersLoading: false,
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest", rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
rotationLoading: false, rotationLoading: false,
auctionData: null, auctionData: null,
@@ -104,6 +108,7 @@ const state = {
screenerSetupPromise: null, screenerSetupPromise: null,
selectedRegime: "", selectedRegime: "",
selectedStrategy: null, selectedStrategy: null,
customStrategyDraft: null,
screenerRunning: false, screenerRunning: false,
screenerRunningMode: "", screenerRunningMode: "",
screenerResults: { smart: null, curated: null, quant: null }, screenerResults: { smart: null, curated: null, quant: null },
@@ -114,7 +119,9 @@ const state = {
? localStorage.getItem("xiaobaiScreenerMode") ? localStorage.getItem("xiaobaiScreenerMode")
: "smart", : "smart",
curatedCategory: "全部", curatedCategory: "全部",
curatedSchool: "全部",
curatedQuery: "", curatedQuery: "",
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
selectedCuratedStrategyId: 0, selectedCuratedStrategyId: 0,
quantFilters: [], quantFilters: [],
quantScores: [], quantScores: [],
@@ -519,8 +526,6 @@ async function applyAuthenticatedSession(session) {
document.querySelector("#syncButton").hidden = !isAdmin; document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin; document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin; document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true; document.querySelector("#authGate").hidden = true;
applyMembershipAccess(); applyMembershipAccess();
await startAuthenticatedApp(); await startAuthenticatedApp();
@@ -815,8 +820,7 @@ function bindEvents() {
} }
renderAuctionTable(); renderAuctionTable();
}); });
document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library")); document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close()); document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
document.querySelector("#strategyDrawer").addEventListener("click", (event) => { document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close(); if (event.target === event.currentTarget) event.currentTarget.close();
@@ -911,8 +915,6 @@ function bindEvents() {
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder); document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride); document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
document.querySelector("#backfillButton").addEventListener("click", backfillData); document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => { document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
await loadScreenerTracking(true); await loadScreenerTracking(true);
openView("screenerTrackingView"); openView("screenerTrackingView");
@@ -943,11 +945,22 @@ function bindEvents() {
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN"); state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderCuratedStrategyLibrary(); renderCuratedStrategyLibrary();
}); });
document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy); document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus); state.curatedCategory = event.target.value;
document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close()); renderCuratedStrategyLibrary();
document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => { });
if (event.target === event.currentTarget) event.currentTarget.close(); 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("#quantResetButton").addEventListener("click", resetQuantBuilder);
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter()); document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
@@ -1161,7 +1174,7 @@ function renderDashboard() {
renderYesterdayTable(state.dashboard.yesterday_limits || []); renderYesterdayTable(state.dashboard.yesterday_limits || []);
renderPerformance(state.dashboard.limit_performance || []); renderPerformance(state.dashboard.limit_performance || []);
renderLadderBoard(ladders || []); renderLadderBoard(ladders || []);
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []); renderRotationMembers();
} }
async function loadSentimentHistory(force = false) { async function loadSentimentHistory(force = false) {
@@ -1787,6 +1800,7 @@ function updateYesterdayControls() {
} }
function renderPerformance(rows) { function renderPerformance(rows) {
rows = normalizePerformanceRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || ""); const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`); setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
@@ -1803,6 +1817,39 @@ function renderPerformance(rows) {
renderMarketBreadth(state.dashboard?.overview || {}); 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) { function performanceRateState(rate) {
const value = number(rate); const value = number(rate);
if (value === 0) return { label: "失效", className: "is-neutral" }; if (value === 0) return { label: "失效", className: "is-neutral" };
@@ -1956,8 +2003,9 @@ function renderRotationHistory() {
<button class="rotation-track-cancel" type="button">取消追踪</button>`; <button class="rotation-track-cancel" type="button">取消追踪</button>`;
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => { tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
state.rotationSelectedSector = ""; state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
renderRotationHistory(); renderRotationHistory();
updateRotationTableSelection(); loadRotationMembers("");
}); });
} else { } else {
tracker.hidden = true; tracker.hidden = true;
@@ -1974,7 +2022,7 @@ function renderRotationHistory() {
const strength = clamp(number(sector.strength), 0, 100); const strength = clamp(number(sector.strength), 0, 100);
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild"; const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
return ` return `
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}"> <button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> · ${formatNumber(sector.strength, 0)}</small> <span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> · ${formatNumber(sector.strength, 0)}</small>
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · ${number(sector.rank)} · 涨停 ${number(sector.count)} · 强度 ${formatNumber(sector.strength, 0)}</span> <span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · ${number(sector.rank)} · 涨停 ${number(sector.count)} · 强度 ${formatNumber(sector.strength, 0)}</span>
</button>`; </button>`;
@@ -1983,58 +2031,76 @@ function renderRotationHistory() {
}).join(""); }).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => { container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector const clickedSector = button.dataset.rotationSector;
? "" const clickedDate = button.dataset.rotationDate;
: button.dataset.rotationSector; const isSameSelection = clickedSector === state.rotationSelectedSector
&& clickedDate === state.rotationSelectedDate;
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
renderRotationHistory(); renderRotationHistory();
updateRotationTableSelection(); loadRotationMembers(state.rotationSelectedSector);
}); });
}); });
} }
function renderRotationTable(rows, sectors) { async function loadRotationMembers(sector, force = false) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector])); 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 body = document.querySelector("#rotationTableBody");
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value); const empty = document.querySelector("#rotationMembersEmpty");
setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`); if (state.rotationMembersLoading) {
body.innerHTML = rows.map((row) => { body.innerHTML = "";
const sector = sectorMap.get(row.name) || {}; empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
const strength = number(row.strength ?? sector.strength); empty.hidden = false;
const currentCount = number(row.count); return;
const previousCount = number(row.previous_count); }
const delta = row.delta == null ? currentCount - previousCount : number(row.delta); const payload = state.rotationMembers;
const trend = previousCount === 0 && currentCount > 0 const rows = payload?.rows || [];
? "新进" if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
: currentCount > previousCount body.innerHTML = "";
? "升温" empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
: currentCount < previousCount ? "降温" : "持平"; empty.hidden = false;
return ` setText("rotationDetailTitle", "板块成分股");
<tr class="rotation-detail-row ${state.rotationSelectedSector === row.name ? "selected" : ""}" data-rotation-detail-sector="${escapeHtml(row.name)}"><td class="number">${number(row.rank)}</td><td class="stock-name">${escapeHtml(row.name)}</td> setText("rotationDetailMeta", state.rotationSelectedSector || "--");
<td><span class="trend-tag ${trendClass(trend)}">${trend}</span></td> return;
<td class="number up">${currentCount}</td><td class="number muted">${previousCount}</td> }
<td class="number ${delta >= 0 ? "delta-positive" : "delta-negative"}" data-sort-value="${delta}">${delta > 0 ? "+" : ""}${delta}</td> empty.hidden = true;
<td data-sort-value="${strength}"><div class="rotation-strength"><span class="strength-cell"><i style="width:${clamp(strength, 0, 100)}%"></i></span><b>${formatNumber(strength, 0)}</b></div></td> setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td> setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)}`);
<td class="number ${changeClass(sector.change)}" data-sort-value="${number(sector.change)}">${signed(sector.change)}%</td> body.innerHTML = rows.map((row, index) => `
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number" data-sort-value="${number(row.amount_billion)}">${formatNumber(row.amount_billion, 1)} 亿</td></tr> <tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
`; <td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
}).join(""); <td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => { <td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "" : ""}</td></tr>
row.addEventListener("click", () => { `).join("");
const sector = row.dataset.rotationDetailSector;
state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
renderRotationHistory();
updateRotationTableSelection();
document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
});
});
animateRows(body); animateRows(body);
} bindStockRows(body);
function updateRotationTableSelection() {
document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
});
} }
function renderLadderMini(ladders) { function renderLadderMini(ladders) {
@@ -3498,12 +3564,7 @@ function applyScreenerSetup(payload, requestKey) {
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result; if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
const smartLatestMeta = latestResults.smart?.meta || {}; const smartLatestMeta = latestResults.smart?.meta || {};
const curatedLatestMeta = latestResults.curated?.meta || {}; const curatedLatestMeta = latestResults.curated?.meta || {};
const availableRegimes = new Set((payload.regimes || []).map((item) => item.id)); state.selectedRegime = payload.regime.id;
if (!state.selectedRegime || dateChanged) {
state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
? smartLatestMeta.regime
: payload.regime.id;
}
const selectedId = state.selectedStrategy?.id; const selectedId = state.selectedStrategy?.id;
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated"); const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
@@ -3617,13 +3678,9 @@ function renderScreenerSetup() {
const selector = document.querySelector("#regimeSelector"); const selector = document.querySelector("#regimeSelector");
selector.innerHTML = setup.regimes.map((item) => ` selector.innerHTML = setup.regimes.map((item) => `
<button type="button" class="regime-option ${item.id === state.selectedRegime ? "active" : ""}" data-regime="${item.id}">${escapeHtml(item.label)}</button> <span class="regime-option ${item.id === state.selectedRegime ? "active" : ""}">${escapeHtml(item.label)}</span>
`).join(""); `).join("");
selector.querySelectorAll("[data-regime]").forEach((button) => {
button.addEventListener("click", () => selectRegime(button.dataset.regime));
});
renderStrategyList(); renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
renderStrategySummary(); renderStrategySummary();
renderScreenerMode(); renderScreenerMode();
renderCuratedStrategyLibrary(); renderCuratedStrategyLibrary();
@@ -3663,7 +3720,7 @@ function renderScreenerMode() {
const results = document.querySelector("#screenerView .screener-results-view"); const results = document.querySelector("#screenerView .screener-results-view");
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`); const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results); if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" }; const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]); setText("screenerResultTitle", titles[mode]);
renderScreenerResult(); renderScreenerResult();
} }
@@ -3677,56 +3734,83 @@ function activeCuratedStrategy() {
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null; 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() { function renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return; if (!state.screenerSetup) return;
const strategies = curatedStrategies(); const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))]; const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部"; if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
setText("curatedStrategyCount", `${strategies.length}`); setText("curatedStrategyCount", `${strategies.length}`);
const filters = document.querySelector("#curatedCategoryFilters"); const categorySelect = document.querySelector("#curatedCategoryFilter");
filters.innerHTML = categories.map((category) => ` categorySelect.innerHTML = categories.map((category) => `
<button class="${category === state.curatedCategory ? "active" : ""}" type="button" data-curated-category="${escapeHtml(category)}">${escapeHtml(category)}</button> <option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
`).join(""); `).join("");
filters.querySelectorAll("[data-curated-category]").forEach((button) => { document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
button.addEventListener("click", () => { const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
state.curatedCategory = button.dataset.curatedCategory; return `<button class="${school === state.curatedSchool ? "active" : ""}" type="button" data-curated-school="${escapeHtml(school)}" aria-pressed="${school === state.curatedSchool}">${escapeHtml(school)}<small>${count}</small></button>`;
renderCuratedStrategyLibrary(); }).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 query = state.curatedQuery;
const visible = strategies.filter((item) => { const visible = strategies.filter((item) => {
const meta = item.formula?.meta || {}; const meta = item.formula?.meta || {};
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory; const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query); const school = curatedStrategySchool(item);
return categoryMatch && queryMatch; 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"); const list = document.querySelector("#curatedStrategyList");
list.classList.toggle("is-grid", state.curatedViewMode === "grid");
list.innerHTML = visible.length ? visible.map((strategy) => { list.innerHTML = visible.length ? visible.map((strategy) => {
const meta = strategy.formula?.meta || {}; const meta = strategy.formula?.meta || {};
const school = curatedStrategySchool(strategy);
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1; 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 `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}"> return `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}">
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(meta.category || "")}</small></span><i class="curated-ready-dot ${strategy.data_ready ? "" : "missing"}" title="${strategy.data_ready ? "" : escapeHtml((strategy.missing_data || []).join(""))}"></i></span> <span class="curated-strategy-icon" aria-hidden="true"><i data-lucide="${curatedSchoolIcon(school)}"></i></span>
<span class="curated-card-tags"><em>质量 ${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span> <span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span>
<span class="curated-card-description">${escapeHtml(strategy.description || "查看策略条件与适用环境。")}</span> <span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
<span class="curated-card-foot"><small>${strategy.data_ready ? "数据已就绪" : `缺少 ${(strategy.missing_data || []).length} 项数据`}</small><span class="curated-card-actions"><button type="button" data-curated-inspect="${strategy.id}"></button><button type="button" class="primary" data-curated-run="${strategy.id}" ${strategy.data_ready ? "" : "disabled"}><i data-lucide="play"></i></button></span></span>
</article>`; </article>`;
}).join("") : '<div class="empty-state">没有符合条件的策略</div>'; }).join("") : '<div class="empty-state">没有符合条件的策略</div>';
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(); renderCuratedStrategyDetail();
} }
@@ -3735,6 +3819,10 @@ function renderCuratedStrategyDetail() {
if (!strategy) return; if (!strategy) return;
const formula = strategy.formula || {}; const formula = strategy.formula || {};
const meta = formula.meta || {}; 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("curatedStrategyCategory", meta.category || "精选策略");
setText("curatedStrategyName", strategy.name); setText("curatedStrategyName", strategy.name);
setText("curatedStrategyDescription", strategy.description); setText("curatedStrategyDescription", strategy.description);
@@ -3742,6 +3830,8 @@ function renderCuratedStrategyDetail() {
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`, `质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
meta.data_group || "行情因子", meta.data_group || "行情因子",
].map((value) => `<span>${escapeHtml(value)}</span>`).join(""); ].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准");
setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效");
const filters = formula.filters || []; const filters = formula.filters || [];
setText("curatedFilterCount", `${filters.length}`); setText("curatedFilterCount", `${filters.length}`);
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => ` document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
@@ -3754,14 +3844,27 @@ function renderCuratedStrategyDetail() {
const percent = number(item.weight) / total * 100; const percent = number(item.weight) / total * 100;
return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`; return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`;
}).join(""); }).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]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
const status = document.querySelector("#curatedDataStatus"); const status = document.querySelector("#curatedDataStatus");
status.classList.toggle("missing", !strategy.data_ready); status.classList.toggle("missing", statusClass === "missing");
status.innerHTML = strategy.data_ready status.innerHTML = strategy.data_ready
? '<i data-lucide="database"></i><span><strong>策略数据已就绪</strong><small>可按当前数据日期执行</small></span>' ? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
: `<i data-lucide="circle-alert"></i><span><strong>需要补充数据</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}</small></span>`; : `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
const runButton = document.querySelector("#curatedRunButton");
runButton.disabled = !strategy.data_ready;
runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(strategy.missing_data || []).join("、")}`;
refreshIcons(); 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) { function selectScreenerMobileView(view) {
state.screenerMobileView = view === "results" ? "results" : "strategy"; state.screenerMobileView = view === "results" ? "results" : "strategy";
const workspace = document.querySelector("#screenerView"); const workspace = document.querySelector("#screenerView");
@@ -3986,11 +4105,7 @@ function selectScreenerMobileView(view) {
} }
function updateBacktestTaskStatus() { function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked; setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
const backtest = activeScreenerResult("smart")?.backtest;
setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
? "正在回测"
: backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
renderScreenerProgress(); renderScreenerProgress();
} }
@@ -4003,18 +4118,18 @@ function selectRegime(regime) {
function renderStrategyList() { function renderStrategyList() {
const list = document.querySelector("#strategyList"); 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) => ` list.innerHTML = strategies.map((strategy) => `
<button type="button" class="strategy-item ${strategy.id === state.selectedStrategy?.id ? "active" : ""}" data-strategy-id="${strategy.id}"> <button type="button" class="strategy-item ${strategy.id === state.customStrategyDraft?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span> <strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small> <small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small>
</button> </button>
`).join(""); `).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>';
list.querySelectorAll("[data-strategy-id]").forEach((button) => { list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId)); state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime; populateStrategyEditor(state.customStrategyDraft);
renderScreenerSetup(); renderStrategyList();
}); });
}); });
} }
@@ -4033,32 +4148,6 @@ function populateStrategyEditor(strategy) {
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2); 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() { async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim(); const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton"); const button = document.querySelector("#compileStrategyButton");
@@ -4071,9 +4160,8 @@ async function compileStrategy() {
regime: state.selectedRegime, regime: state.selectedRegime,
}); });
const strategy = payload.strategy; const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false }; state.customStrategyDraft = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true; document.querySelector("#deleteStrategyButton").hidden = true;
renderStrategySummary();
document.querySelector("#strategyNameInput").value = strategy.name; document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description; document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2); document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
@@ -4099,8 +4187,9 @@ async function saveCurrentStrategy() {
formula, formula,
}); });
state.screenerSetup.strategies = payload.strategies; state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id); state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
renderScreenerSetup(); renderStrategyList();
populateStrategyEditor(state.customStrategyDraft);
showToast("自定义策略已保存"); showToast("自定义策略已保存");
} catch (error) { } catch (error) {
showToast(error.message); showToast(error.message);
@@ -4108,7 +4197,7 @@ async function saveCurrentStrategy() {
} }
async function deleteCurrentStrategy() { async function deleteCurrentStrategy() {
const strategy = state.selectedStrategy; const strategy = state.customStrategyDraft;
if (!strategy?.id || strategy.builtin) { if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略"); showToast("只能删除已保存的自定义策略");
return; return;
@@ -4120,10 +4209,9 @@ async function deleteCurrentStrategy() {
try { try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE"); const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies; state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime)) state.customStrategyDraft = null;
|| payload.strategies.find((item) => item.formula?.meta?.library !== "curated") renderStrategyList();
|| null; openCustomStrategyDrawer();
renderScreenerSetup();
showToast("自定义策略已删除"); showToast("自定义策略已删除");
} catch (error) { } catch (error) {
showToast(error.message || "策略删除失败"); 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() { async function runQuantStrategy() {
let formula; let formula;
try { try {
@@ -4163,27 +4231,27 @@ async function runQuantStrategy() {
await executeScreenerFormula({ await executeScreenerFormula({
mode: "quant", mode: "quant",
formula, formula,
strategyName: "自定义量化公式", strategyName: "自定义选股公式",
regime: state.selectedRegime, regime: state.selectedRegime,
runBacktest: document.querySelector("#quantBacktestToggle").checked, runBacktest: document.querySelector("#quantBacktestToggle").checked,
button: document.querySelector("#quantRunButton"), button: document.querySelector("#quantRunButton"),
loadingText: "正在执行量化公式并计算因子贡献", loadingText: "正在执行自定义公式并计算因子贡献",
}); });
} }
function saveQuantAsStrategy() { function saveQuantAsStrategy() {
try { try {
const formula = buildQuantFormula(); const formula = buildQuantFormula();
state.selectedStrategy = { state.customStrategyDraft = {
id: null, id: null,
builtin: false, builtin: false,
name: "自定义量化策略", name: "自定义选股策略",
description: "由量化因子工作台生成,可在高级公式中继续调整。", description: "由自定义因子工作台生成,可在高级公式中继续调整。",
regimes: [state.selectedRegime], regimes: [state.selectedRegime],
formula, formula,
}; };
populateStrategyEditor(state.selectedStrategy); populateStrategyEditor(state.customStrategyDraft);
document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式"; document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
openStrategyDrawer("editor"); openStrategyDrawer("editor");
} catch (error) { } catch (error) {
showToast(error.message); 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) { async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", ""); const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) { if (!force && state.mentorSetup?.requestedDate === requestedDate) {
@@ -6619,9 +6667,9 @@ function renderScreenerResult() {
const context = activeScreenerResultContext(mode); const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource"); const source = document.querySelector("#screenerResultSource");
const emptyMessages = { const emptyMessages = {
smart: "尚未执行当前阶段与策略的选股", smart: "当日盘后候选尚未生成",
curated: "尚未执行所选策略", curated: "所选策略的当日候选尚未生成",
quant: "尚未执行量化选股", quant: "尚未执行自定义选股",
}; };
if (!result) { if (!result) {
setText("screenerResultCount", "0 只"); setText("screenerResultCount", "0 只");
@@ -6639,9 +6687,9 @@ function renderScreenerResult() {
} }
const candidates = result.candidates || []; const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length}`); setText("screenerResultCount", `${candidates.length}`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" }; const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const sourceParts = [modeLabels[mode]]; 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 || "未命名策略"); sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
source.textContent = sourceParts.join(" · "); source.textContent = sourceParts.join(" · ");
source.hidden = false; source.hidden = false;
@@ -6652,7 +6700,9 @@ function renderScreenerResult() {
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}` ? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${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 body = document.querySelector("#screenerTableBody");
const runId = number(meta.run_id); const runId = number(meta.run_id);
body.innerHTML = candidates.map((row, index) => ` body.innerHTML = candidates.map((row, index) => `
-5
View File
@@ -219,11 +219,6 @@ body.drawer-open .drawer-mask{display:block}
.field textarea{min-height:90px;resize:vertical} .field textarea{min-height:90px;resize:vertical}
.field input:focus,.field textarea:focus{border-color:var(--blue-line)} .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{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.auc-head h2{font-size:17px;font-weight:800} .auc-head h2{font-size:17px;font-weight:800}
+67 -57
View File
@@ -19,9 +19,9 @@
</script> </script>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/renovation.css?v=20260725-5"> <link rel="stylesheet" href="/renovation.css?v=20260725-5">
<link rel="stylesheet" href="/redesign-v2.css?v=20260726-34"> <link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
<link rel="stylesheet" href="/design-system.css?v=20260728-4"> <link rel="stylesheet" href="/design-system.css?v=20260728-4">
<link rel="stylesheet" href="/theme.css?v=20260728-1"> <link rel="stylesheet" href="/theme.css?v=20260728-2">
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7"> <link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
</head> </head>
<body> <body>
@@ -449,12 +449,12 @@
<div class="workspace-heading card-h redesigned-card-head"><h3 id="sentimentStageGuideTitle">判定口径</h3><span>温度 + 结构共同判定</span></div> <div class="workspace-heading card-h redesigned-card-head"><h3 id="sentimentStageGuideTitle">判定口径</h3><span>温度 + 结构共同判定</span></div>
<div class="sentiment-stage-guide-head" aria-hidden="true"><span>阶段</span><span>典型特征</span><span>温度区间</span><span>策略取向</span></div> <div class="sentiment-stage-guide-head" aria-hidden="true"><span>阶段</span><span>典型特征</span><span>温度区间</span><span>策略取向</span></div>
<div class="sentiment-stage-guide-grid"> <div class="sentiment-stage-guide-grid">
<article data-sentiment-stage="冰点"><strong>冰点</strong><span>涨停稀少、跌停成堆、高度压至 2 板</span><span class="stage-range">0-20</span><small>抗跌先手,允许无结果</small></article> <article data-sentiment-stage="冰点"><strong>冰点</strong><span>涨停稀少、跌停成堆、高度显著压缩</span><span class="stage-range">低于 25</span><small>抗跌先手,允许无结果</small></article>
<article data-sentiment-stage="修复"><strong>修复</strong><span>跌停减少、首板增多、出现反包</span><span class="stage-range">20-40</span><small>修复先锋,小仓试错</small></article> <article data-sentiment-stage="修复"><strong>修复</strong><span>风险收敛、温度从低位有效回升</span><span class="stage-range">25+ 且回升</span><small>修复先锋,小仓试错</small></article>
<article data-sentiment-stage="发酵"><strong>发酵</strong><span>主线清晰、梯队成型、晋级率走高</span><span class="stage-range">40-60</span><small>主线跟随</small></article> <article data-sentiment-stage="发酵"><strong>发酵</strong><span>主线清晰、梯队成型、连续转强</span><span class="stage-range">45+ 且连续确认</span><small>主线跟随</small></article>
<article data-sentiment-stage="高潮"><strong>高潮</strong><span>涨停扩散、空间打开、情绪充沛</span><span class="stage-range">60-85</span><small>核心去后排</small></article> <article data-sentiment-stage="高潮"><strong>高潮</strong><span>温度、赚钱效应与涨停生态共振</span><span class="stage-range">80+ 且生态达标</span><small>核心去后排</small></article>
<article data-sentiment-stage="分化"><strong>分化</strong><span>高低切换、炸板增多、主线内部分歧</span><span class="stage-range">45-65</span><small>承接回流</small></article> <article data-sentiment-stage="分化"><strong>分化</strong><span>高低切换、炸板增多、主线内部分歧</span><span class="stage-range">45+ 且结构转弱</span><small>承接回流</small></article>
<article data-sentiment-stage="退潮"><strong>退潮</strong><span>高度压缩、晋级走低、亏钱效应扩散</span><span class="stage-range">20-40 且下降</span><small>防守观察</small></article> <article data-sentiment-stage="退潮"><strong>退潮</strong><span>温度或系统健康度继续走弱</span><span class="stage-range">低于 45 且走弱</span><small>防守观察</small></article>
</div> </div>
</section> </section>
</div> </div>
@@ -801,7 +801,7 @@
<div class="screener-mode-tabs method" role="tablist" aria-label="智能选股模式"> <div class="screener-mode-tabs method" role="tablist" aria-label="智能选股模式">
<button class="active" type="button" role="tab" aria-selected="true" data-screener-mode="smart">阶段选股</button> <button class="active" type="button" role="tab" aria-selected="true" data-screener-mode="smart">阶段选股</button>
<button type="button" role="tab" aria-selected="false" data-screener-mode="curated">策略选股</button> <button type="button" role="tab" aria-selected="false" data-screener-mode="curated">策略选股</button>
<button type="button" role="tab" aria-selected="false" data-screener-mode="quant">量化选股</button> <button type="button" role="tab" aria-selected="false" data-screener-mode="quant">自定义选股</button>
</div> </div>
<button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button> <button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button>
</div> </div>
@@ -831,8 +831,8 @@
</div> </div>
<div id="regimeReason" class="regime-advice">--</div> <div id="regimeReason" class="regime-advice">--</div>
<div class="regime-control-line"> <div class="regime-control-line">
<div id="regimeSelector" class="regime-selector" role="group" aria-label="选择市场阶段"></div> <div id="regimeSelector" class="regime-selector" aria-label="系统识别的市场阶段"></div>
<div class="factor-data-status"><span>跟随自动识别(点击可手动覆盖)</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">尚未同步</small></div> <div class="factor-data-status"><span>盘后行情定格后自动更新</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">等待后台数据</small></div>
</div> </div>
</div> </div>
</div> </div>
@@ -842,17 +842,12 @@
<div class="screener-strategy-summary"> <div class="screener-strategy-summary">
<div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div> <div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div>
<p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p> <p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p>
<div class="screener-strategy-actions"> <div class="screener-strategy-actions"><span class="screener-auto-note">由系统按当前阶段自动匹配</span></div>
<button id="changeStrategyButton" class="button" type="button">更换策略</button>
<button id="openStrategyDrawerButton" class="button" type="button">编辑 / 自定义策略</button>
</div>
</div> </div>
</section> </section>
</div> </div>
<div class="screener-runbar runbar"> <div class="screener-runbar runbar">
<div class="screener-run-actions"> <div class="screener-run-actions">
<button id="screenerRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行选股</button>
<button id="factorSyncButton" class="button" type="button">同步因子数据</button>
<button id="screenerExportButton" class="button" type="button">导出 CSV</button> <button id="screenerExportButton" class="button" type="button">导出 CSV</button>
</div> </div>
<div class="screener-pipeline-status" aria-live="polite"> <div class="screener-pipeline-status" aria-live="polite">
@@ -863,37 +858,52 @@
<div data-screener-results-slot="smart"></div> <div data-screener-results-slot="smart"></div>
</div> </div>
<div class="curated-screener-panel" data-screener-panel="curated" hidden> <div class="curated-screener-panel" data-screener-panel="curated" hidden>
<section class="curated-library-pane" aria-label="精选策略库"> <div class="curated-workspace">
<div class="curated-library-heading"> <aside class="curated-library-pane" aria-label="精选策略库">
<div><span>策略库</span><h3>选择策略并直接执行</h3><p>适用阶段仅作参考,策略条件与权重可在详情中查看。</p></div><strong id="curatedStrategyCount">10 套</strong> <div class="curated-library-heading">
</div> <div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">10 套</strong>
<div class="curated-library-controls"> </div>
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索名称或类别" autocomplete="off"></label> <div class="curated-library-controls">
<div id="curatedCategoryFilters" class="curated-category-filters" role="group" aria-label="策略分类"></div> <label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label>
</div> <label class="curated-category-select"><span class="visually-hidden">策略分类</span><select id="curatedCategoryFilter" aria-label="策略分类"></select><i data-lucide="chevron-down"></i></label>
<div id="curatedStrategyList" class="curated-strategy-list"></div> <div class="curated-view-toggle" role="group" aria-label="策略排列方式">
</section> <button class="active" type="button" data-curated-view="list" aria-label="列表排列" title="列表排列" aria-pressed="true"><i data-lucide="list"></i></button>
<dialog id="curatedDetailDialog" class="curated-detail-dialog" aria-labelledby="curatedStrategyName"> <button type="button" data-curated-view="grid" aria-label="图标排列" title="图标排列" aria-pressed="false"><i data-lucide="layout-grid"></i></button>
<section class="curated-detail-pane"> </div>
<button id="closeCuratedDetailButton" class="icon-button curated-detail-close" type="button" title="关闭" aria-label="关闭策略条件"><i data-lucide="x"></i></button> </div>
<header class="curated-detail-header"> <div id="curatedSchoolFilters" class="curated-school-filters" aria-label="策略流派"></div>
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div> <div id="curatedStrategyList" class="curated-strategy-list"></div>
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div> </aside>
</header> <section class="curated-detail-pane" aria-labelledby="curatedStrategyName">
<div class="curated-detail-grid"> <header class="curated-detail-header">
<section class="curated-condition-section"><div class="mini-section-heading"><h4>准入条件</h4><span id="curatedFilterCount">0 项</span></div><div id="curatedFilterList" class="curated-rule-list"></div></section> <div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
<section class="curated-condition-section"><div class="mini-section-heading"><h4>评分权重</h4><span id="curatedWeightTotal">100%</span></div><div id="curatedScoreList" class="curated-score-list"></div></section> <div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
</div> </header>
<div class="curated-execution-bar"> <div class="curated-environment-notes">
<div id="curatedDataStatus" class="curated-data-status"><i data-lucide="database"></i><span><strong>检查数据中</strong><small>同步后显示可用状态</small></span></div> <p><strong>适用环境</strong><span id="curatedSuitableEnvironment"></span></p>
<label class="checkbox-control"><input id="curatedBacktestToggle" type="checkbox" checked>滚动回测</label> <p><strong>失效风险</strong><span id="curatedFailureRisk"></span></p>
<button id="curatedRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行该策略</button> </div>
</div> <div class="curated-health-grid" id="curatedHealthMetrics" aria-label="策略运行状态"></div>
</section> <div class="curated-detail-grid">
</dialog> <section class="curated-condition-section"><div class="mini-section-heading"><h4>准入条件</h4><span id="curatedFilterCount">0 项</span></div><div id="curatedFilterList" class="curated-rule-list"></div></section>
<section class="curated-condition-section"><div class="mini-section-heading"><h4>评分权重</h4><span id="curatedWeightTotal">100%</span></div><div id="curatedScoreList" class="curated-score-list"></div></section>
</div>
<div class="curated-execution-bar">
<div id="curatedDataStatus" class="curated-data-status"><i data-lucide="database"></i><span><strong>检查数据中</strong><small>同步后显示可用状态</small></span></div>
<span class="screener-auto-note">候选池由后台盘后自动更新</span>
</div>
</section>
</div>
<div data-screener-results-slot="curated"></div> <div data-screener-results-slot="curated"></div>
</div> </div>
<div class="quant-screener-panel" data-screener-panel="quant" hidden> <div class="quant-screener-panel" data-screener-panel="quant" hidden>
<section class="custom-screener-tools card">
<div><span>自定义能力</span><strong>用自然语言生成公式,或直接编辑受控公式</strong><small>自定义选股仅在点击执行后计算,不影响系统盘后候选池。</small></div>
<div>
<button id="openStrategyDrawerButton" class="button" type="button"><i data-lucide="sparkles"></i>自然语言生成公式</button>
<button id="quantSaveButton" class="button" type="button"><i data-lucide="braces"></i>编辑 / 保存公式</button>
</div>
</section>
<section class="quant-builder-pane"> <section class="quant-builder-pane">
<header class="quant-panel-heading"><h3>因子与权重</h3><div><button id="addQuantScoreButton" class="button" type="button"><i data-lucide="plus"></i>添加因子</button><button id="quantResetButton" class="button ghost" type="button"><i data-lucide="rotate-ccw"></i>重置</button></div></header> <header class="quant-panel-heading"><h3>因子与权重</h3><div><button id="addQuantScoreButton" class="button" type="button"><i data-lucide="plus"></i>添加因子</button><button id="quantResetButton" class="button ghost" type="button"><i data-lucide="rotate-ccw"></i>重置</button></div></header>
<div id="quantScoreRows" class="quant-rule-rows"></div> <div id="quantScoreRows" class="quant-rule-rows"></div>
@@ -912,16 +922,15 @@
</div> </div>
<div id="quantFilterRows" class="quant-rule-rows"></div> <div id="quantFilterRows" class="quant-rule-rows"></div>
<div class="quant-execution-actions"> <div class="quant-execution-actions">
<button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行量化选股</button> <button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行自定义选股</button>
<button id="quantSaveButton" class="button" type="button"><i data-lucide="save"></i>保存为方案</button>
<label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label> <label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label>
<span>结果按加权总分排序,并生成逐股贡献解释</span> <span>结果按加权总分排序,并生成逐股贡献解释</span>
</div> </div>
<p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p> <p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p>
</div> </div>
</aside> </aside>
<div data-screener-results-slot="quant"></div>
</div> </div>
<div class="custom-results-slot" data-screener-results-slot="quant"></div>
</div> </div>
<div class="screener-results-view"> <div class="screener-results-view">
<section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden> <section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden>
@@ -935,6 +944,7 @@
</div> </div>
<div class="table-frame card tbl-wrap screener-result-frame"> <div class="table-frame card tbl-wrap screener-result-frame">
<table class="data-table tbl"> <table class="data-table tbl">
<colgroup class="screener-result-columns"><col><col><col><col><col><col><col><col><col><col><col><col></colgroup>
<thead><tr> <thead><tr>
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr"></span></th> <th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr"></span></th>
<th class="number num sortable">历史估计(%<span class="arr"></span></th><th class="number num sortable">当日涨幅(%<span class="arr"></span></th><th class="number num sortable">5日涨幅(%<span class="arr"></span></th> <th class="number num sortable">历史估计(%<span class="arr"></span></th><th class="number num sortable">当日涨幅(%<span class="arr"></span></th><th class="number num sortable">5日涨幅(%<span class="arr"></span></th>
@@ -946,7 +956,7 @@
</div> </div>
</div> </div>
<dialog id="strategyDrawer" class="strategy-drawer" aria-labelledby="strategyDrawerTitle"> <dialog id="strategyDrawer" class="strategy-drawer" aria-labelledby="strategyDrawerTitle">
<div class="strategy-drawer-header"><div><span>阶段选股</span><h2 id="strategyDrawerTitle">编辑 / 自定义策略</h2></div><button id="closeStrategyDrawerButton" class="icon-button" type="button" aria-label="关闭策略编辑"><i data-lucide="x"></i></button></div> <div class="strategy-drawer-header"><div><span>自定义选股</span><h2 id="strategyDrawerTitle">自然语言与受控公式</h2></div><button id="closeStrategyDrawerButton" class="icon-button" type="button" aria-label="关闭策略编辑"><i data-lucide="x"></i></button></div>
<div class="strategy-drawer-body"> <div class="strategy-drawer-body">
<aside class="strategy-sidebar"> <aside class="strategy-sidebar">
<div class="workspace-heading card-h"><h3>策略库</h3><span id="strategyCount">0 套</span></div> <div class="workspace-heading card-h"><h3>策略库</h3><span id="strategyCount">0 套</span></div>
@@ -1108,22 +1118,22 @@
<section class="rotation-detail-card card" aria-labelledby="rotationDetailTitle"> <section class="rotation-detail-card card" aria-labelledby="rotationDetailTitle">
<header class="rotation-card-head card-h"> <header class="rotation-card-head card-h">
<div> <div>
<h3 id="rotationDetailTitle">当日轮动明细</h3> <h3 id="rotationDetailTitle">板块成分股</h3>
<span>趋势、强度与涨停梯队 · 点击行可联动追踪上方轨迹</span> <span>点击上方板块,查看目标交易日有效申万成分与行情</span>
</div> </div>
<span id="rotationDetailMeta" class="rotation-top-tag">--</span> <span id="rotationDetailMeta" class="rotation-top-tag">--</span>
</header> </header>
<div class="rotation-table-frame tbl-wrap"> <div class="rotation-table-frame tbl-wrap">
<table id="rotationTable" class="data-table tbl rotation-table"> <table id="rotationTable" class="data-table tbl rotation-table">
<thead><tr> <thead><tr>
<th class="number num">排名</th><th>板块</th><th>趋势</th><th class="number num">今日涨停(只)</th> <th class="number num">序号</th><th>代码</th><th>股票</th>
<th class="number num">昨日涨停(只)</th><th class="number num" data-auto-sort="true" title="变化:点击排序">变化(只</th> <th class="number num" data-auto-sort="true" title="涨跌幅:点击排序">涨跌幅(%</th>
<th class="number num" data-auto-sort="true" title="强度:点击排序">强度(分</th><th class="number num">最高板(板</th> <th class="number num">开盘价(元</th><th class="number num">收盘价(元</th>
<th class="number num" data-auto-sort="true" title="平均涨幅:点击排序">平均涨幅(%</th><th>领涨股</th> <th class="number num" data-auto-sort="true" title="成交额:点击排序">成交额(亿</th><th>行情状态</th>
<th class="number num" data-auto-sort="true" title="涨停股成交额:点击排序">涨停股成交额(亿)</th>
</tr></thead> </tr></thead>
<tbody id="rotationTableBody"></tbody> <tbody id="rotationTableBody"></tbody>
</table> </table>
<div id="rotationMembersEmpty" class="empty-state">点击上方任意板块查看成分股</div>
</div> </div>
</section> </section>
</section> </section>
@@ -1873,6 +1883,6 @@
<script src="/vendor/lucide.min.js" defer></script> <script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" defer></script> <script src="/ui-core.js" defer></script>
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script> <script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
<script src="/app.js?v=20260728-2" defer></script> <script src="/app.js?v=20260728-3" defer></script>
</body> </body>
</html> </html>
+357 -54
View File
@@ -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 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-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 { 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 b { font-weight: 700; }
.sentiment-phase-info p .down { color: var(--r2-down); } .sentiment-phase-info p .down { color: var(--r2-down); }
.sentiment-phase-info p .up { color: var(--r2-up); } .sentiment-phase-info p .up { color: var(--r2-up); }
@@ -1050,7 +1050,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.pool-side-group p { .pool-side-group p {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
color: #4b5563; color: var(--r2-sub);
font-size: 12px; font-size: 12px;
line-height: 1.8; line-height: 1.8;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1819,7 +1819,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.performance-conclusion { .performance-conclusion {
padding: 14px 16px; padding: 14px 16px;
color: #374151; color: var(--r2-sub);
font-size: 12.5px; font-size: 12.5px;
line-height: 2; line-height: 2;
} }
@@ -5222,38 +5222,114 @@ body.sidebar-collapsed .status-bar { left: 64px; }
/* Curated strategy workspace. */ /* Curated strategy workspace. */
#screenerView .curated-screener-panel { display: block; } #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 { #screenerView .curated-library-heading {
min-height: 58px; min-height: 58px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 9px 14px; padding: 9px 14px;
border: 1px solid var(--r2-line); border-bottom: 1px solid var(--r2-line-soft);
border-radius: 10px 10px 0 0;
background: #fff; background: #fff;
} }
#screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; } #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 h3 { margin: 1px 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 > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; } #screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; }
#screenerView .curated-library-controls { #screenerView .curated-library-controls {
min-height: 50px; min-height: 48px;
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) 104px 62px;
align-items: center; align-items: center;
gap: 12px; gap: 7px;
margin: 0 0 12px; padding: 7px 10px;
padding: 8px 14px; 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: 1px solid var(--r2-line);
border-top: 0; border-radius: 7px;
border-radius: 0 0 10px 10px;
background: #fff; 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 { #screenerView .curated-search {
width: 240px; width: 100%;
min-height: 32px; min-height: 32px;
display: flex; display: flex;
align-items: center; 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 .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-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 { #screenerView .curated-category-select {
display: grid; position: relative;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); min-height: 32px;
gap: 12px; display: flex;
padding: 0; align-items: center;
} }
#screenerView .curated-strategy-card { #screenerView .curated-category-select select {
min-height: 158px; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
padding: 13px 15px; overflow-y: auto;
border: 1px solid var(--r2-line); padding: 8px;
border-radius: 10px; scrollbar-gutter: stable;
background: #fff; }
box-shadow: var(--r2-shadow);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease; #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 { #screenerView .curated-strategy-card:hover {
border-color: #b7c9ee; border-color: #b7c9ee;
transform: translateY(-2px); background: #f4f7fd;
box-shadow: 0 5px 16px rgba(37, 99, 235, .08); 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-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: 27px minmax(0, 1fr) auto; align-items: center; gap: 8px; } #screenerView .curated-card-head { display: grid; grid-template-columns: 25px 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-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 strong,
#screenerView .curated-card-head small { display: block; } #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 strong { overflow: hidden; font-size: 12px; 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 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 { 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 { 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(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-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-detail-pane {
#screenerView .curated-card-foot small { color: var(--r2-faint); font-size: 9.5px; } display: flex;
#screenerView .curated-card-actions { display: flex; gap: 5px; margin-left: auto; } 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. */ /* Quant workspace. */
#screenerView .quant-screener-panel { #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 { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; }
#screenerView .quant-validation-message.error { color: var(--scr-amber); } #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 .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) { @media (max-width: 1180px) {
#screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; } #screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
#screenerView .screener-overview-grid, #screenerView .screener-overview-grid,
#screenerView .quant-screener-panel { grid-template-columns: 1fr; } #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) { @media (max-width: 820px) {
@@ -5443,9 +5641,10 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .screener-pipeline-status { margin-left: 0; } #screenerView .screener-pipeline-status { margin-left: 0; }
#screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; } #screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; }
#screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; } #screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; }
#screenerView .curated-strategy-list { grid-template-columns: 1fr 1fr; } #screenerView .curated-workspace { grid-template-columns: 1fr; }
#screenerView .curated-library-controls { align-items: stretch; flex-direction: column; } #screenerView .curated-strategy-list { min-height: 0; max-height: 310px; }
#screenerView .curated-search { width: 100%; } #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) { @media (max-width: 560px) {
@@ -5461,9 +5660,14 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .result-toolbar .section-subtitle { margin-left: 0; } #screenerView .result-toolbar .section-subtitle { margin-left: 0; }
#screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); } #screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); }
#screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); } #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 { 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-universe-grid,
#screenerView .quant-formula-summary { grid-template-columns: 1fr; } #screenerView .quant-formula-summary { grid-template-columns: 1fr; }
#screenerView .quant-st-toggle { grid-column: auto; } #screenerView .quant-st-toggle { grid-column: auto; }
@@ -7911,10 +8115,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.settings-dialog:not(.heaven-reading-dialog) { .settings-dialog:not(.heaven-reading-dialog) {
width: min(740px, calc(100vw - 28px)); width: min(740px, calc(100vw - 28px));
max-height: min(820px, calc(100dvh - 28px)); max-height: min(820px, calc(100dvh - 28px));
box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }
.settings-dialog:not(.heaven-reading-dialog) .settings-section { .settings-dialog:not(.heaven-reading-dialog) .settings-section {
padding: 18px 20px; padding: 18px 20px;
border-color: var(--dialog-line); 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; } .admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; }
/* Editing dialogs share sensible proportions without changing their fields. */ /* 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 { 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-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.trade-log-dialog .trade-tags-field { grid-column: span 2; } .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) { @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-dialog { margin-top: 8px; }
.global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; } .global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; }
.global-search-head kbd { display: none; } .global-search-head kbd { display: none; }
@@ -8337,3 +8549,94 @@ body.sidebar-collapsed .status-bar { left: 64px; }
white-space: normal; 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; }
}
+16 -2
View File
@@ -3983,20 +3983,29 @@ dialog::backdrop {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.toast { #toast.toast {
position: fixed; position: fixed;
top: auto;
left: auto;
right: 18px; right: 18px;
bottom: 48px; bottom: 48px;
z-index: 60; z-index: 60;
width: max-content;
height: auto;
max-width: min(420px, calc(100vw - 36px)); max-width: min(420px, calc(100vw - 36px));
padding: 11px 14px; padding: 11px 14px;
border-radius: 4px; border-radius: 4px;
background: #21313c; background: #21313c;
color: #fff; color: #fff;
box-shadow: var(--shadow); box-shadow: var(--shadow);
opacity: 1;
pointer-events: none;
transform: none;
animation: toast-enter var(--motion-medium) var(--ease-out) both; animation: toast-enter var(--motion-medium) var(--ease-out) both;
} }
#toast.toast[hidden] { display: none; }
@keyframes toast-enter { @keyframes toast-enter {
from { opacity: 0; transform: translateY(8px); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -5147,10 +5156,15 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
display: none; display: none;
} }
.toast { #toast.toast {
top: auto;
left: auto;
right: 10px; right: 10px;
bottom: 76px; bottom: 76px;
width: max-content;
height: auto;
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
transform: none;
} }
} }
+112 -2
View File
@@ -568,6 +568,54 @@
background: var(--action-soft); 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) { :root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) {
border-color: var(--line-soft); border-color: var(--line-soft);
background: var(--surface-muted); background: var(--surface-muted);
@@ -1030,10 +1078,11 @@
} }
:root[data-theme="dark"] #screenerView :is( :root[data-theme="dark"] #screenerView :is(
.curated-library-pane,
.curated-library-heading, .curated-library-heading,
.curated-library-controls, .curated-library-controls,
.curated-search, .curated-search,
.curated-category-filters button, .curated-category-select select,
.curated-strategy-card, .curated-strategy-card,
.curated-strategy-rank, .curated-strategy-rank,
.curated-card-tags em, .curated-card-tags em,
@@ -1052,7 +1101,6 @@
} }
:root[data-theme="dark"] #screenerView :is( :root[data-theme="dark"] #screenerView :is(
.curated-category-filters button.active,
.curated-strategy-card.active, .curated-strategy-card.active,
.curated-card-tags em, .curated-card-tags em,
.curated-strategy-rank, .curated-strategy-rank,
@@ -1061,6 +1109,30 @@
background: var(--surface-muted); 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( :root[data-theme="dark"] #screenerView :is(
.quant-rule-row select, .quant-rule-row select,
.quant-rule-row input, .quant-rule-row input,
@@ -1257,6 +1329,44 @@
color: var(--text-secondary); 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 /* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former
paper-theme overrides here would repaint its controls and ritual stages. */ paper-theme overrides here would repaint its controls and ritual stages. */
-2
View File
@@ -614,8 +614,6 @@ button { cursor: pointer; }
.login-dialog form { display: grid; gap: 14px; padding: 26px; } .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 h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; }
.login-dialog .button { width: 100%; margin-top: 4px; } .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) { @media (max-width: 960px) {
.heaven-shell { width: min(100% - 20px, 760px); } .heaven-shell { width: min(100% - 20px, 760px); }
.heaven-controls { align-items: stretch; flex-direction: column; } .heaven-controls { align-items: stretch; flex-direction: column; }
+2
View File
@@ -23,6 +23,8 @@ class StrategyTrackingService:
def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]: 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) 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: if not run:
raise ValueError("选股结果不存在或不属于当前账号。") raise ValueError("选股结果不存在或不属于当前账号。")
normalized_code = str(code or "").strip().split(".")[0] normalized_code = str(code or "").strip().split(".")[0]
+53 -77
View File
@@ -1433,7 +1433,8 @@ test("regular account cannot see admin controls and member features are gated",
await expect(page.locator("#accountVipLabel")).toHaveText("非会员"); await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
await page.locator('[data-view="screenerView"]').first().click(); await page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView .member-gate")).toBeVisible(); 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 page.locator("#assistantButton").click();
await expect(page.locator("#settingsDialog")).toBeHidden(); await expect(page.locator("#settingsDialog")).toBeHidden();
await expect(page.locator("#assistantDialog")).toBeVisible(); 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("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量"); await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1); 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 page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden(); 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 page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2); 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"]'); 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(secondStrategy).toHaveClass(/active/);
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.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("#curatedStrategyName")).toHaveText("低波质量");
await expect(page.locator("#curatedDetailDialog")).toBeVisible(); const [libraryBox, detailBox] = await Promise.all([
const dialogBox = await page.locator("#curatedDetailDialog").boundingBox(); page.locator(".curated-library-pane").boundingBox(),
expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2); page.locator(".curated-detail-pane").boundingBox(),
expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2); ]);
await page.locator("#closeCuratedDetailButton").click(); 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.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
await page.locator('[data-screener-mode="quant"]').click(); await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5); await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0); await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
await expect(page.getByText("执行设置", { exact: true })).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([ const [builderBox, summaryBox] = await Promise.all([
page.locator(".quant-builder-pane").boundingBox(), page.locator(".quant-builder-pane").boundingBox(),
page.locator(".quant-summary-pane").boundingBox(), page.locator(".quant-summary-pane").boundingBox(),
]); ]);
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1); expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2); expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
expect(builderBox.width).toBeGreaterThanOrEqual(395); expect(builderBox.width).toBeGreaterThanOrEqual(490);
expect(builderBox.width).toBeLessThanOrEqual(405); expect(builderBox.width).toBeLessThanOrEqual(540);
const quantRunBox = await page.locator("#quantRunButton").boundingBox(); const quantRunBox = await page.locator("#quantRunButton").boundingBox();
expect(quantRunBox.width).toBeLessThan(180); expect(quantRunBox.width).toBeLessThan(180);
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225); 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(); 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 page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); 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('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await page.locator('[data-regime="retreat"]').click(); await expect(page.locator('[data-regime]')).toHaveCount(0);
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2); await expect(page.locator("#screenerRunButton")).toHaveCount(0);
await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行"); await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行"); await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略"); await expect(page.locator("#editStrategyButton")).toHaveCount(0);
await expect(page.locator("#screenerResultSource")).toBeHidden();
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await page.locator('[data-screener-mode="curated"]').click(); await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("所选策略"); await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
await page.evaluate(() => { 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")).toContainText("策略结果");
await expect(page.locator("#screenerTableBody")).not.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 page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("量化选股"); await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
await page.evaluate(() => { await page.evaluate(() => {
setScreenerResult("quant", { setScreenerResult("quant", {
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" }, 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(); renderScreenerResult();
}); });
await expect(page.locator("#screenerTableBody")).toContainText("量化结果"); 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 page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果"); 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("量化结果"); 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 = { const formula = {
meta: { library: "smart" }, universe: {}, filters: [], meta: { library: "smart" }, universe: {}, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }], score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
limit: 10, min_score: 0.5, 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 = { const options = {
recentScreenerResults: [], latestScreenerResults: { smart: smartResult, curated: curatedA },
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }], recentScreenerResults: [smartResult, curatedA, curatedB],
additionalScreenerStrategies: [ 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", id: 4, name: "Quality B", description: "Second curated strategy",
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [], 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 mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView"); await page.goto("/index.html?view=screenerView");
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click(); await expect(page.locator("#screenerRunButton")).toHaveCount(0);
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 page.locator('[data-screener-mode="curated"]').click(); 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 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 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"); await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
expect(options.screenerRunBodies || []).toHaveLength(0);
await page.reload(); await page.reload();
await page.locator('[data-screener-mode="smart"]').click(); await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair"); 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 page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A"); 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"); await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
}); });
+248 -3
View File
@@ -1,26 +1,78 @@
import sqlite3 import sqlite3
import tempfile import tempfile
import unittest import unittest
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from database import ReviewDatabase from database import ReviewDatabase
from screener import ( from screener import (
ADVANCED_CURATED_STRATEGIES,
CURATED_STRATEGIES, CURATED_STRATEGIES,
FACTOR_FIELDS, FACTOR_FIELDS,
FACTOR_GROUPS, FACTOR_GROUPS,
ScreenerEngine, ScreenerEngine,
_broken_reversal_metrics,
_risk_flags,
_rsi,
_quarter_periods, _quarter_periods,
) )
from server import automatic_screener_jobs
class CuratedScreenerTests(unittest.TestCase): class CuratedScreenerTests(unittest.TestCase):
def test_first_batch_contains_ten_distinct_curated_strategies(self): def test_curated_library_contains_original_and_advanced_strategies(self):
self.assertEqual(10, len(CURATED_STRATEGIES)) self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
self.assertEqual(10, len({item["name"] for item in 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( self.assertTrue(
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES) 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): def test_every_curated_formula_uses_supported_factors(self):
with tempfile.TemporaryDirectory() as root: with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db") database = ReviewDatabase(Path(root) / "review.db")
@@ -56,6 +108,15 @@ class CuratedScreenerTests(unittest.TestCase):
connection.close() connection.close()
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns)) self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
self.assertIn("fundamental_indicators", tables) 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): def test_quarter_periods_stop_at_selected_date(self):
periods = _quarter_periods("20260722", 5) periods = _quarter_periods("20260722", 5)
@@ -98,6 +159,10 @@ class CuratedScreenerTests(unittest.TestCase):
for index in range(100) 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") health = database.factor_health_summary("20260722")
self.assertTrue(health["market"]) self.assertTrue(health["market"])
@@ -105,10 +170,190 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue(health["valuation"]) self.assertTrue(health["valuation"])
self.assertTrue(health["fundamental"]) self.assertTrue(health["fundamental"])
self.assertTrue(health["dividend_history"]) self.assertTrue(health["dividend_history"])
self.assertTrue(health["benchmark"])
self.assertEqual(health["valuation_rows"], 1) self.assertEqual(health["valuation_rows"], 1)
self.assertEqual(health["fundamental_rows"], 100) self.assertEqual(health["fundamental_rows"], 100)
self.assertEqual(health["dividend_years"], 5) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+5
View File
@@ -24,6 +24,9 @@ class SnapshotDatabase:
def save_data_snapshot(self, kind, cache_key, _source, payload): def save_data_snapshot(self, kind, cache_key, _source, payload):
self.aliases[(kind, cache_key)] = copy.deepcopy(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): def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
return copy.deepcopy(self.latest) return copy.deepcopy(self.latest)
@@ -43,6 +46,7 @@ class DashboardCacheTests(unittest.TestCase):
"sentiment_phase": "retreat", "sentiment_phase": "retreat",
"sentiment_direction": "cooling", "sentiment_direction": "cooling",
"sentiment_components": {}, "sentiment_components": {},
"sentiment_engine_version": 2,
}, },
} }
service = self.service(snapshot) service = self.service(snapshot)
@@ -71,6 +75,7 @@ class DashboardCacheTests(unittest.TestCase):
"sentiment_phase": "ice", "sentiment_phase": "ice",
"sentiment_direction": "cooling", "sentiment_direction": "cooling",
"sentiment_components": {}, "sentiment_components": {},
"sentiment_engine_version": 2,
}) })
return payload return payload
+42 -1
View File
@@ -114,15 +114,56 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn(f'data-screener-mode="{mode}"', self.html) self.assertIn(f'data-screener-mode="{mode}"', self.html)
self.assertIn(f'data-screener-panel="{mode}"', self.html) self.assertIn(f'data-screener-panel="{mode}"', self.html)
for element_id in ( for element_id in (
"curatedStrategyList", "curatedRunButton", "quantFilterRows", "curatedStrategyList", "quantFilterRows",
"quantScoreRows", "quantRunButton", "quantSaveButton", "quantScoreRows", "quantRunButton", "quantSaveButton",
): ):
self.assertIn(f'id="{element_id}"', self.html) 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="strategyDrawer" class="strategy-drawer"', self.html)
self.assertIn('id="openStrategyDrawerButton"', self.html) self.assertIn('id="openStrategyDrawerButton"', self.html)
self.assertIn('id="closeStrategyDrawerButton"', self.html) self.assertIn('id="closeStrategyDrawerButton"', self.html)
self.assertIn('id="activeStrategyDescription"', self.html) self.assertIn('id="activeStrategyDescription"', self.html)
self.assertIn('openStrategyDrawer("editor")', self.script) 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): def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script) self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
+14
View File
@@ -106,6 +106,20 @@ class MarketInsightsTests(unittest.TestCase):
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2) self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
self.assertEqual(payload["focus_rows"][0]["code"], "000001") 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): def test_real_limit_price_is_isolated_from_scored_candidates(self):
class OnePriceClient(FakeMarketClient): class OnePriceClient(FakeMarketClient):
def query(self, api_name, params=None, fields=""): def query(self, api_name, params=None, fields=""):
+44
View File
@@ -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()
+23
View File
@@ -130,6 +130,29 @@ class StrategyTrackingTests(unittest.TestCase):
self.assertTrue(removed["deleted"]) self.assertTrue(removed["deleted"])
self.assertEqual(removed["tracking"]["batches"], []) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+4
View File
@@ -866,6 +866,10 @@ class TushareClient:
deduped[code] = row deduped[code] = row
return list(deduped.values()) 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]]: def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
now = datetime.now().astimezone() now = datetime.now().astimezone()
with self._stock_listing_lock: with self._stock_listing_lock: