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
ON daily_bars(ts_code, trade_date DESC);
CREATE TABLE IF NOT EXISTS benchmark_bars (
trade_date TEXT NOT NULL,
ts_code TEXT NOT NULL,
close REAL NOT NULL DEFAULT 0,
pct_chg REAL NOT NULL DEFAULT 0,
PRIMARY KEY (trade_date, ts_code)
);
CREATE INDEX IF NOT EXISTS idx_benchmark_bars_code_date
ON benchmark_bars(ts_code, trade_date DESC);
CREATE TABLE IF NOT EXISTS daily_indicators (
trade_date TEXT NOT NULL,
ts_code TEXT NOT NULL,
@@ -532,6 +543,7 @@ class ReviewDatabase:
run_columns = {
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)")
}
legacy_run_ownership = "user_id" not in run_columns
if "user_id" not in run_columns:
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
if "mode" not in run_columns:
@@ -561,16 +573,28 @@ class ReviewDatabase:
"UPDATE screener_runs SET mode = ? WHERE id = ?",
(mode, int(run["id"])),
)
connection.execute(
"UPDATE screener_runs SET user_id = NULL WHERE user_id = 0"
)
if first_user and first_user["id"]:
first_user_id = int(first_user["id"])
connection.execute(
"UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL",
(first_user_id,),
)
connection.execute(
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL",
(first_user_id,),
)
if legacy_run_ownership:
connection.execute(
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL",
(first_user_id,),
)
else:
connection.execute(
"""
UPDATE screener_runs SET user_id = ?
WHERE user_id IS NULL AND mode = 'quant'
""",
(first_user_id,),
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_screener_strategies_user
@@ -1276,6 +1300,26 @@ class ReviewDatabase:
)
return len(values)
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
)
for row in rows if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
VALUES (?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
close=excluded.close, pct_chg=excluded.pct_chg
""",
values,
)
return len(values)
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
values = [
(
@@ -1468,6 +1512,10 @@ class ReviewDatabase:
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
(end_date,),
).fetchone()[0]
benchmark_rows = connection.execute(
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
(end_date,),
).fetchone()[0]
indicator_date = connection.execute(
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
(end_date,),
@@ -1504,15 +1552,33 @@ class ReviewDatabase:
""",
(end_date,),
).fetchone()[0]
moneyflow_dates = connection.execute(
"""
SELECT COUNT(DISTINCT trade_date)
FROM moneyflow_daily
WHERE trade_date IN (
SELECT DISTINCT trade_date
FROM daily_bars
WHERE trade_date <= ?
ORDER BY trade_date DESC
LIMIT 5
)
""",
(end_date,),
).fetchone()[0]
return {
"market": bool(market),
"auction": bool(auction),
"benchmark": int(benchmark_rows or 0) >= 60,
"benchmark_rows": int(benchmark_rows or 0),
"valuation": bool(valuation_available),
"fundamental": int(fundamental_rows or 0) >= 100,
"dividend_history": int(dividend_years or 0) >= 4,
"valuation_rows": int(valuation_rows or 0),
"fundamental_rows": int(fundamental_rows or 0),
"dividend_years": int(dividend_years or 0),
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
"moneyflow_dates": int(moneyflow_dates or 0),
}
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
@@ -1520,7 +1586,9 @@ class ReviewDatabase:
if not dates:
return {
"dates": [], "bars": [], "master": [], "indicators": [],
"indicator_history": [], "fundamentals": [], "moneyflow": [], "auction": [],
"indicator_history": [], "indicator_series": [], "fundamentals": [],
"moneyflow": [], "moneyflow_history": [], "auction": [],
"benchmarks": [],
}
placeholders = ",".join("?" for _ in dates)
with self.connect() as connection:
@@ -1553,6 +1621,15 @@ class ReviewDatabase:
""",
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
).fetchall()
indicator_series = connection.execute(
f"""
SELECT trade_date, ts_code, turnover_rate, volume_ratio
FROM daily_indicators
WHERE trade_date IN ({placeholders})
ORDER BY trade_date, ts_code
""",
dates,
).fetchall()
fundamentals = connection.execute(
"""
SELECT fi.* FROM fundamental_indicators fi
@@ -1576,6 +1653,16 @@ class ReviewDatabase:
""",
(end_date,),
).fetchall()
flow_dates = dates[-min(5, len(dates)):]
flow_placeholders = ",".join("?" for _ in flow_dates)
moneyflow_history = connection.execute(
f"""
SELECT * FROM moneyflow_daily
WHERE trade_date IN ({flow_placeholders})
ORDER BY trade_date, ts_code
""",
flow_dates,
).fetchall()
auction = connection.execute(
"""
SELECT * FROM auction_factors
@@ -1585,15 +1672,26 @@ class ReviewDatabase:
""",
(end_date,),
).fetchall()
benchmarks = connection.execute(
f"""
SELECT * FROM benchmark_bars
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
ORDER BY trade_date
""",
dates,
).fetchall()
return {
"dates": dates,
"bars": [dict(row) for row in bars],
"master": [dict(row) for row in master],
"indicators": [dict(row) for row in indicators],
"indicator_history": [dict(row) for row in indicator_history],
"indicator_series": [dict(row) for row in indicator_series],
"fundamentals": [dict(row) for row in fundamentals],
"moneyflow": [dict(row) for row in moneyflow],
"moneyflow_history": [dict(row) for row in moneyflow_history],
"auction": [dict(row) for row in auction],
"benchmarks": [dict(row) for row in benchmarks],
}
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
@@ -1602,7 +1700,7 @@ class ReviewDatabase:
except ModuleNotFoundError:
from .sentiment_engine import build_sentiment_history
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 240))
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
return [
{
"trade_date": row["trade_date"],
@@ -1618,7 +1716,7 @@ class ReviewDatabase:
for row in series[-limit:]
]
def list_snapshot_payloads(self, end_date: str, limit: int = 240) -> list[dict[str, Any]]:
def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
@@ -1730,7 +1828,8 @@ class ReviewDatabase:
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(int(user_id), trade_date, regime, normalized_mode, strategy_name,
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
normalized_mode, strategy_name,
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
)
@@ -1757,7 +1856,9 @@ class ReviewDatabase:
def latest_screener_run(
self, user_id: int, trade_date: str, mode: str = "",
) -> dict[str, Any] | None:
parameters: tuple[Any, ...] = (int(user_id), trade_date)
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date,)
mode_clause = ""
if mode in {"smart", "curated", "quant"}:
mode_clause = " AND mode = ?"
@@ -1767,7 +1868,7 @@ class ReviewDatabase:
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs
WHERE user_id = ? AND trade_date <= ?{mode_clause}
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
ORDER BY id DESC LIMIT 1
""",
parameters,
@@ -1775,20 +1876,23 @@ class ReviewDatabase:
return self._screener_run_payload(row) if row else None
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date,)
with self.connect() as connection:
rows = connection.execute(
"""
f"""
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
runs.strategy_name, runs.result, runs.created_at
FROM screener_runs runs
INNER JOIN (
SELECT mode, MAX(id) AS id
FROM screener_runs
WHERE user_id = ? AND trade_date <= ?
WHERE {owner_clause} AND trade_date <= ?
GROUP BY mode
) latest ON latest.id = runs.id
""",
(int(user_id), trade_date),
parameters,
).fetchall()
results: dict[str, dict[str, Any]] = {}
for row in rows:
@@ -1802,9 +1906,12 @@ class ReviewDatabase:
self, user_id: int, trade_date: str, limit: int = 60,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date, safe_limit)
with self.connect() as connection:
rows = connection.execute(
"""
f"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
@@ -1815,7 +1922,7 @@ class ReviewDatabase:
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE user_id = ? AND trade_date <= ?
WHERE {owner_clause} AND trade_date <= ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
@@ -1823,7 +1930,7 @@ class ReviewDatabase:
ORDER BY id DESC
LIMIT ?
""",
(int(user_id), trade_date, safe_limit),
parameters,
).fetchall()
return [
payload
@@ -1831,14 +1938,52 @@ class ReviewDatabase:
if (payload := self._screener_run_payload(row)) is not None
]
def screener_runs_for_date(
self, user_id: int, trade_date: str, limit: int = 80,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(160, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date, safe_limit)
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs
WHERE {owner_clause} AND trade_date = ?
ORDER BY id DESC
LIMIT ?
""",
parameters,
).fetchall()
result = []
seen: set[tuple[str, str, str]] = set()
for row in rows:
key = (
str(row["mode"] or "smart"),
str(row["regime"] or ""),
str(row["strategy_name"] or ""),
)
if key in seen:
continue
seen.add(key)
payload = self._screener_run_payload(row)
if payload is not None:
result.append(payload)
return result
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = (int(run_id),)
if int(user_id) != 0:
parameters += (int(user_id),)
with self.connect() as connection:
row = connection.execute(
"""
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs WHERE id = ? AND user_id = ?
FROM screener_runs WHERE id = ? AND {owner_clause}
""",
(int(run_id), int(user_id)),
parameters,
).fetchone()
if not row:
return None
+24 -4
View File
@@ -467,9 +467,23 @@ class MarketInsightsService:
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
dates = self.database.auction_factor_dates(trade_date, 10)
stock_list_dates = {
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
for item in self.database.list_stock_master()
if item.get("ts_code")
}
history = []
for current_date in dates:
rows = self.database.auction_factors_for_date(current_date)
rows = [
row for row in self.database.auction_factors_for_date(current_date)
if (
str(row.get("ts_code") or "") in stock_list_dates
and (
not stock_list_dates[str(row.get("ts_code") or "")]
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
)
)
]
history.append(
{
"trade_date": _display_date(current_date),
@@ -726,7 +740,7 @@ class MarketInsightsService:
carried_forward = data_date != trade_date
cache_key = data_date
if not force and not dynamic:
cached = self.database.get_data_snapshot("auction_center_v5", cache_key)
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {
@@ -790,7 +804,13 @@ class MarketInsightsService:
stock = master.get(ts_code)
price = _number(row.get("price"))
pre_close = _number(row.get("pre_close"))
if not stock or price <= 0 or pre_close <= 0:
list_date = str((stock or {}).get("list_date") or "")
if (
not stock
or price <= 0
or pre_close <= 0
or (list_date and list_date >= data_date)
):
continue
change = (price / pre_close - 1) * 100
amount_million = _number(row.get("amount")) / 1_000_000
@@ -908,7 +928,7 @@ class MarketInsightsService:
"rows": candidates,
}
if not dynamic:
self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result)
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id)
def _theme_directory(self) -> list[dict[str, Any]]:
+581 -38
View File
@@ -8,6 +8,7 @@ from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
from advanced_strategies import ADVANCED_CURATED_STRATEGIES
from database import ReviewDatabase
from sentiment_engine import build_sentiment_history, latest_contiguous_history
from tushare_client import TushareClient, TushareError
@@ -23,18 +24,45 @@ REGIMES = {
}
FACTOR_FIELDS = {
"close": "收盘价",
"pct_chg": "当日涨幅",
"return_5d": "5日涨幅",
"return_10d": "10日涨幅",
"return_20d": "20日涨幅",
"return_60d": "60日涨幅",
"return_5d_rank": "5日涨幅排名",
"momentum_60_5": "中期动量",
"momentum_60_5_rank": "中期动量排名",
"above_ma20": "站上20日线",
"rsi_6": "RSI(6)",
"ma60_slope": "60日线斜率",
"ma20_slope_5d": "20日线5日斜率",
"ma_bull_alignment": "均线多头排列",
"drawdown_from_high_250": "距250日高点回撤",
"donchian_breakout_pct": "唐奇安突破幅度",
"range_20d": "20日振幅",
"rs_high_120": "RS线120日新高",
"excess_return_60d": "60日超额收益",
"weekly_trend_signal": "周线趋势信号",
"daily_buy_trigger": "日线买点",
"weekly_amount_trend": "周成交趋势",
"volume_ratio_5d": "5日量比",
"turnover_5d": "5日累计换手",
"volatility_10d": "10日波动率",
"amount_billion": "成交额",
"turnover_rate": "换手率",
"circ_mv_billion": "流通市值",
"net_flow_million": "主力净流入",
"large_flow_million": "大单净流入",
"net_flow_5d_million": "5日主力净流入",
"flow_to_circ_mv_5d": "5日净流入占流通市值",
"sector_strength": "板块强度",
"sector_return_5d": "行业5日涨幅",
"sector_return_20d": "行业20日涨幅",
"sector_momentum_rank": "行业20日动量排名",
"sector_stock_momentum_rank": "行业内个股动量排名",
"sector_net_flow_5d_million": "行业5日主力净流入",
"sector_flow_rank": "行业资金流排名",
"sector_limit_count": "板块涨停数",
"sector_up_count": "板块强势股数",
"relative_strength": "相对强度",
@@ -66,22 +94,52 @@ FACTOR_FIELDS = {
"previous_limit_signal": "昨日涨停或触板",
"previous_limit_streak": "昨日连板高度",
"previous_amount_billion": "昨日成交额",
"is_limit_up_today": "当日涨停",
"is_limit_down_today": "当日跌停",
"sector_breadth_ma20": "行业20日线宽度",
"no_limit_down_20d": "近20日无跌停",
"financial_risk": "财务风险标记",
"is_market_height": "当前市场最高板",
"new_space_board": "新晋空间板",
"max_continuous_board_10d": "近10日最高连板",
"dragon_first_yin": "龙头首阴",
"yin_day_pct": "首阴跌幅",
"vol_vs_previous": "较前日量能",
"broken_reversal": "断板反包",
"days_since_broken": "断板后天数",
"close_above_broken_high": "收复断板高点",
"vol_vs_broken_day": "较断板日量能",
"recent_limit_up_5d": "近5日涨停次数",
"intraday_min_pct": "盘中最大跌幅",
"lower_shadow_ratio": "下影线实体比",
}
FACTOR_GROUPS = {
"行情动量": [
"pct_chg", "return_5d", "return_10d", "above_ma20", "relative_strength",
"close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d",
"return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20",
"rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment",
"drawdown_from_high_250", "donchian_breakout_pct", "range_20d",
"rs_high_120", "excess_return_60d", "weekly_trend_signal",
"daily_buy_trigger", "weekly_amount_trend", "relative_strength",
"relative_position_60", "close_to_high_15d", "close_to_high_60d",
],
"量价交易": [
"volume_ratio_5d", "volatility_10d", "amount_billion", "turnover_rate",
"net_flow_million", "large_flow_million", "previous_amount_billion",
"volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate",
"net_flow_million", "large_flow_million", "net_flow_5d_million",
"flow_to_circ_mv_5d", "previous_amount_billion",
"intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day",
],
"板块结构": [
"sector_strength", "sector_limit_count", "sector_up_count", "sector_breadth_ma20",
"sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank",
"sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank",
"sector_limit_count", "sector_up_count", "sector_breadth_ma20",
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
"no_limit_30d", "had_limit_80d", "max_abs_change_15d",
"is_limit_up_today", "is_limit_down_today",
"no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d",
"is_market_height", "new_space_board", "max_continuous_board_10d",
"dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken",
"close_above_broken_high", "recent_limit_up_5d",
],
"竞价因子": [
"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio",
@@ -92,7 +150,7 @@ FACTOR_GROUPS = {
],
"财务质量": [
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
"ocf_to_opincome",
"ocf_to_opincome", "financial_risk",
],
}
@@ -496,6 +554,112 @@ CURATED_STRATEGIES = [
},
]
CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES)
STRATEGY_ENVIRONMENT_NOTES = {
"连续分红质量": (
"防守市、低利率环境与中长期配置窗口",
"风险偏好快速上升时,稳健资产的价格弹性通常落后",
),
"ROIC质量低波": (
"震荡偏弱、重视盈利质量与回撤控制的市场",
"主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向",
),
"低估值现金流白马": (
"估值修复、价值回归及防守配置阶段",
"低估值可能来自基本面持续走弱,需警惕价值陷阱",
),
"高增长合理估值": (
"业绩驱动、成长风格占优且趋势获得确认的阶段",
"增长预期下修或估值快速收缩时,回撤可能明显放大",
),
"行业宽度主线": (
"主线清晰、行业内部多数个股同步走强的行情",
"板块快速轮动时,宽度信号容易在确认后迅速衰减",
),
"首板低开": (
"情绪修复期的分歧转一致与首板次日承接",
"退潮加速或低开缺少量能承接时,弱势可能继续扩大",
),
"小碎步临界突破": (
"趋势蓄势、波动收敛后临近突破的结构市",
"无量突破或指数剧烈震荡时,容易形成冲高回落",
),
"连板龙头": (
"高度拓展、题材梯队完整且接力情绪活跃的阶段",
"亏钱效应扩散或高位股集中退潮时,接力风险很高",
),
"微盘三正": (
"小盘风格活跃、流动性宽松且风险偏好较高的行情",
"风格切向大盘或微盘流动性收缩时,组合波动会显著上升",
),
"首板高开弱转强": (
"竞价承接明确、短线情绪修复或主线发酵阶段",
"高开缺乏板块共振时,竞价强势可能转为盘中兑现",
),
"中期动量·强者恒强": (
"趋势延续、主升段及强弱分化清晰的行情",
"无趋势震荡或快速轮动中,动量信号容易反复失效",
),
"强者回调": (
"主升趋势未破、强势股完成良性回踩的窗口",
"趋势已反转时,回调信号可能演变为下跌中继",
),
"超跌反转": (
"急跌后恐慌释放充分、市场进入修复预期的阶段",
"单边下跌初段容易过早介入,超跌不等于止跌",
),
"相对强度新高": (
"指数偏弱但结构性主线明确,或机构抱团强化的行情",
"基准快速补涨或强势方向瓦解时,相对优势可能迅速消失",
),
"均线多头排列": (
"中期趋势向上、回撤有序的趋势市与主升段",
"高位趋势末端或宽幅震荡中,均线信号通常反应滞后",
),
"唐奇安通道突破": (
"整理末端、放量突破并启动新趋势的行情",
"无量突破和宽幅震荡环境中,假突破出现概率较高",
),
"周线趋势·日线买点": (
"中期趋势稳定、日线回踩或再启动的多周期共振阶段",
"周线拐点尚未确认时,日线信号可能只是短暂反抽",
),
"空间板": (
"市场高度持续拓展、板块梯队完整的强接力环境",
"高度压缩或亏钱效应扩散时,最高板的补跌风险极高",
),
"龙头首阴": (
"主线龙头仍有辨识度、首次分歧后存在回流预期的阶段",
"题材退潮或龙头地位被替代后,首阴可能只是下跌起点",
),
"断板反包": (
"强势题材分歧后快速修复、核心股重新获得资金承接时",
"板块强度不足或反包缩量时,形态持续性通常较弱",
),
"核按钮反核": (
"恐慌释放后出现明确承接、短线情绪转暖的窗口",
"系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高",
),
"行业动量轮动": (
"主线相对清晰、行业趋势能够延续两周以上的结构市",
"行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减",
),
"主力资金行业流入": (
"板块轮动初期、资金先于价格形成连续净流入的阶段",
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
),
}
for strategy in CURATED_STRATEGIES:
suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]]
strategy["formula"]["meta"].update(
{
"suitable_environment": suitable_environment,
"failure_risk": failure_risk,
}
)
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
@@ -521,6 +685,7 @@ class FactorDataService:
self.client = client
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
lookback = max(25, min(260, int(lookback)))
trade_date, _ = self.client.resolve_trade_context(requested_date)
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
@@ -532,9 +697,11 @@ class FactorDataService:
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
existing = set(self.database.factor_dates(trade_date, lookback + 10))
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
existing_auction = set(self.database.auction_factor_dates(trade_date, lookback + 10))
auction_source_dates = dates[-min(80, len(dates)):]
existing_auction = set(self.database.auction_factor_dates(trade_date, 90))
auction_dates_to_fetch = [
value for value in dates if value not in existing_auction or value == trade_date
value for value in auction_source_dates
if value not in existing_auction or value == trade_date
]
long_calendar = self.client.query(
"trade_cal",
@@ -551,7 +718,7 @@ class FactorDataService:
if row.get("is_open") == 1 and row.get("cal_date"):
value = str(row["cal_date"])
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
valuation_dates = set(dates)
valuation_dates = set(dates[-min(80, len(dates)):])
valuation_dates.update(last_open_by_year.values())
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
indicator_dates_to_fetch = sorted(
@@ -584,6 +751,16 @@ class FactorDataService:
indicator_count += self.database.upsert_daily_indicators(indicators)
notices = []
benchmark_count = 0
try:
benchmark_rows = self.client.query(
"index_daily",
{"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date},
"ts_code,trade_date,close,pct_chg",
)
benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows)
except TushareError as exc:
notices.append(f"沪深300基准暂不可用:{exc}")
fundamental_count = 0
existing_periods = set(self.database.fundamental_periods())
for period in _quarter_periods(trade_date, 9):
@@ -620,17 +797,22 @@ class FactorDataService:
except TushareError as exc:
notices.append(f"竞价因子接口不可用:{exc}")
break
try:
moneyflow = self.client.query(
"moneyflow",
{"trade_date": trade_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
moneyflow_count = self.database.upsert_moneyflow(moneyflow)
except TushareError as exc:
moneyflow_count = 0
notices.append(f"资金流接口不可用:{exc}")
moneyflow_count = 0
moneyflow_dates = 0
for current_date in dates[-min(5, len(dates)):]:
try:
moneyflow = self.client.query(
"moneyflow",
{"trade_date": current_date},
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
)
moneyflow_count += self.database.upsert_moneyflow(moneyflow)
if moneyflow:
moneyflow_dates += 1
except TushareError as exc:
notices.append(f"资金流接口不可用:{exc}")
break
return {
"trade_date": trade_date,
@@ -638,10 +820,12 @@ class FactorDataService:
"fetched_dates": len(dates_to_fetch),
"stocks": master_count,
"bars": bar_count,
"benchmark_bars": benchmark_count,
"indicators": indicator_count,
"indicator_dates": len(indicator_dates_to_fetch),
"fundamentals": fundamental_count,
"moneyflow": moneyflow_count,
"moneyflow_dates": moneyflow_dates,
"auction_rows": auction_count,
"auction_dates": auction_dates,
"notice": "".join(notices),
@@ -651,6 +835,7 @@ class FactorDataService:
class ScreenerEngine:
def __init__(self, database: ReviewDatabase) -> None:
self.database = database
self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {}
def ensure_builtin_strategies(self) -> None:
existing = {
@@ -667,7 +852,7 @@ class ScreenerEngine:
def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history(
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 240))
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260))
)
if not series:
return {
@@ -747,12 +932,32 @@ class ScreenerEngine:
strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None,
mode: str = "smart",
prepared_factors: list[dict[str, Any]] | None = None,
prepared_date: str = "",
) -> dict[str, Any]:
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
formula = self.validate_formula(formula)
factors, actual_date = self.build_factors(trade_date, realtime_snapshot)
if prepared_factors is None:
history_days = int((formula.get("meta") or {}).get("history_days") or 80)
factors, actual_date = self.build_factors(
trade_date, realtime_snapshot, history_days
)
else:
factors = prepared_factors
actual_date = prepared_date or trade_date
candidates = self.apply_formula(factors, formula, regime)
backtest = self.backtest(actual_date, formula) if run_backtest else None
required_fields = sorted({
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
if item.get("field")
})
complete_rows = sum(
1 for row in factors
if all(row.get(field) is not None for field in required_fields)
)
coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0
health_status = "normal" if candidates else "no_signal"
if backtest and backtest["samples"] >= 20:
for candidate in candidates:
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
@@ -769,9 +974,20 @@ class ScreenerEngine:
"regime_label": REGIMES.get(regime, regime),
"strategy_name": strategy_name,
"mode": mode,
"library_version": int(
(formula.get("meta") or {}).get("library_version") or 0
),
"universe_count": len(factors),
"candidate_count": len(candidates),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"health": {
"status": health_status,
"required_field_count": len(required_fields),
"complete_rows": complete_rows,
"universe_rows": len(factors),
"coverage": coverage,
"signal_count": len(candidates),
},
"selection_source": (
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
),
@@ -791,7 +1007,11 @@ class ScreenerEngine:
"formula": formula,
"candidates": candidates,
"backtest": backtest,
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。",
"disclaimer": (
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
if mode == "curated"
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
),
}
run_id = self.database.save_screener_run(
user_id, actual_date, regime, strategy_name, formula, result, mode
@@ -803,8 +1023,10 @@ class ScreenerEngine:
self,
trade_date: str,
realtime_snapshot: dict[str, Any] | None = None,
history_days: int = 80,
) -> tuple[list[dict[str, Any]], str]:
data = self.database.load_factor_data(trade_date, 80)
history_days = max(21, min(260, int(history_days)))
data = self.database.load_factor_data(trade_date, history_days)
dates = [value for value in data["dates"] if value <= trade_date]
if len(dates) < 21:
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
@@ -822,7 +1044,18 @@ class ScreenerEngine:
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_history", []):
indicator_history[str(row.get("ts_code") or "")].append(row)
indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("indicator_series", []):
indicator_series[str(row.get("ts_code") or "")].append(row)
benchmark_by_date = {
str(row.get("trade_date") or ""): _number(row.get("close"))
for row in data.get("benchmarks", [])
if _number(row.get("close")) > 0
}
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in data.get("moneyflow_history", []):
moneyflow_history[str(row.get("ts_code") or "")].append(row)
auction = {
row["ts_code"]: row
for row in data.get("auction", [])
@@ -863,6 +1096,7 @@ class ScreenerEngine:
indicator = indicators.get(ts_code, {})
fundamental = fundamentals.get(ts_code, {})
flow = moneyflow.get(ts_code, {})
flow_history = moneyflow_history.get(ts_code, [])
auction_row = auction.get(ts_code, {})
list_date = str(info.get("list_date") or "")
try:
@@ -906,6 +1140,72 @@ class ScreenerEngine:
dividend_years = sum(
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
)
current_streak = _ending_streak(limit_flags)
prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2)
streak = max(streak, current_streak)
return_60d = (
(closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
momentum_60_5 = (
(closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
)
ma20 = statistics.fmean(closes[-20:])
ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20
prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20
prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60
ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0
ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0
ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)]
high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high)
drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100
prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0
breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0
prior_lows_20 = shape_low[-21:-1]
range_20d = (
(prior_high_20 / min(prior_lows_20) - 1) * 100
if prior_lows_20 and min(prior_lows_20) > 0 else 100
)
turnover_rows = sorted(
indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "")
)
turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]]
if realtime and _number(realtime.get("turnover_rate")):
turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))]
turnover_5d = sum(turnover_values)
rs_values = [
_number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))]
for item in shape_rows[-120:]
if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0
]
benchmark_60 = [
benchmark_by_date.get(str(item.get("trade_date")))
for item in shape_rows[-61:]
if benchmark_by_date.get(str(item.get("trade_date")))
]
benchmark_return_60 = (
(benchmark_60[-1] / benchmark_60[0] - 1) * 100
if len(benchmark_60) >= 61 and benchmark_60[0] else 0
)
weekly_closes, weekly_amounts = _weekly_series(shape_rows)
weekly_dif, weekly_dea = _macd_last(weekly_closes)
daily_dif, daily_dea = _macd_series(closes)
daily_cross = (
len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1]
and daily_dif[-2] <= daily_dea[-2]
)
current_open = _number(current.get("open"))
daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open
previous_close = closes[-2] if len(closes) >= 2 else closes[-1]
intraday_min = (
(_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0
)
body = abs(closes[-1] - current_open)
lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low")))
lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0)
previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
factors.append(
{
"code": code,
@@ -914,12 +1214,32 @@ class ScreenerEngine:
"sector": info.get("industry") or "其他",
"market": info.get("market") or "--",
"listed_days": listed_days,
"close": round(closes[-1], 2),
"price": round(closes[-1], 2),
"pct_chg": round(_number(current["pct_chg"]), 2),
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
"return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2),
"above_ma20": int(closes[-1] > statistics.fmean(closes[-20:])),
"return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2),
"return_60d": round(return_60d, 2),
"momentum_60_5": round(momentum_60_5, 2),
"above_ma20": int(closes[-1] > ma20),
"rsi_6": round(_rsi(closes, 6), 2),
"ma60_slope": round(ma60_slope, 3),
"ma20_slope_5d": round(ma20_slope, 3),
"ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]),
"drawdown_from_high_250": round(drawdown_250, 2),
"donchian_breakout_pct": round(breakout_pct, 2),
"range_20d": round(range_20d, 2),
"rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)),
"excess_return_60d": round(return_60d - benchmark_return_60, 2),
"weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0),
"daily_buy_trigger": int(daily_cross or daily_pullback),
"weekly_amount_trend": int(
len(weekly_amounts) >= 5
and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1])
),
"volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0,
"turnover_5d": round(turnover_5d, 2),
"volatility_10d": round(statistics.pstdev(returns_10), 2),
"amount_billion": round(
_number(current["amount"]) / (100000000 if realtime else 100000), 2
@@ -945,8 +1265,19 @@ class ScreenerEngine:
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"net_flow_5d_million": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100,
2,
),
"flow_to_circ_mv_5d": round(
sum(_number(item.get("net_mf_amount")) for item in flow_history)
/ _number(indicator.get("circ_mv")) * 100,
4,
) if _number(indicator.get("circ_mv")) else 0,
"limit_status": status,
"limit_streak": streak,
"is_limit_up_today": int(limit_flags[-1]),
"is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)),
"auction_change": round(_number(auction_row.get("change")), 2),
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4),
@@ -957,6 +1288,28 @@ class ScreenerEngine:
"close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0,
"no_limit_30d": int(not any(limit_flags[-30:])),
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
"no_limit_down_20d": int(not any(
_number(item.get("pct_chg")) <= -_limit_threshold(code, name)
for item in shape_rows[-20:]
)),
"financial_risk": int(
"ST" in name.upper() or "退" in name
or (netprofit_yoy is not None and netprofit_yoy <= -100)
),
"prior_limit_streak": prior_streak,
"max_continuous_board_10d": _max_streak(limit_flags[-10:]),
"dragon_first_yin": int(
prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open
),
"yin_day_pct": round(_number(current.get("pct_chg")), 2),
"vol_vs_previous": round(vol_vs_previous, 3),
"broken_reversal": broken["signal"],
"days_since_broken": broken["days"],
"close_above_broken_high": broken["recovered"],
"vol_vs_broken_day": broken["volume_ratio"],
"recent_limit_up_5d": sum(limit_flags[-5:]),
"intraday_min_pct": round(intraday_min, 2),
"lower_shadow_ratio": round(lower_shadow_ratio, 2),
"previous_first_limit": int(previous_limit and not recent_prior_signal),
"previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
"previous_limit_streak": previous_streak,
@@ -968,18 +1321,65 @@ class ScreenerEngine:
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in factors:
sectors[row["sector"]].append(row)
for sector_rows in sectors.values():
sector_metrics = []
for sector_name, sector_rows in sectors.items():
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows)
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
sector_metrics.append(
{
"ts_code": sector_name,
"sector_return_20d": average_return_20d,
"sector_net_flow_5d_million": sector_net_flow,
}
)
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
for row in sector_rows:
row["sector_strength"] = round(strength, 1)
row["sector_return_5d"] = round(average_return, 2)
row["sector_return_20d"] = round(average_return_20d, 2)
row["sector_net_flow_5d_million"] = round(sector_net_flow, 2)
row["sector_stock_momentum_rank"] = round(
stock_momentum_ranks.get(row["ts_code"], 0.0), 4
)
row["sector_limit_count"] = limit_count
row["sector_up_count"] = up_count
row["sector_breadth_ma20"] = round(breadth_ma20, 1)
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
sector_momentum_ranks = _percentile_map(
sector_metrics, "sector_return_20d", "desc"
)
sector_flow_ranks = _percentile_map(
sector_metrics, "sector_net_flow_5d_million", "desc"
)
for sector_name, sector_rows in sectors.items():
for row in sector_rows:
row["sector_momentum_rank"] = round(
sector_momentum_ranks.get(sector_name, 0.0), 4
)
row["sector_flow_rank"] = round(
sector_flow_ranks.get(sector_name, 0.0), 4
)
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
return_ranks = _percentile_map(factors, "return_5d", "desc")
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0)
for row in factors:
row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4)
row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4)
is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height
row["is_market_height"] = int(is_height)
row["new_space_board"] = int(
is_height
and not (
prior_market_height >= 2
and int(row.get("prior_limit_streak") or 0) == prior_market_height
)
)
return factors, actual_date
def apply_formula(
@@ -1030,20 +1430,50 @@ class ScreenerEngine:
item["score_display"] = round(score * 100, 1)
item["contributions"] = contributions
item["reason"] = "".join(entry["label"] for entry in contributions[:3])
item["risk_flags"] = _risk_flags(row, regime)
include_regime_risk = formula.get("meta", {}).get("library") != "curated"
item["risk_flags"] = _risk_flags(row, regime, include_regime_risk)
results.append(item)
results.sort(key=lambda item: item["score"], reverse=True)
return results[: formula["limit"]]
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
dates = self.database.factor_dates(trade_date, 55)
evaluation_dates = dates[20:-3][-8:]
meta = formula.get("meta") or {}
history_days = max(21, min(260, int(meta.get("history_days") or 80)))
holding_days = max(1, min(30, int(meta.get("backtest_days") or 3)))
take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3)))
stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3)))
dates = self.database.factor_dates(trade_date, history_days + holding_days + 20)
eligible_dates = dates[:-holding_days] if len(dates) > holding_days else []
frequency = str(meta.get("frequency") or "每日")
if "" in frequency:
grouped = {}
for value in eligible_dates:
grouped[value[:6]] = value
evaluation_dates = list(grouped.values())[-8:]
elif "双周" in frequency:
weekly_dates = []
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
weekly_dates = list(grouped.values())
evaluation_dates = weekly_dates[-16::2][-8:]
elif "" in frequency:
grouped = {}
for value in eligible_dates:
parsed = datetime.strptime(value, "%Y%m%d")
grouped[parsed.strftime("%G-%V")] = value
evaluation_dates = list(grouped.values())[-8:]
else:
evaluation_dates = eligible_dates[-8:]
wins = 0
losses = 0
samples = 0
returns = []
drawdowns = []
all_data = self.database.load_factor_data(trade_date, 60)
all_data = self.database.load_factor_data(
trade_date, history_days + holding_days + 20
)
bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in all_data["bars"]:
bars_by_code[row["ts_code"]].append(row)
@@ -1052,15 +1482,25 @@ class ScreenerEngine:
for current_date in evaluation_dates:
try:
factors, _ = self.build_factors(current_date)
cache_key = (current_date, history_days)
factors = self._backtest_factor_cache.get(cache_key)
if factors is None:
factors, _ = self.build_factors(
current_date, history_days=history_days
)
if len(self._backtest_factor_cache) >= 64:
self._backtest_factor_cache.pop(
next(iter(self._backtest_factor_cache))
)
self._backtest_factor_cache[cache_key] = factors
except ValueError:
continue
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
for candidate in selected:
bars = bars_by_code.get(candidate["ts_code"], [])
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1)
future = bars[index + 1:index + 4] if index >= 0 else []
if len(future) < 3:
future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
if len(future) < holding_days:
continue
entry = candidate["price"]
won = False
@@ -1068,10 +1508,10 @@ class ScreenerEngine:
for day in future:
low_return = (_number(day["low"]) / entry - 1) * 100
high_return = (_number(day["high"]) / entry - 1) * 100
if low_return <= -3:
if low_return <= stop_loss:
lost = True
break
if high_return >= 3:
if high_return >= take_profit:
won = True
break
if won:
@@ -1087,9 +1527,17 @@ class ScreenerEngine:
"losses": losses,
"win_rate": round(wins / samples * 100, 1) if samples else 0,
"average_3d_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_holding_return": round(statistics.fmean(returns), 2) if returns else 0,
"average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
"evaluation_days": len(evaluation_dates),
"definition": "收盘后选股,未来3日先触及+3%且未先触及-3%计为成功;同日双触发按失败处理。",
"frequency": frequency,
"holding_days": holding_days,
"take_profit": take_profit,
"stop_loss": stop_loss,
"definition": (
f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及"
f"{stop_loss:g}%计为成功;同日双触发按失败处理。"
),
"approximate": True,
}
@@ -1160,6 +1608,99 @@ def _limit_threshold(code: str, name: str) -> float:
return 9.5
def _ending_streak(flags: list[bool], end_index: int | None = None) -> int:
if not flags:
return 0
index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1)
streak = 0
while index >= 0 and flags[index]:
streak += 1
index -= 1
return streak
def _max_streak(flags: list[bool]) -> int:
best = current = 0
for value in flags:
current = current + 1 if value else 0
best = max(best, current)
return best
def _rsi(values: list[float], period: int = 6) -> float:
if len(values) <= period:
return 50.0
changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))]
gains = sum(max(change, 0.0) for change in changes) / period
losses = sum(max(-change, 0.0) for change in changes) / period
if losses == 0:
return 100.0 if gains > 0 else 50.0
return 100 - 100 / (1 + gains / losses)
def _ema(values: list[float], period: int) -> list[float]:
if not values:
return []
alpha = 2 / (period + 1)
result = [values[0]]
for value in values[1:]:
result.append(value * alpha + result[-1] * (1 - alpha))
return result
def _macd_series(values: list[float]) -> tuple[list[float], list[float]]:
fast = _ema(values, 12)
slow = _ema(values, 26)
dif = [left - right for left, right in zip(fast, slow)]
return dif, _ema(dif, 9)
def _macd_last(values: list[float]) -> tuple[float, float]:
dif, dea = _macd_series(values)
return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0)
def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
weeks: dict[str, tuple[float, float]] = {}
for row in rows:
trade_date = str(row.get("trade_date") or "")
try:
key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V")
except ValueError:
continue
close = _number(row.get("close"))
amount = _number(row.get("amount"))
previous = weeks.get(key, (close, 0.0))
weeks[key] = (close, previous[1] + amount)
ordered = list(weeks.values())
return [item[0] for item in ordered], [item[1] for item in ordered]
def _broken_reversal_metrics(
rows: list[dict[str, Any]], flags: list[bool], code: str, name: str,
) -> dict[str, Any]:
result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0}
if not rows or not flags[-1]:
return result
current_close = _number(rows[-1].get("close"))
current_volume = _number(rows[-1].get("vol"))
for days in range(1, 4):
index = len(rows) - 1 - days
if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2:
continue
broken_high = _number(rows[index].get("high"))
broken_volume = _number(rows[index].get("vol"))
recovered = int(current_close >= broken_high > 0)
volume_ratio = current_volume / broken_volume if broken_volume else 0.0
return {
"signal": int(recovered and volume_ratio >= 1),
"days": days,
"recovered": recovered,
"volume_ratio": round(volume_ratio, 3),
}
return result
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
if index < 0 or index >= len(rows):
return False
@@ -1212,7 +1753,9 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
return result
def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
def _risk_flags(
row: dict[str, Any], regime: str, include_regime_risk: bool = True
) -> list[str]:
flags = []
if row.get("pct_chg", 0) >= 9.5:
flags.append("当日接近涨停,次日存在高开与无法成交风险")
@@ -1222,7 +1765,7 @@ def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
flags.append("波动率偏高")
if row.get("amount_billion", 0) < 1:
flags.append("成交承载力偏弱")
if regime == "retreat":
if include_regime_risk and regime == "retreat":
flags.append("市场处于退潮阶段,策略可能选择空仓")
return flags
+15 -3
View File
@@ -13,6 +13,8 @@ COMPONENT_WEIGHTS = {
"liquidity": 10,
}
SENTIMENT_ENGINE_VERSION = 2
def _number(value: Any, default: float = 0.0) -> float:
try:
@@ -43,7 +45,7 @@ def _percentile(value: float, history: list[float]) -> float:
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
return fixed * 0.4 + _percentile(value, history[-120:]) * 0.6
return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75
def _trade_date(payload: dict[str, Any]) -> str:
@@ -274,7 +276,9 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
systemic_health = breadth_score * 0.60 + down_relief * 0.40
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
limit_ecology_score = ecology_base_score * (0.25 + systemic_gate * 0.75)
# Systemic risk is applied once to the final temperature. Reapplying it here
# would count market breadth and limit-down pressure twice.
limit_ecology_score = ecology_base_score
if stats["previous_limit_count"]:
positive_score = float(stats["previous_positive_rate"])
@@ -357,6 +361,11 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
else None
)
day_change = score - float(previous_result["score"]) if previous_result else 0.0
ema_score = round(
score if not previous_result
else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5,
1,
)
phase_signal = _phase_signal(score, momentum, profit_effect_score)
fermentation_ready = (
phase_signal == "发酵"
@@ -428,6 +437,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
{
**stats,
"score": score,
"ema_score": ema_score,
"label": _sentiment_label(score),
"phase": phase,
"phase_signal": phase_signal,
@@ -436,7 +446,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
"day_change": round(day_change, 1),
"direction": direction,
"momentum": round(momentum, 1),
"normalization": normalization,
"normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
"history_days": len(previous) + 1,
"systemic_health": round(systemic_health, 1),
"risk_multiplier": round(systemic_gate, 3),
@@ -474,10 +484,12 @@ def apply_sentiment_to_dashboard(
overview.update(
{
"sentiment_score": sentiment["score"],
"sentiment_trend_score": sentiment["ema_score"],
"sentiment_label": sentiment["label"],
"sentiment_phase": sentiment["phase"],
"sentiment_direction": sentiment["direction"],
"sentiment_components": sentiment["components"],
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
}
)
result["overview"] = overview
+346 -35
View File
@@ -66,6 +66,7 @@ from screener import (
from security import SecretVault, hash_password, token_hash, verify_password
from sentiment_engine import (
COMPONENT_WEIGHTS,
SENTIMENT_ENGINE_VERSION,
apply_sentiment_to_dashboard,
build_sentiment_history,
latest_contiguous_history,
@@ -75,6 +76,30 @@ from trade_journal import TradeJournalService
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
SCREENER_LIBRARY_VERSION = 7
def automatic_screener_jobs(
strategies: list[dict[str, Any]], regime_id: str
) -> list[dict[str, Any]]:
"""Build the close-of-day jobs; only stage screening is regime-gated."""
smart_strategy = next(
(
item for item in strategies
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
and regime_id in (item.get("regimes") or [])
),
None,
)
curated = [
item for item in strategies
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
]
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
return jobs
LEGACY_SECRET_KEYS = {
"TUSHARE_TOKEN",
"IFIND_REFRESH_TOKEN",
@@ -175,6 +200,8 @@ class DashboardService:
self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock()
self.system_lock = threading.Lock()
self.auto_screener_lock = threading.Lock()
self._auto_screener_last_attempt: dict[str, datetime] = {}
self._ifind_event_lock = threading.Lock()
self._request_context = threading.local()
self._system_credentials = self._load_system_credentials(environment_credentials)
@@ -788,6 +815,7 @@ class DashboardService:
snapshot = self.database.get_snapshot(today) or {}
if self._realtime_snapshot_due(today, snapshot):
self._run_background_sync(today)
self._schedule_automatic_screeners(today, snapshot)
except Exception:
pass
self._background_stop.wait(5)
@@ -936,6 +964,11 @@ class DashboardService:
)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
self.database.save_snapshot(
normalized_date,
str((snapshot.get("meta") or {}).get("source") or "tushare"),
snapshot,
)
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot(
@@ -968,7 +1001,7 @@ class DashboardService:
@staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {}
return all(
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
key in overview
for key in (
"sentiment_score",
@@ -1091,7 +1124,7 @@ class DashboardService:
dashboard: dict[str, Any],
end_date: str,
) -> dict[str, Any]:
history = self.database.list_snapshot_payloads(end_date, 240)
history = self.database.list_snapshot_payloads(end_date, 260)
return apply_sentiment_to_dashboard(dashboard, history)
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
@@ -1168,6 +1201,103 @@ class DashboardService:
"rows": rows,
}
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
dashboard = self.get_dashboard(normalized_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
)
cache_key = f"{actual_date}:{sector_name}"
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
if cached:
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
return cached
if not self.configured:
raise ValueError("板块成分数据暂不可用。")
representative = next(
(
item for item in dashboard.get("limits") or []
if str(item.get("sector") or "").strip() == sector_name
),
None,
)
if not representative:
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
if "." in raw_code:
ts_code = raw_code
elif raw_code.startswith(("4", "8", "92")):
ts_code = f"{raw_code}.BJ"
elif raw_code.startswith(("6", "68", "90")):
ts_code = f"{raw_code}.SH"
else:
ts_code = f"{raw_code}.SZ"
client = TushareClient(self.token)
try:
industry = client.sw_stock_industry(ts_code, actual_date)
sector_code = str(industry.get("l2_code") or "")
members = client.sw_sector_members(sector_code, actual_date)
except TushareError as exc:
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
daily_rows = self.database.daily_bars_for_date(actual_date)
if len(daily_rows) < 1000:
try:
daily_rows = client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if daily_rows:
self.database.upsert_daily_bars(daily_rows)
except TushareError:
daily_rows = self.database.daily_bars_for_date(actual_date)
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
rows = []
for member in members:
member_code = str(member.get("ts_code") or "")
quote = daily_map.get(member_code) or {}
rows.append(
{
"code": member_code.split(".")[0],
"ts_code": member_code,
"name": str(member.get("name") or "--"),
"change": quote.get("pct_chg"),
"open": quote.get("open"),
"close": quote.get("close"),
"amount_billion": (
round(float(quote.get("amount") or 0) / 100000, 2)
if quote else None
),
"quoted": bool(quote),
}
)
rows.sort(
key=lambda item: (
bool(item.get("quoted")),
float(item.get("change") or -999),
float(item.get("amount_billion") or 0),
),
reverse=True,
)
result = {
"meta": {
"trade_date": self._display_compact_date(actual_date),
"sector_name": str(industry.get("l2_name") or sector_name),
"sector_code": sector_code,
"member_count": len(rows),
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
"cached": False,
},
"rows": rows,
}
self.database.save_data_snapshot(
"rotation_sector_members_v1", cache_key, "tushare", result
)
return result
def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status()
return {
@@ -1234,35 +1364,63 @@ class DashboardService:
return match.group(1)
return ""
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 100)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
@staticmethod
def _strategy_missing_data(
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
) -> list[str]:
formula = strategy.get("formula") or {}
meta = formula.get("meta") or {}
used_fields = {
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
}
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
missing = []
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
if len(factor_dates) < required_history:
missing.append(f"历史行情(需{required_history}日)")
if used_fields & valuation_fields and not factor_health["valuation"]:
missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量")
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]:
missing.append("竞价数据")
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
missing.append("沪深300基准")
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
missing.append("近5日资金流")
return missing
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 300)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
formula = strategy.get("formula") or {}
used_fields = {
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
}
missing = []
if len(factor_dates) < 21:
missing.append("基础行情")
if used_fields & valuation_fields and not factor_health["valuation"]:
missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量")
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]:
missing.append("竞价数据")
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
personal_results = self.database.screener_runs_for_date(
self.current_user_id, normalized_date
)
recent_results = [
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
]
latest_results: dict[str, dict[str, Any]] = {}
for result in reversed(recent_results):
mode = str(result.get("meta", {}).get("mode") or "smart")
latest_results[mode] = result
automatic_status = self.database.get_data_snapshot(
"screener_auto_v1", normalized_date
) or {}
return {
"trade_date": normalized_date,
"regime": regime,
@@ -1292,16 +1450,11 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
"latest_results": self.database.latest_screener_runs(
self.current_user_id, normalized_date
),
"recent_results": self.database.latest_screener_context_runs(
self.current_user_id, normalized_date
),
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
# Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run(
self.current_user_id, normalized_date, "smart"
),
"latest_result": latest_results.get("smart"),
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
@@ -1565,12 +1718,158 @@ class DashboardService:
if not self.configured:
raise ValueError("请先配置 Tushare Token。")
normalized_date = normalize_date(trade_date)
lookback = max(25, min(80, int(lookback)))
lookback = max(25, min(260, int(lookback)))
with self.sync_lock:
return FactorDataService(self.database, TushareClient(self.token)).sync(
normalized_date, lookback
)
def _schedule_automatic_screeners(
self, trade_date: str, snapshot: dict[str, Any] | None = None
) -> bool:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
if (
normalized_date != now.strftime("%Y%m%d")
or now.weekday() >= 5
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
or self.auto_screener_lock.locked()
):
return False
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
if actual_date != normalized_date:
return False
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
if (
marker.get("status") == "complete"
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
):
return False
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
if last_attempt and (now - last_attempt).total_seconds() < 600:
return False
self._auto_screener_last_attempt[normalized_date] = now
threading.Thread(
target=self.run_automatic_screeners,
args=(normalized_date,),
name=f"automatic-screeners-{normalized_date}",
daemon=True,
).start()
return True
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
with self.auto_screener_lock:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
status: dict[str, Any] = {
"trade_date": normalized_date,
"library_version": SCREENER_LIBRARY_VERSION,
"status": "running",
"started_at": started_at,
"completed": [],
"skipped": [],
"failed": [],
}
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
try:
factor_sync = FactorDataService(
self.database, TushareClient(self.token)
).sync(normalized_date, 260)
factor_dates = self.database.factor_dates(normalized_date, 300)
if not factor_dates or factor_dates[-1] != normalized_date:
raise ValueError("当日收盘行情尚未入库")
factor_health = self.screener.factor_health(normalized_date)
regime = self.screener.detect_regime(normalized_date)
regime_id = str(regime.get("id") or "repair")
strategies = self.database.list_screener_strategies(None)
jobs = automatic_screener_jobs(strategies, regime_id)
existing = {
(
str(item.get("meta", {}).get("mode") or "smart"),
str(item.get("meta", {}).get("strategy_name") or ""),
)
for item in self.database.screener_runs_for_date(0, normalized_date)
if int(item.get("meta", {}).get("library_version") or 0)
== SCREENER_LIBRARY_VERSION
}
required_history = max(
[
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
for job in jobs if job.get("strategy")
] or [80]
)
factors, actual_date = self.screener.build_factors(
normalized_date, history_days=required_history
)
if actual_date != normalized_date:
raise ValueError("当日因子尚未完成收盘定格")
for job in jobs:
strategy = job["strategy"]
mode = str(job["mode"])
name = str(strategy.get("name") or "未命名策略")
if (mode, name) in existing:
status["completed"].append({"mode": mode, "name": name, "cached": True})
continue
missing = self._strategy_missing_data(
strategy, factor_dates, factor_health
)
if missing:
status["skipped"].append(
{"mode": mode, "name": name, "reason": "".join(missing)}
)
continue
try:
formula = copy.deepcopy(strategy.get("formula") or {})
formula.setdefault("meta", {})["library_version"] = (
SCREENER_LIBRARY_VERSION
)
result = self.screener.screen(
0,
normalized_date,
formula,
regime_id,
name,
False,
None,
mode,
factors,
actual_date,
)
status["completed"].append(
{
"mode": mode,
"name": name,
"candidate_count": len(result.get("candidates") or []),
}
)
except Exception as exc:
status["failed"].append(
{"mode": mode, "name": name, "reason": str(exc)}
)
status.update(
{
"status": "complete" if not status["failed"] else "partial",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"factor_sync": factor_sync,
"regime": regime,
}
)
except Exception as exc:
status.update(
{
"status": "failed",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"error": str(exc),
}
)
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
return status
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
prompt = prompt.strip()
if not prompt or len(prompt) > 3000:
@@ -4641,6 +4940,18 @@ class RequestHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/rotation/members":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.rotation_sector_members(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("sector", [""])[0],
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
+257 -207
View File
@@ -53,6 +53,10 @@ const state = {
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
rotationSelectedDate: "",
rotationMembers: null,
rotationMembersKey: "",
rotationMembersLoading: false,
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
rotationLoading: false,
auctionData: null,
@@ -104,6 +108,7 @@ const state = {
screenerSetupPromise: null,
selectedRegime: "",
selectedStrategy: null,
customStrategyDraft: null,
screenerRunning: false,
screenerRunningMode: "",
screenerResults: { smart: null, curated: null, quant: null },
@@ -114,7 +119,9 @@ const state = {
? localStorage.getItem("xiaobaiScreenerMode")
: "smart",
curatedCategory: "全部",
curatedSchool: "全部",
curatedQuery: "",
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
selectedCuratedStrategyId: 0,
quantFilters: [],
quantScores: [],
@@ -519,8 +526,6 @@ async function applyAuthenticatedSession(session) {
document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true;
applyMembershipAccess();
await startAuthenticatedApp();
@@ -815,8 +820,7 @@ function bindEvents() {
}
renderAuctionTable();
});
document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library"));
document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
@@ -911,8 +915,6 @@ function bindEvents() {
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
await loadScreenerTracking(true);
openView("screenerTrackingView");
@@ -943,11 +945,22 @@ function bindEvents() {
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy);
document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close());
document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => {
if (event.target === event.currentTarget) event.currentTarget.close();
document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
state.curatedCategory = event.target.value;
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => {
const button = event.target.closest("[data-curated-school]");
if (!button) return;
state.curatedSchool = button.dataset.curatedSchool;
renderCuratedStrategyLibrary();
});
document.querySelectorAll("[data-curated-view]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list";
localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode);
renderCuratedStrategyLibrary();
});
});
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
@@ -1161,7 +1174,7 @@ function renderDashboard() {
renderYesterdayTable(state.dashboard.yesterday_limits || []);
renderPerformance(state.dashboard.limit_performance || []);
renderLadderBoard(ladders || []);
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
renderRotationMembers();
}
async function loadSentimentHistory(force = false) {
@@ -1787,6 +1800,7 @@ function updateYesterdayControls() {
}
function renderPerformance(rows) {
rows = normalizePerformanceRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
@@ -1803,6 +1817,39 @@ function renderPerformance(rows) {
renderMarketBreadth(state.dashboard?.overview || {});
}
function normalizePerformanceRows(rows) {
const groups = new Map();
(rows || []).forEach((row) => {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups.get(displayLevel) || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}`,
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups.set(displayLevel, group);
});
return [...groups.values()]
.sort((left, right) => right.level - left.level)
.map((group) => ({
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
}));
}
function performanceRateState(rate) {
const value = number(rate);
if (value === 0) return { label: "失效", className: "is-neutral" };
@@ -1956,8 +2003,9 @@ function renderRotationHistory() {
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
renderRotationHistory();
updateRotationTableSelection();
loadRotationMembers("");
});
} else {
tracker.hidden = true;
@@ -1974,7 +2022,7 @@ function renderRotationHistory() {
const strength = clamp(number(sector.strength), 0, 100);
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
return `
<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-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · ${number(sector.rank)} · 涨停 ${number(sector.count)} · 强度 ${formatNumber(sector.strength, 0)}</span>
</button>`;
@@ -1983,58 +2031,76 @@ function renderRotationHistory() {
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
? ""
: button.dataset.rotationSector;
const clickedSector = button.dataset.rotationSector;
const clickedDate = button.dataset.rotationDate;
const isSameSelection = clickedSector === state.rotationSelectedSector
&& clickedDate === state.rotationSelectedDate;
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
renderRotationHistory();
updateRotationTableSelection();
loadRotationMembers(state.rotationSelectedSector);
});
});
}
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
async function loadRotationMembers(sector, force = false) {
if (!sector) {
state.rotationMembers = null;
state.rotationMembersKey = "";
renderRotationMembers();
return;
}
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
const key = `${memberDate}:${sector}`;
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
renderRotationMembers();
return;
}
state.rotationMembersLoading = true;
renderRotationMembers();
try {
const query = new URLSearchParams({ trade_date: memberDate, sector });
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
state.rotationMembersKey = key;
} catch (error) {
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
state.rotationMembersKey = key;
} finally {
state.rotationMembersLoading = false;
renderRotationMembers();
}
}
function renderRotationMembers() {
const body = document.querySelector("#rotationTableBody");
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`);
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
const currentCount = number(row.count);
const previousCount = number(row.previous_count);
const delta = row.delta == null ? currentCount - previousCount : number(row.delta);
const trend = previousCount === 0 && currentCount > 0
? "新进"
: currentCount > previousCount
? "升温"
: currentCount < previousCount ? "降温" : "持平";
return `
<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>
<td><span class="trend-tag ${trendClass(trend)}">${trend}</span></td>
<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>
<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>
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td>
<td class="number ${changeClass(sector.change)}" data-sort-value="${number(sector.change)}">${signed(sector.change)}%</td>
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number" data-sort-value="${number(row.amount_billion)}">${formatNumber(row.amount_billion, 1)} 亿</td></tr>
`;
}).join("");
body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => {
row.addEventListener("click", () => {
const sector = row.dataset.rotationDetailSector;
state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
renderRotationHistory();
updateRotationTableSelection();
document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
});
});
const empty = document.querySelector("#rotationMembersEmpty");
if (state.rotationMembersLoading) {
body.innerHTML = "";
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
empty.hidden = false;
return;
}
const payload = state.rotationMembers;
const rows = payload?.rows || [];
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
body.innerHTML = "";
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
empty.hidden = false;
setText("rotationDetailTitle", "板块成分股");
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
return;
}
empty.hidden = true;
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)}`);
body.innerHTML = rows.map((row, index) => `
<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>
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "" : ""}</td></tr>
`).join("");
animateRows(body);
}
function updateRotationTableSelection() {
document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
});
bindStockRows(body);
}
function renderLadderMini(ladders) {
@@ -3498,12 +3564,7 @@ function applyScreenerSetup(payload, requestKey) {
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
const smartLatestMeta = latestResults.smart?.meta || {};
const curatedLatestMeta = latestResults.curated?.meta || {};
const availableRegimes = new Set((payload.regimes || []).map((item) => item.id));
if (!state.selectedRegime || dateChanged) {
state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
? smartLatestMeta.regime
: payload.regime.id;
}
state.selectedRegime = payload.regime.id;
const selectedId = state.selectedStrategy?.id;
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
@@ -3617,13 +3678,9 @@ function renderScreenerSetup() {
const selector = document.querySelector("#regimeSelector");
selector.innerHTML = setup.regimes.map((item) => `
<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("");
selector.querySelectorAll("[data-regime]").forEach((button) => {
button.addEventListener("click", () => selectRegime(button.dataset.regime));
});
renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
renderStrategySummary();
renderScreenerMode();
renderCuratedStrategyLibrary();
@@ -3663,7 +3720,7 @@ function renderScreenerMode() {
const results = document.querySelector("#screenerView .screener-results-view");
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" };
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
setText("screenerResultTitle", titles[mode]);
renderScreenerResult();
}
@@ -3677,56 +3734,83 @@ function activeCuratedStrategy() {
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
}
function curatedStrategySchool(strategy) {
const category = String(strategy?.formula?.meta?.category || "");
if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面";
if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势";
if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线";
if (["动量反转"].includes(category)) return "动量";
if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面";
if (/趋势|轮动|突破/.test(category)) return "趋势";
if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线";
if (/动量|反转/.test(category)) return "动量";
return "其他";
}
function curatedSchoolIcon(school) {
return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes";
}
function curatedStrategyRunState(strategy, result) {
const missingData = strategy?.missing_data || [];
if (!strategy?.data_ready || missingData.length) {
return { label: "数据不足", className: "missing", verifiedEmpty: false };
}
if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false };
const count = (result.candidates || []).length;
if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
}
function renderCuratedStrategyLibrary() {
if (!state.screenerSetup) return;
const strategies = curatedStrategies();
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
setText("curatedStrategyCount", `${strategies.length}`);
const filters = document.querySelector("#curatedCategoryFilters");
filters.innerHTML = categories.map((category) => `
<button class="${category === state.curatedCategory ? "active" : ""}" type="button" data-curated-category="${escapeHtml(category)}">${escapeHtml(category)}</button>
const categorySelect = document.querySelector("#curatedCategoryFilter");
categorySelect.innerHTML = categories.map((category) => `
<option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
`).join("");
filters.querySelectorAll("[data-curated-category]").forEach((button) => {
button.addEventListener("click", () => {
state.curatedCategory = button.dataset.curatedCategory;
renderCuratedStrategyLibrary();
});
document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
return `<button class="${school === state.curatedSchool ? "active" : ""}" type="button" data-curated-school="${escapeHtml(school)}" aria-pressed="${school === state.curatedSchool}">${escapeHtml(school)}<small>${count}</small></button>`;
}).join("");
document.querySelectorAll("[data-curated-view]").forEach((button) => {
const active = button.dataset.curatedView === state.curatedViewMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
const query = state.curatedQuery;
const visible = strategies.filter((item) => {
const meta = item.formula?.meta || {};
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query);
return categoryMatch && queryMatch;
const school = curatedStrategySchool(item);
const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool;
const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query);
return categoryMatch && schoolMatch && queryMatch;
});
const list = document.querySelector("#curatedStrategyList");
list.classList.toggle("is-grid", state.curatedViewMode === "grid");
list.innerHTML = visible.length ? visible.map((strategy) => {
const meta = strategy.formula?.meta || {};
const school = curatedStrategySchool(strategy);
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
const resultKey = screenerResultKey(screenerResultContext("curated", null, {
regime: strategy.regimes[0] || state.selectedRegime,
strategyId: strategy.id,
strategyName: strategy.name,
}));
const result = state.screenerResultStore[resultKey]?.result;
const runState = curatedStrategyRunState(strategy, result);
return `<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-card-tags"><em>质量 ${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
<span class="curated-card-description">${escapeHtml(strategy.description || "查看策略条件与适用环境。")}</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>
<span class="curated-strategy-icon" aria-hidden="true"><i data-lucide="${curatedSchoolIcon(school)}"></i></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-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em> ${escapeHtml(meta.risk || "--")}</em></span>
</article>`;
}).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();
}
@@ -3735,6 +3819,10 @@ function renderCuratedStrategyDetail() {
if (!strategy) return;
const formula = strategy.formula || {};
const meta = formula.meta || {};
const result = activeScreenerResult("curated");
const resultMeta = result?.meta || {};
const health = resultMeta.health || {};
const runState = curatedStrategyRunState(strategy, result);
setText("curatedStrategyCategory", meta.category || "精选策略");
setText("curatedStrategyName", strategy.name);
setText("curatedStrategyDescription", strategy.description);
@@ -3742,6 +3830,8 @@ function renderCuratedStrategyDetail() {
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
meta.data_group || "行情因子",
].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准");
setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效");
const filters = formula.filters || [];
setText("curatedFilterCount", `${filters.length}`);
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
@@ -3754,14 +3844,27 @@ function renderCuratedStrategyDetail() {
const percent = number(item.weight) / total * 100;
return `<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("");
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");
status.classList.toggle("missing", !strategy.data_ready);
status.classList.toggle("missing", statusClass === "missing");
status.innerHTML = strategy.data_ready
? '<i data-lucide="database"></i><span><strong>策略数据已就绪</strong><small>可按当前数据日期执行</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("、")}`;
? `<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>`;
refreshIcons();
}
@@ -3973,6 +4076,22 @@ function openStrategyDrawer(target = "editor") {
});
}
function openCustomStrategyDrawer() {
if (!state.customStrategyDraft) {
state.customStrategyDraft = {
id: null,
builtin: false,
name: "自定义选股策略",
description: "",
regimes: [state.selectedRegime],
formula: buildQuantFormula(),
};
}
populateStrategyEditor(state.customStrategyDraft);
renderStrategyList();
openStrategyDrawer("editor");
}
function selectScreenerMobileView(view) {
state.screenerMobileView = view === "results" ? "results" : "strategy";
const workspace = document.querySelector("#screenerView");
@@ -3986,11 +4105,7 @@ function selectScreenerMobileView(view) {
}
function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked;
const backtest = activeScreenerResult("smart")?.backtest;
setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
? "正在回测"
: backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
renderScreenerProgress();
}
@@ -4003,18 +4118,18 @@ function selectRegime(regime) {
function renderStrategyList() {
const list = document.querySelector("#strategyList");
const strategies = state.screenerSetup.strategies.filter((item) => item.formula?.meta?.library !== "curated");
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
list.innerHTML = strategies.map((strategy) => `
<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>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small>
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small>
</button>
`).join("");
`).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>';
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime;
renderScreenerSetup();
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
populateStrategyEditor(state.customStrategyDraft);
renderStrategyList();
});
});
}
@@ -4033,32 +4148,6 @@ function populateStrategyEditor(strategy) {
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
}
async function syncFactorData() {
const button = document.querySelector("#factorSyncButton");
button.disabled = true;
setText("factorTaskStatus", "同步中");
setLoading(true, "正在同步 45 个交易日因子数据");
setStatus("正在同步选股因子");
try {
const payload = await apiRequest("/api/screener/sync", "POST", {
trade_date: elements.tradeDate.value,
lookback: 45,
});
const result = payload.result;
showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)}`);
await loadScreenerSetup(true);
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)}`);
setStatus("选股因子已同步");
} catch (error) {
showToast(error.message);
setStatus("选股因子同步失败");
setText("factorTaskStatus", "同步失败");
} finally {
setLoading(false);
button.disabled = false;
}
}
async function compileStrategy() {
const prompt = document.querySelector("#strategyPrompt").value.trim();
const button = document.querySelector("#compileStrategyButton");
@@ -4071,9 +4160,8 @@ async function compileStrategy() {
regime: state.selectedRegime,
});
const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false };
state.customStrategyDraft = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
renderStrategySummary();
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
@@ -4099,8 +4187,9 @@ async function saveCurrentStrategy() {
formula,
});
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id);
renderScreenerSetup();
state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
renderStrategyList();
populateStrategyEditor(state.customStrategyDraft);
showToast("自定义策略已保存");
} catch (error) {
showToast(error.message);
@@ -4108,7 +4197,7 @@ async function saveCurrentStrategy() {
}
async function deleteCurrentStrategy() {
const strategy = state.selectedStrategy;
const strategy = state.customStrategyDraft;
if (!strategy?.id || strategy.builtin) {
showToast("只能删除已保存的自定义策略");
return;
@@ -4120,10 +4209,9 @@ async function deleteCurrentStrategy() {
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime))
|| payload.strategies.find((item) => item.formula?.meta?.library !== "curated")
|| null;
renderScreenerSetup();
state.customStrategyDraft = null;
renderStrategyList();
openCustomStrategyDrawer();
showToast("自定义策略已删除");
} catch (error) {
showToast(error.message || "策略删除失败");
@@ -4132,26 +4220,6 @@ async function deleteCurrentStrategy() {
}
}
async function runCuratedStrategy() {
const strategy = activeCuratedStrategy();
if (!strategy) return;
if (!strategy.data_ready) {
showToast(`请先同步${(strategy.missing_data || []).join("、")}`);
return;
}
const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0];
await executeScreenerFormula({
mode: "curated",
formula: strategy.formula,
strategyName: strategy.name,
strategyId: strategy.id,
regime,
runBacktest: document.querySelector("#curatedBacktestToggle").checked,
button: document.querySelector("#curatedRunButton"),
loadingText: `正在执行“${strategy.name}”并计算历史样本`,
});
}
async function runQuantStrategy() {
let formula;
try {
@@ -4163,27 +4231,27 @@ async function runQuantStrategy() {
await executeScreenerFormula({
mode: "quant",
formula,
strategyName: "自定义量化公式",
strategyName: "自定义选股公式",
regime: state.selectedRegime,
runBacktest: document.querySelector("#quantBacktestToggle").checked,
button: document.querySelector("#quantRunButton"),
loadingText: "正在执行量化公式并计算因子贡献",
loadingText: "正在执行自定义公式并计算因子贡献",
});
}
function saveQuantAsStrategy() {
try {
const formula = buildQuantFormula();
state.selectedStrategy = {
state.customStrategyDraft = {
id: null,
builtin: false,
name: "自定义量化策略",
description: "由量化因子工作台生成,可在高级公式中继续调整。",
name: "自定义选股策略",
description: "由自定义因子工作台生成,可在高级公式中继续调整。",
regimes: [state.selectedRegime],
formula,
};
populateStrategyEditor(state.selectedStrategy);
document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式";
populateStrategyEditor(state.customStrategyDraft);
document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
openStrategyDrawer("editor");
} catch (error) {
showToast(error.message);
@@ -4239,26 +4307,6 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
}
}
async function runScreener() {
let formula;
try {
formula = parseFormulaEditor();
} catch (error) {
showToast(error.message);
return;
}
await executeScreenerFormula({
mode: "smart",
formula,
strategyName: document.querySelector("#strategyNameInput").value,
strategyId: state.selectedStrategy?.id,
regime: state.selectedRegime,
runBacktest: document.querySelector("#runBacktestToggle").checked,
button: document.querySelector("#screenerRunButton"),
loadingText: "正在计算因子排名与滚动回测",
});
}
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
@@ -6619,9 +6667,9 @@ function renderScreenerResult() {
const context = activeScreenerResultContext(mode);
const source = document.querySelector("#screenerResultSource");
const emptyMessages = {
smart: "尚未执行当前阶段与策略的选股",
curated: "尚未执行所选策略",
quant: "尚未执行量化选股",
smart: "当日盘后候选尚未生成",
curated: "所选策略的当日候选尚未生成",
quant: "尚未执行自定义选股",
};
if (!result) {
setText("screenerResultCount", "0 只");
@@ -6639,9 +6687,9 @@ function renderScreenerResult() {
}
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length}`);
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
const sourceParts = [modeLabels[mode]];
if (mode !== "quant" && context?.regime) sourceParts.push(regimeLabel(context.regime));
if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime));
sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
source.textContent = sourceParts.join(" · ");
source.hidden = false;
@@ -6652,7 +6700,9 @@ function renderScreenerResult() {
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
const empty = document.querySelector("#screenerEmpty");
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
empty.hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
const runId = number(meta.run_id);
body.innerHTML = candidates.map((row, index) => `
-5
View File
@@ -219,11 +219,6 @@ body.drawer-open .drawer-mask{display:block}
.field textarea{min-height:90px;resize:vertical}
.field input:focus,.field textarea:focus{border-color:var(--blue-line)}
/* toast */
.toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;
padding:8px 18px;border-radius:8px;font-size:12.5px;z-index:200;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.show{opacity:.95}
/* ========== 集合竞价页 ========== */
.auc-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.auc-head h2{font-size:17px;font-weight:800}
+67 -57
View File
@@ -19,9 +19,9 @@
</script>
<link rel="stylesheet" href="/styles.css">
<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="/theme.css?v=20260728-1">
<link rel="stylesheet" href="/theme.css?v=20260728-2">
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
</head>
<body>
@@ -449,12 +449,12 @@
<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-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">20-40</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">60-85</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">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">25+ 且回升</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">80+ 且生态达标</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">低于 45 且走弱</span><small>防守观察</small></article>
</div>
</section>
</div>
@@ -801,7 +801,7 @@
<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 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>
<button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button>
</div>
@@ -831,8 +831,8 @@
</div>
<div id="regimeReason" class="regime-advice">--</div>
<div class="regime-control-line">
<div id="regimeSelector" class="regime-selector" role="group" aria-label="选择市场阶段"></div>
<div class="factor-data-status"><span>跟随自动识别(点击可手动覆盖)</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">尚未同步</small></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>
</div>
</div>
@@ -842,17 +842,12 @@
<div class="screener-strategy-summary">
<div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div>
<p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p>
<div class="screener-strategy-actions">
<button id="changeStrategyButton" class="button" type="button">更换策略</button>
<button id="openStrategyDrawerButton" class="button" type="button">编辑 / 自定义策略</button>
</div>
<div class="screener-strategy-actions"><span class="screener-auto-note">由系统按当前阶段自动匹配</span></div>
</div>
</section>
</div>
<div class="screener-runbar runbar">
<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>
</div>
<div class="screener-pipeline-status" aria-live="polite">
@@ -863,37 +858,52 @@
<div data-screener-results-slot="smart"></div>
</div>
<div class="curated-screener-panel" data-screener-panel="curated" hidden>
<section class="curated-library-pane" aria-label="精选策略库">
<div class="curated-library-heading">
<div><span>策略库</span><h3>选择策略并直接执行</h3><p>适用阶段仅作参考,策略条件与权重可在详情中查看。</p></div><strong id="curatedStrategyCount">10 套</strong>
</div>
<div class="curated-library-controls">
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索名称或类别" autocomplete="off"></label>
<div id="curatedCategoryFilters" class="curated-category-filters" role="group" aria-label="策略分类"></div>
</div>
<div id="curatedStrategyList" class="curated-strategy-list"></div>
</section>
<dialog id="curatedDetailDialog" class="curated-detail-dialog" aria-labelledby="curatedStrategyName">
<section class="curated-detail-pane">
<button id="closeCuratedDetailButton" class="icon-button curated-detail-close" type="button" title="关闭" aria-label="关闭策略条件"><i data-lucide="x"></i></button>
<header class="curated-detail-header">
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
</header>
<div class="curated-detail-grid">
<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>
<label class="checkbox-control"><input id="curatedBacktestToggle" type="checkbox" checked>滚动回测</label>
<button id="curatedRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行该策略</button>
</div>
</section>
</dialog>
<div class="curated-workspace">
<aside class="curated-library-pane" aria-label="精选策略库">
<div class="curated-library-heading">
<div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">10 套</strong>
</div>
<div class="curated-library-controls">
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label>
<label class="curated-category-select"><span class="visually-hidden">策略分类</span><select id="curatedCategoryFilter" aria-label="策略分类"></select><i data-lucide="chevron-down"></i></label>
<div class="curated-view-toggle" role="group" aria-label="策略排列方式">
<button class="active" type="button" data-curated-view="list" aria-label="列表排列" title="列表排列" aria-pressed="true"><i data-lucide="list"></i></button>
<button type="button" data-curated-view="grid" aria-label="图标排列" title="图标排列" aria-pressed="false"><i data-lucide="layout-grid"></i></button>
</div>
</div>
<div id="curatedSchoolFilters" class="curated-school-filters" aria-label="策略流派"></div>
<div id="curatedStrategyList" class="curated-strategy-list"></div>
</aside>
<section class="curated-detail-pane" aria-labelledby="curatedStrategyName">
<header class="curated-detail-header">
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
</header>
<div class="curated-environment-notes">
<p><strong>适用环境</strong><span id="curatedSuitableEnvironment"></span></p>
<p><strong>失效风险</strong><span id="curatedFailureRisk"></span></p>
</div>
<div class="curated-health-grid" id="curatedHealthMetrics" aria-label="策略运行状态"></div>
<div class="curated-detail-grid">
<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>
<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">
<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>
@@ -912,16 +922,15 @@
</div>
<div id="quantFilterRows" class="quant-rule-rows"></div>
<div class="quant-execution-actions">
<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>
<button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行自定义选股</button>
<label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label>
<span>结果按加权总分排序,并生成逐股贡献解释</span>
</div>
<p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p>
</div>
</aside>
<div data-screener-results-slot="quant"></div>
</div>
<div class="custom-results-slot" data-screener-results-slot="quant"></div>
</div>
<div class="screener-results-view">
<section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden>
@@ -935,6 +944,7 @@
</div>
<div class="table-frame card tbl-wrap screener-result-frame">
<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>
<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>
@@ -946,7 +956,7 @@
</div>
</div>
<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">
<aside class="strategy-sidebar">
<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">
<header class="rotation-card-head card-h">
<div>
<h3 id="rotationDetailTitle">当日轮动明细</h3>
<span>趋势、强度与涨停梯队 · 点击行可联动追踪上方轨迹</span>
<h3 id="rotationDetailTitle">板块成分股</h3>
<span>点击上方板块,查看目标交易日有效申万成分与行情</span>
</div>
<span id="rotationDetailMeta" class="rotation-top-tag">--</span>
</header>
<div class="rotation-table-frame tbl-wrap">
<table id="rotationTable" class="data-table tbl rotation-table">
<thead><tr>
<th class="number num">排名</th><th>板块</th><th>趋势</th><th class="number num">今日涨停(只)</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">最高板(板</th>
<th class="number num" data-auto-sort="true" title="平均涨幅:点击排序">平均涨幅(%</th><th>领涨股</th>
<th class="number num" data-auto-sort="true" title="涨停股成交额:点击排序">涨停股成交额(亿)</th>
<th class="number num">序号</th><th>代码</th><th>股票</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" data-auto-sort="true" title="成交额:点击排序">成交额(亿</th><th>行情状态</th>
</tr></thead>
<tbody id="rotationTableBody"></tbody>
</table>
<div id="rotationMembersEmpty" class="empty-state">点击上方任意板块查看成分股</div>
</div>
</section>
</section>
@@ -1873,6 +1883,6 @@
<script src="/vendor/lucide.min.js" defer></script>
<script src="/ui-core.js" 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>
</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 span { display: block; margin-top: 2px; color: var(--r2-sub); font-size: 11px; white-space: nowrap; }
.sentiment-phase-info { min-width: 0; flex: 1; }
.sentiment-phase-info p { color: #374151; font-size: 12.5px; line-height: 1.7; }
.sentiment-phase-info p { color: var(--r2-sub); font-size: 12.5px; line-height: 1.7; }
.sentiment-phase-info p b { font-weight: 700; }
.sentiment-phase-info p .down { color: var(--r2-down); }
.sentiment-phase-info p .up { color: var(--r2-up); }
@@ -1050,7 +1050,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.pool-side-group p {
margin: 0;
overflow: hidden;
color: #4b5563;
color: var(--r2-sub);
font-size: 12px;
line-height: 1.8;
text-overflow: ellipsis;
@@ -1819,7 +1819,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.performance-conclusion {
padding: 14px 16px;
color: #374151;
color: var(--r2-sub);
font-size: 12.5px;
line-height: 2;
}
@@ -5222,38 +5222,114 @@ body.sidebar-collapsed .status-bar { left: 64px; }
/* Curated strategy workspace. */
#screenerView .curated-screener-panel { display: block; }
#screenerView .curated-library-pane { padding: 0; border: 0; background: transparent; box-shadow: none; }
#screenerView .curated-workspace {
display: grid;
grid-template-columns: minmax(340px, .9fr) minmax(560px, 1.5fr);
gap: 12px;
align-items: stretch;
}
#screenerView .curated-library-pane,
#screenerView .curated-detail-pane {
min-width: 0;
overflow: hidden;
border: 1px solid var(--r2-line);
border-radius: 10px;
background: #fff;
box-shadow: var(--r2-shadow);
}
#screenerView .curated-library-pane {
display: flex;
flex-direction: column;
padding: 0;
}
#screenerView .curated-library-heading {
min-height: 58px;
display: flex;
align-items: center;
padding: 9px 14px;
border: 1px solid var(--r2-line);
border-radius: 10px 10px 0 0;
border-bottom: 1px solid var(--r2-line-soft);
background: #fff;
}
#screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; }
#screenerView .curated-library-heading h3 { display: inline; margin: 0 9px 0 0; font-size: 14px; }
#screenerView .curated-library-heading p { display: inline; margin: 0; color: var(--r2-faint); font-size: 10.5px; }
#screenerView .curated-library-heading h3 { margin: 1px 0 0; font-size: 14px; }
#screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; }
#screenerView .curated-library-controls {
min-height: 50px;
display: flex;
min-height: 48px;
display: grid;
grid-template-columns: minmax(0, 1fr) 104px 62px;
align-items: center;
gap: 12px;
margin: 0 0 12px;
padding: 8px 14px;
gap: 7px;
padding: 7px 10px;
border-bottom: 1px solid var(--r2-line-soft);
background: #fafbfc;
}
#screenerView .curated-view-toggle {
min-height: 32px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 2px;
border: 1px solid var(--r2-line);
border-top: 0;
border-radius: 0 0 10px 10px;
border-radius: 7px;
background: #fff;
}
#screenerView .curated-view-toggle button {
min-width: 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--r2-faint);
cursor: pointer;
}
#screenerView .curated-view-toggle button.active { background: var(--scr-blue-soft); color: var(--scr-blue); }
#screenerView .curated-view-toggle button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; }
#screenerView .curated-view-toggle .lucide { width: 13px; height: 13px; }
#screenerView .curated-school-filters {
min-height: 38px;
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
overflow-x: auto;
border-bottom: 1px solid var(--r2-line-soft);
background: #fff;
scrollbar-width: none;
}
#screenerView .curated-school-filters::-webkit-scrollbar { display: none; }
#screenerView .curated-school-filters button {
min-height: 26px;
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
padding: 0 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--r2-sub);
font-size: 10px;
cursor: pointer;
}
#screenerView .curated-school-filters button small { color: var(--r2-faint); font-size: 8.5px; font-variant-numeric: tabular-nums; }
#screenerView .curated-school-filters button:hover { background: var(--scr-blue-soft); color: var(--scr-blue); }
#screenerView .curated-school-filters button.active { border-color: #c5d4f1; background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; }
#screenerView .curated-school-filters button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; }
#screenerView .curated-search {
width: 240px;
width: 100%;
min-height: 32px;
display: flex;
align-items: center;
@@ -5266,51 +5342,176 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .curated-search .lucide { width: 14px; color: var(--r2-faint); }
#screenerView .curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; font-size: 11.5px; }
#screenerView .curated-category-filters { display: flex; gap: 5px; overflow-x: auto; }
#screenerView .curated-category-filters button { min-height: 28px; padding: 0 10px; border: 1px solid var(--r2-line); border-radius: 6px; background: #fff; color: var(--r2-sub); font-size: 10.5px; white-space: nowrap; }
#screenerView .curated-category-filters button.active { border-color: var(--scr-blue); background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; }
#screenerView .curated-strategy-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 12px;
padding: 0;
#screenerView .curated-category-select {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
}
#screenerView .curated-strategy-card {
min-height: 158px;
#screenerView .curated-category-select select {
width: 100%;
min-height: 32px;
padding: 0 27px 0 9px;
border: 1px solid var(--r2-line);
border-radius: 7px;
appearance: none;
background: #fff;
color: var(--r2-sub);
font-size: 10.5px;
}
#screenerView .curated-category-select .lucide {
position: absolute;
right: 8px;
width: 13px;
pointer-events: none;
color: var(--r2-faint);
}
#screenerView .curated-strategy-list {
min-height: 352px;
max-height: 532px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 13px 15px;
border: 1px solid var(--r2-line);
border-radius: 10px;
background: #fff;
box-shadow: var(--r2-shadow);
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
gap: 6px;
overflow-y: auto;
padding: 8px;
scrollbar-gutter: stable;
}
#screenerView .curated-strategy-icon { display: none; }
#screenerView .curated-strategy-list.is-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-content: start;
}
#screenerView .curated-strategy-list.is-grid .curated-strategy-card {
min-height: 124px;
align-items: center;
justify-content: center;
gap: 7px;
text-align: center;
}
#screenerView .curated-strategy-list.is-grid .curated-strategy-card:hover { transform: translateY(-2px); }
#screenerView .curated-strategy-list.is-grid .curated-strategy-card.active { box-shadow: inset 0 3px var(--scr-blue); }
#screenerView .curated-strategy-list.is-grid .curated-strategy-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--scr-blue-soft);
color: var(--scr-blue);
}
#screenerView .curated-strategy-list.is-grid .curated-strategy-icon .lucide { width: 16px; height: 16px; }
#screenerView .curated-strategy-list.is-grid .curated-card-head { width: 100%; grid-template-columns: minmax(0, 1fr); gap: 4px; }
#screenerView .curated-strategy-list.is-grid .curated-strategy-rank,
#screenerView .curated-strategy-list.is-grid .curated-card-tags { display: none; }
#screenerView .curated-strategy-list.is-grid .curated-card-result { justify-self: center; }
#screenerView .curated-strategy-card {
min-height: 70px;
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 7px;
background: #f8fafc;
box-shadow: none;
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
#screenerView .curated-strategy-card:hover {
border-color: #b7c9ee;
transform: translateY(-2px);
box-shadow: 0 5px 16px rgba(37, 99, 235, .08);
background: #f4f7fd;
transform: translateX(2px);
}
#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: #fbfdff; box-shadow: inset 0 3px var(--scr-blue), var(--r2-shadow); }
#screenerView .curated-card-head { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
#screenerView .curated-strategy-rank { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 5px; background: #f1f3f6; color: var(--r2-sub); font-size: 9.5px; font-style: normal; }
#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: var(--scr-blue-soft); box-shadow: inset 3px 0 var(--scr-blue); }
#screenerView .curated-card-head { display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
#screenerView .curated-strategy-rank { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 5px; background: #eef1f5; color: var(--r2-sub); font-size: 9px; font-style: normal; }
#screenerView .curated-card-head strong,
#screenerView .curated-card-head small { display: block; }
#screenerView .curated-card-head strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
#screenerView .curated-card-head small { margin-top: 2px; color: var(--r2-faint); font-size: 9.5px; }
#screenerView .curated-card-head strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
#screenerView .curated-card-head small { margin-top: 1px; color: var(--r2-faint); font-size: 9px; }
#screenerView .curated-card-result { padding: 2px 6px; border-radius: 4px; background: #eef1f5; color: var(--r2-faint); font-size: 9px; font-style: normal; white-space: nowrap; }
#screenerView .curated-card-result.ready { background: var(--scr-green-soft); color: var(--scr-green); }
#screenerView .curated-card-result.quiet { background: var(--scr-blue-soft); color: var(--scr-blue); }
#screenerView .curated-card-result.missing { background: var(--scr-amber-soft); color: var(--scr-amber); }
#screenerView .curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; }
#screenerView .curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--scr-blue-soft); color: var(--scr-blue); font-size: 9px; font-style: normal; }
#screenerView .curated-card-tags em:nth-child(2) { background: #f3f4f6; color: var(--r2-sub); }
#screenerView .curated-card-tags em:nth-child(3) { background: var(--scr-amber-soft); color: var(--scr-amber); }
#screenerView .curated-card-description { flex: 1; color: var(--r2-sub); font-size: 10.5px; line-height: 1.65; }
#screenerView .curated-card-foot { min-height: 35px; display: flex; align-items: center; gap: 7px; padding-top: 7px; border-top: 1px solid var(--r2-line-soft); }
#screenerView .curated-card-foot small { color: var(--r2-faint); font-size: 9.5px; }
#screenerView .curated-card-actions { display: flex; gap: 5px; margin-left: auto; }
#screenerView .curated-detail-pane {
display: flex;
flex-direction: column;
padding: 14px 16px 0;
}
#screenerView .curated-detail-header { gap: 14px; padding-bottom: 11px; }
#screenerView .curated-detail-header h3 { font-size: 16px; }
#screenerView .curated-detail-header p { max-width: 650px; margin-top: 5px; font-size: 11px; line-height: 1.55; }
#screenerView .curated-strategy-badges span { min-height: 24px; padding: 0 7px; font-size: 10px; }
#screenerView .curated-environment-notes {
display: grid;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid var(--r2-line-soft);
}
#screenerView .curated-environment-notes p {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
gap: 8px;
margin: 0;
color: var(--r2-sub);
font-size: 10.5px;
line-height: 1.55;
}
#screenerView .curated-environment-notes strong { color: var(--scr-green); }
#screenerView .curated-environment-notes p:last-child strong { color: var(--scr-amber); }
#screenerView .curated-health-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0 -16px;
border-bottom: 1px solid var(--r2-line-soft);
background: #fafbfc;
}
#screenerView .curated-health-grid > div { min-width: 0; padding: 8px 12px; border-right: 1px solid var(--r2-line-soft); }
#screenerView .curated-health-grid > div:last-child { border-right: 0; }
#screenerView .curated-health-grid span,
#screenerView .curated-health-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#screenerView .curated-health-grid span { color: var(--r2-faint); font-size: 9px; }
#screenerView .curated-health-grid strong { margin-top: 2px; color: var(--r2-ink); font-size: 11.5px; font-variant-numeric: tabular-nums; }
#screenerView .curated-health-grid strong.ready { color: var(--scr-green); }
#screenerView .curated-health-grid strong.quiet { color: var(--scr-blue); }
#screenerView .curated-health-grid strong.missing { color: var(--scr-amber); }
#screenerView .curated-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 12px 0; }
#screenerView .curated-condition-section { min-width: 0; }
#screenerView .curated-rule-list,
#screenerView .curated-score-list { margin-top: 6px; }
#screenerView .curated-rule-row { min-height: 34px; padding: 5px 4px; font-size: 10.5px; }
#screenerView .curated-rule-row span,
#screenerView .curated-rule-row strong { font-size: 10.5px; }
#screenerView .curated-score-row { min-height: 31px; grid-template-columns: minmax(90px, 1fr) minmax(80px, 1.25fr) 38px; gap: 7px; font-size: 10px; }
#screenerView .curated-execution-bar { min-height: 48px; margin: auto -16px 0; padding: 7px 12px; border-top: 1px solid var(--r2-line-soft); background: #fafbfc; }
#screenerView .curated-data-status > .lucide { width: 16px; height: 16px; }
#screenerView .curated-data-status strong { font-size: 11px; }
#screenerView .curated-data-status small { margin-top: 1px; font-size: 9.5px; }
/* Quant workspace. */
#screenerView .quant-screener-panel {
@@ -5418,16 +5619,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .quant-validation-message { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; }
#screenerView .quant-validation-message.error { color: var(--scr-amber); }
#screenerView .curated-detail-dialog { width: min(850px, calc(100vw - 30px)); max-height: min(760px, calc(100dvh - 30px)); padding: 0; overflow: visible; border: 0; border-radius: 11px; background: transparent; }
#screenerView .curated-detail-dialog::backdrop,
#screenerView .strategy-drawer::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); }
#screenerView .curated-detail-pane { position: relative; max-height: min(760px, calc(100dvh - 30px)); overflow-y: auto; border-radius: 11px; background: #fff; }
@media (max-width: 1180px) {
#screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
#screenerView .screener-overview-grid,
#screenerView .quant-screener-panel { grid-template-columns: 1fr; }
#screenerView .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#screenerView .curated-workspace { grid-template-columns: minmax(310px, .82fr) minmax(480px, 1.3fr); }
}
@media (max-width: 820px) {
@@ -5443,9 +5641,10 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .screener-pipeline-status { margin-left: 0; }
#screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; }
#screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; }
#screenerView .curated-strategy-list { grid-template-columns: 1fr 1fr; }
#screenerView .curated-library-controls { align-items: stretch; flex-direction: column; }
#screenerView .curated-search { width: 100%; }
#screenerView .curated-workspace { grid-template-columns: 1fr; }
#screenerView .curated-strategy-list { min-height: 0; max-height: 310px; }
#screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#screenerView .curated-detail-pane { min-height: 430px; }
}
@media (max-width: 560px) {
@@ -5461,9 +5660,14 @@ body.sidebar-collapsed .status-bar { left: 64px; }
#screenerView .result-toolbar .section-subtitle { margin-left: 0; }
#screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); }
#screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); }
#screenerView .curated-strategy-list { grid-template-columns: 1fr; }
#screenerView .curated-library-heading { align-items: flex-start; }
#screenerView .curated-library-heading p { display: block; margin-top: 2px; }
#screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 104px 62px; }
#screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#screenerView .curated-health-grid { grid-template-columns: 1fr 1fr; }
#screenerView .curated-health-grid > div:nth-child(2) { border-right: 0; }
#screenerView .curated-health-grid > div:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line-soft); }
#screenerView .curated-detail-grid { grid-template-columns: 1fr; }
#screenerView .curated-execution-bar { align-items: flex-start; flex-direction: column; }
#screenerView .quant-universe-grid,
#screenerView .quant-formula-summary { grid-template-columns: 1fr; }
#screenerView .quant-st-toggle { grid-column: auto; }
@@ -7911,10 +8115,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.settings-dialog:not(.heaven-reading-dialog) {
width: min(740px, calc(100vw - 28px));
max-height: min(820px, calc(100dvh - 28px));
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }
.settings-dialog:not(.heaven-reading-dialog) .settings-section {
padding: 18px 20px;
border-color: var(--dialog-line);
@@ -8031,7 +8238,10 @@ body.sidebar-collapsed .status-bar { left: 64px; }
.admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; }
/* Editing dialogs share sensible proportions without changing their fields. */
.settings-dialog.trade-log-dialog { width: min(880px, calc(100vw - 28px)); }
.settings-dialog.trade-log-dialog {
width: min(880px, calc(100vw - 28px));
max-height: min(760px, calc(100dvh - 28px));
}
.trade-log-dialog .trade-log-form { padding: 18px 20px 20px; background: #fafbfc; }
.trade-log-dialog .trade-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
.trade-log-dialog .trade-tags-field { grid-column: span 2; }
@@ -8086,6 +8296,8 @@ body.sidebar-collapsed .status-bar { left: 64px; }
}
@media (max-width: 460px) {
#screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 62px; }
#screenerView .curated-search { grid-column: 1 / -1; }
.global-search-dialog { margin-top: 8px; }
.global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; }
.global-search-head kbd { display: none; }
@@ -8337,3 +8549,94 @@ body.sidebar-collapsed .status-bar { left: 64px; }
white-space: normal;
}
}
/* Automatic screening and the manual custom-formula workspace. */
#screenerView .screener-auto-note {
color: var(--r2-faint);
font-size: 12px;
line-height: 1.6;
}
#screenerView .regime-selector .regime-option {
cursor: default;
}
#screenerView .quant-screener-panel {
grid-template-columns: minmax(360px, .88fr) minmax(0, 1.12fr);
align-items: start;
}
#screenerView .custom-screener-tools,
#screenerView .custom-results-slot {
grid-column: 1 / -1;
min-width: 0;
}
#screenerView .custom-screener-tools {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid var(--r2-line);
border-radius: var(--r2-radius);
background: var(--r2-card);
}
#screenerView .custom-screener-tools > div:first-child {
min-width: 0;
display: grid;
gap: 3px;
}
#screenerView .custom-screener-tools span,
#screenerView .custom-screener-tools small {
color: var(--r2-faint);
font-size: 11.5px;
}
#screenerView .custom-screener-tools strong {
color: var(--r2-ink);
font-size: 14px;
}
#screenerView .custom-screener-tools > div:last-child {
flex: 0 0 auto;
display: flex;
gap: 8px;
}
#screenerView .custom-results-slot .screener-results-view {
margin: 0;
}
#screenerView .screener-result-frame {
overflow-x: auto;
scrollbar-gutter: stable;
}
#screenerView .screener-result-frame .data-table {
width: 100%;
min-width: 1180px;
table-layout: fixed;
}
#screenerView .screener-result-columns col:nth-child(1) { width: 54px; }
#screenerView .screener-result-columns col:nth-child(2) { width: 122px; }
#screenerView .screener-result-columns col:nth-child(3) { width: 104px; }
#screenerView .screener-result-columns col:nth-child(4) { width: 78px; }
#screenerView .screener-result-columns col:nth-child(5) { width: 104px; }
#screenerView .screener-result-columns col:nth-child(6),
#screenerView .screener-result-columns col:nth-child(7),
#screenerView .screener-result-columns col:nth-child(8),
#screenerView .screener-result-columns col:nth-child(9) { width: 82px; }
#screenerView .screener-result-columns col:nth-child(10) { width: 210px; }
#screenerView .screener-result-columns col:nth-child(11) { width: 150px; }
#screenerView .screener-result-columns col:nth-child(12) { width: 142px; }
@media (max-width: 980px) {
#screenerView .quant-screener-panel { grid-template-columns: 1fr; }
#screenerView .custom-screener-tools { align-items: stretch; flex-direction: column; }
#screenerView .custom-screener-tools > div:last-child { flex-wrap: wrap; }
}
+16 -2
View File
@@ -3983,20 +3983,29 @@ dialog::backdrop {
to { transform: rotate(360deg); }
}
.toast {
#toast.toast {
position: fixed;
top: auto;
left: auto;
right: 18px;
bottom: 48px;
z-index: 60;
width: max-content;
height: auto;
max-width: min(420px, calc(100vw - 36px));
padding: 11px 14px;
border-radius: 4px;
background: #21313c;
color: #fff;
box-shadow: var(--shadow);
opacity: 1;
pointer-events: none;
transform: none;
animation: toast-enter var(--motion-medium) var(--ease-out) both;
}
#toast.toast[hidden] { display: none; }
@keyframes toast-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
@@ -5147,10 +5156,15 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
display: none;
}
.toast {
#toast.toast {
top: auto;
left: auto;
right: 10px;
bottom: 76px;
width: max-content;
height: auto;
max-width: calc(100vw - 20px);
transform: none;
}
}
+112 -2
View File
@@ -568,6 +568,54 @@
background: var(--action-soft);
}
:root[data-theme="dark"] #screenerView .curated-detail-pane {
background: var(--surface);
color: var(--text-primary);
}
:root[data-theme="dark"] #screenerView :is(.curated-detail-header, .curated-execution-bar) {
border-color: var(--line-soft);
}
:root[data-theme="dark"] #screenerView :is(
.curated-detail-header h3,
.mini-section-heading h4,
.curated-rule-row strong,
.curated-score-row strong,
.curated-data-status strong
) {
color: var(--text-primary);
}
:root[data-theme="dark"] #screenerView :is(
.curated-detail-header > div > span,
.curated-detail-header p,
.mini-section-heading > span,
.curated-rule-row span,
.curated-score-row > span:first-child,
.curated-data-status small
) {
color: var(--text-secondary);
}
:root[data-theme="dark"] #screenerView .curated-rule-row {
border-color: var(--line-soft);
}
:root[data-theme="dark"] #screenerView .curated-score-track {
background: var(--surface-muted);
}
:root[data-theme="dark"] #screenerView .curated-strategy-badges span {
background: var(--surface-muted);
color: var(--text-secondary);
}
:root[data-theme="dark"] #screenerView .curated-strategy-badges span:first-child {
background: var(--action-soft);
color: var(--action);
}
:root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) {
border-color: var(--line-soft);
background: var(--surface-muted);
@@ -1030,10 +1078,11 @@
}
:root[data-theme="dark"] #screenerView :is(
.curated-library-pane,
.curated-library-heading,
.curated-library-controls,
.curated-search,
.curated-category-filters button,
.curated-category-select select,
.curated-strategy-card,
.curated-strategy-rank,
.curated-card-tags em,
@@ -1052,7 +1101,6 @@
}
:root[data-theme="dark"] #screenerView :is(
.curated-category-filters button.active,
.curated-strategy-card.active,
.curated-card-tags em,
.curated-strategy-rank,
@@ -1061,6 +1109,30 @@
background: var(--surface-muted);
}
:root[data-theme="dark"] #screenerView .curated-strategy-card:hover {
border-color: var(--blue-line);
background: var(--surface-subtle);
}
:root[data-theme="dark"] #screenerView .curated-strategy-card.active {
border-color: var(--blue-line);
background: var(--action-soft);
}
:root[data-theme="dark"] #screenerView :is(.curated-health-grid, .curated-execution-bar) {
border-color: var(--line-soft);
background: var(--surface-muted);
}
:root[data-theme="dark"] #screenerView .curated-health-grid > div {
border-color: var(--line-soft);
}
:root[data-theme="dark"] #screenerView .curated-search input {
background: transparent;
color: var(--text-primary);
}
:root[data-theme="dark"] #screenerView :is(
.quant-rule-row select,
.quant-rule-row input,
@@ -1257,6 +1329,44 @@
color: var(--text-secondary);
}
/* High-specificity dark surfaces for workspaces with later light-theme hover rules. */
:root[data-theme="dark"] #screenerView .curated-library-heading h3 {
color: var(--text-primary);
}
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle, .curated-school-filters) {
border-color: var(--border);
background: var(--surface);
color: var(--text-primary);
}
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button) {
color: var(--text-secondary);
}
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button):is(:hover, .active) {
border-color: var(--blue-line);
background: var(--action-soft);
color: var(--action);
}
:root[data-theme="dark"] #screenerView .curated-strategy-list.is-grid .curated-strategy-icon {
background: var(--action-soft);
color: var(--action);
}
:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td,
:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td:last-child,
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover,
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover td {
background: var(--action-soft) !important;
color: var(--text-primary);
}
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody td {
color: var(--text-primary);
}
/* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former
paper-theme overrides here would repaint its controls and ritual stages. */
-2
View File
@@ -614,8 +614,6 @@ button { cursor: pointer; }
.login-dialog form { display: grid; gap: 14px; padding: 26px; }
.login-dialog h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; }
.login-dialog .button { width: 100%; margin-top: 4px; }
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 100; padding: 10px 14px; border: 1px solid var(--wt-line); border-radius: 7px; background: rgba(13,21,38,.96); color: var(--wt-paper); box-shadow: 0 12px 30px rgba(0,0,0,.3); font-size: 12px; }
@media (max-width: 960px) {
.heaven-shell { width: min(100% - 20px, 760px); }
.heaven-controls { align-items: stretch; flex-direction: column; }
+2
View File
@@ -23,6 +23,8 @@ class StrategyTrackingService:
def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]:
run = self.database.get_screener_run(user_id, run_id)
if not run:
run = self.database.get_screener_run(0, run_id)
if not run:
raise ValueError("选股结果不存在或不属于当前账号。")
normalized_code = str(code or "").strip().split(".")[0]
+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 page.locator('[data-view="screenerView"]').first().click();
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
await expect(page.locator("#screenerRunButton")).toBeDisabled();
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#quantRunButton")).toBeDisabled();
await page.locator("#assistantButton").click();
await expect(page.locator("#settingsDialog")).toBeHidden();
await expect(page.locator("#assistantDialog")).toBeVisible();
@@ -1817,7 +1818,8 @@ test("curated strategies and quant builder form independent screener workspaces"
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1);
await expect(page.locator("#curatedRunButton")).toBeEnabled();
await expect(page.locator("#curatedRunButton")).toHaveCount(0);
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
@@ -1958,32 +1960,35 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
});
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
await expect(page.locator("#curatedDetailDialog")).toBeHidden();
await expect(page.locator(".curated-detail-pane")).toBeVisible();
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
await secondStrategy.locator(".curated-card-description").click();
await secondStrategy.click();
await expect(secondStrategy).toHaveClass(/active/);
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
await secondStrategy.locator('[data-curated-inspect="4"]').click();
await expect(page.locator("#curatedDetailDialog")).toBeVisible();
const dialogBox = await page.locator("#curatedDetailDialog").boundingBox();
expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2);
expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2);
await page.locator("#closeCuratedDetailButton").click();
await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量");
const [libraryBox, detailBox] = await Promise.all([
page.locator(".curated-library-pane").boundingBox(),
page.locator(".curated-detail-pane").boundingBox(),
]);
expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2);
expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1);
expect(libraryBox.width).toBeLessThan(detailBox.width);
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
await expect(page.locator("#screenerResultTitle")).toHaveText("打分结果");
await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果");
const [builderBox, summaryBox] = await Promise.all([
page.locator(".quant-builder-pane").boundingBox(),
page.locator(".quant-summary-pane").boundingBox(),
]);
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
expect(builderBox.width).toBeGreaterThanOrEqual(395);
expect(builderBox.width).toBeLessThanOrEqual(405);
expect(builderBox.width).toBeGreaterThanOrEqual(490);
expect(builderBox.width).toBeLessThanOrEqual(540);
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
expect(quantRunBox.width).toBeLessThan(180);
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
@@ -1996,7 +2001,7 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
});
test("screener stage completion follows its execution context and mode results stay isolated", async ({ page }) => {
test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
@@ -2024,15 +2029,11 @@ test("screener stage completion follows its execution context and mode results s
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2);
await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行");
await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行");
await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略");
await expect(page.locator("#screenerResultSource")).toBeHidden();
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
await expect(page.locator('[data-regime]')).toHaveCount(0);
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
await expect(page.locator("#editStrategyButton")).toHaveCount(0);
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
await page.evaluate(() => {
@@ -2044,10 +2045,10 @@ test("screener stage completion follows its execution context and mode results s
});
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 修复 · 连续分红质量");
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量");
await page.locator('[data-screener-mode="quant"]').click();
await expect(page.locator("#screenerEmpty")).toContainText("量化选股");
await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
await page.evaluate(() => {
setScreenerResult("quant", {
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
@@ -2056,7 +2057,7 @@ test("screener stage completion follows its execution context and mode results s
renderScreenerResult();
});
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
await expect(page.locator("#screenerResultSource")).toHaveText("量化选股 · 自定义因子权重");
await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重");
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
@@ -2064,20 +2065,34 @@ test("screener stage completion follows its execution context and mode results s
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
});
test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => {
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
const formula = {
meta: { library: "smart" }, universe: {}, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
limit: 10, min_score: 0.5,
};
const candidate = (code, name) => ({
code, name, sector: "Test Sector", score_display: 80,
historical_probability: 50, probability_samples: 20, pct_chg: 1,
return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70,
reason: "Context result", risk_flags: [],
});
const result = (mode, runId, strategyName, row) => ({
meta: {
run_id: runId, trade_date: "20260722", regime: "repair",
strategy_name: strategyName, mode,
},
candidates: [row],
disclaimer: "Historical statistics do not predict future returns.",
backtest: null,
});
const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair"));
const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A"));
const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B"));
const options = {
recentScreenerResults: [],
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }],
latestScreenerResults: { smart: smartResult, curated: curatedA },
recentScreenerResults: [smartResult, curatedA, curatedB],
additionalScreenerStrategies: [
{
id: 3, name: "Retreat Defense", description: "Retreat-stage strategy",
regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula,
},
{
id: 4, name: "Quality B", description: "Second curated strategy",
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
@@ -2088,66 +2103,27 @@ test("screener keeps results for each stage and curated strategy across switchin
},
],
};
options.screenerRunResult = (body) => {
const candidateName = body.mode === "smart"
? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair"
: body.strategy_name === "Quality B" ? "Curated B" : "Curated A";
return {
meta: {
run_id: 100 + options.recentScreenerResults.length,
trade_date: "20260722",
regime: body.regime,
strategy_name: body.strategy_name,
mode: body.mode,
},
candidates: [{
code: `60000${options.recentScreenerResults.length + 1}`,
name: candidateName,
sector: "Test Sector",
score_display: 80,
historical_probability: 50,
probability_samples: 20,
pct_chg: 1,
return_5d: 2,
volume_ratio_5d: 1.2,
sector_strength: 70,
reason: "Context result",
risk_flags: [],
}],
disclaimer: "Historical statistics do not predict future returns.",
backtest: null,
};
};
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
await page.locator('[data-screener-mode="curated"]').click();
await page.locator('[data-curated-run="2"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-run="4"]').click();
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
await page.locator('[data-curated-strategy="2"] .curated-card-description').click();
await page.locator('[data-curated-strategy="2"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
expect(options.screenerRunBodies || []).toHaveLength(0);
await page.reload();
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-strategy="4"] .curated-card-description').click();
await page.locator('[data-curated-strategy="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
});
+248 -3
View File
@@ -1,26 +1,78 @@
import sqlite3
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from database import ReviewDatabase
from screener import (
ADVANCED_CURATED_STRATEGIES,
CURATED_STRATEGIES,
FACTOR_FIELDS,
FACTOR_GROUPS,
ScreenerEngine,
_broken_reversal_metrics,
_risk_flags,
_rsi,
_quarter_periods,
)
from server import automatic_screener_jobs
class CuratedScreenerTests(unittest.TestCase):
def test_first_batch_contains_ten_distinct_curated_strategies(self):
self.assertEqual(10, len(CURATED_STRATEGIES))
self.assertEqual(10, len({item["name"] for item in CURATED_STRATEGIES}))
def test_curated_library_contains_original_and_advanced_strategies(self):
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
self.assertEqual(23, len(CURATED_STRATEGIES))
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES}))
self.assertTrue(
{"行业动量轮动", "主力资金行业流入"}.issubset(
{item["name"] for item in CURATED_STRATEGIES}
)
)
self.assertTrue(
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
)
def test_every_curated_strategy_explains_environment_and_failure_risk(self):
for strategy in CURATED_STRATEGIES:
meta = strategy["formula"]["meta"]
self.assertTrue(meta.get("suitable_environment"), strategy["name"])
self.assertTrue(meta.get("failure_risk"), strategy["name"])
self.assertNotIn("emotion_gate", meta, strategy["name"])
def test_automatic_curated_jobs_are_not_filtered_by_market_regime(self):
strategies = [
{
"name": "阶段策略",
"regimes": ["retreat"],
"formula": {"meta": {"library": "stage"}},
},
*CURATED_STRATEGIES,
]
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
jobs = automatic_screener_jobs(strategies, regime)
curated_names = {
job["strategy"]["name"] for job in jobs if job["mode"] == "curated"
}
self.assertEqual(
{strategy["name"] for strategy in CURATED_STRATEGIES},
curated_names,
regime,
)
def test_curated_risk_flags_do_not_reintroduce_regime_gating(self):
row = {
"pct_chg": 0,
"return_10d": 0,
"volatility_10d": 0,
"amount_billion": 5,
}
self.assertIn("市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat"))
self.assertNotIn(
"市场处于退潮阶段,策略可能选择空仓",
_risk_flags(row, "retreat", include_regime_risk=False),
)
def test_every_curated_formula_uses_supported_factors(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
@@ -56,6 +108,15 @@ class CuratedScreenerTests(unittest.TestCase):
connection.close()
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
self.assertIn("fundamental_indicators", tables)
self.assertIn("benchmark_bars", tables)
def test_advanced_strategies_declare_history_and_backtest_contracts(self):
for strategy in ADVANCED_CURATED_STRATEGIES:
meta = strategy["formula"]["meta"]
self.assertGreaterEqual(meta["history_days"], 80, strategy["name"])
self.assertGreaterEqual(meta["backtest_days"], 1, strategy["name"])
self.assertGreater(meta["take_profit"], 0, strategy["name"])
self.assertLess(meta["stop_loss"], 0, strategy["name"])
def test_quarter_periods_stop_at_selected_date(self):
periods = _quarter_periods("20260722", 5)
@@ -98,6 +159,10 @@ class CuratedScreenerTests(unittest.TestCase):
for index in range(100)
],
)
connection.executemany(
"INSERT INTO benchmark_bars (trade_date, ts_code, close) VALUES (?, ?, ?)",
[(f"2026{index + 1:04d}", "000300.SH", 4000 + index) for index in range(60)],
)
health = database.factor_health_summary("20260722")
self.assertTrue(health["market"])
@@ -105,10 +170,190 @@ class CuratedScreenerTests(unittest.TestCase):
self.assertTrue(health["valuation"])
self.assertTrue(health["fundamental"])
self.assertTrue(health["dividend_history"])
self.assertTrue(health["benchmark"])
self.assertEqual(health["valuation_rows"], 1)
self.assertEqual(health["fundamental_rows"], 100)
self.assertEqual(health["dividend_years"], 5)
def test_moneyflow_health_requires_the_latest_five_market_dates(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
dates = [f"202607{day:02d}" for day in range(20, 25)]
database.upsert_daily_bars([
{
"trade_date": trade_date, "ts_code": "600000.SH",
"open": 10, "high": 10.2, "low": 9.8, "close": 10,
"pct_chg": 0, "vol": 1000, "amount": 100000,
}
for trade_date in dates
])
database.upsert_moneyflow([
{"trade_date": "20260105", "ts_code": "600000.SH", "net_mf_amount": 10}
] * 5)
self.assertFalse(database.factor_health_summary(dates[-1])["moneyflow_history"])
database.upsert_moneyflow([
{"trade_date": trade_date, "ts_code": "600000.SH", "net_mf_amount": 10}
for trade_date in dates
])
health = database.factor_health_summary(dates[-1])
self.assertTrue(health["moneyflow_history"])
self.assertEqual(health["moneyflow_dates"], 5)
def test_technical_helpers_detect_rsi_and_daily_reversal_path(self):
self.assertLess(_rsi([10, 9, 8, 7, 6, 5, 4], 6), 1)
rows = [
{"close": 10, "high": 10, "vol": 100},
{"close": 11, "high": 11, "vol": 120},
{"close": 12, "high": 12, "vol": 130},
{"close": 11.2, "high": 11.8, "vol": 100},
{"close": 12.5, "high": 12.5, "vol": 140},
]
metrics = _broken_reversal_metrics(
rows, [False, True, True, False, True], "600000", "示例"
)
self.assertEqual(metrics["signal"], 1)
self.assertEqual(metrics["days"], 1)
def test_factor_builder_generates_long_window_and_benchmark_factors(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
database.upsert_stock_master([
{
"ts_code": "600000.SH", "name": "趋势样本", "industry": "银行",
"market": "主板", "list_date": "20000101",
}
])
dates = []
cursor = datetime(2025, 6, 1)
while len(dates) < 260:
if cursor.weekday() < 5:
dates.append(cursor.strftime("%Y%m%d"))
cursor += timedelta(days=1)
bars = []
benchmarks = []
indicators = []
for index, trade_date in enumerate(dates):
close = 10 + index * 0.05
bars.append({
"trade_date": trade_date, "ts_code": "600000.SH",
"open": close - 0.02, "high": close + 0.08, "low": close - 0.08,
"close": close, "pct_chg": 0.25, "vol": 1000 + index,
"amount": 200000,
})
benchmarks.append({
"trade_date": trade_date, "ts_code": "000300.SH",
"close": 4000 + index, "pct_chg": 0.02,
})
if index >= 250:
indicators.append({
"trade_date": trade_date, "ts_code": "600000.SH",
"turnover_rate": 2, "volume_ratio": 1,
})
database.upsert_daily_bars(bars)
database.upsert_benchmark_bars(benchmarks)
database.upsert_daily_indicators(indicators)
factors, actual_date = ScreenerEngine(database).build_factors(
dates[-1], history_days=260
)
self.assertEqual(actual_date, dates[-1])
self.assertEqual(len(factors), 1)
factor = factors[0]
self.assertEqual(factor["ma_bull_alignment"], 1)
self.assertEqual(factor["rs_high_120"], 1)
self.assertGreater(factor["momentum_60_5"], 0)
self.assertEqual(factor["momentum_60_5_rank"], 0)
def test_factor_builder_generates_sector_momentum_and_five_day_flow(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
stocks = [
("600001.SH", "动量样本", "电子", 0.16, 180),
("600002.SH", "对照样本", "银行", 0.02, -40),
]
database.upsert_stock_master([
{
"ts_code": code, "name": name, "industry": industry,
"market": "主板", "list_date": "20000101",
}
for code, name, industry, _, _ in stocks
])
dates = []
cursor = datetime(2026, 4, 1)
while len(dates) < 80:
if cursor.weekday() < 5:
dates.append(cursor.strftime("%Y%m%d"))
cursor += timedelta(days=1)
bars = []
for index, trade_date in enumerate(dates):
for code, _, _, slope, _ in stocks:
close = 10 + index * slope
bars.append({
"trade_date": trade_date, "ts_code": code,
"open": close - 0.03, "high": close + 0.08,
"low": close - 0.08, "close": close,
"pct_chg": slope, "vol": 1000 + index,
"amount": 300000,
})
database.upsert_daily_bars(bars)
database.upsert_daily_indicators([
{
"trade_date": dates[-1], "ts_code": code,
"turnover_rate": 2, "volume_ratio": 1,
"circ_mv": 1000000, "total_mv": 1500000,
}
for code, *_ in stocks
])
database.upsert_moneyflow([
{
"trade_date": trade_date, "ts_code": code,
"net_mf_amount": daily_flow,
}
for trade_date in dates[-5:]
for code, _, _, _, daily_flow in stocks
])
factors, _ = ScreenerEngine(database).build_factors(
dates[-1], history_days=80
)
by_code = {item["ts_code"]: item for item in factors}
leader = by_code["600001.SH"]
laggard = by_code["600002.SH"]
self.assertGreater(leader["return_20d"], laggard["return_20d"])
self.assertEqual(leader["sector_momentum_rank"], 1)
self.assertEqual(laggard["sector_momentum_rank"], 0)
self.assertGreater(leader["net_flow_5d_million"], 0)
self.assertLess(laggard["net_flow_5d_million"], 0)
self.assertEqual(leader["sector_flow_rank"], 1)
def test_screen_reports_signal_health(self):
with tempfile.TemporaryDirectory() as root:
database = ReviewDatabase(Path(root) / "review.db")
engine = ScreenerEngine(database)
formula = {
"universe": {"exclude_st": True, "listed_days_min": 0},
"filters": [{"field": "pct_chg", "op": ">", "value": 0}],
"score": [{"field": "amount_billion", "weight": 1, "direction": "desc"}],
"limit": 5,
"min_score": 0,
}
result = engine.screen(
0, "20260724", formula, "repair", "健康检查", False,
mode="curated",
prepared_factors=[{
"ts_code": "600000.SH", "code": "600000", "name": "浦发银行",
"sector": "银行", "listed_days": 1000, "pct_chg": 1,
"amount_billion": 5, "price": 10, "return_5d": 1,
"volume_ratio_5d": 1, "sector_strength": 50,
}],
prepared_date="20260724",
)
health = result["meta"]["health"]
self.assertEqual(health["status"], "normal")
self.assertEqual(health["signal_count"], 1)
self.assertEqual(health["coverage"], 100)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -24,6 +24,9 @@ class SnapshotDatabase:
def save_data_snapshot(self, kind, cache_key, _source, payload):
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
def save_snapshot(self, _trade_date, _source, payload):
self.snapshot = copy.deepcopy(payload)
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
return copy.deepcopy(self.latest)
@@ -43,6 +46,7 @@ class DashboardCacheTests(unittest.TestCase):
"sentiment_phase": "retreat",
"sentiment_direction": "cooling",
"sentiment_components": {},
"sentiment_engine_version": 2,
},
}
service = self.service(snapshot)
@@ -71,6 +75,7 @@ class DashboardCacheTests(unittest.TestCase):
"sentiment_phase": "ice",
"sentiment_direction": "cooling",
"sentiment_components": {},
"sentiment_engine_version": 2,
})
return payload
+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-panel="{mode}"', self.html)
for element_id in (
"curatedStrategyList", "curatedRunButton", "quantFilterRows",
"curatedStrategyList", "quantFilterRows",
"quantScoreRows", "quantRunButton", "quantSaveButton",
):
self.assertIn(f'id="{element_id}"', self.html)
for removed_id in (
"curatedRunButton", "factorSyncButton", "screenerRunButton",
"changeStrategyButton",
):
self.assertNotIn(f'id="{removed_id}"', self.html)
self.assertIn("盘后自动候选池", self.html)
self.assertIn("自定义选股", self.html)
self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html)
self.assertIn('id="openStrategyDrawerButton"', self.html)
self.assertIn('id="closeStrategyDrawerButton"', self.html)
self.assertIn('id="activeStrategyDescription"', self.html)
self.assertIn('openStrategyDrawer("editor")', self.script)
for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"):
self.assertIn(f'id="{element_id}"', self.html)
self.assertIn("meta.suitable_environment", self.script)
self.assertIn("meta.failure_risk", self.script)
self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script)
def test_curated_library_explains_empty_signals_and_supports_school_views(self):
for element_id in ("curatedSchoolFilters", "curatedStrategyList"):
self.assertIn(f'id="{element_id}"', self.html)
for view in ("list", "grid"):
self.assertIn(f'data-curated-view="{view}"', self.html)
for school in ("基本面", "趋势", "短线", "动量"):
self.assertIn(school, self.script)
self.assertIn("curatedStrategyRunState", self.script)
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
self.assertIn('#reviewWorkspaceView .data-table tbody td', self.theme)
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
self.assertIn("#toast.toast {", styles)
self.assertIn("top: auto;", styles)
self.assertIn("left: auto;", styles)
self.assertIn("height: auto;", styles)
self.assertIn("#toast.toast[hidden] { display: none; }", styles)
self.assertNotIn(".toast{position:fixed", self.design_system)
self.assertNotRegex(wentian, r"(?m)^\.toast\s*\{")
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
+14
View File
@@ -106,6 +106,20 @@ class MarketInsightsTests(unittest.TestCase):
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
self.assertEqual(payload["focus_rows"][0]["code"], "000001")
def test_auction_amount_history_uses_the_same_a_share_universe_as_summary(self):
self.database.upsert_stock_master([
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"},
{"ts_code": "688001.SH", "name": "首日上市", "industry": "半导体", "market": "科创板", "list_date": "20260723"},
])
self.database.upsert_auction_factors([
{"ts_code": "000001.SZ", "trade_date": "20260723", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000},
{"ts_code": "688001.SH", "trade_date": "20260723", "price": 50, "pre_close": 10, "amount": 150_000_000, "vol": 3_000_000},
{"ts_code": "159001.SZ", "trade_date": "20260723", "price": 1.1, "pre_close": 1, "amount": 90_000_000, "vol": 90_000_000},
])
history = self.service._auction_amount_history("20260723")
self.assertEqual(history[-1]["stock_count"], 1)
self.assertEqual(history[-1]["amount_billion"], 0.05)
def test_real_limit_price_is_isolated_from_scored_candidates(self):
class OnePriceClient(FakeMarketClient):
def query(self, api_name, params=None, fields=""):
+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.assertEqual(removed["tracking"]["batches"], [])
def test_shared_automatic_run_can_be_added_to_private_tracking(self):
run_id = self.database.save_screener_run(
0,
"20260711",
"repair",
"系统盘后策略",
{},
{
"meta": {},
"candidates": [{
"ts_code": "600000.SH",
"code": "600000",
"name": "浦发银行",
"sector": "银行",
"price": 12.5,
}],
},
)
result = self.service.add_candidate(self.other["id"], run_id, "600000")
self.assertEqual(result["added"], 1)
self.assertEqual(len(self.database.list_strategy_tracks(self.other["id"])), 1)
self.assertEqual(self.database.list_strategy_tracks(self.owner["id"]), [])
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -866,6 +866,10 @@ class TushareClient:
deduped[code] = row
return list(deduped.values())
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
"""Return constituents active in a Shenwan L2 industry on the target date."""
return self._sw_sector_members(sector_code, trade_date)
def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
now = datetime.now().astimezone()
with self._stock_listing_lock: