diff --git a/REDESIGN_PLAN.md b/REDESIGN_PLAN.md
new file mode 100644
index 0000000..a29ccb6
--- /dev/null
+++ b/REDESIGN_PLAN.md
@@ -0,0 +1,56 @@
+# 小白复盘全站重塑计划
+
+本文件是全站重塑的固定执行基线。后续上下文变化时,以本文件为准,不得自行压缩、合并或跳过阶段。
+
+## 执行原则
+
+1. 保留现有 API、业务逻辑、权限、用户数据隔离和功能行为。
+2. 有参考原型的页面,直接以 `../界面优化` 内对应 HTML/CSS 为结构和视觉源,不再在旧 DOM 上模仿。
+3. 每次只验收一个界面。该界面的旧布局和旧专属样式先退出控制,再迁移原型结构并重新绑定数据。
+4. 桌面端以 1440x900 和宽屏截图逐项对照;移动端保证可用,不要求与桌面端同等视觉密度。
+5. 每阶段必须通过功能、权限、交互、控制台错误和视觉截图检查后,才可进入下一阶段。
+6. 不使用 UI/UX Pro Max。
+
+## 19 个阶段
+
+1. 恢复功能基线:修复集合竞价、题材库、人气榜错误。
+2. 全局框架:顶部栏、侧栏、折叠行情条、页面容器和基础组件。
+3. 情绪周期:直接迁移 `emotion.html`,补回交易日明细和昨日反馈。
+4. 涨停池:迁移 `pool.html`。
+5. 炸板池:迁移 `broken.html`。
+6. 跌停池:迁移 `limit.html` 对应页面。
+7. 昨日涨停:迁移 `yesterday.html`。
+8. 涨停表现:迁移 `perf.html`。
+9. 市场天梯:迁移 `ladder.html`。
+10. 板块轮动:迁移 `rotation.html`。
+11. 集合竞价:迁移 `index.html` 并接回全部现有功能。
+12. 题材库:保留现有信息架构,按新设计系统重做。
+13. 人气热榜:迁移 `hot.html`。
+14. 龙虎榜:迁移 `dragon.html`,保留当前数据卡和游资档案入口。
+15. 智能选股:迁移 `screener.html` 的三个选股工作区。
+16. 问师:迁移 `mentor.html` 并接回完整功能。
+17. 我的复盘:迁移 `review.html` 并接回完整功能。
+18. 全局功能界面:详情、搜索、提醒、账户、会员、系统管理和模态弹窗。
+19. 全站验收:桌面/移动、权限矩阵、接口、交互、缓存和废弃 CSS 清理。
+
+## 当前批次
+
+- 阶段 1:已完成
+- 阶段 2:已完成
+- 阶段 3:已完成并通过用户验收
+- 阶段 4:已完成并通过用户验收
+- 阶段 5:已完成并通过用户验收
+- 阶段 6:已完成并通过用户验收
+- 阶段 7:已完成并通过用户验收
+- 阶段 8:已完成并通过用户验收
+- 阶段 9:已完成并通过用户验收
+- 阶段 10:已完成并通过用户验收
+- 阶段 11:已完成并通过用户验收
+- 阶段 12:已完成并通过用户验收
+- 阶段 13:已完成并通过用户验收
+- 阶段 14:已完成并通过用户验收
+- 阶段 15:已完成并通过用户验收
+- 阶段 16:已完成并通过用户验收
+- 阶段 17:已完成并通过用户验收
+- 阶段 18:已完成,等待用户验收
+- 阶段 19:阶段 18 经用户验收后开始
diff --git a/api_access.py b/api_access.py
index 3d2a6d5..2f4442c 100644
--- a/api_access.py
+++ b/api_access.py
@@ -24,6 +24,7 @@ MEMBER_POST_PATHS = frozenset(
"/api/screener/compile",
"/api/screener/strategies",
"/api/screener/run",
+ "/api/screener/tracking",
"/api/screener/tracking/refresh",
"/api/mentors/chat",
"/api/mentors/preferences",
@@ -64,4 +65,6 @@ def required_role(method: str, path: str) -> AccessRole:
return "member"
if re.fullmatch(r"/api/heaven/readings/\d+", path):
return "member"
+ if re.fullmatch(r"/api/screener/tracking/\d+", path):
+ return "member"
return "authenticated"
diff --git a/database.py b/database.py
index 3d764e5..13af575 100644
--- a/database.py
+++ b/database.py
@@ -7,6 +7,15 @@ from pathlib import Path
from typing import Any
+def _optional_float(value: Any) -> float | None:
+ if value in (None, ""):
+ return None
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
+
+
class ManagedConnection(sqlite3.Connection):
"""Commit or roll back, then release the SQLite file handle on context exit."""
@@ -128,6 +137,7 @@ class ReviewDatabase:
name TEXT NOT NULL,
sector TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'red',
+ remark TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, code),
@@ -140,6 +150,7 @@ class ReviewDatabase:
code TEXT NOT NULL DEFAULT '',
stock_name TEXT NOT NULL DEFAULT '',
trade_date TEXT NOT NULL,
+ summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
plan TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
@@ -204,9 +215,30 @@ class ReviewDatabase:
volume_ratio REAL NOT NULL DEFAULT 0,
total_mv REAL NOT NULL DEFAULT 0,
circ_mv REAL NOT NULL DEFAULT 0,
+ pe_ttm REAL,
+ pb REAL,
+ ps_ttm REAL,
+ dv_ttm REAL,
PRIMARY KEY (trade_date, ts_code)
);
+ CREATE TABLE IF NOT EXISTS fundamental_indicators (
+ end_date TEXT NOT NULL,
+ ann_date TEXT NOT NULL DEFAULT '',
+ ts_code TEXT NOT NULL,
+ roe REAL,
+ roa REAL,
+ roic REAL,
+ grossprofit_margin REAL,
+ netprofit_yoy REAL,
+ or_yoy REAL,
+ ocf_to_opincome REAL,
+ PRIMARY KEY (end_date, ts_code)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_fundamental_indicators_code_date
+ ON fundamental_indicators(ts_code, ann_date DESC, end_date DESC);
+
CREATE TABLE IF NOT EXISTS moneyflow_daily (
trade_date TEXT NOT NULL,
ts_code TEXT NOT NULL,
@@ -251,6 +283,7 @@ class ReviewDatabase:
user_id INTEGER,
trade_date TEXT NOT NULL,
regime TEXT NOT NULL,
+ mode TEXT NOT NULL DEFAULT 'smart',
strategy_name TEXT NOT NULL,
formula TEXT NOT NULL,
result TEXT NOT NULL,
@@ -397,6 +430,19 @@ class ReviewDatabase:
for column, statement in migrations.items():
if column not in user_columns:
connection.execute(statement)
+ indicator_columns = {
+ str(row["name"])
+ for row in connection.execute("PRAGMA table_info(daily_indicators)")
+ }
+ indicator_migrations = {
+ "pe_ttm": "ALTER TABLE daily_indicators ADD COLUMN pe_ttm REAL",
+ "pb": "ALTER TABLE daily_indicators ADD COLUMN pb REAL",
+ "ps_ttm": "ALTER TABLE daily_indicators ADD COLUMN ps_ttm REAL",
+ "dv_ttm": "ALTER TABLE daily_indicators ADD COLUMN dv_ttm REAL",
+ }
+ for column, statement in indicator_migrations.items():
+ if column not in indicator_columns:
+ connection.execute(statement)
connection.execute(
"""
UPDATE users SET role = 'admin'
@@ -417,6 +463,7 @@ class ReviewDatabase:
name TEXT NOT NULL,
sector TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'red',
+ remark TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, code),
@@ -436,11 +483,20 @@ class ReviewDatabase:
(int(first_user["id"]),),
)
connection.execute("DROP TABLE watchlist_legacy")
+ watchlist_columns.add("remark")
+ if "remark" not in watchlist_columns:
+ connection.execute(
+ "ALTER TABLE watchlist ADD COLUMN remark TEXT NOT NULL DEFAULT ''"
+ )
note_columns = {
str(row["name"]) for row in connection.execute("PRAGMA table_info(review_notes)")
}
if "user_id" not in note_columns:
connection.execute("ALTER TABLE review_notes ADD COLUMN user_id INTEGER")
+ if "summary" not in note_columns:
+ connection.execute(
+ "ALTER TABLE review_notes ADD COLUMN summary TEXT NOT NULL DEFAULT ''"
+ )
first_user = connection.execute("SELECT MIN(id) AS id FROM users").fetchone()
if first_user and first_user["id"]:
connection.execute(
@@ -463,6 +519,33 @@ class ReviewDatabase:
}
if "user_id" not in run_columns:
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
+ if "mode" not in run_columns:
+ connection.execute(
+ "ALTER TABLE screener_runs ADD COLUMN mode TEXT NOT NULL DEFAULT 'smart'"
+ )
+ legacy_runs = connection.execute(
+ "SELECT id, strategy_name, formula FROM screener_runs"
+ ).fetchall()
+ for run in legacy_runs:
+ try:
+ formula = json.loads(run["formula"])
+ except (TypeError, json.JSONDecodeError):
+ formula = {}
+ meta = formula.get("meta") if isinstance(formula, dict) else {}
+ library = str((meta or {}).get("library") or "")
+ category = str((meta or {}).get("category") or "")
+ if library == "curated":
+ mode = "curated"
+ elif library == "quant" or (
+ library == "custom" and category == "量化公式"
+ ) or str(run["strategy_name"] or "") == "自定义量化公式":
+ mode = "quant"
+ else:
+ mode = "smart"
+ connection.execute(
+ "UPDATE screener_runs SET mode = ? WHERE id = ?",
+ (mode, int(run["id"])),
+ )
if first_user and first_user["id"]:
first_user_id = int(first_user["id"])
connection.execute(
@@ -485,6 +568,12 @@ class ReviewDatabase:
ON screener_runs(user_id, trade_date DESC, id DESC)
"""
)
+ connection.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_screener_runs_user_mode_date
+ ON screener_runs(user_id, mode, trade_date DESC, id DESC)
+ """
+ )
def count_users(self) -> int:
with self.connect() as connection:
@@ -883,7 +972,7 @@ class ReviewDatabase:
with self.connect() as connection:
rows = connection.execute(
"""
- SELECT code, name, sector, color, created_at, updated_at
+ SELECT code, name, sector, color, remark, created_at, updated_at
FROM watchlist WHERE user_id = ? ORDER BY updated_at DESC, code
""",
(int(user_id),),
@@ -891,24 +980,53 @@ class ReviewDatabase:
return [dict(row) for row in rows]
def save_watchlist(
- self, user_id: int, code: str, name: str, sector: str, color: str
+ self, user_id: int, code: str, name: str, sector: str, color: str,
+ remark: str | None = None,
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
+ existing = connection.execute(
+ "SELECT remark FROM watchlist WHERE user_id = ? AND code = ?",
+ (int(user_id), code),
+ ).fetchone()
+ saved_remark = (
+ str(existing["remark"] or "") if remark is None and existing else str(remark or "")
+ )
connection.execute(
"""
INSERT INTO watchlist
- (user_id, code, name, sector, color, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ (user_id, code, name, sector, color, remark, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, code) DO UPDATE SET
name = excluded.name,
sector = excluded.sector,
color = excluded.color,
+ remark = excluded.remark,
updated_at = excluded.updated_at
""",
- (int(user_id), code, name, sector, color, now, now),
+ (int(user_id), code, name, sector, color, saved_remark, now, now),
)
+ def watchlist_price_history(
+ self, codes: list[str], end_date: str, limit_per_code: int = 6
+ ) -> dict[str, list[dict[str, Any]]]:
+ result: dict[str, list[dict[str, Any]]] = {}
+ if not codes:
+ return result
+ with self.connect() as connection:
+ for code in codes:
+ rows = connection.execute(
+ """
+ SELECT trade_date, ts_code, close, pct_chg
+ FROM daily_bars
+ WHERE substr(ts_code, 1, 6) = ? AND trade_date <= ?
+ ORDER BY trade_date DESC LIMIT ?
+ """,
+ (str(code), end_date, int(limit_per_code)),
+ ).fetchall()
+ result[str(code)] = [dict(row) for row in reversed(rows)]
+ return result
+
def delete_watchlist(self, user_id: int, code: str) -> bool:
with self.connect() as connection:
cursor = connection.execute(
@@ -940,7 +1058,7 @@ class ReviewDatabase:
with self.connect() as connection:
rows = connection.execute(
f"""
- SELECT id, code, stock_name, trade_date, content, plan, created_at, updated_at
+ SELECT id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at
FROM review_notes {where}
ORDER BY trade_date DESC, updated_at DESC, id DESC LIMIT 200
""",
@@ -957,6 +1075,7 @@ class ReviewDatabase:
content: str,
plan: str,
note_id: int | None = None,
+ summary: str = "",
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
@@ -964,10 +1083,10 @@ class ReviewDatabase:
cursor = connection.execute(
"""
UPDATE review_notes
- SET code = ?, stock_name = ?, trade_date = ?, content = ?, plan = ?, updated_at = ?
+ SET code = ?, stock_name = ?, trade_date = ?, summary = ?, content = ?, plan = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""",
- (code, stock_name, trade_date, content, plan, now, note_id, int(user_id)),
+ (code, stock_name, trade_date, summary, content, plan, now, note_id, int(user_id)),
)
if cursor.rowcount == 0:
raise ValueError("复盘笔记不存在。")
@@ -975,10 +1094,10 @@ class ReviewDatabase:
cursor = connection.execute(
"""
INSERT INTO review_notes
- (user_id, code, stock_name, trade_date, content, plan, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ (user_id, code, stock_name, trade_date, summary, content, plan, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
- (int(user_id), code, stock_name, trade_date, content, plan, now, now),
+ (int(user_id), code, stock_name, trade_date, summary, content, plan, now, now),
)
return int(cursor.lastrowid)
@@ -1148,6 +1267,8 @@ class ReviewDatabase:
str(row.get("trade_date") or ""), row.get("ts_code", ""),
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
+ _optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
+ _optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
)
for row in rows if row.get("trade_date") and row.get("ts_code")
]
@@ -1155,11 +1276,44 @@ class ReviewDatabase:
connection.executemany(
"""
INSERT INTO daily_indicators
- (trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv)
- VALUES (?, ?, ?, ?, ?, ?)
+ (trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
+ pe_ttm, pb, ps_ttm, dv_ttm)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
- total_mv=excluded.total_mv, circ_mv=excluded.circ_mv
+ total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
+ pe_ttm=excluded.pe_ttm, pb=excluded.pb,
+ ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
+ """,
+ values,
+ )
+ return len(values)
+
+ def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
+ values = [
+ (
+ str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
+ str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
+ _optional_float(row.get("roa")), _optional_float(row.get("roic")),
+ _optional_float(row.get("grossprofit_margin")),
+ _optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
+ _optional_float(row.get("ocf_to_opincome")),
+ )
+ for row in rows
+ if row.get("end_date") and row.get("ts_code")
+ ]
+ with self.connect() as connection:
+ connection.executemany(
+ """
+ INSERT INTO fundamental_indicators
+ (end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
+ netprofit_yoy, or_yoy, ocf_to_opincome)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(end_date, ts_code) DO UPDATE SET
+ ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
+ roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
+ netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
+ ocf_to_opincome=excluded.ocf_to_opincome
""",
values,
)
@@ -1244,6 +1398,24 @@ class ReviewDatabase:
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
+ def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
+ where = "WHERE trade_date <= ?" if end_date else ""
+ parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
+ with self.connect() as connection:
+ rows = connection.execute(
+ f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
+ "ORDER BY trade_date DESC LIMIT ?",
+ parameters,
+ ).fetchall()
+ return [row["trade_date"] for row in reversed(rows)]
+
+ def fundamental_periods(self) -> list[str]:
+ with self.connect() as connection:
+ rows = connection.execute(
+ "SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
+ ).fetchall()
+ return [str(row["end_date"]) for row in rows]
+
def auction_factors_for_date(self, trade_date: str) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
@@ -1270,10 +1442,71 @@ class ReviewDatabase:
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
+ def factor_health_summary(self, end_date: str) -> dict[str, Any]:
+ dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
+ with self.connect() as connection:
+ market = connection.execute(
+ "SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
+ (end_date,),
+ ).fetchone()[0]
+ auction = connection.execute(
+ "SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
+ (end_date,),
+ ).fetchone()[0]
+ indicator_date = connection.execute(
+ "SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
+ (end_date,),
+ ).fetchone()[0]
+ if indicator_date:
+ valuation_rows, valuation_available = connection.execute(
+ """
+ SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
+ FROM daily_indicators WHERE trade_date = ?
+ """,
+ (indicator_date,),
+ ).fetchone()
+ else:
+ valuation_rows, valuation_available = 0, 0
+ dividend_years = connection.execute(
+ """
+ SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
+ FROM daily_indicators
+ WHERE trade_date <= ? AND trade_date >= ?
+ """,
+ (end_date, dividend_start),
+ ).fetchone()[0]
+ fundamental_rows = connection.execute(
+ """
+ SELECT COUNT(*) FROM fundamental_indicators fi
+ INNER JOIN (
+ SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
+ FROM fundamental_indicators
+ WHERE ann_date = '' OR ann_date <= ?
+ GROUP BY ts_code
+ ) latest
+ ON latest.ts_code = fi.ts_code
+ AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
+ """,
+ (end_date,),
+ ).fetchone()[0]
+ return {
+ "market": bool(market),
+ "auction": bool(auction),
+ "valuation": bool(valuation_available),
+ "fundamental": int(fundamental_rows or 0) >= 100,
+ "dividend_history": int(dividend_years or 0) >= 4,
+ "valuation_rows": int(valuation_rows or 0),
+ "fundamental_rows": int(fundamental_rows or 0),
+ "dividend_years": int(dividend_years or 0),
+ }
+
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
dates = self.factor_dates(end_date, limit_dates)
if not dates:
- return {"dates": [], "bars": [], "master": [], "indicators": [], "moneyflow": [], "auction": []}
+ return {
+ "dates": [], "bars": [], "master": [], "indicators": [],
+ "indicator_history": [], "fundamentals": [], "moneyflow": [], "auction": [],
+ }
placeholders = ",".join("?" for _ in dates)
with self.connect() as connection:
bars = connection.execute(
@@ -1290,6 +1523,35 @@ class ReviewDatabase:
""",
(end_date,),
).fetchall()
+ indicator_history = connection.execute(
+ """
+ SELECT di.* FROM daily_indicators di
+ INNER JOIN (
+ SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
+ MAX(trade_date) AS max_date
+ FROM daily_indicators
+ WHERE trade_date <= ? AND trade_date >= ?
+ GROUP BY ts_code, substr(trade_date, 1, 4)
+ ) latest
+ ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
+ ORDER BY di.trade_date, di.ts_code
+ """,
+ (end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
+ ).fetchall()
+ fundamentals = connection.execute(
+ """
+ SELECT fi.* FROM fundamental_indicators fi
+ INNER JOIN (
+ SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
+ FROM fundamental_indicators
+ WHERE ann_date = '' OR ann_date <= ?
+ GROUP BY ts_code
+ ) latest
+ ON latest.ts_code = fi.ts_code
+ AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
+ """,
+ (end_date,),
+ ).fetchall()
moneyflow = connection.execute(
"""
SELECT * FROM moneyflow_daily
@@ -1313,6 +1575,8 @@ class ReviewDatabase:
"bars": [dict(row) for row in bars],
"master": [dict(row) for row in master],
"indicators": [dict(row) for row in indicators],
+ "indicator_history": [dict(row) for row in indicator_history],
+ "fundamentals": [dict(row) for row in fundamentals],
"moneyflow": [dict(row) for row in moneyflow],
"auction": [dict(row) for row in auction],
}
@@ -1440,39 +1704,106 @@ class ReviewDatabase:
def save_screener_run(
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
- formula: dict[str, Any], result: dict[str, Any],
+ formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
) -> int:
+ normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO screener_runs
- (user_id, trade_date, regime, strategy_name, formula, result, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ (user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
- (int(user_id), trade_date, regime, strategy_name,
+ (int(user_id), trade_date, regime, normalized_mode, strategy_name,
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
)
return int(cursor.lastrowid)
- def latest_screener_run(self, user_id: int, trade_date: str) -> dict[str, Any] | None:
- with self.connect() as connection:
- row = connection.execute(
- """
- SELECT id, trade_date, regime, strategy_name, result, created_at
- FROM screener_runs WHERE user_id = ? AND trade_date <= ? ORDER BY id DESC LIMIT 1
- """,
- (int(user_id), trade_date),
- ).fetchone()
- if not row:
- return None
+ @staticmethod
+ def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
try:
result = json.loads(row["result"])
except json.JSONDecodeError:
return None
- result.setdefault("meta", {})["run_id"] = row["id"]
- result["meta"]["created_at"] = row["created_at"]
+ result.setdefault("meta", {}).update(
+ {
+ "run_id": int(row["id"]),
+ "mode": str(row["mode"] or "smart"),
+ "created_at": row["created_at"],
+ }
+ )
+ return result
+
+ def latest_screener_run(
+ self, user_id: int, trade_date: str, mode: str = "",
+ ) -> dict[str, Any] | None:
+ parameters: tuple[Any, ...] = (int(user_id), trade_date)
+ mode_clause = ""
+ if mode in {"smart", "curated", "quant"}:
+ mode_clause = " AND mode = ?"
+ parameters += (mode,)
+ with self.connect() as connection:
+ row = connection.execute(
+ f"""
+ SELECT id, trade_date, regime, mode, strategy_name, result, created_at
+ FROM screener_runs
+ WHERE user_id = ? AND trade_date <= ?{mode_clause}
+ ORDER BY id DESC LIMIT 1
+ """,
+ parameters,
+ ).fetchone()
+ return self._screener_run_payload(row) if row else None
+
+ def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
+ with self.connect() as connection:
+ rows = connection.execute(
+ """
+ SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
+ runs.strategy_name, runs.result, runs.created_at
+ FROM screener_runs runs
+ INNER JOIN (
+ SELECT mode, MAX(id) AS id
+ FROM screener_runs
+ WHERE user_id = ? AND trade_date <= ?
+ GROUP BY mode
+ ) latest ON latest.id = runs.id
+ """,
+ (int(user_id), trade_date),
+ ).fetchall()
+ results: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ mode = str(row["mode"] or "smart")
+ payload = self._screener_run_payload(row)
+ if mode in {"smart", "curated", "quant"} and payload:
+ results[mode] = payload
+ return results
+
+ def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
+ with self.connect() as connection:
+ row = connection.execute(
+ """
+ SELECT id, trade_date, regime, mode, strategy_name, result, created_at
+ FROM screener_runs WHERE id = ? AND user_id = ?
+ """,
+ (int(run_id), int(user_id)),
+ ).fetchone()
+ if not row:
+ return None
+ result = self._screener_run_payload(row)
+ if result is None:
+ return None
+ result.setdefault("meta", {}).update(
+ {
+ "run_id": int(row["id"]),
+ "trade_date": row["trade_date"],
+ "mode": str(row["mode"] or "smart"),
+ "created_at": row["created_at"],
+ }
+ )
+ result["strategy_name"] = row["strategy_name"]
+ result["regime"] = row["regime"]
return result
def save_mentor_exchange(
@@ -1625,6 +1956,14 @@ class ReviewDatabase:
).fetchall()
return [dict(row) for row in rows]
+ def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
+ with self.connect() as connection:
+ cursor = connection.execute(
+ "DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
+ (int(track_id), int(user_id)),
+ )
+ return cursor.rowcount > 0
+
def load_tracking_bars(
self, targets: list[tuple[str, str]], limit: int = 5
) -> dict[tuple[str, str], list[dict[str, Any]]]:
diff --git a/market_insights.py b/market_insights.py
index ed7e046..5512fb1 100644
--- a/market_insights.py
+++ b/market_insights.py
@@ -41,6 +41,29 @@ class MarketInsightsService:
self.client = client
self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE))
+ def _trade_context(self, requested_date: str) -> tuple[str, str]:
+ """Resolve trading dates without making cached feature pages depend on Tushare uptime."""
+ requested = str(requested_date or "").replace("-", "")
+ try:
+ return self.client.resolve_trade_context(requested)
+ except TushareError:
+ latest = self.database.get_latest_real_snapshot(requested) or {}
+ trade_date = str(
+ (latest.get("meta") or {}).get("trade_date")
+ or latest.get("_snapshot_date")
+ or requested
+ ).replace("-", "")
+ previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {}
+ previous_date = str(
+ (previous.get("meta") or {}).get("trade_date")
+ or previous.get("_snapshot_date")
+ or ""
+ ).replace("-", "")
+ return trade_date, previous_date
+
+ def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None:
+ return self.database.get_latest_data_snapshot(kind, "", trade_date)
+
def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]:
now = self._now_provider()
if now.tzinfo is None:
@@ -590,7 +613,7 @@ class MarketInsightsService:
force: bool = False,
user_id: int = 0,
) -> dict[str, Any]:
- trade_date, previous_date = self.client.resolve_trade_context(requested_date)
+ trade_date, previous_date = self._trade_context(requested_date)
session = self._auction_session(requested_date, trade_date)
phase = str(session["phase"])
data_date = previous_date if phase in {"pending", "observing"} else trade_date
@@ -611,39 +634,36 @@ class MarketInsightsService:
}
return self._with_auction_watchlist(result, data_date, user_id)
- rows = self.client.query("stk_auction", {"trade_date": data_date})
+ try:
+ rows = self.client.query("stk_auction", {"trade_date": data_date})
+ except TushareError:
+ rows = self.database.auction_factors_for_date(data_date)
if not rows:
- if phase in {"selection", "finalized"}:
- return {
- "meta": {
- **session,
- "requested_date": _display_date(requested_date),
- "trade_date": _display_date(trade_date),
- "carried_forward": False,
- "available": False,
- "cached": False,
- "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
- },
- "summary": {
- "stock_count": 0,
- "up_count": 0,
- "down_count": 0,
- "limit_open_count": 0,
- "strong_open_count": 0,
- "median_change": 0,
- "amount_billion": 0,
- },
- "expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
- "themes": {"carry": [], "new_themes": []},
- "amount_history": [],
- "news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"},
- "focus_rows": [],
- "one_price_rows": [],
- "rows": [],
- "watchlist_rows": [],
- "watchlist_missing_count": 0,
- }
- raise TushareError("暂无可用的集合竞价数据")
+ return {
+ "meta": {
+ **session,
+ "requested_date": _display_date(requested_date),
+ "trade_date": _display_date(data_date),
+ "carried_forward": carried_forward,
+ "available": False,
+ "cached": False,
+ "notice": "该交易日暂无可用竞价快照",
+ "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
+ },
+ "summary": {
+ "stock_count": 0, "up_count": 0, "down_count": 0,
+ "limit_open_count": 0, "strong_open_count": 0,
+ "median_change": 0, "amount_billion": 0,
+ "candidate_count": 0, "focus_count": 0, "one_price_count": 0,
+ },
+ "expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
+ "candidate_meta": {"baseline_date": _display_date(previous_date)},
+ "themes": {"carry": [], "new_themes": []},
+ "amount_history": self._auction_amount_history(data_date),
+ "news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"},
+ "focus_rows": [], "one_price_rows": [], "rows": [],
+ "watchlist_rows": [], "watchlist_missing_count": 0,
+ }
master = self._stock_master()
try:
@@ -699,7 +719,7 @@ class MarketInsightsService:
self.database.upsert_auction_factors(rows)
changes = [item["change"] for item in normalized]
total = len(normalized)
- _, baseline_date = self.client.resolve_trade_context(data_date)
+ _, baseline_date = self._trade_context(data_date)
candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date)
candidate_map = {str(item.get("code") or ""): item for item in candidates}
one_price_rows = []
@@ -807,7 +827,7 @@ class MarketInsightsService:
return items
def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]:
- trade_date, previous_date = self.client.resolve_trade_context(requested_date)
+ trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("theme_library_v1", trade_date)
if cached:
@@ -815,19 +835,36 @@ class MarketInsightsService:
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
- daily = self.client.query(
- "ths_daily",
- {"trade_date": trade_date},
- "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
- )
+ try:
+ daily = self.client.query(
+ "ths_daily",
+ {"trade_date": trade_date},
+ "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
+ )
+ except TushareError:
+ fallback = self._latest_feature_snapshot("theme_library_v1", trade_date)
+ if fallback:
+ result = copy.deepcopy(fallback)
+ result["meta"] = {
+ **result.get("meta", {}),
+ "requested_date": _display_date(requested_date),
+ "carried_forward": True,
+ "cached": True,
+ "notice": "当前题材行情暂不可用,展示最近有效快照",
+ }
+ return result
+ daily = []
actual_date = trade_date
carried_forward = False
if not daily and previous_date:
- daily = self.client.query(
- "ths_daily",
- {"trade_date": previous_date},
- "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
- )
+ try:
+ daily = self.client.query(
+ "ths_daily",
+ {"trade_date": previous_date},
+ "ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
+ )
+ except TushareError:
+ daily = []
actual_date = previous_date
carried_forward = bool(daily)
daily_map = {str(row.get("ts_code") or ""): row for row in daily}
@@ -870,6 +907,7 @@ class MarketInsightsService:
"trade_date": _display_date(actual_date),
"carried_forward": carried_forward,
"cached": False,
+ "notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
@@ -891,9 +929,16 @@ class MarketInsightsService:
if not theme:
raise ValueError("未找到对应题材。")
actual_date = str(library["meta"]["trade_date"]).replace("-", "")
- members = self.client.query(
- "ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name"
- )
+ detail_key = f"{actual_date}:{code}"
+ cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key)
+ if cached_detail:
+ return cached_detail
+ try:
+ members = self.client.query(
+ "ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name"
+ )
+ except TushareError:
+ members = []
bars = self.database.daily_bars_for_date(actual_date)
if not bars:
bars = self.client.query(
@@ -923,15 +968,18 @@ class MarketInsightsService:
reverse=True,
)
end = datetime.strptime(actual_date, "%Y%m%d")
- history = self.client.query(
- "ths_daily",
- {
- "ts_code": code,
- "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
- "end_date": actual_date,
- },
- "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate",
- )
+ try:
+ history = self.client.query(
+ "ths_daily",
+ {
+ "ts_code": code,
+ "start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
+ "end_date": actual_date,
+ },
+ "ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate",
+ )
+ except TushareError:
+ history = []
history.sort(key=lambda row: str(row.get("trade_date") or ""))
series = [
{
@@ -945,8 +993,11 @@ class MarketInsightsService:
}
for row in history[-90:]
]
- return {
- "meta": {"trade_date": _display_date(actual_date)},
+ result = {
+ "meta": {
+ "trade_date": _display_date(actual_date),
+ "notice": "" if members or history else "题材成分与走势暂不可用",
+ },
"theme": theme,
"series": series,
"members": normalized_members,
@@ -957,6 +1008,9 @@ class MarketInsightsService:
"quoted_count": sum(item["has_quote"] for item in normalized_members),
},
}
+ if members or history:
+ self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result)
+ return result
@staticmethod
def _parse_concepts(value: Any) -> list[str]:
@@ -974,7 +1028,7 @@ class MarketInsightsService:
return [part.strip() for part in text.split(",") if part.strip()]
def popularity(self, requested_date: str, force: bool = False) -> dict[str, Any]:
- trade_date, previous_date = self.client.resolve_trade_context(requested_date)
+ trade_date, previous_date = self._trade_context(requested_date)
if not force:
cached = self.database.get_data_snapshot("popularity_v1", trade_date)
if cached:
@@ -990,10 +1044,33 @@ class MarketInsightsService:
actual_date = previous_date
carried_forward = bool(ths_rows or dc_rows)
if not ths_rows and not dc_rows:
- raise TushareError("暂无可用的人气榜数据")
+ fallback = self._latest_feature_snapshot("popularity_v1", trade_date)
+ if fallback:
+ result = copy.deepcopy(fallback)
+ result["meta"] = {
+ **result.get("meta", {}),
+ "requested_date": _display_date(requested_date),
+ "carried_forward": True,
+ "cached": True,
+ "notice": "当前榜单暂不可用,展示最近有效快照",
+ }
+ return result
+ return {
+ "meta": {
+ "requested_date": _display_date(requested_date),
+ "trade_date": _display_date(trade_date),
+ "previous_trade_date": _display_date(previous_date),
+ "carried_forward": False,
+ "cached": False,
+ "notice": "该交易日暂无可用人气榜",
+ "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
+ },
+ "summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0},
+ "combined": [], "ths": [], "dc": [],
+ }
prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
- prior_date, _ = self.client.resolve_trade_context(prior_request)
+ prior_date, _ = self._trade_context(prior_request)
previous_ths, previous_dc, _ = self._hot_rows(prior_date)
ths = self._normalize_hot(ths_rows, "热股", previous_ths)
dc = self._normalize_hot(dc_rows, "A股市场", previous_dc)
diff --git a/screener.py b/screener.py
index 928a6b3..e62616f 100644
--- a/screener.py
+++ b/screener.py
@@ -43,6 +43,57 @@ FACTOR_FIELDS = {
"auction_amount_million": "竞价成交额",
"auction_turnover_rate": "竞价换手率",
"auction_volume_ratio": "竞价量比",
+ "total_mv_billion": "总市值",
+ "pe_ttm": "市盈率TTM",
+ "pb": "市净率",
+ "ps_ttm": "市销率TTM",
+ "dividend_yield_ttm": "股息率TTM",
+ "dividend_years": "近年持续分红",
+ "roe": "净资产收益率",
+ "roa": "总资产收益率",
+ "roic": "投入资本回报率",
+ "gross_margin": "销售毛利率",
+ "netprofit_yoy": "净利润同比",
+ "revenue_yoy": "营业收入同比",
+ "ocf_to_opincome": "经营现金流质量",
+ "relative_position_60": "60日相对位置",
+ "max_abs_change_15d": "15日最大波动",
+ "close_to_high_15d": "距15日高点",
+ "close_to_high_60d": "距60日高点",
+ "no_limit_30d": "近30日无涨停",
+ "had_limit_80d": "近80日曾涨停",
+ "previous_first_limit": "昨日首板",
+ "previous_limit_signal": "昨日涨停或触板",
+ "previous_limit_streak": "昨日连板高度",
+ "previous_amount_billion": "昨日成交额",
+ "sector_breadth_ma20": "行业20日线宽度",
+}
+
+FACTOR_GROUPS = {
+ "行情动量": [
+ "pct_chg", "return_5d", "return_10d", "above_ma20", "relative_strength",
+ "relative_position_60", "close_to_high_15d", "close_to_high_60d",
+ ],
+ "量价交易": [
+ "volume_ratio_5d", "volatility_10d", "amount_billion", "turnover_rate",
+ "net_flow_million", "large_flow_million", "previous_amount_billion",
+ ],
+ "板块结构": [
+ "sector_strength", "sector_limit_count", "sector_up_count", "sector_breadth_ma20",
+ "limit_streak", "previous_limit_streak", "previous_first_limit", "previous_limit_signal",
+ "no_limit_30d", "had_limit_80d", "max_abs_change_15d",
+ ],
+ "竞价因子": [
+ "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio",
+ ],
+ "估值规模": [
+ "circ_mv_billion", "total_mv_billion", "pe_ttm", "pb", "ps_ttm",
+ "dividend_yield_ttm", "dividend_years",
+ ],
+ "财务质量": [
+ "roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy",
+ "ocf_to_opincome",
+ ],
}
ALLOWED_OPERATORS = {">", ">=", "<", "<=", "==", "!=", "between", "in"}
@@ -213,6 +264,256 @@ BUILTIN_STRATEGIES = [
},
]
+for _strategy in BUILTIN_STRATEGIES:
+ _strategy["formula"].setdefault("meta", {
+ "library": "smart", "category": "周期策略", "quality": "系统",
+ "frequency": "每日", "risk": "随市场阶段", "data_group": "行情因子",
+ })
+
+
+CURATED_STRATEGIES = [
+ {
+ "name": "连续分红质量",
+ "description": "寻找持续派息、盈利质量稳定且波动可控的长期现金回报型公司。",
+ "regimes": list(REGIMES),
+ "formula": {
+ "meta": {"library": "curated", "category": "红利价值", "quality": "A", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
+ "universe": {"exclude_st": True, "listed_days_min": 1095},
+ "filters": [
+ {"field": "dividend_years", "op": ">=", "value": 4},
+ {"field": "dividend_yield_ttm", "op": ">=", "value": 2},
+ {"field": "roe", "op": ">=", "value": 6},
+ {"field": "pb", "op": "between", "value": [0.1, 4]},
+ ],
+ "score": [
+ {"field": "dividend_yield_ttm", "weight": 0.30, "direction": "desc"},
+ {"field": "roe", "weight": 0.24, "direction": "desc"},
+ {"field": "ocf_to_opincome", "weight": 0.18, "direction": "desc"},
+ {"field": "volatility_10d", "weight": 0.16, "direction": "asc"},
+ {"field": "total_mv_billion", "weight": 0.12, "direction": "desc"},
+ ], "limit": 20, "min_score": 0.52,
+ },
+ },
+ {
+ "name": "ROIC质量低波",
+ "description": "以投入资本回报、毛利率和估值为核心,寻找低波动的高质量公司。",
+ "regimes": ["ice", "repair", "divergence", "retreat"],
+ "formula": {
+ "meta": {"library": "curated", "category": "质量价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
+ "universe": {"exclude_st": True, "listed_days_min": 730},
+ "filters": [
+ {"field": "roic", "op": ">=", "value": 6},
+ {"field": "gross_margin", "op": ">=", "value": 15},
+ {"field": "pe_ttm", "op": "between", "value": [1, 45]},
+ {"field": "amount_billion", "op": ">=", "value": 1},
+ ],
+ "score": [
+ {"field": "roic", "weight": 0.28, "direction": "desc"},
+ {"field": "gross_margin", "weight": 0.22, "direction": "desc"},
+ {"field": "ps_ttm", "weight": 0.18, "direction": "asc"},
+ {"field": "volatility_10d", "weight": 0.18, "direction": "asc"},
+ {"field": "total_mv_billion", "weight": 0.14, "direction": "desc"},
+ ], "limit": 20, "min_score": 0.54,
+ },
+ },
+ {
+ "name": "低估值现金流白马",
+ "description": "筛选估值克制、经营现金流健康、资产回报稳定的大中型公司。",
+ "regimes": ["ice", "repair", "divergence", "retreat"],
+ "formula": {
+ "meta": {"library": "curated", "category": "现金流价值", "quality": "A-", "frequency": "月度", "risk": "中低", "data_group": "估值与财务"},
+ "universe": {"exclude_st": True, "listed_days_min": 730},
+ "filters": [
+ {"field": "pb", "op": "between", "value": [0.1, 1.8]},
+ {"field": "roa", "op": ">=", "value": 3},
+ {"field": "ocf_to_opincome", "op": ">", "value": 0},
+ {"field": "netprofit_yoy", "op": ">=", "value": -15},
+ {"field": "total_mv_billion", "op": ">=", "value": 100},
+ ],
+ "score": [
+ {"field": "roa", "weight": 0.26, "direction": "desc"},
+ {"field": "ocf_to_opincome", "weight": 0.24, "direction": "desc"},
+ {"field": "pb", "weight": 0.20, "direction": "asc"},
+ {"field": "total_mv_billion", "weight": 0.16, "direction": "desc"},
+ {"field": "volatility_10d", "weight": 0.14, "direction": "asc"},
+ ], "limit": 20, "min_score": 0.53,
+ },
+ },
+ {
+ "name": "高增长合理估值",
+ "description": "在收入和利润同步增长的公司中,优先选择估值合理、趋势得到确认的标的。",
+ "regimes": ["repair", "fermentation", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "成长质量", "quality": "B+", "frequency": "月度", "risk": "中", "data_group": "估值与财务"},
+ "universe": {"exclude_st": True, "listed_days_min": 365},
+ "filters": [
+ {"field": "pe_ttm", "op": "between", "value": [1, 35]},
+ {"field": "revenue_yoy", "op": ">=", "value": 10},
+ {"field": "netprofit_yoy", "op": ">=", "value": 15},
+ {"field": "roe", "op": ">=", "value": 5},
+ {"field": "amount_billion", "op": ">=", "value": 1},
+ ],
+ "score": [
+ {"field": "netprofit_yoy", "weight": 0.27, "direction": "desc"},
+ {"field": "revenue_yoy", "weight": 0.23, "direction": "desc"},
+ {"field": "roe", "weight": 0.20, "direction": "desc"},
+ {"field": "pe_ttm", "weight": 0.16, "direction": "asc"},
+ {"field": "relative_strength", "weight": 0.14, "direction": "desc"},
+ ], "limit": 20, "min_score": 0.55,
+ },
+ },
+ {
+ "name": "行业宽度主线",
+ "description": "从行业站上20日线的覆盖率和板块强度出发,筛选主线中的强势个股。",
+ "regimes": ["repair", "fermentation", "climax", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "行业轮动", "quality": "B+", "frequency": "每周", "risk": "中", "data_group": "行情与行业"},
+ "universe": {"exclude_st": True, "listed_days_min": 180},
+ "filters": [
+ {"field": "sector_breadth_ma20", "op": ">=", "value": 55},
+ {"field": "sector_strength", "op": ">=", "value": 55},
+ {"field": "above_ma20", "op": "==", "value": 1},
+ {"field": "amount_billion", "op": ">=", "value": 2},
+ ],
+ "score": [
+ {"field": "sector_breadth_ma20", "weight": 0.28, "direction": "desc"},
+ {"field": "sector_strength", "weight": 0.24, "direction": "desc"},
+ {"field": "relative_strength", "weight": 0.20, "direction": "desc"},
+ {"field": "sector_limit_count", "weight": 0.16, "direction": "desc"},
+ {"field": "amount_billion", "weight": 0.12, "direction": "desc"},
+ ], "limit": 20, "min_score": 0.56,
+ },
+ },
+ {
+ "name": "首板低开",
+ "description": "昨日首板且位置不高,次日竞价温和低开并具备成交承载时进入候选。",
+ "regimes": ["ice", "repair", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "短线竞价", "quality": "B+", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"},
+ "universe": {"exclude_st": True, "listed_days_min": 250},
+ "filters": [
+ {"field": "previous_first_limit", "op": "==", "value": 1},
+ {"field": "auction_change", "op": "between", "value": [-4.5, -2.5]},
+ {"field": "relative_position_60", "op": "<=", "value": 0.55},
+ {"field": "previous_amount_billion", "op": ">=", "value": 1},
+ ],
+ "score": [
+ {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
+ {"field": "previous_amount_billion", "weight": 0.24, "direction": "desc"},
+ {"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
+ {"field": "sector_strength", "weight": 0.16, "direction": "desc"},
+ {"field": "auction_volume_ratio", "weight": 0.12, "direction": "desc"},
+ ], "limit": 12, "min_score": 0.50,
+ },
+ },
+ {
+ "name": "小碎步临界突破",
+ "description": "寻找近期窄幅爬升、接近阶段高点且具备历史活跃记忆的突破候选。",
+ "regimes": ["repair", "fermentation", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "形态突破", "quality": "B+", "frequency": "每日", "risk": "中高", "data_group": "历史行情"},
+ "universe": {"exclude_st": True, "listed_days_min": 250},
+ "filters": [
+ {"field": "no_limit_30d", "op": "==", "value": 1},
+ {"field": "had_limit_80d", "op": "==", "value": 1},
+ {"field": "max_abs_change_15d", "op": "<=", "value": 3},
+ {"field": "close_to_high_15d", "op": ">=", "value": 0.98},
+ {"field": "close_to_high_60d", "op": ">=", "value": 0.90},
+ ],
+ "score": [
+ {"field": "close_to_high_15d", "weight": 0.26, "direction": "desc"},
+ {"field": "volume_ratio_5d", "weight": 0.22, "direction": "desc"},
+ {"field": "relative_strength", "weight": 0.20, "direction": "desc"},
+ {"field": "max_abs_change_15d", "weight": 0.18, "direction": "asc"},
+ {"field": "circ_mv_billion", "weight": 0.14, "direction": "asc"},
+ ], "limit": 15, "min_score": 0.54,
+ },
+ },
+ {
+ "name": "连板龙头",
+ "description": "从昨日连板梯队中按高度、板块热度和成交承载筛选辨识度前排。",
+ "regimes": ["fermentation", "climax", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "连板接力", "quality": "B", "frequency": "每日", "risk": "很高", "data_group": "涨停结构"},
+ "universe": {"exclude_st": True, "listed_days_min": 120},
+ "filters": [
+ {"field": "previous_limit_streak", "op": ">=", "value": 2},
+ {"field": "previous_amount_billion", "op": ">=", "value": 1},
+ ],
+ "score": [
+ {"field": "previous_limit_streak", "weight": 0.34, "direction": "desc"},
+ {"field": "sector_limit_count", "weight": 0.24, "direction": "desc"},
+ {"field": "previous_amount_billion", "weight": 0.18, "direction": "desc"},
+ {"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
+ {"field": "sector_strength", "weight": 0.10, "direction": "desc"},
+ ], "limit": 10, "min_score": 0.50,
+ },
+ },
+ {
+ "name": "微盘三正",
+ "description": "以正估值、正盈利和正经营现金流约束微盘暴露,保留明确风险提示。",
+ "regimes": ["repair", "fermentation"],
+ "formula": {
+ "meta": {"library": "curated", "category": "小盘质量", "quality": "B", "frequency": "每周", "risk": "高", "data_group": "估值与财务"},
+ "universe": {"exclude_st": True, "listed_days_min": 365},
+ "filters": [
+ {"field": "pb", "op": ">", "value": 0},
+ {"field": "roe", "op": ">", "value": 0},
+ {"field": "ocf_to_opincome", "op": ">", "value": 0},
+ {"field": "circ_mv_billion", "op": "between", "value": [5, 100]},
+ {"field": "amount_billion", "op": ">=", "value": 0.5},
+ ],
+ "score": [
+ {"field": "circ_mv_billion", "weight": 0.32, "direction": "asc"},
+ {"field": "roe", "weight": 0.24, "direction": "desc"},
+ {"field": "ocf_to_opincome", "weight": 0.20, "direction": "desc"},
+ {"field": "turnover_rate", "weight": 0.14, "direction": "desc"},
+ {"field": "relative_strength", "weight": 0.10, "direction": "desc"},
+ ], "limit": 20, "min_score": 0.52,
+ },
+ },
+ {
+ "name": "首板高开弱转强",
+ "description": "昨日涨停或触板后,使用9:25最终竞价涨幅、量比和板块承接确认强度。",
+ "regimes": ["repair", "fermentation", "divergence"],
+ "formula": {
+ "meta": {"library": "curated", "category": "短线竞价", "quality": "B-", "frequency": "每日9:25", "risk": "高", "data_group": "行情与竞价"},
+ "universe": {"exclude_st": True, "listed_days_min": 120},
+ "filters": [
+ {"field": "previous_limit_signal", "op": "==", "value": 1},
+ {"field": "auction_change", "op": "between", "value": [1, 6]},
+ {"field": "auction_volume_ratio", "op": ">=", "value": 0.8},
+ {"field": "previous_amount_billion", "op": "between", "value": [3, 25]},
+ ],
+ "score": [
+ {"field": "auction_amount_million", "weight": 0.28, "direction": "desc"},
+ {"field": "auction_volume_ratio", "weight": 0.24, "direction": "desc"},
+ {"field": "auction_change", "weight": 0.18, "direction": "desc"},
+ {"field": "sector_strength", "weight": 0.17, "direction": "desc"},
+ {"field": "relative_strength", "weight": 0.13, "direction": "desc"},
+ ], "limit": 15, "min_score": 0.52,
+ },
+ },
+]
+
+BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
+
+
+def _quarter_periods(trade_date: str, count: int) -> list[str]:
+ current = datetime.strptime(trade_date, "%Y%m%d")
+ quarter_ends = ((3, 31), (6, 30), (9, 30), (12, 31))
+ periods = []
+ year = current.year
+ while len(periods) < count:
+ for month, day in reversed(quarter_ends):
+ value = datetime(year, month, day)
+ if value <= current:
+ periods.append(value.strftime("%Y%m%d"))
+ if len(periods) == count:
+ break
+ year -= 1
+ return sorted(periods)
+
class FactorDataService:
def __init__(self, database: ReviewDatabase, client: TushareClient) -> None:
@@ -235,6 +536,27 @@ class FactorDataService:
auction_dates_to_fetch = [
value for value in dates if value not in existing_auction or value == trade_date
]
+ long_calendar = self.client.query(
+ "trade_cal",
+ {
+ "exchange": "SSE",
+ "start_date": datetime(end.year - 5, 1, 1).strftime("%Y%m%d"),
+ "end_date": trade_date,
+ "is_open": 1,
+ },
+ "cal_date,is_open",
+ )
+ last_open_by_year: dict[str, str] = {}
+ for row in long_calendar:
+ if row.get("is_open") == 1 and row.get("cal_date"):
+ value = str(row["cal_date"])
+ last_open_by_year[value[:4]] = max(last_open_by_year.get(value[:4], ""), value)
+ valuation_dates = set(dates)
+ valuation_dates.update(last_open_by_year.values())
+ existing_indicators = set(self.database.daily_indicator_dates(trade_date, 500))
+ indicator_dates_to_fetch = sorted(
+ value for value in valuation_dates if value not in existing_indicators or value == trade_date
+ )
master = self.client.query(
"stock_basic",
@@ -251,13 +573,38 @@ class FactorDataService:
)
bar_count += self.database.upsert_daily_bars(rows)
- indicators = self.client.query(
- "daily_basic",
- {"trade_date": trade_date},
- "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
- )
- indicator_count = self.database.upsert_daily_indicators(indicators)
+ indicator_count = 0
+ for current_date in indicator_dates_to_fetch:
+ indicators = self.client.query(
+ "daily_basic",
+ {"trade_date": current_date},
+ "ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv,"
+ "pe_ttm,pb,ps_ttm,dv_ttm",
+ )
+ indicator_count += self.database.upsert_daily_indicators(indicators)
+
notices = []
+ fundamental_count = 0
+ existing_periods = set(self.database.fundamental_periods())
+ for period in _quarter_periods(trade_date, 9):
+ if period in existing_periods and period < trade_date[:4] + "0101":
+ continue
+ try:
+ rows = self.client.query(
+ "fina_indicator_vip",
+ {"period": period},
+ "ts_code,ann_date,end_date,roe,roa,roic,grossprofit_margin,"
+ "netprofit_yoy,or_yoy,ocf_to_opincome",
+ )
+ except TushareError as exc:
+ notices.append(f"财务质量接口不可用:{exc}")
+ break
+ published = [
+ row for row in rows
+ if not row.get("ann_date") or str(row.get("ann_date")) <= trade_date
+ ]
+ published.sort(key=lambda row: str(row.get("ann_date") or ""))
+ fundamental_count += self.database.upsert_fundamental_indicators(published)
auction_count = 0
auction_dates = 0
for current_date in auction_dates_to_fetch:
@@ -292,6 +639,8 @@ class FactorDataService:
"stocks": master_count,
"bars": bar_count,
"indicators": indicator_count,
+ "indicator_dates": len(indicator_dates_to_fetch),
+ "fundamentals": fundamental_count,
"moneyflow": moneyflow_count,
"auction_rows": auction_count,
"auction_dates": auction_dates,
@@ -304,10 +653,17 @@ class ScreenerEngine:
self.database = database
def ensure_builtin_strategies(self) -> None:
- existing = {item["name"] for item in self.database.list_screener_strategies() if item["builtin"]}
+ existing = {
+ item["name"]: item
+ for item in self.database.list_screener_strategies()
+ if item["builtin"]
+ }
for strategy in BUILTIN_STRATEGIES:
- if strategy["name"] not in existing:
- self.database.save_screener_strategy(None, **strategy, builtin=True)
+ current = existing.get(strategy["name"])
+ self.database.save_screener_strategy(
+ None, **strategy, builtin=True,
+ strategy_id=int(current["id"]) if current else None,
+ )
def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history(
@@ -348,6 +704,9 @@ class ScreenerEngine:
],
}
+ def factor_health(self, trade_date: str) -> dict[str, Any]:
+ return self.database.factor_health_summary(trade_date)
+
def validate_formula(self, formula: dict[str, Any]) -> dict[str, Any]:
if not isinstance(formula, dict):
raise ValueError("选股公式必须是 JSON 对象。")
@@ -387,7 +746,9 @@ class ScreenerEngine:
self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str,
strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None,
+ mode: str = "smart",
) -> dict[str, Any]:
+ mode = mode if mode in {"smart", "curated", "quant"} else "smart"
formula = self.validate_formula(formula)
factors, actual_date = self.build_factors(trade_date, realtime_snapshot)
candidates = self.apply_formula(factors, formula, regime)
@@ -407,6 +768,7 @@ class ScreenerEngine:
"regime": regime,
"regime_label": REGIMES.get(regime, regime),
"strategy_name": strategy_name,
+ "mode": mode,
"universe_count": len(factors),
"candidate_count": len(candidates),
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
@@ -432,7 +794,7 @@ class ScreenerEngine:
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。",
}
run_id = self.database.save_screener_run(
- user_id, actual_date, regime, strategy_name, formula, result
+ user_id, actual_date, regime, strategy_name, formula, result, mode
)
result["meta"]["run_id"] = run_id
return result
@@ -456,6 +818,10 @@ class ScreenerEngine:
actual_date = trade_date if use_realtime else history_date
master = {row["ts_code"]: row for row in data["master"]}
indicators = {row["ts_code"]: row for row in data["indicators"]}
+ fundamentals = {row["ts_code"]: row for row in data.get("fundamentals", [])}
+ indicator_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in data.get("indicator_history", []):
+ indicator_history[str(row.get("ts_code") or "")].append(row)
moneyflow = {row["ts_code"]: row for row in data["moneyflow"]}
auction = {
row["ts_code"]: row
@@ -495,6 +861,7 @@ class ScreenerEngine:
returns_10 = returns_10[-9:] + [_number(realtime.get("pct_chg"))]
previous_volume = statistics.fmean(volumes[-6:-1]) if any(volumes[-6:-1]) else 0
indicator = indicators.get(ts_code, {})
+ fundamental = fundamentals.get(ts_code, {})
flow = moneyflow.get(ts_code, {})
auction_row = auction.get(ts_code, {})
list_date = str(info.get("list_date") or "")
@@ -504,11 +871,46 @@ class ScreenerEngine:
listed_days = 9999
code = str(info.get("code") or ts_code.split(".")[0])
status, streak = limit_map.get(code, ("", 0))
+ name = str(info.get("name") or "--")
+ shape_rows = bars + ([realtime] if realtime else [])
+ shape_close = [_number(item.get("close")) for item in shape_rows]
+ shape_high = [_number(item.get("high") or item.get("close")) for item in shape_rows]
+ shape_low = [_number(item.get("low") or item.get("close")) for item in shape_rows]
+ shape_changes = [_number(item.get("pct_chg")) for item in shape_rows]
+ position_rows = shape_rows[-60:]
+ position_high = max((_number(item.get("high") or item.get("close")) for item in position_rows), default=0)
+ position_low = min((_number(item.get("low") or item.get("close")) for item in position_rows), default=0)
+ relative_position = (
+ (closes[-1] - position_low) / (position_high - position_low)
+ if position_high > position_low else 0.5
+ )
+ previous_index = len(bars) - 1 if realtime else len(bars) - 2
+ previous_bar = bars[previous_index] if previous_index >= 0 else {}
+ previous_limit = _is_limit_bar(bars, previous_index, code, name)
+ previous_touched = _touched_limit_bar(bars, previous_index, code, name)
+ recent_prior_signal = any(
+ _is_limit_bar(bars, index, code, name)
+ or _touched_limit_bar(bars, index, code, name)
+ for index in range(max(0, previous_index - 2), previous_index)
+ )
+ previous_streak = 0
+ streak_index = previous_index
+ while streak_index >= 0 and _is_limit_bar(bars, streak_index, code, name):
+ previous_streak += 1
+ streak_index -= 1
+ limit_flags = [
+ _is_limit_bar(shape_rows, index, code, name)
+ for index in range(len(shape_rows))
+ ]
+ annual_dividend_rows = indicator_history.get(ts_code, [])
+ dividend_years = sum(
+ 1 for item in annual_dividend_rows if _optional_number(item.get("dv_ttm")) not in (None, 0)
+ )
factors.append(
{
"code": code,
"ts_code": ts_code,
- "name": info.get("name") or "--",
+ "name": name,
"sector": info.get("industry") or "其他",
"market": info.get("market") or "--",
"listed_days": listed_days,
@@ -528,6 +930,19 @@ class ScreenerEngine:
2,
),
"circ_mv_billion": round(_number(indicator.get("circ_mv")) / 10000, 2),
+ "total_mv_billion": round(_number(indicator.get("total_mv")) / 10000, 2),
+ "pe_ttm": _rounded_optional(indicator.get("pe_ttm"), 2),
+ "pb": _rounded_optional(indicator.get("pb"), 2),
+ "ps_ttm": _rounded_optional(indicator.get("ps_ttm"), 2),
+ "dividend_yield_ttm": _rounded_optional(indicator.get("dv_ttm"), 2),
+ "dividend_years": dividend_years,
+ "roe": _rounded_optional(fundamental.get("roe"), 2),
+ "roa": _rounded_optional(fundamental.get("roa"), 2),
+ "roic": _rounded_optional(fundamental.get("roic"), 2),
+ "gross_margin": _rounded_optional(fundamental.get("grossprofit_margin"), 2),
+ "netprofit_yoy": _rounded_optional(fundamental.get("netprofit_yoy"), 2),
+ "revenue_yoy": _rounded_optional(fundamental.get("or_yoy"), 2),
+ "ocf_to_opincome": _rounded_optional(fundamental.get("ocf_to_opincome"), 2),
"net_flow_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
"large_flow_million": round(_number(flow.get("large_net_amount")) / 100, 2),
"limit_status": status,
@@ -536,6 +951,16 @@ class ScreenerEngine:
"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_volume_ratio": round(_number(auction_row.get("volume_ratio")), 2),
+ "relative_position_60": round(relative_position, 4),
+ "max_abs_change_15d": round(max((abs(value) for value in shape_changes[-15:]), default=0), 2),
+ "close_to_high_15d": round(closes[-1] / max(shape_high[-15:]), 4) if shape_high[-15:] and max(shape_high[-15:]) 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:])),
+ "had_limit_80d": int(any(limit_flags[-80:-30] if len(limit_flags) > 30 else [])),
+ "previous_first_limit": int(previous_limit and not recent_prior_signal),
+ "previous_limit_signal": int((previous_limit or previous_touched) and not recent_prior_signal),
+ "previous_limit_streak": previous_streak,
+ "previous_amount_billion": round(_number(previous_bar.get("amount")) / 100000, 2),
}
)
@@ -547,11 +972,13 @@ class ScreenerEngine:
average_return = statistics.fmean(row["return_5d"] for row in sector_rows)
limit_count = sum(row["limit_status"] == "涨停" or row["pct_chg"] >= 9.5 for row in sector_rows)
up_count = sum(row["pct_chg"] >= 5 for row in sector_rows)
+ breadth_ma20 = sum(row["above_ma20"] for row in sector_rows) / max(len(sector_rows), 1) * 100
strength = min(100, max(0, 50 + average_return * 4 + limit_count * 3 + up_count * 0.6))
for row in sector_rows:
row["sector_strength"] = round(strength, 1)
row["sector_limit_count"] = limit_count
row["sector_up_count"] = up_count
+ row["sector_breadth_ma20"] = round(breadth_ma20, 1)
row["relative_strength"] = round(row["return_5d"] - market_return, 2)
return factors, actual_date
@@ -560,13 +987,16 @@ class ScreenerEngine:
) -> list[dict[str, Any]]:
universe = formula["universe"]
eligible = []
+ score_fields = [item["field"] for item in formula["score"]]
for row in rows:
name = str(row.get("name") or "")
if universe.get("exclude_st") and ("ST" in name.upper() or "退" in name):
continue
if row.get("listed_days", 0) < universe.get("listed_days_min", 0):
continue
- if all(_matches(row.get(item["field"], 0), item["op"], item["value"]) for item in formula["filters"]):
+ if any(row.get(field) is None for field in score_fields):
+ continue
+ if all(_matches(row.get(item["field"]), item["op"], item["value"]) for item in formula["filters"]):
eligible.append(row)
if not eligible:
return []
@@ -707,7 +1137,49 @@ def compile_local_strategy(prompt: str, regime: str) -> dict[str, Any]:
}
+def _optional_number(value: Any) -> float | None:
+ if value in (None, ""):
+ return None
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return None
+ return result if math.isfinite(result) else None
+
+
+def _rounded_optional(value: Any, digits: int = 2) -> float | None:
+ parsed = _optional_number(value)
+ return round(parsed, digits) if parsed is not None else None
+
+
+def _limit_threshold(code: str, name: str) -> float:
+ if code.startswith(("4", "8")):
+ return 29.0
+ if code.startswith(("30", "68")):
+ return 19.0
+ return 9.5
+
+
+def _is_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
+ if index < 0 or index >= len(rows):
+ return False
+ return _number(rows[index].get("pct_chg")) >= _limit_threshold(code, name)
+
+
+def _touched_limit_bar(rows: list[dict[str, Any]], index: int, code: str, name: str) -> bool:
+ if index <= 0 or index >= len(rows):
+ return False
+ previous_close = _number(rows[index - 1].get("close"))
+ high = _number(rows[index].get("high"))
+ if previous_close <= 0 or high <= 0:
+ return False
+ touched_change = (high / previous_close - 1) * 100
+ return touched_change >= _limit_threshold(code, name)
+
+
def _matches(actual: Any, operator: str, expected: Any) -> bool:
+ if actual is None:
+ return False
try:
if operator == "between":
return float(expected[0]) <= float(actual) <= float(expected[1])
diff --git a/server.py b/server.py
index 3bdd13d..f66f8a2 100644
--- a/server.py
+++ b/server.py
@@ -56,6 +56,7 @@ from market_insights import MarketInsightsService
from realtime_aggregator import WebRealtimeAggregator
from screener import (
FACTOR_FIELDS,
+ FACTOR_GROUPS,
REGIMES,
FactorDataService,
ScreenerEngine,
@@ -1093,12 +1094,44 @@ class DashboardService:
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 100)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
+ factor_health = self.screener.factor_health(normalized_date)
+ strategies = self.database.list_screener_strategies(self.current_user_id)
+ valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
+ fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
+ auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
+ for strategy in strategies:
+ formula = strategy.get("formula") or {}
+ used_fields = {
+ str(item.get("field") or "")
+ for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
+ }
+ missing = []
+ if len(factor_dates) < 21:
+ missing.append("基础行情")
+ if used_fields & valuation_fields and not factor_health["valuation"]:
+ missing.append("估值数据")
+ if used_fields & fundamental_fields and not factor_health["fundamental"]:
+ missing.append("财务质量")
+ if "dividend_years" in used_fields and not factor_health["dividend_history"]:
+ missing.append("历年分红")
+ if used_fields & auction_fields and not factor_health["auction"]:
+ missing.append("竞价数据")
+ strategy["data_ready"] = not missing
+ strategy["missing_data"] = missing
return {
"trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
- "strategies": self.database.list_screener_strategies(self.current_user_id),
+ "strategies": strategies,
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
+ "factor_groups": [
+ {
+ "name": name,
+ "fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
+ }
+ for name, fields in FACTOR_GROUPS.items()
+ ],
+ "operators": [">", ">=", "<", "<=", "==", "between"],
"factor_data": {
"date_count": len(factor_dates),
"start_date": factor_dates[0] if factor_dates else "",
@@ -1106,6 +1139,7 @@ class DashboardService:
"ready": len(factor_dates) >= 21,
"auction_date_count": len(auction_dates),
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
+ "health": factor_health,
},
"llm": {
"configured": self.llm_configured,
@@ -1113,12 +1147,31 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
- "latest_result": self.database.latest_screener_run(self.current_user_id, normalized_date),
+ "latest_results": self.database.latest_screener_runs(
+ self.current_user_id, normalized_date
+ ),
+ # Kept during the client transition for compatibility with older frontends.
+ "latest_result": self.database.latest_screener_run(
+ self.current_user_id, normalized_date, "smart"
+ ),
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
+ def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
+ try:
+ run_id = int(payload.get("run_id") or 0)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("选股批次无效。") from exc
+ code = str(payload.get("code") or "").strip()
+ if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
+ raise ValueError("选股批次或股票代码无效。")
+ return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
+
+ def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
+ return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
+
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
notice = ""
@@ -1167,6 +1220,86 @@ class DashboardService:
self.current_user_id, start_date, end_date, code
)
+ def review_watchlist(self, trade_date: str) -> dict[str, Any]:
+ normalized_date = normalize_date(trade_date)
+ items = self.database.list_watchlist(self.current_user_id)
+ if not items:
+ return {"items": [], "trade_date": normalized_date}
+
+ resolved_date = normalized_date
+ if self.configured:
+ try:
+ client = TushareClient(self.token)
+ resolved_date, _ = client.resolve_trade_context(normalized_date)
+ history = self.database.watchlist_price_history(
+ [str(item["code"]) for item in items], resolved_date
+ )
+ missing_codes = [
+ str(item["code"]) for item in items
+ if len(history.get(str(item["code"])) or []) < 6
+ ]
+ start_date = (
+ datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
+ ).strftime("%Y%m%d")
+ for code in missing_codes:
+ rows = client.query(
+ "daily",
+ {
+ "ts_code": tushare_code(code),
+ "start_date": start_date,
+ "end_date": resolved_date,
+ },
+ "ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
+ )
+ if rows:
+ self.database.upsert_daily_bars(rows)
+ if missing_codes:
+ history = self.database.watchlist_price_history(
+ [str(item["code"]) for item in items], resolved_date
+ )
+ except (TushareError, ValueError):
+ history = self.database.watchlist_price_history(
+ [str(item["code"]) for item in items], resolved_date
+ )
+ else:
+ history = self.database.watchlist_price_history(
+ [str(item["code"]) for item in items], resolved_date
+ )
+
+ auction_scores: dict[str, Any] = {}
+ try:
+ auction = self.auction_center(normalized_date, False)
+ auction_scores = {
+ str(row.get("code") or ""): row.get("attention_score")
+ for row in (auction.get("watchlist_rows") or [])
+ if row.get("available", True)
+ }
+ except (TushareError, ValueError):
+ pass
+
+ enriched = []
+ for item in items:
+ code = str(item.get("code") or "")
+ bars = history.get(code) or []
+ latest = bars[-1] if bars else {}
+ close = float(latest.get("close") or 0)
+ base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
+ enriched.append(
+ {
+ **item,
+ "change": (
+ round(float(latest.get("pct_chg") or 0), 2) if latest else None
+ ),
+ "return_5d": (
+ round((close / base_close - 1) * 100, 2)
+ if close > 0 and base_close > 0 else None
+ ),
+ "attention_score": auction_scores.get(code),
+ "market_date": str(latest.get("trade_date") or ""),
+ }
+ )
+ return {"items": enriched, "trade_date": resolved_date}
+
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_id = self.trade_journal.save(self.current_user_id, payload)
return {"id": trade_id, **self.trade_entries()}
@@ -2955,6 +3088,21 @@ class DashboardService:
raise ValueError("市场阶段不支持。")
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
formula = payload.get("formula") or {}
+ requested_mode = str(payload.get("mode") or "").strip()
+ if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
+ raise ValueError("选股模式不受支持。")
+ if requested_mode:
+ mode = requested_mode
+ else:
+ meta = formula.get("meta") if isinstance(formula, dict) else {}
+ library = str((meta or {}).get("library") or "")
+ category = str((meta or {}).get("category") or "")
+ if library == "curated":
+ mode = "curated"
+ elif library == "quant" or (library == "custom" and category == "量化公式"):
+ mode = "quant"
+ else:
+ mode = "smart"
realtime_snapshot = None
dashboard = self.get_dashboard(trade_date)
if self.configured and dashboard.get("meta", {}).get("realtime"):
@@ -2966,13 +3114,7 @@ class DashboardService:
self.current_user_id, trade_date, formula, regime, strategy_name,
bool(payload.get("run_backtest", True)),
realtime_snapshot,
- )
- self.strategy_tracking.record_run(
- self.current_user_id,
- int(result.get("meta", {}).get("run_id") or 0),
- normalize_date(str(result.get("meta", {}).get("trade_date") or trade_date)),
- strategy_name,
- list(result.get("candidates") or []),
+ mode,
)
return result
@@ -4007,9 +4149,15 @@ class RequestHandler(BaseHTTPRequestHandler):
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/watchlist":
- self.send_json(
- {"items": SERVICE.database.list_watchlist(SERVICE.current_user_id)}
- )
+ query = parse_qs(parsed.query)
+ try:
+ self.send_json(
+ SERVICE.review_watchlist(
+ query.get("trade_date", [date.today().isoformat()])[0]
+ )
+ )
+ except ValueError as exc:
+ self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/notes":
query = parse_qs(parsed.query)
@@ -4193,6 +4341,13 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/screener/run":
self.run_screener()
return
+ if parsed.path == "/api/screener/tracking":
+ try:
+ result = SERVICE.add_screener_tracking(self.read_json_body())
+ self.send_json({"ok": True, **result})
+ except (ValueError, json.JSONDecodeError) as exc:
+ self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+ return
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
@@ -4250,6 +4405,11 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
+ tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
+ if tracking_match:
+ result = SERVICE.remove_screener_tracking(int(tracking_match.group(1)))
+ self.send_json({"ok": True, **result})
+ return
watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
if watchlist_match:
deleted = SERVICE.database.delete_watchlist(
@@ -4576,8 +4736,9 @@ class RequestHandler(BaseHTTPRequestHandler):
color = str(body.get("color") or "red")
if color not in {"red", "blue", "green", "amber"}:
raise ValueError("标记颜色不支持。")
+ remark = validate_text(body.get("remark"), "跟踪备注", 240)
SERVICE.database.save_watchlist(
- SERVICE.current_user_id, code, name, sector, color
+ SERVICE.current_user_id, code, name, sector, color, remark
)
self.send_json(
{
@@ -4596,10 +4757,11 @@ class RequestHandler(BaseHTTPRequestHandler):
code = validate_stock_code(code)
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
+ summary = validate_text(body.get("summary"), "盘面摘要", 500)
content = validate_text(body.get("content"), "复盘内容", 5000)
plan = validate_text(body.get("plan"), "明日计划", 2000)
- if not content and not plan:
- raise ValueError("复盘内容和明日计划不能同时为空。")
+ if not summary and not content and not plan:
+ raise ValueError("每日复盘内容不能全部为空。")
raw_id = body.get("id")
note_id = int(raw_id) if raw_id else None
saved_id = SERVICE.database.save_note(
@@ -4610,6 +4772,7 @@ class RequestHandler(BaseHTTPRequestHandler):
content,
plan,
note_id,
+ summary=summary,
)
self.send_json({"ok": True, "id": saved_id})
except (ValueError, TypeError, json.JSONDecodeError) as exc:
diff --git a/static/app.js b/static/app.js
index 7a71bbf..2fd1ac5 100644
--- a/static/app.js
+++ b/static/app.js
@@ -28,6 +28,16 @@ const state = {
query: "",
sortKey: "streak",
sortDirection: "desc",
+ brokenQuery: "",
+ brokenSortKey: "",
+ brokenSortDirection: "desc",
+ downQuery: "",
+ downSortKey: "",
+ downSortDirection: "asc",
+ yesterdayFilter: "all",
+ yesterdayQuery: "",
+ yesterdaySortKey: "",
+ yesterdaySortDirection: "desc",
activeView: "limitPool",
dragonTiger: null,
dragonFilter: "all",
@@ -36,11 +46,14 @@ const state = {
rotationHistory: null,
rotationHistoryKey: "",
rotationSelectedSector: "",
+ rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
rotationLoading: false,
auctionData: null,
auctionDataset: "focus",
auctionFilter: "all",
auctionQuery: "",
+ auctionSortKey: "attention_score",
+ auctionSortDirection: "desc",
auctionLoading: false,
auctionTimer: null,
themeLibrary: null,
@@ -53,6 +66,7 @@ const state = {
popularityQuery: "",
popularityLoading: false,
expandedLadderLevels: new Set(),
+ ladderSortMode: "time",
stockDetail: null,
activeStock: null,
stockDetailChartMode: "daily",
@@ -68,16 +82,34 @@ const state = {
stockPreviewChart: "daily",
stockPreviewFallback: null,
watchlist: [],
+ watchlistSelection: null,
+ watchlistSearchResults: [],
+ watchlistSearchRequestSequence: 0,
+ editingDailyNoteId: 0,
notes: [],
tradeEntries: [],
tradeSummary: {},
editingTradeId: 0,
initialStockOpened: false,
screenerSetup: null,
+ screenerSetupKey: "",
+ screenerSetupRequestKey: "",
+ screenerSetupPromise: null,
selectedRegime: "",
selectedStrategy: null,
- screenerResult: null,
+ screenerRunning: false,
+ screenerRunningMode: "",
+ screenerResults: { smart: null, curated: null, quant: null },
+ screenerResultContexts: { smart: null, curated: null, quant: null },
screenerTracking: null,
+ screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
+ ? localStorage.getItem("xiaobaiScreenerMode")
+ : "smart",
+ curatedCategory: "全部",
+ curatedQuery: "",
+ selectedCuratedStrategyId: 0,
+ quantFilters: [],
+ quantScores: [],
alerts: [],
alertFilter: "all",
alertUnreadCount: 0,
@@ -139,6 +171,7 @@ const elements = {
toast: document.querySelector("#toast"),
stockDialog: document.querySelector("#stockDialog"),
tradeLogDialog: document.querySelector("#tradeLogDialog"),
+ watchlistDialog: document.querySelector("#watchlistDialog"),
alertsDialog: document.querySelector("#alertsDialog"),
assistantDialog: document.querySelector("#assistantDialog"),
heavenReadingDialog: document.querySelector("#heavenReadingDialog"),
@@ -195,6 +228,7 @@ let stockPreviewAnchor = null;
let sentimentChartAnimationFrame = null;
let heavenResizeTimer = null;
let globalSearchTimer = null;
+let watchlistSearchTimer = null;
let assistantRenderFrame = 0;
const heartSound = {
@@ -417,6 +451,15 @@ function bindEvents() {
state.query = event.target.value.trim().toLowerCase();
renderLimitTable();
});
+ document.querySelectorAll("[data-table-search]").forEach((input) => {
+ input.addEventListener("input", () => {
+ const query = input.value.trim().toLowerCase();
+ const body = document.querySelector(`#${CSS.escape(input.dataset.tableSearch)}`);
+ body?.querySelectorAll("tr").forEach((row) => {
+ row.hidden = Boolean(query) && !row.textContent.toLowerCase().includes(query);
+ });
+ });
+ });
document.querySelectorAll("[data-filter]").forEach((button) => {
button.addEventListener("click", () => {
@@ -501,15 +544,68 @@ function bindEvents() {
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
header.addEventListener("click", () => changeSort(header.dataset.sort));
});
+ document.querySelector("#brokenSearch").addEventListener("input", (event) => {
+ state.brokenQuery = event.target.value.trim().toLowerCase();
+ renderBrokenTable(state.dashboard?.broken || []);
+ });
+ document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
+ header.addEventListener("click", () => changeBrokenSort(header.dataset.brokenSort));
+ });
+ document.querySelector("#downSearch").addEventListener("input", (event) => {
+ state.downQuery = event.target.value.trim().toLowerCase();
+ renderDownTable(state.dashboard?.down_limits || []);
+ });
+ document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
+ header.addEventListener("click", () => changeDownSort(header.dataset.downSort));
+ });
+ document.querySelector("#yesterdaySearch").addEventListener("input", (event) => {
+ state.yesterdayQuery = event.target.value.trim().toLowerCase();
+ renderYesterdayTable(state.dashboard?.yesterday_limits || []);
+ });
+ document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.yesterdayFilter = button.dataset.yesterdayFilter;
+ renderYesterdayTable(state.dashboard?.yesterday_limits || []);
+ });
+ });
+ document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
+ header.addEventListener("click", () => changeYesterdaySort(header.dataset.yesterdaySort));
+ });
+ document.querySelectorAll("[data-ladder-sort]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.ladderSortMode = button.dataset.ladderSort === "open" ? "open" : "time";
+ document.querySelectorAll("[data-ladder-sort]").forEach((item) => {
+ const active = item === button;
+ item.classList.toggle("active", active);
+ item.setAttribute("aria-pressed", String(active));
+ });
+ renderLadderBoard(state.dashboard?.ladders || []);
+ });
+ });
document.querySelector("#exportButton").addEventListener("click", exportStocks);
document.querySelector("#brokenExportButton").addEventListener("click", exportBroken);
document.querySelector("#downExportButton").addEventListener("click", exportDown);
document.querySelector("#yesterdayExportButton").addEventListener("click", exportYesterday);
+ document.querySelector("#ladderExportButton").addEventListener("click", exportLadder);
document.querySelector("#rotationExportButton").addEventListener("click", exportRotation);
- document.querySelector("#clearRotationSelection").addEventListener("click", () => {
- state.rotationSelectedSector = "";
- renderRotationHistory();
+ document.querySelectorAll("[data-rotation-order]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.rotationOrder = button.dataset.rotationOrder === "latest" ? "latest" : "oldest";
+ localStorage.setItem("xiaobaiRotationOrder", state.rotationOrder);
+ renderRotationHistory();
+ });
+ });
+ document.querySelector("#overviewToggle").addEventListener("click", () => {
+ const overview = document.querySelector(".overview-strip");
+ const expanded = overview.dataset.overviewExpanded !== "true";
+ overview.dataset.overviewExpanded = String(expanded);
+ const toggle = document.querySelector("#overviewToggle");
+ toggle.setAttribute("aria-expanded", String(expanded));
+ toggle.title = expanded ? "收起市场详情" : "展开市场详情";
+ toggle.querySelector("span").textContent = expanded ? "收起详情" : "展开详情";
+ toggle.querySelector("i").setAttribute("data-lucide", expanded ? "chevron-up" : "chevron-down");
+ refreshIcons();
});
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
@@ -554,6 +650,7 @@ function bindEvents() {
document.addEventListener("keydown", handleStockPreviewKeydown);
document.addEventListener("scroll", repositionStockPreview, true);
document.querySelector("#auctionRefreshButton").addEventListener("click", () => loadAuctionCenter(true));
+ document.querySelector("#auctionExportButton").addEventListener("click", exportAuctionRows);
document.querySelector("#auctionSearch").addEventListener("input", (event) => {
state.auctionQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
renderAuctionTable();
@@ -562,6 +659,8 @@ function bindEvents() {
button.addEventListener("click", () => {
state.auctionDataset = button.dataset.auctionDataset || "focus";
state.auctionFilter = "all";
+ state.auctionSortKey = state.auctionDataset === "onePrice" ? "amount_million" : "attention_score";
+ state.auctionSortDirection = "desc";
document.querySelectorAll("[data-auction-dataset]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
@@ -578,6 +677,23 @@ function bindEvents() {
renderAuctionTable();
});
});
+ document.querySelector("#auctionTable").addEventListener("click", (event) => {
+ const header = event.target.closest("th[data-auction-sort]");
+ if (!header) return;
+ const key = header.dataset.auctionSort;
+ if (state.auctionSortKey === key) state.auctionSortDirection = state.auctionSortDirection === "asc" ? "desc" : "asc";
+ else {
+ state.auctionSortKey = key;
+ state.auctionSortDirection = "desc";
+ }
+ renderAuctionTable();
+ });
+ document.querySelector("#changeStrategyButton").addEventListener("click", () => openStrategyDrawer("library"));
+ document.querySelector("#openStrategyDrawerButton").addEventListener("click", () => openStrategyDrawer("editor"));
+ document.querySelector("#closeStrategyDrawerButton").addEventListener("click", () => document.querySelector("#strategyDrawer").close());
+ document.querySelector("#strategyDrawer").addEventListener("click", (event) => {
+ if (event.target === event.currentTarget) event.currentTarget.close();
+ });
document.querySelector("#themeRefreshButton").addEventListener("click", () => loadThemeLibrary(true));
document.querySelector("#themeSearch").addEventListener("input", (event) => {
state.themeQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
@@ -603,7 +719,9 @@ function bindEvents() {
renderPopularityTable();
});
});
- document.querySelector("#dragonRefreshButton").addEventListener("click", () => loadDragonTiger(false));
+ document.querySelector("#dragonRefreshButton").addEventListener("click", () => loadDragonTiger(true));
+ document.querySelector("#dragonEmptyRefreshButton").addEventListener("click", () => loadDragonTiger(true));
+ document.querySelector("#dragonPreviousButton").addEventListener("click", () => shiftDate(-1));
document.querySelector("#dragonExportButton").addEventListener("click", exportDragonTiger);
document.querySelector("#dragonSearch").addEventListener("input", (event) => {
state.dragonQuery = event.target.value.trim().toLowerCase();
@@ -619,6 +737,22 @@ function bindEvents() {
});
});
document.querySelector("#journalForm").addEventListener("submit", saveJournal);
+ document.querySelector("#journalDate").addEventListener("change", populateJournalForm);
+ document.querySelector("#openWatchlistDialog").addEventListener("click", () => openWatchlistDialog());
+ document.querySelector("#closeWatchlistDialog").addEventListener("click", closeWatchlistDialog);
+ document.querySelector("#cancelWatchlistEdit").addEventListener("click", closeWatchlistDialog);
+ document.querySelector("#changeWatchlistSelection").addEventListener("click", clearWatchlistSelection);
+ document.querySelector("#watchlistSearchInput").addEventListener("input", scheduleWatchlistSearch);
+ document.querySelector("#watchlistForm").addEventListener("submit", saveWatchlistFromDialog);
+ document.querySelector("#watchlistSearchResults").addEventListener("click", handleWatchlistSearchResult);
+ document.querySelector("#reviewHistoryToggle").addEventListener("click", (event) => {
+ const panel = document.querySelector("#reviewHistoryPanel");
+ const expanded = event.currentTarget.getAttribute("aria-expanded") === "true";
+ event.currentTarget.setAttribute("aria-expanded", String(!expanded));
+ event.currentTarget.querySelector("span").textContent = expanded ? "历史复盘" : "收起历史";
+ panel.hidden = expanded;
+ if (!expanded) panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ });
document.querySelector("#openTradeLogDialog").addEventListener("click", openTradeLogDialog);
document.querySelector("#closeTradeLogDialog").addEventListener("click", closeTradeLogDialog);
document.querySelector("#tradeLogForm").addEventListener("submit", saveTradeLog);
@@ -633,7 +767,13 @@ function bindEvents() {
document.querySelector("#backfillButton").addEventListener("click", backfillData);
document.querySelector("#factorSyncButton").addEventListener("click", syncFactorData);
document.querySelector("#screenerRunButton").addEventListener("click", runScreener);
+ document.querySelector("#openScreenerTrackingButton").addEventListener("click", async () => {
+ await loadScreenerTracking(true);
+ openView("screenerTrackingView");
+ });
+ document.querySelector("#closeScreenerTrackingButton").addEventListener("click", () => openView("screenerView"));
document.querySelector("#refreshTrackingButton").addEventListener("click", refreshScreenerTracking);
+ document.querySelector("#trackingTableBody").addEventListener("click", handleTrackingTableAction);
document.querySelectorAll("[data-screener-mobile-view]").forEach((button) => {
button.addEventListener("click", () => selectScreenerMobileView(button.dataset.screenerMobileView));
});
@@ -642,6 +782,43 @@ function bindEvents() {
document.querySelector("#deleteStrategyButton").addEventListener("click", deleteCurrentStrategy);
document.querySelector("#screenerExportButton").addEventListener("click", exportScreenerResults);
document.querySelector("#runBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
+ document.querySelectorAll("[data-screener-mode]").forEach((button) => {
+ button.addEventListener("click", () => selectScreenerMode(button.dataset.screenerMode));
+ });
+ document.querySelector("#curatedStrategyList").addEventListener("click", (event) => {
+ if (event.target.closest("button")) return;
+ const card = event.target.closest("[data-curated-strategy]");
+ if (!card) return;
+ state.selectedCuratedStrategyId = number(card.dataset.curatedStrategy);
+ renderCuratedStrategyLibrary();
+ renderScreenerResult();
+ });
+ document.querySelector("#curatedStrategySearch").addEventListener("input", (event) => {
+ state.curatedQuery = event.target.value.trim().toLocaleLowerCase("zh-CN");
+ renderCuratedStrategyLibrary();
+ });
+ document.querySelector("#curatedRunButton").addEventListener("click", runCuratedStrategy);
+ document.querySelector("#curatedBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
+ document.querySelector("#closeCuratedDetailButton").addEventListener("click", () => document.querySelector("#curatedDetailDialog").close());
+ document.querySelector("#curatedDetailDialog").addEventListener("click", (event) => {
+ if (event.target === event.currentTarget) event.currentTarget.close();
+ });
+ document.querySelector("#quantResetButton").addEventListener("click", resetQuantBuilder);
+ document.querySelector("#addQuantFilterButton").addEventListener("click", () => addQuantFilter());
+ document.querySelector("#addQuantScoreButton").addEventListener("click", () => addQuantScore());
+ document.querySelector("#quantFilterRows").addEventListener("input", handleQuantBuilderInput);
+ document.querySelector("#quantFilterRows").addEventListener("change", handleQuantBuilderInput);
+ document.querySelector("#quantFilterRows").addEventListener("click", handleQuantBuilderClick);
+ document.querySelector("#quantScoreRows").addEventListener("input", handleQuantBuilderInput);
+ document.querySelector("#quantScoreRows").addEventListener("change", handleQuantBuilderInput);
+ document.querySelector("#quantScoreRows").addEventListener("click", handleQuantBuilderClick);
+ ["quantListedDays", "quantLimit", "quantMinScore", "quantExcludeSt"].forEach((id) => {
+ document.querySelector(`#${id}`).addEventListener("input", renderQuantSummary);
+ document.querySelector(`#${id}`).addEventListener("change", renderQuantSummary);
+ });
+ document.querySelector("#quantRunButton").addEventListener("click", runQuantStrategy);
+ document.querySelector("#quantSaveButton").addEventListener("click", saveQuantAsStrategy);
+ document.querySelector("#quantBacktestToggle").addEventListener("change", updateBacktestTaskStatus);
document.querySelector("#mentorChatForm").addEventListener("submit", sendMentorQuestion);
document.querySelector("#clearMentorChatButton").addEventListener("click", clearMentorConversation);
document.querySelector("#mentorDirectoryToggle").addEventListener("click", () => {
@@ -817,6 +994,7 @@ function applyDashboard(payload, background = false) {
if (!background) {
if (state.activeView === "dragonView") loadDragonTiger();
if (state.activeView === "screenerView") loadScreenerSetup();
+ if (state.activeView === "screenerTrackingView") loadScreenerTracking(true);
if (state.activeView === "mentorView") loadMentorSetup(true);
if (state.activeView === "heavenView") loadHeavenSetup(true);
if (state.activeView === "sentimentCycleView") loadSentimentHistory(true);
@@ -935,24 +1113,48 @@ function renderSentimentHistory() {
setText("sentimentCycleLabel", latest.label);
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
setText("sentimentCyclePhase", latest.phase);
- setText("sentimentCycleDirection", `${latest.direction} · 动量 ${latest.momentum > 0 ? "+" : ""}${formatNumber(latest.momentum, 1)}`);
+ setText("sentimentCycleDirection", latest.direction);
+ const dayChange = number(latest.day_change);
+ const confidence = sentimentPhaseConfidence(latest);
+ setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
+ setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
+ setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
+ setText("sentimentLimitUp", number(latest.limit_up_count));
+ setText("sentimentBroken", number(latest.broken_count));
+ setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
+ setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
+ setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
+ setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`);
+ const changeElement = document.querySelector("#sentimentDayChange");
+ changeElement.className = changeClass(dayChange);
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`);
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`);
const marker = document.querySelector("#sentimentCycleScoreMarker");
- marker.className = `sentiment-cycle-score-marker ${sentimentPhaseClass(latest.phase)}`;
+ marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
+ document.querySelectorAll("[data-sentiment-stage]").forEach((item) => {
+ const current = item.dataset.sentimentStage === latest.phase;
+ item.classList.toggle("current", current);
+ item.hidden = !current;
+ const label = item.querySelector("strong");
+ if (label) label.textContent = `${item.dataset.sentimentStage}${current ? "(当前)" : ""}`;
+ });
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
- ${escapeHtml(item.label)}权重 ${number(item.weight)}${formatNumber(item.score, 1)}
-
+
+
${escapeHtml(item.label)}
+
+
${formatNumber(item.score, 1)} × ${number(item.weight)}%
+
${escapeHtml(item.summary)}
`).join("");
requestAnimationFrame(() => {
animateSentimentComponents();
animateSentimentTrendChart(rows);
+ bindSentimentChartTooltip(rows);
});
animateRows(body);
}
@@ -1024,6 +1226,50 @@ function drawSentimentTrendChart(rows, progress = 1) {
context.beginPath();
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
context.clip();
+
+ const finalPhase = rows[rows.length - 1]?.phase;
+ let phaseStart = rows.length - 1;
+ while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
+ if (["退潮", "冰点"].includes(finalPhase)) {
+ const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
+ context.fillStyle = "rgba(224, 69, 54, .05)";
+ context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
+ context.fillStyle = "#e04536";
+ context.font = '10px "Microsoft YaHei UI", sans-serif';
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
+ }
+
+ const movingAverage = rows.map((_row, index) => {
+ const start = Math.max(0, index - 4);
+ const sample = rows.slice(start, index + 1);
+ return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
+ });
+ context.beginPath();
+ movingAverage.forEach((score, index) => {
+ if (index === 0) context.moveTo(x(index), y(score));
+ else context.lineTo(x(index), y(score));
+ });
+ context.strokeStyle = "#d1d5db";
+ context.lineWidth = 1.5;
+ context.setLineDash([5, 4]);
+ context.stroke();
+ context.setLineDash([]);
+
+ context.beginPath();
+ rows.forEach((row, index) => {
+ const pointX = x(index);
+ const pointY = y(row.score);
+ if (index === 0) context.moveTo(pointX, pointY);
+ else context.lineTo(pointX, pointY);
+ });
+ context.lineTo(x(rows.length - 1), padding.top + chartHeight);
+ context.lineTo(x(0), padding.top + chartHeight);
+ context.closePath();
+ context.fillStyle = "rgba(37, 99, 235, .07)";
+ context.fill();
+
context.beginPath();
rows.forEach((row, index) => {
const pointX = x(index);
@@ -1040,8 +1286,11 @@ function drawSentimentTrendChart(rows, progress = 1) {
rows.forEach((row, index) => {
context.beginPath();
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
- context.fillStyle = row.direction === "降温" ? "#d64955" : row.direction === "升温" ? "#087f67" : "#1268c4";
+ context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? "#e04536" : row.phase === "修复" ? "#f59e0b" : "#2563eb";
context.fill();
+ context.strokeStyle = "#fff";
+ context.lineWidth = 1.5;
+ context.stroke();
});
context.restore();
@@ -1056,6 +1305,26 @@ function drawSentimentTrendChart(rows, progress = 1) {
});
}
+function bindSentimentChartTooltip(rows) {
+ const canvas = document.querySelector("#sentimentTrendChart");
+ const tooltip = document.querySelector("#sentimentChartTooltip");
+ if (!canvas || !tooltip || !rows.length) return;
+ canvas.onmousemove = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ const padding = { left: 42, right: 18 };
+ const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
+ const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
+ const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
+ const row = rows[index];
+ tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 ${number(row.score)} · ${escapeHtml(row.phase)}`;
+ tooltip.hidden = false;
+ const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
+ tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
+ tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
+ };
+ canvas.onmouseleave = () => { tooltip.hidden = true; };
+}
+
function sentimentScoreClass(score) {
const value = number(score);
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
@@ -1072,6 +1341,25 @@ function sentimentPhaseClass(phase) {
}[phase] || "phase-divergence";
}
+function sentimentPhaseConfidence(row) {
+ const explicit = number(row?.confidence || row?.phase_confidence);
+ if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
+ const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
+ const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
+ return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
+}
+
+function sentimentPhaseAdvice(phase) {
+ return {
+ "冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
+ "修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
+ "发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
+ "高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
+ "分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
+ "退潮": "情绪指标继续走弱。",
+ }[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
+}
+
function getVisibleStocks() {
if (!state.dashboard) return [];
let rows = [...(state.dashboard.limits || [])];
@@ -1090,95 +1378,353 @@ function getVisibleStocks() {
function renderLimitTable() {
if (!state.dashboard) return;
const rows = getVisibleStocks();
+ const allRows = state.dashboard.limits || [];
const body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
- | ${index + 1} |
- ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} |
- ${streakLabel(row.streak)} |
- ${signed(row.change)}% |
- ${formatNumber(row.price, 2)} |
+ ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${streakLabel(row.streak)} |
+ ${signed(row.change)} |
+ ${formatNumber(row.price, 2)} |
${escapeHtml(row.sector || "其他")} |
- ${escapeHtml(row.reason || "--")} |
- ${escapeHtml(row.first_time || "--")} |
- ${escapeHtml(row.last_time || "--")} |
- ${number(row.open_times)} |
- ${formatNumber(row.turnover_rate, 2)}% |
- ${formatNumber(row.amount_billion, 2)} 亿 |
- ${formatNumber(row.seal_amount_million, 0)} 万 |
+ ${escapeHtml(row.first_time || "")} |
+ ${escapeHtml(row.last_time || "")} |
+ ${limitOpenState(row)} |
+ ${formatNumber(row.turnover_rate, 2)} |
+ ${formatNumber(row.amount_billion, 2)} |
+ ${formatLimitSealAmount(row.seal_amount_million)} |
+ ${escapeHtml(row.reason || "")} |
`).join("");
bindStockRows(body);
setText("resultCount", `${rows.length} 只`);
+ setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
+ setText("limitAllCount", allRows.length);
+ setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
+ setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
+ setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
document.querySelector("#emptyState").hidden = rows.length !== 0;
updateSortHeaders();
}
+function limitOpenState(row) {
+ const openTimes = number(row.open_times);
+ const firstTime = String(row.first_time || "");
+ if (firstTime.startsWith("09:25") && openTimes === 0) return '一字';
+ if (openTimes >= 6) return `烂板×${openTimes}`;
+ return String(openTimes);
+}
+
+function formatLimitSealAmount(value) {
+ const amount = number(value);
+ if (!amount) return "";
+ return Math.round(amount).toLocaleString("zh-CN");
+}
+
function renderBrokenTable(rows) {
+ const visibleRows = getVisibleBrokenRows(rows);
setText("brokenCount", `${rows.length} 只`);
+ setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
const body = document.querySelector("#brokenTableBody");
- body.innerHTML = rows.map((row, index) => `
+ body.innerHTML = visibleRows.map((row, index) => `
- | ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${signed(row.change)}% |
- ${formatNumber(row.price, 2)} | ${escapeHtml(row.sector)} |
- ${escapeHtml(row.reason || "--")} | ${escapeHtml(row.first_time || "--")} |
- ${escapeHtml(row.last_time || "--")} | ${number(row.open_times)} |
- ${formatNumber(row.turnover_rate, 2)}% | ${formatNumber(row.amount_billion, 2)} 亿 |
+ ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${signed(row.change)} |
+ ${formatNumber(row.limitGap, 2)} |
+ ${formatNumber(row.price, 2)} |
+ ${escapeHtml(row.sector || "其他")} |
+ ${escapeHtml(row.first_time || "")} |
+ ${brokenOpenState(row)} |
+ ${formatNumber(row.turnover_rate, 2)} |
+ ${formatNumber(row.amount_billion, 2)} |
+ ${escapeHtml(row.reason || "")} |
`).join("");
bindStockRows(body);
+ document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
+ updateBrokenSortHeaders();
+}
+
+function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
+ let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
+ if (state.brokenQuery) {
+ visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
+ }
+ if (!state.brokenSortKey) return visibleRows;
+ return visibleRows.sort((left, right) => {
+ const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
+ return state.brokenSortDirection === "asc" ? result : -result;
+ });
+}
+
+function brokenLimitRate(row) {
+ const name = String(row.name || "").toUpperCase();
+ const code = String(row.code || "").replace(/\D/g, "");
+ if (name.includes("ST")) return 10;
+ if (/^(300|301|688|689)/.test(code)) return 20;
+ if (/^(4|8|92)/.test(code)) return 30;
+ return 10;
+}
+
+function brokenLimitGap(row) {
+ return Math.max(0, brokenLimitRate(row) - number(row.change));
+}
+
+function brokenOpenState(row) {
+ const openTimes = number(row.open_times);
+ return openTimes >= 6
+ ? `反复炸 ×${openTimes}`
+ : String(openTimes);
+}
+
+function changeBrokenSort(key) {
+ if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
+ else {
+ state.brokenSortKey = key;
+ state.brokenSortDirection = "desc";
+ }
+ renderBrokenTable(state.dashboard?.broken || []);
+}
+
+function updateBrokenSortHeaders() {
+ document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
+ header.classList.remove("sort-asc", "sort-desc", "sorted");
+ header.setAttribute("aria-sort", "none");
+ if (header.dataset.brokenSort === state.brokenSortKey) {
+ header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
+ header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
+ }
+ const arrow = header.querySelector(".arr");
+ if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
+ });
}
function renderDownTable(rows) {
+ const visibleRows = getVisibleDownRows(rows);
setText("downCount", `${rows.length} 只`);
+ setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
+ renderDownSectorCluster(rows);
const body = document.querySelector("#downTableBody");
- body.innerHTML = rows.map((row, index) => `
+ body.innerHTML = visibleRows.map((row, index) => `
- | ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${signed(row.change)}% |
- ${formatNumber(row.price, 2)} | ${escapeHtml(row.sector)} |
- ${escapeHtml(row.reason || "--")} | ${number(row.streak)} |
- ${formatNumber(row.turnover_rate, 2)}% | ${formatNumber(row.amount_billion, 2)} 亿 |
+ ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${signed(row.change)} |
+ ${formatNumber(row.price, 2)} |
+ ${escapeHtml(row.sector || "其他")} |
+ ${formatNumber(row.turnover_rate, 2)} |
+ ${formatNumber(row.amount_billion, 2)} |
+ ${number(row.streak) > 0 ? number(row.streak) : ""} |
+ ${escapeHtml(row.reason || "")} |
`).join("");
bindStockRows(body);
+ document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
+ updateDownSortHeaders();
+}
+
+function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
+ let visibleRows = [...rows];
+ if (state.downQuery) {
+ visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
+ }
+ if (!state.downSortKey) return visibleRows;
+ return visibleRows.sort((left, right) => {
+ const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
+ return state.downSortDirection === "asc" ? result : -result;
+ });
+}
+
+function renderDownSectorCluster(rows) {
+ const counts = new Map();
+ rows.forEach((row) => {
+ const sector = String(row.sector || "其他").trim() || "其他";
+ if (sector === "其他") return;
+ counts.set(sector, (counts.get(sector) || 0) + 1);
+ });
+ const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
+ const element = document.querySelector("#downSectorCluster");
+ element.hidden = !cluster || cluster[1] < 2;
+ element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
+}
+
+function changeDownSort(key) {
+ if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
+ else {
+ state.downSortKey = key;
+ state.downSortDirection = "asc";
+ }
+ renderDownTable(state.dashboard?.down_limits || []);
+}
+
+function updateDownSortHeaders() {
+ document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
+ header.classList.remove("sort-asc", "sort-desc", "sorted");
+ header.setAttribute("aria-sort", "none");
+ if (header.dataset.downSort === state.downSortKey) {
+ header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
+ header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
+ }
+ const arrow = header.querySelector(".arr");
+ if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
+ });
}
function renderYesterdayTable(rows) {
+ const visibleRows = getVisibleYesterdayRows(rows);
+ const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
+ const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("yesterdayCount", `${rows.length} 只`);
- setText("previousTradeDate", `数据日期 ${state.dashboard.meta.previous_trade_date || "--"}`);
- document.querySelector("#yesterdayTableBody").innerHTML = rows.map((row, index) => `
+ setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
+ renderYesterdaySummary(rows);
+ const body = document.querySelector("#yesterdayTableBody");
+ body.innerHTML = visibleRows.map((row, index) => `
- | ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${streakLabel(row.prior_streak)} |
- ${signed(row.current_change)}% |
- ${escapeHtml(row.outcome)} |
- ${number(row.current_streak) ? streakLabel(row.current_streak) : "--"} |
- ${escapeHtml(row.sector || "其他")} | ${escapeHtml(row.reason || "--")} |
+ ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${number(row.prior_streak)} |
+ ${signed(row.current_change)} |
+ ${escapeHtml(row.outcome)} |
+ ${number(row.current_streak) ? `${number(row.current_streak)}` : ""} |
+ ${escapeHtml(row.sector || "其他")} |
+ ${escapeHtml(row.reason || "")} |
`).join("");
- bindStockRows(document.querySelector("#yesterdayTableBody"));
+ bindStockRows(body);
+ document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
+ updateYesterdayControls();
+}
+
+function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
+ let visibleRows = rows.filter((row) => {
+ if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
+ if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
+ if (state.yesterdayFilter === "fail") return row.outcome === "断板";
+ if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
+ return true;
+ });
+ if (state.yesterdayQuery) {
+ visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
+ }
+ if (!state.yesterdaySortKey) return visibleRows;
+ return visibleRows.sort((left, right) => {
+ const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
+ return state.yesterdaySortDirection === "asc" ? result : -result;
+ });
+}
+
+function renderYesterdaySummary(rows) {
+ const total = rows.length;
+ const advance = rows.filter((row) => row.outcome === "晋级").length;
+ const positive = rows.filter((row) => number(row.current_change) > 0).length;
+ const fail = rows.filter((row) => row.outcome === "断板").length;
+ const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
+ const rate = (value) => total ? value / total * 100 : 0;
+ setText("yesterdayAllCount", total);
+ setText("yesterdayAdvanceCount", advance);
+ setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
+ setText("yesterdayPositiveCount", positive);
+ setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
+ setText("yesterdayFailCount", fail);
+ setText("yesterdayFailRate", `占 ${formatNumber(rate(fail), 1)}%`);
+ setText("yesterdayRiskCount", risk);
+ setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
+}
+
+function yesterdayOutcomeClass(outcome) {
+ return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
+}
+
+function changeYesterdaySort(key) {
+ if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
+ else {
+ state.yesterdaySortKey = key;
+ state.yesterdaySortDirection = "desc";
+ }
+ renderYesterdayTable(state.dashboard?.yesterday_limits || []);
+}
+
+function updateYesterdayControls() {
+ document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
+ const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-pressed", String(active));
+ });
+ document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
+ header.classList.remove("sort-asc", "sort-desc", "sorted");
+ header.setAttribute("aria-sort", "none");
+ if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
+ header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
+ header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
+ }
+ const arrow = header.querySelector(".arr");
+ if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
+ });
}
function renderPerformance(rows) {
+ const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
+ const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
+ setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
-
-
- ${formatNumber(row.advance_rate, 1)}%晋级率
- 收红 ${formatNumber(row.positive_rate, 1)}%均涨 ${signed(row.average_change)}%
+
+ ${escapeHtml(row.label)} → 今日${performanceRateState(row.advance_rate).label}
+ ${formatNumber(row.advance_rate, 1)}%
+ 晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只
+
- `).join("") || '暂无昨日涨停统计
';
- document.querySelector("#performanceTableBody").innerHTML = rows.map((row) => `
- | ${escapeHtml(row.label)} | ${number(row.count)} |
- ${number(row.advanced)} | ${formatNumber(row.advance_rate, 1)}% |
- ${formatNumber(row.positive_rate, 1)}% |
- ${signed(row.average_change)}% |
- `).join("");
+ `).join("") || '暂无昨日涨停统计
';
+ renderPerformanceConclusion(rows);
renderMarketBreadth(state.dashboard?.overview || {});
}
+function performanceRateState(rate) {
+ const value = number(rate);
+ if (value === 0) return { label: "失效", className: "is-neutral" };
+ if (value < 20) return { label: "危险", className: "is-warning" };
+ return { label: "活跃", className: "is-active" };
+}
+
+function renderPerformanceConclusion(rows) {
+ const container = document.querySelector("#performanceConclusion");
+ if (!rows.length) {
+ container.innerHTML = '暂无昨日梯队数据,暂不生成结论
';
+ return;
+ }
+ const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
+ const highRows = sorted.filter((row) => number(row.level) >= 4);
+ const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
+ const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
+ const strongest = [...rows].sort((left, right) => (
+ number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
+ ))[0];
+ const firstBoard = rows.find((row) => number(row.level) === 1);
+ const overview = state.dashboard?.overview || {};
+ const phase = overview.sentiment_phase || "观察";
+ const up = number(overview.up_count);
+ const down = number(overview.down_count);
+ const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
+ const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
+ const highText = highRows.length
+ ? `高位晋级率${highAdvanced ? "仍有承接" : "全线失效"}:${highSamples}${highAdvanced ? `共晋级 ${highAdvanced} 只` : "今日均未晋级"};`
+ : "高位梯队暂无昨日样本,空间信号仍待确认;";
+ const strongestText = strongest
+ ? `${escapeHtml(strongest.label)}晋级率最高,为 ${formatNumber(strongest.advance_rate, 1)}%(${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
+ : "暂无相对占优梯队;";
+ const firstBoardText = firstBoard
+ ? `首板基数 ${number(firstBoard.count)} 只,晋级率 ${formatNumber(firstBoard.advance_rate, 1)}%,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"};`
+ : "首板梯队暂无有效样本;";
+ container.innerHTML = `
+ · ${highText}
+ · ${strongestText}
+ · ${firstBoardText}
+ · 结论:${stance},当前情绪周期「${escapeHtml(phase)}」。
+ `;
+}
+
function renderMarketBreadth(overview) {
const up = number(overview.up_count);
const down = number(overview.down_count);
@@ -1187,22 +1733,22 @@ function renderMarketBreadth(overview) {
const upRate = up / total * 100;
const flatRate = flat / total * 100;
const downRate = down / total * 100;
- const ratio = down > 0 ? up / down : up > 0 ? up : 0;
- const difference = up - down;
const panel = document.querySelector(".market-breadth-panel");
panel.classList.remove("breadth-enter");
void panel.offsetWidth;
panel.classList.add("breadth-enter");
- setText("breadthSummary", `${up + down + flat} 只股票参与统计`);
- animateMetric("breadthRatio", upRate, (value) => `红盘 ${formatNumber(value, 1)}%`);
- animateMetric("breadthUpCount", up, (value) => `${Math.round(value)} 家`);
- animateMetric("breadthFlatCount", flat, (value) => `${Math.round(value)} 家`);
- animateMetric("breadthDownCount", down, (value) => `${Math.round(value)} 家`);
- animateMetric("breadthAdvanceDecline", ratio, (value) => `${formatNumber(value, 2)} : 1`);
- animateMetric("breadthDifference", difference, (value) => `${value > 0 ? "+" : ""}${Math.round(value)} 家`);
- const differenceElement = document.querySelector("#breadthDifference");
- differenceElement.classList.remove("up", "down", "warning");
- differenceElement.classList.add(changeClass(difference));
+ setText("breadthDataTime", dashboardDataTimestamp(state.dashboard?.meta || {}));
+ animateMetric("breadthRatio", upRate, (value) => `${formatNumber(value, 1)}%`);
+ animateMetric("breadthUpCount", up, (value) => formatNumber(Math.round(value)));
+ animateMetric("breadthDownCount", down, (value) => formatNumber(Math.round(value)));
+ setText("breadthUpLegend", `${formatNumber(up)}(${formatNumber(upRate, 1)}%)`);
+ setText("breadthFlatLegend", `${formatNumber(flat)}(${formatNumber(flatRate, 1)}%)`);
+ setText("breadthDownLegend", `${formatNumber(down)}(${formatNumber(downRate, 1)}%)`);
+ document.querySelector("#breadthFlatLegendItem").hidden = flat === 0;
+ const limitUp = number(overview.limit_up_count);
+ const limitDown = number(overview.limit_down_count);
+ const breadthLabel = upRate < 20 ? "宽度极差" : upRate < 40 ? "宽度偏弱" : upRate < 55 ? "宽度均衡" : "宽度偏强";
+ setText("breadthWarning", `△ ${breadthLabel},涨跌停 ${limitUp}:${limitDown}`);
const bars = [
["breadthUpBar", upRate],
["breadthFlatBar", flatRate],
@@ -1250,26 +1796,67 @@ function renderRotationHistory() {
const rows = state.rotationHistory?.rows || [];
const selected = state.rotationSelectedSector;
const container = document.querySelector("#rotationHistory");
+ const tracker = document.querySelector("#rotationTracker");
if (!rows.length) {
container.innerHTML = '尚无连续交易日的板块数据
';
setText("rotationHistoryRange", "暂无轮动历史");
+ tracker.hidden = true;
return;
}
+ const chronological = [...rows]
+ .sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
+ .slice(-9);
+ const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
+ document.querySelectorAll("[data-rotation-order]").forEach((button) => {
+ button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
+ });
setText(
"rotationHistoryRange",
- `最近 ${rows.length} 个交易日 · ${displayCompactDate(rows[0].trade_date)} → ${displayCompactDate(rows[rows.length - 1].trade_date)} · 由近到远`,
+ `最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)} → ${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
);
- setText("rotationSelectionHint", selected ? `正在追踪:${selected}` : "左近右远 · 点击板块查看连续性");
- document.querySelector("#clearRotationSelection").hidden = !selected;
- container.innerHTML = rows.map((day) => {
+ setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
+ if (selected) {
+ const sequence = displayRows.map((day) => {
+ const sector = (day.sectors || []).find((item) => item.name === selected);
+ return { tradeDate: day.trade_date, sector };
+ });
+ const appearances = sequence.filter((item) => item.sector);
+ const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
+ tracker.hidden = false;
+ const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
+ tracker.innerHTML = `
+ ${escapeHtml(selected)}近 9 日在榜 ${appearances.length} 天 · 最高排名 #${bestRank || "--"} · ${continuity}
+
+ ${sequence.map((item) => item.sector
+ ? `#${number(item.sector.rank)}`
+ : `--`).join("")}
+
+ `;
+ tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
+ state.rotationSelectedSector = "";
+ renderRotationHistory();
+ updateRotationTableSelection();
+ });
+ } else {
+ tracker.hidden = true;
+ tracker.innerHTML = "";
+ }
+ container.classList.toggle("tracking", Boolean(selected));
+ const latestTradeDate = chronological[chronological.length - 1].trade_date;
+ container.innerHTML = displayRows.map((day) => {
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
return `
-
- ${(day.sectors || []).length} 个热点
- ${(day.sectors || []).map((sector) => `
- `).join("")}
+
+ ${(day.sectors || []).length} 个热点
+ ${(day.sectors || []).map((sector) => {
+ const strength = clamp(number(sector.strength), 0, 100);
+ const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
+ return `
+ `;
+ }).join("")}
`;
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
@@ -1278,6 +1865,7 @@ function renderRotationHistory() {
? ""
: button.dataset.rotationSector;
renderRotationHistory();
+ updateRotationTableSelection();
});
});
}
@@ -1285,59 +1873,128 @@ function renderRotationHistory() {
function renderRotationTable(rows, sectors) {
const sectorMap = new Map(sectors.map((sector) => [sector.name, sector]));
const body = document.querySelector("#rotationTableBody");
+ const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
+ setText("rotationDetailMeta", `${currentDate} · ${rows.length} 个板块`);
body.innerHTML = rows.map((row) => {
const sector = sectorMap.get(row.name) || {};
const strength = number(row.strength ?? sector.strength);
+ const currentCount = number(row.count);
+ const previousCount = number(row.previous_count);
+ const delta = row.delta == null ? currentCount - previousCount : number(row.delta);
+ const trend = previousCount === 0 && currentCount > 0
+ ? "新进"
+ : currentCount > previousCount
+ ? "升温"
+ : currentCount < previousCount ? "降温" : "持平";
return `
- | ${number(row.rank)} | ${escapeHtml(row.name)} |
- ${escapeHtml(row.trend)} |
- ${number(row.count)} | ${number(row.previous_count)} |
- ${number(row.delta) > 0 ? "+" : ""}${number(row.delta)} |
- ${formatNumber(strength, 0)} |
+
| ${number(row.rank)} | ${escapeHtml(row.name)} |
+ ${trend} |
+ ${currentCount} | ${previousCount} |
+ ${delta > 0 ? "+" : ""}${delta} |
+ ${formatNumber(strength, 0)} |
${streakLabel(row.max_streak || 1)} |
- ${signed(sector.change)}% |
- ${escapeHtml(row.leader || sector.leader || "--")} | ${formatNumber(row.amount_billion, 1)} 亿 |
+ ${signed(sector.change)}% |
+ ${escapeHtml(row.leader || sector.leader || "--")} | ${formatNumber(row.amount_billion, 1)} 亿 |
`;
}).join("");
+ body.querySelectorAll("[data-rotation-detail-sector]").forEach((row) => {
+ row.addEventListener("click", () => {
+ const sector = row.dataset.rotationDetailSector;
+ state.rotationSelectedSector = state.rotationSelectedSector === sector ? "" : sector;
+ renderRotationHistory();
+ updateRotationTableSelection();
+ document.querySelector("#rotationHistory").scrollIntoView({ behavior: "smooth", block: "center" });
+ });
+ });
animateRows(body);
}
+function updateRotationTableSelection() {
+ document.querySelectorAll("#rotationTableBody [data-rotation-detail-sector]").forEach((row) => {
+ row.classList.toggle("selected", row.dataset.rotationDetailSector === state.rotationSelectedSector);
+ });
+}
+
function renderLadderMini(ladders) {
const container = document.querySelector("#ladderMini");
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
setText("maxHeight", highest ? `最高 ${highest} 板` : "暂无");
container.innerHTML = ladders.slice(0, 5).map((group) => {
- const names = group.stocks.slice(0, 3).map((stock) => stock.name).join("、");
- return `${escapeHtml(group.label)}
- ${escapeHtml(names || "--")}${group.count}只
`;
+ const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
+ const visibleNames = allNames.slice(0, 3).join("、");
+ const suffix = allNames.length > 3 ? ` 等 ${number(group.count)} 只` : "";
+ return `
+
${escapeHtml(group.label)}${number(group.count)} 只
+
${escapeHtml(visibleNames || "--")}${suffix}
+
`;
}).join("") || '暂无梯队数据
';
}
function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
- ${escapeHtml(sector.name)}
- ${number(sector.count)}
+ ${escapeHtml(sector.name)}${number(sector.count)}
`).join("") || '暂无板块数据
';
}
function renderLadderBoard(ladders) {
const container = document.querySelector("#ladderBoard");
+ const insights = document.querySelector("#ladderInsights");
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
- const maxLevel = Math.max(1, ...ordered.map((group) => number(group.level)));
- container.innerHTML = ordered.map((group) => {
+ const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
+ const topVisibleLevel = Math.max(5, maxLevel);
+ const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
+ const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
+ const level = topVisibleLevel - index;
+ return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`, count: 0, stocks: [] };
+ });
+ const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
+ const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
+ const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
+ const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
+ setText("ladderDateRange", `数据日期 ${currentDate}`);
+ container.innerHTML = displayGroups.map((group) => {
const level = number(group.level);
- const limit = level === 1 ? 8 : 6;
+ const limit = level === 1 || level === 2 ? 8 : 99;
const expanded = state.expandedLadderLevels.has(level);
- const stocks = expanded ? group.stocks : group.stocks.slice(0, limit);
- const remaining = Math.max(0, group.stocks.length - stocks.length);
+ const groupStocks = [...(group.stocks || [])].sort((left, right) => {
+ if (state.ladderSortMode === "open") {
+ return number(left.open_times) - number(right.open_times)
+ || String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
+ }
+ return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
+ });
+ const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
+ const remaining = Math.max(0, groupStocks.length - stocks.length);
+ const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}板`);
+ const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
return `
-
-
- ${stocks.map((stock) => ``).join("")}
- ${group.stocks.length > limit ? `` : ""}
+
+ ${escapeHtml(label)}
${number(group.count)} 只
${number(group.count) && level > 1 ? `
${escapeHtml(label)} · ${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%
` : ""}
+ ${stocks.length ? stocks.map((stock) => {
+ const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
+ const broken = number(stock.open_times) >= 6;
+ const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)} 万` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
+ return `
`;
+ }).join("") : `
${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}
`}${groupStocks.length > limit ? `
` : ""}
`;
- }).join("") || '暂无连板数据
';
+ }).join("");
+ const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
+ const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
+ const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
+ label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}板`)} → 今日`,
+ value: clamp(number(row.advance_rate), 0, 100),
+ }));
+ const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
+ const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
+ const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
+ const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
+ insights.innerHTML = `
+ ${maxLevel ? `${maxLevel} 板` : "--"}${escapeHtml(spaceChange)}
${spaceStocks.length ? spaceStocks.map((stock) => `${escapeHtml(stock.name)}(${escapeHtml(stock.sector || "其他")})`).join(" · ") : "暂无空间板"}
${spaceNote}
+ ${structureRows.map((group) => `
${escapeHtml(group.label || `${number(group.level)}板`)}${number(group.count) ? `${number(group.count)} 只` : "断层"}
`).join("")}
断层越少,梯队从低位向高位传导越连贯。当前腰部为 ${escapeHtml(strongestGroup?.label || "--")}。
+ ${rateRows.length ? rateRows.map((row) => `
${escapeHtml(row.label)}${formatNumber(row.value, 1)}%
`).join("") : '
暂无可比梯队
'}
数据来自“涨停表现”页 · 昨日梯队样本`;
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
button.addEventListener("click", () => {
const level = number(button.dataset.ladderLevel);
@@ -1387,17 +2044,13 @@ function renderAuctionCenter() {
"auctionDateLabel",
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
);
- const expectations = payload.expectations || {};
document.querySelector("#auctionSummary").innerHTML = [
- ["竞价覆盖", `${number(summary.stock_count)} 只`, ""],
- ["重点异动", `${number(summary.focus_count)} 只`, "up"],
- ["竞价一字", `${number(summary.one_price_count)} 只`, ""],
+ ["竞价覆盖", `${formatNumber(summary.stock_count, 0)} 只`, ""],
+ ["重点异动", `${formatNumber(summary.focus_count, 0)} 只`, "up"],
+ ["竞价一字", `${formatNumber(summary.one_price_count, 0)} 只`, ""],
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
].map(([label, value, tone]) => `${label}${value}
`).join("");
setText("auctionFocusCount", number(summary.focus_count));
- setText("auctionAboveCount", number(expectations["超预期"]));
- setText("auctionMatchedCount", number(expectations["符合预期"]));
- setText("auctionBelowCount", number(expectations["低于预期"]));
setText("auctionAllCount", number(summary.candidate_count));
setText("auctionOnePriceCount", number(summary.one_price_count));
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
@@ -1411,13 +2064,12 @@ function renderAuctionInsights(payload) {
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
- ? carry.slice(0, 5).map((item) => `
+ ? carry.map((item) => `
-
${escapeHtml(item.name)}${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停
-
- ${escapeHtml(item.status)}
- ${item.median_change == null ? "暂无有效候选" : `中位 ${signed(item.median_change)}%`}
-
+
${escapeHtml(item.name)}
+
${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停
+
${escapeHtml(item.status)}
+
${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}中位
`).join("")
: '暂无昨日强势题材基线
';
@@ -1428,6 +2080,10 @@ function renderAuctionInsights(payload) {
const history = payload.amount_history || [];
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
+ const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
+ const fiveDayAverage = priorFive.length
+ ? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
+ : null;
document.querySelector("#auctionAmountTrend").innerHTML = history.length
? history.map((item, index) => {
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
@@ -1435,7 +2091,7 @@ function renderAuctionInsights(payload) {
return `
${escapeHtml(String(item.trade_date || "").slice(5))}
`;
- }).join("")
+ }).join("") + (fiveDayAverage === null ? "" : `5日均 ${formatNumber(fiveDayAverage, 1)}
`)
: '历史竞价量能尚未形成
';
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
const comparison = [
@@ -1445,7 +2101,6 @@ function renderAuctionInsights(payload) {
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
${label}${value == null ? "--" : `${signed(value)}%`}
`).join("");
- setText("auctionNewsMessage", payload.news_feedback?.detail || "待稳定的新闻与公告数据接入后开放");
}
function renderAuctionPhase(meta) {
@@ -1453,7 +2108,7 @@ function renderAuctionPhase(meta) {
const available = Boolean(meta.available);
const copy = {
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
- observing: ["竞价观察期", "当前数据源不提供动态虚拟撮合,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
+ observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
selection: available
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
@@ -1493,6 +2148,39 @@ function scheduleAuctionTransition(meta) {
}
function renderAuctionTable() {
+ const rows = currentAuctionRows();
+ const columns = auctionColumns();
+ const head = document.querySelector("#auctionTableHead");
+ head.innerHTML = columns.map((column) => {
+ const sorted = column.sortKey === state.auctionSortKey;
+ const arrow = !column.sortKey ? "" : `${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}`;
+ return `${column.label}${arrow} | `;
+ }).join("");
+ const body = document.querySelector("#auctionTableBody");
+ body.innerHTML = rows.map((row) => `${columns.map((column) => renderAuctionCell(row, column.key)).join("")}
`).join("");
+ bindStockRows(body);
+ const datasetCopy = {
+ focus: ["重点异动", "优先查看市场核心与显著预期差"],
+ onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
+ watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
+ all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
+ }[state.auctionDataset] || ["竞价异动", ""];
+ setText("auctionWorkspaceTitle", datasetCopy[0]);
+ setText("auctionWorkspaceSubtitle", datasetCopy[1]);
+ document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
+ const empty = document.querySelector("#auctionEmpty");
+ const phase = state.auctionData?.meta?.phase || "archive";
+ empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
+ ? "正在等待 9:25 最终竞价数据"
+ : state.auctionDataset === "watchlist"
+ ? "当前账号还没有可观察的自选股"
+ : state.auctionDataset === "onePrice"
+ ? "当前没有竞价封于涨停价的股票"
+ : "没有符合条件的竞价候选";
+ empty.hidden = rows.length > 0;
+}
+
+function currentAuctionRows() {
const datasets = {
focus: state.auctionData?.focus_rows || [],
onePrice: state.auctionData?.one_price_rows || [],
@@ -1506,58 +2194,82 @@ function renderAuctionTable() {
if (state.auctionQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
}
- rows = rows.slice(0, 300);
- const body = document.querySelector("#auctionTableBody");
+ const key = state.auctionSortKey;
+ const direction = state.auctionSortDirection === "asc" ? 1 : -1;
+ if (key) {
+ rows.sort((left, right) => {
+ const leftValue = left[key];
+ const rightValue = right[key];
+ if (leftValue == null && rightValue == null) return 0;
+ if (leftValue == null) return 1;
+ if (rightValue == null) return -1;
+ const result = typeof leftValue === "number" || typeof rightValue === "number"
+ ? number(leftValue) - number(rightValue)
+ : String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
+ return result * direction;
+ });
+ }
+ return rows.slice(0, 300);
+}
+
+function auctionColumns() {
+ const base = [
+ { key: "stock", label: "股票" },
+ { key: "context", label: "方向与来源" },
+ { key: "identity", label: "市场身份" },
+ ];
+ const metrics = [
+ { key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
+ { key: "expectation", label: "预期判断" },
+ { key: "change", label: "竞价涨幅(%)", numeric: true, sortKey: "change" },
+ { key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
+ { key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
+ ];
+ return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
+}
+
+function renderAuctionCell(row, key) {
+ const unavailable = row.available === false;
+ const onePrice = Boolean(row.is_one_price);
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
- body.innerHTML = rows.map((row, index) => {
- if (row.available === false) {
- return `
- | ${index + 1}${escapeHtml(row.name)}${escapeHtml(row.code)} |
- ${escapeHtml(row.sector || "其他")}我的自选 |
- ${renderAuctionCoreTags(row.core_tags)} | -- | 暂无竞价 | -- | -- |
-
`;
- }
- const onePrice = Boolean(row.is_one_price);
- const expectation = onePrice
+ if (key === "stock") return `${escapeHtml(row.name)}${escapeHtml(row.code)} | `;
+ if (key === "context") return `${escapeHtml(row.sector || "其他")}${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))} | `;
+ if (key === "identity") return `${renderAuctionCoreTags(row.core_tags)} | `;
+ if (unavailable) return key === "expectation"
+ ? '暂无竞价 | '
+ : ` | `;
+ if (key === "score") return `${onePrice ? "" : formatNumber(row.attention_score, 1)} | `;
+ if (key === "expectation") {
+ const tag = onePrice
? '竞价一字'
: `${escapeHtml(row.expectation || "符合预期")}`;
- return `
- | ${index + 1}${escapeHtml(row.name)}${escapeHtml(row.code)} |
- ${escapeHtml(row.sector || "其他")}${escapeHtml(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))} |
- ${renderAuctionCoreTags(row.core_tags)} |
- ${onePrice ? "--" : formatNumber(row.attention_score, 1)} |
- ${expectation} |
- ${signed(row.change)}% |
- ${formatNumber(row.amount_million, 2)} 百万量比 ${formatNumber(row.volume_ratio, 2)} |
-
`;
- }).join("");
- bindStockRows(body);
- const datasetCopy = {
- focus: ["重点异动", "优先查看市场核心与显著预期差"],
- onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
- watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
- all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
- }[state.auctionDataset] || ["竞价异动", ""];
- setText("auctionWorkspaceTitle", datasetCopy[0]);
- setText("auctionWorkspaceSubtitle", datasetCopy[1]);
- document.querySelector("#auctionExpectationFilterbar").hidden = state.auctionDataset === "onePrice";
- const empty = document.querySelector("#auctionEmpty");
- const phase = state.auctionData?.meta?.phase || "archive";
- empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
- ? "正在等待 9:25 最终竞价数据"
- : state.auctionDataset === "watchlist"
- ? "当前账号还没有可观察的自选股"
- : state.auctionDataset === "onePrice"
- ? "当前没有竞价封于涨停价的股票"
- : "没有符合条件的竞价候选";
- empty.hidden = rows.length > 0;
+ return `${tag} | `;
+ }
+ if (key === "change") return `${signed(row.change)} | `;
+ if (key === "amount") return `${formatNumber(row.amount_million, 2)} | `;
+ if (key === "volume") return `${formatNumber(row.volume_ratio, 2)} | `;
+ return " | ";
+}
+
+function renderAuctionSources(value) {
+ const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
+ return `${sources.map((source) => `${escapeHtml(source)}`).join("")}`;
}
function renderAuctionCoreTags(tags) {
const values = Array.isArray(tags) ? tags : [];
return values.length
? `${values.slice(0, 2).map((tag) => `${escapeHtml(tag)}`).join("")}`
- : '--';
+ : '';
+}
+
+function exportAuctionRows() {
+ const rows = currentAuctionRows();
+ exportRows("集合竞价", rows, [
+ ["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
+ ["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
+ ["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
+ ]);
}
async function loadThemeLibrary(force = false) {
@@ -1591,11 +2303,11 @@ function renderThemeLibrary() {
const summary = payload.summary || {};
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
document.querySelector("#themeSummary").innerHTML = [
- ["收录题材", `${number(summary.theme_count)} 个`, ""],
- ["当日上涨", `${number(summary.up_count)} 个`, "up"],
- ["当日下跌", `${number(summary.down_count)} 个`, "down"],
- ["人气题材", `${number(summary.hot_count)} 个`, "warning"],
- ].map(([label, value, tone]) => `${label}${value}
`).join("");
+ ["收录题材", number(summary.theme_count), "个", ""],
+ ["当日上涨", number(summary.up_count), "个", "up"],
+ ["当日下跌", number(summary.down_count), "个", "down"],
+ ["人气题材", number(summary.hot_count), "个", "warning"],
+ ].map(([label, value, unit, tone]) => `${label}${value}${unit}
`).join("");
renderThemeDirectory();
}
@@ -1605,11 +2317,15 @@ function renderThemeDirectory() {
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
}
setText("themeResultCount", `${items.length} 个`);
- document.querySelector("#themeDirectory").innerHTML = items.map((item) => `
- `;
+ }).join("") || '没有匹配的题材
';
}
async function selectTheme(code, keepSelection = false) {
@@ -1641,15 +2357,19 @@ function renderThemeDetail() {
setText("themeDetailChange", `${signed(theme.change)}%`);
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
document.querySelector("#themeDetailMetrics").innerHTML = [
- ["成分股", `${number(summary.member_count)} 只`], ["上涨", `${number(summary.up_count)} 只`],
- ["下跌", `${number(summary.down_count)} 只`], ["换手率", `${formatNumber(theme.turnover_rate, 2)}%`],
- ].map(([label, value]) => `${label}${value}
`).join("");
- setText("themeMemberCount", `${number(summary.quoted_count)} / ${number(summary.member_count)} 只有行情`);
+ ["成分股", `${number(summary.member_count)} 只`, ""],
+ ["有行情", `${number(summary.quoted_count)} 只`, ""],
+ ["上涨", `${number(summary.up_count)} 只`, "up"],
+ ["下跌", `${number(summary.down_count)} 只`, "down"],
+ ["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
+ ].map(([label, value, tone]) => `${label}${value}
`).join("");
+ setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
const body = document.querySelector("#themeMemberTableBody");
body.innerHTML = (payload.members || []).map((row, index) => `
- | ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${row.has_quote ? `${signed(row.change)}%` : "--"} |
- ${row.has_quote ? formatNumber(row.price, 2) : "--"} | ${row.has_quote ? `${formatNumber(row.amount_billion, 2)} 亿` : "--"} |
`).join("");
+ | ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${row.has_quote ? signed(row.change) : ""} |
+ ${row.has_quote ? formatNumber(row.price, 2) : ""} | ${row.has_quote ? formatNumber(row.amount_billion, 2) : ""} |
`).join("");
bindStockRows(body);
requestAnimationFrame(() => drawEntityDetailChart(payload.series || [], elements.themeDetailChart));
renderThemeDirectory();
@@ -1682,10 +2402,12 @@ function renderPopularity() {
if (!payload) return;
const summary = payload.summary || {};
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
+ const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
document.querySelector("#popularitySummary").innerHTML = [
- ["同花顺热股", `${number(summary.ths_count)} 只`], ["东方财富热股", `${number(summary.dc_count)} 只`],
- ["双榜重合", `${number(summary.dual_count)} 只`], ["榜首", escapeHtml(payload.combined?.[0]?.name || "--")],
- ].map(([label, value]) => `${label}${value}
`).join("");
+ ["同花顺热度 Top3", topNames(payload.ths), `共 ${number(summary.ths_count)} 只上榜`],
+ ["东方财富热度 Top3", topNames(payload.dc), `共 ${number(summary.dc_count)} 只上榜`],
+ ["双榜共识", `${number(summary.dual_count)} 只`, "同时进入两榜,共识度更高"],
+ ].map(([label, value, detail], index) => `${label}${escapeHtml(value)}${escapeHtml(detail)}`).join("");
renderPopularityTable();
}
@@ -1695,19 +2417,37 @@ function renderPopularityTable() {
if (state.popularityQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
}
+ const combined = source === "combined";
+ const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
+ setText("popularityTableTitle", `${sourceName}榜`);
+ setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
+ const headers = [
+ ["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%)", "number num"],
+ ...(source !== "dc" ? [["同花顺", "number num"]] : []),
+ ...(source !== "ths" ? [["东方财富", "number num"]] : []),
+ ["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
+ ];
+ document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `${label} | `).join("");
const body = document.querySelector("#popularityTableBody");
body.innerHTML = rows.map((row, index) => {
const thsRank = source === "ths" ? row.rank : row.ths_rank;
const dcRank = source === "dc" ? row.rank : row.dc_rank;
const move = row.rank_change;
- return `| ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${signed(row.change)}% |
- ${formatNumber(row.price, 2)} | ${thsRank || "--"} | ${dcRank || "--"} |
- ${move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "--"} |
- ${escapeHtml((row.concepts || []).slice(0, 3).join("、") || "--")} |
- ${source === "combined" ? (row.dual_source ? "双榜共识" : "单榜入选") : source === "ths" ? "同花顺" : "东方财富"} |
`;
+ const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `↑${number(move)}` : number(move) < 0 ? `↓${Math.abs(number(move))}` : "持平";
+ return `
+ | ${index + 1}${index < 3 ? '热' : ""} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${row.price == null ? "" : formatNumber(row.price, 2)} |
+ ${row.change == null ? "" : signed(row.change)} |
+ ${source !== "dc" ? `${thsRank ? number(thsRank) : ""} | ` : ""}
+ ${source !== "ths" ? `${dcRank ? number(dcRank) : ""} | ` : ""}
+ ${movement} |
+ ${escapeHtml((row.concepts || []).slice(0, 3).join("、"))} |
+ ${!combined ? `${row.dual_source ? "双榜共识" : "单榜入选"} | ` : ""}
+
`;
}).join("");
bindStockRows(body);
+ markAutoSortableHeaders(body.closest("table"));
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
}
@@ -1747,6 +2487,20 @@ function renderDragonTiger() {
if (!payload) return;
const summary = payload.summary || {};
setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
+ const status = payload.meta?.status || "empty";
+ const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
+ const showEmptyState = !hasRecognizedTraders
+ && !(payload.unclassified_seats || []).length
+ && ["empty", "error", "unavailable"].includes(status);
+ document.querySelector("#dragonEmptyState").hidden = !showEmptyState;
+ document.querySelector("#dragonDailyContent").hidden = showEmptyState;
+ if (showEmptyState) {
+ const unavailable = ["error", "unavailable"].includes(status);
+ setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
+ setText("dragonEmptyDescription", unavailable
+ ? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
+ : "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
+ }
document.querySelector("#dragonSummary").innerHTML = [
["上榜游资", `${number(summary.trader_count)} 位`, ""],
["操作明细", `${number(summary.operation_count)} 条`, ""],
@@ -1856,27 +2610,31 @@ function layoutDragonCards(container = document.querySelector("#dragonTraderList
function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail");
if (!trader) {
+ container.hidden = true;
container.innerHTML = '选择一位游资查看操作明细
';
return;
}
+ container.hidden = false;
container.innerHTML = `
-
-
- | 股票 | 方向 | 涨幅 | 买入 | 卖出 | 净额 | 关联席位 | 标签 / 上榜原因 |
- ${(trader.operations || []).map((operation) => `
+
+
+
+ | 序号 | 股票 | 方向 | 涨幅(%) | 买入(百万) | 卖出(百万) | 净额(百万) | 关联席位 | 标签 / 上榜原因 |
+ ${(trader.operations || []).map((operation, index) => `
- | ${escapeHtml(operation.name)}${escapeHtml(operation.code)} |
+ ${index + 1} |
+ ${escapeHtml(operation.name)}${escapeHtml(operation.code)} |
${escapeHtml(operation.direction)} |
- ${operation.change == null ? "--" : `${signed(operation.change)}%`} |
- ${formatMoneyMillion(operation.buy_million)} |
- ${formatMoneyMillion(operation.sell_million)} |
- ${formatMoneyMillion(operation.net_buy_million)} |
+ ${operation.change == null ? "" : signed(operation.change)} |
+ ${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)} |
+ ${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)} |
+ ${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)} |
${escapeHtml(operation.seat_name)} |
- ${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason || "--")} |
+ ${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")} |
`).join("")}
`;
@@ -1941,7 +2699,7 @@ async function saveSeatAlias(event) {
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
- apiRequest("/api/watchlist"),
+ apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
apiRequest("/api/notes?scope=daily"),
apiRequest("/api/trades"),
]);
@@ -1949,10 +2707,12 @@ async function loadReviewWorkspace() {
state.notes = notesPayload.items || [];
state.tradeEntries = tradesPayload.items || [];
state.tradeSummary = tradesPayload.summary || {};
+ setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
renderWatchlist();
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
setText("notesCount", `${state.notes.length} 条`);
renderTradeLog();
+ populateJournalForm();
} catch (error) {
showToast(error.message || "我的复盘加载失败");
}
@@ -1962,16 +2722,21 @@ function renderWatchlist() {
setText("watchlistCount", `${state.watchlist.length} 只`);
const body = document.querySelector("#watchlistTableBody");
body.innerHTML = state.watchlist.map((item) => `
- |
- ${escapeHtml(item.code)} | ${escapeHtml(item.name)} |
- ${escapeHtml(item.sector || "其他")} | 详情
- 移除 |
+ | ★ |
+ ${escapeHtml(item.name)}${escapeHtml(item.code)} |
+ ${escapeHtml(item.sector || "其他")} |
+ ${formatWatchMetric(item.change)} |
+ ${formatWatchMetric(item.return_5d)} |
+ ${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)} |
+ |
+ 备注
+ 移除 |
`).join("");
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
- body.querySelectorAll("[data-watch-detail]").forEach((button) => {
+ body.querySelectorAll("[data-watch-remark]").forEach((button) => {
button.addEventListener("click", () => {
- const item = state.watchlist.find((row) => row.code === button.dataset.watchDetail);
- openStock(button.dataset.watchDetail, item);
+ const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
+ openWatchlistDialog(item);
});
});
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
@@ -1980,6 +2745,116 @@ function renderWatchlist() {
bindStockRows(body);
}
+function formatWatchMetric(value) {
+ if (value == null || !Number.isFinite(Number(value))) return "";
+ return signed(value);
+}
+
+function openWatchlistDialog(item = null) {
+ clearTimeout(watchlistSearchTimer);
+ state.watchlistSelection = item ? {
+ code: item.code,
+ name: item.name,
+ sector: item.sector || "其他",
+ color: item.color || "red",
+ } : null;
+ state.watchlistSearchResults = [];
+ setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
+ document.querySelector("#watchlistRemark").value = item?.remark || "";
+ document.querySelector("#watchlistSearchInput").value = "";
+ document.querySelector("#watchlistSearchResults").innerHTML = "";
+ syncWatchlistSelection(Boolean(item));
+ if (!elements.watchlistDialog.open) elements.watchlistDialog.showModal();
+ requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
+}
+
+function closeWatchlistDialog() {
+ clearTimeout(watchlistSearchTimer);
+ if (elements.watchlistDialog.open) elements.watchlistDialog.close();
+}
+
+function clearWatchlistSelection() {
+ state.watchlistSelection = null;
+ syncWatchlistSelection(false);
+ document.querySelector("#watchlistSearchInput").focus();
+}
+
+function syncWatchlistSelection(editing = false) {
+ const item = state.watchlistSelection;
+ document.querySelector("#watchlistSearchField").hidden = Boolean(item);
+ document.querySelector("#watchlistSelection").hidden = !item;
+ document.querySelector("#changeWatchlistSelection").hidden = editing;
+ document.querySelector("#saveWatchlist").disabled = !item;
+ if (!item) return;
+ setText("watchlistSelectionName", item.name || "--");
+ setText("watchlistSelectionCode", item.code || "--");
+ setText("watchlistSelectionSector", item.sector || "其他");
+ refreshIcons();
+}
+
+function scheduleWatchlistSearch() {
+ clearTimeout(watchlistSearchTimer);
+ const query = document.querySelector("#watchlistSearchInput").value.trim();
+ if (!query) {
+ document.querySelector("#watchlistSearchResults").innerHTML = "";
+ return;
+ }
+ document.querySelector("#watchlistSearchResults").innerHTML = '正在查找股票
';
+ watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
+}
+
+async function runWatchlistSearch(query) {
+ const sequence = ++state.watchlistSearchRequestSequence;
+ try {
+ const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
+ const payload = await apiRequest(`/api/search?${params}`);
+ if (sequence !== state.watchlistSearchRequestSequence) return;
+ state.watchlistSearchResults = payload.groups?.stocks || [];
+ document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
+ ${escapeHtml(item.name)}${escapeHtml(item.industry || "其他")}${escapeHtml(item.code)}
+ `).join("") || '没有找到匹配的股票
';
+ } catch (error) {
+ document.querySelector("#watchlistSearchResults").innerHTML = `${escapeHtml(error.message || "搜索失败")}
`;
+ }
+}
+
+function handleWatchlistSearchResult(event) {
+ const button = event.target.closest("[data-watchlist-result]");
+ if (!button) return;
+ const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
+ if (!item) return;
+ state.watchlistSelection = {
+ code: item.code,
+ name: item.name,
+ sector: item.industry || "其他",
+ color: "red",
+ };
+ syncWatchlistSelection(false);
+}
+
+async function saveWatchlistFromDialog(event) {
+ event.preventDefault();
+ const item = state.watchlistSelection;
+ if (!item) return;
+ const button = document.querySelector("#saveWatchlist");
+ button.disabled = true;
+ try {
+ await apiRequest("/api/watchlist", "POST", {
+ code: item.code,
+ name: item.name,
+ sector: item.sector || "其他",
+ color: item.color || "red",
+ remark: document.querySelector("#watchlistRemark").value.trim(),
+ });
+ closeWatchlistDialog();
+ await loadReviewWorkspace();
+ showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
+ } catch (error) {
+ showToast(error.message || "自选保存失败");
+ button.disabled = false;
+ }
+}
+
async function toggleActiveWatchlist() {
const stock = state.activeStock;
if (!stock?.code) return;
@@ -2030,11 +2905,11 @@ async function saveJournal(event) {
try {
await apiRequest("/api/notes", "POST", {
trade_date: document.querySelector("#journalDate").value,
+ id: state.editingDailyNoteId || undefined,
+ summary: document.querySelector("#journalSummary").value,
content: document.querySelector("#journalContent").value,
plan: document.querySelector("#journalPlan").value,
});
- document.querySelector("#journalContent").value = "";
- document.querySelector("#journalPlan").value = "";
await loadReviewWorkspace();
showToast("每日复盘已保存");
} catch (error) {
@@ -2042,6 +2917,15 @@ async function saveJournal(event) {
}
}
+function populateJournalForm() {
+ const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
+ const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
+ state.editingDailyNoteId = number(note?.id);
+ document.querySelector("#journalSummary").value = note?.summary || "";
+ document.querySelector("#journalContent").value = note?.content || "";
+ document.querySelector("#journalPlan").value = note?.plan || "";
+}
+
function openTradeLogDialog() {
resetTradeLogForm();
if (!elements.tradeLogDialog.open) elements.tradeLogDialog.showModal();
@@ -2154,15 +3038,13 @@ function renderTradeLog() {
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
| ${displayCompactDate(item.trade_date)} |
- ${escapeHtml(item.name)}${escapeHtml(item.code)} |
+ ${escapeHtml(item.name)}${escapeHtml(item.code)} |
${escapeHtml(item.action_label)} |
- ${formatNumber(item.price, 3)} |
- ${formatNumber(item.quantity, 0)} |
- ${formatNumber(item.position_pct, 1)}% |
- ${item.pnl_pct == null ? "--" : `${signed(item.pnl_pct)}%`}${item.pnl_amount == null ? "" : `${number(item.pnl_amount) > 0 ? "+" : ""}${formatNumber(item.pnl_amount, 2)}`} |
+ ${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)} |
+ ${item.pnl_pct == null ? "" : signed(item.pnl_pct)} |
+ ${item.pnl_amount == null ? "" : signed(item.pnl_amount)} |
${escapeHtml(item.emotion_label)} ${(item.tags || []).map((tag) => `${escapeHtml(tag)}`).join("")} |
- ${escapeHtml(item.thesis || "--")} |
- ${escapeHtml(item.execution || "--")} |
+ ${escapeHtml(item.thesis || "")}${escapeHtml(item.execution || "尚未填写执行复核")} |
编辑删除 |
`).join("");
@@ -2230,6 +3112,7 @@ function renderNotesHistory(notes, container, compact) {
container.innerHTML = notes.map((note) => `
${note.stock_name ? `${escapeHtml(note.stock_name)}` : ""}
+ ${!compact ? `盘面${escapeHtml(note.summary || "--")}
` : ""}
复盘${escapeHtml(note.content || "--")}
计划${escapeHtml(note.plan || "--")}
删除
@@ -2274,24 +3157,131 @@ async function backfillData() {
}
}
-async function loadScreenerSetup() {
- try {
- const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
- const payload = await apiRequest(`/api/screener/setup?${query}`);
- state.screenerSetup = payload;
- if (!state.screenerResult && payload.latest_result) state.screenerResult = payload.latest_result;
- if (!state.selectedRegime) state.selectedRegime = payload.regime.id;
- const selectedId = state.selectedStrategy?.id;
- state.selectedStrategy = payload.strategies.find((item) => item.id === selectedId)
- || payload.strategies.find((item) => item.regimes.includes(state.selectedRegime))
- || payload.strategies[0]
- || null;
- renderScreenerSetup();
- if (state.screenerResult) renderScreenerResult();
- await loadScreenerTracking();
- } catch (error) {
- showToast(error.message || "选股配置加载失败");
+function screenerStrategyKey(strategyId, strategyName) {
+ return strategyId != null && strategyId !== 0 ? `id:${strategyId}` : `name:${strategyName || ""}`;
+}
+
+function currentScreenerStrategy(mode) {
+ if (mode === "curated") return activeCuratedStrategy();
+ if (mode === "smart") return state.selectedStrategy;
+ return null;
+}
+
+function screenerResultMatchesSelection(mode) {
+ const result = state.screenerResults[mode];
+ const context = state.screenerResultContexts[mode];
+ if (!result || !context) return false;
+ if (mode === "quant") return true;
+ const strategy = currentScreenerStrategy(mode);
+ if (!strategy || (mode === "smart" && context.regime !== state.selectedRegime)) return false;
+ return context.strategyKey === screenerStrategyKey(strategy.id, strategy.name);
+}
+
+function activeScreenerResult(mode = state.screenerMode) {
+ return screenerResultMatchesSelection(mode) ? state.screenerResults[mode] : null;
+}
+
+function activeScreenerResultContext(mode = state.screenerMode) {
+ return activeScreenerResult(mode) ? state.screenerResultContexts[mode] : null;
+}
+
+function setScreenerResult(mode, result, { regime, strategyId = null, strategyName = "" } = {}) {
+ const normalizedMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
+ state.screenerResults[normalizedMode] = result || null;
+ state.screenerResultContexts[normalizedMode] = result ? {
+ mode: normalizedMode,
+ regime: regime || result.meta?.regime || state.selectedRegime,
+ strategyName: strategyName || result.meta?.strategy_name || "",
+ strategyKey: screenerStrategyKey(strategyId, strategyName || result.meta?.strategy_name || ""),
+ } : null;
+}
+
+function applyScreenerSetup(payload, requestKey) {
+ const dateChanged = Boolean(state.screenerSetupKey && state.screenerSetupKey !== requestKey);
+ if (dateChanged) {
+ state.screenerResults = { smart: null, curated: null, quant: null };
+ state.screenerResultContexts = { smart: null, curated: null, quant: null };
}
+ state.screenerSetup = payload;
+ state.screenerSetupKey = requestKey;
+
+ const latestResults = { ...(payload.latest_results || {}) };
+ if (!latestResults.smart && payload.latest_result) latestResults.smart = payload.latest_result;
+ const smartLatestMeta = latestResults.smart?.meta || {};
+ const curatedLatestMeta = latestResults.curated?.meta || {};
+ const availableRegimes = new Set((payload.regimes || []).map((item) => item.id));
+ if (!state.selectedRegime || dateChanged) {
+ state.selectedRegime = availableRegimes.has(smartLatestMeta.regime)
+ ? smartLatestMeta.regime
+ : payload.regime.id;
+ }
+
+ const selectedId = state.selectedStrategy?.id;
+ const smartStrategies = payload.strategies.filter((item) => item.formula?.meta?.library !== "curated");
+ const curatedStrategies = payload.strategies.filter((item) => item.formula?.meta?.library === "curated");
+ const latestSmartStrategy = smartStrategies.find((item) => item.name === smartLatestMeta.strategy_name);
+ state.selectedStrategy = smartStrategies.find((item) => item.id === selectedId)
+ || latestSmartStrategy
+ || smartStrategies.find((item) => item.regimes.includes(state.selectedRegime))
+ || smartStrategies[0]
+ || null;
+
+ if (!curatedStrategies.some((item) => item.id === state.selectedCuratedStrategyId) || dateChanged) {
+ state.selectedCuratedStrategyId = curatedStrategies.find(
+ (item) => item.name === curatedLatestMeta.strategy_name,
+ )?.id || curatedStrategies[0]?.id || 0;
+ }
+
+ for (const mode of ["smart", "curated", "quant"]) {
+ if (state.screenerResults[mode] || !latestResults[mode]) continue;
+ const result = latestResults[mode];
+ const meta = result.meta || {};
+ const strategy = mode === "curated"
+ ? curatedStrategies.find((item) => item.name === meta.strategy_name)
+ : mode === "smart"
+ ? smartStrategies.find((item) => item.name === meta.strategy_name)
+ : null;
+ setScreenerResult(mode, result, {
+ regime: meta.regime || payload.regime.id,
+ strategyId: strategy?.id,
+ strategyName: meta.strategy_name || strategy?.name || "",
+ });
+ }
+ if (!state.quantScores.length) resetQuantBuilder(false);
+ renderScreenerSetup();
+ renderScreenerResult();
+}
+
+async function loadScreenerSetup(force = false) {
+ const requestKey = elements.tradeDate.value.replaceAll("-", "");
+ if (!force && state.screenerSetup && state.screenerSetupKey === requestKey) {
+ renderScreenerSetup();
+ renderScreenerResult();
+ return state.screenerSetup;
+ }
+ if (!force && state.screenerSetupPromise && state.screenerSetupRequestKey === requestKey) {
+ return state.screenerSetupPromise;
+ }
+ const request = (async () => {
+ try {
+ const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
+ const payload = await apiRequest(`/api/screener/setup?${query}`);
+ applyScreenerSetup(payload, requestKey);
+ await loadScreenerTracking();
+ return payload;
+ } catch (error) {
+ showToast(error.message || "选股配置加载失败");
+ return null;
+ } finally {
+ if (state.screenerSetupPromise === request) {
+ state.screenerSetupPromise = null;
+ state.screenerSetupRequestKey = "";
+ }
+ }
+ })();
+ state.screenerSetupRequestKey = requestKey;
+ state.screenerSetupPromise = request;
+ return request;
}
function renderScreenerSetup() {
@@ -2300,8 +3290,15 @@ function renderScreenerSetup() {
setText("screenerDateLabel", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("regimeLabel", setup.regime.label);
setText("regimeConfidence", `置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
+ setText("regimeStepStatus", `${setup.regime.label} · 置信度 ${formatNumber(setup.regime.confidence, 0)}%`);
setText("regimeReason", setup.regime.reason);
- document.querySelector("#regimeEvidenceList").textContent = setup.regime.evidence.join(" · ");
+ const evidence = (setup.regime.evidence || []).filter(Boolean);
+ if (!evidence.some((item) => String(item).includes("情绪温度"))) {
+ const temperature = formatNumber(state.dashboard?.overview?.sentiment_score, 0);
+ const direction = state.dashboard?.overview?.sentiment_direction;
+ evidence.unshift(`情绪温度 ${temperature}${direction ? `,较前一交易日${direction}` : ""}`);
+ }
+ document.querySelector("#regimeEvidenceList").textContent = evidence.join(" · ");
setText("factorDateCount", `${number(setup.factor_data.date_count)} 日`);
setText("factorDateRange", setup.factor_data.ready
? `${displayCompactDate(setup.factor_data.start_date)} 至 ${displayCompactDate(setup.factor_data.end_date)} · 竞价 ${number(setup.factor_data.auction_date_count)} 日`
@@ -2310,9 +3307,9 @@ function renderScreenerSetup() {
setText("compilerStatus", "策略生成已就绪");
setText(
"screenerRunStatus",
- state.screenerResult ? `已有结果 · ${(state.screenerResult.candidates || []).length} 只` : "等待执行",
+ activeScreenerResult("smart") ? `已有结果 · ${(activeScreenerResult("smart").candidates || []).length} 只` : "等待执行",
);
- setText("strategyCount", `${setup.strategies.length} 套`);
+ setText("strategyCount", `${setup.strategies.filter((item) => item.formula?.meta?.library !== "curated").length} 套`);
updateBacktestTaskStatus();
selectScreenerMobileView(state.screenerMobileView);
@@ -2325,6 +3322,353 @@ function renderScreenerSetup() {
});
renderStrategyList();
populateStrategyEditor(state.selectedStrategy);
+ renderStrategySummary();
+ renderScreenerMode();
+ renderCuratedStrategyLibrary();
+ renderQuantBuilder();
+ renderScreenerProgress();
+}
+
+function renderStrategySummary() {
+ const strategy = state.selectedStrategy;
+ setText("activeStrategyHeading", strategy?.name || "--");
+ setText("activeStrategyEditorHeading", strategy?.name || "--");
+ setText("activeStrategyDescription", strategy?.description || "等待匹配当前市场阶段的策略。");
+ setText("strategyStepStatus", strategy?.name || "等待匹配");
+ document.querySelector("#activeStrategyRegimes").innerHTML = strategy
+ ? `${strategy.regimes.map((item) => `${escapeHtml(regimeLabel(item))}`).join("")}${strategy.builtin ? "内置" : "自定义"}`
+ : "";
+}
+
+function selectScreenerMode(mode) {
+ state.screenerMode = ["smart", "curated", "quant"].includes(mode) ? mode : "smart";
+ localStorage.setItem("xiaobaiScreenerMode", state.screenerMode);
+ state.screenerMobileView = "strategy";
+ renderScreenerMode();
+ selectScreenerMobileView("strategy");
+}
+
+function renderScreenerMode() {
+ const mode = state.screenerMode || "smart";
+ document.querySelectorAll("[data-screener-mode]").forEach((button) => {
+ const active = button.dataset.screenerMode === mode;
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-selected", String(active));
+ });
+ document.querySelectorAll("[data-screener-panel]").forEach((panel) => {
+ panel.hidden = panel.dataset.screenerPanel !== mode;
+ });
+ const results = document.querySelector("#screenerView .screener-results-view");
+ const resultsSlot = document.querySelector(`[data-screener-results-slot="${mode}"]`);
+ if (results && resultsSlot && results.parentElement !== resultsSlot) resultsSlot.append(results);
+ const titles = { smart: "候选结果", curated: "执行结果", quant: "打分结果" };
+ setText("screenerResultTitle", titles[mode]);
+ renderScreenerResult();
+}
+
+function curatedStrategies() {
+ return (state.screenerSetup?.strategies || []).filter((item) => item.formula?.meta?.library === "curated");
+}
+
+function activeCuratedStrategy() {
+ const strategies = curatedStrategies();
+ return strategies.find((item) => item.id === state.selectedCuratedStrategyId) || strategies[0] || null;
+}
+
+function renderCuratedStrategyLibrary() {
+ if (!state.screenerSetup) return;
+ const strategies = curatedStrategies();
+ const categories = ["全部", ...new Set(strategies.map((item) => item.formula?.meta?.category || "其他"))];
+ if (!categories.includes(state.curatedCategory)) state.curatedCategory = "全部";
+ setText("curatedStrategyCount", `${strategies.length} 套`);
+ const filters = document.querySelector("#curatedCategoryFilters");
+ filters.innerHTML = categories.map((category) => `
+ ${escapeHtml(category)}
+ `).join("");
+ filters.querySelectorAll("[data-curated-category]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.curatedCategory = button.dataset.curatedCategory;
+ renderCuratedStrategyLibrary();
+ });
+ });
+ const query = state.curatedQuery;
+ const visible = strategies.filter((item) => {
+ const meta = item.formula?.meta || {};
+ const categoryMatch = state.curatedCategory === "全部" || meta.category === state.curatedCategory;
+ const queryMatch = !query || `${item.name} ${item.description} ${meta.category}`.toLocaleLowerCase("zh-CN").includes(query);
+ return categoryMatch && queryMatch;
+ });
+ const list = document.querySelector("#curatedStrategyList");
+ list.innerHTML = visible.length ? visible.map((strategy) => {
+ const meta = strategy.formula?.meta || {};
+ const rank = strategies.findIndex((item) => item.id === strategy.id) + 1;
+ return `
+ ${String(rank).padStart(2, "0")}${escapeHtml(strategy.name)}${escapeHtml(meta.category || "策略")}
+ 质量 ${escapeHtml(meta.quality || "--")}${escapeHtml(meta.frequency || "--")}风险 ${escapeHtml(meta.risk || "--")}
+ ${escapeHtml(strategy.description || "查看策略条件与适用环境。")}
+
+ `;
+ }).join("") : '没有符合条件的策略
';
+ list.querySelectorAll("[data-curated-inspect]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.selectedCuratedStrategyId = number(button.dataset.curatedInspect);
+ renderCuratedStrategyLibrary();
+ renderScreenerResult();
+ document.querySelector("#curatedDetailDialog").showModal();
+ });
+ });
+ list.querySelectorAll("[data-curated-run]").forEach((button) => {
+ button.addEventListener("click", () => {
+ state.selectedCuratedStrategyId = number(button.dataset.curatedRun);
+ renderCuratedStrategyLibrary();
+ renderScreenerResult();
+ runCuratedStrategy();
+ });
+ });
+ renderCuratedStrategyDetail();
+}
+
+function renderCuratedStrategyDetail() {
+ const strategy = activeCuratedStrategy();
+ if (!strategy) return;
+ const formula = strategy.formula || {};
+ const meta = formula.meta || {};
+ setText("curatedStrategyCategory", meta.category || "精选策略");
+ setText("curatedStrategyName", strategy.name);
+ setText("curatedStrategyDescription", strategy.description);
+ document.querySelector("#curatedStrategyBadges").innerHTML = [
+ `质量 ${meta.quality || "--"}`, meta.frequency || "--", `风险 ${meta.risk || "--"}`,
+ meta.data_group || "行情因子",
+ ].map((value) => `${escapeHtml(value)}`).join("");
+ const filters = formula.filters || [];
+ setText("curatedFilterCount", `${filters.length} 项`);
+ document.querySelector("#curatedFilterList").innerHTML = filters.map((item) => `
+ ${escapeHtml(factorLabel(item.field))}${escapeHtml(formatRuleValue(item))}
+ `).join("");
+ const scores = formula.score || [];
+ const total = scores.reduce((sum, item) => sum + number(item.weight), 0) || 1;
+ setText("curatedWeightTotal", `${formatNumber(total * 100, 0)}%`);
+ document.querySelector("#curatedScoreList").innerHTML = scores.map((item) => {
+ const percent = number(item.weight) / total * 100;
+ return `${escapeHtml(factorLabel(item.field))}${formatNumber(percent, 0)}%
`;
+ }).join("");
+ const status = document.querySelector("#curatedDataStatus");
+ status.classList.toggle("missing", !strategy.data_ready);
+ status.innerHTML = strategy.data_ready
+ ? '策略数据已就绪可按当前数据日期执行'
+ : `需要补充数据${escapeHtml((strategy.missing_data || []).join("、") || "请同步因子")}`;
+ const runButton = document.querySelector("#curatedRunButton");
+ runButton.disabled = !strategy.data_ready;
+ runButton.title = strategy.data_ready ? "执行当前策略" : `缺少${(strategy.missing_data || []).join("、")}`;
+ refreshIcons();
+}
+
+function factorLabel(field) {
+ return state.screenerSetup?.factor_fields?.find((item) => item.id === field)?.label || field;
+}
+
+function formatRuleValue(item) {
+ const operator = { between: "介于", ">=": "不低于", "<=": "不高于", ">": "高于", "<": "低于", "==": "等于" }[item.op] || item.op;
+ const value = Array.isArray(item.value) ? item.value.join(" ~ ") : item.value;
+ return `${operator} ${value}`;
+}
+
+function groupedFactorOptions(selected = "") {
+ return (state.screenerSetup?.factor_groups || []).map((group) => `
+
+ `).join("");
+}
+
+function quantId() {
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+function resetQuantBuilder(render = true) {
+ state.quantFilters = [
+ { id: quantId(), field: "amount_billion", op: ">=", value: "1" },
+ { id: quantId(), field: "above_ma20", op: "==", value: "1" },
+ ];
+ state.quantScores = [
+ { id: quantId(), field: "relative_strength", weight: 30, direction: "desc" },
+ { id: quantId(), field: "sector_strength", weight: 25, direction: "desc" },
+ { id: quantId(), field: "volume_ratio_5d", weight: 20, direction: "desc" },
+ { id: quantId(), field: "amount_billion", weight: 15, direction: "desc" },
+ { id: quantId(), field: "volatility_10d", weight: 10, direction: "asc" },
+ ];
+ if (render) renderQuantBuilder();
+}
+
+function addQuantFilter() {
+ const used = new Set(state.quantFilters.map((item) => item.field));
+ const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
+ state.quantFilters.push({ id: quantId(), field, op: ">=", value: "0" });
+ renderQuantBuilder();
+}
+
+function addQuantScore() {
+ const used = new Set(state.quantScores.map((item) => item.field));
+ const field = state.screenerSetup.factor_fields.find((item) => !used.has(item.id))?.id || "pct_chg";
+ state.quantScores.push({ id: quantId(), field, weight: 10, direction: "desc" });
+ renderQuantBuilder();
+}
+
+function renderQuantBuilder() {
+ if (!state.screenerSetup) return;
+ document.querySelector("#quantFilterRows").innerHTML = state.quantFilters.map((item) => `
+
+
+
+
+
+
+ `).join("");
+ document.querySelector("#quantScoreRows").innerHTML = state.quantScores.map((item) => `
+
+ ✓${item.direction === "desc" ? "数值越高越优" : "数值越低越优"}
+
+
+
+ `).join("");
+ renderQuantSummary();
+ refreshIcons();
+}
+
+function handleQuantBuilderInput(event) {
+ const row = event.target.closest("[data-quant-filter], [data-quant-score]");
+ const key = event.target.dataset.quantKey;
+ if (!row || !key) return;
+ const collection = row.dataset.quantFilter ? state.quantFilters : state.quantScores;
+ const id = row.dataset.quantFilter || row.dataset.quantScore;
+ const item = collection.find((entry) => entry.id === id);
+ if (!item) return;
+ item[key] = key === "weight" ? number(event.target.value) : event.target.value;
+ if (key === "weight") {
+ const output = event.target.closest(".quant-weight-control")?.querySelector("output");
+ if (output) output.textContent = `${number(event.target.value)}%`;
+ }
+ renderQuantSummary();
+}
+
+function handleQuantBuilderClick(event) {
+ const button = event.target.closest("[data-quant-action]");
+ if (!button) return;
+ const row = button.closest("[data-quant-filter], [data-quant-score]");
+ const isFilter = Boolean(row?.dataset.quantFilter);
+ const id = row?.dataset.quantFilter || row?.dataset.quantScore;
+ const collection = isFilter ? state.quantFilters : state.quantScores;
+ const item = collection.find((entry) => entry.id === id);
+ if (button.dataset.quantAction === "remove") {
+ if (!isFilter && collection.length <= 1) {
+ showToast("至少保留一个评分因子");
+ return;
+ }
+ const index = collection.findIndex((entry) => entry.id === id);
+ if (index >= 0) collection.splice(index, 1);
+ renderQuantBuilder();
+ } else if (button.dataset.quantAction === "direction" && item) {
+ item.direction = button.dataset.direction;
+ renderQuantBuilder();
+ }
+}
+
+function buildQuantFormula() {
+ const filters = state.quantFilters.map((item) => {
+ let value;
+ if (item.op === "between") {
+ value = String(item.value).split(/[,,~~]/).map((part) => Number(part.trim()));
+ if (value.length !== 2 || value.some((part) => !Number.isFinite(part))) throw new Error(`${factorLabel(item.field)}需要两个有效区间值`);
+ if (value[0] > value[1]) value.reverse();
+ } else {
+ value = Number(item.value);
+ if (!Number.isFinite(value)) throw new Error(`${factorLabel(item.field)}的条件值无效`);
+ }
+ return { field: item.field, op: item.op, value };
+ });
+ const score = state.quantScores.map((item) => {
+ const weight = number(item.weight) / 100;
+ if (weight <= 0 || weight > 1) throw new Error(`${factorLabel(item.field)}的权重应为1%至100%`);
+ return { field: item.field, weight, direction: item.direction };
+ });
+ return {
+ meta: { library: "custom", category: "量化公式", frequency: "按需", risk: "自定义", data_group: "组合因子" },
+ universe: {
+ exclude_st: document.querySelector("#quantExcludeSt").checked,
+ listed_days_min: Math.max(0, Math.min(5000, number(document.querySelector("#quantListedDays").value))),
+ },
+ filters,
+ score,
+ limit: Math.max(1, Math.min(50, number(document.querySelector("#quantLimit").value))),
+ min_score: Math.max(0, Math.min(1, number(document.querySelector("#quantMinScore").value) / 100)),
+ };
+}
+
+function formulaMissingData(formula) {
+ const health = state.screenerSetup?.factor_data?.health || {};
+ const fields = new Set([...(formula.filters || []), ...(formula.score || [])].map((item) => item.field));
+ const missing = [];
+ if (!state.screenerSetup?.factor_data?.ready) missing.push("基础行情");
+ if (["pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"].some((field) => fields.has(field)) && !health.valuation) missing.push("估值数据");
+ if (["roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"].some((field) => fields.has(field)) && !health.fundamental) missing.push("财务质量");
+ if (fields.has("dividend_years") && !health.dividend_history) missing.push("历年分红");
+ if (["auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"].some((field) => fields.has(field)) && !health.auction) missing.push("竞价数据");
+ return missing;
+}
+
+function renderQuantSummary() {
+ if (!state.screenerSetup) return;
+ const total = state.quantScores.reduce((sum, item) => sum + number(item.weight), 0);
+ setText("quantWeightTotal", `${formatNumber(total, 0)}%`);
+ const bar = document.querySelector("#quantWeightBar");
+ bar.style.width = `${Math.min(100, total)}%`;
+ bar.style.background = Math.abs(total - 100) < 0.01 ? "#2563eb" : "#d97706";
+ const message = document.querySelector("#quantValidationMessage");
+ try {
+ const formula = buildQuantFormula();
+ const missing = formulaMissingData(formula);
+ message.classList.toggle("error", Boolean(missing.length));
+ message.textContent = missing.length ? `需要先同步:${missing.join("、")}` : "公式有效,可执行并生成逐股贡献解释。";
+ document.querySelector("#quantRunButton").disabled = Boolean(missing.length);
+ } catch (error) {
+ message.classList.add("error");
+ message.textContent = error.message;
+ document.querySelector("#quantRunButton").disabled = true;
+ }
+}
+
+function renderScreenerProgress() {
+ const hasSetup = Boolean(state.screenerSetup?.regime);
+ const hasStrategy = Boolean(state.selectedStrategy);
+ const hasResult = Boolean(activeScreenerResult("smart"));
+ const states = {
+ regime: hasSetup ? "complete" : "current",
+ strategy: hasStrategy ? "complete" : hasSetup ? "current" : "pending",
+ run: state.screenerRunning ? "current" : hasResult ? "complete" : hasStrategy ? "current" : "pending",
+ result: hasResult ? "current" : "pending",
+ };
+ const steps = [...document.querySelectorAll("[data-screener-step]")];
+ steps.forEach((step, index) => {
+ const status = states[step.dataset.screenerStep] || "pending";
+ step.dataset.state = status;
+ if (status === "current") step.setAttribute("aria-current", "step");
+ else step.removeAttribute("aria-current");
+ const line = step.nextElementSibling;
+ if (line?.classList.contains("step-line")) line.classList.toggle("complete", status === "complete" && index < steps.length - 1);
+ });
+}
+
+function openStrategyDrawer(target = "editor") {
+ const drawer = document.querySelector("#strategyDrawer");
+ if (!drawer.open) drawer.showModal();
+ requestAnimationFrame(() => {
+ const focusTarget = target === "library"
+ ? document.querySelector("#strategyList .strategy-item.active") || document.querySelector("#strategyList .strategy-item")
+ : document.querySelector("#strategyNameInput");
+ focusTarget?.focus();
+ });
}
function selectScreenerMobileView(view) {
@@ -2341,20 +3685,24 @@ function selectScreenerMobileView(view) {
function updateBacktestTaskStatus() {
const enabled = document.querySelector("#runBacktestToggle")?.checked;
- const backtest = state.screenerResult?.backtest;
- setText("backtestTaskStatus", backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
+ const backtest = activeScreenerResult("smart")?.backtest;
+ setText("backtestTaskStatus", state.screenerRunning && state.screenerRunningMode === "smart" && enabled
+ ? "正在回测"
+ : backtest ? `已完成 · ${number(backtest.samples)} 样本` : enabled ? "随选股执行" : "本次不执行");
+ renderScreenerProgress();
}
function selectRegime(regime) {
state.selectedRegime = regime;
- const recommended = state.screenerSetup.strategies.find((item) => item.regimes.includes(regime));
+ const recommended = state.screenerSetup.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(regime));
if (recommended) state.selectedStrategy = recommended;
renderScreenerSetup();
}
function renderStrategyList() {
const list = document.querySelector("#strategyList");
- list.innerHTML = state.screenerSetup.strategies.map((strategy) => `
+ const strategies = state.screenerSetup.strategies.filter((item) => item.formula?.meta?.library !== "curated");
+ list.innerHTML = strategies.map((strategy) => `
${escapeHtml(strategy.name)}${escapeHtml(strategy.description || "--")}
${strategy.regimes.map((item) => regimeLabel(item)).join(" / ")}${strategy.builtin ? " · 内置" : ""}
@@ -2373,10 +3721,10 @@ function populateStrategyEditor(strategy) {
const deleteButton = document.querySelector("#deleteStrategyButton");
deleteButton.hidden = !strategy?.id || Boolean(strategy.builtin);
if (!strategy) {
- setText("activeStrategyHeading", "--");
+ setText("activeStrategyEditorHeading", "--");
return;
}
- setText("activeStrategyHeading", strategy.name || "未命名策略");
+ setText("activeStrategyEditorHeading", strategy.name || "未命名策略");
document.querySelector("#strategyNameInput").value = strategy.name || "";
document.querySelector("#strategyDescriptionInput").value = strategy.description || "";
document.querySelector("#strategyPrompt").value = strategy.builtin ? strategy.description || "" : document.querySelector("#strategyPrompt").value;
@@ -2396,7 +3744,7 @@ async function syncFactorData() {
});
const result = payload.result;
showToast(`因子同步完成:${result.calendar_dates} 个交易日,竞价覆盖 ${number(result.auction_dates)} 日`);
- await loadScreenerSetup();
+ await loadScreenerSetup(true);
setText("factorTaskStatus", `已就绪 · ${number(result.calendar_dates)} 日`);
setStatus("选股因子已同步");
} catch (error) {
@@ -2423,7 +3771,7 @@ async function compileStrategy() {
const strategy = payload.strategy;
state.selectedStrategy = { ...strategy, id: null, builtin: false };
document.querySelector("#deleteStrategyButton").hidden = true;
- setText("activeStrategyHeading", strategy.name || "未命名策略");
+ renderStrategySummary();
document.querySelector("#strategyNameInput").value = strategy.name;
document.querySelector("#strategyDescriptionInput").value = strategy.description;
document.querySelector("#formulaEditor").value = JSON.stringify(strategy.formula, null, 2);
@@ -2470,8 +3818,8 @@ async function deleteCurrentStrategy() {
try {
const payload = await apiRequest(`/api/screener/strategies/${strategy.id}`, "DELETE");
state.screenerSetup.strategies = payload.strategies;
- state.selectedStrategy = payload.strategies.find((item) => item.regimes.includes(state.selectedRegime))
- || payload.strategies[0]
+ state.selectedStrategy = payload.strategies.find((item) => item.formula?.meta?.library !== "curated" && item.regimes.includes(state.selectedRegime))
+ || payload.strategies.find((item) => item.formula?.meta?.library !== "curated")
|| null;
renderScreenerSetup();
showToast("自定义策略已删除");
@@ -2482,44 +3830,133 @@ async function deleteCurrentStrategy() {
}
}
-async function runScreener() {
+async function runCuratedStrategy() {
+ const strategy = activeCuratedStrategy();
+ if (!strategy) return;
+ if (!strategy.data_ready) {
+ showToast(`请先同步${(strategy.missing_data || []).join("、")}`);
+ return;
+ }
+ const regime = strategy.regimes.includes(state.selectedRegime) ? state.selectedRegime : strategy.regimes[0];
+ await executeScreenerFormula({
+ mode: "curated",
+ formula: strategy.formula,
+ strategyName: strategy.name,
+ strategyId: strategy.id,
+ regime,
+ runBacktest: document.querySelector("#curatedBacktestToggle").checked,
+ button: document.querySelector("#curatedRunButton"),
+ loadingText: `正在执行“${strategy.name}”并计算历史样本`,
+ });
+}
+
+async function runQuantStrategy() {
+ let formula;
+ try {
+ formula = buildQuantFormula();
+ } catch (error) {
+ showToast(error.message);
+ return;
+ }
+ await executeScreenerFormula({
+ mode: "quant",
+ formula,
+ strategyName: "自定义量化公式",
+ regime: state.selectedRegime,
+ runBacktest: document.querySelector("#quantBacktestToggle").checked,
+ button: document.querySelector("#quantRunButton"),
+ loadingText: "正在执行量化公式并计算因子贡献",
+ });
+}
+
+function saveQuantAsStrategy() {
+ try {
+ const formula = buildQuantFormula();
+ state.selectedStrategy = {
+ id: null,
+ builtin: false,
+ name: "自定义量化策略",
+ description: "由量化因子工作台生成,可在高级公式中继续调整。",
+ regimes: [state.selectedRegime],
+ formula,
+ };
+ populateStrategyEditor(state.selectedStrategy);
+ document.querySelector("#strategyPrompt").value = "量化因子工作台生成的自定义公式";
+ openStrategyDrawer("editor");
+ } catch (error) {
+ showToast(error.message);
+ }
+}
+
+async function executeScreenerFormula({ mode, formula, strategyName, strategyId = null, regime, runBacktest, button, loadingText }) {
if (!state.screenerSetup?.factor_data?.ready) {
showToast("请先同步至少 21 个交易日的因子数据");
return;
}
- const button = document.querySelector("#screenerRunButton");
+ const missing = formulaMissingData(formula);
+ if (missing.length) {
+ showToast(`请先同步${missing.join("、")}`);
+ return;
+ }
+ const executionMode = ["smart", "curated", "quant"].includes(mode) ? mode : state.screenerMode;
button.disabled = true;
- setText("screenerRunStatus", "正在计算");
- setText("backtestTaskStatus", document.querySelector("#runBacktestToggle").checked ? "正在回测" : "本次不执行");
- setLoading(true, "正在计算因子排名与滚动回测", "screener");
- setStatus("正在执行智能选股");
+ state.screenerRunning = true;
+ state.screenerRunningMode = executionMode;
+ if (executionMode === "smart") {
+ setText("screenerRunStatus", "正在计算");
+ setText("backtestTaskStatus", runBacktest ? "正在回测" : "本次不执行");
+ }
+ setLoading(true, loadingText, "screener");
+ setStatus(`正在执行${strategyName}`);
try {
- const formula = parseFormulaEditor();
const payload = await apiRequest("/api/screener/run", "POST", {
trade_date: elements.tradeDate.value,
- regime: state.selectedRegime,
- strategy_name: document.querySelector("#strategyNameInput").value,
+ regime,
+ strategy_name: strategyName,
formula,
- run_backtest: document.querySelector("#runBacktestToggle").checked,
+ mode: executionMode,
+ run_backtest: runBacktest,
});
- state.screenerResult = payload.result;
+ setScreenerResult(executionMode, payload.result, { regime, strategyId, strategyName });
renderScreenerResult();
- await loadScreenerTracking(true);
- setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
+ if (executionMode === "smart") setText("screenerRunStatus", `完成 · ${payload.result.candidates.length} 只`);
updateBacktestTaskStatus();
if (window.innerWidth <= 720) selectScreenerMobileView("results");
- setStatus(`智能选股完成 · ${payload.result.candidates.length} 只候选`);
+ setStatus(`${strategyName}完成 · ${payload.result.candidates.length} 只候选`);
} catch (error) {
showToast(error.message);
- setStatus("智能选股失败");
- setText("screenerRunStatus", "执行失败");
+ setStatus("选股执行失败");
+ if (executionMode === "smart") setText("screenerRunStatus", "执行失败");
updateBacktestTaskStatus();
} finally {
+ state.screenerRunning = false;
+ state.screenerRunningMode = "";
setLoading(false);
button.disabled = false;
+ updateBacktestTaskStatus();
}
}
+async function runScreener() {
+ let formula;
+ try {
+ formula = parseFormulaEditor();
+ } catch (error) {
+ showToast(error.message);
+ return;
+ }
+ await executeScreenerFormula({
+ mode: "smart",
+ formula,
+ strategyName: document.querySelector("#strategyNameInput").value,
+ strategyId: state.selectedStrategy?.id,
+ regime: state.selectedRegime,
+ runBacktest: document.querySelector("#runBacktestToggle").checked,
+ button: document.querySelector("#screenerRunButton"),
+ loadingText: "正在计算因子排名与滚动回测",
+ });
+}
+
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
@@ -2600,10 +4037,16 @@ function renderMentorDirectory() {
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
- ${escapeHtml(mentor.name)}
+
+ ${escapeHtml(mentor.name)}
+ ${renderMentorBadges(mentor)}
+
${escapeHtml(mentor.description || mentor.tagline || "思维模型")}
+
+ ${mentor.evidence?.label ? `${escapeHtml(mentor.evidence.label)}` : ""}
+ ${(mentor.focus || []).slice(0, 2).map((item) => `#${escapeHtml(item)}`).join("")}
+
- ${renderMentorBadges(mentor)}
- ${escapeHtml(selected?.name || "问师")}
+
+ 向「${escapeHtml(selected?.name || "问师")}」请教
${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}
`;
+ refreshIcons();
} else {
container.innerHTML = state.mentorMessages.map((message) => `
@@ -4767,10 +6212,37 @@ function capitalize(value) {
const LINE_POSITIONS_CLIENT = ["初爻", "二爻", "三爻", "四爻", "五爻", "上爻"];
function renderScreenerResult() {
- const result = state.screenerResult;
- if (!result) return;
+ const mode = state.screenerMode || "smart";
+ const result = activeScreenerResult(mode);
+ const context = activeScreenerResultContext(mode);
+ const source = document.querySelector("#screenerResultSource");
+ const emptyMessages = {
+ smart: "尚未执行当前阶段与策略的选股",
+ curated: "尚未执行所选策略",
+ quant: "尚未执行量化选股",
+ };
+ if (!result) {
+ setText("screenerResultCount", "0 只");
+ source.hidden = true;
+ source.textContent = "";
+ setText("screenerDisclaimer", "历史统计不代表未来收益");
+ document.querySelector("#screenerTableBody").innerHTML = "";
+ document.querySelector("#screenerEmpty").textContent = emptyMessages[mode];
+ document.querySelector("#screenerEmpty").hidden = false;
+ renderBacktest(null);
+ if (mode === "smart") setText("screenerRunStatus", "等待执行");
+ updateBacktestTaskStatus();
+ renderScreenerProgress();
+ return;
+ }
const candidates = result.candidates || [];
setText("screenerResultCount", `${candidates.length} 只`);
+ const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
+ const sourceParts = [modeLabels[mode]];
+ if (mode !== "quant" && context?.regime) sourceParts.push(regimeLabel(context.regime));
+ sourceParts.push(mode === "quant" ? "自定义因子权重" : context?.strategyName || result.meta?.strategy_name || "未命名策略");
+ source.textContent = sourceParts.join(" · ");
+ source.hidden = false;
const meta = result.meta || {};
setText(
"screenerDisclaimer",
@@ -4780,17 +6252,18 @@ function renderScreenerResult() {
);
document.querySelector("#screenerEmpty").hidden = candidates.length > 0;
const body = document.querySelector("#screenerTableBody");
+ const runId = number(meta.run_id);
body.innerHTML = candidates.map((row, index) => `
- | ${index + 1} | ${escapeHtml(row.code)} |
- ${escapeHtml(row.name)} | ${escapeHtml(row.sector)} |
- ${formatNumber(row.score_display, 1)} |
- ${row.historical_probability === null ? "样本不足" : `${formatNumber(row.historical_probability, 1)}%`}${number(row.probability_samples)} 个样本 |
- ${signed(row.pct_chg)}% |
- ${signed(row.return_5d)}% |
- ${formatNumber(row.volume_ratio_5d, 2)} | ${formatNumber(row.sector_strength, 1)} |
+
| ${index + 1} |
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} | ${escapeHtml(row.sector)} |
+ ${formatNumber(row.score_display, 1)} |
+ ${row.historical_probability === null ? "" : formatNumber(row.historical_probability, 1)}${number(row.probability_samples)} 个样本 |
+ ${signed(row.pct_chg)} |
+ ${signed(row.return_5d)} |
+ ${formatNumber(row.volume_ratio_5d, 2)} | ${formatNumber(row.sector_strength, 1)} |
${escapeHtml(row.reason)} |
- ${escapeHtml(row.risk_flags.join(";") || "--")} |
- 详情 |
+ ${escapeHtml(row.risk_flags.join(";"))} |
+ 详情${isCandidateTracked(runId, row.code) ? "已跟踪" : "加入跟踪"} |
`).join("");
body.querySelectorAll("[data-screen-detail]").forEach((button) => {
button.addEventListener("click", () => {
@@ -4798,10 +6271,14 @@ function renderScreenerResult() {
openStock(row.code, row);
});
});
+ body.querySelectorAll("[data-add-tracking]").forEach((button) => {
+ button.addEventListener("click", () => addCandidateToTracking(button.dataset.addTracking, button));
+ });
bindStockRows(body);
renderBacktest(result.backtest);
- setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
+ if (mode === "smart") setText("screenerRunStatus", `完成 · ${candidates.length} 只`);
updateBacktestTaskStatus();
+ renderScreenerProgress();
}
async function loadScreenerTracking(force = false) {
@@ -4812,11 +6289,38 @@ async function loadScreenerTracking(force = false) {
try {
state.screenerTracking = await apiRequest("/api/screener/tracking?limit=12");
renderScreenerTracking();
+ if (activeScreenerResult()) renderScreenerResult();
} catch (error) {
showToast(error.message || "策略跟踪加载失败");
}
}
+function isCandidateTracked(runId, code) {
+ if (!runId) return false;
+ return (state.screenerTracking?.batches || []).some((batch) =>
+ number(batch.run_id) === number(runId)
+ && (batch.items || []).some((item) => item.code === code));
+}
+
+async function addCandidateToTracking(code, button) {
+ const runId = number(activeScreenerResult()?.meta?.run_id);
+ if (!runId) {
+ showToast("本次结果缺少选股批次,请重新执行后再加入跟踪");
+ return;
+ }
+ button.disabled = true;
+ try {
+ const payload = await apiRequest("/api/screener/tracking", "POST", { run_id: runId, code });
+ state.screenerTracking = payload.tracking;
+ renderScreenerTracking();
+ renderScreenerResult();
+ showToast(`${code} 已加入策略跟踪`);
+ } catch (error) {
+ button.disabled = false;
+ showToast(error.message || "加入跟踪失败");
+ }
+}
+
async function refreshScreenerTracking() {
const button = document.querySelector("#refreshTrackingButton");
button.disabled = true;
@@ -4842,6 +6346,7 @@ function renderScreenerTracking() {
const batches = payload.batches || [];
const rows = batches.flatMap((batch) => (batch.items || []).map((item) => ({
...item,
+ run_id: batch.run_id,
selection_date: batch.selection_date,
strategy_name: batch.strategy_name,
})));
@@ -4859,17 +6364,35 @@ function renderScreenerTracking() {
| ${displayCompactDate(row.selection_date)} |
${escapeHtml(row.strategy_name)} |
- ${escapeHtml(row.name)}${escapeHtml(row.code)} |
- ${formatNumber(row.entry_price, 2)} |
- ${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `${trackingReturn(row[key])} | `).join("")}
+ ${escapeHtml(row.name)}${escapeHtml(row.code)} |
+ ${row.entry_price == null ? "" : formatNumber(row.entry_price, 2)} |
+ ${["t1_open", "t1_close", "t3_close", "t5_close", "max_gain", "max_drawdown"].map((key) => `${trackingReturn(row[key], false)} | `).join("")}
${escapeHtml(row.status)} |
+ 移除 |
`).join("");
bindStockRows(document.querySelector("#trackingTableBody"));
}
-function trackingReturn(value) {
- return value == null ? "--" : `${signed(value)}%`;
+async function handleTrackingTableAction(event) {
+ const button = event.target.closest("[data-remove-tracking]");
+ if (!button) return;
+ if (!window.confirm("确定停止跟踪这只股票吗?")) return;
+ button.disabled = true;
+ try {
+ const payload = await apiRequest(`/api/screener/tracking/${button.dataset.removeTracking}`, "DELETE");
+ state.screenerTracking = payload.tracking;
+ renderScreenerTracking();
+ if (activeScreenerResult()) renderScreenerResult();
+ showToast("已移出策略跟踪");
+ } catch (error) {
+ button.disabled = false;
+ showToast(error.message || "移除跟踪失败");
+ }
+}
+
+function trackingReturn(value, includeUnit = true) {
+ return value == null ? (includeUnit ? "--" : "") : `${signed(value)}${includeUnit ? "%" : ""}`;
}
function trackingPercent(value) {
@@ -4898,7 +6421,8 @@ function parseFormulaEditor() {
}
function exportScreenerResults() {
- exportRows("智能选股", state.screenerResult?.candidates || [], [
+ const modeLabels = { smart: "阶段选股", curated: "策略选股", quant: "量化选股" };
+ exportRows(modeLabels[state.screenerMode] || "智能选股", activeScreenerResult()?.candidates || [], [
["股票代码", "code"], ["股票名称", "name"], ["板块", "sector"], ["综合分", "score_display"],
["历史条件估计%", "historical_probability"], ["当日涨幅%", "pct_chg"], ["5日涨幅%", "return_5d"],
["10日涨幅%", "return_10d"], ["量比", "volume_ratio_5d"], ["板块强度", "sector_strength"],
@@ -5314,7 +6838,7 @@ function findStockFallback(code) {
...(state.dashboard?.down_limits || []),
...(state.dashboard?.yesterday_limits || []),
];
- const screenerRows = state.screenerResult?.candidates || [];
+ const screenerRows = Object.values(state.screenerResults).flatMap((result) => result?.candidates || []);
const dragonRows = (state.dragonTiger?.traders || []).flatMap((trader) => trader.operations || []);
const auctionRows = state.auctionData?.rows || [];
const themeRows = state.themeDetail?.members || [];
@@ -6315,7 +7839,7 @@ function applyMembershipAccess() {
const gate = view.querySelector(".member-gate");
if (gate) gate.hidden = unlocked;
view.querySelectorAll("button, input, textarea, select").forEach((control) => {
- if (control.closest(".member-gate")) return;
+ if (control.closest(".member-gate") || control.hasAttribute("data-member-navigation")) return;
control.disabled = !unlocked;
});
});
@@ -6347,6 +7871,7 @@ function openView(viewId, updateHash = true) {
}
});
syncNavigationState(viewId);
+ setPageStatus(viewId);
if (updateHash) {
const url = new URL(window.location.href);
url.searchParams.set("view", viewId);
@@ -6357,7 +7882,7 @@ function openView(viewId, updateHash = true) {
applyMembershipAccess();
if (viewId === "dragonView") loadDragonTiger();
if (viewId === "reviewWorkspaceView") loadReviewWorkspace();
- if (viewId === "screenerView" && hasMemberAccess()) loadScreenerSetup();
+ if (viewId === "screenerView" && hasMemberAccess() && state.dashboard) loadScreenerSetup();
if (viewId === "mentorView" && hasMemberAccess()) loadMentorSetup();
if (viewId === "heavenView" && hasMemberAccess()) loadHeavenSetup(false, "", document.querySelector("#heavenStockInput").value.trim());
if (viewId === "sentimentCycleView") loadSentimentHistory();
@@ -6377,11 +7902,15 @@ function initializeAutoTableSorting() {
if (!body || body.rows.length < 2) return;
const direction = header.classList.contains("sort-asc") ? "desc" : "asc";
table.querySelectorAll("th.sort-asc, th.sort-desc").forEach((item) => {
- item.classList.remove("sort-asc", "sort-desc");
+ item.classList.remove("sort-asc", "sort-desc", "sorted");
item.removeAttribute("aria-sort");
+ const arrow = item.querySelector(".arr");
+ if (arrow) arrow.textContent = "↕";
});
- header.classList.add(`sort-${direction}`);
+ header.classList.add(`sort-${direction}`, "sorted");
header.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
+ const activeArrow = header.querySelector(".arr");
+ if (activeArrow) activeArrow.textContent = direction === "asc" ? "▲" : "▼";
const columnIndex = header.cellIndex;
const rows = [...body.rows].map((row, index) => ({ row, index }));
rows.sort((left, right) => {
@@ -6405,10 +7934,13 @@ function initializeAutoTableSorting() {
function markAutoSortableHeaders(root) {
root.querySelectorAll?.(".data-table:not(#limitTable) thead th").forEach((header) => {
+ if (header.closest("#brokenTable, #downTable, #yesterdayTable, #rotationTable")) return;
if (number(header.colSpan) > 1) return;
const label = header.textContent.trim();
if (!label || ["#", "操作"].includes(label)) return;
header.dataset.autoSort = "true";
+ header.classList.add("sortable");
+ if (!header.querySelector(".arr")) header.insertAdjacentHTML("beforeend", '↕');
header.title = `${label}:点击排序`;
});
}
@@ -6449,8 +7981,11 @@ function compareRows(left, right) {
function updateSortHeaders() {
document.querySelectorAll("#limitTable th[data-sort]").forEach((header) => {
- header.classList.remove("sort-asc", "sort-desc");
- if (header.dataset.sort === state.sortKey) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc");
+ header.classList.remove("sort-asc", "sort-desc", "sorted");
+ const active = header.dataset.sort === state.sortKey;
+ if (active) header.classList.add(state.sortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
+ const arrow = header.querySelector(".arr");
+ if (arrow) arrow.textContent = active ? (state.sortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
@@ -6504,9 +8039,8 @@ async function openSettings(panel = "profile") {
updateAccountIdentityBadges(membership);
applyMembershipAccess();
}
- const sourceLabel = membership.active && access.platform_configured ? "智能功能已就绪" : "智能功能尚未开通";
- status.textContent = `公共行情${payload.configured ? "已就绪" : "等待管理员配置"} · ${sourceLabel}`;
- status.classList.toggle("connected", Boolean(payload.configured));
+ status.textContent = membership.active ? "账户权益已同步" : "账户信息已同步";
+ status.classList.toggle("connected", true);
setText("membershipBadge", membership.subscribed ? "会员有效" : membership.is_admin ? "管理员权限" : "普通用户");
setText("membershipStateValue", membership.subscribed ? "已开通" : membership.is_admin ? "管理员可用" : "未开通");
setText("membershipRemainingValue", membership.subscribed && membership.expires_at
@@ -6534,7 +8068,7 @@ async function openSettings(panel = "profile") {
document.querySelector("#deleteBirthProfileButton").disabled = !payload.birth_profile_configured;
} catch (error) {
status.hidden = false;
- status.textContent = "无法连接本地后端";
+ status.textContent = "账户状态暂时无法同步";
showToast(error.message || "账号信息加载失败");
}
}
@@ -6809,18 +8343,36 @@ function exportStocks() {
}
function exportBroken() {
- exportRows("炸板池", state.dashboard?.broken || [], commonReviewColumns());
+ exportRows("炸板池", getVisibleBrokenRows(), [
+ ["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
+ ["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
+ ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
+ ]);
}
function exportDown() {
- exportRows("跌停板", state.dashboard?.down_limits || [], commonReviewColumns());
+ exportRows("跌停板", getVisibleDownRows(), [
+ ["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
+ ["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
+ ]);
}
function exportYesterday() {
- exportRows("昨日涨停", state.dashboard?.yesterday_limits || [], [
+ exportRows("昨日涨停", getVisibleYesterdayRows(), [
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
- ["所属板块", "sector"], ["涨停逻辑", "reason"],
+ ["所属板块", "sector"],
+ ]);
+}
+
+function exportLadder() {
+ const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
+ level: group.label || group.level,
+ ...stock,
+ })));
+ exportRows("市场天梯", rows, [
+ ["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
+ ["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
]);
}
@@ -6915,7 +8467,7 @@ function outcomeClass(outcome) {
}
function trendClass(trend) {
- return { "升温": "trend-hot", "降温": "trend-cool", "持平": "trend-flat" }[trend] || "trend-flat";
+ return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
}
function changeClass(value) {
@@ -6997,6 +8549,31 @@ function setStatus(text) {
setText("statusText", text);
}
+function setPageStatus(viewId) {
+ const labels = {
+ sentimentCycleView: "情绪周期",
+ limitPool: "涨停池",
+ brokenView: "炸板池",
+ downView: "跌停板",
+ yesterdayView: "昨日涨停",
+ performanceView: "涨停表现",
+ ladderView: "市场天梯",
+ rotationView: "板块轮动",
+ auctionView: "集合竞价",
+ themeLibraryView: "题材库",
+ popularityView: "人气热榜",
+ dragonView: "龙虎榜",
+ screenerView: "智能选股",
+ screenerTrackingView: "策略持续跟踪",
+ mentorView: "问师",
+ heavenView: "问天",
+ reviewWorkspaceView: "我的复盘",
+ };
+ const label = labels[viewId] || "小白复盘";
+ const tradeDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate?.value || "");
+ setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
+}
+
let toastTimer;
function showToast(message) {
clearTimeout(toastTimer);
@@ -7107,8 +8684,12 @@ function syncNavigationState(viewId) {
const marketView = MARKET_VIEWS.has(viewId);
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
- button.classList.toggle("active", button.dataset.view === viewId);
- button.classList.toggle("mobile-active", marketView && button.dataset.view === "limitPool");
+ button.classList.toggle(
+ "active",
+ button.dataset.view === viewId
+ || (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"),
+ );
+ button.classList.remove("mobile-active");
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");
diff --git a/static/design-system.css b/static/design-system.css
new file mode 100644
index 0000000..6fb1909
--- /dev/null
+++ b/static/design-system.css
@@ -0,0 +1,931 @@
+/* Canonical non-heaven design system. Question-to-Heaven remains isolated. */
+/* ========== 小白复盘 · 打样设计系统 ========== */
+:root{
+ --blue:#2563eb; --blue-d:#1d4ed8; --blue-soft:#eff4ff; --blue-line:#c7d8fb;
+ --up:#e04536; --up-soft:#fdecea; --down:#16a34a; --down-soft:#e9f7ee;
+ --amber:#b45309; --amber-soft:#fdf3e3;
+ --ink:#1f2937; --sub:#6b7280; --faint:#9ca3af;
+ --line:#e5e7eb; --line-soft:#eef0f3; --bg:#f4f5f7; --card:#fff;
+ --radius:10px;
+ --shadow:0 1px 2px rgba(16,24,40,.05);
+ /* Shell dimensions are tokens because data-heavy pages reuse them in viewport calculations. */
+ --sidebar-width:200px;
+ --topbar-height:46px;
+ --summary-height:34px;
+ --statusbar-height:30px;
+ --page-pad-y:14px;
+ --page-pad-x:16px;
+ --card-gap:12px;
+ --workspace-height:calc(100vh - var(--topbar-height) - var(--statusbar-height));
+ --content-height:calc(var(--workspace-height) - var(--summary-height));
+ --table-wide:1180px;
+ --table-medium:930px;
+ --table-compact:720px;
+ --col-rank:44px;
+ --col-date:94px;
+ --col-stock:160px;
+ --col-number:96px;
+ --col-action:96px;
+ --col-text:220px;
+ --right-rail-wide:372px;
+ --pool-table-max-height:calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
+ --primary-share:1.45fr;
+ --secondary-share:.75fr;
+ --mobile-nav-height:58px;
+ --mobile-header-height:50px;
+ --mobile-tab-height:54px;
+ --mobile-shell-pad:8px;
+ --mobile-page-pad:10px;
+ --mobile-min-width:320px;
+ --space-4:4px;
+ --font-aux:10.5px;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%}
+body{
+ display:block;
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+ background:var(--bg); color:var(--ink); font-size:13px; padding-bottom:var(--statusbar-height);
+}
+button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
+input,textarea,select{font-family:inherit;font-size:13px;color:var(--ink)}
+a{color:inherit;text-decoration:none}
+/* Keep native modal centering after the system reset removes browser defaults. */
+dialog{margin:auto}
+
+/* ---------- 侧栏 ---------- */
+.sidebar{
+ position:fixed;left:0;top:0;bottom:0;width:var(--sidebar-width);background:#fff;border-right:1px solid var(--line);
+ display:flex;flex-direction:column;z-index:60;
+}
+.sidebar .brand{display:flex;align-items:center;gap:8px;padding:14px 16px;border-bottom:1px solid var(--line-soft)}
+.sidebar .brand .logo{width:26px;height:26px;border-radius:7px;background:var(--blue);color:#fff;
+ display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700}
+.sidebar .brand b{font-size:15px}
+.nav{flex:1;overflow-y:auto;padding:8px}
+.nav .group{font-size:11px;color:var(--faint);padding:12px 10px 4px}
+.nav .item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:7px;color:#374151;
+ font-size:13px;margin-bottom:1px;cursor:default}
+.nav .item .ico{width:16px;text-align:center;opacity:.75}
+.nav .item.active{background:var(--blue-soft);color:var(--blue);font-weight:600}
+.nav .item.dis{opacity:.55}
+.sidebar .collapse{border-top:1px solid var(--line-soft);padding:10px 16px;color:var(--sub);font-size:12px}
+
+/* ---------- 顶栏 ---------- */
+.main{margin-left:var(--sidebar-width);min-width:1080px}
+.topbar{
+ position:sticky;top:0;z-index:50;background:#fff;border-bottom:1px solid var(--line);
+ display:flex;align-items:center;gap:14px;padding:0 var(--page-pad-x);height:var(--topbar-height);
+}
+.mkt{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--sub)}
+.mkt b{font-weight:600}
+.mkt .up{color:var(--up)} .mkt .down{color:var(--down)}
+.topbar .spacer{flex:1}
+.datepick{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:7px;padding:4px 10px;color:var(--ink);background:#fff}
+.datepick .arrow{color:var(--faint)}
+.tbtn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:7px;
+ padding:5px 11px;font-size:12px;color:#374151;background:#fff}
+.tbtn.primary{background:var(--blue);border-color:var(--blue);color:#fff}
+.tbtn:hover{border-color:var(--blue-line)}
+.tbtn.primary:hover{background:var(--blue-d)}
+.avatar{display:flex;align-items:center;gap:6px;color:var(--sub);font-size:12px}
+.badge-admin{background:var(--amber-soft);color:var(--amber);border-radius:5px;padding:1px 6px;font-size:11px}
+
+/* ---------- 全市场摘要条(折叠态) ---------- */
+.mktstrip{background:#fff;border-bottom:1px solid var(--line)}
+ .mktstrip .row{display:flex;align-items:center;gap:18px;padding:7px var(--page-pad-x);font-size:12px;color:var(--sub)}
+.mktstrip .row b{color:var(--ink);font-weight:600}
+.mktstrip .emo{display:inline-flex;align-items:center;gap:6px}
+.mktstrip .emo .dot{width:8px;height:8px;border-radius:50%;background:var(--up)}
+.mktstrip .toggle{margin-left:auto;color:var(--blue);font-size:12px}
+.mktstrip .full{display:none;grid-template-columns:repeat(7,1fr);gap:1px;background:var(--line-soft);
+ border-top:1px solid var(--line-soft)}
+.mktstrip.open .full{display:grid}
+.mktstrip .full .cell{background:#fff;padding:10px 16px}
+.mktstrip .full .k{font-size:11px;color:var(--faint)}
+.mktstrip .full .v{font-size:18px;font-weight:700;margin-top:2px}
+
+/* ---------- 页面通用 ---------- */
+.page{padding:var(--page-pad-y) var(--page-pad-x)}
+.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
+.card-h{display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid var(--line-soft)}
+.card-h h3{font-size:14px;font-weight:700}
+.card-h .sub{font-size:11px;color:var(--faint)}
+.card-h .right{margin-left:auto;display:flex;align-items:center;gap:8px}
+.dtag{font-size:11px;color:var(--sub);background:#f3f4f6;border-radius:5px;padding:2px 7px}
+
+.btn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:7px;
+ padding:6px 13px;font-size:12.5px;background:#fff;color:#374151;font-weight:500}
+.btn:hover{border-color:var(--blue-line);color:var(--blue)}
+.btn.primary{background:var(--blue);border-color:var(--blue);color:#fff}
+.btn.primary:hover{background:var(--blue-d);color:#fff}
+.btn.sm{padding:4px 9px;font-size:12px}
+.btn.ghost{border-color:transparent;color:var(--blue)}
+
+/* 轻量 Tab(下划线式) */
+.tabs{display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--line-soft);padding:0 14px}
+.tabs .tab{padding:10px 14px;font-size:13px;color:var(--sub);border-bottom:2px solid transparent;margin-bottom:-1px;font-weight:500}
+.tabs .tab .n{font-size:11px;color:var(--faint);margin-left:3px;font-weight:400}
+.tabs .tab.active{color:var(--blue);border-bottom-color:var(--blue);font-weight:600}
+.tabs .tab.active .n{color:var(--blue)}
+
+/* 分段筛选 */
+.seg{display:inline-flex;background:#f3f4f6;border-radius:8px;padding:2px;gap:2px}
+.seg button{padding:4px 12px;border-radius:6px;font-size:12px;color:var(--sub)}
+.seg button.on{background:#fff;color:var(--ink);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.08)}
+.seg button .n{font-size:11px;color:var(--faint);margin-left:2px}
+.seg button.on .n{color:var(--blue)}
+
+/* 搜索框 */
+.search{display:flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:7px;padding:5px 10px;background:#fff}
+.search input{border:none;outline:none;width:150px;font-size:12.5px}
+.search .ico{color:var(--faint)}
+
+/* ---------- 表格 ---------- */
+.tbl-wrap{overflow:auto}
+table.tbl{width:100%;border-collapse:collapse;font-size:12.5px}
+.tbl thead th{
+ position:sticky;top:0;background:#f8fafc;color:var(--sub);font-weight:600;font-size:12px;
+ text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;z-index:2;
+}
+.tbl thead th.sortable{cursor:pointer;user-select:none}
+.tbl thead th.sortable:hover{color:var(--blue)}
+.tbl thead th .arr{font-size:9px;color:var(--faint);margin-left:3px}
+.tbl thead th.sorted .arr{color:var(--blue)}
+.tbl tbody td{padding:9px 12px;border-bottom:1px solid var(--line-soft);white-space:nowrap;vertical-align:middle}
+.tbl tbody tr:hover{background:#f8faff}
+.tbl.compact tbody td{padding:5px 12px}
+.tbl .num{text-align:right;font-variant-numeric:tabular-nums}
+.tbl thead th.num{text-align:right}
+.sname{font-weight:700;font-size:13px}
+.scode{font-size:11px;color:var(--faint);margin-left:6px;font-weight:400}
+.muted{color:var(--faint)}
+.up{color:var(--up)} .down{color:var(--down)}
+
+/* 标签 */
+.tag{display:inline-block;border-radius:5px;padding:1.5px 7px;font-size:11px;line-height:1.6;border:1px solid transparent}
+.tag.neu{background:#f3f4f6;color:#4b5563;border-color:#e5e7eb}
+.tag.red{background:var(--up-soft);color:var(--up)}
+.tag.green{background:var(--down-soft);color:var(--down)}
+.tag.amber{background:var(--amber-soft);color:var(--amber)}
+.tag.b1{background:#e3ecfd;color:#3b62c4} /* 强度:浅 */
+.tag.b2{background:#c2d5fa;color:#2b56bd} /* 强度:中 */
+.tag.b3{background:var(--blue);color:#fff} /* 强度:强 */
+
+/* ---------- 状态栏 ---------- */
+.statusbar{
+ position:fixed;left:var(--sidebar-width);right:0;bottom:0;height:var(--statusbar-height);background:#fff;border-top:1px solid var(--line);
+ display:flex;align-items:center;gap:16px;padding:0 var(--page-pad-x);font-size:11.5px;color:var(--faint);z-index:55;
+}
+.statusbar #statusText{display:block;flex:1;text-align:left}
+.statusbar .risk-note{display:block;flex:1;margin:0;text-align:center}
+.statusbar #updatedAt{display:block;flex:1;text-align:right}
+.statusbar .ok{color:var(--down)}
+
+/* ---------- 浮动说明按钮 ---------- */
+.helpfab{position:fixed;right:18px;bottom:44px;z-index:80}
+.helpfab .fab{width:38px;height:38px;border-radius:50%;background:var(--ink);color:#fff;font-size:15px;
+ display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,.25)}
+.helppanel{position:fixed;right:18px;bottom:90px;width:380px;max-height:70vh;overflow:auto;z-index:80;
+ background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.18);
+ display:none}
+.helppanel.open{display:block}
+.helppanel .hp-h{padding:12px 16px;border-bottom:1px solid var(--line-soft);font-weight:700;display:flex;align-items:center}
+.helppanel .hp-h .x{margin-left:auto;color:var(--faint);font-size:16px}
+.helppanel .hp-b{padding:12px 16px}
+.helppanel h4{font-size:12.5px;margin:10px 0 5px;color:var(--blue)}
+.helppanel h4:first-child{margin-top:0}
+.helppanel li{font-size:12px;color:#4b5563;margin:3px 0 3px 16px;line-height:1.7}
+
+/* ---------- 抽屉 ---------- */
+.drawer-mask{position:fixed;inset:0;background:rgba(15,23,42,.35);z-index:90;display:none}
+.drawer{position:fixed;top:0;right:-460px;width:440px;bottom:0;background:#fff;z-index:95;
+ box-shadow:-8px 0 24px rgba(0,0,0,.12);transition:right .25s;display:flex;flex-direction:column}
+body.drawer-open .drawer{right:0}
+body.drawer-open .drawer-mask{display:block}
+.drawer .d-h{padding:14px 18px;border-bottom:1px solid var(--line-soft);display:flex;align-items:center;font-weight:700;font-size:14px}
+.drawer .d-h .x{margin-left:auto;color:var(--faint);font-size:18px}
+.drawer .d-b{flex:1;overflow:auto;padding:16px 18px}
+.drawer .d-f{padding:12px 18px;border-top:1px solid var(--line-soft);display:flex;justify-content:flex-end;gap:8px}
+.field{margin-bottom:14px}
+.field label{display:block;font-size:12px;color:var(--sub);margin-bottom:5px;font-weight:600}
+.field input[type=text],.field textarea,.field select{
+ width:100%;border:1px solid var(--line);border-radius:7px;padding:8px 10px;outline:none}
+.field textarea{min-height:90px;resize:vertical}
+.field input:focus,.field textarea:focus{border-color:var(--blue-line)}
+
+/* toast */
+.toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:var(--ink);color:#fff;
+ padding:8px 18px;border-radius:8px;font-size:12.5px;z-index:200;opacity:0;transition:opacity .2s;pointer-events:none}
+.toast.show{opacity:.95}
+
+/* ========== 集合竞价页 ========== */
+.auc-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
+.auc-head h2{font-size:17px;font-weight:800}
+.auc-head .frozen{display:inline-flex;align-items:center;gap:5px;background:var(--down-soft);color:var(--down);
+ border-radius:6px;padding:3px 9px;font-size:12px;font-weight:600}
+.auc-head .frozen .dot{width:7px;height:7px;border-radius:50%;background:var(--down)}
+.auc-stats{margin-left:auto;display:flex;gap:22px}
+.auc-stats .st{text-align:right}
+.auc-stats .st .k{font-size:11px;color:var(--faint)}
+.auc-stats .st .v{font-size:16px;font-weight:800;font-variant-numeric:tabular-nums}
+.auc-stats .st .v em{font-style:normal;font-size:11px;font-weight:500;color:var(--sub)}
+
+.auc-grid{display:grid;grid-template-columns:minmax(0,1fr) 372px;gap:12px;align-items:start}
+.auc-side{display:flex;flex-direction:column;gap:12px}
+
+/* 表格工具行 */
+.tbl-tools{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--line-soft);flex-wrap:wrap}
+.tbl-tools .lbl{font-size:12px;color:var(--faint)}
+.tbl-tools .right{margin-left:auto;display:flex;align-items:center;gap:8px}
+
+/* 来源 chips */
+.src{display:inline-block;background:#f3f4f6;color:#6b7280;border-radius:4px;padding:0 5px;font-size:10.5px;margin-right:3px;line-height:1.7}
+.src.hot{background:#fdf0e6;color:#c2691a}
+
+/* 侧栏卡片:题材承接 */
+.sector{padding:6px 14px}
+.sector .row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px dashed var(--line-soft)}
+.sector .row:last-child{border-bottom:none}
+.sector .nm{font-weight:700;font-size:13px;width:64px}
+.sector .info{flex:1;min-width:0}
+.sector .info .lead{font-size:11.5px;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.sector .stat{text-align:right}
+.sector .stat .pct{font-size:12.5px;font-weight:700;font-variant-numeric:tabular-nums}
+.sector .stat .cnt{font-size:10.5px;color:var(--faint)}
+.newclue{padding:9px 14px 12px;font-size:12px;color:var(--sub);border-top:1px solid var(--line-soft)}
+.newclue b{color:var(--ink);font-size:12.5px}
+
+/* 竞价成交额对比 */
+.volcard .vc-body{padding:12px 14px 8px}
+.vol-sum{display:flex;align-items:baseline;gap:16px;margin-bottom:8px}
+.vol-sum .big{font-size:22px;font-weight:800;font-variant-numeric:tabular-nums}
+.vol-sum .cmp{font-size:11.5px;color:var(--sub)}
+.vol-sum .cmp b{font-weight:700}
+.volchart{display:flex;align-items:flex-end;gap:5px;height:96px;padding:6px 0 0;margin-top:16px;position:relative}
+.volchart .bar{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;position:relative}
+.volchart .bar i{display:block;width:70%;background:#c9d6ee;border-radius:3px 3px 0 0;min-height:4px}
+.volchart .bar.today i{background:var(--up)}
+.volchart .bar span{font-size:9.5px;color:var(--faint);margin-top:4px;transform:scale(.92)}
+.volchart .bar.today span{color:var(--up);font-weight:700}
+.volchart .avgline{position:absolute;left:0;right:0;border-top:1.5px dashed #f59e0b;pointer-events:none}
+.volchart .avgline em{position:absolute;right:0;top:-16px;font-size:10px;color:#d97706;font-style:normal;background:#fff;padding:0 2px}
+.vol-legend{display:flex;gap:14px;padding:6px 14px 11px;font-size:10.5px;color:var(--faint)}
+.vol-legend i{display:inline-block;width:10px;height:8px;border-radius:2px;margin-right:4px;vertical-align:-1px}
+
+/* 隔夜消息反馈 */
+.msg-empty{padding:18px 14px;text-align:center;color:var(--faint);font-size:12px}
+.msg-empty .ico{font-size:22px;margin-bottom:6px}
+.msg-item{padding:9px 14px;border-bottom:1px dashed var(--line-soft);display:flex;gap:8px;align-items:flex-start}
+.msg-item:last-child{border-bottom:none}
+.msg-item .txt{flex:1;font-size:12.5px;line-height:1.6}
+.msg-item .txt .rel{color:var(--faint);font-size:11px;margin-top:2px}
+.msg-item .dir{flex-shrink:0;margin-top:1px}
+
+/* ========== 智能选股页 ========== */
+.scr-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
+.scr-head h2{font-size:17px;font-weight:800}
+.scr-head .sub{font-size:12px;color:var(--faint)}
+.method{display:inline-flex;background:#fff;border:1px solid var(--line);border-radius:10px;padding:3px;gap:3px;margin-left:auto}
+.method button{padding:6px 18px;border-radius:7px;font-size:13px;color:var(--sub);font-weight:600;display:flex;align-items:center;gap:6px}
+.method button.on{background:var(--blue);color:#fff}
+.method button .soon{font-size:10px;background:var(--amber-soft);color:var(--amber);border-radius:4px;padding:0 5px;font-weight:500}
+.method button.on .soon{background:rgba(255,255,255,.22);color:#fff}
+
+/* 步骤条 */
+.stepper{display:flex;align-items:center;gap:0;margin-bottom:12px;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:12px 18px}
+.step{display:flex;align-items:center;gap:9px}
+.step .no{width:24px;height:24px;border-radius:50%;background:var(--down);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0}
+.step .no.cur{background:var(--blue)}
+.step .no.todo{background:#e5e7eb;color:var(--faint)}
+.step .tt{font-size:13px;font-weight:700}
+.step .ds{font-size:11px;color:var(--faint)}
+.step .ln{width:64px;height:1.5px;background:var(--line);margin:0 14px}
+
+/* 阶段+策略 双卡 */
+.ps-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
+.phase-b{display:flex;gap:16px;padding:14px 16px;align-items:flex-start}
+.phase-badge{flex-shrink:0;text-align:center;background:var(--up-soft);border:1px solid #f5cfc9;border-radius:10px;padding:10px 18px}
+.phase-badge .p{font-size:19px;font-weight:800;color:var(--up)}
+.phase-badge .c{font-size:11px;color:var(--sub);margin-top:2px}
+.phase-info{flex:1;min-width:0}
+.phase-info .sum{font-size:12.5px;color:#374151;line-height:1.7}
+.phase-info .adv{font-size:12px;color:var(--amber);background:var(--amber-soft);border-radius:6px;padding:5px 9px;margin-top:8px;line-height:1.6}
+.phase-pick{display:flex;gap:5px;margin-top:10px;flex-wrap:wrap;align-items:center}
+.phase-pick .pp{border:1px solid var(--line);border-radius:6px;padding:3px 11px;font-size:12px;color:var(--sub)}
+.phase-pick .pp.on{border-color:var(--up);color:var(--up);background:var(--up-soft);font-weight:700}
+.phase-pick .auto{font-size:11px;color:var(--down);margin-left:4px}
+.strat-b{padding:14px 16px}
+.strat-cur{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.strat-cur .nm{font-size:15px;font-weight:800}
+.strat-desc{font-size:12px;color:var(--sub);margin-top:8px;line-height:1.7}
+.strat-acts{display:flex;gap:8px;margin-top:12px}
+
+/* 执行条 */
+.runbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;background:#fff;border:1px solid var(--line);
+ border-radius:var(--radius);padding:10px 14px;flex-wrap:wrap}
+.runbar .pipe{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-left:auto;flex-wrap:wrap}
+.runbar .pipe .ok{color:var(--down)}
+
+/* 回测摘要条 */
+.bt-strip{display:flex;align-items:center;gap:24px;padding:10px 14px;border-bottom:1px solid var(--line-soft);
+ background:#fffaf3;flex-wrap:wrap}
+.bt-strip .warn-ico{color:var(--amber);font-size:15px}
+.bt-strip .bt .k{font-size:11px;color:var(--faint)}
+.bt-strip .bt .v{font-size:15px;font-weight:800;font-variant-numeric:tabular-nums}
+.bt-strip .note{margin-left:auto;font-size:11px;color:var(--faint);max-width:420px;line-height:1.6}
+
+/* 策略库(策略选股视图) */
+.lib-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
+.lib-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:8px}
+.lib-card:hover{border-color:var(--blue-line);box-shadow:0 4px 14px rgba(37,99,235,.08)}
+.lib-card .nm{font-size:14px;font-weight:800;display:flex;align-items:center;gap:8px}
+.lib-card .ds{font-size:12px;color:var(--sub);line-height:1.7;flex:1}
+.lib-card .meta{display:flex;align-items:center;gap:10px;font-size:11.5px;color:var(--faint)}
+.lib-card .acts{display:flex;gap:8px}
+.lib-card.new{border:1.5px dashed var(--line);align-items:center;justify-content:center;color:var(--faint);min-height:150px;font-size:13px;cursor:pointer}
+.lib-card.new:hover{color:var(--blue);border-color:var(--blue-line)}
+
+/* 量化选股视图 */
+.quant-grid{display:grid;grid-template-columns:400px minmax(0,1fr);gap:12px;align-items:start}
+.factor{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--line-soft)}
+.factor:last-child{border-bottom:none}
+.factor .fname{width:88px;font-size:12.5px;font-weight:600}
+.factor .fname small{display:block;font-weight:400;color:var(--faint);font-size:10.5px}
+.factor input[type=range]{flex:1;accent-color:var(--blue)}
+.factor .wv{width:38px;text-align:right;font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+.factor .neg .wv{color:var(--down)}
+.cond{display:flex;align-items:center;gap:8px;padding:7px 0;font-size:12.5px;flex-wrap:wrap}
+.cond input[type=text],.cond select{border:1px solid var(--line);border-radius:6px;padding:4px 8px;width:76px;font-size:12px}
+.wsum{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#f8fafc;border-top:1px solid var(--line-soft);font-size:12px}
+.wsum .bar{flex:1;height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden}
+.wsum .bar i{display:block;height:100%;background:var(--blue);border-radius:3px}
+
+.view{display:none}
+.view.on{display:block}
+.preview-tag{font-size:10.5px;background:var(--amber-soft);color:var(--amber);border-radius:4px;padding:1px 6px;font-weight:600}
+
+@media (max-width:1400px){
+ .auc-grid{grid-template-columns:minmax(0,1fr) 340px}
+}
+
+/* ========== 市场天梯页 ========== */
+.lad-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
+.lad-head h2{font-size:17px;font-weight:800}
+.lad-head .sub{font-size:12px;color:var(--faint)}
+.lad-head .right{margin-left:auto;display:flex;align-items:center;gap:8px}
+
+.lad-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:12px;align-items:start}
+.lad-side{display:flex;flex-direction:column;gap:12px}
+
+/* 梯队层 */
+.tier{display:flex;border-bottom:1px solid var(--line-soft)}
+.tier:last-child{border-bottom:none}
+.tier .lab{width:118px;flex-shrink:0;padding:14px 0 14px 16px;border-right:1px solid var(--line-soft)}
+.tier .lab .lv{display:inline-flex;align-items:center;gap:6px;font-size:15px;font-weight:800}
+.tier .lab .lv .dot{width:9px;height:9px;border-radius:3px}
+.tier .lab .cnt{font-size:11px;color:var(--faint);margin-top:3px}
+.tier .lab .rate{font-size:10.5px;margin-top:6px;color:var(--sub)}
+.tier .lab .rate b{font-weight:700}
+.tier .cards{flex:1;display:flex;flex-wrap:wrap;gap:8px;padding:12px 14px;align-content:flex-start}
+.tier.t4 .lab{background:linear-gradient(90deg,#fdf1ef,#fff)}
+.tier.t3 .lab{background:linear-gradient(90deg,#fdf6ec,#fff)}
+.tier.t2 .lab{background:linear-gradient(90deg,#ecf7f1,#fff)}
+.tier.t1 .lab{background:linear-gradient(90deg,#eef4fd,#fff)}
+
+/* 断层带 */
+.tier.gap .cards{display:flex;align-items:center;color:var(--faint);font-size:12px}
+.tier.gap .lab{background:repeating-linear-gradient(45deg,#fafafa,#fafafa 8px,#f3f4f6 8px,#f3f4f6 16px)}
+.tier.gap .gapnote{border:1.5px dashed var(--line);border-radius:8px;padding:8px 14px;color:var(--faint)}
+
+/* 股票卡 */
+.scard{border:1px solid var(--line);border-radius:8px;padding:7px 11px;min-width:172px;background:#fff;cursor:default;transition:box-shadow .15s}
+.scard:hover{box-shadow:0 3px 10px rgba(16,24,40,.1);border-color:var(--blue-line)}
+.scard .r1{display:flex;align-items:center;gap:6px}
+.scard .r1 .nm{font-weight:800;font-size:13px}
+.scard .r1 .cd{font-size:10.5px;color:var(--faint)}
+.scard .r1 .tags{margin-left:auto;display:flex;gap:3px}
+.scard .r2{display:flex;align-items:center;gap:6px;margin-top:4px;font-size:11px;color:var(--sub)}
+.scard .r2 .sec{color:var(--blue);background:var(--blue-soft);border-radius:4px;padding:0 5px}
+.scard .r2 .tm{font-variant-numeric:tabular-nums}
+.scard .r2 .fd{margin-left:auto;color:var(--faint);font-size:10.5px}
+.tag.yz{background:#fde8e8;color:#c22e2e;font-weight:700} /* 一字 */
+.tag.lb{background:var(--amber-soft);color:var(--amber)} /* 烂板 */
+
+/* 更多展开 */
+.more-btn{align-self:center;border:1px dashed var(--line);border-radius:8px;padding:8px 16px;color:var(--sub);font-size:12px}
+.more-btn:hover{color:var(--blue);border-color:var(--blue-line)}
+
+/* 右栏:空间板 */
+.apex{padding:14px 16px}
+.apex .h{display:flex;align-items:baseline;gap:10px}
+.apex .h .big{font-size:26px;font-weight:800;color:var(--up)}
+.apex .h .chg{font-size:11.5px;color:var(--amber);background:var(--amber-soft);border-radius:5px;padding:2px 7px}
+.apex .names{margin-top:8px;font-size:12.5px;line-height:1.9}
+.apex .names b{font-weight:700}
+.apex .note{margin-top:8px;font-size:11.5px;color:var(--sub);line-height:1.7;border-top:1px dashed var(--line-soft);padding-top:8px}
+
+/* 右栏:梯队结构条 */
+.pyr{padding:8px 16px 12px}
+.pyr .row{display:flex;align-items:center;gap:8px;padding:4px 0}
+.pyr .row .k{width:52px;font-size:12px;color:var(--sub);text-align:right}
+.pyr .row .bar{height:14px;border-radius:4px;min-width:3px}
+.pyr .row .v{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+.pyr .row.gapped .bar{background:repeating-linear-gradient(45deg,#e5e7eb,#e5e7eb 4px,#f3f4f6 4px,#f3f4f6 8px)!important}
+.pyr .row.gapped .v{color:var(--faint);font-weight:400}
+.pyr .sum{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.6}
+
+/* 右栏:晋级率参考 */
+.rate-list{padding:6px 16px 12px}
+.rate-list .row{display:flex;align-items:center;gap:8px;padding:5px 0;font-size:12px}
+.rate-list .row .k{width:96px;color:var(--sub)}
+.rate-list .row .bar{flex:1;height:8px;background:#f3f4f6;border-radius:4px;overflow:hidden}
+.rate-list .row .bar i{display:block;height:100%;border-radius:4px;background:var(--blue)}
+.rate-list .row .bar i.low{background:#f59e0b}
+.rate-list .row .bar i.zero{background:#d1d5db}
+.rate-list .row .v{width:44px;text-align:right;font-weight:700;font-variant-numeric:tabular-nums}
+.rate-list .src{font-size:10.5px;color:var(--faint);margin-top:4px}
+
+@media (max-width:1400px){.lad-grid{grid-template-columns:minmax(0,1fr) 300px}}
+
+/* ========== 板块轮动页 ========== */
+.rot-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
+.rot-head h2{font-size:17px;font-weight:800}
+.rot-head .sub{font-size:12px;color:var(--faint)}
+.rot-head .right{margin-left:auto;display:flex;align-items:center;gap:8px}
+
+/* 图例 */
+.rot-legend{display:flex;align-items:center;gap:14px;padding:8px 14px;border-bottom:1px solid var(--line-soft);font-size:11px;color:var(--sub);flex-wrap:wrap}
+.rot-legend .sw{display:inline-flex;align-items:center;gap:4px}
+.rot-legend .sw i{width:14px;height:10px;border-radius:2px}
+.rot-legend .sep{width:1px;height:12px;background:var(--line)}
+.rot-legend .q{color:var(--faint);cursor:help;border-bottom:1px dashed var(--faint)}
+
+/* 追踪条 */
+.trackbar{display:none;align-items:center;gap:16px;padding:9px 14px;background:var(--blue-soft);border-bottom:1px solid var(--blue-line);flex-wrap:wrap}
+.trackbar.on{display:flex}
+.trackbar .tn{font-weight:800;color:var(--blue);font-size:13.5px}
+.trackbar .ti{font-size:12px;color:#3b62c4}
+.trackbar .ti b{font-weight:700}
+.trackbar .spark{display:flex;align-items:flex-end;gap:3px;height:26px;margin-left:4px}
+.trackbar .spark i{width:12px;background:#93b4f5;border-radius:2px 2px 0 0;min-height:3px;position:relative}
+.trackbar .spark i.g{background:transparent;border:1px dashed #b9c8e8;border-bottom:none;min-height:8px}
+.trackbar .spark i em{position:absolute;top:-13px;left:50%;transform:translateX(-50%);font-size:9px;color:#3b62c4;font-style:normal}
+.trackbar .x{margin-left:auto}
+
+/* 热点轨迹矩阵 */
+.rot-matrix{display:grid;grid-template-columns:repeat(9,minmax(150px,1fr));overflow-x:auto}
+.rot-day{border-right:1px solid var(--line-soft);min-width:150px}
+.rot-day:last-child{border-right:none}
+.rot-day .dh{padding:9px 12px;border-bottom:1px solid var(--line-soft);background:#f8fafc}
+.rot-day .dh .d{font-size:12.5px;font-weight:700}
+.rot-day .dh .n{font-size:10.5px;color:var(--faint);margin-top:1px}
+.rot-day.today .dh{background:var(--blue-soft)}
+.rot-day.today .dh .d{color:var(--blue)}
+.rot-day.today .dh .d::after{content:"今天";font-size:10px;background:var(--blue);color:#fff;border-radius:4px;padding:0 5px;margin-left:6px;vertical-align:1px}
+.rot-cell{display:flex;align-items:center;gap:7px;padding:6.5px 12px;border-bottom:1px dashed var(--line-soft);cursor:pointer;position:relative;transition:filter .15s}
+.rot-cell:last-child{border-bottom:none}
+.rot-cell:hover{filter:brightness(.96)}
+.rot-cell .rk{width:16px;height:16px;border-radius:4px;background:#eef1f5;color:var(--sub);font-size:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-weight:700}
+.rot-cell .rk.r1{background:#e04536;color:#fff}
+.rot-cell .rk.r2{background:#f0714f;color:#fff}
+.rot-cell .rk.r3{background:#f5a623;color:#fff}
+.rot-cell .nm{font-size:12.5px;font-weight:700;white-space:nowrap}
+.rot-cell .inf{margin-left:auto;text-align:right;font-size:10px;color:var(--sub);white-space:nowrap}
+.rot-cell .inf b{font-weight:700;color:var(--ink)}
+.rot-matrix.tracking .rot-cell:not(.hit){opacity:.22}
+.rot-cell.hit{box-shadow:inset 0 0 0 1.5px var(--blue);border-radius:6px}
+.rot-cell .why{display:none;position:absolute;bottom:100%;left:8px;background:var(--ink);color:#fff;font-size:10.5px;border-radius:5px;padding:3px 8px;white-space:nowrap;z-index:5}
+.rot-cell:hover .why{display:block}
+
+/* 明细表趋势标签 */
+.tag.hot{background:var(--up-soft);color:var(--up)} /* 升温 */
+.tag.cool{background:#e8f4fd;color:#2563eb} /* 降温 */
+.tag.newin{background:#e9f7ee;color:var(--down)} /* 新进 */
+tr.rowhit td{background:var(--blue-soft)!important}
+tbody tr.clickable{cursor:pointer}
+
+/* ========== 登录页 ========== */
+.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#f4f6fb 0%,#eef2f9 100%)}
+.login-card{width:380px;background:#fff;border-radius:14px;box-shadow:0 12px 40px rgba(30,50,90,.1);padding:34px 36px 28px}
+.login-card .lg-brand{text-align:center;margin-bottom:22px}
+.login-card .lg-brand .logo{width:44px;height:44px;border-radius:12px;background:var(--blue);color:#fff;font-size:22px;font-weight:800;display:inline-flex;align-items:center;justify-content:center}
+.login-card .lg-brand h1{font-size:19px;margin-top:10px}
+.login-card .lg-brand p{font-size:12px;color:var(--faint);margin-top:4px}
+.login-tabs{display:flex;border-bottom:1px solid var(--line);margin-bottom:20px}
+.login-tabs button{flex:1;padding:9px;font-size:14px;color:var(--sub);border-bottom:2px solid transparent;margin-bottom:-1px;font-weight:600}
+.login-tabs button.on{color:var(--blue);border-bottom-color:var(--blue)}
+.login-card .field{margin-bottom:14px}
+.login-card .lg-btn{width:100%;background:var(--blue);color:#fff;border-radius:8px;padding:11px;font-size:14px;font-weight:700;margin-top:6px}
+.login-card .lg-btn:hover{background:var(--blue-d)}
+.login-links{display:flex;justify-content:space-between;margin-top:14px;font-size:12px}
+.login-links a{color:var(--blue)}
+.login-tip{margin-top:22px;padding-top:16px;border-top:1px dashed var(--line);font-size:11px;color:var(--faint);text-align:center;line-height:1.8}
+
+/* ========== 情绪周期 ========== */
+.emo-grid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:12px;align-items:start;margin-bottom:12px}
+.emo-legend{display:flex;gap:16px;padding:8px 14px;border-bottom:1px solid var(--line-soft);font-size:11px;color:var(--sub);flex-wrap:wrap}
+.emo-legend .li{display:inline-flex;align-items:center;gap:5px}
+.emo-legend .li i{width:16px;height:3px;border-radius:2px}
+.emo-legend .li .dot{width:7px;height:7px;border-radius:50%}
+.chart-box{padding:14px 16px 6px;position:relative}
+.chart-box svg{width:100%;display:block}
+.chart-tip{position:absolute;pointer-events:none;background:var(--ink);color:#fff;font-size:11px;border-radius:6px;padding:5px 9px;display:none;white-space:nowrap;z-index:5}
+.score-list{padding:8px 16px 14px}
+.score-list .row{display:flex;align-items:center;gap:10px;padding:6px 0;font-size:12px}
+.score-list .row .k{width:88px;color:var(--sub)}
+.score-list .row .bar{flex:1;height:9px;background:#f0f2f5;border-radius:5px;overflow:hidden}
+.score-list .row .bar i{display:block;height:100%;background:linear-gradient(90deg,#93b4f5,var(--blue));border-radius:5px}
+.score-list .row .v{width:64px;text-align:right;font-weight:700;font-variant-numeric:tabular-nums}
+.score-list .row .v small{color:var(--faint);font-weight:400}
+.period-note{font-size:11px;color:var(--amber);background:var(--amber-soft);border-radius:6px;padding:5px 10px;margin-left:8px}
+
+/* ========== 池页右栏 ========== */
+.side-list{padding:6px 14px 10px}
+.side-list .grp{padding:7px 0;border-bottom:1px dashed var(--line-soft)}
+.side-list .grp:last-child{border-bottom:none}
+.side-list .grp .gt{display:flex;align-items:center;font-size:12px;margin-bottom:4px}
+.side-list .grp .gt b{color:var(--up)}
+.side-list .grp .gt .n{margin-left:auto;color:var(--faint);font-size:11px}
+.side-list .grp .gs{font-size:12px;color:#4b5563;line-height:1.8;cursor:default}
+.hotlist{padding:6px 14px 10px}
+.hotlist .row{display:flex;align-items:center;gap:8px;padding:5.5px 0;font-size:12.5px;border-bottom:1px dashed var(--line-soft)}
+.hotlist .row:last-child{border-bottom:none}
+.hotlist .row .nm{flex:1;font-weight:600}
+.hotlist .row .v{font-weight:700;color:var(--up);font-variant-numeric:tabular-nums}
+
+/* ========== 昨日涨停汇总条 ========== */
+.res-sum{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-soft);border-bottom:1px solid var(--line-soft)}
+.res-sum .cell{background:#fff;padding:12px 16px;cursor:pointer;transition:background .15s}
+.res-sum .cell:hover{background:#f8faff}
+.res-sum .cell.on{background:var(--blue-soft);box-shadow:inset 0 -2px 0 var(--blue)}
+.res-sum .k{font-size:12px;color:var(--sub);display:flex;align-items:center;gap:6px}
+.res-sum .v{font-size:22px;font-weight:800;margin-top:2px;font-variant-numeric:tabular-nums}
+.res-sum .v small{font-size:11px;color:var(--faint);font-weight:400}
+.res-sum .pct{font-size:11px;color:var(--faint);margin-top:2px}
+
+/* ========== 涨停表现 ========== */
+.perf-cards{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:12px}
+.perf-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;box-shadow:var(--shadow)}
+.perf-card .k{font-size:12px;color:var(--sub);display:flex;align-items:center;justify-content:space-between}
+.perf-card .rate{font-size:26px;font-weight:800;margin-top:6px;font-variant-numeric:tabular-nums}
+.perf-card .cnt{font-size:11.5px;color:var(--faint);margin-top:4px}
+.perf-card .bar{height:6px;background:#f0f2f5;border-radius:3px;margin-top:10px;overflow:hidden}
+.perf-card .bar i{display:block;height:100%;border-radius:3px}
+.width-box{padding:14px 16px}
+.width-bar{display:flex;height:22px;border-radius:6px;overflow:hidden;margin-top:8px}
+.width-bar .up{background:#e04536}
+.width-bar .dn{background:#16a34a}
+.width-legend{display:flex;gap:18px;margin-top:8px;font-size:11.5px;color:var(--sub)}
+.width-legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}
+
+/* ========== 题材库 ========== */
+.theme-grid{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:12px;align-items:start}
+.kline-box{padding:10px 14px 4px;position:relative}
+.kline-box svg{width:100%;display:block}
+.kline-legend{display:flex;gap:16px;padding:8px 14px;border-bottom:1px solid var(--line-soft);font-size:11px;color:var(--sub)}
+.kline-legend .li i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:4px;vertical-align:-1px}
+.theme-list .row{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line-soft);font-size:12.5px}
+.theme-list .row .rk{width:22px;color:var(--faint);font-size:11px;text-align:right}
+.theme-list .row .nm{font-weight:700;width:82px}
+.theme-list .row .hot{flex:1;color:var(--sub);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.theme-list .row .chg{width:70px;text-align:right;font-weight:700;font-variant-numeric:tabular-nums}
+.theme-stat{display:flex;gap:24px;padding:12px 16px;border-bottom:1px solid var(--line-soft)}
+.theme-stat .st .k{font-size:11px;color:var(--faint)}
+.theme-stat .st .v{font-size:18px;font-weight:800;margin-top:2px;font-variant-numeric:tabular-nums}
+
+/* ========== 人气热榜 ========== */
+.hot3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:12px}
+.hot3 .hc{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:13px 16px;box-shadow:var(--shadow)}
+.hot3 .hc .t{font-size:12px;color:var(--sub)}
+.hot3 .hc .n{font-size:15px;font-weight:800;margin-top:4px}
+.hot3 .hc .d{font-size:11px;color:var(--faint);margin-top:4px;line-height:1.6}
+
+/* ========== 龙虎榜 ========== */
+.empty-box{padding:44px 20px;text-align:center;color:var(--faint)}
+.empty-box .ico{font-size:30px;margin-bottom:10px}
+.empty-box .tt{font-size:14px;font-weight:600;color:var(--sub);margin-bottom:6px}
+.empty-box .ds{font-size:12px;line-height:1.9}
+.empty-box .act{margin-top:14px}
+
+/* ========== 问师 ========== */
+.mentor-grid{display:grid;grid-template-columns:340px minmax(0,1fr);gap:12px;align-items:start}
+.model{padding:12px 14px;border-bottom:1px solid var(--line-soft);cursor:pointer}
+.model:hover{background:#f8faff}
+.model.on{background:var(--blue-soft);box-shadow:inset 2px 0 0 var(--blue)}
+.model .r1{display:flex;align-items:center;gap:8px}
+.model .r1 .nm{font-weight:700;font-size:13px}
+.model .r1 .lv{margin-left:auto}
+.model .r2{font-size:11.5px;color:var(--sub);margin-top:4px;line-height:1.6}
+.model .r3{display:flex;gap:10px;margin-top:6px;font-size:10.5px;color:var(--faint)}
+.model .r3 .q{border-bottom:1px dashed var(--faint);cursor:help}
+.chat-box{display:flex;flex-direction:column;height:calc(100vh - 250px);min-height:420px}
+.chat-log{flex:1;overflow:auto;padding:18px}
+.chat-empty{text-align:center;color:var(--faint);padding-top:80px}
+.chat-empty .ico{font-size:30px;margin-bottom:10px}
+.chat-empty .samples{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:18px}
+.chat-empty .samples button{border:1px solid var(--line);border-radius:16px;padding:6px 14px;font-size:12px;color:var(--sub);background:#fff}
+.chat-empty .samples button:hover{border-color:var(--blue-line);color:var(--blue)}
+.chat-input{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--line-soft)}
+.chat-input input{flex:1;border:1px solid var(--line);border-radius:8px;padding:9px 12px;outline:none}
+.chat-input input:focus{border-color:var(--blue-line)}
+
+/* ========== 我的复盘 ========== */
+.review-grid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:12px;align-items:start}
+.journal-empty{padding:30px;text-align:center;color:var(--faint);font-size:12px}
+.txt-area{width:100%;border:1px solid var(--line);border-radius:8px;padding:10px 12px;min-height:120px;resize:vertical;outline:none;font-size:13px;line-height:1.8}
+.txt-area:focus{border-color:var(--blue-line)}
+.star{color:#f59e0b;font-size:14px;cursor:pointer}
+.star.off{color:#d1d5db}
+
+/* ========== Canonical shell integration ========== */
+.workspace-view.page:not(#heavenView){margin-top:0;border:0;border-radius:0;background:transparent;box-shadow:none}
+.workspace-view.page:not(#heavenView) > :last-child{margin-bottom:0}
+
+/* Restore the continuous expanded market strip used before the card-style treatment. */
+.overview-strip[data-overview-expanded="true"]{
+ gap:0;
+ padding-inline:var(--page-pad-x);
+ background:var(--card);
+}
+.overview-strip[data-overview-expanded="true"] .sentiment-block,
+.overview-strip[data-overview-expanded="true"] .metric{
+ border-right:1px solid var(--line-soft);
+ background:transparent;
+}
+.overview-strip[data-overview-expanded="true"] .overview-toggle{
+ border-right:0;
+ background:transparent;
+}
+
+/* The directory is supporting navigation; market and members remain the main canvas. */
+.theme-grid{grid-template-columns:var(--right-rail-wide) minmax(0,1fr)}
+
+/* Rotation intensity is communicated by fill alone. */
+#rotationView .rotation-sector-chip.heat-strong,
+#rotationView .rotation-sector-chip.heat-warm,
+#rotationView .rotation-sector-chip.heat-mild,
+#rotationView .rotation-sector-chip:hover{box-shadow:none}
+
+/* The summary grid reveals the page canvas between otherwise unchanged cards. */
+#popularityView .popularity-glance-v2{
+ background:var(--bg);
+}
+#popularityView #popularitySummary{
+ border:0;
+ border-radius:0;
+ box-shadow:none;
+ overflow:visible;
+}
+#popularityView .popularity-glance-v2 article{
+ background:var(--card);
+ box-shadow:none;
+}
+
+/* Auction summary shares the dataset row without changing its metric styling. */
+#auctionView .auction-tabs-v2 .auction-summary-v2{
+ margin-left:auto;
+ align-self:center;
+}
+#auctionView .auction-tabs-v2{padding-right:0}
+
+/* Match the temperature header with the right-aligned range values below it. */
+#sentimentView .sentiment-stage-guide-head > span:nth-child(3){
+ padding-right:12px;
+ text-align:right;
+}
+
+/* Strategy cards select on direct click; the condition dialog stays viewport-centered. */
+#screenerView .curated-strategy-card{cursor:pointer}
+#screenerView .curated-detail-dialog{margin:auto}
+
+/* Column roles override legacy percentage layouts. */
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable){min-width:var(--table-wide);table-layout:auto}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) th{width:auto}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) th.row-number{width:var(--col-rank)}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) th:nth-child(2){width:var(--col-stock)}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) th.number{width:var(--col-number)}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) thead th.num{text-align:right;font-variant-numeric:tabular-nums}
+:is(#limitTable,#brokenTable,#downTable,#yesterdayTable) .reason-column{width:auto;min-width:var(--col-text);white-space:normal}
+
+#rotationView .rotation-table,
+#popularityView .popularity-table-v2,
+#dragonView .dragon-operation-table,
+#screenerTrackingView .tracking-table,
+#reviewWorkspaceView .review-watchlist-table,
+#reviewWorkspaceView .trade-log-table{table-layout:auto}
+
+#rotationView .rotation-table{min-width:var(--table-wide)}
+#popularityView .popularity-table-v2{min-width:var(--table-medium)}
+#dragonView .dragon-operation-table{min-width:var(--table-wide)}
+#screenerTrackingView .tracking-table{min-width:var(--table-wide)}
+#reviewWorkspaceView .review-watchlist-table{min-width:var(--table-compact)}
+#reviewWorkspaceView .trade-log-table{min-width:var(--table-medium)}
+
+#popularityView .popularity-table-v2 th,
+#reviewWorkspaceView .review-watchlist-table th,
+#reviewWorkspaceView .trade-log-table th{width:auto}
+#popularityView .popularity-table-v2 th:first-child{width:var(--col-rank)}
+#popularityView .popularity-table-v2 th:nth-child(2),
+#reviewWorkspaceView :is(.review-watchlist-table,.trade-log-table) th:nth-child(2){width:var(--col-stock)}
+#popularityView .popularity-table-v2 th.number,
+#reviewWorkspaceView :is(.review-watchlist-table,.trade-log-table) th.number{width:var(--col-number)}
+#reviewWorkspaceView .trade-log-table th:first-child{width:var(--col-date)}
+#reviewWorkspaceView :is(.review-watchlist-table,.trade-log-table) th:last-child{width:var(--col-action)}
+#reviewWorkspaceView .review-watchlist-table th:nth-last-child(2),
+#reviewWorkspaceView .trade-log-table th:nth-last-child(2){width:auto;min-width:var(--col-text)}
+#reviewWorkspaceView :is(.watch-remark,.trade-copy){white-space:normal}
+
+#dragonView .dragon-operation-table .dragon-col-index{width:var(--col-rank)}
+#dragonView .dragon-operation-table .dragon-col-stock{width:var(--col-stock)}
+#dragonView .dragon-operation-table .dragon-col-direction{width:var(--col-number)}
+#dragonView .dragon-operation-table .dragon-col-number{width:var(--col-number)}
+#dragonView .dragon-operation-table .dragon-col-seat{width:var(--col-text)}
+#dragonView .dragon-operation-table .dragon-col-reason{width:auto}
+#dragonView .dragon-operation-table .reason-column{white-space:normal}
+:is(#limitPool,#brokenView,#downView,#yesterdayView) .tbl-wrap{max-height:var(--pool-table-max-height);overflow:auto}
+
+@media (min-width:721px){
+ body:is(
+ [data-active-view="auctionView"],
+ [data-active-view="themeLibraryView"],
+ [data-active-view="popularityView"],
+ [data-active-view="dragonView"],
+ [data-active-view="mentorView"],
+ [data-active-view="rotationView"]
+ ) .app-main{
+ height:var(--workspace-height);
+ min-height:0;
+ display:flex;
+ flex-direction:column;
+ overflow:hidden;
+ }
+
+ body:is(
+ [data-active-view="auctionView"],
+ [data-active-view="themeLibraryView"],
+ [data-active-view="popularityView"],
+ [data-active-view="dragonView"],
+ [data-active-view="mentorView"],
+ [data-active-view="rotationView"]
+ ) .overview-strip{flex:0 0 auto}
+
+ body:is(
+ [data-active-view="auctionView"],
+ [data-active-view="themeLibraryView"],
+ [data-active-view="popularityView"],
+ [data-active-view="dragonView"],
+ [data-active-view="mentorView"],
+ [data-active-view="rotationView"]
+ ) .workspace-view.active-view{
+ min-height:0;
+ flex:1 1 auto;
+ overflow:hidden;
+ }
+
+ #auctionView.active-view,
+ #themeLibraryView.active-view,
+ #popularityView.active-view,
+ #dragonView.active-view,
+ #mentorView.active-view{display:flex;flex-direction:column}
+
+ #rotationView.active-view{
+ display:grid;
+ grid-template-rows:auto minmax(0,var(--primary-share)) minmax(0,var(--secondary-share));
+ gap:var(--card-gap);
+ overflow:hidden;
+ }
+ #rotationView .rotation-page-head{margin-bottom:0}
+ #rotationView .rotation-trajectory-card,
+ #rotationView .rotation-detail-card{min-height:0;margin-top:0;display:flex;flex-direction:column;overflow:hidden}
+ #rotationView .rotation-history,
+ #rotationView .rotation-table-frame{min-height:0;flex:1 1 auto;overflow:auto}
+
+ #auctionView .auction-page-head-v2,
+ #themeLibraryView .theme-page-head-v2,
+ #themeLibraryView .theme-summary-v2,
+ #popularityView .popularity-page-head-v2,
+ #popularityView .popularity-glance-v2,
+ #dragonView .dragon-page-head-v2,
+ #mentorView .mentor-page-header,
+ #mentorView .member-gate,
+ #mentorView #mentorNotice{flex:0 0 auto}
+
+ #auctionView .auction-workspace-v2,
+ #themeLibraryView .theme-library-workspace-v2,
+ #popularityView .popularity-table-card-v2,
+ #dragonView .dragon-daily-content-v2,
+ #mentorView .mentor-layout{min-height:0;flex:1 1 auto}
+
+ #auctionView .auction-workspace-v2{height:100%;grid-template-columns:minmax(0,1fr) var(--right-rail-wide);grid-template-rows:minmax(0,1fr);align-items:stretch;overflow:hidden}
+ #auctionView .auction-primary-card{min-height:0;display:flex;flex-direction:column;overflow:hidden}
+ #auctionView .auction-table-frame-v2{min-height:0;flex:1 1 auto;overflow:auto}
+ #auctionView .auction-side-v2{min-height:0;overflow:auto}
+
+ #themeLibraryView.active-view{overflow:hidden}
+ #themeLibraryView .theme-library-workspace-v2{height:100%;grid-template-rows:minmax(0,1fr);align-items:stretch;overflow:hidden}
+ #themeLibraryView .theme-detail-stack-v2{grid-template-rows:minmax(0,1.35fr) minmax(0,.85fr)}
+ #themeLibraryView .theme-directory-card-v2,
+ #themeLibraryView .theme-detail-column-v2,
+ #themeLibraryView .theme-detail-stack-v2{height:100%;min-height:0;overflow:hidden}
+
+ #popularityView .popularity-table-card-v2{display:flex;flex-direction:column;overflow:hidden}
+ #popularityView .popularity-table-frame-v2{min-height:0;flex:1 1 auto;overflow:auto}
+
+ #dragonView .dragon-daily-content-v2{overflow:hidden}
+ #dragonView .dragon-trader-detail-v2{min-height:0}
+ #dragonView .dragon-trader-detail .trader-operations{min-height:0;overflow:auto}
+
+ #mentorView .mentor-layout{height:auto;overflow:hidden}
+ #mentorView .mentor-sidebar,
+ #mentorView .mentor-chat-panel,
+ #mentorView .mentor-directory-content,
+ #mentorView .chat-box{min-height:0;height:100%;overflow:hidden}
+ #mentorView .mentor-list,
+ #mentorView .mentor-messages{min-height:0;overflow:auto}
+}
+
+@media (max-width:720px), (max-width:1023px) and (max-height:600px){
+ html,body{width:100%;min-width:var(--mobile-min-width)}
+ body,body.sidebar-collapsed{display:block;padding-bottom:var(--mobile-nav-height)}
+ .main{width:100%;min-width:0;margin-left:0}
+ .topbar{
+ position:sticky;
+ width:100%;
+ height:auto;
+ min-height:var(--mobile-header-height);
+ padding:var(--mobile-shell-pad);
+ }
+ .market-tape{display:none}
+ .header-actions{width:100%}
+ .header-command-group{position:absolute}
+ .sidebar,
+ body.sidebar-collapsed .sidebar{
+ inset:auto 0 0;
+ width:100%;
+ height:var(--mobile-nav-height);
+ min-height:var(--mobile-nav-height);
+ max-height:var(--mobile-nav-height);
+ flex-direction:row;
+ justify-content:space-around;
+ padding:0;
+ overflow:hidden;
+ border:0;
+ border-top:1px solid var(--line);
+ }
+ .sidebar-brand,
+ .module-nav .nav-group-label,
+ .sidebar-collapse-button,
+ .module-nav .market-sub-tab{display:none}
+ .module-nav .nav-group,
+ body.sidebar-collapsed .module-nav .nav-group{display:contents}
+ .module-nav .module-tab,
+ body.sidebar-collapsed .module-nav .module-tab{display:none}
+ .module-nav .module-tab.mobile-primary-tab,
+ body.sidebar-collapsed .module-nav .module-tab.mobile-primary-tab{
+ min-height:var(--mobile-tab-height);
+ display:flex;
+ flex:1;
+ align-items:center;
+ justify-content:center;
+ flex-direction:column;
+ gap:var(--space-4);
+ padding:var(--space-4);
+ font-size:var(--font-aux);
+ }
+ .module-nav .module-tab.mobile-primary-tab span{display:inline}
+ .module-nav .module-tab.mobile-primary-tab .nav-label-desktop{display:none}
+ .module-nav .module-tab.mobile-primary-tab .nav-label-mobile{display:inline}
+ .app-main,
+ body:is(
+ [data-active-view="auctionView"],
+ [data-active-view="themeLibraryView"],
+ [data-active-view="popularityView"],
+ [data-active-view="dragonView"],
+ [data-active-view="mentorView"],
+ [data-active-view="rotationView"]
+ ) .app-main{
+ width:100%;
+ height:auto;
+ min-height:0;
+ display:block;
+ padding:0 var(--mobile-shell-pad) var(--mobile-page-pad);
+ overflow:visible;
+ }
+ .overview-strip{margin-inline:calc(var(--mobile-shell-pad) * -1);padding-inline:var(--mobile-shell-pad);overflow-x:auto}
+ .overview-strip.mktstrip .row{width:max-content;min-width:100%;padding:0}
+ .overview-strip .metric:nth-of-type(n + 4),
+ .overview-strip .metric-wide{display:none}
+ .overview-toggle{display:none}
+ .workspace-view.page:not(#heavenView){width:100%;height:auto;padding:var(--mobile-page-pad) 0;overflow:visible}
+ #auctionView .auction-workspace-v2{display:block}
+ #rotationView.active-view{display:block;overflow:visible}
+ #rotationView .rotation-trajectory-card,
+ #rotationView .rotation-detail-card{margin-top:var(--card-gap)}
+ #themeLibraryView .theme-library-workspace-v2{height:auto;display:block;grid-template-columns:none;grid-template-rows:auto;overflow:visible}
+ #themeLibraryView .theme-directory-v2{display:block}
+ #themeLibraryView .theme-directory-card-v2,
+ #themeLibraryView .theme-detail-column-v2,
+ #themeLibraryView .theme-detail-stack-v2{height:auto;overflow:visible}
+ .statusbar{display:none}
+}
diff --git a/static/index.html b/static/index.html
index 21dd1d5..e3cb7c1 100644
--- a/static/index.html
+++ b/static/index.html
@@ -6,18 +6,15 @@
小白复盘
+
+
+