feat: expand screeners and stabilize interactive feedback
This commit is contained in:
@@ -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
@@ -208,6 +208,17 @@ class ReviewDatabase:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date
|
CREATE INDEX IF NOT EXISTS idx_daily_bars_code_date
|
||||||
ON daily_bars(ts_code, trade_date DESC);
|
ON daily_bars(ts_code, trade_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS benchmark_bars (
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
ts_code TEXT NOT NULL,
|
||||||
|
close REAL NOT NULL DEFAULT 0,
|
||||||
|
pct_chg REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (trade_date, ts_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_benchmark_bars_code_date
|
||||||
|
ON benchmark_bars(ts_code, trade_date DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_indicators (
|
CREATE TABLE IF NOT EXISTS daily_indicators (
|
||||||
trade_date TEXT NOT NULL,
|
trade_date TEXT NOT NULL,
|
||||||
ts_code TEXT NOT NULL,
|
ts_code TEXT NOT NULL,
|
||||||
@@ -532,6 +543,7 @@ class ReviewDatabase:
|
|||||||
run_columns = {
|
run_columns = {
|
||||||
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)")
|
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)")
|
||||||
}
|
}
|
||||||
|
legacy_run_ownership = "user_id" not in run_columns
|
||||||
if "user_id" not in run_columns:
|
if "user_id" not in run_columns:
|
||||||
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
|
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
|
||||||
if "mode" not in run_columns:
|
if "mode" not in run_columns:
|
||||||
@@ -561,16 +573,28 @@ class ReviewDatabase:
|
|||||||
"UPDATE screener_runs SET mode = ? WHERE id = ?",
|
"UPDATE screener_runs SET mode = ? WHERE id = ?",
|
||||||
(mode, int(run["id"])),
|
(mode, int(run["id"])),
|
||||||
)
|
)
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE screener_runs SET user_id = NULL WHERE user_id = 0"
|
||||||
|
)
|
||||||
if first_user and first_user["id"]:
|
if first_user and first_user["id"]:
|
||||||
first_user_id = int(first_user["id"])
|
first_user_id = int(first_user["id"])
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL",
|
"UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL",
|
||||||
(first_user_id,),
|
(first_user_id,),
|
||||||
)
|
)
|
||||||
connection.execute(
|
if legacy_run_ownership:
|
||||||
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL",
|
connection.execute(
|
||||||
(first_user_id,),
|
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL",
|
||||||
)
|
(first_user_id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE screener_runs SET user_id = ?
|
||||||
|
WHERE user_id IS NULL AND mode = 'quant'
|
||||||
|
""",
|
||||||
|
(first_user_id,),
|
||||||
|
)
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_screener_strategies_user
|
CREATE INDEX IF NOT EXISTS idx_screener_strategies_user
|
||||||
@@ -1276,6 +1300,26 @@ class ReviewDatabase:
|
|||||||
)
|
)
|
||||||
return len(values)
|
return len(values)
|
||||||
|
|
||||||
|
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
|
||||||
|
values = [
|
||||||
|
(
|
||||||
|
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
|
||||||
|
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
|
||||||
|
)
|
||||||
|
for row in rows if row.get("trade_date") and row.get("ts_code")
|
||||||
|
]
|
||||||
|
with self.connect() as connection:
|
||||||
|
connection.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
|
||||||
|
close=excluded.close, pct_chg=excluded.pct_chg
|
||||||
|
""",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
return len(values)
|
||||||
|
|
||||||
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
|
||||||
values = [
|
values = [
|
||||||
(
|
(
|
||||||
@@ -1468,6 +1512,10 @@ class ReviewDatabase:
|
|||||||
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
|
||||||
(end_date,),
|
(end_date,),
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
benchmark_rows = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
indicator_date = connection.execute(
|
indicator_date = connection.execute(
|
||||||
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
|
||||||
(end_date,),
|
(end_date,),
|
||||||
@@ -1504,15 +1552,33 @@ class ReviewDatabase:
|
|||||||
""",
|
""",
|
||||||
(end_date,),
|
(end_date,),
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
moneyflow_dates = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(DISTINCT trade_date)
|
||||||
|
FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN (
|
||||||
|
SELECT DISTINCT trade_date
|
||||||
|
FROM daily_bars
|
||||||
|
WHERE trade_date <= ?
|
||||||
|
ORDER BY trade_date DESC
|
||||||
|
LIMIT 5
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(end_date,),
|
||||||
|
).fetchone()[0]
|
||||||
return {
|
return {
|
||||||
"market": bool(market),
|
"market": bool(market),
|
||||||
"auction": bool(auction),
|
"auction": bool(auction),
|
||||||
|
"benchmark": int(benchmark_rows or 0) >= 60,
|
||||||
|
"benchmark_rows": int(benchmark_rows or 0),
|
||||||
"valuation": bool(valuation_available),
|
"valuation": bool(valuation_available),
|
||||||
"fundamental": int(fundamental_rows or 0) >= 100,
|
"fundamental": int(fundamental_rows or 0) >= 100,
|
||||||
"dividend_history": int(dividend_years or 0) >= 4,
|
"dividend_history": int(dividend_years or 0) >= 4,
|
||||||
"valuation_rows": int(valuation_rows or 0),
|
"valuation_rows": int(valuation_rows or 0),
|
||||||
"fundamental_rows": int(fundamental_rows or 0),
|
"fundamental_rows": int(fundamental_rows or 0),
|
||||||
"dividend_years": int(dividend_years or 0),
|
"dividend_years": int(dividend_years or 0),
|
||||||
|
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
|
||||||
|
"moneyflow_dates": int(moneyflow_dates or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
|
||||||
@@ -1520,7 +1586,9 @@ class ReviewDatabase:
|
|||||||
if not dates:
|
if not dates:
|
||||||
return {
|
return {
|
||||||
"dates": [], "bars": [], "master": [], "indicators": [],
|
"dates": [], "bars": [], "master": [], "indicators": [],
|
||||||
"indicator_history": [], "fundamentals": [], "moneyflow": [], "auction": [],
|
"indicator_history": [], "indicator_series": [], "fundamentals": [],
|
||||||
|
"moneyflow": [], "moneyflow_history": [], "auction": [],
|
||||||
|
"benchmarks": [],
|
||||||
}
|
}
|
||||||
placeholders = ",".join("?" for _ in dates)
|
placeholders = ",".join("?" for _ in dates)
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
@@ -1553,6 +1621,15 @@ class ReviewDatabase:
|
|||||||
""",
|
""",
|
||||||
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
indicator_series = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT trade_date, ts_code, turnover_rate, volume_ratio
|
||||||
|
FROM daily_indicators
|
||||||
|
WHERE trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
fundamentals = connection.execute(
|
fundamentals = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT fi.* FROM fundamental_indicators fi
|
SELECT fi.* FROM fundamental_indicators fi
|
||||||
@@ -1576,6 +1653,16 @@ class ReviewDatabase:
|
|||||||
""",
|
""",
|
||||||
(end_date,),
|
(end_date,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
flow_dates = dates[-min(5, len(dates)):]
|
||||||
|
flow_placeholders = ",".join("?" for _ in flow_dates)
|
||||||
|
moneyflow_history = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM moneyflow_daily
|
||||||
|
WHERE trade_date IN ({flow_placeholders})
|
||||||
|
ORDER BY trade_date, ts_code
|
||||||
|
""",
|
||||||
|
flow_dates,
|
||||||
|
).fetchall()
|
||||||
auction = connection.execute(
|
auction = connection.execute(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM auction_factors
|
SELECT * FROM auction_factors
|
||||||
@@ -1585,15 +1672,26 @@ class ReviewDatabase:
|
|||||||
""",
|
""",
|
||||||
(end_date,),
|
(end_date,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
benchmarks = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT * FROM benchmark_bars
|
||||||
|
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date
|
||||||
|
""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
"dates": dates,
|
"dates": dates,
|
||||||
"bars": [dict(row) for row in bars],
|
"bars": [dict(row) for row in bars],
|
||||||
"master": [dict(row) for row in master],
|
"master": [dict(row) for row in master],
|
||||||
"indicators": [dict(row) for row in indicators],
|
"indicators": [dict(row) for row in indicators],
|
||||||
"indicator_history": [dict(row) for row in indicator_history],
|
"indicator_history": [dict(row) for row in indicator_history],
|
||||||
|
"indicator_series": [dict(row) for row in indicator_series],
|
||||||
"fundamentals": [dict(row) for row in fundamentals],
|
"fundamentals": [dict(row) for row in fundamentals],
|
||||||
"moneyflow": [dict(row) for row in moneyflow],
|
"moneyflow": [dict(row) for row in moneyflow],
|
||||||
|
"moneyflow_history": [dict(row) for row in moneyflow_history],
|
||||||
"auction": [dict(row) for row in auction],
|
"auction": [dict(row) for row in auction],
|
||||||
|
"benchmarks": [dict(row) for row in benchmarks],
|
||||||
}
|
}
|
||||||
|
|
||||||
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
@@ -1602,7 +1700,7 @@ class ReviewDatabase:
|
|||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
from .sentiment_engine import build_sentiment_history
|
from .sentiment_engine import build_sentiment_history
|
||||||
|
|
||||||
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 240))
|
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"trade_date": row["trade_date"],
|
"trade_date": row["trade_date"],
|
||||||
@@ -1618,7 +1716,7 @@ class ReviewDatabase:
|
|||||||
for row in series[-limit:]
|
for row in series[-limit:]
|
||||||
]
|
]
|
||||||
|
|
||||||
def list_snapshot_payloads(self, end_date: str, limit: int = 240) -> list[dict[str, Any]]:
|
def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
"""
|
||||||
@@ -1730,7 +1828,8 @@ class ReviewDatabase:
|
|||||||
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(int(user_id), trade_date, regime, normalized_mode, strategy_name,
|
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
|
||||||
|
normalized_mode, strategy_name,
|
||||||
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
|
||||||
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
|
||||||
)
|
)
|
||||||
@@ -1757,7 +1856,9 @@ class ReviewDatabase:
|
|||||||
def latest_screener_run(
|
def latest_screener_run(
|
||||||
self, user_id: int, trade_date: str, mode: str = "",
|
self, user_id: int, trade_date: str, mode: str = "",
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
parameters: tuple[Any, ...] = (int(user_id), trade_date)
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
mode_clause = ""
|
mode_clause = ""
|
||||||
if mode in {"smart", "curated", "quant"}:
|
if mode in {"smart", "curated", "quant"}:
|
||||||
mode_clause = " AND mode = ?"
|
mode_clause = " AND mode = ?"
|
||||||
@@ -1767,7 +1868,7 @@ class ReviewDatabase:
|
|||||||
f"""
|
f"""
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
FROM screener_runs
|
FROM screener_runs
|
||||||
WHERE user_id = ? AND trade_date <= ?{mode_clause}
|
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
|
||||||
ORDER BY id DESC LIMIT 1
|
ORDER BY id DESC LIMIT 1
|
||||||
""",
|
""",
|
||||||
parameters,
|
parameters,
|
||||||
@@ -1775,20 +1876,23 @@ class ReviewDatabase:
|
|||||||
return self._screener_run_payload(row) if row else None
|
return self._screener_run_payload(row) if row else None
|
||||||
|
|
||||||
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date,)
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
|
||||||
runs.strategy_name, runs.result, runs.created_at
|
runs.strategy_name, runs.result, runs.created_at
|
||||||
FROM screener_runs runs
|
FROM screener_runs runs
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT mode, MAX(id) AS id
|
SELECT mode, MAX(id) AS id
|
||||||
FROM screener_runs
|
FROM screener_runs
|
||||||
WHERE user_id = ? AND trade_date <= ?
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
GROUP BY mode
|
GROUP BY mode
|
||||||
) latest ON latest.id = runs.id
|
) latest ON latest.id = runs.id
|
||||||
""",
|
""",
|
||||||
(int(user_id), trade_date),
|
parameters,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
results: dict[str, dict[str, Any]] = {}
|
results: dict[str, dict[str, Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -1802,9 +1906,12 @@ class ReviewDatabase:
|
|||||||
self, user_id: int, trade_date: str, limit: int = 60,
|
self, user_id: int, trade_date: str, limit: int = 60,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
safe_limit = max(1, min(120, int(limit)))
|
safe_limit = max(1, min(120, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"""
|
f"""
|
||||||
WITH ranked AS (
|
WITH ranked AS (
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
|
||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
@@ -1815,7 +1922,7 @@ class ReviewDatabase:
|
|||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
) AS context_rank
|
) AS context_rank
|
||||||
FROM screener_runs
|
FROM screener_runs
|
||||||
WHERE user_id = ? AND trade_date <= ?
|
WHERE {owner_clause} AND trade_date <= ?
|
||||||
)
|
)
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
FROM ranked
|
FROM ranked
|
||||||
@@ -1823,7 +1930,7 @@ class ReviewDatabase:
|
|||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(int(user_id), trade_date, safe_limit),
|
parameters,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [
|
return [
|
||||||
payload
|
payload
|
||||||
@@ -1831,14 +1938,52 @@ class ReviewDatabase:
|
|||||||
if (payload := self._screener_run_payload(row)) is not None
|
if (payload := self._screener_run_payload(row)) is not None
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def screener_runs_for_date(
|
||||||
|
self, user_id: int, trade_date: str, limit: int = 80,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
safe_limit = max(1, min(160, int(limit)))
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
|
||||||
|
parameters += (trade_date, safe_limit)
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
|
FROM screener_runs
|
||||||
|
WHERE {owner_clause} AND trade_date = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
result = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for row in rows:
|
||||||
|
key = (
|
||||||
|
str(row["mode"] or "smart"),
|
||||||
|
str(row["regime"] or ""),
|
||||||
|
str(row["strategy_name"] or ""),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
payload = self._screener_run_payload(row)
|
||||||
|
if payload is not None:
|
||||||
|
result.append(payload)
|
||||||
|
return result
|
||||||
|
|
||||||
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
|
||||||
|
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
|
||||||
|
parameters: tuple[Any, ...] = (int(run_id),)
|
||||||
|
if int(user_id) != 0:
|
||||||
|
parameters += (int(user_id),)
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
|
||||||
FROM screener_runs WHERE id = ? AND user_id = ?
|
FROM screener_runs WHERE id = ? AND {owner_clause}
|
||||||
""",
|
""",
|
||||||
(int(run_id), int(user_id)),
|
parameters,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+24
-4
@@ -467,9 +467,23 @@ class MarketInsightsService:
|
|||||||
|
|
||||||
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
|
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
|
||||||
dates = self.database.auction_factor_dates(trade_date, 10)
|
dates = self.database.auction_factor_dates(trade_date, 10)
|
||||||
|
stock_list_dates = {
|
||||||
|
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
|
||||||
|
for item in self.database.list_stock_master()
|
||||||
|
if item.get("ts_code")
|
||||||
|
}
|
||||||
history = []
|
history = []
|
||||||
for current_date in dates:
|
for current_date in dates:
|
||||||
rows = self.database.auction_factors_for_date(current_date)
|
rows = [
|
||||||
|
row for row in self.database.auction_factors_for_date(current_date)
|
||||||
|
if (
|
||||||
|
str(row.get("ts_code") or "") in stock_list_dates
|
||||||
|
and (
|
||||||
|
not stock_list_dates[str(row.get("ts_code") or "")]
|
||||||
|
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
history.append(
|
history.append(
|
||||||
{
|
{
|
||||||
"trade_date": _display_date(current_date),
|
"trade_date": _display_date(current_date),
|
||||||
@@ -726,7 +740,7 @@ class MarketInsightsService:
|
|||||||
carried_forward = data_date != trade_date
|
carried_forward = data_date != trade_date
|
||||||
cache_key = data_date
|
cache_key = data_date
|
||||||
if not force and not dynamic:
|
if not force and not dynamic:
|
||||||
cached = self.database.get_data_snapshot("auction_center_v5", cache_key)
|
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
result = copy.deepcopy(cached)
|
result = copy.deepcopy(cached)
|
||||||
result["meta"] = {
|
result["meta"] = {
|
||||||
@@ -790,7 +804,13 @@ class MarketInsightsService:
|
|||||||
stock = master.get(ts_code)
|
stock = master.get(ts_code)
|
||||||
price = _number(row.get("price"))
|
price = _number(row.get("price"))
|
||||||
pre_close = _number(row.get("pre_close"))
|
pre_close = _number(row.get("pre_close"))
|
||||||
if not stock or price <= 0 or pre_close <= 0:
|
list_date = str((stock or {}).get("list_date") or "")
|
||||||
|
if (
|
||||||
|
not stock
|
||||||
|
or price <= 0
|
||||||
|
or pre_close <= 0
|
||||||
|
or (list_date and list_date >= data_date)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
change = (price / pre_close - 1) * 100
|
change = (price / pre_close - 1) * 100
|
||||||
amount_million = _number(row.get("amount")) / 1_000_000
|
amount_million = _number(row.get("amount")) / 1_000_000
|
||||||
@@ -908,7 +928,7 @@ class MarketInsightsService:
|
|||||||
"rows": candidates,
|
"rows": candidates,
|
||||||
}
|
}
|
||||||
if not dynamic:
|
if not dynamic:
|
||||||
self.database.save_data_snapshot("auction_center_v5", cache_key, "market", result)
|
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
|
||||||
return self._with_auction_watchlist(result, data_date, user_id)
|
return self._with_auction_watchlist(result, data_date, user_id)
|
||||||
|
|
||||||
def _theme_directory(self) -> list[dict[str, Any]]:
|
def _theme_directory(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
+581
-38
@@ -8,6 +8,7 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from advanced_strategies import ADVANCED_CURATED_STRATEGIES
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from sentiment_engine import build_sentiment_history, latest_contiguous_history
|
from sentiment_engine import build_sentiment_history, latest_contiguous_history
|
||||||
from tushare_client import TushareClient, TushareError
|
from tushare_client import TushareClient, TushareError
|
||||||
@@ -23,18 +24,45 @@ REGIMES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FACTOR_FIELDS = {
|
FACTOR_FIELDS = {
|
||||||
|
"close": "收盘价",
|
||||||
"pct_chg": "当日涨幅",
|
"pct_chg": "当日涨幅",
|
||||||
"return_5d": "5日涨幅",
|
"return_5d": "5日涨幅",
|
||||||
"return_10d": "10日涨幅",
|
"return_10d": "10日涨幅",
|
||||||
|
"return_20d": "20日涨幅",
|
||||||
|
"return_60d": "60日涨幅",
|
||||||
|
"return_5d_rank": "5日涨幅排名",
|
||||||
|
"momentum_60_5": "中期动量",
|
||||||
|
"momentum_60_5_rank": "中期动量排名",
|
||||||
"above_ma20": "站上20日线",
|
"above_ma20": "站上20日线",
|
||||||
|
"rsi_6": "RSI(6)",
|
||||||
|
"ma60_slope": "60日线斜率",
|
||||||
|
"ma20_slope_5d": "20日线5日斜率",
|
||||||
|
"ma_bull_alignment": "均线多头排列",
|
||||||
|
"drawdown_from_high_250": "距250日高点回撤",
|
||||||
|
"donchian_breakout_pct": "唐奇安突破幅度",
|
||||||
|
"range_20d": "20日振幅",
|
||||||
|
"rs_high_120": "RS线120日新高",
|
||||||
|
"excess_return_60d": "60日超额收益",
|
||||||
|
"weekly_trend_signal": "周线趋势信号",
|
||||||
|
"daily_buy_trigger": "日线买点",
|
||||||
|
"weekly_amount_trend": "周成交趋势",
|
||||||
"volume_ratio_5d": "5日量比",
|
"volume_ratio_5d": "5日量比",
|
||||||
|
"turnover_5d": "5日累计换手",
|
||||||
"volatility_10d": "10日波动率",
|
"volatility_10d": "10日波动率",
|
||||||
"amount_billion": "成交额",
|
"amount_billion": "成交额",
|
||||||
"turnover_rate": "换手率",
|
"turnover_rate": "换手率",
|
||||||
"circ_mv_billion": "流通市值",
|
"circ_mv_billion": "流通市值",
|
||||||
"net_flow_million": "主力净流入",
|
"net_flow_million": "主力净流入",
|
||||||
"large_flow_million": "大单净流入",
|
"large_flow_million": "大单净流入",
|
||||||
|
"net_flow_5d_million": "5日主力净流入",
|
||||||
|
"flow_to_circ_mv_5d": "5日净流入占流通市值",
|
||||||
"sector_strength": "板块强度",
|
"sector_strength": "板块强度",
|
||||||
|
"sector_return_5d": "行业5日涨幅",
|
||||||
|
"sector_return_20d": "行业20日涨幅",
|
||||||
|
"sector_momentum_rank": "行业20日动量排名",
|
||||||
|
"sector_stock_momentum_rank": "行业内个股动量排名",
|
||||||
|
"sector_net_flow_5d_million": "行业5日主力净流入",
|
||||||
|
"sector_flow_rank": "行业资金流排名",
|
||||||
"sector_limit_count": "板块涨停数",
|
"sector_limit_count": "板块涨停数",
|
||||||
"sector_up_count": "板块强势股数",
|
"sector_up_count": "板块强势股数",
|
||||||
"relative_strength": "相对强度",
|
"relative_strength": "相对强度",
|
||||||
@@ -66,22 +94,52 @@ FACTOR_FIELDS = {
|
|||||||
"previous_limit_signal": "昨日涨停或触板",
|
"previous_limit_signal": "昨日涨停或触板",
|
||||||
"previous_limit_streak": "昨日连板高度",
|
"previous_limit_streak": "昨日连板高度",
|
||||||
"previous_amount_billion": "昨日成交额",
|
"previous_amount_billion": "昨日成交额",
|
||||||
|
"is_limit_up_today": "当日涨停",
|
||||||
|
"is_limit_down_today": "当日跌停",
|
||||||
"sector_breadth_ma20": "行业20日线宽度",
|
"sector_breadth_ma20": "行业20日线宽度",
|
||||||
|
"no_limit_down_20d": "近20日无跌停",
|
||||||
|
"financial_risk": "财务风险标记",
|
||||||
|
"is_market_height": "当前市场最高板",
|
||||||
|
"new_space_board": "新晋空间板",
|
||||||
|
"max_continuous_board_10d": "近10日最高连板",
|
||||||
|
"dragon_first_yin": "龙头首阴",
|
||||||
|
"yin_day_pct": "首阴跌幅",
|
||||||
|
"vol_vs_previous": "较前日量能",
|
||||||
|
"broken_reversal": "断板反包",
|
||||||
|
"days_since_broken": "断板后天数",
|
||||||
|
"close_above_broken_high": "收复断板高点",
|
||||||
|
"vol_vs_broken_day": "较断板日量能",
|
||||||
|
"recent_limit_up_5d": "近5日涨停次数",
|
||||||
|
"intraday_min_pct": "盘中最大跌幅",
|
||||||
|
"lower_shadow_ratio": "下影线实体比",
|
||||||
}
|
}
|
||||||
|
|
||||||
FACTOR_GROUPS = {
|
FACTOR_GROUPS = {
|
||||||
"行情动量": [
|
"行情动量": [
|
||||||
"pct_chg", "return_5d", "return_10d", "above_ma20", "relative_strength",
|
"close", "pct_chg", "return_5d", "return_10d", "return_20d", "return_60d",
|
||||||
|
"return_5d_rank", "momentum_60_5", "momentum_60_5_rank", "above_ma20",
|
||||||
|
"rsi_6", "ma60_slope", "ma20_slope_5d", "ma_bull_alignment",
|
||||||
|
"drawdown_from_high_250", "donchian_breakout_pct", "range_20d",
|
||||||
|
"rs_high_120", "excess_return_60d", "weekly_trend_signal",
|
||||||
|
"daily_buy_trigger", "weekly_amount_trend", "relative_strength",
|
||||||
"relative_position_60", "close_to_high_15d", "close_to_high_60d",
|
"relative_position_60", "close_to_high_15d", "close_to_high_60d",
|
||||||
],
|
],
|
||||||
"量价交易": [
|
"量价交易": [
|
||||||
"volume_ratio_5d", "volatility_10d", "amount_billion", "turnover_rate",
|
"volume_ratio_5d", "turnover_5d", "volatility_10d", "amount_billion", "turnover_rate",
|
||||||
"net_flow_million", "large_flow_million", "previous_amount_billion",
|
"net_flow_million", "large_flow_million", "net_flow_5d_million",
|
||||||
|
"flow_to_circ_mv_5d", "previous_amount_billion",
|
||||||
|
"intraday_min_pct", "lower_shadow_ratio", "vol_vs_previous", "vol_vs_broken_day",
|
||||||
],
|
],
|
||||||
"板块结构": [
|
"板块结构": [
|
||||||
"sector_strength", "sector_limit_count", "sector_up_count", "sector_breadth_ma20",
|
"sector_strength", "sector_return_5d", "sector_return_20d", "sector_momentum_rank",
|
||||||
|
"sector_stock_momentum_rank", "sector_net_flow_5d_million", "sector_flow_rank",
|
||||||
|
"sector_limit_count", "sector_up_count", "sector_breadth_ma20",
|
||||||
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
|
"limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
|
||||||
"no_limit_30d", "had_limit_80d", "max_abs_change_15d",
|
"is_limit_up_today", "is_limit_down_today",
|
||||||
|
"no_limit_30d", "had_limit_80d", "max_abs_change_15d", "no_limit_down_20d",
|
||||||
|
"is_market_height", "new_space_board", "max_continuous_board_10d",
|
||||||
|
"dragon_first_yin", "yin_day_pct", "broken_reversal", "days_since_broken",
|
||||||
|
"close_above_broken_high", "recent_limit_up_5d",
|
||||||
],
|
],
|
||||||
"竞价因子": [
|
"竞价因子": [
|
||||||
"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio",
|
"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio",
|
||||||
@@ -92,7 +150,7 @@ FACTOR_GROUPS = {
|
|||||||
],
|
],
|
||||||
"财务质量": [
|
"财务质量": [
|
||||||
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
|
"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
|
||||||
"ocf_to_opincome",
|
"ocf_to_opincome", "financial_risk",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +554,112 @@ CURATED_STRATEGIES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CURATED_STRATEGIES.extend(ADVANCED_CURATED_STRATEGIES)
|
||||||
|
|
||||||
|
STRATEGY_ENVIRONMENT_NOTES = {
|
||||||
|
"连续分红质量": (
|
||||||
|
"防守市、低利率环境与中长期配置窗口",
|
||||||
|
"风险偏好快速上升时,稳健资产的价格弹性通常落后",
|
||||||
|
),
|
||||||
|
"ROIC质量低波": (
|
||||||
|
"震荡偏弱、重视盈利质量与回撤控制的市场",
|
||||||
|
"主题快速扩散或高弹性行情中,低波筛选可能错过进攻方向",
|
||||||
|
),
|
||||||
|
"低估值现金流白马": (
|
||||||
|
"估值修复、价值回归及防守配置阶段",
|
||||||
|
"低估值可能来自基本面持续走弱,需警惕价值陷阱",
|
||||||
|
),
|
||||||
|
"高增长合理估值": (
|
||||||
|
"业绩驱动、成长风格占优且趋势获得确认的阶段",
|
||||||
|
"增长预期下修或估值快速收缩时,回撤可能明显放大",
|
||||||
|
),
|
||||||
|
"行业宽度主线": (
|
||||||
|
"主线清晰、行业内部多数个股同步走强的行情",
|
||||||
|
"板块快速轮动时,宽度信号容易在确认后迅速衰减",
|
||||||
|
),
|
||||||
|
"首板低开": (
|
||||||
|
"情绪修复期的分歧转一致与首板次日承接",
|
||||||
|
"退潮加速或低开缺少量能承接时,弱势可能继续扩大",
|
||||||
|
),
|
||||||
|
"小碎步临界突破": (
|
||||||
|
"趋势蓄势、波动收敛后临近突破的结构市",
|
||||||
|
"无量突破或指数剧烈震荡时,容易形成冲高回落",
|
||||||
|
),
|
||||||
|
"连板龙头": (
|
||||||
|
"高度拓展、题材梯队完整且接力情绪活跃的阶段",
|
||||||
|
"亏钱效应扩散或高位股集中退潮时,接力风险很高",
|
||||||
|
),
|
||||||
|
"微盘三正": (
|
||||||
|
"小盘风格活跃、流动性宽松且风险偏好较高的行情",
|
||||||
|
"风格切向大盘或微盘流动性收缩时,组合波动会显著上升",
|
||||||
|
),
|
||||||
|
"首板高开弱转强": (
|
||||||
|
"竞价承接明确、短线情绪修复或主线发酵阶段",
|
||||||
|
"高开缺乏板块共振时,竞价强势可能转为盘中兑现",
|
||||||
|
),
|
||||||
|
"中期动量·强者恒强": (
|
||||||
|
"趋势延续、主升段及强弱分化清晰的行情",
|
||||||
|
"无趋势震荡或快速轮动中,动量信号容易反复失效",
|
||||||
|
),
|
||||||
|
"强者回调": (
|
||||||
|
"主升趋势未破、强势股完成良性回踩的窗口",
|
||||||
|
"趋势已反转时,回调信号可能演变为下跌中继",
|
||||||
|
),
|
||||||
|
"超跌反转": (
|
||||||
|
"急跌后恐慌释放充分、市场进入修复预期的阶段",
|
||||||
|
"单边下跌初段容易过早介入,超跌不等于止跌",
|
||||||
|
),
|
||||||
|
"相对强度新高": (
|
||||||
|
"指数偏弱但结构性主线明确,或机构抱团强化的行情",
|
||||||
|
"基准快速补涨或强势方向瓦解时,相对优势可能迅速消失",
|
||||||
|
),
|
||||||
|
"均线多头排列": (
|
||||||
|
"中期趋势向上、回撤有序的趋势市与主升段",
|
||||||
|
"高位趋势末端或宽幅震荡中,均线信号通常反应滞后",
|
||||||
|
),
|
||||||
|
"唐奇安通道突破": (
|
||||||
|
"整理末端、放量突破并启动新趋势的行情",
|
||||||
|
"无量突破和宽幅震荡环境中,假突破出现概率较高",
|
||||||
|
),
|
||||||
|
"周线趋势·日线买点": (
|
||||||
|
"中期趋势稳定、日线回踩或再启动的多周期共振阶段",
|
||||||
|
"周线拐点尚未确认时,日线信号可能只是短暂反抽",
|
||||||
|
),
|
||||||
|
"空间板": (
|
||||||
|
"市场高度持续拓展、板块梯队完整的强接力环境",
|
||||||
|
"高度压缩或亏钱效应扩散时,最高板的补跌风险极高",
|
||||||
|
),
|
||||||
|
"龙头首阴": (
|
||||||
|
"主线龙头仍有辨识度、首次分歧后存在回流预期的阶段",
|
||||||
|
"题材退潮或龙头地位被替代后,首阴可能只是下跌起点",
|
||||||
|
),
|
||||||
|
"断板反包": (
|
||||||
|
"强势题材分歧后快速修复、核心股重新获得资金承接时",
|
||||||
|
"板块强度不足或反包缩量时,形态持续性通常较弱",
|
||||||
|
),
|
||||||
|
"核按钮反核": (
|
||||||
|
"恐慌释放后出现明确承接、短线情绪转暖的窗口",
|
||||||
|
"系统性退潮中深水拉回可能只是日内脉冲,隔日风险较高",
|
||||||
|
),
|
||||||
|
"行业动量轮动": (
|
||||||
|
"主线相对清晰、行业趋势能够延续两周以上的结构市",
|
||||||
|
"行业轮动速度过快或前三名差距很小时,动量优势容易迅速衰减",
|
||||||
|
),
|
||||||
|
"主力资金行业流入": (
|
||||||
|
"板块轮动初期、资金先于价格形成连续净流入的阶段",
|
||||||
|
"资金流口径可能受大宗交易和短期对倒影响,单日突增不代表趋势",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
for strategy in CURATED_STRATEGIES:
|
||||||
|
suitable_environment, failure_risk = STRATEGY_ENVIRONMENT_NOTES[strategy["name"]]
|
||||||
|
strategy["formula"]["meta"].update(
|
||||||
|
{
|
||||||
|
"suitable_environment": suitable_environment,
|
||||||
|
"failure_risk": failure_risk,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
|
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
|
||||||
|
|
||||||
|
|
||||||
@@ -521,6 +685,7 @@ class FactorDataService:
|
|||||||
self.client = client
|
self.client = client
|
||||||
|
|
||||||
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
|
def sync(self, requested_date: str, lookback: int = 45) -> dict[str, Any]:
|
||||||
|
lookback = max(25, min(260, int(lookback)))
|
||||||
trade_date, _ = self.client.resolve_trade_context(requested_date)
|
trade_date, _ = self.client.resolve_trade_context(requested_date)
|
||||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||||
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
|
start = (end - timedelta(days=max(100, lookback * 2 + 20))).strftime("%Y%m%d")
|
||||||
@@ -532,9 +697,11 @@ class FactorDataService:
|
|||||||
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
|
dates = sorted(row["cal_date"] for row in calendar if row.get("is_open") == 1)[-lookback:]
|
||||||
existing = set(self.database.factor_dates(trade_date, lookback + 10))
|
existing = set(self.database.factor_dates(trade_date, lookback + 10))
|
||||||
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
|
dates_to_fetch = [value for value in dates if value not in existing or value == trade_date]
|
||||||
existing_auction = set(self.database.auction_factor_dates(trade_date, lookback + 10))
|
auction_source_dates = dates[-min(80, len(dates)):]
|
||||||
|
existing_auction = set(self.database.auction_factor_dates(trade_date, 90))
|
||||||
auction_dates_to_fetch = [
|
auction_dates_to_fetch = [
|
||||||
value for value in dates if value not in existing_auction or value == trade_date
|
value for value in auction_source_dates
|
||||||
|
if value not in existing_auction or value == trade_date
|
||||||
]
|
]
|
||||||
long_calendar = self.client.query(
|
long_calendar = self.client.query(
|
||||||
"trade_cal",
|
"trade_cal",
|
||||||
@@ -551,7 +718,7 @@ class FactorDataService:
|
|||||||
if row.get("is_open") == 1 and row.get("cal_date"):
|
if row.get("is_open") == 1 and row.get("cal_date"):
|
||||||
value = str(row["cal_date"])
|
value = str(row["cal_date"])
|
||||||
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
|
last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
|
||||||
valuation_dates = set(dates)
|
valuation_dates = set(dates[-min(80, len(dates)):])
|
||||||
valuation_dates.update(last_open_by_year.values())
|
valuation_dates.update(last_open_by_year.values())
|
||||||
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
|
existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
|
||||||
indicator_dates_to_fetch = sorted(
|
indicator_dates_to_fetch = sorted(
|
||||||
@@ -584,6 +751,16 @@ class FactorDataService:
|
|||||||
indicator_count += self.database.upsert_daily_indicators(indicators)
|
indicator_count += self.database.upsert_daily_indicators(indicators)
|
||||||
|
|
||||||
notices = []
|
notices = []
|
||||||
|
benchmark_count = 0
|
||||||
|
try:
|
||||||
|
benchmark_rows = self.client.query(
|
||||||
|
"index_daily",
|
||||||
|
{"ts_code": "000300.SH", "start_date": dates[0], "end_date": trade_date},
|
||||||
|
"ts_code,trade_date,close,pct_chg",
|
||||||
|
)
|
||||||
|
benchmark_count = self.database.upsert_benchmark_bars(benchmark_rows)
|
||||||
|
except TushareError as exc:
|
||||||
|
notices.append(f"沪深300基准暂不可用:{exc}")
|
||||||
fundamental_count = 0
|
fundamental_count = 0
|
||||||
existing_periods = set(self.database.fundamental_periods())
|
existing_periods = set(self.database.fundamental_periods())
|
||||||
for period in _quarter_periods(trade_date, 9):
|
for period in _quarter_periods(trade_date, 9):
|
||||||
@@ -620,17 +797,22 @@ class FactorDataService:
|
|||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
notices.append(f"竞价因子接口不可用:{exc}")
|
notices.append(f"竞价因子接口不可用:{exc}")
|
||||||
break
|
break
|
||||||
try:
|
moneyflow_count = 0
|
||||||
moneyflow = self.client.query(
|
moneyflow_dates = 0
|
||||||
"moneyflow",
|
for current_date in dates[-min(5, len(dates)):]:
|
||||||
{"trade_date": trade_date},
|
try:
|
||||||
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
|
moneyflow = self.client.query(
|
||||||
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
|
"moneyflow",
|
||||||
)
|
{"trade_date": current_date},
|
||||||
moneyflow_count = self.database.upsert_moneyflow(moneyflow)
|
"ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,"
|
||||||
except TushareError as exc:
|
"buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount",
|
||||||
moneyflow_count = 0
|
)
|
||||||
notices.append(f"资金流接口不可用:{exc}")
|
moneyflow_count += self.database.upsert_moneyflow(moneyflow)
|
||||||
|
if moneyflow:
|
||||||
|
moneyflow_dates += 1
|
||||||
|
except TushareError as exc:
|
||||||
|
notices.append(f"资金流接口不可用:{exc}")
|
||||||
|
break
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"trade_date": trade_date,
|
"trade_date": trade_date,
|
||||||
@@ -638,10 +820,12 @@ class FactorDataService:
|
|||||||
"fetched_dates": len(dates_to_fetch),
|
"fetched_dates": len(dates_to_fetch),
|
||||||
"stocks": master_count,
|
"stocks": master_count,
|
||||||
"bars": bar_count,
|
"bars": bar_count,
|
||||||
|
"benchmark_bars": benchmark_count,
|
||||||
"indicators": indicator_count,
|
"indicators": indicator_count,
|
||||||
"indicator_dates": len(indicator_dates_to_fetch),
|
"indicator_dates": len(indicator_dates_to_fetch),
|
||||||
"fundamentals": fundamental_count,
|
"fundamentals": fundamental_count,
|
||||||
"moneyflow": moneyflow_count,
|
"moneyflow": moneyflow_count,
|
||||||
|
"moneyflow_dates": moneyflow_dates,
|
||||||
"auction_rows": auction_count,
|
"auction_rows": auction_count,
|
||||||
"auction_dates": auction_dates,
|
"auction_dates": auction_dates,
|
||||||
"notice": ";".join(notices),
|
"notice": ";".join(notices),
|
||||||
@@ -651,6 +835,7 @@ class FactorDataService:
|
|||||||
class ScreenerEngine:
|
class ScreenerEngine:
|
||||||
def __init__(self, database: ReviewDatabase) -> None:
|
def __init__(self, database: ReviewDatabase) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
|
self._backtest_factor_cache: dict[tuple[str, int], list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
def ensure_builtin_strategies(self) -> None:
|
def ensure_builtin_strategies(self) -> None:
|
||||||
existing = {
|
existing = {
|
||||||
@@ -667,7 +852,7 @@ class ScreenerEngine:
|
|||||||
|
|
||||||
def detect_regime(self, trade_date: str) -> dict[str, Any]:
|
def detect_regime(self, trade_date: str) -> dict[str, Any]:
|
||||||
series = latest_contiguous_history(
|
series = latest_contiguous_history(
|
||||||
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 240))
|
build_sentiment_history(self.database.list_snapshot_payloads(trade_date, 260))
|
||||||
)
|
)
|
||||||
if not series:
|
if not series:
|
||||||
return {
|
return {
|
||||||
@@ -747,12 +932,32 @@ class ScreenerEngine:
|
|||||||
strategy_name: str, run_backtest: bool = True,
|
strategy_name: str, run_backtest: bool = True,
|
||||||
realtime_snapshot: dict[str, Any] | None = None,
|
realtime_snapshot: dict[str, Any] | None = None,
|
||||||
mode: str = "smart",
|
mode: str = "smart",
|
||||||
|
prepared_factors: list[dict[str, Any]] | None = None,
|
||||||
|
prepared_date: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
mode = mode if mode in {"smart", "curated", "quant"} else "smart"
|
||||||
formula = self.validate_formula(formula)
|
formula = self.validate_formula(formula)
|
||||||
factors, actual_date = self.build_factors(trade_date, realtime_snapshot)
|
if prepared_factors is None:
|
||||||
|
history_days = int((formula.get("meta") or {}).get("history_days") or 80)
|
||||||
|
factors, actual_date = self.build_factors(
|
||||||
|
trade_date, realtime_snapshot, history_days
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
factors = prepared_factors
|
||||||
|
actual_date = prepared_date or trade_date
|
||||||
candidates = self.apply_formula(factors, formula, regime)
|
candidates = self.apply_formula(factors, formula, regime)
|
||||||
backtest = self.backtest(actual_date, formula) if run_backtest else None
|
backtest = self.backtest(actual_date, formula) if run_backtest else None
|
||||||
|
required_fields = sorted({
|
||||||
|
str(item.get("field") or "")
|
||||||
|
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
||||||
|
if item.get("field")
|
||||||
|
})
|
||||||
|
complete_rows = sum(
|
||||||
|
1 for row in factors
|
||||||
|
if all(row.get(field) is not None for field in required_fields)
|
||||||
|
)
|
||||||
|
coverage = round(complete_rows / len(factors) * 100, 1) if factors else 0.0
|
||||||
|
health_status = "normal" if candidates else "no_signal"
|
||||||
if backtest and backtest["samples"] >= 20:
|
if backtest and backtest["samples"] >= 20:
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
|
estimate = backtest["win_rate"] * 0.65 + candidate["score"] * 100 * 0.35
|
||||||
@@ -769,9 +974,20 @@ class ScreenerEngine:
|
|||||||
"regime_label": REGIMES.get(regime, regime),
|
"regime_label": REGIMES.get(regime, regime),
|
||||||
"strategy_name": strategy_name,
|
"strategy_name": strategy_name,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
|
"library_version": int(
|
||||||
|
(formula.get("meta") or {}).get("library_version") or 0
|
||||||
|
),
|
||||||
"universe_count": len(factors),
|
"universe_count": len(factors),
|
||||||
"candidate_count": len(candidates),
|
"candidate_count": len(candidates),
|
||||||
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"health": {
|
||||||
|
"status": health_status,
|
||||||
|
"required_field_count": len(required_fields),
|
||||||
|
"complete_rows": complete_rows,
|
||||||
|
"universe_rows": len(factors),
|
||||||
|
"coverage": coverage,
|
||||||
|
"signal_count": len(candidates),
|
||||||
|
},
|
||||||
"selection_source": (
|
"selection_source": (
|
||||||
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
|
"tushare_rt_k+history" if realtime_snapshot else "historical_eod"
|
||||||
),
|
),
|
||||||
@@ -791,7 +1007,11 @@ class ScreenerEngine:
|
|||||||
"formula": formula,
|
"formula": formula,
|
||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
"backtest": backtest,
|
"backtest": backtest,
|
||||||
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。",
|
"disclaimer": (
|
||||||
|
"候选仅由策略条件与当日数据计算;历史统计不代表未来收益。"
|
||||||
|
if mode == "curated"
|
||||||
|
else "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
run_id = self.database.save_screener_run(
|
run_id = self.database.save_screener_run(
|
||||||
user_id, actual_date, regime, strategy_name, formula, result, mode
|
user_id, actual_date, regime, strategy_name, formula, result, mode
|
||||||
@@ -803,8 +1023,10 @@ class ScreenerEngine:
|
|||||||
self,
|
self,
|
||||||
trade_date: str,
|
trade_date: str,
|
||||||
realtime_snapshot: dict[str, Any] | None = None,
|
realtime_snapshot: dict[str, Any] | None = None,
|
||||||
|
history_days: int = 80,
|
||||||
) -> tuple[list[dict[str, Any]], str]:
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
data = self.database.load_factor_data(trade_date, 80)
|
history_days = max(21, min(260, int(history_days)))
|
||||||
|
data = self.database.load_factor_data(trade_date, history_days)
|
||||||
dates = [value for value in data["dates"] if value <= trade_date]
|
dates = [value for value in data["dates"] if value <= trade_date]
|
||||||
if len(dates) < 21:
|
if len(dates) < 21:
|
||||||
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
|
raise ValueError("历史行情不足 21 个交易日,请先同步因子数据。")
|
||||||
@@ -822,7 +1044,18 @@ class ScreenerEngine:
|
|||||||
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
for row in data.get("indicator_history", []):
|
for row in data.get("indicator_history", []):
|
||||||
indicator_history[str(row.get("ts_code") or "")].append(row)
|
indicator_history[str(row.get("ts_code") or "")].append(row)
|
||||||
|
indicator_series: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in data.get("indicator_series", []):
|
||||||
|
indicator_series[str(row.get("ts_code") or "")].append(row)
|
||||||
|
benchmark_by_date = {
|
||||||
|
str(row.get("trade_date") or ""): _number(row.get("close"))
|
||||||
|
for row in data.get("benchmarks", [])
|
||||||
|
if _number(row.get("close")) > 0
|
||||||
|
}
|
||||||
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
|
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
|
||||||
|
moneyflow_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for row in data.get("moneyflow_history", []):
|
||||||
|
moneyflow_history[str(row.get("ts_code") or "")].append(row)
|
||||||
auction = {
|
auction = {
|
||||||
row["ts_code"]: row
|
row["ts_code"]: row
|
||||||
for row in data.get("auction", [])
|
for row in data.get("auction", [])
|
||||||
@@ -863,6 +1096,7 @@ class ScreenerEngine:
|
|||||||
indicator = indicators.get(ts_code, {})
|
indicator = indicators.get(ts_code, {})
|
||||||
fundamental = fundamentals.get(ts_code, {})
|
fundamental = fundamentals.get(ts_code, {})
|
||||||
flow = moneyflow.get(ts_code, {})
|
flow = moneyflow.get(ts_code, {})
|
||||||
|
flow_history = moneyflow_history.get(ts_code, [])
|
||||||
auction_row = auction.get(ts_code, {})
|
auction_row = auction.get(ts_code, {})
|
||||||
list_date = str(info.get("list_date") or "")
|
list_date = str(info.get("list_date") or "")
|
||||||
try:
|
try:
|
||||||
@@ -906,6 +1140,72 @@ class ScreenerEngine:
|
|||||||
dividend_years = sum(
|
dividend_years = sum(
|
||||||
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
|
1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
|
||||||
)
|
)
|
||||||
|
current_streak = _ending_streak(limit_flags)
|
||||||
|
prior_streak = _ending_streak(limit_flags, len(limit_flags) - 2)
|
||||||
|
streak = max(streak, current_streak)
|
||||||
|
return_60d = (
|
||||||
|
(closes[-1] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
|
||||||
|
)
|
||||||
|
momentum_60_5 = (
|
||||||
|
(closes[-6] / closes[-61] - 1) * 100 if len(closes) >= 61 and closes[-61] else 0
|
||||||
|
)
|
||||||
|
ma20 = statistics.fmean(closes[-20:])
|
||||||
|
ma60 = statistics.fmean(closes[-60:]) if len(closes) >= 60 else ma20
|
||||||
|
prior_ma20 = statistics.fmean(closes[-25:-5]) if len(closes) >= 25 else ma20
|
||||||
|
prior_ma60 = statistics.fmean(closes[-65:-5]) if len(closes) >= 65 else ma60
|
||||||
|
ma20_slope = (ma20 / prior_ma20 - 1) * 100 if prior_ma20 else 0
|
||||||
|
ma60_slope = (ma60 / prior_ma60 - 1) * 100 if prior_ma60 else 0
|
||||||
|
ma_values = [statistics.fmean(closes[-window:]) for window in (5, 10, 20, 60)]
|
||||||
|
high_250 = max(shape_high[-250:]) if len(shape_high) >= 250 else max(shape_high)
|
||||||
|
drawdown_250 = (1 - closes[-1] / high_250) * 100 if high_250 else 100
|
||||||
|
prior_high_20 = max(shape_high[-21:-1]) if len(shape_high) >= 21 else 0
|
||||||
|
breakout_pct = (closes[-1] / prior_high_20 - 1) * 100 if prior_high_20 else 0
|
||||||
|
prior_lows_20 = shape_low[-21:-1]
|
||||||
|
range_20d = (
|
||||||
|
(prior_high_20 / min(prior_lows_20) - 1) * 100
|
||||||
|
if prior_lows_20 and min(prior_lows_20) > 0 else 100
|
||||||
|
)
|
||||||
|
turnover_rows = sorted(
|
||||||
|
indicator_series.get(ts_code, []), key=lambda item: str(item.get("trade_date") or "")
|
||||||
|
)
|
||||||
|
turnover_values = [_number(item.get("turnover_rate")) for item in turnover_rows[-5:]]
|
||||||
|
if realtime and _number(realtime.get("turnover_rate")):
|
||||||
|
turnover_values = turnover_values[-4:] + [_number(realtime.get("turnover_rate"))]
|
||||||
|
turnover_5d = sum(turnover_values)
|
||||||
|
rs_values = [
|
||||||
|
_number(item.get("close")) / benchmark_by_date[str(item.get("trade_date"))]
|
||||||
|
for item in shape_rows[-120:]
|
||||||
|
if benchmark_by_date.get(str(item.get("trade_date"))) and _number(item.get("close")) > 0
|
||||||
|
]
|
||||||
|
benchmark_60 = [
|
||||||
|
benchmark_by_date.get(str(item.get("trade_date")))
|
||||||
|
for item in shape_rows[-61:]
|
||||||
|
if benchmark_by_date.get(str(item.get("trade_date")))
|
||||||
|
]
|
||||||
|
benchmark_return_60 = (
|
||||||
|
(benchmark_60[-1] / benchmark_60[0] - 1) * 100
|
||||||
|
if len(benchmark_60) >= 61 and benchmark_60[0] else 0
|
||||||
|
)
|
||||||
|
weekly_closes, weekly_amounts = _weekly_series(shape_rows)
|
||||||
|
weekly_dif, weekly_dea = _macd_last(weekly_closes)
|
||||||
|
daily_dif, daily_dea = _macd_series(closes)
|
||||||
|
daily_cross = (
|
||||||
|
len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1]
|
||||||
|
and daily_dif[-2] <= daily_dea[-2]
|
||||||
|
)
|
||||||
|
current_open = _number(current.get("open"))
|
||||||
|
daily_pullback = closes[-1] >= ma20 and current_open <= ma20 * 1.02 and closes[-1] > current_open
|
||||||
|
previous_close = closes[-2] if len(closes) >= 2 else closes[-1]
|
||||||
|
intraday_min = (
|
||||||
|
(_number(current.get("low")) / previous_close - 1) * 100 if previous_close else 0
|
||||||
|
)
|
||||||
|
body = abs(closes[-1] - current_open)
|
||||||
|
lower_shadow = max(0.0, min(current_open, closes[-1]) - _number(current.get("low")))
|
||||||
|
lower_shadow_ratio = lower_shadow / body if body > 0 else (10.0 if lower_shadow > 0 else 0.0)
|
||||||
|
previous_volume_value = volumes[-2] if len(volumes) >= 2 else 0
|
||||||
|
vol_vs_previous = volumes[-1] / previous_volume_value if previous_volume_value else 0
|
||||||
|
broken = _broken_reversal_metrics(shape_rows, limit_flags, code, name)
|
||||||
|
netprofit_yoy = _optional_number(fundamental.get("netprofit_yoy"))
|
||||||
factors.append(
|
factors.append(
|
||||||
{
|
{
|
||||||
"code": code,
|
"code": code,
|
||||||
@@ -914,12 +1214,32 @@ class ScreenerEngine:
|
|||||||
"sector": info.get("industry") or "其他",
|
"sector": info.get("industry") or "其他",
|
||||||
"market": info.get("market") or "--",
|
"market": info.get("market") or "--",
|
||||||
"listed_days": listed_days,
|
"listed_days": listed_days,
|
||||||
|
"close": round(closes[-1], 2),
|
||||||
"price": round(closes[-1], 2),
|
"price": round(closes[-1], 2),
|
||||||
"pct_chg": round(_number(current["pct_chg"]), 2),
|
"pct_chg": round(_number(current["pct_chg"]), 2),
|
||||||
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
|
"return_5d": round((closes[-1] / closes[-6] - 1) * 100, 2),
|
||||||
"return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2),
|
"return_10d": round((closes[-1] / closes[-11] - 1) * 100, 2),
|
||||||
"above_ma20": int(closes[-1] > statistics.fmean(closes[-20:])),
|
"return_20d": round((closes[-1] / closes[-21] - 1) * 100, 2),
|
||||||
|
"return_60d": round(return_60d, 2),
|
||||||
|
"momentum_60_5": round(momentum_60_5, 2),
|
||||||
|
"above_ma20": int(closes[-1] > ma20),
|
||||||
|
"rsi_6": round(_rsi(closes, 6), 2),
|
||||||
|
"ma60_slope": round(ma60_slope, 3),
|
||||||
|
"ma20_slope_5d": round(ma20_slope, 3),
|
||||||
|
"ma_bull_alignment": int(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3]),
|
||||||
|
"drawdown_from_high_250": round(drawdown_250, 2),
|
||||||
|
"donchian_breakout_pct": round(breakout_pct, 2),
|
||||||
|
"range_20d": round(range_20d, 2),
|
||||||
|
"rs_high_120": int(len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)),
|
||||||
|
"excess_return_60d": round(return_60d - benchmark_return_60, 2),
|
||||||
|
"weekly_trend_signal": int(len(weekly_closes) >= 30 and weekly_dif > 0 and weekly_dea > 0),
|
||||||
|
"daily_buy_trigger": int(daily_cross or daily_pullback),
|
||||||
|
"weekly_amount_trend": int(
|
||||||
|
len(weekly_amounts) >= 5
|
||||||
|
and weekly_amounts[-1] >= statistics.fmean(weekly_amounts[-5:-1])
|
||||||
|
),
|
||||||
"volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0,
|
"volume_ratio_5d": round(volumes[-1] / previous_volume, 2) if previous_volume else 0,
|
||||||
|
"turnover_5d": round(turnover_5d, 2),
|
||||||
"volatility_10d": round(statistics.pstdev(returns_10), 2),
|
"volatility_10d": round(statistics.pstdev(returns_10), 2),
|
||||||
"amount_billion": round(
|
"amount_billion": round(
|
||||||
_number(current["amount"]) / (100000000 if realtime else 100000), 2
|
_number(current["amount"]) / (100000000 if realtime else 100000), 2
|
||||||
@@ -945,8 +1265,19 @@ class ScreenerEngine:
|
|||||||
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
|
"ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
|
||||||
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
|
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
|
||||||
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
|
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
|
||||||
|
"net_flow_5d_million": round(
|
||||||
|
sum(_number(item.get("net_mf_amount")) for item in flow_history) / 100,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"flow_to_circ_mv_5d": round(
|
||||||
|
sum(_number(item.get("net_mf_amount")) for item in flow_history)
|
||||||
|
/ _number(indicator.get("circ_mv")) * 100,
|
||||||
|
4,
|
||||||
|
) if _number(indicator.get("circ_mv")) else 0,
|
||||||
"limit_status": status,
|
"limit_status": status,
|
||||||
"limit_streak": streak,
|
"limit_streak": streak,
|
||||||
|
"is_limit_up_today": int(limit_flags[-1]),
|
||||||
|
"is_limit_down_today": int(_number(current.get("pct_chg")) <= -_limit_threshold(code, name)),
|
||||||
"auction_change": round(_number(auction_row.get("change")), 2),
|
"auction_change": round(_number(auction_row.get("change")), 2),
|
||||||
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
|
"auction_amount_million": round(_number(auction_row.get("amount")) / 1_000_000, 2),
|
||||||
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4),
|
"auction_turnover_rate": round(_number(auction_row.get("turnover_rate")), 4),
|
||||||
@@ -957,6 +1288,28 @@ class ScreenerEngine:
|
|||||||
"close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0,
|
"close_to_high_60d": round(closes[-1] / max(shape_high[-60:]), 4) if shape_high[-60:] and max(shape_high[-60:]) else 0,
|
||||||
"no_limit_30d": int(not any(limit_flags[-30:])),
|
"no_limit_30d": int(not any(limit_flags[-30:])),
|
||||||
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
|
"had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
|
||||||
|
"no_limit_down_20d": int(not any(
|
||||||
|
_number(item.get("pct_chg")) <= -_limit_threshold(code, name)
|
||||||
|
for item in shape_rows[-20:]
|
||||||
|
)),
|
||||||
|
"financial_risk": int(
|
||||||
|
"ST" in name.upper() or "退" in name
|
||||||
|
or (netprofit_yoy is not None and netprofit_yoy <= -100)
|
||||||
|
),
|
||||||
|
"prior_limit_streak": prior_streak,
|
||||||
|
"max_continuous_board_10d": _max_streak(limit_flags[-10:]),
|
||||||
|
"dragon_first_yin": int(
|
||||||
|
prior_streak >= 3 and not limit_flags[-1] and closes[-1] < current_open
|
||||||
|
),
|
||||||
|
"yin_day_pct": round(_number(current.get("pct_chg")), 2),
|
||||||
|
"vol_vs_previous": round(vol_vs_previous, 3),
|
||||||
|
"broken_reversal": broken["signal"],
|
||||||
|
"days_since_broken": broken["days"],
|
||||||
|
"close_above_broken_high": broken["recovered"],
|
||||||
|
"vol_vs_broken_day": broken["volume_ratio"],
|
||||||
|
"recent_limit_up_5d": sum(limit_flags[-5:]),
|
||||||
|
"intraday_min_pct": round(intraday_min, 2),
|
||||||
|
"lower_shadow_ratio": round(lower_shadow_ratio, 2),
|
||||||
"previous_first_limit": int(previous_limit and not recent_prior_signal),
|
"previous_first_limit": int(previous_limit and not recent_prior_signal),
|
||||||
"previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
|
"previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
|
||||||
"previous_limit_streak": previous_streak,
|
"previous_limit_streak": previous_streak,
|
||||||
@@ -968,18 +1321,65 @@ class ScreenerEngine:
|
|||||||
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
for row in factors:
|
for row in factors:
|
||||||
sectors[row["sector"]].append(row)
|
sectors[row["sector"]].append(row)
|
||||||
for sector_rows in sectors.values():
|
sector_metrics = []
|
||||||
|
for sector_name, sector_rows in sectors.items():
|
||||||
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
|
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
|
||||||
|
average_return_20d = statistics.fmean(row["return_20d"] for row in sector_rows)
|
||||||
|
sector_net_flow = sum(row["net_flow_5d_million"] for row in sector_rows)
|
||||||
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
|
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
|
||||||
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
|
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
|
||||||
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
|
breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
|
||||||
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
|
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
|
||||||
|
sector_metrics.append(
|
||||||
|
{
|
||||||
|
"ts_code": sector_name,
|
||||||
|
"sector_return_20d": average_return_20d,
|
||||||
|
"sector_net_flow_5d_million": sector_net_flow,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stock_momentum_ranks = _percentile_map(sector_rows, "return_20d", "desc")
|
||||||
for row in sector_rows:
|
for row in sector_rows:
|
||||||
row["sector_strength"] = round(strength, 1)
|
row["sector_strength"] = round(strength, 1)
|
||||||
|
row["sector_return_5d"] = round(average_return, 2)
|
||||||
|
row["sector_return_20d"] = round(average_return_20d, 2)
|
||||||
|
row["sector_net_flow_5d_million"] = round(sector_net_flow, 2)
|
||||||
|
row["sector_stock_momentum_rank"] = round(
|
||||||
|
stock_momentum_ranks.get(row["ts_code"], 0.0), 4
|
||||||
|
)
|
||||||
row["sector_limit_count"] = limit_count
|
row["sector_limit_count"] = limit_count
|
||||||
row["sector_up_count"] = up_count
|
row["sector_up_count"] = up_count
|
||||||
row["sector_breadth_ma20"] = round(breadth_ma20, 1)
|
row["sector_breadth_ma20"] = round(breadth_ma20, 1)
|
||||||
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
|
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
|
||||||
|
sector_momentum_ranks = _percentile_map(
|
||||||
|
sector_metrics, "sector_return_20d", "desc"
|
||||||
|
)
|
||||||
|
sector_flow_ranks = _percentile_map(
|
||||||
|
sector_metrics, "sector_net_flow_5d_million", "desc"
|
||||||
|
)
|
||||||
|
for sector_name, sector_rows in sectors.items():
|
||||||
|
for row in sector_rows:
|
||||||
|
row["sector_momentum_rank"] = round(
|
||||||
|
sector_momentum_ranks.get(sector_name, 0.0), 4
|
||||||
|
)
|
||||||
|
row["sector_flow_rank"] = round(
|
||||||
|
sector_flow_ranks.get(sector_name, 0.0), 4
|
||||||
|
)
|
||||||
|
momentum_ranks = _percentile_map(factors, "momentum_60_5", "desc")
|
||||||
|
return_ranks = _percentile_map(factors, "return_5d", "desc")
|
||||||
|
market_height = max((int(row.get("limit_streak") or 0) for row in factors), default=0)
|
||||||
|
prior_market_height = max((int(row.get("prior_limit_streak") or 0) for row in factors), default=0)
|
||||||
|
for row in factors:
|
||||||
|
row["momentum_60_5_rank"] = round(momentum_ranks.get(row["ts_code"], 0.0), 4)
|
||||||
|
row["return_5d_rank"] = round(return_ranks.get(row["ts_code"], 0.0), 4)
|
||||||
|
is_height = market_height >= 2 and int(row.get("limit_streak") or 0) == market_height
|
||||||
|
row["is_market_height"] = int(is_height)
|
||||||
|
row["new_space_board"] = int(
|
||||||
|
is_height
|
||||||
|
and not (
|
||||||
|
prior_market_height >= 2
|
||||||
|
and int(row.get("prior_limit_streak") or 0) == prior_market_height
|
||||||
|
)
|
||||||
|
)
|
||||||
return factors, actual_date
|
return factors, actual_date
|
||||||
|
|
||||||
def apply_formula(
|
def apply_formula(
|
||||||
@@ -1030,20 +1430,50 @@ class ScreenerEngine:
|
|||||||
item["score_display"] = round(score * 100, 1)
|
item["score_display"] = round(score * 100, 1)
|
||||||
item["contributions"] = contributions
|
item["contributions"] = contributions
|
||||||
item["reason"] = "、".join(entry["label"] for entry in contributions[:3])
|
item["reason"] = "、".join(entry["label"] for entry in contributions[:3])
|
||||||
item["risk_flags"] = _risk_flags(row, regime)
|
include_regime_risk = formula.get("meta", {}).get("library") != "curated"
|
||||||
|
item["risk_flags"] = _risk_flags(row, regime, include_regime_risk)
|
||||||
results.append(item)
|
results.append(item)
|
||||||
results.sort(key=lambda item: item["score"], reverse=True)
|
results.sort(key=lambda item: item["score"], reverse=True)
|
||||||
return results[: formula["limit"]]
|
return results[: formula["limit"]]
|
||||||
|
|
||||||
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
|
def backtest(self, trade_date: str, formula: dict[str, Any]) -> dict[str, Any]:
|
||||||
dates = self.database.factor_dates(trade_date, 55)
|
meta = formula.get("meta") or {}
|
||||||
evaluation_dates = dates[20:-3][-8:]
|
history_days = max(21, min(260, int(meta.get("history_days") or 80)))
|
||||||
|
holding_days = max(1, min(30, int(meta.get("backtest_days") or 3)))
|
||||||
|
take_profit = max(0.5, min(50.0, float(meta.get("take_profit") or 3)))
|
||||||
|
stop_loss = min(-0.5, max(-50.0, float(meta.get("stop_loss") or -3)))
|
||||||
|
dates = self.database.factor_dates(trade_date, history_days + holding_days + 20)
|
||||||
|
eligible_dates = dates[:-holding_days] if len(dates) > holding_days else []
|
||||||
|
frequency = str(meta.get("frequency") or "每日")
|
||||||
|
if "月" in frequency:
|
||||||
|
grouped = {}
|
||||||
|
for value in eligible_dates:
|
||||||
|
grouped[value[:6]] = value
|
||||||
|
evaluation_dates = list(grouped.values())[-8:]
|
||||||
|
elif "双周" in frequency:
|
||||||
|
weekly_dates = []
|
||||||
|
grouped = {}
|
||||||
|
for value in eligible_dates:
|
||||||
|
parsed = datetime.strptime(value, "%Y%m%d")
|
||||||
|
grouped[parsed.strftime("%G-%V")] = value
|
||||||
|
weekly_dates = list(grouped.values())
|
||||||
|
evaluation_dates = weekly_dates[-16::2][-8:]
|
||||||
|
elif "周" in frequency:
|
||||||
|
grouped = {}
|
||||||
|
for value in eligible_dates:
|
||||||
|
parsed = datetime.strptime(value, "%Y%m%d")
|
||||||
|
grouped[parsed.strftime("%G-%V")] = value
|
||||||
|
evaluation_dates = list(grouped.values())[-8:]
|
||||||
|
else:
|
||||||
|
evaluation_dates = eligible_dates[-8:]
|
||||||
wins = 0
|
wins = 0
|
||||||
losses = 0
|
losses = 0
|
||||||
samples = 0
|
samples = 0
|
||||||
returns = []
|
returns = []
|
||||||
drawdowns = []
|
drawdowns = []
|
||||||
all_data = self.database.load_factor_data(trade_date, 60)
|
all_data = self.database.load_factor_data(
|
||||||
|
trade_date, history_days + holding_days + 20
|
||||||
|
)
|
||||||
bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
bars_by_code: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||||
for row in all_data["bars"]:
|
for row in all_data["bars"]:
|
||||||
bars_by_code[row["ts_code"]].append(row)
|
bars_by_code[row["ts_code"]].append(row)
|
||||||
@@ -1052,15 +1482,25 @@ class ScreenerEngine:
|
|||||||
|
|
||||||
for current_date in evaluation_dates:
|
for current_date in evaluation_dates:
|
||||||
try:
|
try:
|
||||||
factors, _ = self.build_factors(current_date)
|
cache_key = (current_date, history_days)
|
||||||
|
factors = self._backtest_factor_cache.get(cache_key)
|
||||||
|
if factors is None:
|
||||||
|
factors, _ = self.build_factors(
|
||||||
|
current_date, history_days=history_days
|
||||||
|
)
|
||||||
|
if len(self._backtest_factor_cache) >= 64:
|
||||||
|
self._backtest_factor_cache.pop(
|
||||||
|
next(iter(self._backtest_factor_cache))
|
||||||
|
)
|
||||||
|
self._backtest_factor_cache[cache_key] = factors
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
|
selected = self.apply_formula(factors, {**formula, "limit": min(10, formula["limit"])}, "backtest")
|
||||||
for candidate in selected:
|
for candidate in selected:
|
||||||
bars = bars_by_code.get(candidate["ts_code"], [])
|
bars = bars_by_code.get(candidate["ts_code"], [])
|
||||||
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1)
|
index = next((i for i, row in enumerate(bars) if row["trade_date"] == current_date), -1)
|
||||||
future = bars[index + 1:index + 4] if index >= 0 else []
|
future = bars[index + 1:index + 1 + holding_days] if index >= 0 else []
|
||||||
if len(future) < 3:
|
if len(future) < holding_days:
|
||||||
continue
|
continue
|
||||||
entry = candidate["price"]
|
entry = candidate["price"]
|
||||||
won = False
|
won = False
|
||||||
@@ -1068,10 +1508,10 @@ class ScreenerEngine:
|
|||||||
for day in future:
|
for day in future:
|
||||||
low_return = (_number(day["low"]) / entry - 1) * 100
|
low_return = (_number(day["low"]) / entry - 1) * 100
|
||||||
high_return = (_number(day["high"]) / entry - 1) * 100
|
high_return = (_number(day["high"]) / entry - 1) * 100
|
||||||
if low_return <= -3:
|
if low_return <= stop_loss:
|
||||||
lost = True
|
lost = True
|
||||||
break
|
break
|
||||||
if high_return >= 3:
|
if high_return >= take_profit:
|
||||||
won = True
|
won = True
|
||||||
break
|
break
|
||||||
if won:
|
if won:
|
||||||
@@ -1087,9 +1527,17 @@ class ScreenerEngine:
|
|||||||
"losses": losses,
|
"losses": losses,
|
||||||
"win_rate": round(wins / samples * 100, 1) if samples else 0,
|
"win_rate": round(wins / samples * 100, 1) if samples else 0,
|
||||||
"average_3d_return": round(statistics.fmean(returns), 2) if returns else 0,
|
"average_3d_return": round(statistics.fmean(returns), 2) if returns else 0,
|
||||||
|
"average_holding_return": round(statistics.fmean(returns), 2) if returns else 0,
|
||||||
"average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
|
"average_drawdown": round(statistics.fmean(drawdowns), 2) if drawdowns else 0,
|
||||||
"evaluation_days": len(evaluation_dates),
|
"evaluation_days": len(evaluation_dates),
|
||||||
"definition": "收盘后选股,未来3日先触及+3%且未先触及-3%计为成功;同日双触发按失败处理。",
|
"frequency": frequency,
|
||||||
|
"holding_days": holding_days,
|
||||||
|
"take_profit": take_profit,
|
||||||
|
"stop_loss": stop_loss,
|
||||||
|
"definition": (
|
||||||
|
f"收盘后选股,未来{holding_days}日先触及+{take_profit:g}%且未先触及"
|
||||||
|
f"{stop_loss:g}%计为成功;同日双触发按失败处理。"
|
||||||
|
),
|
||||||
"approximate": True,
|
"approximate": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,6 +1608,99 @@ def _limit_threshold(code: str, name: str) -> float:
|
|||||||
return 9.5
|
return 9.5
|
||||||
|
|
||||||
|
|
||||||
|
def _ending_streak(flags: list[bool], end_index: int | None = None) -> int:
|
||||||
|
if not flags:
|
||||||
|
return 0
|
||||||
|
index = len(flags) - 1 if end_index is None else min(end_index, len(flags) - 1)
|
||||||
|
streak = 0
|
||||||
|
while index >= 0 and flags[index]:
|
||||||
|
streak += 1
|
||||||
|
index -= 1
|
||||||
|
return streak
|
||||||
|
|
||||||
|
|
||||||
|
def _max_streak(flags: list[bool]) -> int:
|
||||||
|
best = current = 0
|
||||||
|
for value in flags:
|
||||||
|
current = current + 1 if value else 0
|
||||||
|
best = max(best, current)
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _rsi(values: list[float], period: int = 6) -> float:
|
||||||
|
if len(values) <= period:
|
||||||
|
return 50.0
|
||||||
|
changes = [values[index] - values[index - 1] for index in range(len(values) - period, len(values))]
|
||||||
|
gains = sum(max(change, 0.0) for change in changes) / period
|
||||||
|
losses = sum(max(-change, 0.0) for change in changes) / period
|
||||||
|
if losses == 0:
|
||||||
|
return 100.0 if gains > 0 else 50.0
|
||||||
|
return 100 - 100 / (1 + gains / losses)
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(values: list[float], period: int) -> list[float]:
|
||||||
|
if not values:
|
||||||
|
return []
|
||||||
|
alpha = 2 / (period + 1)
|
||||||
|
result = [values[0]]
|
||||||
|
for value in values[1:]:
|
||||||
|
result.append(value * alpha + result[-1] * (1 - alpha))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _macd_series(values: list[float]) -> tuple[list[float], list[float]]:
|
||||||
|
fast = _ema(values, 12)
|
||||||
|
slow = _ema(values, 26)
|
||||||
|
dif = [left - right for left, right in zip(fast, slow)]
|
||||||
|
return dif, _ema(dif, 9)
|
||||||
|
|
||||||
|
|
||||||
|
def _macd_last(values: list[float]) -> tuple[float, float]:
|
||||||
|
dif, dea = _macd_series(values)
|
||||||
|
return (dif[-1], dea[-1]) if dif and dea else (0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
|
||||||
|
weeks: dict[str, tuple[float, float]] = {}
|
||||||
|
for row in rows:
|
||||||
|
trade_date = str(row.get("trade_date") or "")
|
||||||
|
try:
|
||||||
|
key = datetime.strptime(trade_date, "%Y%m%d").strftime("%G-%V")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
close = _number(row.get("close"))
|
||||||
|
amount = _number(row.get("amount"))
|
||||||
|
previous = weeks.get(key, (close, 0.0))
|
||||||
|
weeks[key] = (close, previous[1] + amount)
|
||||||
|
ordered = list(weeks.values())
|
||||||
|
return [item[0] for item in ordered], [item[1] for item in ordered]
|
||||||
|
|
||||||
|
|
||||||
|
def _broken_reversal_metrics(
|
||||||
|
rows: list[dict[str, Any]], flags: list[bool], code: str, name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = {"signal": 0, "days": 0, "recovered": 0, "volume_ratio": 0.0}
|
||||||
|
if not rows or not flags[-1]:
|
||||||
|
return result
|
||||||
|
current_close = _number(rows[-1].get("close"))
|
||||||
|
current_volume = _number(rows[-1].get("vol"))
|
||||||
|
for days in range(1, 4):
|
||||||
|
index = len(rows) - 1 - days
|
||||||
|
if index <= 0 or flags[index] or _ending_streak(flags, index - 1) < 2:
|
||||||
|
continue
|
||||||
|
broken_high = _number(rows[index].get("high"))
|
||||||
|
broken_volume = _number(rows[index].get("vol"))
|
||||||
|
recovered = int(current_close >= broken_high > 0)
|
||||||
|
volume_ratio = current_volume / broken_volume if broken_volume else 0.0
|
||||||
|
return {
|
||||||
|
"signal": int(recovered and volume_ratio >= 1),
|
||||||
|
"days": days,
|
||||||
|
"recovered": recovered,
|
||||||
|
"volume_ratio": round(volume_ratio, 3),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
|
def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
|
||||||
if index < 0 or index >= len(rows):
|
if index < 0 or index >= len(rows):
|
||||||
return False
|
return False
|
||||||
@@ -1212,7 +1753,9 @@ def _percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> d
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
|
def _risk_flags(
|
||||||
|
row: dict[str, Any], regime: str, include_regime_risk: bool = True
|
||||||
|
) -> list[str]:
|
||||||
flags = []
|
flags = []
|
||||||
if row.get("pct_chg", 0) >= 9.5:
|
if row.get("pct_chg", 0) >= 9.5:
|
||||||
flags.append("当日接近涨停,次日存在高开与无法成交风险")
|
flags.append("当日接近涨停,次日存在高开与无法成交风险")
|
||||||
@@ -1222,7 +1765,7 @@ def _risk_flags(row: dict[str, Any], regime: str) -> list[str]:
|
|||||||
flags.append("波动率偏高")
|
flags.append("波动率偏高")
|
||||||
if row.get("amount_billion", 0) < 1:
|
if row.get("amount_billion", 0) < 1:
|
||||||
flags.append("成交承载力偏弱")
|
flags.append("成交承载力偏弱")
|
||||||
if regime == "retreat":
|
if include_regime_risk and regime == "retreat":
|
||||||
flags.append("市场处于退潮阶段,策略可能选择空仓")
|
flags.append("市场处于退潮阶段,策略可能选择空仓")
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -13,6 +13,8 @@ COMPONENT_WEIGHTS = {
|
|||||||
"liquidity": 10,
|
"liquidity": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SENTIMENT_ENGINE_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
def _number(value: Any, default: float = 0.0) -> float:
|
def _number(value: Any, default: float = 0.0) -> float:
|
||||||
try:
|
try:
|
||||||
@@ -43,7 +45,7 @@ def _percentile(value: float, history: list[float]) -> float:
|
|||||||
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
|
def _adaptive_score(value: float, fixed: float, history: list[float]) -> float:
|
||||||
if len(history) < 20:
|
if len(history) < 20:
|
||||||
return fixed
|
return fixed
|
||||||
return fixed * 0.4 + _percentile(value, history[-120:]) * 0.6
|
return fixed * 0.25 + _percentile(value, history[-250:]) * 0.75
|
||||||
|
|
||||||
|
|
||||||
def _trade_date(payload: dict[str, Any]) -> str:
|
def _trade_date(payload: dict[str, Any]) -> str:
|
||||||
@@ -274,7 +276,9 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
|
|||||||
systemic_health = breadth_score * 0.60 + down_relief * 0.40
|
systemic_health = breadth_score * 0.60 + down_relief * 0.40
|
||||||
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
|
systemic_gate = 1.0 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
|
||||||
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
|
ecology_base_score = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
|
||||||
limit_ecology_score = ecology_base_score * (0.25 + systemic_gate * 0.75)
|
# Systemic risk is applied once to the final temperature. Reapplying it here
|
||||||
|
# would count market breadth and limit-down pressure twice.
|
||||||
|
limit_ecology_score = ecology_base_score
|
||||||
|
|
||||||
if stats["previous_limit_count"]:
|
if stats["previous_limit_count"]:
|
||||||
positive_score = float(stats["previous_positive_rate"])
|
positive_score = float(stats["previous_positive_rate"])
|
||||||
@@ -357,6 +361,11 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
day_change = score - float(previous_result["score"]) if previous_result else 0.0
|
day_change = score - float(previous_result["score"]) if previous_result else 0.0
|
||||||
|
ema_score = round(
|
||||||
|
score if not previous_result
|
||||||
|
else score * 0.5 + float(previous_result.get("ema_score", previous_result["score"])) * 0.5,
|
||||||
|
1,
|
||||||
|
)
|
||||||
phase_signal = _phase_signal(score, momentum, profit_effect_score)
|
phase_signal = _phase_signal(score, momentum, profit_effect_score)
|
||||||
fermentation_ready = (
|
fermentation_ready = (
|
||||||
phase_signal == "发酵"
|
phase_signal == "发酵"
|
||||||
@@ -428,6 +437,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
|
|||||||
{
|
{
|
||||||
**stats,
|
**stats,
|
||||||
"score": score,
|
"score": score,
|
||||||
|
"ema_score": ema_score,
|
||||||
"label": _sentiment_label(score),
|
"label": _sentiment_label(score),
|
||||||
"phase": phase,
|
"phase": phase,
|
||||||
"phase_signal": phase_signal,
|
"phase_signal": phase_signal,
|
||||||
@@ -436,7 +446,7 @@ def build_sentiment_history(snapshots: list[dict[str, Any]]) -> list[dict[str, A
|
|||||||
"day_change": round(day_change, 1),
|
"day_change": round(day_change, 1),
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"momentum": round(momentum, 1),
|
"momentum": round(momentum, 1),
|
||||||
"normalization": normalization,
|
"normalization": "250日历史百分位" if len(previous) >= 20 else normalization,
|
||||||
"history_days": len(previous) + 1,
|
"history_days": len(previous) + 1,
|
||||||
"systemic_health": round(systemic_health, 1),
|
"systemic_health": round(systemic_health, 1),
|
||||||
"risk_multiplier": round(systemic_gate, 3),
|
"risk_multiplier": round(systemic_gate, 3),
|
||||||
@@ -474,10 +484,12 @@ def apply_sentiment_to_dashboard(
|
|||||||
overview.update(
|
overview.update(
|
||||||
{
|
{
|
||||||
"sentiment_score": sentiment["score"],
|
"sentiment_score": sentiment["score"],
|
||||||
|
"sentiment_trend_score": sentiment["ema_score"],
|
||||||
"sentiment_label": sentiment["label"],
|
"sentiment_label": sentiment["label"],
|
||||||
"sentiment_phase": sentiment["phase"],
|
"sentiment_phase": sentiment["phase"],
|
||||||
"sentiment_direction": sentiment["direction"],
|
"sentiment_direction": sentiment["direction"],
|
||||||
"sentiment_components": sentiment["components"],
|
"sentiment_components": sentiment["components"],
|
||||||
|
"sentiment_engine_version": SENTIMENT_ENGINE_VERSION,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result["overview"] = overview
|
result["overview"] = overview
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from screener import (
|
|||||||
from security import SecretVault, hash_password, token_hash, verify_password
|
from security import SecretVault, hash_password, token_hash, verify_password
|
||||||
from sentiment_engine import (
|
from sentiment_engine import (
|
||||||
COMPONENT_WEIGHTS,
|
COMPONENT_WEIGHTS,
|
||||||
|
SENTIMENT_ENGINE_VERSION,
|
||||||
apply_sentiment_to_dashboard,
|
apply_sentiment_to_dashboard,
|
||||||
build_sentiment_history,
|
build_sentiment_history,
|
||||||
latest_contiguous_history,
|
latest_contiguous_history,
|
||||||
@@ -75,6 +76,30 @@ from trade_journal import TradeJournalService
|
|||||||
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
|
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
|
||||||
|
|
||||||
|
|
||||||
|
SCREENER_LIBRARY_VERSION = 7
|
||||||
|
|
||||||
|
|
||||||
|
def automatic_screener_jobs(
|
||||||
|
strategies: list[dict[str, Any]], regime_id: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build the close-of-day jobs; only stage screening is regime-gated."""
|
||||||
|
smart_strategy = next(
|
||||||
|
(
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
|
||||||
|
and regime_id in (item.get("regimes") or [])
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
curated = [
|
||||||
|
item for item in strategies
|
||||||
|
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
|
||||||
|
]
|
||||||
|
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
|
||||||
|
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
LEGACY_SECRET_KEYS = {
|
LEGACY_SECRET_KEYS = {
|
||||||
"TUSHARE_TOKEN",
|
"TUSHARE_TOKEN",
|
||||||
"IFIND_REFRESH_TOKEN",
|
"IFIND_REFRESH_TOKEN",
|
||||||
@@ -175,6 +200,8 @@ class DashboardService:
|
|||||||
self.sync_lock = threading.Lock()
|
self.sync_lock = threading.Lock()
|
||||||
self.auth_lock = threading.Lock()
|
self.auth_lock = threading.Lock()
|
||||||
self.system_lock = threading.Lock()
|
self.system_lock = threading.Lock()
|
||||||
|
self.auto_screener_lock = threading.Lock()
|
||||||
|
self._auto_screener_last_attempt: dict[str, datetime] = {}
|
||||||
self._ifind_event_lock = threading.Lock()
|
self._ifind_event_lock = threading.Lock()
|
||||||
self._request_context = threading.local()
|
self._request_context = threading.local()
|
||||||
self._system_credentials = self._load_system_credentials(environment_credentials)
|
self._system_credentials = self._load_system_credentials(environment_credentials)
|
||||||
@@ -788,6 +815,7 @@ class DashboardService:
|
|||||||
snapshot = self.database.get_snapshot(today) or {}
|
snapshot = self.database.get_snapshot(today) or {}
|
||||||
if self._realtime_snapshot_due(today, snapshot):
|
if self._realtime_snapshot_due(today, snapshot):
|
||||||
self._run_background_sync(today)
|
self._run_background_sync(today)
|
||||||
|
self._schedule_automatic_screeners(today, snapshot)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._background_stop.wait(5)
|
self._background_stop.wait(5)
|
||||||
@@ -936,6 +964,11 @@ class DashboardService:
|
|||||||
)
|
)
|
||||||
if not self._dashboard_sentiment_ready(snapshot):
|
if not self._dashboard_sentiment_ready(snapshot):
|
||||||
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
|
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
|
||||||
|
self.database.save_snapshot(
|
||||||
|
normalized_date,
|
||||||
|
str((snapshot.get("meta") or {}).get("source") or "tushare"),
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
|
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
|
||||||
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
|
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
|
||||||
resolved = self.database.get_data_snapshot(
|
resolved = self.database.get_data_snapshot(
|
||||||
@@ -968,7 +1001,7 @@ class DashboardService:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
|
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
|
||||||
overview = dashboard.get("overview") or {}
|
overview = dashboard.get("overview") or {}
|
||||||
return all(
|
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
|
||||||
key in overview
|
key in overview
|
||||||
for key in (
|
for key in (
|
||||||
"sentiment_score",
|
"sentiment_score",
|
||||||
@@ -1091,7 +1124,7 @@ class DashboardService:
|
|||||||
dashboard: dict[str, Any],
|
dashboard: dict[str, Any],
|
||||||
end_date: str,
|
end_date: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
history = self.database.list_snapshot_payloads(end_date, 240)
|
history = self.database.list_snapshot_payloads(end_date, 260)
|
||||||
return apply_sentiment_to_dashboard(dashboard, history)
|
return apply_sentiment_to_dashboard(dashboard, history)
|
||||||
|
|
||||||
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
|
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
|
||||||
@@ -1168,6 +1201,103 @@ class DashboardService:
|
|||||||
"rows": rows,
|
"rows": rows,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
|
||||||
|
dashboard = self.get_dashboard(normalized_date)
|
||||||
|
actual_date = normalize_date(
|
||||||
|
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
|
||||||
|
)
|
||||||
|
cache_key = f"{actual_date}:{sector_name}"
|
||||||
|
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
|
||||||
|
if cached:
|
||||||
|
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
|
||||||
|
return cached
|
||||||
|
if not self.configured:
|
||||||
|
raise ValueError("板块成分数据暂不可用。")
|
||||||
|
|
||||||
|
representative = next(
|
||||||
|
(
|
||||||
|
item for item in dashboard.get("limits") or []
|
||||||
|
if str(item.get("sector") or "").strip() == sector_name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not representative:
|
||||||
|
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
|
||||||
|
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
|
||||||
|
if "." in raw_code:
|
||||||
|
ts_code = raw_code
|
||||||
|
elif raw_code.startswith(("4", "8", "92")):
|
||||||
|
ts_code = f"{raw_code}.BJ"
|
||||||
|
elif raw_code.startswith(("6", "68", "90")):
|
||||||
|
ts_code = f"{raw_code}.SH"
|
||||||
|
else:
|
||||||
|
ts_code = f"{raw_code}.SZ"
|
||||||
|
client = TushareClient(self.token)
|
||||||
|
try:
|
||||||
|
industry = client.sw_stock_industry(ts_code, actual_date)
|
||||||
|
sector_code = str(industry.get("l2_code") or "")
|
||||||
|
members = client.sw_sector_members(sector_code, actual_date)
|
||||||
|
except TushareError as exc:
|
||||||
|
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
|
||||||
|
|
||||||
|
daily_rows = self.database.daily_bars_for_date(actual_date)
|
||||||
|
if len(daily_rows) < 1000:
|
||||||
|
try:
|
||||||
|
daily_rows = client.query(
|
||||||
|
"daily",
|
||||||
|
{"trade_date": actual_date},
|
||||||
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||||
|
)
|
||||||
|
if daily_rows:
|
||||||
|
self.database.upsert_daily_bars(daily_rows)
|
||||||
|
except TushareError:
|
||||||
|
daily_rows = self.database.daily_bars_for_date(actual_date)
|
||||||
|
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
|
||||||
|
rows = []
|
||||||
|
for member in members:
|
||||||
|
member_code = str(member.get("ts_code") or "")
|
||||||
|
quote = daily_map.get(member_code) or {}
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": member_code.split(".")[0],
|
||||||
|
"ts_code": member_code,
|
||||||
|
"name": str(member.get("name") or "--"),
|
||||||
|
"change": quote.get("pct_chg"),
|
||||||
|
"open": quote.get("open"),
|
||||||
|
"close": quote.get("close"),
|
||||||
|
"amount_billion": (
|
||||||
|
round(float(quote.get("amount") or 0) / 100000, 2)
|
||||||
|
if quote else None
|
||||||
|
),
|
||||||
|
"quoted": bool(quote),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
bool(item.get("quoted")),
|
||||||
|
float(item.get("change") or -999),
|
||||||
|
float(item.get("amount_billion") or 0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"meta": {
|
||||||
|
"trade_date": self._display_compact_date(actual_date),
|
||||||
|
"sector_name": str(industry.get("l2_name") or sector_name),
|
||||||
|
"sector_code": sector_code,
|
||||||
|
"member_count": len(rows),
|
||||||
|
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
|
||||||
|
"cached": False,
|
||||||
|
},
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"rotation_sector_members_v1", cache_key, "tushare", result
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
def status(self) -> dict[str, Any]:
|
def status(self) -> dict[str, Any]:
|
||||||
llm_access = self.llm_access_status()
|
llm_access = self.llm_access_status()
|
||||||
return {
|
return {
|
||||||
@@ -1234,35 +1364,63 @@ class DashboardService:
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
@staticmethod
|
||||||
normalized_date = normalize_date(trade_date)
|
def _strategy_missing_data(
|
||||||
regime = self.screener.detect_regime(normalized_date)
|
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
|
||||||
factor_dates = self.database.factor_dates(normalized_date, 100)
|
) -> list[str]:
|
||||||
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
|
formula = strategy.get("formula") or {}
|
||||||
factor_health = self.screener.factor_health(normalized_date)
|
meta = formula.get("meta") or {}
|
||||||
strategies = self.database.list_screener_strategies(self.current_user_id)
|
used_fields = {
|
||||||
|
str(item.get("field") or "")
|
||||||
|
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
||||||
|
}
|
||||||
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
|
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
|
||||||
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
|
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
|
||||||
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
|
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
|
||||||
|
missing = []
|
||||||
|
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
|
||||||
|
if len(factor_dates) < required_history:
|
||||||
|
missing.append(f"历史行情(需{required_history}日)")
|
||||||
|
if used_fields & valuation_fields and not factor_health["valuation"]:
|
||||||
|
missing.append("估值数据")
|
||||||
|
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
||||||
|
missing.append("财务质量")
|
||||||
|
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
||||||
|
missing.append("历年分红")
|
||||||
|
if used_fields & auction_fields and not factor_health["auction"]:
|
||||||
|
missing.append("竞价数据")
|
||||||
|
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
|
||||||
|
missing.append("沪深300基准")
|
||||||
|
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
|
||||||
|
missing.append("近5日资金流")
|
||||||
|
return missing
|
||||||
|
|
||||||
|
def screener_setup(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
strategies = self.database.list_screener_strategies(self.current_user_id)
|
||||||
for strategy in strategies:
|
for strategy in strategies:
|
||||||
formula = strategy.get("formula") or {}
|
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
|
||||||
used_fields = {
|
|
||||||
str(item.get("field") or "")
|
|
||||||
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
|
|
||||||
}
|
|
||||||
missing = []
|
|
||||||
if len(factor_dates) < 21:
|
|
||||||
missing.append("基础行情")
|
|
||||||
if used_fields & valuation_fields and not factor_health["valuation"]:
|
|
||||||
missing.append("估值数据")
|
|
||||||
if used_fields & fundamental_fields and not factor_health["fundamental"]:
|
|
||||||
missing.append("财务质量")
|
|
||||||
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
|
|
||||||
missing.append("历年分红")
|
|
||||||
if used_fields & auction_fields and not factor_health["auction"]:
|
|
||||||
missing.append("竞价数据")
|
|
||||||
strategy["data_ready"] = not missing
|
strategy["data_ready"] = not missing
|
||||||
strategy["missing_data"] = missing
|
strategy["missing_data"] = missing
|
||||||
|
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
personal_results = self.database.screener_runs_for_date(
|
||||||
|
self.current_user_id, normalized_date
|
||||||
|
)
|
||||||
|
recent_results = [
|
||||||
|
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
|
||||||
|
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
|
||||||
|
]
|
||||||
|
latest_results: dict[str, dict[str, Any]] = {}
|
||||||
|
for result in reversed(recent_results):
|
||||||
|
mode = str(result.get("meta", {}).get("mode") or "smart")
|
||||||
|
latest_results[mode] = result
|
||||||
|
automatic_status = self.database.get_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date
|
||||||
|
) or {}
|
||||||
return {
|
return {
|
||||||
"trade_date": normalized_date,
|
"trade_date": normalized_date,
|
||||||
"regime": regime,
|
"regime": regime,
|
||||||
@@ -1292,16 +1450,11 @@ class DashboardService:
|
|||||||
"fallback_configured": self.llm_fallback_configured,
|
"fallback_configured": self.llm_fallback_configured,
|
||||||
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
|
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
|
||||||
},
|
},
|
||||||
"latest_results": self.database.latest_screener_runs(
|
"latest_results": latest_results,
|
||||||
self.current_user_id, normalized_date
|
"recent_results": recent_results,
|
||||||
),
|
"automatic_status": automatic_status,
|
||||||
"recent_results": self.database.latest_screener_context_runs(
|
|
||||||
self.current_user_id, normalized_date
|
|
||||||
),
|
|
||||||
# Kept during the client transition for compatibility with older frontends.
|
# Kept during the client transition for compatibility with older frontends.
|
||||||
"latest_result": self.database.latest_screener_run(
|
"latest_result": latest_results.get("smart"),
|
||||||
self.current_user_id, normalized_date, "smart"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
|
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
|
||||||
@@ -1565,12 +1718,158 @@ class DashboardService:
|
|||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise ValueError("请先配置 Tushare Token。")
|
raise ValueError("请先配置 Tushare Token。")
|
||||||
normalized_date = normalize_date(trade_date)
|
normalized_date = normalize_date(trade_date)
|
||||||
lookback = max(25, min(80, int(lookback)))
|
lookback = max(25, min(260, int(lookback)))
|
||||||
with self.sync_lock:
|
with self.sync_lock:
|
||||||
return FactorDataService(self.database, TushareClient(self.token)).sync(
|
return FactorDataService(self.database, TushareClient(self.token)).sync(
|
||||||
normalized_date, lookback
|
normalized_date, lookback
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _schedule_automatic_screeners(
|
||||||
|
self, trade_date: str, snapshot: dict[str, Any] | None = None
|
||||||
|
) -> bool:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
if (
|
||||||
|
normalized_date != now.strftime("%Y%m%d")
|
||||||
|
or now.weekday() >= 5
|
||||||
|
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
|
||||||
|
or self.auto_screener_lock.locked()
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
|
||||||
|
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
return False
|
||||||
|
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
|
||||||
|
if (
|
||||||
|
marker.get("status") == "complete"
|
||||||
|
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
|
||||||
|
if last_attempt and (now - last_attempt).total_seconds() < 600:
|
||||||
|
return False
|
||||||
|
self._auto_screener_last_attempt[normalized_date] = now
|
||||||
|
threading.Thread(
|
||||||
|
target=self.run_automatic_screeners,
|
||||||
|
args=(normalized_date,),
|
||||||
|
name=f"automatic-screeners-{normalized_date}",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
|
||||||
|
normalized_date = normalize_date(trade_date)
|
||||||
|
with self.auto_screener_lock:
|
||||||
|
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"trade_date": normalized_date,
|
||||||
|
"library_version": SCREENER_LIBRARY_VERSION,
|
||||||
|
"status": "running",
|
||||||
|
"started_at": started_at,
|
||||||
|
"completed": [],
|
||||||
|
"skipped": [],
|
||||||
|
"failed": [],
|
||||||
|
}
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
factor_sync = FactorDataService(
|
||||||
|
self.database, TushareClient(self.token)
|
||||||
|
).sync(normalized_date, 260)
|
||||||
|
factor_dates = self.database.factor_dates(normalized_date, 300)
|
||||||
|
if not factor_dates or factor_dates[-1] != normalized_date:
|
||||||
|
raise ValueError("当日收盘行情尚未入库")
|
||||||
|
factor_health = self.screener.factor_health(normalized_date)
|
||||||
|
regime = self.screener.detect_regime(normalized_date)
|
||||||
|
regime_id = str(regime.get("id") or "repair")
|
||||||
|
strategies = self.database.list_screener_strategies(None)
|
||||||
|
jobs = automatic_screener_jobs(strategies, regime_id)
|
||||||
|
existing = {
|
||||||
|
(
|
||||||
|
str(item.get("meta", {}).get("mode") or "smart"),
|
||||||
|
str(item.get("meta", {}).get("strategy_name") or ""),
|
||||||
|
)
|
||||||
|
for item in self.database.screener_runs_for_date(0, normalized_date)
|
||||||
|
if int(item.get("meta", {}).get("library_version") or 0)
|
||||||
|
== SCREENER_LIBRARY_VERSION
|
||||||
|
}
|
||||||
|
required_history = max(
|
||||||
|
[
|
||||||
|
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
|
||||||
|
for job in jobs if job.get("strategy")
|
||||||
|
] or [80]
|
||||||
|
)
|
||||||
|
factors, actual_date = self.screener.build_factors(
|
||||||
|
normalized_date, history_days=required_history
|
||||||
|
)
|
||||||
|
if actual_date != normalized_date:
|
||||||
|
raise ValueError("当日因子尚未完成收盘定格")
|
||||||
|
for job in jobs:
|
||||||
|
strategy = job["strategy"]
|
||||||
|
mode = str(job["mode"])
|
||||||
|
name = str(strategy.get("name") or "未命名策略")
|
||||||
|
if (mode, name) in existing:
|
||||||
|
status["completed"].append({"mode": mode, "name": name, "cached": True})
|
||||||
|
continue
|
||||||
|
missing = self._strategy_missing_data(
|
||||||
|
strategy, factor_dates, factor_health
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
status["skipped"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": "、".join(missing)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
formula = copy.deepcopy(strategy.get("formula") or {})
|
||||||
|
formula.setdefault("meta", {})["library_version"] = (
|
||||||
|
SCREENER_LIBRARY_VERSION
|
||||||
|
)
|
||||||
|
result = self.screener.screen(
|
||||||
|
0,
|
||||||
|
normalized_date,
|
||||||
|
formula,
|
||||||
|
regime_id,
|
||||||
|
name,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
mode,
|
||||||
|
factors,
|
||||||
|
actual_date,
|
||||||
|
)
|
||||||
|
status["completed"].append(
|
||||||
|
{
|
||||||
|
"mode": mode,
|
||||||
|
"name": name,
|
||||||
|
"candidate_count": len(result.get("candidates") or []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status["failed"].append(
|
||||||
|
{"mode": mode, "name": name, "reason": str(exc)}
|
||||||
|
)
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "complete" if not status["failed"] else "partial",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"factor_sync": factor_sync,
|
||||||
|
"regime": regime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status.update(
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"screener_auto_v1", normalized_date, "system", status
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
|
||||||
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
|
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
|
||||||
prompt = prompt.strip()
|
prompt = prompt.strip()
|
||||||
if not prompt or len(prompt) > 3000:
|
if not prompt or len(prompt) > 3000:
|
||||||
@@ -4641,6 +4940,18 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
|
if parsed.path == "/api/rotation/members":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
try:
|
||||||
|
self.send_json(
|
||||||
|
SERVICE.rotation_sector_members(
|
||||||
|
query.get("trade_date", [date.today().isoformat()])[0],
|
||||||
|
query.get("sector", [""])[0],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
if parsed.path == "/api/dragon-tiger":
|
if parsed.path == "/api/dragon-tiger":
|
||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
|
trade_date = query.get("trade_date", [date.today().isoformat()])[0]
|
||||||
|
|||||||
+257
-207
@@ -53,6 +53,10 @@ const state = {
|
|||||||
rotationHistory: null,
|
rotationHistory: null,
|
||||||
rotationHistoryKey: "",
|
rotationHistoryKey: "",
|
||||||
rotationSelectedSector: "",
|
rotationSelectedSector: "",
|
||||||
|
rotationSelectedDate: "",
|
||||||
|
rotationMembers: null,
|
||||||
|
rotationMembersKey: "",
|
||||||
|
rotationMembersLoading: false,
|
||||||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||||||
rotationLoading: false,
|
rotationLoading: false,
|
||||||
auctionData: null,
|
auctionData: null,
|
||||||
@@ -104,6 +108,7 @@ const state = {
|
|||||||
screenerSetupPromise: null,
|
screenerSetupPromise: null,
|
||||||
selectedRegime: "",
|
selectedRegime: "",
|
||||||
selectedStrategy: null,
|
selectedStrategy: null,
|
||||||
|
customStrategyDraft: null,
|
||||||
screenerRunning: false,
|
screenerRunning: false,
|
||||||
screenerRunningMode: "",
|
screenerRunningMode: "",
|
||||||
screenerResults: { smart: null, curated: null, quant: null },
|
screenerResults: { smart: null, curated: null, quant: null },
|
||||||
@@ -114,7 +119,9 @@ const state = {
|
|||||||
? localStorage.getItem("xiaobaiScreenerMode")
|
? localStorage.getItem("xiaobaiScreenerMode")
|
||||||
: "smart",
|
: "smart",
|
||||||
curatedCategory: "全部",
|
curatedCategory: "全部",
|
||||||
|
curatedSchool: "全部",
|
||||||
curatedQuery: "",
|
curatedQuery: "",
|
||||||
|
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||||||
selectedCuratedStrategyId: 0,
|
selectedCuratedStrategyId: 0,
|
||||||
quantFilters: [],
|
quantFilters: [],
|
||||||
quantScores: [],
|
quantScores: [],
|
||||||
@@ -519,8 +526,6 @@ async function applyAuthenticatedSession(session) {
|
|||||||
document.querySelector("#syncButton").hidden = !isAdmin;
|
document.querySelector("#syncButton").hidden = !isAdmin;
|
||||||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||||||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||||||
const factorSyncButton = document.querySelector("#factorSyncButton");
|
|
||||||
if (factorSyncButton) factorSyncButton.hidden = false;
|
|
||||||
document.querySelector("#authGate").hidden = true;
|
document.querySelector("#authGate").hidden = true;
|
||||||
applyMembershipAccess();
|
applyMembershipAccess();
|
||||||
await startAuthenticatedApp();
|
await startAuthenticatedApp();
|
||||||
@@ -815,8 +820,7 @@ function bindEvents() {
|
|||||||
}
|
}
|
||||||
renderAuctionTable();
|
renderAuctionTable();
|
||||||
});
|
});
|
||||||
document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library"));
|
document.querySelector("#openStrategyDrawerButton").addEventListener("click", openCustomStrategyDrawer);
|
||||||
document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
|
|
||||||
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
|
document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
|
||||||
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
|
document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
|
||||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
if (event.target === event.currentTarget) event.currentTarget.close();
|
||||||
@@ -911,8 +915,6 @@ function bindEvents() {
|
|||||||
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
|
document.querySelector("#stockReminderButton").addEventListener("click", openStockReminder);
|
||||||
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
|
document.querySelector("#reasonForm").addEventListener("submit", saveReasonOverride);
|
||||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||||||
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
|
|
||||||
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
|
|
||||||
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
|
document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
|
||||||
await loadScreenerTracking(true);
|
await loadScreenerTracking(true);
|
||||||
openView("screenerTrackingView");
|
openView("screenerTrackingView");
|
||||||
@@ -943,11 +945,22 @@ function bindEvents() {
|
|||||||
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
|
||||||
renderCuratedStrategyLibrary();
|
renderCuratedStrategyLibrary();
|
||||||
});
|
});
|
||||||
document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy);
|
document.querySelector("#curatedCategoryFilter").addEventListener("change", (event) => {
|
||||||
document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
|
state.curatedCategory = event.target.value;
|
||||||
document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close());
|
renderCuratedStrategyLibrary();
|
||||||
document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => {
|
});
|
||||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
document.querySelector("#curatedSchoolFilters").addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-curated-school]");
|
||||||
|
if (!button) return;
|
||||||
|
state.curatedSchool = button.dataset.curatedSchool;
|
||||||
|
renderCuratedStrategyLibrary();
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-curated-view]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.curatedViewMode = button.dataset.curatedView === "grid" ? "grid" : "list";
|
||||||
|
localStorage.setItem("xiaobaiCuratedViewMode", state.curatedViewMode);
|
||||||
|
renderCuratedStrategyLibrary();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
|
document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
|
||||||
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
|
document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
|
||||||
@@ -1161,7 +1174,7 @@ function renderDashboard() {
|
|||||||
renderYesterdayTable(state.dashboard.yesterday_limits || []);
|
renderYesterdayTable(state.dashboard.yesterday_limits || []);
|
||||||
renderPerformance(state.dashboard.limit_performance || []);
|
renderPerformance(state.dashboard.limit_performance || []);
|
||||||
renderLadderBoard(ladders || []);
|
renderLadderBoard(ladders || []);
|
||||||
renderRotationTable(state.dashboard.sector_rotation || [], sectors || []);
|
renderRotationMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSentimentHistory(force = false) {
|
async function loadSentimentHistory(force = false) {
|
||||||
@@ -1787,6 +1800,7 @@ function updateYesterdayControls() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPerformance(rows) {
|
function renderPerformance(rows) {
|
||||||
|
rows = normalizePerformanceRows(rows);
|
||||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
||||||
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
|
||||||
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
|
||||||
@@ -1803,6 +1817,39 @@ function renderPerformance(rows) {
|
|||||||
renderMarketBreadth(state.dashboard?.overview || {});
|
renderMarketBreadth(state.dashboard?.overview || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePerformanceRows(rows) {
|
||||||
|
const groups = new Map();
|
||||||
|
(rows || []).forEach((row) => {
|
||||||
|
const level = Math.max(1, number(row.level));
|
||||||
|
const displayLevel = Math.min(level, 5);
|
||||||
|
const group = groups.get(displayLevel) || {
|
||||||
|
level: displayLevel,
|
||||||
|
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}板`,
|
||||||
|
count: 0,
|
||||||
|
advanced: 0,
|
||||||
|
positive: 0,
|
||||||
|
changeTotal: 0,
|
||||||
|
};
|
||||||
|
const count = number(row.count);
|
||||||
|
group.count += count;
|
||||||
|
group.advanced += number(row.advanced);
|
||||||
|
group.positive += count * number(row.positive_rate) / 100;
|
||||||
|
group.changeTotal += count * number(row.average_change);
|
||||||
|
groups.set(displayLevel, group);
|
||||||
|
});
|
||||||
|
return [...groups.values()]
|
||||||
|
.sort((left, right) => right.level - left.level)
|
||||||
|
.map((group) => ({
|
||||||
|
level: group.level,
|
||||||
|
label: group.label,
|
||||||
|
count: group.count,
|
||||||
|
advanced: group.advanced,
|
||||||
|
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
||||||
|
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
||||||
|
average_change: group.count ? group.changeTotal / group.count : 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function performanceRateState(rate) {
|
function performanceRateState(rate) {
|
||||||
const value = number(rate);
|
const value = number(rate);
|
||||||
if (value === 0) return { label: "失效", className: "is-neutral" };
|
if (value === 0) return { label: "失效", className: "is-neutral" };
|
||||||
@@ -1956,8 +2003,9 @@ function renderRotationHistory() {
|
|||||||
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
|
||||||
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
|
||||||
state.rotationSelectedSector = "";
|
state.rotationSelectedSector = "";
|
||||||
|
state.rotationSelectedDate = "";
|
||||||
renderRotationHistory();
|
renderRotationHistory();
|
||||||
updateRotationTableSelection();
|
loadRotationMembers("");
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tracker.hidden = true;
|
tracker.hidden = true;
|
||||||
@@ -1974,7 +2022,7 @@ function renderRotationHistory() {
|
|||||||
const strength = clamp(number(sector.strength), 0, 100);
|
const strength = clamp(number(sector.strength), 0, 100);
|
||||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
||||||
return `
|
return `
|
||||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}">
|
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
||||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
@@ -1983,58 +2031,76 @@ function renderRotationHistory() {
|
|||||||
}).join("");
|
}).join("");
|
||||||
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
state.rotationSelectedSector = button.dataset.rotationSector === state.rotationSelectedSector
|
const clickedSector = button.dataset.rotationSector;
|
||||||
? ""
|
const clickedDate = button.dataset.rotationDate;
|
||||||
: button.dataset.rotationSector;
|
const isSameSelection = clickedSector === state.rotationSelectedSector
|
||||||
|
&& clickedDate === state.rotationSelectedDate;
|
||||||
|
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
|
||||||
|
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
|
||||||
renderRotationHistory();
|
renderRotationHistory();
|
||||||
updateRotationTableSelection();
|
loadRotationMembers(state.rotationSelectedSector);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRotationTable(rows, sectors) {
|
async function loadRotationMembers(sector, force = false) {
|
||||||
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
|
if (!sector) {
|
||||||
|
state.rotationMembers = null;
|
||||||
|
state.rotationMembersKey = "";
|
||||||
|
renderRotationMembers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
|
||||||
|
const key = `${memberDate}:${sector}`;
|
||||||
|
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
|
||||||
|
renderRotationMembers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.rotationMembersLoading = true;
|
||||||
|
renderRotationMembers();
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({ trade_date: memberDate, sector });
|
||||||
|
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
|
||||||
|
state.rotationMembersKey = key;
|
||||||
|
} catch (error) {
|
||||||
|
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
|
||||||
|
state.rotationMembersKey = key;
|
||||||
|
} finally {
|
||||||
|
state.rotationMembersLoading = false;
|
||||||
|
renderRotationMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRotationMembers() {
|
||||||
const body = document.querySelector("#rotationTableBody");
|
const body = document.querySelector("#rotationTableBody");
|
||||||
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
|
const empty = document.querySelector("#rotationMembersEmpty");
|
||||||
setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`);
|
if (state.rotationMembersLoading) {
|
||||||
body.innerHTML = rows.map((row) => {
|
body.innerHTML = "";
|
||||||
const sector = sectorMap.get(row.name) || {};
|
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
|
||||||
const strength = number(row.strength ?? sector.strength);
|
empty.hidden = false;
|
||||||
const currentCount = number(row.count);
|
return;
|
||||||
const previousCount = number(row.previous_count);
|
}
|
||||||
const delta = row.delta == null ? currentCount - previousCount : number(row.delta);
|
const payload = state.rotationMembers;
|
||||||
const trend = previousCount === 0 && currentCount > 0
|
const rows = payload?.rows || [];
|
||||||
? "新进"
|
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
|
||||||
: currentCount > previousCount
|
body.innerHTML = "";
|
||||||
? "升温"
|
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
|
||||||
: currentCount < previousCount ? "降温" : "持平";
|
empty.hidden = false;
|
||||||
return `
|
setText("rotationDetailTitle", "板块成分股");
|
||||||
<tr class="rotation-detail-row ${state.rotationSelectedSector === row.name ? "selected" : ""}" data-rotation-detail-sector="${escapeHtml(row.name)}"><td class="number">${number(row.rank)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
|
||||||
<td><span class="trend-tag ${trendClass(trend)}">${trend}</span></td>
|
return;
|
||||||
<td class="number up">${currentCount}</td><td class="number muted">${previousCount}</td>
|
}
|
||||||
<td class="number ${delta >= 0 ? "delta-positive" : "delta-negative"}" data-sort-value="${delta}">${delta > 0 ? "+" : ""}${delta}</td>
|
empty.hidden = true;
|
||||||
<td data-sort-value="${strength}"><div class="rotation-strength"><span class="strength-cell"><i style="width:${clamp(strength, 0, 100)}%"></i></span><b>${formatNumber(strength, 0)}</b></div></td>
|
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
|
||||||
<td class="number streak-value">${streakLabel(row.max_streak || 1)}</td>
|
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)} 只`);
|
||||||
<td class="number ${changeClass(sector.change)}" data-sort-value="${number(sector.change)}">${signed(sector.change)}%</td>
|
body.innerHTML = rows.map((row, index) => `
|
||||||
<td>${escapeHtml(row.leader || sector.leader || "--")}</td><td class="number" data-sort-value="${number(row.amount_billion)}">${formatNumber(row.amount_billion, 1)} 亿</td></tr>
|
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
|
||||||
`;
|
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
|
||||||
}).join("");
|
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
|
||||||
body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => {
|
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
|
||||||
row.addEventListener("click", () => {
|
`).join("");
|
||||||
const sector = row.dataset.rotationDetailSector;
|
|
||||||
state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
|
|
||||||
renderRotationHistory();
|
|
||||||
updateRotationTableSelection();
|
|
||||||
document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
animateRows(body);
|
animateRows(body);
|
||||||
}
|
bindStockRows(body);
|
||||||
|
|
||||||
function updateRotationTableSelection() {
|
|
||||||
document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
|
|
||||||
row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLadderMini(ladders) {
|
function renderLadderMini(ladders) {
|
||||||
@@ -3498,12 +3564,7 @@ function applyScreenerSetup(payload, requestKey) {
|
|||||||
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
|
if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
|
||||||
const smartLatestMeta = latestResults.smart?.meta || {};
|
const smartLatestMeta = latestResults.smart?.meta || {};
|
||||||
const curatedLatestMeta = latestResults.curated?.meta || {};
|
const curatedLatestMeta = latestResults.curated?.meta || {};
|
||||||
const availableRegimes = new Set((payload.regimes || []).map((item) => item.id));
|
state.selectedRegime = payload.regime.id;
|
||||||
if (!state.selectedRegime || dateChanged) {
|
|
||||||
state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
|
|
||||||
? smartLatestMeta.regime
|
|
||||||
: payload.regime.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedId = state.selectedStrategy?.id;
|
const selectedId = state.selectedStrategy?.id;
|
||||||
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
|
const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
|
||||||
@@ -3617,13 +3678,9 @@ function renderScreenerSetup() {
|
|||||||
|
|
||||||
const selector = document.querySelector("#regimeSelector");
|
const selector = document.querySelector("#regimeSelector");
|
||||||
selector.innerHTML = setup.regimes.map((item) => `
|
selector.innerHTML = setup.regimes.map((item) => `
|
||||||
<button type="button" class="regime-option ${item.id === state.selectedRegime ? "active" : ""}" data-regime="${item.id}">${escapeHtml(item.label)}</button>
|
<span class="regime-option ${item.id === state.selectedRegime ? "active" : ""}">${escapeHtml(item.label)}</span>
|
||||||
`).join("");
|
`).join("");
|
||||||
selector.querySelectorAll("[data-regime]").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => selectRegime(button.dataset.regime));
|
|
||||||
});
|
|
||||||
renderStrategyList();
|
renderStrategyList();
|
||||||
populateStrategyEditor(state.selectedStrategy);
|
|
||||||
renderStrategySummary();
|
renderStrategySummary();
|
||||||
renderScreenerMode();
|
renderScreenerMode();
|
||||||
renderCuratedStrategyLibrary();
|
renderCuratedStrategyLibrary();
|
||||||
@@ -3663,7 +3720,7 @@ function renderScreenerMode() {
|
|||||||
const results = document.querySelector("#screenerView .screener-results-view");
|
const results = document.querySelector("#screenerView .screener-results-view");
|
||||||
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
|
const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
|
||||||
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
|
if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
|
||||||
const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" };
|
const titles = { smart: "盘后候选结果", curated: "策略候选结果", quant: "自定义选股结果" };
|
||||||
setText("screenerResultTitle", titles[mode]);
|
setText("screenerResultTitle", titles[mode]);
|
||||||
renderScreenerResult();
|
renderScreenerResult();
|
||||||
}
|
}
|
||||||
@@ -3677,56 +3734,83 @@ function activeCuratedStrategy() {
|
|||||||
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
|
return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function curatedStrategySchool(strategy) {
|
||||||
|
const category = String(strategy?.formula?.meta?.category || "");
|
||||||
|
if (["红利价值", "质量价值", "现金流价值", "成长质量", "小盘质量"].includes(category)) return "基本面";
|
||||||
|
if (["行业轮动", "形态突破", "趋势追踪"].includes(category)) return "趋势";
|
||||||
|
if (["短线竞价", "连板接力", "低吸反核"].includes(category)) return "短线";
|
||||||
|
if (["动量反转"].includes(category)) return "动量";
|
||||||
|
if (/红利|价值|质量|成长|财务|现金流/.test(category)) return "基本面";
|
||||||
|
if (/趋势|轮动|突破/.test(category)) return "趋势";
|
||||||
|
if (/竞价|连板|龙头|反核|首阴|反包|打板/.test(category)) return "短线";
|
||||||
|
if (/动量|反转/.test(category)) return "动量";
|
||||||
|
return "其他";
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatedSchoolIcon(school) {
|
||||||
|
return { 基本面: "circle-dollar-sign", 趋势: "trending-up", 短线: "zap", 动量: "refresh-cw", 其他: "boxes" }[school] || "boxes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function curatedStrategyRunState(strategy, result) {
|
||||||
|
const missingData = strategy?.missing_data || [];
|
||||||
|
if (!strategy?.data_ready || missingData.length) {
|
||||||
|
return { label: "数据不足", className: "missing", verifiedEmpty: false };
|
||||||
|
}
|
||||||
|
if (!result) return { label: "等待盘后", className: "pending", verifiedEmpty: false };
|
||||||
|
const count = (result.candidates || []).length;
|
||||||
|
if (count) return { label: `${count} 只候选`, className: "ready", verifiedEmpty: false };
|
||||||
|
return { label: "暂无信号", className: "quiet", verifiedEmpty: true };
|
||||||
|
}
|
||||||
|
|
||||||
function renderCuratedStrategyLibrary() {
|
function renderCuratedStrategyLibrary() {
|
||||||
if (!state.screenerSetup) return;
|
if (!state.screenerSetup) return;
|
||||||
const strategies = curatedStrategies();
|
const strategies = curatedStrategies();
|
||||||
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
|
const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
|
||||||
|
const schools = ["全部", "基本面", "趋势", "短线", "动量"];
|
||||||
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
|
if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
|
||||||
|
if (!schools.includes(state.curatedSchool)) state.curatedSchool = "全部";
|
||||||
setText("curatedStrategyCount", `${strategies.length} 套`);
|
setText("curatedStrategyCount", `${strategies.length} 套`);
|
||||||
const filters = document.querySelector("#curatedCategoryFilters");
|
const categorySelect = document.querySelector("#curatedCategoryFilter");
|
||||||
filters.innerHTML = categories.map((category) => `
|
categorySelect.innerHTML = categories.map((category) => `
|
||||||
<button class="${category === state.curatedCategory ? "active" : ""}" type="button" data-curated-category="${escapeHtml(category)}">${escapeHtml(category)}</button>
|
<option value="${escapeHtml(category)}" ${category === state.curatedCategory ? "selected" : ""}>${escapeHtml(category)}</option>
|
||||||
`).join("");
|
`).join("");
|
||||||
filters.querySelectorAll("[data-curated-category]").forEach((button) => {
|
document.querySelector("#curatedSchoolFilters").innerHTML = schools.map((school) => {
|
||||||
button.addEventListener("click", () => {
|
const count = school === "全部" ? strategies.length : strategies.filter((item) => curatedStrategySchool(item) === school).length;
|
||||||
state.curatedCategory = button.dataset.curatedCategory;
|
return `<button class="${school === state.curatedSchool ? "active" : ""}" type="button" data-curated-school="${escapeHtml(school)}" aria-pressed="${school === state.curatedSchool}">${escapeHtml(school)}<small>${count}</small></button>`;
|
||||||
renderCuratedStrategyLibrary();
|
}).join("");
|
||||||
});
|
document.querySelectorAll("[data-curated-view]").forEach((button) => {
|
||||||
|
const active = button.dataset.curatedView === state.curatedViewMode;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
button.setAttribute("aria-pressed", String(active));
|
||||||
});
|
});
|
||||||
const query = state.curatedQuery;
|
const query = state.curatedQuery;
|
||||||
const visible = strategies.filter((item) => {
|
const visible = strategies.filter((item) => {
|
||||||
const meta = item.formula?.meta || {};
|
const meta = item.formula?.meta || {};
|
||||||
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
|
const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
|
||||||
const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query);
|
const school = curatedStrategySchool(item);
|
||||||
return categoryMatch && queryMatch;
|
const schoolMatch = state.curatedSchool === "全部" || school === state.curatedSchool;
|
||||||
|
const queryMatch = !query || `${item.name} ${item.description} ${meta.category} ${school} ${meta.suitable_environment} ${meta.failure_risk}`.toLocaleLowerCase("zh-CN").includes(query);
|
||||||
|
return categoryMatch && schoolMatch && queryMatch;
|
||||||
});
|
});
|
||||||
const list = document.querySelector("#curatedStrategyList");
|
const list = document.querySelector("#curatedStrategyList");
|
||||||
|
list.classList.toggle("is-grid", state.curatedViewMode === "grid");
|
||||||
list.innerHTML = visible.length ? visible.map((strategy) => {
|
list.innerHTML = visible.length ? visible.map((strategy) => {
|
||||||
const meta = strategy.formula?.meta || {};
|
const meta = strategy.formula?.meta || {};
|
||||||
|
const school = curatedStrategySchool(strategy);
|
||||||
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
|
const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
|
||||||
|
const resultKey = screenerResultKey(screenerResultContext("curated", null, {
|
||||||
|
regime: strategy.regimes[0] || state.selectedRegime,
|
||||||
|
strategyId: strategy.id,
|
||||||
|
strategyName: strategy.name,
|
||||||
|
}));
|
||||||
|
const result = state.screenerResultStore[resultKey]?.result;
|
||||||
|
const runState = curatedStrategyRunState(strategy, result);
|
||||||
return `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}">
|
return `<article class="curated-strategy-card ${strategy.id === activeCuratedStrategy()?.id ? "active" : ""}" data-curated-strategy="${strategy.id}">
|
||||||
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(meta.category || "策略")}</small></span><i class="curated-ready-dot ${strategy.data_ready ? "" : "missing"}" title="${strategy.data_ready ? "数据已就绪" : escapeHtml((strategy.missing_data || []).join("、"))}"></i></span>
|
<span class="curated-strategy-icon" aria-hidden="true"><i data-lucide="${curatedSchoolIcon(school)}"></i></span>
|
||||||
<span class="curated-card-tags"><em>质量 ${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em>风险 ${escapeHtml(meta.risk || "--")}</em></span>
|
<span class="curated-card-head"><i class="curated-strategy-rank">${String(rank).padStart(2, "0")}</i><span><strong>${escapeHtml(strategy.name)}</strong><small>${escapeHtml(school)} · ${escapeHtml(meta.category || "策略")}</small></span><em class="curated-card-result ${runState.className}">${escapeHtml(runState.label)}</em></span>
|
||||||
<span class="curated-card-description">${escapeHtml(strategy.description || "查看策略条件与适用环境。")}</span>
|
<span class="curated-card-tags"><em>${escapeHtml(meta.quality || "--")}</em><em>${escapeHtml(meta.frequency || "--")}</em><em>风险 ${escapeHtml(meta.risk || "--")}</em></span>
|
||||||
<span class="curated-card-foot"><small>${strategy.data_ready ? "数据已就绪" : `缺少 ${(strategy.missing_data || []).length} 项数据`}</small><span class="curated-card-actions"><button type="button" data-curated-inspect="${strategy.id}">条件</button><button type="button" class="primary" data-curated-run="${strategy.id}" ${strategy.data_ready ? "" : "disabled"}><i data-lucide="play"></i>执行</button></span></span>
|
|
||||||
</article>`;
|
</article>`;
|
||||||
}).join("") : '<div class="empty-state">没有符合条件的策略</div>';
|
}).join("") : '<div class="empty-state">没有符合条件的策略</div>';
|
||||||
list.querySelectorAll("[data-curated-inspect]").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
state.selectedCuratedStrategyId = number(button.dataset.curatedInspect);
|
|
||||||
renderCuratedStrategyLibrary();
|
|
||||||
renderScreenerResult();
|
|
||||||
document.querySelector("#curatedDetailDialog").showModal();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
list.querySelectorAll("[data-curated-run]").forEach((button) => {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
state.selectedCuratedStrategyId = number(button.dataset.curatedRun);
|
|
||||||
renderCuratedStrategyLibrary();
|
|
||||||
renderScreenerResult();
|
|
||||||
runCuratedStrategy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
renderCuratedStrategyDetail();
|
renderCuratedStrategyDetail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3735,6 +3819,10 @@ function renderCuratedStrategyDetail() {
|
|||||||
if (!strategy) return;
|
if (!strategy) return;
|
||||||
const formula = strategy.formula || {};
|
const formula = strategy.formula || {};
|
||||||
const meta = formula.meta || {};
|
const meta = formula.meta || {};
|
||||||
|
const result = activeScreenerResult("curated");
|
||||||
|
const resultMeta = result?.meta || {};
|
||||||
|
const health = resultMeta.health || {};
|
||||||
|
const runState = curatedStrategyRunState(strategy, result);
|
||||||
setText("curatedStrategyCategory", meta.category || "精选策略");
|
setText("curatedStrategyCategory", meta.category || "精选策略");
|
||||||
setText("curatedStrategyName", strategy.name);
|
setText("curatedStrategyName", strategy.name);
|
||||||
setText("curatedStrategyDescription", strategy.description);
|
setText("curatedStrategyDescription", strategy.description);
|
||||||
@@ -3742,6 +3830,8 @@ function renderCuratedStrategyDetail() {
|
|||||||
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
|
`质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
|
||||||
meta.data_group || "行情因子",
|
meta.data_group || "行情因子",
|
||||||
].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
|
].map((value) => `<span>${escapeHtml(value)}</span>`).join("");
|
||||||
|
setText("curatedSuitableEnvironment", meta.suitable_environment || "以策略条件为准");
|
||||||
|
setText("curatedFailureRisk", meta.failure_risk || "策略可能随市场结构变化而失效");
|
||||||
const filters = formula.filters || [];
|
const filters = formula.filters || [];
|
||||||
setText("curatedFilterCount", `${filters.length} 项`);
|
setText("curatedFilterCount", `${filters.length} 项`);
|
||||||
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
|
document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
|
||||||
@@ -3754,14 +3844,27 @@ function renderCuratedStrategyDetail() {
|
|||||||
const percent = number(item.weight) / total * 100;
|
const percent = number(item.weight) / total * 100;
|
||||||
return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`;
|
return `<div class="curated-score-row"><span>${escapeHtml(factorLabel(item.field))}</span><span class="curated-score-track"><i style="width:${Math.min(100, percent)}%"></i></span><strong>${formatNumber(percent, 0)}%</strong></div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
const candidateCount = (result?.candidates || []).length;
|
||||||
|
const statusLabel = runState.className === "ready" ? "运行正常" : runState.label;
|
||||||
|
const statusClass = runState.className;
|
||||||
|
let updatedLabel = "--";
|
||||||
|
if (resultMeta.updated_at) {
|
||||||
|
const updated = new Date(resultMeta.updated_at);
|
||||||
|
if (!Number.isNaN(updated.getTime())) {
|
||||||
|
updatedLabel = `${String(updated.getMonth() + 1).padStart(2, "0")}-${String(updated.getDate()).padStart(2, "0")} ${updated.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelector("#curatedHealthMetrics").innerHTML = [
|
||||||
|
["运行状态", statusLabel, statusClass],
|
||||||
|
["当日信号", result ? `${candidateCount} 只` : "--", ""],
|
||||||
|
["字段覆盖", health.coverage != null ? `${formatNumber(health.coverage, 1)}%` : strategy.data_ready ? "数据已就绪" : "--", ""],
|
||||||
|
["最近更新", updatedLabel, ""],
|
||||||
|
].map(([label, value, className]) => `<div><span>${escapeHtml(label)}</span><strong class="${className}">${escapeHtml(value)}</strong></div>`).join("");
|
||||||
const status = document.querySelector("#curatedDataStatus");
|
const status = document.querySelector("#curatedDataStatus");
|
||||||
status.classList.toggle("missing", !strategy.data_ready);
|
status.classList.toggle("missing", statusClass === "missing");
|
||||||
status.innerHTML = strategy.data_ready
|
status.innerHTML = strategy.data_ready
|
||||||
? '<i data-lucide="database"></i><span><strong>策略数据已就绪</strong><small>可按当前数据日期执行</small></span>'
|
? `<i data-lucide="${runState.verifiedEmpty ? "circle-check" : "database"}"></i><span><strong>${runState.verifiedEmpty ? "本日暂无信号" : "盘后自动更新"}</strong><small>${runState.verifiedEmpty ? `必需数据已完整,本日没有股票同时满足 ${filters.length} 项准入条件` : result ? health.required_field_count != null ? `已核验 ${number(health.required_field_count)} 项因子 · ${number(health.complete_rows)} 只股票` : "盘后定格结果已载入" : "等待当日行情定格后生成"}</small></span>`
|
||||||
: `<i data-lucide="circle-alert"></i><span><strong>需要补充数据</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}</small></span>`;
|
: `<i data-lucide="circle-alert"></i><span><strong>数据尚未完备</strong><small>${escapeHtml((strategy.missing_data || []).join("、") || "等待后台同步")}</small></span>`;
|
||||||
const runButton = document.querySelector("#curatedRunButton");
|
|
||||||
runButton.disabled = !strategy.data_ready;
|
|
||||||
runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(strategy.missing_data || []).join("、")}`;
|
|
||||||
refreshIcons();
|
refreshIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3973,6 +4076,22 @@ function openStrategyDrawer(target = "editor") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCustomStrategyDrawer() {
|
||||||
|
if (!state.customStrategyDraft) {
|
||||||
|
state.customStrategyDraft = {
|
||||||
|
id: null,
|
||||||
|
builtin: false,
|
||||||
|
name: "自定义选股策略",
|
||||||
|
description: "",
|
||||||
|
regimes: [state.selectedRegime],
|
||||||
|
formula: buildQuantFormula(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
populateStrategyEditor(state.customStrategyDraft);
|
||||||
|
renderStrategyList();
|
||||||
|
openStrategyDrawer("editor");
|
||||||
|
}
|
||||||
|
|
||||||
function selectScreenerMobileView(view) {
|
function selectScreenerMobileView(view) {
|
||||||
state.screenerMobileView = view === "results" ? "results" : "strategy";
|
state.screenerMobileView = view === "results" ? "results" : "strategy";
|
||||||
const workspace = document.querySelector("#screenerView");
|
const workspace = document.querySelector("#screenerView");
|
||||||
@@ -3986,11 +4105,7 @@ function selectScreenerMobileView(view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateBacktestTaskStatus() {
|
function updateBacktestTaskStatus() {
|
||||||
const enabled = document.querySelector("#runBacktestToggle")?.checked;
|
setText("backtestTaskStatus", activeScreenerResult("smart") ? "结果已归档" : "等待盘后生成");
|
||||||
const backtest = activeScreenerResult("smart")?.backtest;
|
|
||||||
setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
|
|
||||||
? "正在回测"
|
|
||||||
: backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
|
|
||||||
renderScreenerProgress();
|
renderScreenerProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4003,18 +4118,18 @@ function selectRegime(regime) {
|
|||||||
|
|
||||||
function renderStrategyList() {
|
function renderStrategyList() {
|
||||||
const list = document.querySelector("#strategyList");
|
const list = document.querySelector("#strategyList");
|
||||||
const strategies = state.screenerSetup.strategies.filter((item) => item.formula?.meta?.library !== "curated");
|
const strategies = state.screenerSetup.strategies.filter((item) => !item.builtin && item.formula?.meta?.library !== "curated");
|
||||||
list.innerHTML = strategies.map((strategy) => `
|
list.innerHTML = strategies.map((strategy) => `
|
||||||
<button type="button" class="strategy-item ${strategy.id === state.selectedStrategy?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
|
<button type="button" class="strategy-item ${strategy.id === state.customStrategyDraft?.id ? "active" : ""}" data-strategy-id="${strategy.id}">
|
||||||
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
|
<strong>${escapeHtml(strategy.name)}</strong><span>${escapeHtml(strategy.description || "--")}</span>
|
||||||
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}</small>
|
<small>${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}</small>
|
||||||
</button>
|
</button>
|
||||||
`).join("");
|
`).join("") || '<div class="empty-state">暂无已保存的自定义公式</div>';
|
||||||
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
|
list.querySelectorAll("[data-strategy-id]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
state.selectedStrategy = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
|
state.customStrategyDraft = state.screenerSetup.strategies.find((item) => item.id === number(button.dataset.strategyId));
|
||||||
state.selectedRegime = state.selectedStrategy.regimes[0] || state.selectedRegime;
|
populateStrategyEditor(state.customStrategyDraft);
|
||||||
renderScreenerSetup();
|
renderStrategyList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4033,32 +4148,6 @@ function populateStrategyEditor(strategy) {
|
|||||||
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncFactorData() {
|
|
||||||
const button = document.querySelector("#factorSyncButton");
|
|
||||||
button.disabled = true;
|
|
||||||
setText("factorTaskStatus", "同步中");
|
|
||||||
setLoading(true, "正在同步 45 个交易日因子数据");
|
|
||||||
setStatus("正在同步选股因子");
|
|
||||||
try {
|
|
||||||
const payload = await apiRequest("/api/screener/sync", "POST", {
|
|
||||||
trade_date: elements.tradeDate.value,
|
|
||||||
lookback: 45,
|
|
||||||
});
|
|
||||||
const result = payload.result;
|
|
||||||
showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)} 日`);
|
|
||||||
await loadScreenerSetup(true);
|
|
||||||
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)} 日`);
|
|
||||||
setStatus("选股因子已同步");
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message);
|
|
||||||
setStatus("选股因子同步失败");
|
|
||||||
setText("factorTaskStatus", "同步失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compileStrategy() {
|
async function compileStrategy() {
|
||||||
const prompt = document.querySelector("#strategyPrompt").value.trim();
|
const prompt = document.querySelector("#strategyPrompt").value.trim();
|
||||||
const button = document.querySelector("#compileStrategyButton");
|
const button = document.querySelector("#compileStrategyButton");
|
||||||
@@ -4071,9 +4160,8 @@ async function compileStrategy() {
|
|||||||
regime: state.selectedRegime,
|
regime: state.selectedRegime,
|
||||||
});
|
});
|
||||||
const strategy = payload.strategy;
|
const strategy = payload.strategy;
|
||||||
state.selectedStrategy = { ...strategy, id: null, builtin: false };
|
state.customStrategyDraft = { ...strategy, id: null, builtin: false };
|
||||||
document.querySelector("#deleteStrategyButton").hidden = true;
|
document.querySelector("#deleteStrategyButton").hidden = true;
|
||||||
renderStrategySummary();
|
|
||||||
document.querySelector("#strategyNameInput").value = strategy.name;
|
document.querySelector("#strategyNameInput").value = strategy.name;
|
||||||
document.querySelector("#strategyDescriptionInput").value = strategy.description;
|
document.querySelector("#strategyDescriptionInput").value = strategy.description;
|
||||||
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
|
||||||
@@ -4099,8 +4187,9 @@ async function saveCurrentStrategy() {
|
|||||||
formula,
|
formula,
|
||||||
});
|
});
|
||||||
state.screenerSetup.strategies = payload.strategies;
|
state.screenerSetup.strategies = payload.strategies;
|
||||||
state.selectedStrategy = payload.strategies.find((item) => item.id === payload.id);
|
state.customStrategyDraft = payload.strategies.find((item) => item.id === payload.id);
|
||||||
renderScreenerSetup();
|
renderStrategyList();
|
||||||
|
populateStrategyEditor(state.customStrategyDraft);
|
||||||
showToast("自定义策略已保存");
|
showToast("自定义策略已保存");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message);
|
showToast(error.message);
|
||||||
@@ -4108,7 +4197,7 @@ async function saveCurrentStrategy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCurrentStrategy() {
|
async function deleteCurrentStrategy() {
|
||||||
const strategy = state.selectedStrategy;
|
const strategy = state.customStrategyDraft;
|
||||||
if (!strategy?.id || strategy.builtin) {
|
if (!strategy?.id || strategy.builtin) {
|
||||||
showToast("只能删除已保存的自定义策略");
|
showToast("只能删除已保存的自定义策略");
|
||||||
return;
|
return;
|
||||||
@@ -4120,10 +4209,9 @@ async function deleteCurrentStrategy() {
|
|||||||
try {
|
try {
|
||||||
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
|
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
|
||||||
state.screenerSetup.strategies = payload.strategies;
|
state.screenerSetup.strategies = payload.strategies;
|
||||||
state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime))
|
state.customStrategyDraft = null;
|
||||||
|| payload.strategies.find((item) => item.formula?.meta?.library !== "curated")
|
renderStrategyList();
|
||||||
|| null;
|
openCustomStrategyDrawer();
|
||||||
renderScreenerSetup();
|
|
||||||
showToast("自定义策略已删除");
|
showToast("自定义策略已删除");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || "策略删除失败");
|
showToast(error.message || "策略删除失败");
|
||||||
@@ -4132,26 +4220,6 @@ async function deleteCurrentStrategy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCuratedStrategy() {
|
|
||||||
const strategy = activeCuratedStrategy();
|
|
||||||
if (!strategy) return;
|
|
||||||
if (!strategy.data_ready) {
|
|
||||||
showToast(`请先同步${(strategy.missing_data || []).join("、")}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0];
|
|
||||||
await executeScreenerFormula({
|
|
||||||
mode: "curated",
|
|
||||||
formula: strategy.formula,
|
|
||||||
strategyName: strategy.name,
|
|
||||||
strategyId: strategy.id,
|
|
||||||
regime,
|
|
||||||
runBacktest: document.querySelector("#curatedBacktestToggle").checked,
|
|
||||||
button: document.querySelector("#curatedRunButton"),
|
|
||||||
loadingText: `正在执行“${strategy.name}”并计算历史样本`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runQuantStrategy() {
|
async function runQuantStrategy() {
|
||||||
let formula;
|
let formula;
|
||||||
try {
|
try {
|
||||||
@@ -4163,27 +4231,27 @@ async function runQuantStrategy() {
|
|||||||
await executeScreenerFormula({
|
await executeScreenerFormula({
|
||||||
mode: "quant",
|
mode: "quant",
|
||||||
formula,
|
formula,
|
||||||
strategyName: "自定义量化公式",
|
strategyName: "自定义选股公式",
|
||||||
regime: state.selectedRegime,
|
regime: state.selectedRegime,
|
||||||
runBacktest: document.querySelector("#quantBacktestToggle").checked,
|
runBacktest: document.querySelector("#quantBacktestToggle").checked,
|
||||||
button: document.querySelector("#quantRunButton"),
|
button: document.querySelector("#quantRunButton"),
|
||||||
loadingText: "正在执行量化公式并计算因子贡献",
|
loadingText: "正在执行自定义公式并计算因子贡献",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveQuantAsStrategy() {
|
function saveQuantAsStrategy() {
|
||||||
try {
|
try {
|
||||||
const formula = buildQuantFormula();
|
const formula = buildQuantFormula();
|
||||||
state.selectedStrategy = {
|
state.customStrategyDraft = {
|
||||||
id: null,
|
id: null,
|
||||||
builtin: false,
|
builtin: false,
|
||||||
name: "自定义量化策略",
|
name: "自定义选股策略",
|
||||||
description: "由量化因子工作台生成,可在高级公式中继续调整。",
|
description: "由自定义因子工作台生成,可在高级公式中继续调整。",
|
||||||
regimes: [state.selectedRegime],
|
regimes: [state.selectedRegime],
|
||||||
formula,
|
formula,
|
||||||
};
|
};
|
||||||
populateStrategyEditor(state.selectedStrategy);
|
populateStrategyEditor(state.customStrategyDraft);
|
||||||
document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式";
|
document.querySelector("#strategyPrompt").value = "自定义因子工作台生成的选股公式";
|
||||||
openStrategyDrawer("editor");
|
openStrategyDrawer("editor");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message);
|
showToast(error.message);
|
||||||
@@ -4239,26 +4307,6 @@ async function executeScreenerFormula({ mode, formula, strategyName, strategyId
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runScreener() {
|
|
||||||
let formula;
|
|
||||||
try {
|
|
||||||
formula = parseFormulaEditor();
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await executeScreenerFormula({
|
|
||||||
mode: "smart",
|
|
||||||
formula,
|
|
||||||
strategyName: document.querySelector("#strategyNameInput").value,
|
|
||||||
strategyId: state.selectedStrategy?.id,
|
|
||||||
regime: state.selectedRegime,
|
|
||||||
runBacktest: document.querySelector("#runBacktestToggle").checked,
|
|
||||||
button: document.querySelector("#screenerRunButton"),
|
|
||||||
loadingText: "正在计算因子排名与滚动回测",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMentorSetup(force = false) {
|
async function loadMentorSetup(force = false) {
|
||||||
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
|
||||||
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
|
||||||
@@ -6619,9 +6667,9 @@ function renderScreenerResult() {
|
|||||||
const context = activeScreenerResultContext(mode);
|
const context = activeScreenerResultContext(mode);
|
||||||
const source = document.querySelector("#screenerResultSource");
|
const source = document.querySelector("#screenerResultSource");
|
||||||
const emptyMessages = {
|
const emptyMessages = {
|
||||||
smart: "尚未执行当前阶段与策略的选股",
|
smart: "当日盘后候选尚未生成",
|
||||||
curated: "尚未执行所选策略",
|
curated: "所选策略的当日候选尚未生成",
|
||||||
quant: "尚未执行量化选股",
|
quant: "尚未执行自定义选股",
|
||||||
};
|
};
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setText("screenerResultCount", "0 只");
|
setText("screenerResultCount", "0 只");
|
||||||
@@ -6639,9 +6687,9 @@ function renderScreenerResult() {
|
|||||||
}
|
}
|
||||||
const candidates = result.candidates || [];
|
const candidates = result.candidates || [];
|
||||||
setText("screenerResultCount", `${candidates.length} 只`);
|
setText("screenerResultCount", `${candidates.length} 只`);
|
||||||
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
|
const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "自定义选股" };
|
||||||
const sourceParts = [modeLabels[mode]];
|
const sourceParts = [modeLabels[mode]];
|
||||||
if (mode !== "quant" && context?.regime) sourceParts.push(regimeLabel(context.regime));
|
if (mode === "smart" && context?.regime) sourceParts.push(regimeLabel(context.regime));
|
||||||
sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
|
sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
|
||||||
source.textContent = sourceParts.join(" · ");
|
source.textContent = sourceParts.join(" · ");
|
||||||
source.hidden = false;
|
source.hidden = false;
|
||||||
@@ -6652,7 +6700,9 @@ function renderScreenerResult() {
|
|||||||
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
|
? `盘中行情 · 历史样本截至 ${displayCompactDate(meta.history_cutoff)} · ${result.disclaimer}`
|
||||||
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
|
: `盘后数据 ${displayCompactDate(meta.trade_date)} · ${result.disclaimer}`,
|
||||||
);
|
);
|
||||||
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
|
const empty = document.querySelector("#screenerEmpty");
|
||||||
|
empty.textContent = mode === "curated" ? "暂无符合条件个股" : emptyMessages[mode];
|
||||||
|
empty.hidden = candidates.length > 0;
|
||||||
const body = document.querySelector("#screenerTableBody");
|
const body = document.querySelector("#screenerTableBody");
|
||||||
const runId = number(meta.run_id);
|
const runId = number(meta.run_id);
|
||||||
body.innerHTML = candidates.map((row, index) => `
|
body.innerHTML = candidates.map((row, index) => `
|
||||||
|
|||||||
@@ -219,11 +219,6 @@ body.drawer-open .drawer-mask{display:block}
|
|||||||
.field textarea{min-height:90px;resize:vertical}
|
.field textarea{min-height:90px;resize:vertical}
|
||||||
.field input:focus,.field textarea:focus{border-color:var(--blue-line)}
|
.field input:focus,.field textarea:focus{border-color:var(--blue-line)}
|
||||||
|
|
||||||
/* toast */
|
|
||||||
.toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;
|
|
||||||
padding:8px 18px;border-radius:8px;font-size:12.5px;z-index:200;opacity:0;transition:opacity .2s;pointer-events:none}
|
|
||||||
.toast.show{opacity:.95}
|
|
||||||
|
|
||||||
/* ========== 集合竞价页 ========== */
|
/* ========== 集合竞价页 ========== */
|
||||||
.auc-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
.auc-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
|
||||||
.auc-head h2{font-size:17px;font-weight:800}
|
.auc-head h2{font-size:17px;font-weight:800}
|
||||||
|
|||||||
+67
-57
@@ -19,9 +19,9 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/styles.css">
|
<link rel="stylesheet" href="/styles.css">
|
||||||
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
|
<link rel="stylesheet" href="/renovation.css?v=20260725-5">
|
||||||
<link rel="stylesheet" href="/redesign-v2.css?v=20260726-34">
|
<link rel="stylesheet" href="/redesign-v2.css?v=20260728-1">
|
||||||
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
|
<link rel="stylesheet" href="/design-system.css?v=20260728-4">
|
||||||
<link rel="stylesheet" href="/theme.css?v=20260728-1">
|
<link rel="stylesheet" href="/theme.css?v=20260728-2">
|
||||||
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
|
<link rel="stylesheet" href="/wentian-v2.css?v=20260728-7">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -449,12 +449,12 @@
|
|||||||
<div class="workspace-heading card-h redesigned-card-head"><h3 id="sentimentStageGuideTitle">判定口径</h3><span>温度 + 结构共同判定</span></div>
|
<div class="workspace-heading card-h redesigned-card-head"><h3 id="sentimentStageGuideTitle">判定口径</h3><span>温度 + 结构共同判定</span></div>
|
||||||
<div class="sentiment-stage-guide-head" aria-hidden="true"><span>阶段</span><span>典型特征</span><span>温度区间</span><span>策略取向</span></div>
|
<div class="sentiment-stage-guide-head" aria-hidden="true"><span>阶段</span><span>典型特征</span><span>温度区间</span><span>策略取向</span></div>
|
||||||
<div class="sentiment-stage-guide-grid">
|
<div class="sentiment-stage-guide-grid">
|
||||||
<article data-sentiment-stage="冰点"><strong>冰点</strong><span>涨停稀少、跌停成堆、高度压至 2 板</span><span class="stage-range">0-20</span><small>抗跌先手,允许无结果</small></article>
|
<article data-sentiment-stage="冰点"><strong>冰点</strong><span>涨停稀少、跌停成堆、高度显著压缩</span><span class="stage-range">低于 25</span><small>抗跌先手,允许无结果</small></article>
|
||||||
<article data-sentiment-stage="修复"><strong>修复</strong><span>跌停减少、首板增多、出现反包</span><span class="stage-range">20-40</span><small>修复先锋,小仓试错</small></article>
|
<article data-sentiment-stage="修复"><strong>修复</strong><span>风险收敛、温度从低位有效回升</span><span class="stage-range">25+ 且回升</span><small>修复先锋,小仓试错</small></article>
|
||||||
<article data-sentiment-stage="发酵"><strong>发酵</strong><span>主线清晰、梯队成型、晋级率走高</span><span class="stage-range">40-60</span><small>主线跟随</small></article>
|
<article data-sentiment-stage="发酵"><strong>发酵</strong><span>主线清晰、梯队成型、连续转强</span><span class="stage-range">45+ 且连续确认</span><small>主线跟随</small></article>
|
||||||
<article data-sentiment-stage="高潮"><strong>高潮</strong><span>涨停扩散、空间打开、情绪充沛</span><span class="stage-range">60-85</span><small>核心去后排</small></article>
|
<article data-sentiment-stage="高潮"><strong>高潮</strong><span>温度、赚钱效应与涨停生态共振</span><span class="stage-range">80+ 且生态达标</span><small>核心去后排</small></article>
|
||||||
<article data-sentiment-stage="分化"><strong>分化</strong><span>高低切换、炸板增多、主线内部分歧</span><span class="stage-range">45-65</span><small>承接回流</small></article>
|
<article data-sentiment-stage="分化"><strong>分化</strong><span>高低切换、炸板增多、主线内部分歧</span><span class="stage-range">45+ 且结构转弱</span><small>承接回流</small></article>
|
||||||
<article data-sentiment-stage="退潮"><strong>退潮</strong><span>高度压缩、晋级走低、亏钱效应扩散</span><span class="stage-range">20-40 且下降</span><small>防守观察</small></article>
|
<article data-sentiment-stage="退潮"><strong>退潮</strong><span>温度或系统健康度继续走弱</span><span class="stage-range">低于 45 且走弱</span><small>防守观察</small></article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -801,7 +801,7 @@
|
|||||||
<div class="screener-mode-tabs method" role="tablist" aria-label="智能选股模式">
|
<div class="screener-mode-tabs method" role="tablist" aria-label="智能选股模式">
|
||||||
<button class="active" type="button" role="tab" aria-selected="true" data-screener-mode="smart">阶段选股</button>
|
<button class="active" type="button" role="tab" aria-selected="true" data-screener-mode="smart">阶段选股</button>
|
||||||
<button type="button" role="tab" aria-selected="false" data-screener-mode="curated">策略选股</button>
|
<button type="button" role="tab" aria-selected="false" data-screener-mode="curated">策略选股</button>
|
||||||
<button type="button" role="tab" aria-selected="false" data-screener-mode="quant">量化选股</button>
|
<button type="button" role="tab" aria-selected="false" data-screener-mode="quant">自定义选股</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button>
|
<button id="openScreenerTrackingButton" class="button screener-tracking-entry" type="button"><i data-lucide="chart-no-axes-combined"></i>策略跟踪</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -831,8 +831,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="regimeReason" class="regime-advice">--</div>
|
<div id="regimeReason" class="regime-advice">--</div>
|
||||||
<div class="regime-control-line">
|
<div class="regime-control-line">
|
||||||
<div id="regimeSelector" class="regime-selector" role="group" aria-label="选择市场阶段"></div>
|
<div id="regimeSelector" class="regime-selector" aria-label="系统识别的市场阶段"></div>
|
||||||
<div class="factor-data-status"><span>跟随自动识别(点击可手动覆盖)</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">尚未同步</small></div>
|
<div class="factor-data-status"><span>盘后行情定格后自动更新</span><strong id="factorDateCount">0 日</strong><small id="factorDateRange">等待后台数据</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,17 +842,12 @@
|
|||||||
<div class="screener-strategy-summary">
|
<div class="screener-strategy-summary">
|
||||||
<div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div>
|
<div class="screener-strategy-title"><strong id="activeStrategyHeading">--</strong><span id="activeStrategyRegimes"></span></div>
|
||||||
<p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p>
|
<p id="activeStrategyDescription">等待匹配当前市场阶段的策略。</p>
|
||||||
<div class="screener-strategy-actions">
|
<div class="screener-strategy-actions"><span class="screener-auto-note">由系统按当前阶段自动匹配</span></div>
|
||||||
<button id="changeStrategyButton" class="button" type="button">更换策略</button>
|
|
||||||
<button id="openStrategyDrawerButton" class="button" type="button">编辑 / 自定义策略</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="screener-runbar runbar">
|
<div class="screener-runbar runbar">
|
||||||
<div class="screener-run-actions">
|
<div class="screener-run-actions">
|
||||||
<button id="screenerRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行选股</button>
|
|
||||||
<button id="factorSyncButton" class="button" type="button">同步因子数据</button>
|
|
||||||
<button id="screenerExportButton" class="button" type="button">导出 CSV</button>
|
<button id="screenerExportButton" class="button" type="button">导出 CSV</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="screener-pipeline-status" aria-live="polite">
|
<div class="screener-pipeline-status" aria-live="polite">
|
||||||
@@ -863,37 +858,52 @@
|
|||||||
<div data-screener-results-slot="smart"></div>
|
<div data-screener-results-slot="smart"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="curated-screener-panel" data-screener-panel="curated" hidden>
|
<div class="curated-screener-panel" data-screener-panel="curated" hidden>
|
||||||
<section class="curated-library-pane" aria-label="精选策略库">
|
<div class="curated-workspace">
|
||||||
<div class="curated-library-heading">
|
<aside class="curated-library-pane" aria-label="精选策略库">
|
||||||
<div><span>策略库</span><h3>选择策略并直接执行</h3><p>适用阶段仅作参考,策略条件与权重可在详情中查看。</p></div><strong id="curatedStrategyCount">10 套</strong>
|
<div class="curated-library-heading">
|
||||||
</div>
|
<div><span>策略库</span><h3>盘后自动候选池</h3></div><strong id="curatedStrategyCount">10 套</strong>
|
||||||
<div class="curated-library-controls">
|
</div>
|
||||||
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索名称或类别" autocomplete="off"></label>
|
<div class="curated-library-controls">
|
||||||
<div id="curatedCategoryFilters" class="curated-category-filters" role="group" aria-label="策略分类"></div>
|
<label class="curated-search"><i data-lucide="search"></i><span class="visually-hidden">搜索策略</span><input id="curatedStrategySearch" type="search" placeholder="搜索策略" autocomplete="off"></label>
|
||||||
</div>
|
<label class="curated-category-select"><span class="visually-hidden">策略分类</span><select id="curatedCategoryFilter" aria-label="策略分类"></select><i data-lucide="chevron-down"></i></label>
|
||||||
<div id="curatedStrategyList" class="curated-strategy-list"></div>
|
<div class="curated-view-toggle" role="group" aria-label="策略排列方式">
|
||||||
</section>
|
<button class="active" type="button" data-curated-view="list" aria-label="列表排列" title="列表排列" aria-pressed="true"><i data-lucide="list"></i></button>
|
||||||
<dialog id="curatedDetailDialog" class="curated-detail-dialog" aria-labelledby="curatedStrategyName">
|
<button type="button" data-curated-view="grid" aria-label="图标排列" title="图标排列" aria-pressed="false"><i data-lucide="layout-grid"></i></button>
|
||||||
<section class="curated-detail-pane">
|
</div>
|
||||||
<button id="closeCuratedDetailButton" class="icon-button curated-detail-close" type="button" title="关闭" aria-label="关闭策略条件"><i data-lucide="x"></i></button>
|
</div>
|
||||||
<header class="curated-detail-header">
|
<div id="curatedSchoolFilters" class="curated-school-filters" aria-label="策略流派"></div>
|
||||||
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
|
<div id="curatedStrategyList" class="curated-strategy-list"></div>
|
||||||
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
|
</aside>
|
||||||
</header>
|
<section class="curated-detail-pane" aria-labelledby="curatedStrategyName">
|
||||||
<div class="curated-detail-grid">
|
<header class="curated-detail-header">
|
||||||
<section class="curated-condition-section"><div class="mini-section-heading"><h4>准入条件</h4><span id="curatedFilterCount">0 项</span></div><div id="curatedFilterList" class="curated-rule-list"></div></section>
|
<div><span id="curatedStrategyCategory">精选策略</span><h3 id="curatedStrategyName">选择一套策略</h3><p id="curatedStrategyDescription">查看策略条件、数据状态和适用环境。</p></div>
|
||||||
<section class="curated-condition-section"><div class="mini-section-heading"><h4>评分权重</h4><span id="curatedWeightTotal">100%</span></div><div id="curatedScoreList" class="curated-score-list"></div></section>
|
<div id="curatedStrategyBadges" class="curated-strategy-badges"></div>
|
||||||
</div>
|
</header>
|
||||||
<div class="curated-execution-bar">
|
<div class="curated-environment-notes">
|
||||||
<div id="curatedDataStatus" class="curated-data-status"><i data-lucide="database"></i><span><strong>检查数据中</strong><small>同步后显示可用状态</small></span></div>
|
<p><strong>适用环境</strong><span id="curatedSuitableEnvironment"></span></p>
|
||||||
<label class="checkbox-control"><input id="curatedBacktestToggle" type="checkbox" checked>滚动回测</label>
|
<p><strong>失效风险</strong><span id="curatedFailureRisk"></span></p>
|
||||||
<button id="curatedRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行该策略</button>
|
</div>
|
||||||
</div>
|
<div class="curated-health-grid" id="curatedHealthMetrics" aria-label="策略运行状态"></div>
|
||||||
</section>
|
<div class="curated-detail-grid">
|
||||||
</dialog>
|
<section class="curated-condition-section"><div class="mini-section-heading"><h4>准入条件</h4><span id="curatedFilterCount">0 项</span></div><div id="curatedFilterList" class="curated-rule-list"></div></section>
|
||||||
|
<section class="curated-condition-section"><div class="mini-section-heading"><h4>评分权重</h4><span id="curatedWeightTotal">100%</span></div><div id="curatedScoreList" class="curated-score-list"></div></section>
|
||||||
|
</div>
|
||||||
|
<div class="curated-execution-bar">
|
||||||
|
<div id="curatedDataStatus" class="curated-data-status"><i data-lucide="database"></i><span><strong>检查数据中</strong><small>同步后显示可用状态</small></span></div>
|
||||||
|
<span class="screener-auto-note">候选池由后台盘后自动更新</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<div data-screener-results-slot="curated"></div>
|
<div data-screener-results-slot="curated"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="quant-screener-panel" data-screener-panel="quant" hidden>
|
<div class="quant-screener-panel" data-screener-panel="quant" hidden>
|
||||||
|
<section class="custom-screener-tools card">
|
||||||
|
<div><span>自定义能力</span><strong>用自然语言生成公式,或直接编辑受控公式</strong><small>自定义选股仅在点击执行后计算,不影响系统盘后候选池。</small></div>
|
||||||
|
<div>
|
||||||
|
<button id="openStrategyDrawerButton" class="button" type="button"><i data-lucide="sparkles"></i>自然语言生成公式</button>
|
||||||
|
<button id="quantSaveButton" class="button" type="button"><i data-lucide="braces"></i>编辑 / 保存公式</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="quant-builder-pane">
|
<section class="quant-builder-pane">
|
||||||
<header class="quant-panel-heading"><h3>因子与权重</h3><div><button id="addQuantScoreButton" class="button" type="button"><i data-lucide="plus"></i>添加因子</button><button id="quantResetButton" class="button ghost" type="button"><i data-lucide="rotate-ccw"></i>重置</button></div></header>
|
<header class="quant-panel-heading"><h3>因子与权重</h3><div><button id="addQuantScoreButton" class="button" type="button"><i data-lucide="plus"></i>添加因子</button><button id="quantResetButton" class="button ghost" type="button"><i data-lucide="rotate-ccw"></i>重置</button></div></header>
|
||||||
<div id="quantScoreRows" class="quant-rule-rows"></div>
|
<div id="quantScoreRows" class="quant-rule-rows"></div>
|
||||||
@@ -912,16 +922,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="quantFilterRows" class="quant-rule-rows"></div>
|
<div id="quantFilterRows" class="quant-rule-rows"></div>
|
||||||
<div class="quant-execution-actions">
|
<div class="quant-execution-actions">
|
||||||
<button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行量化选股</button>
|
<button id="quantRunButton" class="button primary" type="button"><i data-lucide="play"></i>执行自定义选股</button>
|
||||||
<button id="quantSaveButton" class="button" type="button"><i data-lucide="save"></i>保存为方案</button>
|
|
||||||
<label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label>
|
<label class="checkbox-control"><input id="quantBacktestToggle" type="checkbox" checked>滚动回测</label>
|
||||||
<span>结果按加权总分排序,并生成逐股贡献解释</span>
|
<span>结果按加权总分排序,并生成逐股贡献解释</span>
|
||||||
</div>
|
</div>
|
||||||
<p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p>
|
<p id="quantValidationMessage" class="quant-validation-message" role="status" aria-live="polite"></p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div data-screener-results-slot="quant"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="custom-results-slot" data-screener-results-slot="quant"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="screener-results-view">
|
<div class="screener-results-view">
|
||||||
<section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden>
|
<section id="backtestPanel" class="backtest-panel screener-backtest-strip" hidden>
|
||||||
@@ -935,6 +944,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-frame card tbl-wrap screener-result-frame">
|
<div class="table-frame card tbl-wrap screener-result-frame">
|
||||||
<table class="data-table tbl">
|
<table class="data-table tbl">
|
||||||
|
<colgroup class="screener-result-columns"><col><col><col><col><col><col><col><col><col><col><col><col></colgroup>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr">↕</span></th>
|
<th class="num">排名</th><th>股票</th><th>板块</th><th class="number num sortable">综合分<span class="arr">↕</span></th>
|
||||||
<th class="number num sortable">历史估计(%)<span class="arr">↕</span></th><th class="number num sortable">当日涨幅(%)<span class="arr">↕</span></th><th class="number num sortable">5日涨幅(%)<span class="arr">↕</span></th>
|
<th class="number num sortable">历史估计(%)<span class="arr">↕</span></th><th class="number num sortable">当日涨幅(%)<span class="arr">↕</span></th><th class="number num sortable">5日涨幅(%)<span class="arr">↕</span></th>
|
||||||
@@ -946,7 +956,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dialog id="strategyDrawer" class="strategy-drawer" aria-labelledby="strategyDrawerTitle">
|
<dialog id="strategyDrawer" class="strategy-drawer" aria-labelledby="strategyDrawerTitle">
|
||||||
<div class="strategy-drawer-header"><div><span>阶段选股</span><h2 id="strategyDrawerTitle">编辑 / 自定义策略</h2></div><button id="closeStrategyDrawerButton" class="icon-button" type="button" aria-label="关闭策略编辑"><i data-lucide="x"></i></button></div>
|
<div class="strategy-drawer-header"><div><span>自定义选股</span><h2 id="strategyDrawerTitle">自然语言与受控公式</h2></div><button id="closeStrategyDrawerButton" class="icon-button" type="button" aria-label="关闭策略编辑"><i data-lucide="x"></i></button></div>
|
||||||
<div class="strategy-drawer-body">
|
<div class="strategy-drawer-body">
|
||||||
<aside class="strategy-sidebar">
|
<aside class="strategy-sidebar">
|
||||||
<div class="workspace-heading card-h"><h3>策略库</h3><span id="strategyCount">0 套</span></div>
|
<div class="workspace-heading card-h"><h3>策略库</h3><span id="strategyCount">0 套</span></div>
|
||||||
@@ -1108,22 +1118,22 @@
|
|||||||
<section class="rotation-detail-card card" aria-labelledby="rotationDetailTitle">
|
<section class="rotation-detail-card card" aria-labelledby="rotationDetailTitle">
|
||||||
<header class="rotation-card-head card-h">
|
<header class="rotation-card-head card-h">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="rotationDetailTitle">当日轮动明细</h3>
|
<h3 id="rotationDetailTitle">板块成分股</h3>
|
||||||
<span>趋势、强度与涨停梯队 · 点击行可联动追踪上方轨迹</span>
|
<span>点击上方板块,查看目标交易日有效申万成分与行情</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="rotationDetailMeta" class="rotation-top-tag">--</span>
|
<span id="rotationDetailMeta" class="rotation-top-tag">--</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="rotation-table-frame tbl-wrap">
|
<div class="rotation-table-frame tbl-wrap">
|
||||||
<table id="rotationTable" class="data-table tbl rotation-table">
|
<table id="rotationTable" class="data-table tbl rotation-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="number num">排名</th><th>板块</th><th>趋势</th><th class="number num">今日涨停(只)</th>
|
<th class="number num">序号</th><th>代码</th><th>股票</th>
|
||||||
<th class="number num">昨日涨停(只)</th><th class="number num" data-auto-sort="true" title="变化:点击排序">变化(只)</th>
|
<th class="number num" data-auto-sort="true" title="涨跌幅:点击排序">涨跌幅(%)</th>
|
||||||
<th class="number num" data-auto-sort="true" title="强度:点击排序">强度(分)</th><th class="number num">最高板(板)</th>
|
<th class="number num">开盘价(元)</th><th class="number num">收盘价(元)</th>
|
||||||
<th class="number num" data-auto-sort="true" title="平均涨幅:点击排序">平均涨幅(%)</th><th>领涨股</th>
|
<th class="number num" data-auto-sort="true" title="成交额:点击排序">成交额(亿)</th><th>行情状态</th>
|
||||||
<th class="number num" data-auto-sort="true" title="涨停股成交额:点击排序">涨停股成交额(亿)</th>
|
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="rotationTableBody"></tbody>
|
<tbody id="rotationTableBody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div id="rotationMembersEmpty" class="empty-state">点击上方任意板块查看成分股</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -1873,6 +1883,6 @@
|
|||||||
<script src="/vendor/lucide.min.js" defer></script>
|
<script src="/vendor/lucide.min.js" defer></script>
|
||||||
<script src="/ui-core.js" defer></script>
|
<script src="/ui-core.js" defer></script>
|
||||||
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
||||||
<script src="/app.js?v=20260728-2" defer></script>
|
<script src="/app.js?v=20260728-3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+357
-54
@@ -616,7 +616,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
.sentiment-current-phase-badge strong { display: block; color: var(--r2-up); font-size: 19px; font-weight: 800; line-height: 1.35; }
|
.sentiment-current-phase-badge strong { display: block; color: var(--r2-up); font-size: 19px; font-weight: 800; line-height: 1.35; }
|
||||||
.sentiment-current-phase-badge span { display: block; margin-top: 2px; color: var(--r2-sub); font-size: 11px; white-space: nowrap; }
|
.sentiment-current-phase-badge span { display: block; margin-top: 2px; color: var(--r2-sub); font-size: 11px; white-space: nowrap; }
|
||||||
.sentiment-phase-info { min-width: 0; flex: 1; }
|
.sentiment-phase-info { min-width: 0; flex: 1; }
|
||||||
.sentiment-phase-info p { color: #374151; font-size: 12.5px; line-height: 1.7; }
|
.sentiment-phase-info p { color: var(--r2-sub); font-size: 12.5px; line-height: 1.7; }
|
||||||
.sentiment-phase-info p b { font-weight: 700; }
|
.sentiment-phase-info p b { font-weight: 700; }
|
||||||
.sentiment-phase-info p .down { color: var(--r2-down); }
|
.sentiment-phase-info p .down { color: var(--r2-down); }
|
||||||
.sentiment-phase-info p .up { color: var(--r2-up); }
|
.sentiment-phase-info p .up { color: var(--r2-up); }
|
||||||
@@ -1050,7 +1050,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
.pool-side-group p {
|
.pool-side-group p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #4b5563;
|
color: var(--r2-sub);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1819,7 +1819,7 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
|
|
||||||
.performance-conclusion {
|
.performance-conclusion {
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
color: #374151;
|
color: var(--r2-sub);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
}
|
}
|
||||||
@@ -5222,38 +5222,114 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
|
|
||||||
/* Curated strategy workspace. */
|
/* Curated strategy workspace. */
|
||||||
#screenerView .curated-screener-panel { display: block; }
|
#screenerView .curated-screener-panel { display: block; }
|
||||||
#screenerView .curated-library-pane { padding: 0; border: 0; background: transparent; box-shadow: none; }
|
#screenerView .curated-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(340px, .9fr) minmax(560px, 1.5fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-library-pane,
|
||||||
|
#screenerView .curated-detail-pane {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--r2-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--r2-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-library-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#screenerView .curated-library-heading {
|
#screenerView .curated-library-heading {
|
||||||
min-height: 58px;
|
min-height: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
border: 1px solid var(--r2-line);
|
border-bottom: 1px solid var(--r2-line-soft);
|
||||||
border-radius: 10px 10px 0 0;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; }
|
#screenerView .curated-library-heading > div > span { color: var(--scr-blue); font-size: 10px; font-weight: 700; }
|
||||||
#screenerView .curated-library-heading h3 { display: inline; margin: 0 9px 0 0; font-size: 14px; }
|
#screenerView .curated-library-heading h3 { margin: 1px 0 0; font-size: 14px; }
|
||||||
#screenerView .curated-library-heading p { display: inline; margin: 0; color: var(--r2-faint); font-size: 10.5px; }
|
|
||||||
#screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; }
|
#screenerView .curated-library-heading > strong { margin-left: auto; color: var(--r2-sub); font-size: 11px; }
|
||||||
|
|
||||||
#screenerView .curated-library-controls {
|
#screenerView .curated-library-controls {
|
||||||
min-height: 50px;
|
min-height: 48px;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 104px 62px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 7px;
|
||||||
margin: 0 0 12px;
|
padding: 7px 10px;
|
||||||
padding: 8px 14px;
|
border-bottom: 1px solid var(--r2-line-soft);
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-view-toggle {
|
||||||
|
min-height: 32px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
padding: 2px;
|
||||||
border: 1px solid var(--r2-line);
|
border: 1px solid var(--r2-line);
|
||||||
border-top: 0;
|
border-radius: 7px;
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-view-toggle button {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--r2-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-view-toggle button.active { background: var(--scr-blue-soft); color: var(--scr-blue); }
|
||||||
|
#screenerView .curated-view-toggle button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; }
|
||||||
|
#screenerView .curated-view-toggle .lucide { width: 13px; height: 13px; }
|
||||||
|
|
||||||
|
#screenerView .curated-school-filters {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid var(--r2-line-soft);
|
||||||
|
background: #fff;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-school-filters::-webkit-scrollbar { display: none; }
|
||||||
|
#screenerView .curated-school-filters button {
|
||||||
|
min-height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--r2-sub);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-school-filters button small { color: var(--r2-faint); font-size: 8.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
#screenerView .curated-school-filters button:hover { background: var(--scr-blue-soft); color: var(--scr-blue); }
|
||||||
|
#screenerView .curated-school-filters button.active { border-color: #c5d4f1; background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; }
|
||||||
|
#screenerView .curated-school-filters button:focus-visible { outline: 2px solid var(--scr-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
#screenerView .curated-search {
|
#screenerView .curated-search {
|
||||||
width: 240px;
|
width: 100%;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -5266,51 +5342,176 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
|
|
||||||
#screenerView .curated-search .lucide { width: 14px; color: var(--r2-faint); }
|
#screenerView .curated-search .lucide { width: 14px; color: var(--r2-faint); }
|
||||||
#screenerView .curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; font-size: 11.5px; }
|
#screenerView .curated-search input { min-width: 0; flex: 1; border: 0; outline: 0; font-size: 11.5px; }
|
||||||
#screenerView .curated-category-filters { display: flex; gap: 5px; overflow-x: auto; }
|
|
||||||
#screenerView .curated-category-filters button { min-height: 28px; padding: 0 10px; border: 1px solid var(--r2-line); border-radius: 6px; background: #fff; color: var(--r2-sub); font-size: 10.5px; white-space: nowrap; }
|
|
||||||
#screenerView .curated-category-filters button.active { border-color: var(--scr-blue); background: var(--scr-blue-soft); color: var(--scr-blue); font-weight: 700; }
|
|
||||||
|
|
||||||
#screenerView .curated-strategy-list {
|
#screenerView .curated-category-select {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
min-height: 32px;
|
||||||
gap: 12px;
|
display: flex;
|
||||||
padding: 0;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-strategy-card {
|
#screenerView .curated-category-select select {
|
||||||
min-height: 158px;
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 27px 0 9px;
|
||||||
|
border: 1px solid var(--r2-line);
|
||||||
|
border-radius: 7px;
|
||||||
|
appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--r2-sub);
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-category-select .lucide {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
width: 13px;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--r2-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-strategy-list {
|
||||||
|
min-height: 352px;
|
||||||
|
max-height: 532px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 13px 15px;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--r2-line);
|
padding: 8px;
|
||||||
border-radius: 10px;
|
scrollbar-gutter: stable;
|
||||||
background: #fff;
|
}
|
||||||
box-shadow: var(--r2-shadow);
|
|
||||||
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
#screenerView .curated-strategy-icon { display: none; }
|
||||||
|
|
||||||
|
#screenerView .curated-strategy-list.is-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-card {
|
||||||
|
min-height: 124px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-card:hover { transform: translateY(-2px); }
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-card.active { box-shadow: inset 0 3px var(--scr-blue); }
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--scr-blue-soft);
|
||||||
|
color: var(--scr-blue);
|
||||||
|
}
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-icon .lucide { width: 16px; height: 16px; }
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-card-head { width: 100%; grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-strategy-rank,
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-card-tags { display: none; }
|
||||||
|
#screenerView .curated-strategy-list.is-grid .curated-card-result { justify-self: center; }
|
||||||
|
|
||||||
|
#screenerView .curated-strategy-card {
|
||||||
|
min-height: 70px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f8fafc;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-strategy-card:hover {
|
#screenerView .curated-strategy-card:hover {
|
||||||
border-color: #b7c9ee;
|
border-color: #b7c9ee;
|
||||||
transform: translateY(-2px);
|
background: #f4f7fd;
|
||||||
box-shadow: 0 5px 16px rgba(37, 99, 235, .08);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: #fbfdff; box-shadow: inset 0 3px var(--scr-blue), var(--r2-shadow); }
|
#screenerView .curated-strategy-card.active { border-color: #9cb5ec; background: var(--scr-blue-soft); box-shadow: inset 3px 0 var(--scr-blue); }
|
||||||
#screenerView .curated-card-head { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
#screenerView .curated-card-head { display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 8px; }
|
||||||
#screenerView .curated-strategy-rank { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 5px; background: #f1f3f6; color: var(--r2-sub); font-size: 9.5px; font-style: normal; }
|
#screenerView .curated-strategy-rank { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 5px; background: #eef1f5; color: var(--r2-sub); font-size: 9px; font-style: normal; }
|
||||||
#screenerView .curated-card-head strong,
|
#screenerView .curated-card-head strong,
|
||||||
#screenerView .curated-card-head small { display: block; }
|
#screenerView .curated-card-head small { display: block; }
|
||||||
#screenerView .curated-card-head strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
#screenerView .curated-card-head strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
#screenerView .curated-card-head small { margin-top: 2px; color: var(--r2-faint); font-size: 9.5px; }
|
#screenerView .curated-card-head small { margin-top: 1px; color: var(--r2-faint); font-size: 9px; }
|
||||||
|
#screenerView .curated-card-result { padding: 2px 6px; border-radius: 4px; background: #eef1f5; color: var(--r2-faint); font-size: 9px; font-style: normal; white-space: nowrap; }
|
||||||
|
#screenerView .curated-card-result.ready { background: var(--scr-green-soft); color: var(--scr-green); }
|
||||||
|
#screenerView .curated-card-result.quiet { background: var(--scr-blue-soft); color: var(--scr-blue); }
|
||||||
|
#screenerView .curated-card-result.missing { background: var(--scr-amber-soft); color: var(--scr-amber); }
|
||||||
#screenerView .curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
#screenerView .curated-card-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
#screenerView .curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--scr-blue-soft); color: var(--scr-blue); font-size: 9px; font-style: normal; }
|
#screenerView .curated-card-tags em { padding: 2px 6px; border-radius: 4px; background: var(--scr-blue-soft); color: var(--scr-blue); font-size: 9px; font-style: normal; }
|
||||||
#screenerView .curated-card-tags em:nth-child(2) { background: #f3f4f6; color: var(--r2-sub); }
|
#screenerView .curated-card-tags em:nth-child(2) { background: #f3f4f6; color: var(--r2-sub); }
|
||||||
#screenerView .curated-card-tags em:nth-child(3) { background: var(--scr-amber-soft); color: var(--scr-amber); }
|
#screenerView .curated-card-tags em:nth-child(3) { background: var(--scr-amber-soft); color: var(--scr-amber); }
|
||||||
#screenerView .curated-card-description { flex: 1; color: var(--r2-sub); font-size: 10.5px; line-height: 1.65; }
|
|
||||||
#screenerView .curated-card-foot { min-height: 35px; display: flex; align-items: center; gap: 7px; padding-top: 7px; border-top: 1px solid var(--r2-line-soft); }
|
#screenerView .curated-detail-pane {
|
||||||
#screenerView .curated-card-foot small { color: var(--r2-faint); font-size: 9.5px; }
|
display: flex;
|
||||||
#screenerView .curated-card-actions { display: flex; gap: 5px; margin-left: auto; }
|
flex-direction: column;
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-detail-header { gap: 14px; padding-bottom: 11px; }
|
||||||
|
#screenerView .curated-detail-header h3 { font-size: 16px; }
|
||||||
|
#screenerView .curated-detail-header p { max-width: 650px; margin-top: 5px; font-size: 11px; line-height: 1.55; }
|
||||||
|
#screenerView .curated-strategy-badges span { min-height: 24px; padding: 0 7px; font-size: 10px; }
|
||||||
|
|
||||||
|
#screenerView .curated-environment-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--r2-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-environment-notes p {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--r2-sub);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-environment-notes strong { color: var(--scr-green); }
|
||||||
|
#screenerView .curated-environment-notes p:last-child strong { color: var(--scr-amber); }
|
||||||
|
|
||||||
|
#screenerView .curated-health-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 0 -16px;
|
||||||
|
border-bottom: 1px solid var(--r2-line-soft);
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .curated-health-grid > div { min-width: 0; padding: 8px 12px; border-right: 1px solid var(--r2-line-soft); }
|
||||||
|
#screenerView .curated-health-grid > div:last-child { border-right: 0; }
|
||||||
|
#screenerView .curated-health-grid span,
|
||||||
|
#screenerView .curated-health-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#screenerView .curated-health-grid span { color: var(--r2-faint); font-size: 9px; }
|
||||||
|
#screenerView .curated-health-grid strong { margin-top: 2px; color: var(--r2-ink); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
#screenerView .curated-health-grid strong.ready { color: var(--scr-green); }
|
||||||
|
#screenerView .curated-health-grid strong.quiet { color: var(--scr-blue); }
|
||||||
|
#screenerView .curated-health-grid strong.missing { color: var(--scr-amber); }
|
||||||
|
|
||||||
|
#screenerView .curated-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; padding: 12px 0; }
|
||||||
|
#screenerView .curated-condition-section { min-width: 0; }
|
||||||
|
#screenerView .curated-rule-list,
|
||||||
|
#screenerView .curated-score-list { margin-top: 6px; }
|
||||||
|
#screenerView .curated-rule-row { min-height: 34px; padding: 5px 4px; font-size: 10.5px; }
|
||||||
|
#screenerView .curated-rule-row span,
|
||||||
|
#screenerView .curated-rule-row strong { font-size: 10.5px; }
|
||||||
|
#screenerView .curated-score-row { min-height: 31px; grid-template-columns: minmax(90px, 1fr) minmax(80px, 1.25fr) 38px; gap: 7px; font-size: 10px; }
|
||||||
|
#screenerView .curated-execution-bar { min-height: 48px; margin: auto -16px 0; padding: 7px 12px; border-top: 1px solid var(--r2-line-soft); background: #fafbfc; }
|
||||||
|
#screenerView .curated-data-status > .lucide { width: 16px; height: 16px; }
|
||||||
|
#screenerView .curated-data-status strong { font-size: 11px; }
|
||||||
|
#screenerView .curated-data-status small { margin-top: 1px; font-size: 9.5px; }
|
||||||
|
|
||||||
/* Quant workspace. */
|
/* Quant workspace. */
|
||||||
#screenerView .quant-screener-panel {
|
#screenerView .quant-screener-panel {
|
||||||
@@ -5418,16 +5619,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
#screenerView .quant-validation-message { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; }
|
#screenerView .quant-validation-message { margin: 9px 14px 12px; color: var(--scr-green); font-size: 9.5px; }
|
||||||
#screenerView .quant-validation-message.error { color: var(--scr-amber); }
|
#screenerView .quant-validation-message.error { color: var(--scr-amber); }
|
||||||
|
|
||||||
#screenerView .curated-detail-dialog { width: min(850px, calc(100vw - 30px)); max-height: min(760px, calc(100dvh - 30px)); padding: 0; overflow: visible; border: 0; border-radius: 11px; background: transparent; }
|
|
||||||
#screenerView .curated-detail-dialog::backdrop,
|
|
||||||
#screenerView .strategy-drawer::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); }
|
#screenerView .strategy-drawer::backdrop { background: rgba(20, 29, 44, .38); backdrop-filter: blur(3px); }
|
||||||
#screenerView .curated-detail-pane { position: relative; max-height: min(760px, calc(100dvh - 30px)); overflow-y: auto; border-radius: 11px; background: #fff; }
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
#screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
#screenerView .screener-step small { max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
||||||
#screenerView .screener-overview-grid,
|
#screenerView .screener-overview-grid,
|
||||||
#screenerView .quant-screener-panel { grid-template-columns: 1fr; }
|
#screenerView .quant-screener-panel { grid-template-columns: 1fr; }
|
||||||
#screenerView .curated-strategy-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
#screenerView .curated-workspace { grid-template-columns: minmax(310px, .82fr) minmax(480px, 1.3fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
@@ -5443,9 +5641,10 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
#screenerView .screener-pipeline-status { margin-left: 0; }
|
#screenerView .screener-pipeline-status { margin-left: 0; }
|
||||||
#screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; }
|
#screenerView .screener-backtest-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||||
#screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; }
|
#screenerView .screener-backtest-strip > p { max-width: none; margin-left: 0; }
|
||||||
#screenerView .curated-strategy-list { grid-template-columns: 1fr 1fr; }
|
#screenerView .curated-workspace { grid-template-columns: 1fr; }
|
||||||
#screenerView .curated-library-controls { align-items: stretch; flex-direction: column; }
|
#screenerView .curated-strategy-list { min-height: 0; max-height: 310px; }
|
||||||
#screenerView .curated-search { width: 100%; }
|
#screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
#screenerView .curated-detail-pane { min-height: 430px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
@@ -5461,9 +5660,14 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
#screenerView .result-toolbar .section-subtitle { margin-left: 0; }
|
#screenerView .result-toolbar .section-subtitle { margin-left: 0; }
|
||||||
#screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); }
|
#screenerView .tracking-summary { grid-template-columns: repeat(2, 1fr); }
|
||||||
#screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); }
|
#screenerView .tracking-summary > div { border-bottom: 1px solid var(--r2-line-soft); }
|
||||||
#screenerView .curated-strategy-list { grid-template-columns: 1fr; }
|
|
||||||
#screenerView .curated-library-heading { align-items: flex-start; }
|
#screenerView .curated-library-heading { align-items: flex-start; }
|
||||||
#screenerView .curated-library-heading p { display: block; margin-top: 2px; }
|
#screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 104px 62px; }
|
||||||
|
#screenerView .curated-strategy-list.is-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
#screenerView .curated-health-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
#screenerView .curated-health-grid > div:nth-child(2) { border-right: 0; }
|
||||||
|
#screenerView .curated-health-grid > div:nth-child(-n + 2) { border-bottom: 1px solid var(--r2-line-soft); }
|
||||||
|
#screenerView .curated-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
#screenerView .curated-execution-bar { align-items: flex-start; flex-direction: column; }
|
||||||
#screenerView .quant-universe-grid,
|
#screenerView .quant-universe-grid,
|
||||||
#screenerView .quant-formula-summary { grid-template-columns: 1fr; }
|
#screenerView .quant-formula-summary { grid-template-columns: 1fr; }
|
||||||
#screenerView .quant-st-toggle { grid-column: auto; }
|
#screenerView .quant-st-toggle { grid-column: auto; }
|
||||||
@@ -7911,10 +8115,13 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
.settings-dialog:not(.heaven-reading-dialog) {
|
.settings-dialog:not(.heaven-reading-dialog) {
|
||||||
width: min(740px, calc(100vw - 28px));
|
width: min(740px, calc(100vw - 28px));
|
||||||
max-height: min(820px, calc(100dvh - 28px));
|
max-height: min(820px, calc(100dvh - 28px));
|
||||||
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }
|
||||||
.settings-dialog:not(.heaven-reading-dialog) .settings-section {
|
.settings-dialog:not(.heaven-reading-dialog) .settings-section {
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
border-color: var(--dialog-line);
|
border-color: var(--dialog-line);
|
||||||
@@ -8031,7 +8238,10 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
.admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; }
|
.admin-dialog .membership-form { grid-template-columns: 92px minmax(135px, .8fr) minmax(140px, 1fr) auto; }
|
||||||
|
|
||||||
/* Editing dialogs share sensible proportions without changing their fields. */
|
/* Editing dialogs share sensible proportions without changing their fields. */
|
||||||
.settings-dialog.trade-log-dialog { width: min(880px, calc(100vw - 28px)); }
|
.settings-dialog.trade-log-dialog {
|
||||||
|
width: min(880px, calc(100vw - 28px));
|
||||||
|
max-height: min(760px, calc(100dvh - 28px));
|
||||||
|
}
|
||||||
.trade-log-dialog .trade-log-form { padding: 18px 20px 20px; background: #fafbfc; }
|
.trade-log-dialog .trade-log-form { padding: 18px 20px 20px; background: #fafbfc; }
|
||||||
.trade-log-dialog .trade-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
.trade-log-dialog .trade-log-form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||||
.trade-log-dialog .trade-tags-field { grid-column: span 2; }
|
.trade-log-dialog .trade-tags-field { grid-column: span 2; }
|
||||||
@@ -8086,6 +8296,8 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
@media (max-width: 460px) {
|
||||||
|
#screenerView .curated-library-controls { grid-template-columns: minmax(0, 1fr) 62px; }
|
||||||
|
#screenerView .curated-search { grid-column: 1 / -1; }
|
||||||
.global-search-dialog { margin-top: 8px; }
|
.global-search-dialog { margin-top: 8px; }
|
||||||
.global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; }
|
.global-search-head { grid-template-columns: 20px minmax(0, 1fr) 32px; padding-left: 12px; }
|
||||||
.global-search-head kbd { display: none; }
|
.global-search-head kbd { display: none; }
|
||||||
@@ -8337,3 +8549,94 @@ body.sidebar-collapsed .status-bar { left: 64px; }
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Automatic screening and the manual custom-formula workspace. */
|
||||||
|
#screenerView .screener-auto-note {
|
||||||
|
color: var(--r2-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .regime-selector .regime-option {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .quant-screener-panel {
|
||||||
|
grid-template-columns: minmax(360px, .88fr) minmax(0, 1.12fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools,
|
||||||
|
#screenerView .custom-results-slot {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools {
|
||||||
|
min-height: 68px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--r2-line);
|
||||||
|
border-radius: var(--r2-radius);
|
||||||
|
background: var(--r2-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools span,
|
||||||
|
#screenerView .custom-screener-tools small {
|
||||||
|
color: var(--r2-faint);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools strong {
|
||||||
|
color: var(--r2-ink);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-screener-tools > div:last-child {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .custom-results-slot .screener-results-view {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .screener-result-frame {
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .screener-result-frame .data-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1180px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#screenerView .screener-result-columns col:nth-child(1) { width: 54px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(2) { width: 122px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(3) { width: 104px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(4) { width: 78px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(5) { width: 104px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(6),
|
||||||
|
#screenerView .screener-result-columns col:nth-child(7),
|
||||||
|
#screenerView .screener-result-columns col:nth-child(8),
|
||||||
|
#screenerView .screener-result-columns col:nth-child(9) { width: 82px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(10) { width: 210px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(11) { width: 150px; }
|
||||||
|
#screenerView .screener-result-columns col:nth-child(12) { width: 142px; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
#screenerView .quant-screener-panel { grid-template-columns: 1fr; }
|
||||||
|
#screenerView .custom-screener-tools { align-items: stretch; flex-direction: column; }
|
||||||
|
#screenerView .custom-screener-tools > div:last-child { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|||||||
+16
-2
@@ -3983,20 +3983,29 @@ dialog::backdrop {
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
#toast.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
right: 18px;
|
right: 18px;
|
||||||
bottom: 48px;
|
bottom: 48px;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
|
width: max-content;
|
||||||
|
height: auto;
|
||||||
max-width: min(420px, calc(100vw - 36px));
|
max-width: min(420px, calc(100vw - 36px));
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #21313c;
|
background: #21313c;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: none;
|
||||||
animation: toast-enter var(--motion-medium) var(--ease-out) both;
|
animation: toast-enter var(--motion-medium) var(--ease-out) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#toast.toast[hidden] { display: none; }
|
||||||
|
|
||||||
@keyframes toast-enter {
|
@keyframes toast-enter {
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -5147,10 +5156,15 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
#toast.toast {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 76px;
|
bottom: 76px;
|
||||||
|
width: max-content;
|
||||||
|
height: auto;
|
||||||
max-width: calc(100vw - 20px);
|
max-width: calc(100vw - 20px);
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
-2
@@ -568,6 +568,54 @@
|
|||||||
background: var(--action-soft);
|
background: var(--action-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-detail-pane {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(.curated-detail-header, .curated-execution-bar) {
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(
|
||||||
|
.curated-detail-header h3,
|
||||||
|
.mini-section-heading h4,
|
||||||
|
.curated-rule-row strong,
|
||||||
|
.curated-score-row strong,
|
||||||
|
.curated-data-status strong
|
||||||
|
) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(
|
||||||
|
.curated-detail-header > div > span,
|
||||||
|
.curated-detail-header p,
|
||||||
|
.mini-section-heading > span,
|
||||||
|
.curated-rule-row span,
|
||||||
|
.curated-score-row > span:first-child,
|
||||||
|
.curated-data-status small
|
||||||
|
) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-rule-row {
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-score-track {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-strategy-badges span {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-strategy-badges span:first-child {
|
||||||
|
background: var(--action-soft);
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) {
|
:root[data-theme="dark"] :is(.strategy-drawer-sidebar, .strategy-sidebar, .strategy-drawer-content) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
@@ -1030,10 +1078,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #screenerView :is(
|
:root[data-theme="dark"] #screenerView :is(
|
||||||
|
.curated-library-pane,
|
||||||
.curated-library-heading,
|
.curated-library-heading,
|
||||||
.curated-library-controls,
|
.curated-library-controls,
|
||||||
.curated-search,
|
.curated-search,
|
||||||
.curated-category-filters button,
|
.curated-category-select select,
|
||||||
.curated-strategy-card,
|
.curated-strategy-card,
|
||||||
.curated-strategy-rank,
|
.curated-strategy-rank,
|
||||||
.curated-card-tags em,
|
.curated-card-tags em,
|
||||||
@@ -1052,7 +1101,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #screenerView :is(
|
:root[data-theme="dark"] #screenerView :is(
|
||||||
.curated-category-filters button.active,
|
|
||||||
.curated-strategy-card.active,
|
.curated-strategy-card.active,
|
||||||
.curated-card-tags em,
|
.curated-card-tags em,
|
||||||
.curated-strategy-rank,
|
.curated-strategy-rank,
|
||||||
@@ -1061,6 +1109,30 @@
|
|||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-strategy-card:hover {
|
||||||
|
border-color: var(--blue-line);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-strategy-card.active {
|
||||||
|
border-color: var(--blue-line);
|
||||||
|
background: var(--action-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(.curated-health-grid, .curated-execution-bar) {
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-health-grid > div {
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-search input {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #screenerView :is(
|
:root[data-theme="dark"] #screenerView :is(
|
||||||
.quant-rule-row select,
|
.quant-rule-row select,
|
||||||
.quant-rule-row input,
|
.quant-rule-row input,
|
||||||
@@ -1257,6 +1329,44 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* High-specificity dark surfaces for workspaces with later light-theme hover rules. */
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-library-heading h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle, .curated-school-filters) {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView :is(.curated-view-toggle button, .curated-school-filters button):is(:hover, .active) {
|
||||||
|
border-color: var(--blue-line);
|
||||||
|
background: var(--action-soft);
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .curated-strategy-list.is-grid .curated-strategy-icon {
|
||||||
|
background: var(--action-soft);
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td,
|
||||||
|
:root[data-theme="dark"] #screenerView .screener-result-frame tbody tr:hover td:last-child,
|
||||||
|
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover,
|
||||||
|
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody tr:hover td {
|
||||||
|
background: var(--action-soft) !important;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] #reviewWorkspaceView .data-table tbody td {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former
|
/* Wentian v2 owns its complete palette in wentian-v2.css. Keeping the former
|
||||||
paper-theme overrides here would repaint its controls and ritual stages. */
|
paper-theme overrides here would repaint its controls and ritual stages. */
|
||||||
|
|
||||||
|
|||||||
@@ -614,8 +614,6 @@ button { cursor: pointer; }
|
|||||||
.login-dialog form { display: grid; gap: 14px; padding: 26px; }
|
.login-dialog form { display: grid; gap: 14px; padding: 26px; }
|
||||||
.login-dialog h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; }
|
.login-dialog h2 { margin: 0 0 6px; color: var(--wt-paper); font-size: 24px; }
|
||||||
.login-dialog .button { width: 100%; margin-top: 4px; }
|
.login-dialog .button { width: 100%; margin-top: 4px; }
|
||||||
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 100; padding: 10px 14px; border: 1px solid var(--wt-line); border-radius: 7px; background: rgba(13,21,38,.96); color: var(--wt-paper); box-shadow: 0 12px 30px rgba(0,0,0,.3); font-size: 12px; }
|
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.heaven-shell { width: min(100% - 20px, 760px); }
|
.heaven-shell { width: min(100% - 20px, 760px); }
|
||||||
.heaven-controls { align-items: stretch; flex-direction: column; }
|
.heaven-controls { align-items: stretch; flex-direction: column; }
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class StrategyTrackingService:
|
|||||||
|
|
||||||
def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]:
|
def add_candidate(self, user_id: int, run_id: int, code: str) -> dict[str, Any]:
|
||||||
run = self.database.get_screener_run(user_id, run_id)
|
run = self.database.get_screener_run(user_id, run_id)
|
||||||
|
if not run:
|
||||||
|
run = self.database.get_screener_run(0, run_id)
|
||||||
if not run:
|
if not run:
|
||||||
raise ValueError("选股结果不存在或不属于当前账号。")
|
raise ValueError("选股结果不存在或不属于当前账号。")
|
||||||
normalized_code = str(code or "").strip().split(".")[0]
|
normalized_code = str(code or "").strip().split(".")[0]
|
||||||
|
|||||||
+53
-77
@@ -1433,7 +1433,8 @@ test("regular account cannot see admin controls and member features are gated",
|
|||||||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
||||||
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
await page.locator('[data-screener-mode="quant"]').click();
|
||||||
|
await expect(page.locator("#quantRunButton")).toBeDisabled();
|
||||||
await page.locator("#assistantButton").click();
|
await page.locator("#assistantButton").click();
|
||||||
await expect(page.locator("#settingsDialog")).toBeHidden();
|
await expect(page.locator("#settingsDialog")).toBeHidden();
|
||||||
await expect(page.locator("#assistantDialog")).toBeVisible();
|
await expect(page.locator("#assistantDialog")).toBeVisible();
|
||||||
@@ -1817,7 +1818,8 @@ test("curated strategies and quant builder form independent screener workspaces"
|
|||||||
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
|
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(1);
|
||||||
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
|
await expect(page.locator("#curatedStrategyName")).toHaveText("连续分红质量");
|
||||||
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1);
|
await expect(page.locator("#curatedFilterList .curated-rule-row")).toHaveCount(1);
|
||||||
await expect(page.locator("#curatedRunButton")).toBeEnabled();
|
await expect(page.locator("#curatedRunButton")).toHaveCount(0);
|
||||||
|
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||||||
|
|
||||||
await page.locator('[data-screener-mode="quant"]').click();
|
await page.locator('[data-screener-mode="quant"]').click();
|
||||||
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
|
await expect(page.locator('[data-screener-panel="curated"]')).toBeHidden();
|
||||||
@@ -1958,32 +1960,35 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
|
|||||||
});
|
});
|
||||||
await page.locator('[data-screener-mode="curated"]').click();
|
await page.locator('[data-screener-mode="curated"]').click();
|
||||||
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
|
await expect(page.locator("#curatedStrategyList .curated-strategy-card")).toHaveCount(2);
|
||||||
await expect(page.locator("#curatedDetailDialog")).toBeHidden();
|
await expect(page.locator(".curated-detail-pane")).toBeVisible();
|
||||||
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
|
const secondStrategy = page.locator('#curatedStrategyList [data-curated-strategy="4"]');
|
||||||
await secondStrategy.locator(".curated-card-description").click();
|
await secondStrategy.click();
|
||||||
await expect(secondStrategy).toHaveClass(/active/);
|
await expect(secondStrategy).toHaveClass(/active/);
|
||||||
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
|
await expect(page.locator('#curatedStrategyList [data-curated-strategy="2"]')).not.toHaveClass(/active/);
|
||||||
await secondStrategy.locator('[data-curated-inspect="4"]').click();
|
await expect(page.locator("#curatedStrategyName")).toHaveText("低波质量");
|
||||||
await expect(page.locator("#curatedDetailDialog")).toBeVisible();
|
const [libraryBox, detailBox] = await Promise.all([
|
||||||
const dialogBox = await page.locator("#curatedDetailDialog").boundingBox();
|
page.locator(".curated-library-pane").boundingBox(),
|
||||||
expect(Math.abs(dialogBox.x + dialogBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
page.locator(".curated-detail-pane").boundingBox(),
|
||||||
expect(Math.abs(dialogBox.y + dialogBox.height / 2 - 450)).toBeLessThanOrEqual(2);
|
]);
|
||||||
await page.locator("#closeCuratedDetailButton").click();
|
expect(detailBox.x).toBeGreaterThan(libraryBox.x + libraryBox.width - 2);
|
||||||
|
expect(Math.abs(detailBox.y - libraryBox.y)).toBeLessThanOrEqual(1);
|
||||||
|
expect(libraryBox.width).toBeLessThan(detailBox.width);
|
||||||
|
await expect(page.locator("#curatedHealthMetrics > div")).toHaveCount(4);
|
||||||
await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
|
await page.screenshot({ path: "test-results/screener-stage15-strategy-1440.png", fullPage: true });
|
||||||
|
|
||||||
await page.locator('[data-screener-mode="quant"]').click();
|
await page.locator('[data-screener-mode="quant"]').click();
|
||||||
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
|
await expect(page.locator("#quantScoreRows .quant-score-row")).toHaveCount(5);
|
||||||
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
|
await expect(page.locator("#screenerView .quant-intro-band")).toHaveCount(0);
|
||||||
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("执行设置", { exact: true })).toHaveCount(0);
|
||||||
await expect(page.locator("#screenerResultTitle")).toHaveText("打分结果");
|
await expect(page.locator("#screenerResultTitle")).toHaveText("自定义选股结果");
|
||||||
const [builderBox, summaryBox] = await Promise.all([
|
const [builderBox, summaryBox] = await Promise.all([
|
||||||
page.locator(".quant-builder-pane").boundingBox(),
|
page.locator(".quant-builder-pane").boundingBox(),
|
||||||
page.locator(".quant-summary-pane").boundingBox(),
|
page.locator(".quant-summary-pane").boundingBox(),
|
||||||
]);
|
]);
|
||||||
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
|
expect(Math.abs(builderBox.y - summaryBox.y)).toBeLessThanOrEqual(1);
|
||||||
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
|
expect(summaryBox.x).toBeGreaterThan(builderBox.x + builderBox.width - 2);
|
||||||
expect(builderBox.width).toBeGreaterThanOrEqual(395);
|
expect(builderBox.width).toBeGreaterThanOrEqual(490);
|
||||||
expect(builderBox.width).toBeLessThanOrEqual(405);
|
expect(builderBox.width).toBeLessThanOrEqual(540);
|
||||||
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
|
const quantRunBox = await page.locator("#quantRunButton").boundingBox();
|
||||||
expect(quantRunBox.width).toBeLessThan(180);
|
expect(quantRunBox.width).toBeLessThan(180);
|
||||||
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
|
expect((await page.locator("#quantFilterRows .quant-filter-row select").first().boundingBox()).width).toBeLessThanOrEqual(225);
|
||||||
@@ -1996,7 +2001,7 @@ test("screener redesign preserves three clear workspaces across desktop and mobi
|
|||||||
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
|
await expect(page.locator("#screenerView .screener-mode-tabs")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("screener stage completion follows its execution context and mode results stay isolated", async ({ page }) => {
|
test("automatic screener results stay read-only and mode results stay isolated", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
await mockApplication(page, session("user", true));
|
await mockApplication(page, session("user", true));
|
||||||
await page.goto("/index.html");
|
await page.goto("/index.html");
|
||||||
@@ -2024,15 +2029,11 @@ test("screener stage completion follows its execution context and mode results s
|
|||||||
|
|
||||||
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
|
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(3);
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||||||
await page.locator('[data-regime="retreat"]').click();
|
await expect(page.locator('[data-regime]')).toHaveCount(0);
|
||||||
await expect(page.locator('#screenerView .screener-step[data-state="complete"]')).toHaveCount(2);
|
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||||||
await expect(page.locator("#screenerRunStatus")).toHaveText("等待执行");
|
await expect(page.locator("#syncScreenerButton")).toHaveCount(0);
|
||||||
await expect(page.locator("#backtestTaskStatus")).toHaveText("随选股执行");
|
await expect(page.locator("#changeStrategyButton")).toHaveCount(0);
|
||||||
await expect(page.locator("#screenerEmpty")).toContainText("当前阶段与策略");
|
await expect(page.locator("#editStrategyButton")).toHaveCount(0);
|
||||||
await expect(page.locator("#screenerResultSource")).toBeHidden();
|
|
||||||
|
|
||||||
await page.locator('[data-regime="repair"]').click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
|
||||||
await page.locator('[data-screener-mode="curated"]').click();
|
await page.locator('[data-screener-mode="curated"]').click();
|
||||||
await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
|
await expect(page.locator("#screenerEmpty")).toContainText("所选策略");
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -2044,10 +2045,10 @@ test("screener stage completion follows its execution context and mode results s
|
|||||||
});
|
});
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
|
await expect(page.locator("#screenerTableBody")).toContainText("策略结果");
|
||||||
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
|
await expect(page.locator("#screenerTableBody")).not.toContainText("阶段结果");
|
||||||
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 修复 · 连续分红质量");
|
await expect(page.locator("#screenerResultSource")).toHaveText("策略选股 · 连续分红质量");
|
||||||
|
|
||||||
await page.locator('[data-screener-mode="quant"]').click();
|
await page.locator('[data-screener-mode="quant"]').click();
|
||||||
await expect(page.locator("#screenerEmpty")).toContainText("量化选股");
|
await expect(page.locator("#screenerEmpty")).toContainText("自定义选股");
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
setScreenerResult("quant", {
|
setScreenerResult("quant", {
|
||||||
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
|
meta: { run_id: 53, trade_date: "20260722", regime: "repair", strategy_name: "自定义量化公式" },
|
||||||
@@ -2056,7 +2057,7 @@ test("screener stage completion follows its execution context and mode results s
|
|||||||
renderScreenerResult();
|
renderScreenerResult();
|
||||||
});
|
});
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
|
await expect(page.locator("#screenerTableBody")).toContainText("量化结果");
|
||||||
await expect(page.locator("#screenerResultSource")).toHaveText("量化选股 · 自定义因子权重");
|
await expect(page.locator("#screenerResultSource")).toHaveText("自定义选股 · 自定义因子权重");
|
||||||
|
|
||||||
await page.locator('[data-screener-mode="smart"]').click();
|
await page.locator('[data-screener-mode="smart"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
await expect(page.locator("#screenerTableBody")).toContainText("阶段结果");
|
||||||
@@ -2064,20 +2065,34 @@ test("screener stage completion follows its execution context and mode results s
|
|||||||
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
|
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => {
|
test("screener restores automatic stage and curated pools across switching and reload", async ({ page }) => {
|
||||||
const formula = {
|
const formula = {
|
||||||
meta: { library: "smart" }, universe: {}, filters: [],
|
meta: { library: "smart" }, universe: {}, filters: [],
|
||||||
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
|
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
|
||||||
limit: 10, min_score: 0.5,
|
limit: 10, min_score: 0.5,
|
||||||
};
|
};
|
||||||
|
const candidate = (code, name) => ({
|
||||||
|
code, name, sector: "Test Sector", score_display: 80,
|
||||||
|
historical_probability: 50, probability_samples: 20, pct_chg: 1,
|
||||||
|
return_5d: 2, volume_ratio_5d: 1.2, sector_strength: 70,
|
||||||
|
reason: "Context result", risk_flags: [],
|
||||||
|
});
|
||||||
|
const result = (mode, runId, strategyName, row) => ({
|
||||||
|
meta: {
|
||||||
|
run_id: runId, trade_date: "20260722", regime: "repair",
|
||||||
|
strategy_name: strategyName, mode,
|
||||||
|
},
|
||||||
|
candidates: [row],
|
||||||
|
disclaimer: "Historical statistics do not predict future returns.",
|
||||||
|
backtest: null,
|
||||||
|
});
|
||||||
|
const smartResult = result("smart", 101, "修复确认", candidate("600001", "Smart Repair"));
|
||||||
|
const curatedA = result("curated", 102, "连续分红质量", candidate("600002", "Curated A"));
|
||||||
|
const curatedB = result("curated", 103, "Quality B", candidate("600003", "Curated B"));
|
||||||
const options = {
|
const options = {
|
||||||
recentScreenerResults: [],
|
latestScreenerResults: { smart: smartResult, curated: curatedA },
|
||||||
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }],
|
recentScreenerResults: [smartResult, curatedA, curatedB],
|
||||||
additionalScreenerStrategies: [
|
additionalScreenerStrategies: [
|
||||||
{
|
|
||||||
id: 3, name: "Retreat Defense", description: "Retreat-stage strategy",
|
|
||||||
regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 4, name: "Quality B", description: "Second curated strategy",
|
id: 4, name: "Quality B", description: "Second curated strategy",
|
||||||
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
|
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
|
||||||
@@ -2088,66 +2103,27 @@ test("screener keeps results for each stage and curated strategy across switchin
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
options.screenerRunResult = (body) => {
|
|
||||||
const candidateName = body.mode === "smart"
|
|
||||||
? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair"
|
|
||||||
: body.strategy_name === "Quality B" ? "Curated B" : "Curated A";
|
|
||||||
return {
|
|
||||||
meta: {
|
|
||||||
run_id: 100 + options.recentScreenerResults.length,
|
|
||||||
trade_date: "20260722",
|
|
||||||
regime: body.regime,
|
|
||||||
strategy_name: body.strategy_name,
|
|
||||||
mode: body.mode,
|
|
||||||
},
|
|
||||||
candidates: [{
|
|
||||||
code: `60000${options.recentScreenerResults.length + 1}`,
|
|
||||||
name: candidateName,
|
|
||||||
sector: "Test Sector",
|
|
||||||
score_display: 80,
|
|
||||||
historical_probability: 50,
|
|
||||||
probability_samples: 20,
|
|
||||||
pct_chg: 1,
|
|
||||||
return_5d: 2,
|
|
||||||
volume_ratio_5d: 1.2,
|
|
||||||
sector_strength: 70,
|
|
||||||
reason: "Context result",
|
|
||||||
risk_flags: [],
|
|
||||||
}],
|
|
||||||
disclaimer: "Historical statistics do not predict future returns.",
|
|
||||||
backtest: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockApplication(page, session("user", true), options);
|
await mockApplication(page, session("user", true), options);
|
||||||
await page.goto("/index.html?view=screenerView");
|
await page.goto("/index.html?view=screenerView");
|
||||||
|
|
||||||
await page.locator("#screenerRunButton").click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||||||
await page.locator('[data-regime="retreat"]').click();
|
await expect(page.locator("#screenerRunButton")).toHaveCount(0);
|
||||||
await page.locator("#screenerRunButton").click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
|
||||||
await page.locator('[data-regime="repair"]').click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
|
||||||
await page.locator('[data-regime="retreat"]').click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
|
||||||
|
|
||||||
await page.locator('[data-screener-mode="curated"]').click();
|
await page.locator('[data-screener-mode="curated"]').click();
|
||||||
await page.locator('[data-curated-run="2"]').click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||||
await page.locator('[data-curated-run="4"]').click();
|
await page.locator('[data-curated-strategy="4"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||||||
await page.locator('[data-curated-strategy="2"] .curated-card-description').click();
|
await page.locator('[data-curated-strategy="2"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||||
|
expect(options.screenerRunBodies || []).toHaveLength(0);
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.locator('[data-screener-mode="smart"]').click();
|
await page.locator('[data-screener-mode="smart"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
|
||||||
await page.locator('[data-regime="retreat"]').click();
|
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
|
|
||||||
await page.locator('[data-screener-mode="curated"]').click();
|
await page.locator('[data-screener-mode="curated"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
|
||||||
await page.locator('[data-curated-strategy="4"] .curated-card-description').click();
|
await page.locator('[data-curated-strategy="4"]').click();
|
||||||
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,78 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from screener import (
|
from screener import (
|
||||||
|
ADVANCED_CURATED_STRATEGIES,
|
||||||
CURATED_STRATEGIES,
|
CURATED_STRATEGIES,
|
||||||
FACTOR_FIELDS,
|
FACTOR_FIELDS,
|
||||||
FACTOR_GROUPS,
|
FACTOR_GROUPS,
|
||||||
ScreenerEngine,
|
ScreenerEngine,
|
||||||
|
_broken_reversal_metrics,
|
||||||
|
_risk_flags,
|
||||||
|
_rsi,
|
||||||
_quarter_periods,
|
_quarter_periods,
|
||||||
)
|
)
|
||||||
|
from server import automatic_screener_jobs
|
||||||
|
|
||||||
|
|
||||||
class CuratedScreenerTests(unittest.TestCase):
|
class CuratedScreenerTests(unittest.TestCase):
|
||||||
def test_first_batch_contains_ten_distinct_curated_strategies(self):
|
def test_curated_library_contains_original_and_advanced_strategies(self):
|
||||||
self.assertEqual(10, len(CURATED_STRATEGIES))
|
self.assertEqual(13, len(ADVANCED_CURATED_STRATEGIES))
|
||||||
self.assertEqual(10, len({item["name"] for item in CURATED_STRATEGIES}))
|
self.assertEqual(23, len(CURATED_STRATEGIES))
|
||||||
|
self.assertEqual(23, len({item["name"] for item in CURATED_STRATEGIES}))
|
||||||
|
self.assertTrue(
|
||||||
|
{"行业动量轮动", "主力资金行业流入"}.issubset(
|
||||||
|
{item["name"] for item in CURATED_STRATEGIES}
|
||||||
|
)
|
||||||
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
|
all(item["formula"]["meta"]["library"] == "curated" for item in CURATED_STRATEGIES)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_every_curated_strategy_explains_environment_and_failure_risk(self):
|
||||||
|
for strategy in CURATED_STRATEGIES:
|
||||||
|
meta = strategy["formula"]["meta"]
|
||||||
|
self.assertTrue(meta.get("suitable_environment"), strategy["name"])
|
||||||
|
self.assertTrue(meta.get("failure_risk"), strategy["name"])
|
||||||
|
self.assertNotIn("emotion_gate", meta, strategy["name"])
|
||||||
|
|
||||||
|
def test_automatic_curated_jobs_are_not_filtered_by_market_regime(self):
|
||||||
|
strategies = [
|
||||||
|
{
|
||||||
|
"name": "阶段策略",
|
||||||
|
"regimes": ["retreat"],
|
||||||
|
"formula": {"meta": {"library": "stage"}},
|
||||||
|
},
|
||||||
|
*CURATED_STRATEGIES,
|
||||||
|
]
|
||||||
|
for regime in ("ice", "repair", "fermentation", "climax", "divergence", "retreat"):
|
||||||
|
jobs = automatic_screener_jobs(strategies, regime)
|
||||||
|
curated_names = {
|
||||||
|
job["strategy"]["name"] for job in jobs if job["mode"] == "curated"
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
{strategy["name"] for strategy in CURATED_STRATEGIES},
|
||||||
|
curated_names,
|
||||||
|
regime,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_curated_risk_flags_do_not_reintroduce_regime_gating(self):
|
||||||
|
row = {
|
||||||
|
"pct_chg": 0,
|
||||||
|
"return_10d": 0,
|
||||||
|
"volatility_10d": 0,
|
||||||
|
"amount_billion": 5,
|
||||||
|
}
|
||||||
|
self.assertIn("市场处于退潮阶段,策略可能选择空仓", _risk_flags(row, "retreat"))
|
||||||
|
self.assertNotIn(
|
||||||
|
"市场处于退潮阶段,策略可能选择空仓",
|
||||||
|
_risk_flags(row, "retreat", include_regime_risk=False),
|
||||||
|
)
|
||||||
|
|
||||||
def test_every_curated_formula_uses_supported_factors(self):
|
def test_every_curated_formula_uses_supported_factors(self):
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
database = ReviewDatabase(Path(root) / "review.db")
|
database = ReviewDatabase(Path(root) / "review.db")
|
||||||
@@ -56,6 +108,15 @@ class CuratedScreenerTests(unittest.TestCase):
|
|||||||
connection.close()
|
connection.close()
|
||||||
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
|
self.assertTrue({"pe_ttm", "pb", "ps_ttm", "dv_ttm"}.issubset(indicator_columns))
|
||||||
self.assertIn("fundamental_indicators", tables)
|
self.assertIn("fundamental_indicators", tables)
|
||||||
|
self.assertIn("benchmark_bars", tables)
|
||||||
|
|
||||||
|
def test_advanced_strategies_declare_history_and_backtest_contracts(self):
|
||||||
|
for strategy in ADVANCED_CURATED_STRATEGIES:
|
||||||
|
meta = strategy["formula"]["meta"]
|
||||||
|
self.assertGreaterEqual(meta["history_days"], 80, strategy["name"])
|
||||||
|
self.assertGreaterEqual(meta["backtest_days"], 1, strategy["name"])
|
||||||
|
self.assertGreater(meta["take_profit"], 0, strategy["name"])
|
||||||
|
self.assertLess(meta["stop_loss"], 0, strategy["name"])
|
||||||
|
|
||||||
def test_quarter_periods_stop_at_selected_date(self):
|
def test_quarter_periods_stop_at_selected_date(self):
|
||||||
periods = _quarter_periods("20260722", 5)
|
periods = _quarter_periods("20260722", 5)
|
||||||
@@ -98,6 +159,10 @@ class CuratedScreenerTests(unittest.TestCase):
|
|||||||
for index in range(100)
|
for index in range(100)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
connection.executemany(
|
||||||
|
"INSERT INTO benchmark_bars (trade_date, ts_code, close) VALUES (?, ?, ?)",
|
||||||
|
[(f"2026{index + 1:04d}", "000300.SH", 4000 + index) for index in range(60)],
|
||||||
|
)
|
||||||
|
|
||||||
health = database.factor_health_summary("20260722")
|
health = database.factor_health_summary("20260722")
|
||||||
self.assertTrue(health["market"])
|
self.assertTrue(health["market"])
|
||||||
@@ -105,10 +170,190 @@ class CuratedScreenerTests(unittest.TestCase):
|
|||||||
self.assertTrue(health["valuation"])
|
self.assertTrue(health["valuation"])
|
||||||
self.assertTrue(health["fundamental"])
|
self.assertTrue(health["fundamental"])
|
||||||
self.assertTrue(health["dividend_history"])
|
self.assertTrue(health["dividend_history"])
|
||||||
|
self.assertTrue(health["benchmark"])
|
||||||
self.assertEqual(health["valuation_rows"], 1)
|
self.assertEqual(health["valuation_rows"], 1)
|
||||||
self.assertEqual(health["fundamental_rows"], 100)
|
self.assertEqual(health["fundamental_rows"], 100)
|
||||||
self.assertEqual(health["dividend_years"], 5)
|
self.assertEqual(health["dividend_years"], 5)
|
||||||
|
|
||||||
|
def test_moneyflow_health_requires_the_latest_five_market_dates(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
database = ReviewDatabase(Path(root) / "review.db")
|
||||||
|
dates = [f"202607{day:02d}" for day in range(20, 25)]
|
||||||
|
database.upsert_daily_bars([
|
||||||
|
{
|
||||||
|
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||||
|
"open": 10, "high": 10.2, "low": 9.8, "close": 10,
|
||||||
|
"pct_chg": 0, "vol": 1000, "amount": 100000,
|
||||||
|
}
|
||||||
|
for trade_date in dates
|
||||||
|
])
|
||||||
|
database.upsert_moneyflow([
|
||||||
|
{"trade_date": "20260105", "ts_code": "600000.SH", "net_mf_amount": 10}
|
||||||
|
] * 5)
|
||||||
|
self.assertFalse(database.factor_health_summary(dates[-1])["moneyflow_history"])
|
||||||
|
database.upsert_moneyflow([
|
||||||
|
{"trade_date": trade_date, "ts_code": "600000.SH", "net_mf_amount": 10}
|
||||||
|
for trade_date in dates
|
||||||
|
])
|
||||||
|
health = database.factor_health_summary(dates[-1])
|
||||||
|
self.assertTrue(health["moneyflow_history"])
|
||||||
|
self.assertEqual(health["moneyflow_dates"], 5)
|
||||||
|
|
||||||
|
def test_technical_helpers_detect_rsi_and_daily_reversal_path(self):
|
||||||
|
self.assertLess(_rsi([10, 9, 8, 7, 6, 5, 4], 6), 1)
|
||||||
|
rows = [
|
||||||
|
{"close": 10, "high": 10, "vol": 100},
|
||||||
|
{"close": 11, "high": 11, "vol": 120},
|
||||||
|
{"close": 12, "high": 12, "vol": 130},
|
||||||
|
{"close": 11.2, "high": 11.8, "vol": 100},
|
||||||
|
{"close": 12.5, "high": 12.5, "vol": 140},
|
||||||
|
]
|
||||||
|
metrics = _broken_reversal_metrics(
|
||||||
|
rows, [False, True, True, False, True], "600000", "示例"
|
||||||
|
)
|
||||||
|
self.assertEqual(metrics["signal"], 1)
|
||||||
|
self.assertEqual(metrics["days"], 1)
|
||||||
|
|
||||||
|
def test_factor_builder_generates_long_window_and_benchmark_factors(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
database = ReviewDatabase(Path(root) / "review.db")
|
||||||
|
database.upsert_stock_master([
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH", "name": "趋势样本", "industry": "银行",
|
||||||
|
"market": "主板", "list_date": "20000101",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
dates = []
|
||||||
|
cursor = datetime(2025, 6, 1)
|
||||||
|
while len(dates) < 260:
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
dates.append(cursor.strftime("%Y%m%d"))
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
bars = []
|
||||||
|
benchmarks = []
|
||||||
|
indicators = []
|
||||||
|
for index, trade_date in enumerate(dates):
|
||||||
|
close = 10 + index * 0.05
|
||||||
|
bars.append({
|
||||||
|
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||||
|
"open": close - 0.02, "high": close + 0.08, "low": close - 0.08,
|
||||||
|
"close": close, "pct_chg": 0.25, "vol": 1000 + index,
|
||||||
|
"amount": 200000,
|
||||||
|
})
|
||||||
|
benchmarks.append({
|
||||||
|
"trade_date": trade_date, "ts_code": "000300.SH",
|
||||||
|
"close": 4000 + index, "pct_chg": 0.02,
|
||||||
|
})
|
||||||
|
if index >= 250:
|
||||||
|
indicators.append({
|
||||||
|
"trade_date": trade_date, "ts_code": "600000.SH",
|
||||||
|
"turnover_rate": 2, "volume_ratio": 1,
|
||||||
|
})
|
||||||
|
database.upsert_daily_bars(bars)
|
||||||
|
database.upsert_benchmark_bars(benchmarks)
|
||||||
|
database.upsert_daily_indicators(indicators)
|
||||||
|
|
||||||
|
factors, actual_date = ScreenerEngine(database).build_factors(
|
||||||
|
dates[-1], history_days=260
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(actual_date, dates[-1])
|
||||||
|
self.assertEqual(len(factors), 1)
|
||||||
|
factor = factors[0]
|
||||||
|
self.assertEqual(factor["ma_bull_alignment"], 1)
|
||||||
|
self.assertEqual(factor["rs_high_120"], 1)
|
||||||
|
self.assertGreater(factor["momentum_60_5"], 0)
|
||||||
|
self.assertEqual(factor["momentum_60_5_rank"], 0)
|
||||||
|
|
||||||
|
def test_factor_builder_generates_sector_momentum_and_five_day_flow(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
database = ReviewDatabase(Path(root) / "review.db")
|
||||||
|
stocks = [
|
||||||
|
("600001.SH", "动量样本", "电子", 0.16, 180),
|
||||||
|
("600002.SH", "对照样本", "银行", 0.02, -40),
|
||||||
|
]
|
||||||
|
database.upsert_stock_master([
|
||||||
|
{
|
||||||
|
"ts_code": code, "name": name, "industry": industry,
|
||||||
|
"market": "主板", "list_date": "20000101",
|
||||||
|
}
|
||||||
|
for code, name, industry, _, _ in stocks
|
||||||
|
])
|
||||||
|
dates = []
|
||||||
|
cursor = datetime(2026, 4, 1)
|
||||||
|
while len(dates) < 80:
|
||||||
|
if cursor.weekday() < 5:
|
||||||
|
dates.append(cursor.strftime("%Y%m%d"))
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
bars = []
|
||||||
|
for index, trade_date in enumerate(dates):
|
||||||
|
for code, _, _, slope, _ in stocks:
|
||||||
|
close = 10 + index * slope
|
||||||
|
bars.append({
|
||||||
|
"trade_date": trade_date, "ts_code": code,
|
||||||
|
"open": close - 0.03, "high": close + 0.08,
|
||||||
|
"low": close - 0.08, "close": close,
|
||||||
|
"pct_chg": slope, "vol": 1000 + index,
|
||||||
|
"amount": 300000,
|
||||||
|
})
|
||||||
|
database.upsert_daily_bars(bars)
|
||||||
|
database.upsert_daily_indicators([
|
||||||
|
{
|
||||||
|
"trade_date": dates[-1], "ts_code": code,
|
||||||
|
"turnover_rate": 2, "volume_ratio": 1,
|
||||||
|
"circ_mv": 1000000, "total_mv": 1500000,
|
||||||
|
}
|
||||||
|
for code, *_ in stocks
|
||||||
|
])
|
||||||
|
database.upsert_moneyflow([
|
||||||
|
{
|
||||||
|
"trade_date": trade_date, "ts_code": code,
|
||||||
|
"net_mf_amount": daily_flow,
|
||||||
|
}
|
||||||
|
for trade_date in dates[-5:]
|
||||||
|
for code, _, _, _, daily_flow in stocks
|
||||||
|
])
|
||||||
|
|
||||||
|
factors, _ = ScreenerEngine(database).build_factors(
|
||||||
|
dates[-1], history_days=80
|
||||||
|
)
|
||||||
|
by_code = {item["ts_code"]: item for item in factors}
|
||||||
|
leader = by_code["600001.SH"]
|
||||||
|
laggard = by_code["600002.SH"]
|
||||||
|
self.assertGreater(leader["return_20d"], laggard["return_20d"])
|
||||||
|
self.assertEqual(leader["sector_momentum_rank"], 1)
|
||||||
|
self.assertEqual(laggard["sector_momentum_rank"], 0)
|
||||||
|
self.assertGreater(leader["net_flow_5d_million"], 0)
|
||||||
|
self.assertLess(laggard["net_flow_5d_million"], 0)
|
||||||
|
self.assertEqual(leader["sector_flow_rank"], 1)
|
||||||
|
|
||||||
|
def test_screen_reports_signal_health(self):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
database = ReviewDatabase(Path(root) / "review.db")
|
||||||
|
engine = ScreenerEngine(database)
|
||||||
|
formula = {
|
||||||
|
"universe": {"exclude_st": True, "listed_days_min": 0},
|
||||||
|
"filters": [{"field": "pct_chg", "op": ">", "value": 0}],
|
||||||
|
"score": [{"field": "amount_billion", "weight": 1, "direction": "desc"}],
|
||||||
|
"limit": 5,
|
||||||
|
"min_score": 0,
|
||||||
|
}
|
||||||
|
result = engine.screen(
|
||||||
|
0, "20260724", formula, "repair", "健康检查", False,
|
||||||
|
mode="curated",
|
||||||
|
prepared_factors=[{
|
||||||
|
"ts_code": "600000.SH", "code": "600000", "name": "浦发银行",
|
||||||
|
"sector": "银行", "listed_days": 1000, "pct_chg": 1,
|
||||||
|
"amount_billion": 5, "price": 10, "return_5d": 1,
|
||||||
|
"volume_ratio_5d": 1, "sector_strength": 50,
|
||||||
|
}],
|
||||||
|
prepared_date="20260724",
|
||||||
|
)
|
||||||
|
health = result["meta"]["health"]
|
||||||
|
self.assertEqual(health["status"], "normal")
|
||||||
|
self.assertEqual(health["signal_count"], 1)
|
||||||
|
self.assertEqual(health["coverage"], 100)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class SnapshotDatabase:
|
|||||||
def save_data_snapshot(self, kind, cache_key, _source, payload):
|
def save_data_snapshot(self, kind, cache_key, _source, payload):
|
||||||
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
|
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
|
||||||
|
|
||||||
|
def save_snapshot(self, _trade_date, _source, payload):
|
||||||
|
self.snapshot = copy.deepcopy(payload)
|
||||||
|
|
||||||
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
|
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
|
||||||
return copy.deepcopy(self.latest)
|
return copy.deepcopy(self.latest)
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ class DashboardCacheTests(unittest.TestCase):
|
|||||||
"sentiment_phase": "retreat",
|
"sentiment_phase": "retreat",
|
||||||
"sentiment_direction": "cooling",
|
"sentiment_direction": "cooling",
|
||||||
"sentiment_components": {},
|
"sentiment_components": {},
|
||||||
|
"sentiment_engine_version": 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
service = self.service(snapshot)
|
service = self.service(snapshot)
|
||||||
@@ -71,6 +75,7 @@ class DashboardCacheTests(unittest.TestCase):
|
|||||||
"sentiment_phase": "ice",
|
"sentiment_phase": "ice",
|
||||||
"sentiment_direction": "cooling",
|
"sentiment_direction": "cooling",
|
||||||
"sentiment_components": {},
|
"sentiment_components": {},
|
||||||
|
"sentiment_engine_version": 2,
|
||||||
})
|
})
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -114,15 +114,56 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
self.assertIn(f'data-screener-mode="{mode}"', self.html)
|
self.assertIn(f'data-screener-mode="{mode}"', self.html)
|
||||||
self.assertIn(f'data-screener-panel="{mode}"', self.html)
|
self.assertIn(f'data-screener-panel="{mode}"', self.html)
|
||||||
for element_id in (
|
for element_id in (
|
||||||
"curatedStrategyList", "curatedRunButton", "quantFilterRows",
|
"curatedStrategyList", "quantFilterRows",
|
||||||
"quantScoreRows", "quantRunButton", "quantSaveButton",
|
"quantScoreRows", "quantRunButton", "quantSaveButton",
|
||||||
):
|
):
|
||||||
self.assertIn(f'id="{element_id}"', self.html)
|
self.assertIn(f'id="{element_id}"', self.html)
|
||||||
|
for removed_id in (
|
||||||
|
"curatedRunButton", "factorSyncButton", "screenerRunButton",
|
||||||
|
"changeStrategyButton",
|
||||||
|
):
|
||||||
|
self.assertNotIn(f'id="{removed_id}"', self.html)
|
||||||
|
self.assertIn("盘后自动候选池", self.html)
|
||||||
|
self.assertIn("自定义选股", self.html)
|
||||||
self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html)
|
self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html)
|
||||||
self.assertIn('id="openStrategyDrawerButton"', self.html)
|
self.assertIn('id="openStrategyDrawerButton"', self.html)
|
||||||
self.assertIn('id="closeStrategyDrawerButton"', self.html)
|
self.assertIn('id="closeStrategyDrawerButton"', self.html)
|
||||||
self.assertIn('id="activeStrategyDescription"', self.html)
|
self.assertIn('id="activeStrategyDescription"', self.html)
|
||||||
self.assertIn('openStrategyDrawer("editor")', self.script)
|
self.assertIn('openStrategyDrawer("editor")', self.script)
|
||||||
|
for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"):
|
||||||
|
self.assertIn(f'id="{element_id}"', self.html)
|
||||||
|
self.assertIn("meta.suitable_environment", self.script)
|
||||||
|
self.assertIn("meta.failure_risk", self.script)
|
||||||
|
self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script)
|
||||||
|
|
||||||
|
def test_curated_library_explains_empty_signals_and_supports_school_views(self):
|
||||||
|
for element_id in ("curatedSchoolFilters", "curatedStrategyList"):
|
||||||
|
self.assertIn(f'id="{element_id}"', self.html)
|
||||||
|
for view in ("list", "grid"):
|
||||||
|
self.assertIn(f'data-curated-view="{view}"', self.html)
|
||||||
|
for school in ("基本面", "趋势", "短线", "动量"):
|
||||||
|
self.assertIn(school, self.script)
|
||||||
|
self.assertIn("curatedStrategyRunState", self.script)
|
||||||
|
self.assertIn("必需数据已完整,本日没有股票同时满足", self.script)
|
||||||
|
|
||||||
|
def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self):
|
||||||
|
redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign)
|
||||||
|
self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign)
|
||||||
|
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme)
|
||||||
|
self.assertIn('#reviewWorkspaceView .data-table tbody td', self.theme)
|
||||||
|
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme)
|
||||||
|
|
||||||
|
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
||||||
|
styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8")
|
||||||
|
wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("#toast.toast {", styles)
|
||||||
|
self.assertIn("top: auto;", styles)
|
||||||
|
self.assertIn("left: auto;", styles)
|
||||||
|
self.assertIn("height: auto;", styles)
|
||||||
|
self.assertIn("#toast.toast[hidden] { display: none; }", styles)
|
||||||
|
self.assertNotIn(".toast{position:fixed", self.design_system)
|
||||||
|
self.assertNotRegex(wentian, r"(?m)^\.toast\s*\{")
|
||||||
|
|
||||||
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
|
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
|
||||||
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
|
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
|
||||||
|
|||||||
@@ -106,6 +106,20 @@ class MarketInsightsTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
|
self.assertEqual(payload["amount_history"][-1]["stock_count"], 2)
|
||||||
self.assertEqual(payload["focus_rows"][0]["code"], "000001")
|
self.assertEqual(payload["focus_rows"][0]["code"], "000001")
|
||||||
|
|
||||||
|
def test_auction_amount_history_uses_the_same_a_share_universe_as_summary(self):
|
||||||
|
self.database.upsert_stock_master([
|
||||||
|
{"ts_code": "000001.SZ", "name": "平安银行", "industry": "银行", "market": "主板", "list_date": "19910403"},
|
||||||
|
{"ts_code": "688001.SH", "name": "首日上市", "industry": "半导体", "market": "科创板", "list_date": "20260723"},
|
||||||
|
])
|
||||||
|
self.database.upsert_auction_factors([
|
||||||
|
{"ts_code": "000001.SZ", "trade_date": "20260723", "price": 10.5, "pre_close": 10, "amount": 5_000_000, "vol": 20_000},
|
||||||
|
{"ts_code": "688001.SH", "trade_date": "20260723", "price": 50, "pre_close": 10, "amount": 150_000_000, "vol": 3_000_000},
|
||||||
|
{"ts_code": "159001.SZ", "trade_date": "20260723", "price": 1.1, "pre_close": 1, "amount": 90_000_000, "vol": 90_000_000},
|
||||||
|
])
|
||||||
|
history = self.service._auction_amount_history("20260723")
|
||||||
|
self.assertEqual(history[-1]["stock_count"], 1)
|
||||||
|
self.assertEqual(history[-1]["amount_billion"], 0.05)
|
||||||
|
|
||||||
def test_real_limit_price_is_isolated_from_scored_candidates(self):
|
def test_real_limit_price_is_isolated_from_scored_candidates(self):
|
||||||
class OnePriceClient(FakeMarketClient):
|
class OnePriceClient(FakeMarketClient):
|
||||||
def query(self, api_name, params=None, fields=""):
|
def query(self, api_name, params=None, fields=""):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -130,6 +130,29 @@ class StrategyTrackingTests(unittest.TestCase):
|
|||||||
self.assertTrue(removed["deleted"])
|
self.assertTrue(removed["deleted"])
|
||||||
self.assertEqual(removed["tracking"]["batches"], [])
|
self.assertEqual(removed["tracking"]["batches"], [])
|
||||||
|
|
||||||
|
def test_shared_automatic_run_can_be_added_to_private_tracking(self):
|
||||||
|
run_id = self.database.save_screener_run(
|
||||||
|
0,
|
||||||
|
"20260711",
|
||||||
|
"repair",
|
||||||
|
"系统盘后策略",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"meta": {},
|
||||||
|
"candidates": [{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"code": "600000",
|
||||||
|
"name": "浦发银行",
|
||||||
|
"sector": "银行",
|
||||||
|
"price": 12.5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = self.service.add_candidate(self.other["id"], run_id, "600000")
|
||||||
|
self.assertEqual(result["added"], 1)
|
||||||
|
self.assertEqual(len(self.database.list_strategy_tracks(self.other["id"])), 1)
|
||||||
|
self.assertEqual(self.database.list_strategy_tracks(self.owner["id"]), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -866,6 +866,10 @@ class TushareClient:
|
|||||||
deduped[code] = row
|
deduped[code] = row
|
||||||
return list(deduped.values())
|
return list(deduped.values())
|
||||||
|
|
||||||
|
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return constituents active in a Shenwan L2 industry on the target date."""
|
||||||
|
return self._sw_sector_members(sector_code, trade_date)
|
||||||
|
|
||||||
def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
|
def _stock_listing_reference(self) -> dict[str, dict[str, Any]]:
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
with self._stock_listing_lock:
|
with self._stock_listing_lock:
|
||||||
|
|||||||
Reference in New Issue
Block a user